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__/Providers.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