first webserver app
This commit is contained in:
parent
a482b79f6a
commit
e2a08d7ab3
5
.gitignore
vendored
5
.gitignore
vendored
@ -13,3 +13,8 @@
|
|||||||
/src/Loaders/__pycache__/Loaders.cpython-310.pyc
|
/src/Loaders/__pycache__/Loaders.cpython-310.pyc
|
||||||
/src/Loaders/__pycache__/Providers.cpython-310.pyc
|
/src/Loaders/__pycache__/Providers.cpython-310.pyc
|
||||||
/src/Loaders/provider/__pycache__/voe.cpython-310.pyc
|
/src/Loaders/provider/__pycache__/voe.cpython-310.pyc
|
||||||
|
/src/noGerFound.log
|
||||||
|
/src/errors.log
|
||||||
|
/src/server/__pycache__/*
|
||||||
|
/src/NoKeyFound.log
|
||||||
|
/download_errors.log
|
||||||
|
|||||||
99
instruction.md
Normal file
99
instruction.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
|
||||||
|
Write a App in python with Flask. Make sure that you do not override the existing main.py
|
||||||
|
Use existing classes but if a chnae is needed make sure main.py works as before. Look at Main.py to understand the function.
|
||||||
|
Write all files in folder src/server/
|
||||||
|
Use the checklist to write the app. start on the first task. make sure each task is finished.
|
||||||
|
mark a finished task with x, and save it.
|
||||||
|
Stop if all Task are finshed
|
||||||
|
|
||||||
|
AniWorld Web App Feature Checklist
|
||||||
|
|
||||||
|
[x] Anime Search
|
||||||
|
[x] Implement search bar UI (auto-suggest, clear button)
|
||||||
|
[x] Connect search to backend loader
|
||||||
|
[x] Display search results (name, link, cover)
|
||||||
|
[x] Add anime from search results to global list
|
||||||
|
|
||||||
|
[x] Global Series List
|
||||||
|
[x] Display all series in a card/grid layout
|
||||||
|
[x] Show missing episodes per series
|
||||||
|
[x] Show cover, name, folder, and quick actions
|
||||||
|
[x] Multi-select series with checkboxes
|
||||||
|
[x] Select all series option
|
||||||
|
|
||||||
|
[x] Download Management
|
||||||
|
[x] Start download for selected series
|
||||||
|
[x] Show overall and per-episode progress bars
|
||||||
|
[x] Status indicators (downloading, finished, error)
|
||||||
|
[x] Pause, resume, cancel actions
|
||||||
|
|
||||||
|
[x] Reinit/Rescan Functionality
|
||||||
|
[x] UI button for rescan/reinit
|
||||||
|
[x] Show scanning progress modal
|
||||||
|
[x] Live updates during scan
|
||||||
|
[x] Update global series list after scan
|
||||||
|
|
||||||
|
[x] Status & Feedback
|
||||||
|
[x] Real-time status updates for scanning/downloading
|
||||||
|
[x] Snackbar/toast notifications for actions
|
||||||
|
[x] Centralized error dialog (friendly messages)
|
||||||
|
|
||||||
|
[x] Configuration & Environment
|
||||||
|
[x] Read base directory from environment variable
|
||||||
|
[x] UI for changing directory (if needed)
|
||||||
|
[x] Display current config (read-only)
|
||||||
|
|
||||||
|
[x] Security
|
||||||
|
[x] Validate all user inputs
|
||||||
|
[x] Do not expose internal errors or stack traces
|
||||||
|
|
||||||
|
[x] Modern GUI Concepts
|
||||||
|
[x] Fluent UI design (Windows 11 iconography, shapes, typography)
|
||||||
|
[x] Responsive design for desktop/mobile
|
||||||
|
[x] Dark and light mode support
|
||||||
|
[x] Localization-ready (resource files for text)
|
||||||
|
[x] Effortless, calm, and familiar user experience
|
||||||
|
|
||||||
|
[] Authentication & Security
|
||||||
|
[] Implement login page with master password authentication
|
||||||
|
[] Add password configuration option in config file
|
||||||
|
[] Add session management for authenticated users
|
||||||
|
[] Implement fail2ban compatible logging for failed login attempts
|
||||||
|
[] Use standard fail2ban log format: "authentication failure for [IP] user [attempt]"
|
||||||
|
|
||||||
|
[] Enhanced Anime Display
|
||||||
|
[] Modify main anime list to show animes with missing episodes first
|
||||||
|
[] Add filter toggle to show only animes with missing episodes
|
||||||
|
[] Implement alphabetical sorting option for anime names
|
||||||
|
[] Make only animes with missing episodes selectable for download
|
||||||
|
[] Add visual indicators for animes with/without missing episodes
|
||||||
|
|
||||||
|
[] Download Queue Management
|
||||||
|
[] Create dedicated download queue page showing active downloads
|
||||||
|
[] Display current download progress with episode name and download speed
|
||||||
|
[] Show download queue with pending items
|
||||||
|
[] Implement download queue status indicators (queued, downloading, completed, failed)
|
||||||
|
[] Add download queue statistics (total items, ETA, current speed)
|
||||||
|
|
||||||
|
[] Process Locking System
|
||||||
|
[] Implement rescan process lock (only one rescan at a time)
|
||||||
|
[] Add UI feedback when rescan is already running
|
||||||
|
[] Disable rescan button when process is active
|
||||||
|
[] Implement download queue lock (only one download process)
|
||||||
|
[] Prevent duplicate episodes in download queue
|
||||||
|
[] Add queue deduplication logic
|
||||||
|
|
||||||
|
[] Scheduled Operations
|
||||||
|
[] Add configuration option for scheduled rescan time (HH:MM format)
|
||||||
|
[] Implement daily automatic rescan at configured time
|
||||||
|
[] Auto-start download of missing episodes after scheduled rescan
|
||||||
|
[] Add UI to configure/view scheduled rescan settings
|
||||||
|
[] Show next scheduled rescan time in UI
|
||||||
|
|
||||||
|
[] Enhanced Logging
|
||||||
|
[] Configure console logging to show only essential information
|
||||||
|
[] Remove progress bars from console output
|
||||||
|
[] Implement structured logging for web interface
|
||||||
|
[] Add authentication failure logging in fail2ban format
|
||||||
|
[] Separate download progress logging from console output
|
||||||
|
[] Add log level configuration (INFO, WARNING, ERROR)
|
||||||
146
src/server/README.md
Normal file
146
src/server/README.md
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
# AniWorld Web Manager
|
||||||
|
|
||||||
|
A modern Flask-based web application for managing anime downloads with a beautiful Fluent UI design.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
✅ **Anime Search**
|
||||||
|
- Real-time search with auto-suggest
|
||||||
|
- Easy addition of series from search results
|
||||||
|
- Clear search functionality
|
||||||
|
|
||||||
|
✅ **Series Management**
|
||||||
|
- Grid layout with card-based display
|
||||||
|
- Shows missing episodes count
|
||||||
|
- Multi-select with checkboxes
|
||||||
|
- Select all/deselect all functionality
|
||||||
|
|
||||||
|
✅ **Download Management**
|
||||||
|
- Background downloading with progress tracking
|
||||||
|
- Pause, resume, and cancel functionality
|
||||||
|
- Real-time status updates via WebSocket
|
||||||
|
|
||||||
|
✅ **Modern UI**
|
||||||
|
- Fluent UI design system (Windows 11 style)
|
||||||
|
- Dark and light theme support
|
||||||
|
- Responsive design for desktop and mobile
|
||||||
|
- Smooth animations and transitions
|
||||||
|
|
||||||
|
✅ **Localization**
|
||||||
|
- Support for multiple languages (English, German)
|
||||||
|
- Easy to add new languages
|
||||||
|
- Resource-based text management
|
||||||
|
|
||||||
|
✅ **Real-time Updates**
|
||||||
|
- WebSocket connection for live updates
|
||||||
|
- Toast notifications for user feedback
|
||||||
|
- Status panel with progress tracking
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Install Dependencies**
|
||||||
|
```bash
|
||||||
|
pip install Flask Flask-SocketIO eventlet
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Environment Configuration**
|
||||||
|
Set the `ANIME_DIRECTORY` environment variable to your anime storage path:
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
set ANIME_DIRECTORY="Z:\media\serien\Serien"
|
||||||
|
|
||||||
|
# Linux/Mac
|
||||||
|
export ANIME_DIRECTORY="/path/to/your/anime/directory"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Run the Application**
|
||||||
|
```bash
|
||||||
|
cd src/server
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Access the Web Interface**
|
||||||
|
Open your browser and navigate to: `http://localhost:5000`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Searching and Adding Anime
|
||||||
|
1. Use the search bar to find anime
|
||||||
|
2. Browse search results
|
||||||
|
3. Click "Add" to add series to your collection
|
||||||
|
|
||||||
|
### Managing Downloads
|
||||||
|
1. Select series using checkboxes
|
||||||
|
2. Click "Download Selected" to start downloading
|
||||||
|
3. Monitor progress in the status panel
|
||||||
|
4. Use pause/resume/cancel controls as needed
|
||||||
|
|
||||||
|
### Theme and Language
|
||||||
|
- Click the moon/sun icon to toggle between light and dark themes
|
||||||
|
- Language is automatically detected from browser settings
|
||||||
|
- Supports English and German out of the box
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- Click the "Config" button to view current settings
|
||||||
|
- Shows anime directory path, series count, and connection status
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/server/
|
||||||
|
├── app.py # Main Flask application
|
||||||
|
├── templates/
|
||||||
|
│ └── index.html # Main HTML template
|
||||||
|
├── static/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── styles.css # Fluent UI styles
|
||||||
|
│ └── js/
|
||||||
|
│ ├── app.js # Main application logic
|
||||||
|
│ └── localization.js # Multi-language support
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `GET /` - Main web interface
|
||||||
|
- `GET /api/series` - Get all series with missing episodes
|
||||||
|
- `POST /api/search` - Search for anime
|
||||||
|
- `POST /api/add_series` - Add series to collection
|
||||||
|
- `POST /api/download` - Start downloading selected series
|
||||||
|
- `POST /api/rescan` - Rescan anime directory
|
||||||
|
- `GET /api/status` - Get application status
|
||||||
|
- `POST /api/download/pause` - Pause current download
|
||||||
|
- `POST /api/download/resume` - Resume paused download
|
||||||
|
- `POST /api/download/cancel` - Cancel current download
|
||||||
|
|
||||||
|
## WebSocket Events
|
||||||
|
|
||||||
|
- `connect` - Client connection established
|
||||||
|
- `scan_started` - Directory scan initiated
|
||||||
|
- `scan_progress` - Scan progress update
|
||||||
|
- `scan_completed` - Scan finished successfully
|
||||||
|
- `download_started` - Download initiated
|
||||||
|
- `download_progress` - Download progress update
|
||||||
|
- `download_completed` - Download finished
|
||||||
|
- `download_paused` - Download paused
|
||||||
|
- `download_resumed` - Download resumed
|
||||||
|
- `download_cancelled` - Download cancelled
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
- Input validation on all API endpoints
|
||||||
|
- No exposure of internal stack traces
|
||||||
|
- Secure WebSocket connections
|
||||||
|
- Environment-based configuration
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
- Modern browsers with ES6+ support
|
||||||
|
- WebSocket support required
|
||||||
|
- Responsive design works on mobile devices
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
- Uses existing `SeriesApp` class without modifications
|
||||||
|
- Maintains compatibility with original CLI application
|
||||||
|
- Thread-safe download management
|
||||||
|
- Proper error handling and user feedback
|
||||||
472
src/server/app.py
Normal file
472
src/server/app.py
Normal file
@ -0,0 +1,472 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
from flask_socketio import SocketIO, emit
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Add the parent directory to sys.path to import our modules
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
from Main import SeriesApp
|
||||||
|
from Serie import Serie
|
||||||
|
import SerieList
|
||||||
|
import SerieScanner
|
||||||
|
from Loaders.Loaders import Loaders
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SECRET_KEY'] = 'your-secret-key-here'
|
||||||
|
socketio = SocketIO(app, cors_allowed_origins="*")
|
||||||
|
|
||||||
|
# Global variables to store app state
|
||||||
|
series_app = None
|
||||||
|
is_scanning = False
|
||||||
|
is_downloading = False
|
||||||
|
is_paused = False
|
||||||
|
download_thread = None
|
||||||
|
download_progress = {}
|
||||||
|
download_queue = []
|
||||||
|
current_downloading = None
|
||||||
|
download_stats = {
|
||||||
|
'total_series': 0,
|
||||||
|
'completed_series': 0,
|
||||||
|
'current_episode': None,
|
||||||
|
'total_episodes': 0,
|
||||||
|
'completed_episodes': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def init_series_app():
|
||||||
|
"""Initialize the SeriesApp with environment variable or default path."""
|
||||||
|
global series_app
|
||||||
|
directory_to_search = os.getenv("ANIME_DIRECTORY", "\\\\sshfs.r\\ubuntu@192.168.178.43\\media\\serien\\Serien")
|
||||||
|
series_app = SeriesApp(directory_to_search)
|
||||||
|
return series_app
|
||||||
|
|
||||||
|
# Initialize the app on startup
|
||||||
|
init_series_app()
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""Main page route."""
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/api/series')
|
||||||
|
def get_series():
|
||||||
|
"""Get all series with missing episodes."""
|
||||||
|
try:
|
||||||
|
series_list = series_app.series_list
|
||||||
|
series_data = []
|
||||||
|
|
||||||
|
for serie in series_list:
|
||||||
|
missing_count = sum(len(episodes) for episodes in serie.episodeDict.values())
|
||||||
|
series_data.append({
|
||||||
|
'folder': serie.folder,
|
||||||
|
'name': serie.name or serie.folder,
|
||||||
|
'key': serie.key,
|
||||||
|
'site': serie.site,
|
||||||
|
'missing_episodes': missing_count,
|
||||||
|
'episode_dict': serie.episodeDict
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'series': series_data
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/search', methods=['POST'])
|
||||||
|
def search_anime():
|
||||||
|
"""Search for anime using the loader."""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
search_term = data.get('query', '').strip()
|
||||||
|
|
||||||
|
if not search_term:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Search term is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
results = series_app.search(search_term)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'results': results
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/add_series', methods=['POST'])
|
||||||
|
def add_series():
|
||||||
|
"""Add a series from search results to the global list."""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
link = data.get('link')
|
||||||
|
name = data.get('name')
|
||||||
|
|
||||||
|
if not link or not name:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Link and name are required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Create new serie and add it
|
||||||
|
new_serie = Serie(link, name, "aniworld.to", link, {})
|
||||||
|
series_app.List.add(new_serie)
|
||||||
|
|
||||||
|
# Refresh the series list
|
||||||
|
series_app.__InitList__()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Added series: {name}'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/rescan', methods=['POST'])
|
||||||
|
def rescan_series():
|
||||||
|
"""Rescan/reinit the series directory."""
|
||||||
|
global is_scanning
|
||||||
|
|
||||||
|
if is_scanning:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Scan already in progress'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
def scan_thread():
|
||||||
|
global is_scanning
|
||||||
|
is_scanning = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Emit scanning started
|
||||||
|
socketio.emit('scan_started')
|
||||||
|
|
||||||
|
# Reinit and scan
|
||||||
|
series_app.SerieScanner.Reinit()
|
||||||
|
series_app.SerieScanner.Scan(lambda folder, counter:
|
||||||
|
socketio.emit('scan_progress', {
|
||||||
|
'folder': folder,
|
||||||
|
'counter': counter
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh the series list
|
||||||
|
series_app.List = SerieList.SerieList(series_app.directory_to_search)
|
||||||
|
series_app.__InitList__()
|
||||||
|
|
||||||
|
# Emit scan completed
|
||||||
|
socketio.emit('scan_completed')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
socketio.emit('scan_error', {'message': str(e)})
|
||||||
|
finally:
|
||||||
|
is_scanning = False
|
||||||
|
|
||||||
|
# Start scan in background thread
|
||||||
|
threading.Thread(target=scan_thread, daemon=True).start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Scan started'
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/download', methods=['POST'])
|
||||||
|
def download_series():
|
||||||
|
"""Download selected series."""
|
||||||
|
global is_downloading
|
||||||
|
|
||||||
|
if is_downloading:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Download already in progress'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
selected_folders = data.get('folders', [])
|
||||||
|
|
||||||
|
if not selected_folders:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No series selected'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Find selected series
|
||||||
|
selected_series = []
|
||||||
|
for serie in series_app.series_list:
|
||||||
|
if serie.folder in selected_folders:
|
||||||
|
selected_series.append(serie)
|
||||||
|
|
||||||
|
if not selected_series:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Selected series not found'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
def download_thread_func():
|
||||||
|
global is_downloading, is_paused, download_thread, download_queue, current_downloading, download_stats
|
||||||
|
is_downloading = True
|
||||||
|
is_paused = False
|
||||||
|
|
||||||
|
# Initialize download queue and stats
|
||||||
|
download_queue = selected_series.copy()
|
||||||
|
download_stats = {
|
||||||
|
'total_series': len(selected_series),
|
||||||
|
'completed_series': 0,
|
||||||
|
'current_episode': None,
|
||||||
|
'total_episodes': sum(sum(len(episodes) for episodes in serie.episodeDict.values()) for serie in selected_series),
|
||||||
|
'completed_episodes': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Emit download started
|
||||||
|
socketio.emit('download_started', {
|
||||||
|
'total_series': len(selected_series),
|
||||||
|
'queue': [{'folder': s.folder, 'name': s.name or s.folder} for s in selected_series]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Custom progress callback
|
||||||
|
def progress_callback(d):
|
||||||
|
if not is_downloading: # Check if cancelled
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait if paused
|
||||||
|
while is_paused and is_downloading:
|
||||||
|
import time
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
if is_downloading: # Check again after potential pause
|
||||||
|
socketio.emit('download_progress', {
|
||||||
|
'status': d.get('status'),
|
||||||
|
'downloaded_bytes': d.get('downloaded_bytes', 0),
|
||||||
|
'total_bytes': d.get('total_bytes') or d.get('total_bytes_estimate'),
|
||||||
|
'percent': d.get('_percent_str', '0%')
|
||||||
|
})
|
||||||
|
|
||||||
|
# Process each series in queue
|
||||||
|
for serie in selected_series:
|
||||||
|
if not is_downloading: # Check if cancelled
|
||||||
|
break
|
||||||
|
|
||||||
|
# Update current downloading series
|
||||||
|
current_downloading = serie
|
||||||
|
if serie in download_queue:
|
||||||
|
download_queue.remove(serie)
|
||||||
|
|
||||||
|
# Emit queue update
|
||||||
|
socketio.emit('download_queue_update', {
|
||||||
|
'current_downloading': {
|
||||||
|
'folder': serie.folder,
|
||||||
|
'name': serie.name or serie.folder,
|
||||||
|
'missing_episodes': sum(len(episodes) for episodes in serie.episodeDict.values())
|
||||||
|
},
|
||||||
|
'queue': [{'folder': s.folder, 'name': s.name or s.folder} for s in download_queue],
|
||||||
|
'stats': download_stats
|
||||||
|
})
|
||||||
|
|
||||||
|
# Download episodes for current series
|
||||||
|
serie_episodes = sum(len(episodes) for episodes in serie.episodeDict.values())
|
||||||
|
episode_count = 0
|
||||||
|
|
||||||
|
for season, episodes in serie.episodeDict.items():
|
||||||
|
for episode in episodes:
|
||||||
|
if not is_downloading: # Check if cancelled
|
||||||
|
break
|
||||||
|
|
||||||
|
# Wait if paused
|
||||||
|
while is_paused and is_downloading:
|
||||||
|
import time
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
if not is_downloading: # Check again after potential pause
|
||||||
|
break
|
||||||
|
|
||||||
|
# Update current episode info
|
||||||
|
download_stats['current_episode'] = f"S{season:02d}E{episode:02d}"
|
||||||
|
|
||||||
|
# Emit episode update
|
||||||
|
socketio.emit('download_episode_update', {
|
||||||
|
'serie': serie.name or serie.folder,
|
||||||
|
'episode': f"S{season:02d}E{episode:02d}",
|
||||||
|
'episode_progress': f"{episode_count + 1}/{serie_episodes}",
|
||||||
|
'overall_progress': f"{download_stats['completed_episodes'] + episode_count + 1}/{download_stats['total_episodes']}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Perform the actual download
|
||||||
|
loader = series_app.Loaders.GetLoader(key="aniworld.to")
|
||||||
|
if loader.IsLanguage(season, episode, serie.key):
|
||||||
|
series_app.retry(loader.Download, 3, 1,
|
||||||
|
series_app.directory_to_search, serie.folder,
|
||||||
|
season, episode, serie.key, "German Dub",
|
||||||
|
progress_callback)
|
||||||
|
|
||||||
|
episode_count += 1
|
||||||
|
download_stats['completed_episodes'] += 1
|
||||||
|
|
||||||
|
# Mark series as completed
|
||||||
|
download_stats['completed_series'] += 1
|
||||||
|
|
||||||
|
# Emit series completion
|
||||||
|
socketio.emit('download_series_completed', {
|
||||||
|
'serie': serie.name or serie.folder,
|
||||||
|
'completed_series': download_stats['completed_series'],
|
||||||
|
'total_series': download_stats['total_series']
|
||||||
|
})
|
||||||
|
|
||||||
|
# Clear current downloading
|
||||||
|
current_downloading = None
|
||||||
|
download_queue.clear()
|
||||||
|
|
||||||
|
# Emit download completed only if not cancelled
|
||||||
|
if is_downloading:
|
||||||
|
socketio.emit('download_completed', {
|
||||||
|
'stats': download_stats
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if is_downloading: # Only emit error if not cancelled
|
||||||
|
socketio.emit('download_error', {'message': str(e)})
|
||||||
|
finally:
|
||||||
|
is_downloading = False
|
||||||
|
is_paused = False
|
||||||
|
download_thread = None
|
||||||
|
current_downloading = None
|
||||||
|
download_queue.clear()
|
||||||
|
|
||||||
|
# Start download in background thread
|
||||||
|
download_thread = threading.Thread(target=download_thread_func, daemon=True)
|
||||||
|
download_thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Download started'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/download/pause', methods=['POST'])
|
||||||
|
def pause_download():
|
||||||
|
"""Pause current download."""
|
||||||
|
global is_paused
|
||||||
|
|
||||||
|
if not is_downloading:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No download in progress'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
is_paused = True
|
||||||
|
socketio.emit('download_paused')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Download paused'
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/download/resume', methods=['POST'])
|
||||||
|
def resume_download():
|
||||||
|
"""Resume paused download."""
|
||||||
|
global is_paused
|
||||||
|
|
||||||
|
if not is_downloading:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No download in progress'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if not is_paused:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Download is not paused'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
is_paused = False
|
||||||
|
socketio.emit('download_resumed')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Download resumed'
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/download/cancel', methods=['POST'])
|
||||||
|
def cancel_download():
|
||||||
|
"""Cancel current download."""
|
||||||
|
global is_downloading, is_paused, download_thread
|
||||||
|
|
||||||
|
if not is_downloading:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No download in progress'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
is_downloading = False
|
||||||
|
is_paused = False
|
||||||
|
|
||||||
|
# Note: In a real implementation, you would need to stop the download thread
|
||||||
|
# This would require more sophisticated thread management
|
||||||
|
|
||||||
|
socketio.emit('download_cancelled')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Download cancelled'
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/download/status')
|
||||||
|
def get_download_status():
|
||||||
|
"""Get detailed download status including queue and current progress."""
|
||||||
|
return jsonify({
|
||||||
|
'is_downloading': is_downloading,
|
||||||
|
'is_paused': is_paused,
|
||||||
|
'queue': [{'folder': serie.folder, 'name': serie.name or serie.folder} for serie in download_queue],
|
||||||
|
'current_downloading': {
|
||||||
|
'folder': current_downloading.folder,
|
||||||
|
'name': current_downloading.name or current_downloading.folder,
|
||||||
|
'current_episode': download_stats['current_episode'],
|
||||||
|
'missing_episodes': sum(len(episodes) for episodes in current_downloading.episodeDict.values())
|
||||||
|
} if current_downloading else None,
|
||||||
|
'stats': download_stats
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/status')
|
||||||
|
def get_status():
|
||||||
|
"""Get current application status."""
|
||||||
|
return jsonify({
|
||||||
|
'is_scanning': is_scanning,
|
||||||
|
'is_downloading': is_downloading,
|
||||||
|
'is_paused': is_paused,
|
||||||
|
'directory': series_app.directory_to_search if series_app else None,
|
||||||
|
'series_count': len(series_app.series_list) if series_app else 0,
|
||||||
|
'download_queue_count': len(download_queue),
|
||||||
|
'current_downloading': current_downloading.name if current_downloading else None
|
||||||
|
})
|
||||||
|
|
||||||
|
@socketio.on('connect')
|
||||||
|
def handle_connect():
|
||||||
|
"""Handle client connection."""
|
||||||
|
emit('connected', {'message': 'Connected to AniWorld server'})
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
print("Starting AniWorld Flask server...")
|
||||||
|
print(f"Using directory: {series_app.directory_to_search}")
|
||||||
|
|
||||||
|
socketio.run(app, debug=True, host='0.0.0.0', port=5000)
|
||||||
22
src/server/start_server.bat
Normal file
22
src/server/start_server.bat
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
@echo off
|
||||||
|
echo Starting AniWorld Web Manager...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Check if environment variable is set
|
||||||
|
if "%ANIME_DIRECTORY%"=="" (
|
||||||
|
echo WARNING: ANIME_DIRECTORY environment variable not set!
|
||||||
|
echo Using default directory: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien
|
||||||
|
echo.
|
||||||
|
echo To set your own directory, run:
|
||||||
|
echo set ANIME_DIRECTORY="\\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien"
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Change to server directory
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
REM Start the Flask application
|
||||||
|
python app.py
|
||||||
|
|
||||||
|
pause
|
||||||
21
src/server/start_server.sh
Normal file
21
src/server/start_server.sh
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Starting AniWorld Web Manager..."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check if environment variable is set
|
||||||
|
if [ -z "$ANIME_DIRECTORY" ]; then
|
||||||
|
echo "WARNING: ANIME_DIRECTORY environment variable not set!"
|
||||||
|
echo "Using default directory: \\\\sshfs.r\\ubuntu@192.168.178.43\\media\\serien\\Serien"
|
||||||
|
echo
|
||||||
|
echo "To set your own directory, run:"
|
||||||
|
echo "export ANIME_DIRECTORY=\"/path/to/your/anime/directory\""
|
||||||
|
echo
|
||||||
|
read -p "Press Enter to continue..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Change to server directory
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# Start the Flask application
|
||||||
|
python app.py
|
||||||
882
src/server/static/css/styles.css
Normal file
882
src/server/static/css/styles.css
Normal file
@ -0,0 +1,882 @@
|
|||||||
|
/* Fluent UI Design System Variables */
|
||||||
|
: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 */
|
||||||
|
--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;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
* {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search section */
|
||||||
|
.search-section {
|
||||||
|
margin-bottom: var(--spacing-xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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 {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Series section */
|
||||||
|
.series-section {
|
||||||
|
margin-bottom: var(--spacing-xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-title);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover:not(:disabled) {
|
||||||
|
background-color: #0e6b0e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast notifications */
|
||||||
|
.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 {
|
||||||
|
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.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styles */
|
||||||
|
.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-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-0 { margin-bottom: 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; }
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-download-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-progress {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-index {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
746
src/server/static/js/app.js
Normal file
746
src/server/static/js/app.js
Normal file
@ -0,0 +1,746 @@
|
|||||||
|
/**
|
||||||
|
* AniWorld Manager - Main JavaScript Application
|
||||||
|
* Implements Fluent UI design principles with modern web app functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
class AniWorldApp {
|
||||||
|
constructor() {
|
||||||
|
this.socket = null;
|
||||||
|
this.selectedSeries = new Set();
|
||||||
|
this.seriesData = [];
|
||||||
|
this.isConnected = false;
|
||||||
|
this.isDownloading = false;
|
||||||
|
this.isPaused = false;
|
||||||
|
this.localization = new Localization();
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.initSocket();
|
||||||
|
this.bindEvents();
|
||||||
|
this.loadSeries();
|
||||||
|
this.initTheme();
|
||||||
|
this.updateConnectionStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
initSocket() {
|
||||||
|
this.socket = io();
|
||||||
|
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
this.isConnected = true;
|
||||||
|
console.log('Connected to server');
|
||||||
|
this.showToast(this.localization.getText('connected-server'), 'success');
|
||||||
|
this.updateConnectionStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('disconnect', () => {
|
||||||
|
this.isConnected = false;
|
||||||
|
console.log('Disconnected from server');
|
||||||
|
this.showToast(this.localization.getText('disconnected-server'), 'warning');
|
||||||
|
this.updateConnectionStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scan events
|
||||||
|
this.socket.on('scan_started', () => {
|
||||||
|
this.showStatus('Scanning series...', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('scan_progress', (data) => {
|
||||||
|
this.updateStatus(`Scanning: ${data.folder} (${data.counter})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('scan_completed', () => {
|
||||||
|
this.hideStatus();
|
||||||
|
this.showToast('Scan completed successfully', 'success');
|
||||||
|
this.loadSeries();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('scan_error', (data) => {
|
||||||
|
this.hideStatus();
|
||||||
|
this.showToast(`Scan error: ${data.message}`, 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download events
|
||||||
|
this.socket.on('download_started', (data) => {
|
||||||
|
this.isDownloading = true;
|
||||||
|
this.isPaused = false;
|
||||||
|
this.showDownloadQueue(data);
|
||||||
|
this.showStatus(`Starting download of ${data.total_series} series...`, true, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('download_progress', (data) => {
|
||||||
|
if (data.total_bytes) {
|
||||||
|
const percent = ((data.downloaded_bytes || 0) / data.total_bytes * 100).toFixed(1);
|
||||||
|
this.updateProgress(percent, `Downloading: ${percent}%`);
|
||||||
|
} else {
|
||||||
|
this.updateStatus(`Downloading: ${data.percent || '0%'}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('download_completed', (data) => {
|
||||||
|
this.isDownloading = false;
|
||||||
|
this.isPaused = false;
|
||||||
|
this.hideDownloadQueue();
|
||||||
|
this.hideStatus();
|
||||||
|
this.showToast(this.localization.getText('download-completed'), 'success');
|
||||||
|
this.loadSeries();
|
||||||
|
this.clearSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('download_error', (data) => {
|
||||||
|
this.isDownloading = false;
|
||||||
|
this.isPaused = false;
|
||||||
|
this.hideDownloadQueue();
|
||||||
|
this.hideStatus();
|
||||||
|
this.showToast(`${this.localization.getText('download-failed')}: ${data.message}`, 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download queue events
|
||||||
|
this.socket.on('download_queue_update', (data) => {
|
||||||
|
this.updateDownloadQueue(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('download_episode_update', (data) => {
|
||||||
|
this.updateCurrentEpisode(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('download_series_completed', (data) => {
|
||||||
|
this.updateDownloadProgress(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download control events
|
||||||
|
this.socket.on('download_paused', () => {
|
||||||
|
this.isPaused = true;
|
||||||
|
this.updateStatus(this.localization.getText('paused'));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('download_resumed', () => {
|
||||||
|
this.isPaused = false;
|
||||||
|
this.updateStatus(this.localization.getText('downloading'));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('download_cancelled', () => {
|
||||||
|
this.isDownloading = false;
|
||||||
|
this.isPaused = false;
|
||||||
|
this.hideDownloadQueue();
|
||||||
|
this.hideStatus();
|
||||||
|
this.showToast('Download cancelled', 'warning');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Theme toggle
|
||||||
|
document.getElementById('theme-toggle').addEventListener('click', () => {
|
||||||
|
this.toggleTheme();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const searchBtn = document.getElementById('search-btn');
|
||||||
|
const clearSearch = document.getElementById('clear-search');
|
||||||
|
|
||||||
|
searchBtn.addEventListener('click', () => {
|
||||||
|
this.performSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
this.performSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearSearch.addEventListener('click', () => {
|
||||||
|
searchInput.value = '';
|
||||||
|
this.hideSearchResults();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Series management
|
||||||
|
document.getElementById('select-all').addEventListener('click', () => {
|
||||||
|
this.toggleSelectAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('download-selected').addEventListener('click', () => {
|
||||||
|
this.downloadSelected();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rescan
|
||||||
|
document.getElementById('rescan-btn').addEventListener('click', () => {
|
||||||
|
this.rescanSeries();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configuration modal
|
||||||
|
document.getElementById('config-btn').addEventListener('click', () => {
|
||||||
|
this.showConfigModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('close-config').addEventListener('click', () => {
|
||||||
|
this.hideConfigModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#config-modal .modal-overlay').addEventListener('click', () => {
|
||||||
|
this.hideConfigModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status panel
|
||||||
|
document.getElementById('close-status').addEventListener('click', () => {
|
||||||
|
this.hideStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download controls
|
||||||
|
document.getElementById('pause-download').addEventListener('click', () => {
|
||||||
|
this.pauseDownload();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('resume-download').addEventListener('click', () => {
|
||||||
|
this.resumeDownload();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('cancel-download').addEventListener('click', () => {
|
||||||
|
this.cancelDownload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initTheme() {
|
||||||
|
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||||
|
this.setTheme(savedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(theme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
|
||||||
|
const themeIcon = document.querySelector('#theme-toggle i');
|
||||||
|
themeIcon.className = theme === 'light' ? 'fas fa-moon' : 'fas fa-sun';
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTheme() {
|
||||||
|
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
||||||
|
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||||
|
this.setTheme(newTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSeries() {
|
||||||
|
try {
|
||||||
|
this.showLoading();
|
||||||
|
|
||||||
|
const response = await fetch('/api/series');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
this.seriesData = data.series;
|
||||||
|
this.renderSeries();
|
||||||
|
} else {
|
||||||
|
this.showToast(`Error loading series: ${data.message}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading series:', error);
|
||||||
|
this.showToast('Failed to load series', 'error');
|
||||||
|
} finally {
|
||||||
|
this.hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSeries() {
|
||||||
|
const grid = document.getElementById('series-grid');
|
||||||
|
|
||||||
|
if (this.seriesData.length === 0) {
|
||||||
|
grid.innerHTML = `
|
||||||
|
<div class="text-center" style="grid-column: 1 / -1; padding: 2rem;">
|
||||||
|
<i class="fas fa-tv" style="font-size: 48px; color: var(--color-text-tertiary); margin-bottom: 1rem;"></i>
|
||||||
|
<p style="color: var(--color-text-secondary);">No series found. Try searching for anime or rescanning your directory.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = this.seriesData.map(serie => this.createSerieCard(serie)).join('');
|
||||||
|
|
||||||
|
// Bind checkbox events
|
||||||
|
grid.querySelectorAll('.series-checkbox').forEach(checkbox => {
|
||||||
|
checkbox.addEventListener('change', (e) => {
|
||||||
|
this.toggleSerieSelection(e.target.dataset.folder, e.target.checked);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createSerieCard(serie) {
|
||||||
|
const isSelected = this.selectedSeries.has(serie.folder);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="series-card ${isSelected ? 'selected' : ''}" data-folder="${serie.folder}">
|
||||||
|
<div class="series-card-header">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="series-checkbox"
|
||||||
|
data-folder="${serie.folder}"
|
||||||
|
${isSelected ? 'checked' : ''}>
|
||||||
|
<div class="series-info">
|
||||||
|
<h3>${this.escapeHtml(serie.name)}</h3>
|
||||||
|
<div class="series-folder">${this.escapeHtml(serie.folder)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="series-stats">
|
||||||
|
<div class="missing-episodes">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span>${serie.missing_episodes} missing episodes</span>
|
||||||
|
</div>
|
||||||
|
<span class="series-site">${serie.site}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSerieSelection(folder, selected) {
|
||||||
|
if (selected) {
|
||||||
|
this.selectedSeries.add(folder);
|
||||||
|
} else {
|
||||||
|
this.selectedSeries.delete(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateSelectionUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectionUI() {
|
||||||
|
const downloadBtn = document.getElementById('download-selected');
|
||||||
|
const selectAllBtn = document.getElementById('select-all');
|
||||||
|
|
||||||
|
downloadBtn.disabled = this.selectedSeries.size === 0;
|
||||||
|
|
||||||
|
if (this.selectedSeries.size === 0) {
|
||||||
|
selectAllBtn.innerHTML = '<i class="fas fa-check-double"></i><span>Select All</span>';
|
||||||
|
} else if (this.selectedSeries.size === this.seriesData.length) {
|
||||||
|
selectAllBtn.innerHTML = '<i class="fas fa-times"></i><span>Deselect All</span>';
|
||||||
|
} else {
|
||||||
|
selectAllBtn.innerHTML = '<i class="fas fa-check-double"></i><span>Select All</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update card appearances
|
||||||
|
document.querySelectorAll('.series-card').forEach(card => {
|
||||||
|
const folder = card.dataset.folder;
|
||||||
|
const isSelected = this.selectedSeries.has(folder);
|
||||||
|
card.classList.toggle('selected', isSelected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelectAll() {
|
||||||
|
if (this.selectedSeries.size === this.seriesData.length) {
|
||||||
|
// Deselect all
|
||||||
|
this.selectedSeries.clear();
|
||||||
|
document.querySelectorAll('.series-checkbox').forEach(cb => cb.checked = false);
|
||||||
|
} else {
|
||||||
|
// Select all
|
||||||
|
this.selectedSeries.clear();
|
||||||
|
this.seriesData.forEach(serie => this.selectedSeries.add(serie.folder));
|
||||||
|
document.querySelectorAll('.series-checkbox').forEach(cb => cb.checked = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateSelectionUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection() {
|
||||||
|
this.selectedSeries.clear();
|
||||||
|
document.querySelectorAll('.series-checkbox').forEach(cb => cb.checked = false);
|
||||||
|
this.updateSelectionUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
async performSearch() {
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const query = searchInput.value.trim();
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
this.showToast('Please enter a search term', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.showLoading();
|
||||||
|
|
||||||
|
const response = await fetch('/api/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
this.displaySearchResults(data.results);
|
||||||
|
} else {
|
||||||
|
this.showToast(`Search error: ${data.message}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
this.showToast('Search failed', 'error');
|
||||||
|
} finally {
|
||||||
|
this.hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displaySearchResults(results) {
|
||||||
|
const resultsContainer = document.getElementById('search-results');
|
||||||
|
const resultsList = document.getElementById('search-results-list');
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
resultsContainer.classList.add('hidden');
|
||||||
|
this.showToast('No search results found', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsList.innerHTML = results.map(result => `
|
||||||
|
<div class="search-result-item">
|
||||||
|
<span class="search-result-name">${this.escapeHtml(result.name)}</span>
|
||||||
|
<button class="btn btn-small btn-primary" onclick="app.addSeries('${this.escapeHtml(result.link)}', '${this.escapeHtml(result.name)}')">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
resultsContainer.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSearchResults() {
|
||||||
|
document.getElementById('search-results').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSeries(link, name) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/add_series', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ link, name })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
this.showToast(data.message, 'success');
|
||||||
|
this.loadSeries();
|
||||||
|
this.hideSearchResults();
|
||||||
|
document.getElementById('search-input').value = '';
|
||||||
|
} else {
|
||||||
|
this.showToast(`Error adding series: ${data.message}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding series:', error);
|
||||||
|
this.showToast('Failed to add series', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadSelected() {
|
||||||
|
if (this.selectedSeries.size === 0) {
|
||||||
|
this.showToast('No series selected', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const folders = Array.from(this.selectedSeries);
|
||||||
|
|
||||||
|
const response = await fetch('/api/download', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ folders })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
this.showToast('Download started', 'success');
|
||||||
|
} else {
|
||||||
|
this.showToast(`Download error: ${data.message}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download error:', error);
|
||||||
|
this.showToast('Failed to start download', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rescanSeries() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/rescan', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
this.showToast('Rescan started', 'success');
|
||||||
|
} else {
|
||||||
|
this.showToast(`Rescan error: ${data.message}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Rescan error:', error);
|
||||||
|
this.showToast('Failed to start rescan', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatus(message, showProgress = false, showControls = false) {
|
||||||
|
const panel = document.getElementById('status-panel');
|
||||||
|
const messageEl = document.getElementById('status-message');
|
||||||
|
const progressContainer = document.getElementById('progress-container');
|
||||||
|
const controlsContainer = document.getElementById('download-controls');
|
||||||
|
|
||||||
|
messageEl.textContent = message;
|
||||||
|
progressContainer.classList.toggle('hidden', !showProgress);
|
||||||
|
controlsContainer.classList.toggle('hidden', !showControls);
|
||||||
|
|
||||||
|
if (showProgress) {
|
||||||
|
this.updateProgress(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(message) {
|
||||||
|
document.getElementById('status-message').textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgress(percent, message = null) {
|
||||||
|
const fill = document.getElementById('progress-fill');
|
||||||
|
const text = document.getElementById('progress-text');
|
||||||
|
|
||||||
|
fill.style.width = `${percent}%`;
|
||||||
|
text.textContent = message || `${percent}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideStatus() {
|
||||||
|
document.getElementById('status-panel').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading() {
|
||||||
|
document.getElementById('loading-overlay').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
hideLoading() {
|
||||||
|
document.getElementById('loading-overlay').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(message, type = 'info') {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span>${this.escapeHtml(message)}</span>
|
||||||
|
<button onclick="this.parentElement.parentElement.remove()" style="background: none; border: none; color: var(--color-text-secondary); cursor: pointer; padding: 0; margin-left: 1rem;">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
// Auto-remove after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (toast.parentElement) {
|
||||||
|
toast.remove();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConnectionStatus() {
|
||||||
|
const indicator = document.getElementById('connection-status-display');
|
||||||
|
if (indicator) {
|
||||||
|
const statusIndicator = indicator.querySelector('.status-indicator');
|
||||||
|
const statusText = indicator.querySelector('.status-text');
|
||||||
|
|
||||||
|
if (this.isConnected) {
|
||||||
|
statusIndicator.classList.add('connected');
|
||||||
|
statusText.textContent = this.localization.getText('connected');
|
||||||
|
} else {
|
||||||
|
statusIndicator.classList.remove('connected');
|
||||||
|
statusText.textContent = this.localization.getText('disconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async showConfigModal() {
|
||||||
|
const modal = document.getElementById('config-modal');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load current status
|
||||||
|
const response = await fetch('/api/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('anime-directory-display').textContent = data.directory || 'Not configured';
|
||||||
|
document.getElementById('series-count-display').textContent = data.series_count || '0';
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading configuration:', error);
|
||||||
|
this.showToast('Failed to load configuration', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideConfigModal() {
|
||||||
|
document.getElementById('config-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async pauseDownload() {
|
||||||
|
if (!this.isDownloading || this.isPaused) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/download/pause', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
document.getElementById('pause-download').classList.add('hidden');
|
||||||
|
document.getElementById('resume-download').classList.remove('hidden');
|
||||||
|
this.showToast('Download paused', 'warning');
|
||||||
|
} else {
|
||||||
|
this.showToast(`Pause failed: ${data.message}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Pause error:', error);
|
||||||
|
this.showToast('Failed to pause download', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeDownload() {
|
||||||
|
if (!this.isDownloading || !this.isPaused) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/download/resume', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
document.getElementById('pause-download').classList.remove('hidden');
|
||||||
|
document.getElementById('resume-download').classList.add('hidden');
|
||||||
|
this.showToast('Download resumed', 'success');
|
||||||
|
} else {
|
||||||
|
this.showToast(`Resume failed: ${data.message}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Resume error:', error);
|
||||||
|
this.showToast('Failed to resume download', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelDownload() {
|
||||||
|
if (!this.isDownloading) return;
|
||||||
|
|
||||||
|
if (confirm('Are you sure you want to cancel the download?')) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/download/cancel', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
this.showToast('Download cancelled', 'warning');
|
||||||
|
} else {
|
||||||
|
this.showToast(`Cancel failed: ${data.message}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cancel error:', error);
|
||||||
|
this.showToast('Failed to cancel download', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showDownloadQueue(data) {
|
||||||
|
const queueSection = document.getElementById('download-queue-section');
|
||||||
|
const queueProgress = document.getElementById('queue-progress');
|
||||||
|
|
||||||
|
queueProgress.textContent = `0/${data.total_series} series`;
|
||||||
|
this.updateDownloadQueue({
|
||||||
|
queue: data.queue || [],
|
||||||
|
current_downloading: null,
|
||||||
|
stats: {
|
||||||
|
completed_series: 0,
|
||||||
|
total_series: data.total_series
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
queueSection.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
hideDownloadQueue() {
|
||||||
|
const queueSection = document.getElementById('download-queue-section');
|
||||||
|
const currentDownload = document.getElementById('current-download');
|
||||||
|
|
||||||
|
queueSection.classList.add('hidden');
|
||||||
|
currentDownload.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDownloadQueue(data) {
|
||||||
|
const queueList = document.getElementById('queue-list');
|
||||||
|
const currentDownload = document.getElementById('current-download');
|
||||||
|
const queueProgress = document.getElementById('queue-progress');
|
||||||
|
|
||||||
|
// Update overall progress
|
||||||
|
if (data.stats) {
|
||||||
|
queueProgress.textContent = `${data.stats.completed_series}/${data.stats.total_series} series`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current downloading
|
||||||
|
if (data.current_downloading) {
|
||||||
|
currentDownload.classList.remove('hidden');
|
||||||
|
document.getElementById('current-serie-name').textContent = data.current_downloading.name;
|
||||||
|
document.getElementById('current-episode').textContent = `${data.current_downloading.missing_episodes} episodes remaining`;
|
||||||
|
} else {
|
||||||
|
currentDownload.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update queue list
|
||||||
|
if (data.queue && data.queue.length > 0) {
|
||||||
|
queueList.innerHTML = data.queue.map((serie, index) => `
|
||||||
|
<div class="queue-item">
|
||||||
|
<div class="queue-item-index">${index + 1}</div>
|
||||||
|
<div class="queue-item-name">${this.escapeHtml(serie.name)}</div>
|
||||||
|
<div class="queue-item-status">Waiting</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
queueList.innerHTML = '<div class="queue-empty">No series in queue</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCurrentEpisode(data) {
|
||||||
|
const currentEpisode = document.getElementById('current-episode');
|
||||||
|
const progressFill = document.getElementById('current-progress-fill');
|
||||||
|
const progressText = document.getElementById('current-progress-text');
|
||||||
|
|
||||||
|
if (currentEpisode && data.episode) {
|
||||||
|
currentEpisode.textContent = `${data.episode} (${data.episode_progress})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mini progress bar based on overall progress
|
||||||
|
if (data.overall_progress && progressFill && progressText) {
|
||||||
|
const [current, total] = data.overall_progress.split('/').map(n => parseInt(n));
|
||||||
|
const percent = total > 0 ? (current / total * 100).toFixed(1) : 0;
|
||||||
|
|
||||||
|
progressFill.style.width = `${percent}%`;
|
||||||
|
progressText.textContent = `${percent}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDownloadProgress(data) {
|
||||||
|
const queueProgress = document.getElementById('queue-progress');
|
||||||
|
|
||||||
|
if (queueProgress && data.completed_series && data.total_series) {
|
||||||
|
queueProgress.textContent = `${data.completed_series}/${data.total_series} series`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showToast(`Completed: ${data.serie}`, 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the application when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.app = new AniWorldApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global functions for inline event handlers
|
||||||
|
window.app = null;
|
||||||
236
src/server/static/js/localization.js
Normal file
236
src/server/static/js/localization.js
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* Localization support for AniWorld Manager
|
||||||
|
* Implements resource-based text management for easy translation
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Localization {
|
||||||
|
constructor() {
|
||||||
|
this.currentLanguage = 'en';
|
||||||
|
this.fallbackLanguage = 'en';
|
||||||
|
this.translations = {};
|
||||||
|
this.loadTranslations();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTranslations() {
|
||||||
|
// English (default)
|
||||||
|
this.translations.en = {
|
||||||
|
// Header
|
||||||
|
'config-title': 'Configuration',
|
||||||
|
'toggle-theme': 'Toggle theme',
|
||||||
|
'rescan': 'Rescan',
|
||||||
|
|
||||||
|
// Search
|
||||||
|
'search-placeholder': 'Search for anime...',
|
||||||
|
'search-results': 'Search Results',
|
||||||
|
'no-results': 'No results found',
|
||||||
|
'add': 'Add',
|
||||||
|
|
||||||
|
// Series
|
||||||
|
'series-collection': 'Series Collection',
|
||||||
|
'select-all': 'Select All',
|
||||||
|
'deselect-all': 'Deselect All',
|
||||||
|
'download-selected': 'Download Selected',
|
||||||
|
'missing-episodes': 'missing episodes',
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
'anime-directory': 'Anime Directory',
|
||||||
|
'series-count': 'Series Count',
|
||||||
|
'connection-status': 'Connection Status',
|
||||||
|
'connected': 'Connected',
|
||||||
|
'disconnected': 'Disconnected',
|
||||||
|
|
||||||
|
// Download controls
|
||||||
|
'pause': 'Pause',
|
||||||
|
'resume': 'Resume',
|
||||||
|
'cancel': 'Cancel',
|
||||||
|
'downloading': 'Downloading',
|
||||||
|
'paused': 'Paused',
|
||||||
|
|
||||||
|
// Download queue
|
||||||
|
'download-queue': 'Download Queue',
|
||||||
|
'currently-downloading': 'Currently Downloading',
|
||||||
|
'queued-series': 'Queued Series',
|
||||||
|
|
||||||
|
// Status messages
|
||||||
|
'connected-server': 'Connected to server',
|
||||||
|
'disconnected-server': 'Disconnected from server',
|
||||||
|
'scan-started': 'Scan started',
|
||||||
|
'scan-completed': 'Scan completed successfully',
|
||||||
|
'download-started': 'Download started',
|
||||||
|
'download-completed': 'Download completed successfully',
|
||||||
|
'series-added': 'Series added successfully',
|
||||||
|
|
||||||
|
// Error messages
|
||||||
|
'search-failed': 'Search failed',
|
||||||
|
'download-failed': 'Download failed',
|
||||||
|
'scan-failed': 'Scan failed',
|
||||||
|
'connection-failed': 'Connection failed',
|
||||||
|
|
||||||
|
// General
|
||||||
|
'loading': 'Loading...',
|
||||||
|
'close': 'Close',
|
||||||
|
'ok': 'OK',
|
||||||
|
'cancel-action': 'Cancel'
|
||||||
|
};
|
||||||
|
|
||||||
|
// German
|
||||||
|
this.translations.de = {
|
||||||
|
// Header
|
||||||
|
'config-title': 'Konfiguration',
|
||||||
|
'toggle-theme': 'Design wechseln',
|
||||||
|
'rescan': 'Neu scannen',
|
||||||
|
|
||||||
|
// Search
|
||||||
|
'search-placeholder': 'Nach Anime suchen...',
|
||||||
|
'search-results': 'Suchergebnisse',
|
||||||
|
'no-results': 'Keine Ergebnisse gefunden',
|
||||||
|
'add': 'Hinzufügen',
|
||||||
|
|
||||||
|
// Series
|
||||||
|
'series-collection': 'Serien-Sammlung',
|
||||||
|
'select-all': 'Alle auswählen',
|
||||||
|
'deselect-all': 'Alle abwählen',
|
||||||
|
'download-selected': 'Ausgewählte herunterladen',
|
||||||
|
'missing-episodes': 'fehlende Episoden',
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
'anime-directory': 'Anime-Verzeichnis',
|
||||||
|
'series-count': 'Anzahl Serien',
|
||||||
|
'connection-status': 'Verbindungsstatus',
|
||||||
|
'connected': 'Verbunden',
|
||||||
|
'disconnected': 'Getrennt',
|
||||||
|
|
||||||
|
// Download controls
|
||||||
|
'pause': 'Pausieren',
|
||||||
|
'resume': 'Fortsetzen',
|
||||||
|
'cancel': 'Abbrechen',
|
||||||
|
'downloading': 'Herunterladen',
|
||||||
|
'paused': 'Pausiert',
|
||||||
|
|
||||||
|
// Download queue
|
||||||
|
'download-queue': 'Download-Warteschlange',
|
||||||
|
'currently-downloading': 'Wird heruntergeladen',
|
||||||
|
'queued-series': 'Warteschlange',
|
||||||
|
|
||||||
|
// Status messages
|
||||||
|
'connected-server': 'Mit Server verbunden',
|
||||||
|
'disconnected-server': 'Verbindung zum Server getrennt',
|
||||||
|
'scan-started': 'Scan gestartet',
|
||||||
|
'scan-completed': 'Scan erfolgreich abgeschlossen',
|
||||||
|
'download-started': 'Download gestartet',
|
||||||
|
'download-completed': 'Download erfolgreich abgeschlossen',
|
||||||
|
'series-added': 'Serie erfolgreich hinzugefügt',
|
||||||
|
|
||||||
|
// Error messages
|
||||||
|
'search-failed': 'Suche fehlgeschlagen',
|
||||||
|
'download-failed': 'Download fehlgeschlagen',
|
||||||
|
'scan-failed': 'Scan fehlgeschlagen',
|
||||||
|
'connection-failed': 'Verbindung fehlgeschlagen',
|
||||||
|
|
||||||
|
// General
|
||||||
|
'loading': 'Wird geladen...',
|
||||||
|
'close': 'Schließen',
|
||||||
|
'ok': 'OK',
|
||||||
|
'cancel-action': 'Abbrechen'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load saved language preference
|
||||||
|
const savedLanguage = localStorage.getItem('language') || this.detectLanguage();
|
||||||
|
this.setLanguage(savedLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
detectLanguage() {
|
||||||
|
const browserLang = navigator.language || navigator.userLanguage;
|
||||||
|
const langCode = browserLang.split('-')[0];
|
||||||
|
return this.translations[langCode] ? langCode : this.fallbackLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLanguage(langCode) {
|
||||||
|
if (this.translations[langCode]) {
|
||||||
|
this.currentLanguage = langCode;
|
||||||
|
localStorage.setItem('language', langCode);
|
||||||
|
this.updatePageTexts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getText(key, fallback = key) {
|
||||||
|
const translation = this.translations[this.currentLanguage];
|
||||||
|
if (translation && translation[key]) {
|
||||||
|
return translation[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try fallback language
|
||||||
|
const fallbackTranslation = this.translations[this.fallbackLanguage];
|
||||||
|
if (fallbackTranslation && fallbackTranslation[key]) {
|
||||||
|
return fallbackTranslation[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePageTexts() {
|
||||||
|
// Update all elements with data-text attributes
|
||||||
|
document.querySelectorAll('[data-text]').forEach(element => {
|
||||||
|
const key = element.getAttribute('data-text');
|
||||||
|
const text = this.getText(key);
|
||||||
|
|
||||||
|
if (element.tagName === 'INPUT' && element.type === 'text') {
|
||||||
|
element.placeholder = text;
|
||||||
|
} else {
|
||||||
|
element.textContent = text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update specific elements that need special handling
|
||||||
|
this.updateSearchPlaceholder();
|
||||||
|
this.updateDynamicTexts();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSearchPlaceholder() {
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.placeholder = this.getText('search-placeholder');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDynamicTexts() {
|
||||||
|
// Update any dynamically generated content
|
||||||
|
const selectAllBtn = document.getElementById('select-all');
|
||||||
|
if (selectAllBtn && window.app) {
|
||||||
|
const selectedCount = window.app.selectedSeries ? window.app.selectedSeries.size : 0;
|
||||||
|
const totalCount = window.app.seriesData ? window.app.seriesData.length : 0;
|
||||||
|
|
||||||
|
if (selectedCount === totalCount && totalCount > 0) {
|
||||||
|
selectAllBtn.innerHTML = `<i class="fas fa-times"></i><span>${this.getText('deselect-all')}</span>`;
|
||||||
|
} else {
|
||||||
|
selectAllBtn.innerHTML = `<i class="fas fa-check-double"></i><span>${this.getText('select-all')}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvailableLanguages() {
|
||||||
|
return Object.keys(this.translations).map(code => ({
|
||||||
|
code: code,
|
||||||
|
name: this.getLanguageName(code)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
getLanguageName(code) {
|
||||||
|
const names = {
|
||||||
|
'en': 'English',
|
||||||
|
'de': 'Deutsch'
|
||||||
|
};
|
||||||
|
return names[code] || code.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatMessage(key, ...args) {
|
||||||
|
let message = this.getText(key);
|
||||||
|
args.forEach((arg, index) => {
|
||||||
|
message = message.replace(`{${index}}`, arg);
|
||||||
|
});
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
window.Localization = Localization;
|
||||||
201
src/server/templates/index.html
Normal file
201
src/server/templates/index.html
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AniWorld Manager</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-title">
|
||||||
|
<i class="fas fa-play-circle"></i>
|
||||||
|
<h1>AniWorld Manager</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="config-btn" class="btn btn-secondary" title="Show configuration">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
<span data-text="config-title">Config</span>
|
||||||
|
</button>
|
||||||
|
<button id="theme-toggle" class="btn btn-icon" title="Toggle theme" data-title="toggle-theme">
|
||||||
|
<i class="fas fa-moon"></i>
|
||||||
|
</button>
|
||||||
|
<button id="rescan-btn" class="btn btn-primary">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
<span data-text="rescan">Rescan</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- Search section -->
|
||||||
|
<section class="search-section">
|
||||||
|
<div class="search-container">
|
||||||
|
<div class="search-input-group">
|
||||||
|
<input type="text" id="search-input" data-text="search-placeholder" placeholder="Search for anime..." class="search-input">
|
||||||
|
<button id="search-btn" class="btn btn-primary">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
<button id="clear-search" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search results -->
|
||||||
|
<div id="search-results" class="search-results hidden">
|
||||||
|
<h3>Search Results</h3>
|
||||||
|
<div id="search-results-list" class="search-results-list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Download Queue Section -->
|
||||||
|
<section id="download-queue-section" class="download-queue-section hidden">
|
||||||
|
<div class="queue-header">
|
||||||
|
<h2>
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
<span data-text="download-queue">Download Queue</span>
|
||||||
|
</h2>
|
||||||
|
<div class="queue-stats">
|
||||||
|
<span id="queue-progress" class="queue-progress">0/0 series</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Download -->
|
||||||
|
<div id="current-download" class="current-download hidden">
|
||||||
|
<div class="current-download-header">
|
||||||
|
<h3 data-text="currently-downloading">Currently Downloading</h3>
|
||||||
|
</div>
|
||||||
|
<div class="current-download-item">
|
||||||
|
<div class="download-info">
|
||||||
|
<div id="current-serie-name" class="serie-name">-</div>
|
||||||
|
<div id="current-episode" class="episode-info">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="download-progress">
|
||||||
|
<div class="progress-bar-mini">
|
||||||
|
<div id="current-progress-fill" class="progress-fill-mini"></div>
|
||||||
|
</div>
|
||||||
|
<div id="current-progress-text" class="progress-text-mini">0%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Queue List -->
|
||||||
|
<div id="queue-list-container" class="queue-list-container">
|
||||||
|
<h3 data-text="queued-series">Queued Series</h3>
|
||||||
|
<div id="queue-list" class="queue-list">
|
||||||
|
<!-- Queue items will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Series management section -->
|
||||||
|
<section class="series-section">
|
||||||
|
<div class="series-header">
|
||||||
|
<h2 data-text="series-collection">Series Collection</h2>
|
||||||
|
<div class="series-actions">
|
||||||
|
<button id="select-all" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-check-double"></i>
|
||||||
|
<span data-text="select-all">Select All</span>
|
||||||
|
</button>
|
||||||
|
<button id="download-selected" class="btn btn-success" disabled>
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
<span data-text="download-selected">Download Selected</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Series grid -->
|
||||||
|
<div id="series-grid" class="series-grid">
|
||||||
|
<!-- Series cards will be populated here -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Status panel -->
|
||||||
|
<div id="status-panel" class="status-panel hidden">
|
||||||
|
<div class="status-header">
|
||||||
|
<h3 id="status-title">Status</h3>
|
||||||
|
<button id="close-status" class="btn btn-icon">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="status-content">
|
||||||
|
<div id="status-message" class="status-message"></div>
|
||||||
|
<div id="progress-container" class="progress-container hidden">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div id="progress-fill" class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div id="progress-text" class="progress-text">0%</div>
|
||||||
|
</div>
|
||||||
|
<div id="download-controls" class="download-controls hidden">
|
||||||
|
<button id="pause-download" class="btn btn-secondary btn-small">
|
||||||
|
<i class="fas fa-pause"></i>
|
||||||
|
<span data-text="pause">Pause</span>
|
||||||
|
</button>
|
||||||
|
<button id="resume-download" class="btn btn-primary btn-small hidden">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
<span data-text="resume">Resume</span>
|
||||||
|
</button>
|
||||||
|
<button id="cancel-download" class="btn btn-small" style="background-color: var(--color-error); color: white;">
|
||||||
|
<i class="fas fa-stop"></i>
|
||||||
|
<span data-text="cancel">Cancel</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Modal -->
|
||||||
|
<div id="config-modal" class="modal hidden">
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 data-text="config-title">Configuration</h3>
|
||||||
|
<button id="close-config" class="btn btn-icon">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="config-item">
|
||||||
|
<label data-text="anime-directory">Anime Directory:</label>
|
||||||
|
<div id="anime-directory-display" class="config-value"></div>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<label data-text="series-count">Series Count:</label>
|
||||||
|
<div id="series-count-display" class="config-value">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<label data-text="connection-status">Connection Status:</label>
|
||||||
|
<div id="connection-status-display" class="config-value">
|
||||||
|
<span class="status-indicator"></span>
|
||||||
|
<span class="status-text">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast notifications -->
|
||||||
|
<div id="toast-container" class="toast-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div id="loading-overlay" class="loading-overlay hidden">
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/localization.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
42
src/server/test_app.py
Normal file
42
src/server/test_app.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to verify Flask app structure without initializing SeriesApp
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Test if we can import Flask modules
|
||||||
|
try:
|
||||||
|
from flask import Flask
|
||||||
|
from flask_socketio import SocketIO
|
||||||
|
print("✅ Flask and SocketIO imports successful")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"❌ Flask import failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Test if we can import our modules
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
from Serie import Serie
|
||||||
|
from SerieList import SerieList
|
||||||
|
print("✅ Core modules import successful")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"❌ Core module import failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Test Flask app creation
|
||||||
|
try:
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SECRET_KEY'] = 'test-key'
|
||||||
|
socketio = SocketIO(app, cors_allowed_origins="*")
|
||||||
|
print("✅ Flask app creation successful")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Flask app creation failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("🎉 All tests passed! Flask app structure is valid.")
|
||||||
|
print("\nTo run the server:")
|
||||||
|
print("1. Set ANIME_DIRECTORY environment variable to your anime directory")
|
||||||
|
print("2. Run: python app.py")
|
||||||
|
print("3. Open browser to http://localhost:5000")
|
||||||
Loading…
x
Reference in New Issue
Block a user