Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor

This commit is contained in:
ske087
2026-05-10 21:07:50 +03:00
commit 8d9df56b0b
364 changed files with 73655 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
.venv
# Development
.git/
.gitignore
*.md
.vscode/
.idea/
# Exclude shell scripts except Docker-related ones
*.sh
!docker-entrypoint.sh
!install_libreoffice.sh
!install_emoji_fonts.sh
# Database (will be created in volume)
instance/
!instance/.gitkeep
# Uploads (will be in volume)
app/static/uploads/*
!app/static/uploads/.gitkeep
static/uploads/*
!static/uploads/.gitkeep
# Logs
*.log
# Development data
*.db
*.db-*
flask_session/
# OS
.DS_Store
Thumbs.db
# Documentation
BLUEPRINT_GUIDE.md
ICON_INTEGRATION.md
KIVY_PLAYER_COMPATIBILITY.md
PLAYER_AUTH.md
PROGRESS.md
README.md
+62
View File
@@ -0,0 +1,62 @@
# DigiServer v2 Production Environment Configuration
# Copy to .env and update with your production values
# IMPORTANT: Never commit this file to git
# Flask Configuration
FLASK_ENV=production
FLASK_APP=app.app:create_app
# Security - MUST BE SET IN PRODUCTION
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY=change-me-to-a-strong-random-secret-key-at-least-32-characters
# Admin User Configuration
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-to-a-strong-password
ADMIN_EMAIL=admin@your-domain.com
# Database Configuration (optional - defaults to SQLite)
# For PostgreSQL: postgresql://user:pass@host:5432/database
# For SQLite: sqlite:////data/instance/dashboard.db
# DATABASE_URL=
# Server Configuration
# Set BEFORE deployment if host will have static IP after restart
# This IP/domain will be used for SSL certificates and nginx configuration
DOMAIN=your-domain.com
HOST_IP=192.168.0.121
EMAIL=admin@your-domain.com
PREFERRED_URL_SCHEME=https
# SSL/HTTPS (configured in nginx.conf by default)
SSL_CERT_PATH=/etc/nginx/ssl/cert.pem
SSL_KEY_PATH=/etc/nginx/ssl/key.pem
# Logging
LOG_LEVEL=INFO
# Security Headers (configured in nginx.conf)
HSTS_MAX_AGE=31536000
HSTS_INCLUDE_SUBDOMAINS=true
# Features (optional)
ENABLE_LIBREOFFICE=true
MAX_UPLOAD_SIZE=500000000 # 500MB
# Cache Configuration (optional)
CACHE_TYPE=simple
CACHE_DEFAULT_TIMEOUT=300
# Session Configuration
SESSION_COOKIE_SECURE=true
SESSION_COOKIE_HTTPONLY=true
SESSION_COOKIE_SAMESITE=Lax
# Proxy Configuration (configured in app.py)
# IMPORTANT: Set this to your actual network range or specific proxy IP
# Examples:
# - 192.168.0.0/24 (local network with /24 subnet)
# - 10.0.0.0/8 (AWS or similar cloud)
# - 172.16.0.0/12 (Docker networks)
# For multiple IPs: 192.168.0.121,10.0.1.50
TRUSTED_PROXIES=192.168.0.0/24
+61
View File
@@ -0,0 +1,61 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
.venv
# Flask
instance/
.webassets-cache
# Persistent data folder (containers, database, uploads)
data/
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# Environment
.env
.env.local
# Database
*.db
*.sqlite
*.sqlite3
# Uploads
app/static/uploads/*
!app/static/uploads/.gitkeep
app/static/resurse/*
!app/static/resurse/.gitkeep
# Logs
*.log
# OS
.DS_Store
Thumbs.db
# Testing
.coverage
htmlcov/
.pytest_cache/
.tox/
# Build
dist/
build/
*.egg-info/
#data
data/
+60
View File
@@ -0,0 +1,60 @@
# Use Python 3.13 slim image
FROM python:3.13-slim
# Set working directory
WORKDIR /app
# Install system dependencies including LibreOffice for PPTX conversion
RUN apt-get update && \
apt-get install -y --no-install-recommends \
poppler-utils \
ffmpeg \
libmagic1 \
sudo \
fonts-noto-color-emoji \
libreoffice-core \
libreoffice-impress \
libreoffice-writer \
&& apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy entire application code into container
# This includes: app/, migrations/, configs, and all scripts
# Code is immutable in the image - only data folders are mounted as volumes
COPY . .
# Copy and set permissions for entrypoint script
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Set environment variables
ENV FLASK_APP=app.app:create_app
ENV PYTHONUNBUFFERED=1
ENV FLASK_ENV=production
# Expose port
EXPOSE 5000
# Create a non-root user and grant sudo access for dependency installation
RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app /docker-entrypoint.sh && \
echo "Defaults:appuser !requiretty, !use_pty" >> /etc/sudoers && \
echo "appuser ALL=(ALL) NOPASSWD: /usr/bin/apt-get" >> /etc/sudoers && \
echo "appuser ALL=(ALL) NOPASSWD: /app/install_libreoffice.sh" >> /etc/sudoers && \
echo "appuser ALL=(ALL) NOPASSWD: /app/install_emoji_fonts.sh" >> /etc/sudoers && \
chmod +x /app/install_libreoffice.sh /app/install_emoji_fonts.sh
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/').read()" || exit 1
# Run the application via entrypoint
ENTRYPOINT ["/docker-entrypoint.sh"]
+564
View File
@@ -0,0 +1,564 @@
# DigiServer v2 - Quick Deployment Guide
## 📋 Overview
DigiServer is deployed using Docker Compose with the following architecture:
```
Internet (User)
Nginx Reverse Proxy (Port 80/443)
Internal Docker Network
Flask App (Gunicorn on Port 5000)
SQLite Database
```
---
## 🚀 Complete Deployment Workflow
### **1️⃣ Clone & Setup**
```bash
# Copy the app folder from repository
git clone <repository>
cd digiserver-v2
# Copy environment file and modify as needed
cp .env.example .env
# Edit .env with your configuration:
nano .env
```
**Configure in .env:**
```env
SECRET_KEY=your-secret-key-change-this
ADMIN_USERNAME=admin
ADMIN_PASSWORD=your-secure-password
DOMAIN=your-domain.com
EMAIL=admin@your-domain.com
IP_ADDRESS=192.168.0.111
```
---
### **2️⃣ Deploy via Script**
```bash
# Run the deployment script
./deploy.sh
```
This automatically:
1. ✅ Creates `data/` directories (instance, uploads, nginx-ssl, etc.)
2. ✅ Copies nginx configs from repo root to `data/`
3. ✅ Starts Docker containers
4. ✅ Initializes database
5. ✅ Runs all migrations
6. ✅ Configures HTTPS with SSL certificates
7. ✅ Displays access information
**Output shows:**
- Access URLs (HTTP/HTTPS)
- Default credentials
- Next steps for configuration
---
### **3️⃣ Network Migration (When Network Changes)**
When moving the server to a different network with a new IP:
```bash
# Migrate to the new network IP
./migrate_network.sh 10.55.150.160
# Optional: with custom hostname
./migrate_network.sh 10.55.150.160 digiserver-secured
```
This automatically:
1. ✅ Regenerates SSL certificates for new IP
2. ✅ Updates database HTTPS configuration
3. ✅ Restarts nginx and app containers
4. ✅ Verifies HTTPS connectivity
---
### **4️⃣ Normal Operations**
**Restart containers:**
```bash
docker compose restart
```
**Stop containers:**
```bash
docker compose down
```
**View logs:**
```bash
docker compose logs -f
```
**View container status:**
```bash
docker compose ps
```
---
## 📦 Container Architecture
### **Container 1: digiserver-app (Flask)**
- **Image**: Built from Dockerfile (Python 3.13)
- **Port**: 5000 (internal only)
- **Volumes**:
- `./data:/app` - Persistent application data
- `./data/instance:/app/instance` - Database & configuration
- `./data/uploads:/app/app/static/uploads` - User uploads
- **Startup**: Automatically initializes database on first run
- **Health Check**: Every 30 seconds
### **Container 2: nginx (Reverse Proxy)**
- **Image**: nginx:alpine
- **Ports**: 80 & 443 (exposed to internet)
- **Volumes**:
- `nginx.conf` - Main configuration
- `./data/nginx-ssl/` - SSL certificates
- `./data/nginx-logs/` - Access/error logs
- `./data/certbot/` - Let's Encrypt challenges
- **Startup**: Waits for Flask app to start
- **Health Check**: Every 30 seconds
---
## 🔧 Deployment Steps Explained
### **Step 1: Start Containers**
```bash
docker-compose up -d
```
- Builds Flask app image (if needed)
- Starts both containers
- Waits for containers to be healthy
### **Step 2: Database Initialization** (docker-entrypoint.sh)
When Flask container starts:
1. Create required directories
2. Check if database exists
3. If NOT exists:
- Initialize SQLite database (dashboard.db)
- Create admin user from environment variables
4. Start Gunicorn server (4 workers, 120s timeout)
### **Step 3: Run Migrations**
```bash
docker-compose exec -T digiserver-app python /app/migrations/[migration_name].py
```
Applied migrations:
- `add_https_config_table.py` - HTTPS settings
- `add_player_user_table.py` - Player user management
- `add_email_to_https_config.py` - Email configuration
- `migrate_player_user_global.py` - Global settings
### **Step 4: Configure HTTPS**
- SSL certificates stored in `./data/nginx-ssl/`
- Pre-generated self-signed certs for development
- Ready for Let's Encrypt integration
### **Step 5: Reverse Proxy Routing** (nginx.conf)
```
HTTP (80):
• Redirect all traffic to HTTPS
• Allow ACME challenges for Let's Encrypt
HTTPS (443):
• TLS 1.2+, HTTP/2 enabled
• Proxy all requests to Flask app
• Security headers added
• Gzip compression enabled
• Max upload size: 2GB
• Proxy timeout: 300s
```
### **Step 6: ProxyFix Middleware** (app/app.py)
Extracts real client information from Nginx headers:
- `X-Forwarded-For` → Real client IP
- `X-Forwarded-Proto` → Protocol (http/https)
- `X-Forwarded-Host` → Original hostname
- `X-Forwarded-Port` → Original port
---
## 📂 Directory Structure & Persistence
```
/srv/digiserver-v2/
├── app/ (Flask application code)
├── data/ (PERSISTENT - mounted as Docker volume)
│ ├── app/ (Copy of app/ for container)
│ ├── instance/ (dashboard.db - SQLite database)
│ ├── uploads/ (User uploaded files)
│ ├── nginx-ssl/ (SSL certificates)
│ ├── nginx-logs/ (Nginx logs)
│ └── certbot/ (Let's Encrypt challenges)
├── migrations/ (Database schema updates)
├── docker-compose.yml (Container orchestration)
├── Dockerfile (Flask app image definition)
├── nginx.conf (Reverse proxy configuration)
└── docker-entrypoint.sh (Container startup script)
```
---
## 🔐 Default Credentials
```
Username: admin
Password: admin123
```
⚠️ **CHANGE IMMEDIATELY IN PRODUCTION!**
---
## 🌐 Access Points
After deployment, access the app at:
- `https://localhost` (if deployed locally)
- `https://192.168.0.121` (if deployed on server)
- `https://<DOMAIN>` (if DNS configured)
---
## ⚙️ Environment Variables (Optional)
Create `.env` file in project root:
```bash
# Network Configuration
HOSTNAME=digiserver
DOMAIN=digiserver.example.com
IP_ADDRESS=192.168.0.121
# SSL/HTTPS
EMAIL=admin@example.com
# Flask Configuration
SECRET_KEY=your-secret-key-here
FLASK_ENV=production
# Admin User
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
```
Then start with:
```bash
docker-compose up -d
```
---
## 🛠️ Common Commands
### **Start/Stop Containers**
```bash
# Start containers
docker-compose up -d
# Stop containers
docker-compose down
# Restart containers
docker-compose restart
# Restart specific container
docker-compose restart digiserver-app
docker-compose restart nginx
```
### **View Logs**
```bash
# All containers
docker-compose logs
# Follow logs (real-time)
docker-compose logs -f
# Specific container
docker-compose logs -f digiserver-app
docker-compose logs -f nginx
# Show last 50 lines
docker-compose logs --tail=50 digiserver-app
```
### **Container Status**
```bash
# Show running containers
docker-compose ps
# Show container details
docker-compose ps -a
# Check container health
docker ps --format="table {{.Names}}\t{{.Status}}"
```
### **Database Operations**
```bash
# Access database shell
docker-compose exec digiserver-app sqlite3 /app/instance/dashboard.db
# Backup database
docker-compose exec digiserver-app cp /app/instance/dashboard.db /app/instance/dashboard.db.backup
# Restore database
docker-compose exec digiserver-app cp /app/instance/dashboard.db.backup /app/instance/dashboard.db
```
### **Nginx Operations**
```bash
# Validate Nginx configuration
docker exec digiserver-nginx nginx -t
# Reload Nginx (without restart)
docker exec digiserver-nginx nginx -s reload
# View Nginx logs
docker-compose logs -f nginx
```
---
## 🔒 SSL Certificate Management
### **Generate New Self-Signed Certificate**
```bash
bash generate_nginx_certs.sh 192.168.0.121 365
docker-compose restart nginx
```
Parameters:
- `192.168.0.121` - Domain/IP for certificate
- `365` - Certificate validity in days
### **Set Up Let's Encrypt (Production)**
1. Update `DOMAIN` and `EMAIL` in environment
2. Modify `nginx.conf` to enable certbot challenges
3. Run certbot:
```bash
docker run --rm -v $(pwd)/data/certbot:/etc/letsencrypt \
-v $(pwd)/data/nginx-logs:/var/log/letsencrypt \
certbot/certbot certonly --webroot \
-w /var/www/certbot \
-d yourdomain.com \
-m your-email@example.com \
--agree-tos
```
---
## 🐛 Troubleshooting
### **Containers not starting?**
```bash
# Check docker-compose logs
docker-compose logs
# Check system resources
docker stats
# Restart Docker daemon
sudo systemctl restart docker
```
### **Application not responding?**
```bash
# Check app container health
docker-compose ps
# View app logs
docker-compose logs -f digiserver-app
# Test Flask directly
docker-compose exec digiserver-app curl http://localhost:5000/
```
### **HTTPS not working?**
```bash
# Verify Nginx config
docker exec digiserver-nginx nginx -t
# Check SSL certificates exist
ls -la ./data/nginx-ssl/
# View Nginx error logs
docker-compose logs nginx
```
### **Database issues?**
```bash
# Check database file exists
ls -la ./data/instance/dashboard.db
# Verify database permissions
docker-compose exec digiserver-app ls -la /app/instance/
# Check database tables
docker-compose exec digiserver-app sqlite3 /app/instance/dashboard.db ".tables"
```
### **Port already in use?**
```bash
# Find process using port 80
sudo lsof -i :80
# Find process using port 443
sudo lsof -i :443
# Kill process (if needed)
sudo kill -9 <PID>
```
---
## 📊 Health Checks
Both containers have health checks:
**Flask App**: Pings `http://localhost:5000/` every 30 seconds
**Nginx**: Pings `http://localhost:80/` every 30 seconds
Check health status:
```bash
docker-compose ps
# Look for "Up (healthy)" status
```
---
## 🔄 Database Backup & Restore
### **Backup**
```bash
# Create backup
docker-compose exec digiserver-app cp /app/instance/dashboard.db /app/instance/dashboard.backup.db
# Download to local machine
cp ./data/instance/dashboard.backup.db ./backup/
```
### **Restore**
```bash
# Stop containers
docker-compose stop
# Restore database
cp ./backup/dashboard.backup.db ./data/instance/dashboard.db
# Start containers
docker-compose up -d
```
---
## 📈 Performance Tuning
### **Gunicorn Workers** (docker-entrypoint.sh)
```bash
# Default: 4 workers
# Formula: (2 × CPU_count) + 1
# For 4-core CPU: 9 workers
# Modify docker-entrypoint.sh:
gunicorn --workers 9 ...
```
### **Nginx Worker Processes** (nginx.conf)
```nginx
# Default: auto (CPU count)
worker_processes auto;
# Or specify manually:
worker_processes 4;
```
### **Upload Timeout** (nginx.conf)
```nginx
# Default: 300s
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
```
---
## 🔐 Security Checklist
- [ ] Change admin password immediately
- [ ] Set strong `SECRET_KEY` environment variable
- [ ] Enable firewall rules (allow only ports 80, 443)
- [ ] Set up HTTPS with Let's Encrypt
- [ ] Configure regular database backups
- [ ] Review Nginx security headers
- [ ] Update Flask dependencies regularly
- [ ] Monitor container logs for errors
- [ ] Restrict admin panel access (IP whitelist optional)
- [ ] Enable Flask debug mode only in development
---
## 📞 Support & Documentation
- **Nginx Setup**: See `NGINX_SETUP_QUICK.md`
- **ProxyFix Configuration**: See `PROXY_FIX_SETUP.md`
- **Deployment Commands**: See `DEPLOYMENT_COMMANDS.md`
- **Issue Troubleshooting**: Check `old_code_documentation/`
---
## ✅ Deployment Checklist
- [ ] Docker & Docker Compose installed
- [ ] Running from `/srv/digiserver-v2` directory
- [ ] Environment variables configured (optional)
- [ ] Port 80/443 available
- [ ] Sufficient disk space (min 5GB)
- [ ] Sufficient RAM (min 2GB free)
- [ ] Network connectivity verified
- [ ] SSL certificates generated or obtained
- [ ] Admin credentials changed (production)
---
## 🚀 Next Steps After Deployment
1. **Access Web Interface**
- Login with admin credentials
- Change password immediately
2. **Configure Application**
- Set up players
- Upload content
- Configure groups & permissions
3. **Production Hardening**
- Enable Let's Encrypt HTTPS
- Configure firewall rules
- Set up database backups
- Monitor logs
4. **Optional Enhancements**
- Set up custom domain
- Configure email notifications
- Install optional dependencies (LibreOffice, etc.)
---
**Last Updated**: January 15, 2026
View File
+249
View File
@@ -0,0 +1,249 @@
"""
DigiServer v2 - Application Factory
Modern Flask application with blueprint architecture
"""
import os
from flask import Flask, render_template
from werkzeug.middleware.proxy_fix import ProxyFix
from dotenv import load_dotenv
from app.config import DevelopmentConfig, ProductionConfig, TestingConfig
from app.extensions import db, bcrypt, login_manager, migrate, cache
# Load environment variables
load_dotenv()
def create_app(config_name=None):
"""
Application factory pattern
Args:
config_name: Configuration environment (development, production, testing)
Returns:
Flask application instance
"""
# Set instance path to absolute path
instance_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'instance'))
app = Flask(__name__, instance_path=instance_path, instance_relative_config=True)
# Load configuration
if config_name == 'production':
config = ProductionConfig
elif config_name == 'testing':
config = TestingConfig
else:
config = DevelopmentConfig
app.config.from_object(config)
# Apply ProxyFix middleware for reverse proxy (Nginx/Caddy)
# This ensures proper handling of X-Forwarded-* headers
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
# ScriptNameFix: reads X-Script-Name header set by the umbrella nginx
# (e.g. /digiserver) so that url_for() generates correct full paths.
from app.utils.script_name_fix import ScriptNameFix
app.wsgi_app = ScriptNameFix(app.wsgi_app)
# Initialize extensions
db.init_app(app)
bcrypt.init_app(app)
login_manager.init_app(app)
migrate.init_app(app, db)
cache.init_app(app)
# Configure Flask-Login
configure_login_manager(app)
# Initialize CORS for player API access
from app.extensions import cors
cors.init_app(app, resources={
r"/api/*": {
"origins": ["*"],
"methods": ["GET", "POST", "OPTIONS", "PUT", "DELETE"],
"allow_headers": ["Content-Type", "Authorization"],
"supports_credentials": True,
"max_age": 3600
}
})
# Register components
register_blueprints(app)
register_error_handlers(app)
register_commands(app)
register_context_processors(app)
register_template_filters(app)
# Portal SSO: auto-login users arriving via the umbrella nginx gateway
from app.utils.portal_sso import init_portal_sso
init_portal_sso(app)
# Ensure DB schema exists (idempotent; safe to call even with migrate)
with app.app_context():
db.create_all()
return app
def register_blueprints(app):
"""Register application blueprints"""
from app.blueprints.main import main_bp
from app.blueprints.auth import auth_bp
from app.blueprints.admin import admin_bp
from app.blueprints.players import players_bp
from app.blueprints.content import content_bp
from app.blueprints.playlist import playlist_bp
from app.blueprints.api import api_bp
from app.blueprints.internal import internal_bp
# Register blueprints (using URL prefixes from blueprint definitions)
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(players_bp)
app.register_blueprint(content_bp)
app.register_blueprint(playlist_bp)
app.register_blueprint(api_bp)
app.register_blueprint(internal_bp)
def configure_login_manager(app):
"""Configure Flask-Login"""
from app.models.user import User
# Unauthenticated users are sent to the portal login, not DigiServer's own
# login page (which is disabled). Flask-Login's login_view is still set as a
# fallback URL builder target, but the actual /login route redirects to the portal.
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in via the Enterprise Digital Platform portal.'
login_manager.login_message_category = 'info'
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
def register_error_handlers(app):
"""Register error handlers"""
@app.errorhandler(404)
def not_found(error):
return render_template('errors/404.html'), 404
@app.errorhandler(403)
def forbidden(error):
return render_template('errors/403.html'), 403
@app.errorhandler(500)
def internal_error(error):
db.session.rollback()
return render_template('errors/500.html'), 500
@app.errorhandler(413)
def request_entity_too_large(error):
return render_template('errors/413.html'), 413
@app.errorhandler(408)
def request_timeout(error):
return render_template('errors/408.html'), 408
def register_commands(app):
"""Register CLI commands"""
import click
@app.cli.command('init-db')
def init_db():
"""Initialize the database"""
db.create_all()
click.echo('Database initialized.')
@app.cli.command('create-admin')
@click.option('--username', default='admin', help='Admin username')
@click.option('--password', prompt=True, hide_input=True, help='Admin password')
def create_admin(username, password):
"""Create an admin user"""
from app.models.user import User
# Check if user exists
if User.query.filter_by(username=username).first():
click.echo(f'User {username} already exists.')
return
# Create admin user
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
admin = User(username=username, password=hashed_password, role='admin')
db.session.add(admin)
db.session.commit()
click.echo(f'Admin user {username} created successfully.')
@app.cli.command('seed-db')
def seed_db():
"""Seed database with sample data (development only)"""
if app.config['ENV'] == 'production':
click.echo('Cannot seed database in production.')
return
click.echo('Seeding database with sample data...')
# Add your seeding logic here
click.echo('Database seeded successfully.')
def register_context_processors(app):
"""Register context processors for templates"""
from flask_login import current_user
@app.context_processor
def inject_config():
"""Inject configuration variables into all templates"""
return {
'server_version': app.config['SERVER_VERSION'],
'build_date': app.config['BUILD_DATE'],
'logo_exists': os.path.exists(
os.path.join(app.root_path, app.config['UPLOAD_FOLDERLOGO'], 'logo.png')
)
}
@app.context_processor
def inject_user_theme():
"""Inject user theme preference"""
theme = 'light'
if current_user.is_authenticated and hasattr(current_user, 'theme'):
theme = current_user.theme
return {'theme': theme}
def register_template_filters(app):
"""Register custom Jinja2 template filters"""
from datetime import datetime, timezone
@app.template_filter('localtime')
def localtime_filter(dt, format='%Y-%m-%d %H:%M'):
"""Convert UTC datetime to local time and format it.
Args:
dt: datetime object in UTC
format: strftime format string
Returns:
Formatted datetime string in local timezone
"""
if dt is None:
return ''
# If datetime is naive (no timezone), assume it's UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
# Convert to local time
local_dt = dt.astimezone()
return local_dt.strftime(format)
# For backwards compatibility and direct running
if __name__ == '__main__':
app = create_app()
app.run(host='0.0.0.0', port=5000, debug=True)
+3
View File
@@ -0,0 +1,3 @@
"""
Blueprints package initialization
"""
+938
View File
@@ -0,0 +1,938 @@
"""Admin blueprint for user management and system settings."""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from functools import wraps
import os
from datetime import datetime
from typing import Optional
from app.extensions import db, bcrypt
from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig
from app.utils.logger import log_action
from app.utils.caddy_manager import CaddyConfigGenerator
from app.utils.nginx_config_reader import get_nginx_status
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
def admin_required(f):
"""Decorator to require admin role for route access."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
flash('Please login to access this page.', 'warning')
return redirect(url_for('auth.login'))
if current_user.role != 'admin':
log_action('warning', f'Unauthorized admin access attempt by {current_user.username}')
flash('You do not have permission to access this page.', 'danger')
return redirect(url_for('main.dashboard'))
return f(*args, **kwargs)
return decorated_function
@admin_bp.route('/')
@login_required
def admin_panel():
"""Display admin panel with system overview."""
try:
# Get statistics
total_users = User.query.count()
total_players = Player.query.count()
total_playlists = Playlist.query.count()
total_content = Content.query.count()
# Get recent logs
recent_logs = ServerLog.query.order_by(ServerLog.timestamp.desc()).limit(10).all()
# Get all users
users = User.query.all()
# Calculate storage usage
upload_folder = current_app.config['UPLOAD_FOLDER']
total_size = 0
if os.path.exists(upload_folder):
for dirpath, dirnames, filenames in os.walk(upload_folder):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total_size += os.path.getsize(filepath)
storage_mb = round(total_size / (1024 * 1024), 2)
return render_template('admin/admin.html',
total_users=total_users,
total_players=total_players,
total_playlists=total_playlists,
total_content=total_content,
storage_mb=storage_mb,
users=users,
recent_logs=recent_logs)
except Exception as e:
log_action('error', f'Error loading admin panel: {str(e)}')
flash('Error loading admin panel.', 'danger')
return redirect(url_for('main.dashboard'))
@admin_bp.route('/user/create', methods=['POST'])
@login_required
@admin_required
def create_user():
"""User creation is disabled — accounts are managed by the EDP portal."""
flash('User management is handled through the Enterprise Digital Platform portal.', 'info')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/user/<int:user_id>/role', methods=['POST'])
@login_required
@admin_required
def change_user_role(user_id: int):
"""Role changes are disabled — roles are set from the EDP portal and synced via SSO."""
flash('Role management is handled through the Enterprise Digital Platform portal.', 'info')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/user/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_user(user_id: int):
"""User deletion is disabled — accounts are managed by the EDP portal."""
flash('User management is handled through the Enterprise Digital Platform portal.', 'info')
return redirect(url_for('admin.admin_panel'))
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/theme', methods=['POST'])
@login_required
@admin_required
def change_theme():
"""Change application theme."""
try:
theme = request.form.get('theme', 'light').strip()
if theme not in ['light', 'dark']:
flash('Invalid theme specified.', 'danger')
return redirect(url_for('admin.admin_panel'))
# Store theme preference (you can extend this to save to database)
# For now, just log the action
log_action('info', f'Theme changed to {theme} by {current_user.username}')
flash(f'Theme changed to {theme} mode.', 'success')
except Exception as e:
log_action('error', f'Error changing theme: {str(e)}')
flash('Error changing theme. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/logo/upload', methods=['POST'])
@login_required
@admin_required
def upload_logo():
"""Upload custom logo for application."""
try:
if 'logo' not in request.files:
flash('No logo file provided.', 'warning')
return redirect(url_for('admin.admin_panel'))
file = request.files['logo']
if file.filename == '':
flash('No file selected.', 'warning')
return redirect(url_for('admin.admin_panel'))
# Validate file type
allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'svg'}
filename = secure_filename(file.filename)
if not ('.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions):
flash('Invalid file type. Please upload an image file (PNG, JPG, GIF, SVG).', 'danger')
return redirect(url_for('admin.admin_panel'))
# Save logo
static_folder = current_app.config.get('STATIC_FOLDER', 'app/static')
logo_path = os.path.join(static_folder, 'logo.png')
# Create static folder if it doesn't exist
os.makedirs(static_folder, exist_ok=True)
file.save(logo_path)
log_action('info', f'Logo uploaded by admin {current_user.username}')
flash('Logo uploaded successfully.', 'success')
except Exception as e:
log_action('error', f'Error uploading logo: {str(e)}')
flash('Error uploading logo. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/logs/clear', methods=['POST'])
@login_required
@admin_required
def clear_logs():
"""Clear all server logs."""
try:
ServerLog.query.delete()
db.session.commit()
log_action('info', f'All logs cleared by admin {current_user.username}')
flash('All logs cleared successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error clearing logs: {str(e)}')
flash('Error clearing logs. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/users')
@login_required
@admin_required
def user_management():
"""Display user management page."""
try:
users = User.query.order_by(User.created_at.desc()).all()
return render_template('admin/user_management.html', users=users)
except Exception as e:
log_action('error', f'Error loading user management: {str(e)}')
flash('Error loading user management page.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/user/<int:user_id>/password', methods=['POST'])
@login_required
@admin_required
def reset_user_password(user_id: int):
"""Reset user password."""
try:
user = User.query.get_or_404(user_id)
new_password = request.form.get('password', '').strip()
# Validation
if not new_password or len(new_password) < 6:
flash('Password must be at least 6 characters long.', 'warning')
return redirect(url_for('admin.user_management'))
# Prevent changing own password through this route
if user.id == current_user.id:
flash('Use the change password option to update your own password.', 'warning')
return redirect(url_for('admin.user_management'))
# Update password
hashed_password = bcrypt.generate_password_hash(new_password).decode('utf-8')
user.password = hashed_password
db.session.commit()
log_action('info', f'Password reset for user {user.username} by admin {current_user.username}')
flash(f'Password reset successfully for user "{user.username}".', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error resetting password: {str(e)}')
flash('Error resetting password. Please try again.', 'danger')
return redirect(url_for('admin.user_management'))
@admin_bp.route('/system/info')
@login_required
@admin_required
def system_info():
"""Get system information as JSON."""
try:
import platform
import psutil
# Get system info
info = {
'system': platform.system(),
'release': platform.release(),
'version': platform.version(),
'machine': platform.machine(),
'processor': platform.processor(),
'cpu_count': psutil.cpu_count(),
'cpu_percent': psutil.cpu_percent(interval=1),
'memory_total': round(psutil.virtual_memory().total / (1024**3), 2), # GB
'memory_used': round(psutil.virtual_memory().used / (1024**3), 2), # GB
'memory_percent': psutil.virtual_memory().percent,
'disk_total': round(psutil.disk_usage('/').total / (1024**3), 2), # GB
'disk_used': round(psutil.disk_usage('/').used / (1024**3), 2), # GB
'disk_percent': psutil.disk_usage('/').percent
}
return jsonify(info)
except Exception as e:
log_action('error', f'Error getting system info: {str(e)}')
return jsonify({'error': str(e)}), 500
@admin_bp.route('/leftover-media')
@login_required
def leftover_media():
"""Display leftover media files not assigned to any playlist."""
from app.models.playlist import playlist_content
from sqlalchemy import select
try:
# Get all content IDs that are in playlists
stmt = select(playlist_content.c.content_id).distinct()
content_in_playlists = set(row[0] for row in db.session.execute(stmt))
# Get all content
all_content = Content.query.all()
# Filter content not in any playlist
leftover_content = [c for c in all_content if c.id not in content_in_playlists]
# Separate by type
leftover_images = [c for c in leftover_content if c.content_type == 'image']
leftover_videos = [c for c in leftover_content if c.content_type == 'video']
leftover_pdfs = [c for c in leftover_content if c.content_type == 'pdf']
leftover_pptx = [c for c in leftover_content if c.content_type == 'pptx']
# Calculate storage (handle None values)
def safe_file_size(content_list):
return sum(c.file_size or 0 for c in content_list)
total_leftover_size = safe_file_size(leftover_content)
images_size = safe_file_size(leftover_images)
videos_size = safe_file_size(leftover_videos)
pdfs_size = safe_file_size(leftover_pdfs)
pptx_size = safe_file_size(leftover_pptx)
return render_template('admin/leftover_media.html',
leftover_images=leftover_images,
leftover_videos=leftover_videos,
leftover_pdfs=leftover_pdfs,
leftover_pptx=leftover_pptx,
total_leftover=len(leftover_content),
total_leftover_size_mb=total_leftover_size / (1024 * 1024),
images_size_mb=images_size / (1024 * 1024),
videos_size_mb=videos_size / (1024 * 1024),
pdfs_size_mb=pdfs_size / (1024 * 1024),
pptx_size_mb=pptx_size / (1024 * 1024))
except Exception as e:
log_action('error', f'Error loading leftover media: {str(e)}')
flash('Error loading leftover media.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/delete-leftover-images', methods=['POST'])
@login_required
def delete_leftover_images():
"""Delete all leftover images that are not part of any playlist"""
from app.models.playlist import playlist_content
try:
# Find all leftover image content
leftover_images = db.session.query(Content).filter(
Content.content_type == 'image',
~Content.id.in_(
db.session.query(playlist_content.c.content_id)
)
).all()
deleted_count = 0
errors = []
for content in leftover_images:
try:
# Delete physical file
if content.filename:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(file_path):
os.remove(file_path)
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
PlayerEdit.query.filter_by(content_id=content.id).delete()
# Delete database record
db.session.delete(content)
deleted_count += 1
except Exception as e:
errors.append(f"Error deleting {content.filename}: {str(e)}")
db.session.commit()
if errors:
flash(f'Deleted {deleted_count} images with {len(errors)} errors', 'warning')
else:
flash(f'Successfully deleted {deleted_count} leftover images', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting leftover images: {str(e)}', 'danger')
return redirect(url_for('admin.leftover_media'))
@admin_bp.route('/delete-leftover-videos', methods=['POST'])
@login_required
def delete_leftover_videos():
"""Delete all leftover videos that are not part of any playlist"""
from app.models.playlist import playlist_content
try:
# Find all leftover video content
leftover_videos = db.session.query(Content).filter(
Content.content_type == 'video',
~Content.id.in_(
db.session.query(playlist_content.c.content_id)
)
).all()
deleted_count = 0
errors = []
for content in leftover_videos:
try:
# Delete physical file
if content.filename:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(file_path):
os.remove(file_path)
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
PlayerEdit.query.filter_by(content_id=content.id).delete()
# Delete database record
db.session.delete(content)
deleted_count += 1
except Exception as e:
errors.append(f"Error deleting {content.filename}: {str(e)}")
db.session.commit()
if errors:
flash(f'Deleted {deleted_count} videos with {len(errors)} errors', 'warning')
else:
flash(f'Successfully deleted {deleted_count} leftover videos', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting leftover videos: {str(e)}', 'danger')
return redirect(url_for('admin.leftover_media'))
@admin_bp.route('/delete-single-leftover/<int:content_id>', methods=['POST'])
@login_required
def delete_single_leftover(content_id):
"""Delete a single leftover content file"""
try:
content = Content.query.get_or_404(content_id)
# Delete physical file
if content.filename:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(file_path):
os.remove(file_path)
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
PlayerEdit.query.filter_by(content_id=content.id).delete()
# Delete database record
db.session.delete(content)
db.session.commit()
flash(f'Successfully deleted {content.filename}', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting file: {str(e)}', 'danger')
return redirect(url_for('admin.leftover_media'))
@admin_bp.route('/dependencies')
@login_required
@admin_required
def dependencies():
"""Show system dependencies status."""
import subprocess
# Check LibreOffice
libreoffice_installed = False
libreoffice_version = "Not installed"
try:
result = subprocess.run(['libreoffice', '--version'],
capture_output=True,
text=True,
timeout=5)
if result.returncode == 0:
libreoffice_installed = True
libreoffice_version = result.stdout.strip()
except Exception:
pass
# Check Poppler (for PDF)
poppler_installed = False
poppler_version = "Not installed"
try:
result = subprocess.run(['pdftoppm', '-v'],
capture_output=True,
text=True,
timeout=5)
if result.returncode == 0 or 'pdftoppm' in result.stderr:
poppler_installed = True
poppler_version = "Installed"
except Exception:
pass
# Check FFmpeg (for video)
ffmpeg_installed = False
ffmpeg_version = "Not installed"
try:
result = subprocess.run(['ffmpeg', '-version'],
capture_output=True,
text=True,
timeout=5)
if result.returncode == 0:
ffmpeg_installed = True
ffmpeg_version = result.stdout.split('\n')[0]
except Exception:
pass
# Check Emoji Fonts
emoji_installed = False
emoji_version = 'Not installed'
try:
result = subprocess.run(['dpkg', '-l', 'fonts-noto-color-emoji'],
capture_output=True,
text=True,
timeout=5)
if result.returncode == 0 and 'ii' in result.stdout:
emoji_installed = True
# Get version from dpkg output
lines = result.stdout.split('\n')
for line in lines:
if 'fonts-noto-color-emoji' in line and line.startswith('ii'):
parts = line.split()
if len(parts) >= 3:
emoji_version = f'Noto Color Emoji {parts[2]}'
break
except Exception:
pass
return render_template('admin/dependencies.html',
libreoffice_installed=libreoffice_installed,
libreoffice_version=libreoffice_version,
poppler_installed=poppler_installed,
poppler_version=poppler_version,
ffmpeg_installed=ffmpeg_installed,
ffmpeg_version=ffmpeg_version,
emoji_installed=emoji_installed,
emoji_version=emoji_version)
@admin_bp.route('/install-libreoffice', methods=['POST'])
@login_required
@admin_required
def install_libreoffice():
"""Install LibreOffice for PPTX conversion."""
import subprocess
try:
# Run installation script
script_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'install_libreoffice.sh')
if not os.path.exists(script_path):
flash('Installation script not found', 'danger')
return redirect(url_for('admin.dependencies'))
result = subprocess.run(['sudo', '-n', script_path],
capture_output=True,
text=True,
timeout=300)
if result.returncode == 0:
log_action('info', 'LibreOffice installed successfully')
flash('LibreOffice installed successfully! You can now convert PPTX files.', 'success')
else:
log_action('error', f'LibreOffice installation failed: {result.stderr}')
flash(f'Installation failed: {result.stderr}', 'danger')
except subprocess.TimeoutExpired:
flash('Installation timeout. Please try again.', 'warning')
except Exception as e:
log_action('error', f'Error installing LibreOffice: {str(e)}')
flash(f'Error: {str(e)}', 'danger')
return redirect(url_for('admin.dependencies'))
@admin_bp.route('/install-emoji-fonts', methods=['POST'])
@login_required
@admin_required
def install_emoji_fonts():
"""Install Emoji Fonts for better UI display."""
import subprocess
try:
# Run installation script
script_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'install_emoji_fonts.sh')
if not os.path.exists(script_path):
flash('Installation script not found', 'danger')
return redirect(url_for('admin.dependencies'))
result = subprocess.run(['sudo', '-n', script_path],
capture_output=True,
text=True,
timeout=180)
if result.returncode == 0:
log_action('info', 'Emoji fonts installed successfully')
flash('Emoji fonts installed successfully! Please restart your browser to see changes.', 'success')
else:
log_action('error', f'Emoji fonts installation failed: {result.stderr}')
flash(f'Installation failed: {result.stderr}', 'danger')
except subprocess.TimeoutExpired:
flash('Installation timeout. Please try again.', 'warning')
except Exception as e:
log_action('error', f'Error installing emoji fonts: {str(e)}')
flash(f'Error: {str(e)}', 'danger')
return redirect(url_for('admin.dependencies'))
@admin_bp.route('/customize-logos')
@login_required
@admin_required
def customize_logos():
"""Logo customization page."""
import time
return render_template('admin/customize_logos.html', version=int(time.time()))
@admin_bp.route('/upload-header-logo', methods=['POST'])
@login_required
@admin_required
def upload_header_logo():
"""Upload header logo."""
try:
if 'header_logo' not in request.files:
flash('No file selected', 'warning')
return redirect(url_for('admin.customize_logos'))
file = request.files['header_logo']
if file.filename == '':
flash('No file selected', 'warning')
return redirect(url_for('admin.customize_logos'))
if file:
# Save as header_logo.png
filename = 'header_logo.png'
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
log_action('info', f'Header logo uploaded: {filename}')
flash('Header logo uploaded successfully!', 'success')
except Exception as e:
log_action('error', f'Error uploading header logo: {str(e)}')
flash(f'Error uploading logo: {str(e)}', 'danger')
return redirect(url_for('admin.customize_logos'))
@admin_bp.route('/upload-login-logo', methods=['POST'])
@login_required
@admin_required
def upload_login_logo():
"""Upload login page logo."""
try:
if 'login_logo' not in request.files:
flash('No file selected', 'warning')
return redirect(url_for('admin.customize_logos'))
file = request.files['login_logo']
if file.filename == '':
flash('No file selected', 'warning')
return redirect(url_for('admin.customize_logos'))
if file:
# Save as login_logo.png
filename = 'login_logo.png'
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
log_action('info', f'Login logo uploaded: {filename}')
flash('Login logo uploaded successfully!', 'success')
except Exception as e:
log_action('error', f'Error uploading login logo: {str(e)}')
flash(f'Error uploading logo: {str(e)}', 'danger')
return redirect(url_for('admin.customize_logos'))
@admin_bp.route('/editing-users')
@login_required
def manage_editing_users():
"""Display and manage users that edit images on players."""
try:
from app.models.player_user import PlayerUser
from app.models.player_edit import PlayerEdit
# Get all editing users
users = PlayerUser.query.order_by(PlayerUser.created_at.desc()).all()
# Get edit counts for each user
user_stats = {}
for user in users:
edit_count = PlayerEdit.query.filter_by(user=user.user_code).count()
user_stats[user.user_code] = edit_count
return render_template('admin/editing_users.html',
users=users,
user_stats=user_stats)
except Exception as e:
log_action('error', f'Error loading editing users: {str(e)}')
flash('Error loading editing users.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/editing-users/<int:user_id>/update', methods=['POST'])
@login_required
def update_editing_user(user_id: int):
"""Update editing user name."""
try:
from app.models.player_user import PlayerUser
user = PlayerUser.query.get_or_404(user_id)
user_name = request.form.get('user_name', '').strip()
user.user_name = user_name if user_name else None
user.updated_at = datetime.utcnow()
db.session.commit()
log_action('info', f'Updated editing user {user.user_code} name to: {user_name or "None"}')
flash('User name updated successfully!', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating editing user: {str(e)}')
flash(f'Error updating user: {str(e)}', 'danger')
return redirect(url_for('admin.manage_editing_users'))
@admin_bp.route('/editing-users/<int:user_id>/delete', methods=['POST'])
@login_required
def delete_editing_user(user_id: int):
"""Delete editing user."""
try:
from app.models.player_user import PlayerUser
user = PlayerUser.query.get_or_404(user_id)
user_code = user.user_code
db.session.delete(user)
db.session.commit()
log_action('info', f'Deleted editing user: {user_code}')
flash('User deleted successfully!', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting editing user: {str(e)}')
flash(f'Error deleting user: {str(e)}', 'danger')
return redirect(url_for('admin.manage_editing_users'))
# ============================================================================
# HTTPS Configuration Management Routes
# ============================================================================
@admin_bp.route('/https-config', methods=['GET'])
@login_required
@admin_required
def https_config():
"""Display HTTPS configuration management page."""
try:
config = HTTPSConfig.get_config()
# Detect actual current HTTPS status
# Check if current connection is HTTPS
is_https_active = request.scheme == 'https' or request.headers.get('X-Forwarded-Proto') == 'https'
current_host = request.host.split(':')[0] # Remove port if present
# If HTTPS is active but database shows disabled, sync it
if is_https_active and config and not config.https_enabled:
# Update database to reflect actual HTTPS status
config.https_enabled = True
db.session.commit()
log_action('info', f'HTTPS status auto-corrected to enabled (detected from request)')
# Get Nginx configuration status
nginx_status = get_nginx_status()
return render_template('admin/https_config.html',
config=config,
is_https_active=is_https_active,
current_host=current_host,
nginx_status=nginx_status)
except Exception as e:
log_action('error', f'Error loading HTTPS config page: {str(e)}')
flash('Error loading HTTPS configuration page.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/https-config/update', methods=['POST'])
@login_required
@admin_required
def update_https_config():
"""Update HTTPS configuration."""
try:
https_enabled = request.form.get('https_enabled') == 'on'
hostname = request.form.get('hostname', '').strip()
domain = request.form.get('domain', '').strip()
ip_address = request.form.get('ip_address', '').strip()
email = request.form.get('email', '').strip()
port = request.form.get('port', '443').strip()
# Validation
errors = []
if https_enabled:
if not hostname:
errors.append('Hostname is required when HTTPS is enabled.')
if not domain:
errors.append('Domain name is required when HTTPS is enabled.')
if not ip_address:
errors.append('IP address is required when HTTPS is enabled.')
if not email:
errors.append('Email address is required when HTTPS is enabled.')
# Validate domain format (basic)
if domain and '.' not in domain:
errors.append('Please enter a valid domain name (e.g., example.com).')
# Validate IP format (basic)
if ip_address:
ip_parts = ip_address.split('.')
if len(ip_parts) != 4:
errors.append('Please enter a valid IPv4 address (e.g., 10.76.152.164).')
else:
try:
for part in ip_parts:
num = int(part)
if num < 0 or num > 255:
raise ValueError()
except ValueError:
errors.append('Please enter a valid IPv4 address.')
# Validate email format (basic)
if email and '@' not in email:
errors.append('Please enter a valid email address.')
# Validate port
try:
port_num = int(port)
if port_num < 1 or port_num > 65535:
errors.append('Port must be between 1 and 65535.')
port = port_num
except ValueError:
errors.append('Port must be a valid number.')
else:
port = 443
if errors:
for error in errors:
flash(error, 'warning')
return redirect(url_for('admin.https_config'))
# Update configuration
config = HTTPSConfig.create_or_update(
https_enabled=https_enabled,
hostname=hostname if https_enabled else None,
domain=domain if https_enabled else None,
ip_address=ip_address if https_enabled else None,
email=email if https_enabled else None,
port=port if https_enabled else 443,
updated_by=current_user.username
)
# Generate and update Caddyfile
try:
caddyfile_content = CaddyConfigGenerator.generate_caddyfile(config)
if CaddyConfigGenerator.write_caddyfile(caddyfile_content):
# Reload Caddy configuration
if CaddyConfigGenerator.reload_caddy():
caddy_status = '✅ Caddy configuration updated successfully!'
log_action('info', f'Caddy configuration reloaded by {current_user.username}')
else:
caddy_status = '⚠️ Caddyfile updated but reload failed. Please restart containers.'
log_action('warning', f'Caddy reload failed for {current_user.username}')
else:
caddy_status = '⚠️ Configuration saved but Caddyfile update failed.'
log_action('warning', f'Caddyfile write failed for {current_user.username}')
except Exception as caddy_error:
caddy_status = f'⚠️ Configuration saved but Caddy update failed: {str(caddy_error)}'
log_action('error', f'Caddy update error: {str(caddy_error)}')
if https_enabled:
log_action('info', f'HTTPS enabled by {current_user.username}: domain={domain}, hostname={hostname}, ip={ip_address}, email={email}')
flash(f'✅ HTTPS configuration saved successfully!\n{caddy_status}\nServer available at https://{domain}', 'success')
else:
log_action('info', f'HTTPS disabled by {current_user.username}')
flash(f'✅ HTTPS has been disabled. Server running on HTTP only.\n{caddy_status}', 'success')
return redirect(url_for('admin.https_config'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating HTTPS config: {str(e)}')
flash(f'Error updating HTTPS configuration: {str(e)}', 'danger')
return redirect(url_for('admin.https_config'))
@admin_bp.route('/https-config/status')
@login_required
@admin_required
def https_config_status():
"""Get current HTTPS configuration status as JSON."""
try:
config = HTTPSConfig.get_config()
if config:
return jsonify(config.to_dict())
else:
return jsonify({
'https_enabled': False,
'hostname': None,
'domain': None,
'ip_address': None,
'port': 443,
})
except Exception as e:
log_action('error', f'Error getting HTTPS status: {str(e)}')
return jsonify({'error': str(e)}), 500
+878
View File
@@ -0,0 +1,878 @@
"""API blueprint for REST endpoints and player communication."""
from flask import Blueprint, request, jsonify, current_app
from functools import wraps
from datetime import datetime, timedelta
import secrets
import bcrypt
from typing import Optional, Dict, List
from app.extensions import db, cache
from app.models import Player, Content, PlayerFeedback, ServerLog
from app.utils.logger import log_action
api_bp = Blueprint('api', __name__, url_prefix='/api')
# Simple rate limiting (use Redis-based solution in production)
rate_limit_storage = {}
def rate_limit(max_requests: int = 60, window: int = 60):
"""Rate limiting decorator.
Args:
max_requests: Maximum number of requests allowed
window: Time window in seconds
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# Get client identifier (IP address or API key)
client_id = request.remote_addr
auth_header = request.headers.get('Authorization')
if auth_header and auth_header.startswith('Bearer '):
client_id = auth_header[7:] # Use API key as identifier
now = datetime.now()
key = f"{client_id}:{f.__name__}"
# Clean old entries
if key in rate_limit_storage:
rate_limit_storage[key] = [
req_time for req_time in rate_limit_storage[key]
if now - req_time < timedelta(seconds=window)
]
else:
rate_limit_storage[key] = []
# Check rate limit
if len(rate_limit_storage[key]) >= max_requests:
return jsonify({
'error': 'Rate limit exceeded',
'retry_after': window
}), 429
# Add current request
rate_limit_storage[key].append(now)
return f(*args, **kwargs)
return decorated_function
return decorator
def verify_player_auth(f):
"""Decorator to verify player authentication via auth code."""
@wraps(f)
def decorated_function(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Missing or invalid authorization header'}), 401
auth_code = auth_header[7:] # Remove 'Bearer ' prefix
# Find player with this auth code
player = Player.query.filter_by(auth_code=auth_code).first()
if not player:
log_action('warning', f'Invalid auth code attempt: {auth_code}')
return jsonify({'error': 'Invalid authentication code'}), 403
# Store player in request context
request.player = player
return f(*args, **kwargs)
return decorated_function
@api_bp.route('/health', methods=['GET'])
def health_check():
"""API health check endpoint."""
return jsonify({
'status': 'healthy',
'timestamp': datetime.utcnow().isoformat(),
'version': '2.0.0'
})
@api_bp.route('/certificate', methods=['GET'])
def get_server_certificate():
"""Get server SSL certificate."""
return jsonify({'test': 'certificate_endpoint_works'}), 200
@api_bp.route('/auth/player', methods=['POST'])
@rate_limit(max_requests=120, window=60)
def authenticate_player():
"""Authenticate a player and return auth code and configuration.
Request JSON:
hostname: Player hostname/identifier (required)
password: Player password (optional if using quickconnect)
quickconnect_code: Quick connect code (optional if using password)
Returns:
JSON with auth_code, player_id, group_id, and configuration
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
hostname = data.get('hostname')
password = data.get('password')
quickconnect_code = data.get('quickconnect_code')
if not hostname:
return jsonify({'error': 'Hostname is required'}), 400
if not password and not quickconnect_code:
return jsonify({'error': 'Password or quickconnect code required'}), 400
# Authenticate player
player = Player.authenticate(hostname, password, quickconnect_code)
if not player:
log_action('warning', f'Failed authentication attempt for hostname: {hostname}')
return jsonify({'error': 'Invalid credentials'}), 401
# Update player status
player.update_status('online')
db.session.commit()
log_action('info', f'Player authenticated: {player.name} ({player.hostname})')
# Return authentication response
response = {
'success': True,
'player_id': player.id,
'player_name': player.name,
'hostname': player.hostname,
'auth_code': player.auth_code,
'playlist_id': player.playlist_id,
'orientation': player.orientation,
'status': player.status
}
return jsonify(response), 200
@api_bp.route('/auth/verify', methods=['POST'])
@rate_limit(max_requests=300, window=60)
def verify_auth_code():
"""Verify an auth code and return player information.
Request JSON:
auth_code: Player authentication code
Returns:
JSON with player information if valid
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
auth_code = data.get('auth_code')
if not auth_code:
return jsonify({'error': 'Auth code is required'}), 400
# Find player with this auth code
player = Player.query.filter_by(auth_code=auth_code).first()
if not player:
return jsonify({'error': 'Invalid auth code'}), 401
# Update last seen
player.update_status(player.status)
db.session.commit()
response = {
'valid': True,
'player_id': player.id,
'player_name': player.name,
'hostname': player.hostname,
'playlist_id': player.playlist_id,
'orientation': player.orientation,
'status': player.status
}
return jsonify(response), 200
@api_bp.route('/playlists', methods=['GET'])
@rate_limit(max_requests=30, window=60)
def get_playlist_by_quickconnect():
"""Get playlist using hostname and quickconnect code (Kivy player compatible).
Query parameters:
hostname: Player hostname/identifier
quickconnect_code: Quick connect code for authentication
Returns:
JSON with playlist, playlist_version, and hashed_quickconnect
"""
try:
import bcrypt
hostname = request.args.get('hostname')
quickconnect_code = request.args.get('quickconnect_code')
if not hostname or not quickconnect_code:
return jsonify({
'error': 'hostname and quickconnect_code are required',
'playlist': [],
'playlist_version': 0
}), 400
# Find player by hostname and validate quickconnect
player = Player.query.filter_by(hostname=hostname).first()
if not player:
log_action('warning', f'Player not found with hostname: {hostname}')
return jsonify({
'error': 'Player not found',
'playlist': [],
'playlist_version': 0
}), 404
# Validate quickconnect code
if not player.quickconnect_code:
log_action('warning', f'Player {hostname} has no quickconnect code set')
return jsonify({
'error': 'Quickconnect not configured',
'playlist': [],
'playlist_version': 0
}), 403
# Check if quickconnect matches (using bcrypt verification)
if not player.check_quickconnect_code(quickconnect_code):
log_action('warning', f'Invalid quickconnect code for player: {hostname}')
return jsonify({
'error': 'Invalid quickconnect code',
'playlist': [],
'playlist_version': 0
}), 403
# Get playlist (with caching)
playlist = get_cached_playlist(player.id)
# Update player's last seen timestamp and status
player.last_seen = datetime.utcnow()
player.status = 'online'
db.session.commit()
# Get playlist version from the assigned playlist
playlist_version = 1
if player.playlist_id:
from app.models import Playlist
assigned_playlist = Playlist.query.get(player.playlist_id)
if assigned_playlist:
playlist_version = assigned_playlist.version
# Hash the quickconnect code for validation on client side
hashed_quickconnect = bcrypt.hashpw(
quickconnect_code.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
log_action('info', f'Playlist fetched for player: {player.name} ({hostname})')
return jsonify({
'player_id': player.id,
'player_name': player.name,
'playlist_id': player.playlist_id,
'playlist_version': playlist_version,
'playlist': playlist,
'hashed_quickconnect': hashed_quickconnect,
'count': len(playlist)
}), 200
except Exception as e:
log_action('error', f'Error getting playlist: {str(e)}')
return jsonify({
'error': 'Internal server error',
'playlist': [],
'playlist_version': 0
}), 500
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
@rate_limit(max_requests=300, window=60)
@verify_player_auth
def get_player_playlist(player_id: int):
"""Get playlist for a specific player.
Requires player authentication via Bearer token.
"""
try:
# Verify the authenticated player matches the requested player_id
if request.player.id != player_id:
return jsonify({'error': 'Unauthorized access to this player'}), 403
player = request.player
# Get playlist (with caching)
playlist = get_cached_playlist(player_id)
# Update player's last seen timestamp
player.last_seen = datetime.utcnow()
db.session.commit()
# Get playlist version from the assigned playlist
playlist_version = 1
if player.playlist_id:
from app.models import Playlist
assigned_playlist = Playlist.query.get(player.playlist_id)
if assigned_playlist:
playlist_version = assigned_playlist.version
return jsonify({
'player_id': player_id,
'player_name': player.name,
'playlist_id': player.playlist_id,
'playlist_version': playlist_version,
'playlist': playlist,
'count': len(playlist)
})
except Exception as e:
log_action('error', f'Error getting playlist for player {player_id}: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/playlist-version/<int:player_id>', methods=['GET'])
@verify_player_auth
@rate_limit(max_requests=60, window=60)
def get_playlist_version(player_id: int):
"""Get current playlist version for a player.
Lightweight endpoint for players to check if playlist needs updating.
Requires player authentication via Bearer token.
"""
try:
# Verify the authenticated player matches the requested player_id
if request.player.id != player_id:
return jsonify({'error': 'Unauthorized access to this player'}), 403
player = request.player
# Update last seen
player.last_seen = datetime.utcnow()
db.session.commit()
return jsonify({
'player_id': player_id,
'playlist_version': player.playlist_version,
'content_count': Content.query.filter_by(player_id=player_id).count()
})
except Exception as e:
log_action('error', f'Error getting playlist version for player {player_id}: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@cache.memoize(timeout=300)
def get_cached_playlist(player_id: int) -> List[Dict]:
"""Get cached playlist for a player based on assigned playlist."""
from flask import url_for
from app.models import Playlist
player = Player.query.get(player_id)
if not player or not player.playlist_id:
return []
# Get the playlist assigned to this player
playlist = Playlist.query.get(player.playlist_id)
if not playlist:
return []
# Get content from playlist (ordered)
content_list = playlist.get_content_ordered()
# Build playlist response
playlist_data = []
for idx, content in enumerate(content_list, start=1):
# Generate full URL for content.
# request.script_root holds the X-Script-Name prefix set by the umbrella
# nginx (e.g. '/digiserver'), so the URL is correct whether the app runs
# standalone or behind the portal reverse proxy.
from flask import request as current_request
server_base = current_request.host_url.rstrip('/')
script_root = current_request.script_root.rstrip('/')
content_url = f"{server_base}{script_root}/static/uploads/{content.filename}"
playlist_data.append({
'id': content.id,
'file_name': content.filename, # Player expects 'file_name' not 'filename'
'type': content.content_type,
'duration': content._playlist_duration or content.duration or 10,
'position': content._playlist_position or idx,
'url': content_url, # Full URL for downloads
'description': content.description,
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
})
return playlist_data
@api_bp.route('/player-feedback', methods=['POST'])
@rate_limit(max_requests=600, window=60)
def receive_player_feedback():
"""Receive feedback/status updates from players (Kivy player compatible).
Expected JSON payload:
{
"player_name": "Screen1",
"quickconnect_code": "ABC123",
"status": "playing|paused|error|restarting",
"message": "Status message",
"playlist_version": 1,
"error_details": "Optional error details",
"timestamp": "ISO timestamp"
}
"""
try:
data = request.json
if not data:
log_action('warning', 'Player feedback received with no data')
return jsonify({'error': 'No data provided'}), 400
player_name = data.get('player_name')
hostname = data.get('hostname') # Also accept hostname
quickconnect_code = data.get('quickconnect_code')
# Find player by hostname first (more reliable), then by name
player = None
if hostname:
player = Player.query.filter_by(hostname=hostname).first()
if not player and player_name:
player = Player.query.filter_by(name=player_name).first()
# If player not found and no credentials provided, try to infer from IP and recent auth
if not player and (not quickconnect_code or (not player_name and not hostname)):
# Try to find player by recent authentication from same IP
client_ip = request.remote_addr
# Look for players with matching IP in recent activity (last 5 minutes)
recent_time = datetime.utcnow() - timedelta(minutes=5)
possible_player = Player.query.filter(
Player.last_seen >= recent_time
).order_by(Player.last_seen.desc()).first()
if possible_player:
player = possible_player
log_action('info', f'Inferred player feedback from {player.name} ({player.hostname}) based on recent activity')
# Still require quickconnect validation if provided
if not player:
if not player_name and not hostname:
log_action('warning', f'Player feedback missing required fields. Data: {data}')
return jsonify({'error': 'player_name/hostname and quickconnect_code required'}), 400
else:
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
return jsonify({'error': 'Player not found'}), 404
if not player:
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
return jsonify({'error': 'Player not found'}), 404
# Validate quickconnect code if provided (using bcrypt verification)
if quickconnect_code and not player.check_quickconnect_code(quickconnect_code):
log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})')
return jsonify({'error': 'Invalid quickconnect code'}), 403
# Create feedback record
status = data.get('status', 'unknown')
message = data.get('message', '')
error_details = data.get('error_details')
feedback = PlayerFeedback(
player_id=player.id,
status=status,
message=message,
error=error_details
)
db.session.add(feedback)
# Update player's last seen and status
player.last_seen = datetime.utcnow()
player.status = status
db.session.commit()
log_action('info', f'Feedback received from {player.name} ({player.hostname}): {status} - {message}')
return jsonify({
'success': True,
'message': 'Feedback received',
'player_id': player.id
}), 200
except Exception as e:
db.session.rollback()
log_action('error', f'Error receiving player feedback: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/player-status/<int:player_id>', methods=['GET'])
@rate_limit(max_requests=60, window=60)
def get_player_status(player_id: int):
"""Get current status of a player (public endpoint for monitoring)."""
try:
player = Player.query.get_or_404(player_id)
# Get latest feedback
latest_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\
.order_by(PlayerFeedback.timestamp.desc())\
.first()
# Calculate if player is online (seen in last 5 minutes)
is_online = False
if player.last_seen:
is_online = (datetime.utcnow() - player.last_seen).total_seconds() < 300
return jsonify({
'player_id': player_id,
'name': player.name,
'location': player.location,
'group_id': player.group_id,
'status': player.status,
'is_online': is_online,
'last_seen': player.last_seen.isoformat() if player.last_seen else None,
'latest_feedback': {
'status': latest_feedback.status,
'message': latest_feedback.message,
'timestamp': latest_feedback.timestamp.isoformat()
} if latest_feedback else None
})
except Exception as e:
log_action('error', f'Error getting player status: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/upload-progress/<upload_id>', methods=['GET'])
@rate_limit(max_requests=120, window=60)
def get_upload_progress(upload_id: str):
"""Get progress of a file upload."""
from app.utils.uploads import get_upload_progress as get_progress
try:
progress = get_progress(upload_id)
return jsonify(progress)
except Exception as e:
log_action('error', f'Error getting upload progress: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/system-info', methods=['GET'])
@rate_limit(max_requests=30, window=60)
def system_info():
"""Get system information and statistics."""
try:
# Get counts
total_players = Player.query.count()
total_groups = Group.query.count()
total_content = Content.query.count()
# Count online players (seen in last 5 minutes)
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
online_players = Player.query.filter(Player.last_seen >= five_min_ago).count()
# Get recent logs count
recent_logs = ServerLog.query.filter(
ServerLog.timestamp >= datetime.utcnow() - timedelta(hours=24)
).count()
return jsonify({
'players': {
'total': total_players,
'online': online_players
},
'groups': total_groups,
'content': total_content,
'logs_24h': recent_logs,
'timestamp': datetime.utcnow().isoformat()
})
except Exception as e:
log_action('error', f'Error getting system info: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
# DEPRECATED: Groups functionality has been archived
# @api_bp.route('/groups', methods=['GET'])
# @rate_limit(max_requests=60, window=60)
# def list_groups():
# """List all groups with basic information."""
# try:
# groups = Group.query.order_by(Group.name).all()
#
# groups_data = []
# for group in groups:
# groups_data.append({
# 'id': group.id,
# 'name': group.name,
# 'description': group.description,
# 'player_count': group.players.count(),
# 'content_count': group.contents.count()
# })
#
# return jsonify({
# 'groups': groups_data,
# 'count': len(groups_data)
# })
#
# except Exception as e:
# log_action('error', f'Error listing groups: {str(e)}')
# return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/content', methods=['GET'])
@rate_limit(max_requests=60, window=60)
def list_content():
"""List all content with basic information."""
try:
contents = Content.query.order_by(Content.uploaded_at.desc()).all()
content_data = []
for content in contents:
content_data.append({
'id': content.id,
'filename': content.filename,
'type': content.content_type,
'duration': content.duration,
'size': content.file_size,
'uploaded_at': content.uploaded_at.isoformat(),
'group_count': content.groups.count()
})
return jsonify({
'content': content_data,
'count': len(content_data)
})
except Exception as e:
log_action('error', f'Error listing content: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/logs', methods=['GET'])
@rate_limit(max_requests=30, window=60)
def get_logs():
"""Get recent server logs.
Query parameters:
limit: Number of logs to return (default: 50, max: 200)
level: Filter by log level (info, warning, error)
since: ISO timestamp to get logs since that time
"""
try:
# Get query parameters
limit = min(request.args.get('limit', 50, type=int), 200)
level = request.args.get('level')
since_str = request.args.get('since')
# Build query
query = ServerLog.query
if level:
query = query.filter_by(level=level)
if since_str:
try:
since = datetime.fromisoformat(since_str)
query = query.filter(ServerLog.timestamp >= since)
except ValueError:
return jsonify({'error': 'Invalid since timestamp format'}), 400
# Get logs
logs = query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
logs_data = []
for log in logs:
logs_data.append({
'id': log.id,
'level': log.level,
'message': log.message,
'timestamp': log.timestamp.isoformat()
})
return jsonify({
'logs': logs_data,
'count': len(logs_data)
})
except Exception as e:
log_action('error', f'Error getting logs: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/player-edit-media', methods=['POST'])
@rate_limit(max_requests=60, window=60)
@verify_player_auth
def receive_edited_media():
"""Receive edited media from player.
Expected multipart/form-data:
- image_file: The edited image file
- metadata: JSON string with metadata
Metadata JSON structure:
{
"time_of_modification": "ISO timestamp",
"original_name": "original_file.jpg",
"new_name": "original_file_v1.jpg",
"version": 1,
"user": "player_user"
}
"""
try:
player = request.player
# Check if file is present
if 'image_file' not in request.files:
return jsonify({'error': 'No image file provided'}), 400
file = request.files['image_file']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
# Get metadata
import json
metadata_str = request.form.get('metadata')
if not metadata_str:
return jsonify({'error': 'No metadata provided'}), 400
try:
metadata = json.loads(metadata_str)
except json.JSONDecodeError:
return jsonify({'error': 'Invalid metadata JSON'}), 400
# Validate required metadata fields
required_fields = ['time_of_modification', 'original_name', 'new_name', 'version']
for field in required_fields:
if field not in metadata:
return jsonify({'error': f'Missing required field: {field}'}), 400
# Import required modules
import os
from werkzeug.utils import secure_filename
from app.models.player_edit import PlayerEdit
# Find the original content by filename
original_name = metadata['original_name']
content = Content.query.filter_by(filename=original_name).first()
if not content:
log_action('warning', f'Player {player.name} tried to edit non-existent content: {original_name}')
return jsonify({'error': f'Original content not found: {original_name}'}), 404
# Create versioned folder structure: edited_media/<content_id>/
base_upload_dir = os.path.join(current_app.root_path, 'static', 'uploads')
edited_media_dir = os.path.join(base_upload_dir, 'edited_media', str(content.id))
os.makedirs(edited_media_dir, exist_ok=True)
# Save the edited file with version suffix
version = metadata['version']
new_filename = metadata['new_name']
edited_file_path = os.path.join(edited_media_dir, new_filename)
file.save(edited_file_path)
# Save metadata JSON file
metadata_filename = f"{os.path.splitext(new_filename)[0]}_metadata.json"
metadata_path = os.path.join(edited_media_dir, metadata_filename)
with open(metadata_path, 'w') as f:
json.dump(metadata, f, indent=2)
# Update the content record to reference the edited version path
# Keep original filename unchanged, point to edited_media folder
old_filename = content.filename
content.filename = f"edited_media/{content.id}/{new_filename}"
# Create edit record
time_of_mod = None
if metadata.get('time_of_modification'):
try:
time_of_mod = datetime.fromisoformat(metadata['time_of_modification'].replace('Z', '+00:00'))
except:
time_of_mod = datetime.utcnow()
# Auto-create PlayerUser record if user code is provided
user_code = metadata.get('user_card_data')
log_action('debug', f'Metadata user code: {user_code}')
if user_code:
from app.models.player_user import PlayerUser
existing_user = PlayerUser.query.filter_by(user_code=user_code).first()
if not existing_user:
new_user = PlayerUser(user_code=user_code)
db.session.add(new_user)
log_action('info', f'Auto-created PlayerUser record for code: {user_code}')
else:
log_action('debug', f'PlayerUser already exists for code: {user_code}')
else:
log_action('debug', 'No user code in metadata')
edit_record = PlayerEdit(
player_id=player.id,
content_id=content.id,
original_name=original_name,
new_name=new_filename,
version=version,
user=user_code,
time_of_modification=time_of_mod,
metadata_path=metadata_path,
edited_file_path=edited_file_path
)
db.session.add(edit_record)
# Update playlist version to force player refresh
playlist = None
if player.playlist_id:
from app.models.playlist import Playlist
playlist = db.session.get(Playlist, player.playlist_id)
if playlist:
playlist.version += 1
# Clear playlist cache
cache.delete_memoized(get_cached_playlist, player.id)
db.session.commit()
log_action('info', f'Player {player.name} uploaded edited media: {old_filename} -> {new_filename} (v{version})')
return jsonify({
'success': True,
'message': 'Edited media received and processed',
'edit_id': edit_record.id,
'version': version,
'old_filename': old_filename,
'new_filename': new_filename,
'new_playlist_version': playlist.version if playlist else None
}), 200
except Exception as e:
db.session.rollback()
log_action('error', f'Error receiving edited media: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.errorhandler(404)
def api_not_found(error):
"""Handle 404 errors in API."""
return jsonify({'error': 'Endpoint not found'}), 404
@api_bp.errorhandler(405)
def method_not_allowed(error):
"""Handle 405 errors in API."""
return jsonify({'error': 'Method not allowed'}), 405
@api_bp.errorhandler(500)
def internal_error(error):
"""Handle 500 errors in API."""
return jsonify({'error': 'Internal server error'}), 500
+63
View File
@@ -0,0 +1,63 @@
"""
Authentication Blueprint - Login, Logout
User management is handled exclusively by the Enterprise Digital Platform portal.
Direct registration and local user creation are disabled.
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from flask_login import login_user, logout_user, login_required, current_user
from app.extensions import db, bcrypt, login_manager
from app.models import User
from app.utils.logger import log_action
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""
Login handler.
When accessed through the portal nginx gateway the portal_sso.py before_request
hook already logs the user in and redirects to the dashboard — this handler is
only reached if someone accesses DigiServer directly (bypassing the gateway).
In that case we redirect them to the portal login page.
"""
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
# If there are X-Auth-Username headers the SSO hook should have handled this
# already. If we still end up here the user has no portal session — send them
# to the portal login so they can authenticate through the proper gateway.
portal_login = current_app.config.get('PORTAL_LOGIN_URL', '/login')
return redirect(portal_login)
@auth_bp.route('/logout')
@login_required
def logout():
"""User logout"""
username = current_user.username
logout_user()
log_action('info', f'User {username} logged out')
flash('You have been logged out.', 'info')
return redirect(url_for('auth.login'))
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
"""
Self-registration is disabled — users are managed exclusively by the portal.
Redirect to the portal login page.
"""
portal_login = current_app.config.get('PORTAL_LOGIN_URL', '/login')
return redirect(portal_login)
@auth_bp.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
"""
Password changes are managed by the portal.
Passwords for portal-managed users are randomly generated and not user-facing.
"""
flash('Password management is handled through the Enterprise Digital Platform portal.', 'info')
return redirect(url_for('main.dashboard'))
File diff suppressed because it is too large Load Diff
+500
View File
@@ -0,0 +1,500 @@
"""Content blueprint for media upload and management."""
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app, send_from_directory)
from flask_login import login_required
from werkzeug.utils import secure_filename
import os
from typing import Optional, Dict
import json
from app.extensions import db, cache
from app.models import Content, Group
from app.utils.logger import log_action
from app.utils.uploads import (
save_uploaded_file,
process_video_file,
process_pdf_file,
get_upload_progress,
set_upload_progress
)
content_bp = Blueprint('content', __name__, url_prefix='/content')
# In-memory storage for upload progress (for simple demo; use Redis in production)
upload_progress = {}
@content_bp.route('/')
@login_required
def content_list():
"""Display list of all content."""
try:
# Get all unique content files (by filename)
from sqlalchemy import func
# Get content with player information
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
# Group content by filename to show which players have each file
content_map = {}
for content in contents:
if content.filename not in content_map:
content_map[content.filename] = {
'content': content,
'players': [],
'groups': []
}
# Add player info if assigned to a player
if content.player_id:
from app.models import Player
player = Player.query.get(content.player_id)
if player:
content_map[content.filename]['players'].append({
'id': player.id,
'name': player.name,
'group': player.group.name if player.group else None
})
# Convert to list for template
content_list = []
for filename, data in content_map.items():
content_list.append({
'filename': filename,
'content_type': data['content'].content_type,
'duration': data['content'].duration,
'file_size': data['content'].file_size_mb,
'uploaded_at': data['content'].uploaded_at,
'players': data['players'],
'player_count': len(data['players'])
})
# Sort by upload date
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
return render_template('content/content_list.html',
content_list=content_list)
except Exception as e:
log_action('error', f'Error loading content list: {str(e)}')
flash('Error loading content list.', 'danger')
return redirect(url_for('main.dashboard'))
@content_bp.route('/upload', methods=['GET', 'POST'])
@login_required
def upload_content():
"""Upload new content."""
if request.method == 'GET':
# Get parameters for return URL and pre-selection
player_id = request.args.get('player_id', type=int)
return_url = request.args.get('return_url', url_for('content.content_list'))
# Get all players for selection
from app.models import Player
players = Player.query.order_by(Player.name).all()
return render_template('content/upload_content.html',
players=players,
selected_player_id=player_id,
return_url=return_url)
try:
# Get form data
player_id = request.form.get('player_id', type=int)
media_type = request.form.get('media_type', 'image')
duration = request.form.get('duration', type=int, default=10)
session_id = request.form.get('session_id', os.urandom(8).hex())
return_url = request.form.get('return_url', url_for('content.content_list'))
# Get files
files = request.files.getlist('files')
if not files or files[0].filename == '':
flash('No files provided.', 'warning')
return redirect(url_for('content.upload_content'))
if not player_id:
flash('Please select a player.', 'warning')
return redirect(url_for('content.upload_content'))
# Initialize progress tracking using shared utility
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
# Process each file
upload_folder = current_app.config['UPLOAD_FOLDER']
os.makedirs(upload_folder, exist_ok=True)
processed_count = 0
total_files = len(files)
for idx, file in enumerate(files):
if file.filename == '':
continue
# Update progress
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
set_upload_progress(session_id, progress_pct,
f'Processing file {idx + 1} of {total_files}...', 'processing')
filename = secure_filename(file.filename)
filepath = os.path.join(upload_folder, filename)
# Save file
file.save(filepath)
# Determine content type
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
content_type = 'image'
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
content_type = 'video'
# Process video (convert to Raspberry Pi optimized format)
set_upload_progress(session_id, progress_pct + 5,
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
success, message = process_video_file(filepath, session_id)
if not success:
log_action('error', f'Video optimization failed: {message}')
continue # Skip this file and move to next
elif file_ext == 'pdf':
content_type = 'pdf'
# Process PDF (convert to images)
set_upload_progress(session_id, progress_pct + 5,
f'Converting PDF {idx + 1}...', 'processing')
# process_pdf_file(filepath, session_id)
elif file_ext in ['ppt', 'pptx']:
content_type = 'presentation'
# Process presentation (convert to PDF then images)
set_upload_progress(session_id, progress_pct + 5,
f'Converting PowerPoint {idx + 1}...', 'processing')
# This would call pptx_converter utility
else:
content_type = 'other'
# Create content record linked to player
from app.models import Player
player = Player.query.get(player_id)
if player:
new_content = Content(
filename=filename,
content_type=content_type,
duration=duration,
file_size=os.path.getsize(filepath),
player_id=player_id
)
db.session.add(new_content)
# Increment playlist version
player.playlist_version += 1
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
processed_count += 1
# Commit all changes
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
db.session.commit()
# Complete
set_upload_progress(session_id, 100,
f'Successfully uploaded {processed_count} file(s)!', 'complete')
# Clear all playlist caches
cache.clear()
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
return redirect(return_url)
except Exception as e:
db.session.rollback()
# Update progress to error state
if 'session_id' in locals():
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
log_action('error', f'Error uploading content: {str(e)}')
flash('Error uploading content. Please try again.', 'danger')
return redirect(url_for('content.upload_content'))
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_content(content_id: int):
"""Edit content metadata."""
content = Content.query.get_or_404(content_id)
if request.method == 'GET':
return render_template('content/edit_content.html', content=content)
try:
duration = request.form.get('duration', type=int)
description = request.form.get('description', '').strip()
# Update content
if duration is not None:
content.duration = duration
content.description = description or None
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
flash(f'Content "{content.filename}" updated successfully.', 'success')
return redirect(url_for('content.content_list'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating content: {str(e)}')
flash('Error updating content. Please try again.', 'danger')
return redirect(url_for('content.edit_content', content_id=content_id))
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
@login_required
def delete_content(content_id: int):
"""Delete content and associated file."""
try:
content = Content.query.get_or_404(content_id)
filename = content.filename
# Delete file from disk
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
os.remove(filepath)
# Delete from database
db.session.delete(content)
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
flash(f'Content "{filename}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting content: {str(e)}')
flash('Error deleting content. Please try again.', 'danger')
return redirect(url_for('content.content_list'))
@content_bp.route('/delete-by-filename', methods=['POST'])
@login_required
def delete_by_filename():
"""Delete all content entries with a specific filename."""
try:
data = request.get_json()
filename = data.get('filename')
if not filename:
return jsonify({'success': False, 'message': 'No filename provided'}), 400
# Find all content entries with this filename
contents = Content.query.filter_by(filename=filename).all()
if not contents:
return jsonify({'success': False, 'message': 'Content not found'}), 404
deleted_count = len(contents)
# Delete file from disk (only once)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
os.remove(filepath)
log_action('info', f'Deleted file from disk: {filename}')
# Delete all database entries
for content in contents:
db.session.delete(content)
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
return jsonify({
'success': True,
'message': f'Content deleted from {deleted_count} playlist(s)',
'deleted_count': deleted_count
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting content by filename: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/bulk/delete', methods=['POST'])
@login_required
def bulk_delete_content():
"""Delete multiple content items at once."""
try:
content_ids = request.json.get('content_ids', [])
if not content_ids:
return jsonify({'success': False, 'error': 'No content selected'}), 400
# Delete content
deleted_count = 0
for content_id in content_ids:
content = Content.query.get(content_id)
if content:
# Delete file
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(filepath):
os.remove(filepath)
db.session.delete(content)
deleted_count += 1
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Bulk deleted {deleted_count} content items')
return jsonify({'success': True, 'deleted': deleted_count})
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk deleting content: {str(e)}')
return jsonify({'success': False, 'error': str(e)}), 500
@content_bp.route('/upload-progress/<upload_id>')
@login_required
def upload_progress_status(upload_id: str):
"""Get upload progress for a specific upload."""
progress = get_upload_progress(upload_id)
return jsonify(progress)
@content_bp.route('/preview/<int:content_id>')
@login_required
def preview_content(content_id: int):
"""Preview content in browser."""
try:
content = Content.query.get_or_404(content_id)
# Serve file from uploads folder
return send_from_directory(
current_app.config['UPLOAD_FOLDER'],
content.filename,
as_attachment=False
)
except Exception as e:
log_action('error', f'Error previewing content: {str(e)}')
return "Error loading content", 500
@content_bp.route('/<int:content_id>/download')
@login_required
def download_content(content_id: int):
"""Download content file."""
try:
content = Content.query.get_or_404(content_id)
log_action('info', f'Content "{content.filename}" downloaded')
return send_from_directory(
current_app.config['UPLOAD_FOLDER'],
content.filename,
as_attachment=True
)
except Exception as e:
log_action('error', f'Error downloading content: {str(e)}')
return "Error downloading content", 500
@content_bp.route('/statistics')
@login_required
def content_statistics():
"""Get content statistics."""
try:
total_content = Content.query.count()
# Count by type
type_counts = {}
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
count = Content.query.filter_by(content_type=content_type).count()
type_counts[content_type] = count
# Calculate total storage
upload_folder = current_app.config['UPLOAD_FOLDER']
total_size = 0
if os.path.exists(upload_folder):
for dirpath, dirnames, filenames in os.walk(upload_folder):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total_size += os.path.getsize(filepath)
return jsonify({
'total': total_content,
'by_type': type_counts,
'total_size_mb': round(total_size / (1024 * 1024), 2)
})
except Exception as e:
log_action('error', f'Error getting content statistics: {str(e)}')
return jsonify({'error': str(e)}), 500
@content_bp.route('/check-duplicates')
@login_required
def check_duplicates():
"""Check for duplicate filenames."""
try:
# Get all filenames
all_content = Content.query.all()
filename_counts = {}
for content in all_content:
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
# Find duplicates
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
return jsonify({
'has_duplicates': len(duplicates) > 0,
'duplicates': duplicates
})
except Exception as e:
log_action('error', f'Error checking duplicates: {str(e)}')
return jsonify({'error': str(e)}), 500
@content_bp.route('/<int:content_id>/groups')
@login_required
def content_groups_info(content_id: int):
"""Get groups that contain this content."""
try:
content = Content.query.get_or_404(content_id)
groups_data = []
for group in content.groups:
groups_data.append({
'id': group.id,
'name': group.name,
'description': group.description,
'player_count': group.players.count()
})
return jsonify({
'content_id': content_id,
'filename': content.filename,
'groups': groups_data
})
except Exception as e:
log_action('error', f'Error getting content groups: {str(e)}')
return jsonify({'error': str(e)}), 500
+89
View File
@@ -0,0 +1,89 @@
"""
Internal blueprint — service-to-service endpoints.
These routes are NOT exposed through nginx to the public internet.
They are called from the portal when it needs to pre-provision users.
Protected with a shared secret in the X-Internal-Token header.
"""
import os
import secrets as _secrets
from flask import Blueprint, request, jsonify, current_app
from app.extensions import db, bcrypt
internal_bp = Blueprint('internal', __name__, url_prefix='/internal')
def _check_token():
"""Return True if the request carries a valid internal sync token."""
expected = current_app.config.get('INTERNAL_SYNC_SECRET', '')
provided = request.headers.get('X-Internal-Token', '')
if not expected or not provided:
return False
# Constant-time comparison to prevent timing attacks
return _secrets.compare_digest(expected, provided)
@internal_bp.route('/sync-user', methods=['POST'])
def sync_user():
"""
Create or update a portal-managed user in DigiServer's local DB.
Request JSON:
{
"username": "<portal username>",
"role": "admin" | "user"
}
"""
if not _check_token():
return jsonify({'error': 'forbidden'}), 403
data = request.get_json(silent=True) or {}
username = (data.get('username') or '').strip()
role_raw = (data.get('role') or 'user').strip()
role = 'admin' if role_raw == 'admin' else 'user'
if not username:
return jsonify({'error': 'username required'}), 400
from app.models.user import User
try:
user = User.query.filter_by(username=username).first()
if user:
if user.role != role:
user.role = role
db.session.commit()
status = 'updated'
else:
pw_hash = bcrypt.generate_password_hash(
_secrets.token_hex(32)
).decode('utf-8')
user = User(username=username, password=pw_hash, role=role)
db.session.add(user)
db.session.commit()
status = 'created'
return jsonify({'status': status, 'username': username, 'role': role}), 200
except Exception as exc:
db.session.rollback()
current_app.logger.error('sync-user error: %s', exc)
return jsonify({'error': 'internal error'}), 500
@internal_bp.route('/users', methods=['GET'])
def list_users():
"""Return a list of portal-managed users (for portal Settings UI)."""
if not _check_token():
return jsonify({'error': 'forbidden'}), 403
from app.models.user import User
users = User.query.order_by(User.username).all()
return jsonify([
{
'username': u.username,
'role': u.role,
'last_login': u.last_login.isoformat() if u.last_login else None,
}
for u in users
]), 200
+80
View File
@@ -0,0 +1,80 @@
"""
Main Blueprint - Dashboard and Home Routes
"""
from flask import Blueprint, render_template, redirect, url_for
from flask_login import login_required, current_user
from app.extensions import db, cache
from app.models.player import Player
from app.models.playlist import Playlist
from app.models.content import Content
from app.utils.logger import get_recent_logs
import os
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
@login_required
@cache.cached(timeout=60, unless=lambda: current_user.role != 'viewer')
def dashboard():
"""Main dashboard page"""
# Get statistics
total_players = Player.query.count()
total_playlists = Playlist.query.count()
total_content = Content.query.count()
# Calculate storage usage
upload_folder = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads')
storage_mb = 0
if os.path.exists(upload_folder):
for filename in os.listdir(upload_folder):
filepath = os.path.join(upload_folder, filename)
if os.path.isfile(filepath):
storage_mb += os.path.getsize(filepath)
storage_mb = round(storage_mb / (1024 * 1024), 2) # Convert to MB
server_logs = get_recent_logs(20)
return render_template(
'dashboard.html',
total_players=total_players,
total_playlists=total_playlists,
total_content=total_content,
storage_mb=storage_mb,
recent_logs=server_logs
)
@main_bp.route('/health')
def health():
"""Health check endpoint"""
from flask import jsonify
import os
try:
# Check database
db.session.execute(db.text('SELECT 1'))
# Check disk space
upload_folder = os.path.join(
main_bp.root_path or '.',
'static/uploads'
)
if os.path.exists(upload_folder):
stat = os.statvfs(upload_folder)
free_space_gb = (stat.f_bavail * stat.f_frsize) / (1024**3)
else:
free_space_gb = 0
return jsonify({
'status': 'healthy',
'database': 'ok',
'disk_space_gb': round(free_space_gb, 2)
}), 200
except Exception as e:
return jsonify({
'status': 'unhealthy',
'error': str(e)
}), 500
+612
View File
@@ -0,0 +1,612 @@
"""Players blueprint for player management and display."""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required
from werkzeug.security import generate_password_hash
import secrets
from typing import Optional, List
from app.extensions import db, cache
from app.models import Player, Content, PlayerFeedback, Playlist
from app.utils.logger import log_action
from app.utils.group_player_management import get_player_status_info
players_bp = Blueprint('players', __name__, url_prefix='/players')
@players_bp.route('/')
@players_bp.route('/list')
@login_required
def list():
"""Display list of all players."""
try:
players = Player.query.order_by(Player.name).all()
playlists = Playlist.query.all()
# Get player status for each player
player_statuses = {}
for player in players:
status_info = get_player_status_info(player.id)
player_statuses[player.id] = status_info
return render_template('players/players_list.html',
players=players,
playlists=playlists,
player_statuses=player_statuses)
except Exception as e:
log_action('error', f'Error loading players list: {str(e)}')
flash('Error loading players list.', 'danger')
return redirect(url_for('main.dashboard'))
@players_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add_player():
"""Add a new player."""
if request.method == 'GET':
playlists = Playlist.query.filter_by(is_active=True).order_by(Playlist.name).all()
return render_template('players/add_player.html', playlists=playlists)
try:
name = request.form.get('name', '').strip()
hostname = request.form.get('hostname', '').strip()
location = request.form.get('location', '').strip()
password = request.form.get('password', '').strip()
quickconnect_code = request.form.get('quickconnect_code', '').strip()
orientation = request.form.get('orientation', 'Landscape')
playlist_id = request.form.get('playlist_id', '').strip()
# Validation
if not name or len(name) < 3:
flash('Player name must be at least 3 characters long.', 'warning')
return redirect(url_for('players.add_player'))
if not hostname or len(hostname) < 3:
flash('Hostname must be at least 3 characters long.', 'warning')
return redirect(url_for('players.add_player'))
# Check if hostname already exists
existing_player = Player.query.filter_by(hostname=hostname).first()
if existing_player:
flash(f'A player with hostname "{hostname}" already exists.', 'warning')
return redirect(url_for('players.add_player'))
if not quickconnect_code:
flash('Quick Connect Code is required.', 'warning')
return redirect(url_for('players.add_player'))
# Generate unique auth code
auth_code = secrets.token_urlsafe(32)
# Create player
new_player = Player(
name=name,
hostname=hostname,
location=location or None,
auth_code=auth_code,
orientation=orientation,
playlist_id=int(playlist_id) if playlist_id else None
)
# Set password if provided
if password:
new_player.set_password(password)
else:
# Use quickconnect code as default password
new_player.set_password(quickconnect_code)
# Set quickconnect code
new_player.set_quickconnect_code(quickconnect_code)
db.session.add(new_player)
db.session.commit()
log_action('info', f'Player "{name}" (hostname: {hostname}) created')
# Flash detailed success message
success_msg = f'''
Player "{name}" created successfully!<br>
<strong>Auth Code:</strong> {auth_code}<br>
<strong>Hostname:</strong> {hostname}<br>
<strong>Quick Connect:</strong> {quickconnect_code}<br>
<small>Configure the player with these credentials in app_config.json</small>
'''
flash(success_msg, 'success')
return redirect(url_for('players.list'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error creating player: {str(e)}')
flash('Error creating player. Please try again.', 'danger')
return redirect(url_for('players.add_player'))
@players_bp.route('/<int:player_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_player(player_id: int):
"""Edit player details."""
player = Player.query.get_or_404(player_id)
if request.method == 'GET':
return render_template('players/edit_player.html', player=player)
try:
name = request.form.get('name', '').strip()
location = request.form.get('location', '').strip()
# Validation
if not name or len(name) < 3:
flash('Player name must be at least 3 characters long.', 'warning')
return redirect(url_for('players.edit_player', player_id=player_id))
# Update player
player.name = name
player.location = location or None
db.session.commit()
# Clear cache for this player
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Player "{name}" (ID: {player_id}) updated')
flash(f'Player "{name}" updated successfully.', 'success')
return redirect(url_for('players.list'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating player: {str(e)}')
flash('Error updating player. Please try again.', 'danger')
return redirect(url_for('players.edit_player', player_id=player_id))
@players_bp.route('/<int:player_id>/delete', methods=['POST'])
@login_required
def delete_player(player_id: int):
"""Delete a player."""
try:
player = Player.query.get_or_404(player_id)
player_name = player.name
# Delete associated feedback
PlayerFeedback.query.filter_by(player_id=player_id).delete()
db.session.delete(player)
db.session.commit()
# Clear cache
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Player "{player_name}" (ID: {player_id}) deleted')
flash(f'Player "{player_name}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting player: {str(e)}')
flash('Error deleting player. Please try again.', 'danger')
return redirect(url_for('players.list'))
@players_bp.route('/<int:player_id>/regenerate-auth', methods=['POST'])
@login_required
def regenerate_auth_code(player_id: int):
"""Regenerate authentication code for a player."""
try:
player = Player.query.get_or_404(player_id)
# Generate new auth code
new_auth_code = secrets.token_urlsafe(16)
player.auth_code = new_auth_code
db.session.commit()
log_action('info', f'Auth code regenerated for player "{player.name}" (ID: {player_id})')
flash(f'New auth code for "{player.name}": {new_auth_code}', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error regenerating auth code: {str(e)}')
flash('Error regenerating auth code. Please try again.', 'danger')
return redirect(url_for('players.list'))
@players_bp.route('/<int:player_id>')
@login_required
def player_page(player_id: int):
"""Redirect to manage player page (combined view)."""
return redirect(url_for('players.manage_player', player_id=player_id))
@players_bp.route('/<int:player_id>/manage', methods=['GET', 'POST'])
@login_required
def manage_player(player_id: int):
"""Manage player - edit credentials, assign playlist, view logs."""
player = Player.query.get_or_404(player_id)
if request.method == 'POST':
action = request.form.get('action')
try:
if action == 'update_credentials':
# Update player name, location, orientation, and authentication
name = request.form.get('name', '').strip()
location = request.form.get('location', '').strip()
orientation = request.form.get('orientation', 'Landscape')
hostname = request.form.get('hostname', '').strip()
password = request.form.get('password', '').strip()
quickconnect_code = request.form.get('quickconnect_code', '').strip()
if not name or len(name) < 3:
flash('Player name must be at least 3 characters long.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
if not hostname or len(hostname) < 3:
flash('Hostname must be at least 3 characters long.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
# Check if hostname is taken by another player
if hostname != player.hostname:
existing = Player.query.filter_by(hostname=hostname).first()
if existing:
flash(f'Hostname "{hostname}" is already in use by another player.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
# Update basic info
player.name = name
player.hostname = hostname
player.location = location or None
player.orientation = orientation
# Update password if provided
if password:
player.set_password(password)
log_action('info', f'Password updated for player "{name}"')
# Update quickconnect code if provided
if quickconnect_code:
player.set_quickconnect_code(quickconnect_code)
log_action('info', f'QuickConnect code updated for player "{name}" to: {quickconnect_code}')
db.session.commit()
log_action('info', f'Player "{name}" (hostname: {hostname}) credentials updated')
flash(f'Player "{name}" updated successfully.', 'success')
elif action == 'assign_playlist':
# Assign playlist to player
playlist_id = request.form.get('playlist_id')
if playlist_id:
playlist = Playlist.query.get(int(playlist_id))
if playlist:
player.playlist_id = int(playlist_id)
db.session.commit()
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Player "{player.name}" assigned to playlist "{playlist.name}"')
flash(f'Player assigned to playlist "{playlist.name}".', 'success')
else:
flash('Invalid playlist selected.', 'warning')
else:
# Unassign playlist
player.playlist_id = None
db.session.commit()
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Player "{player.name}" unassigned from playlist')
flash('Player unassigned from playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error managing player: {str(e)}')
flash('Error updating player. Please try again.', 'danger')
return redirect(url_for('players.manage_player', player_id=player_id))
# GET request - show manage page
playlists = Playlist.query.order_by(Playlist.name).all()
# Get player's current playlist
current_playlist = None
if player.playlist_id:
current_playlist = Playlist.query.get(player.playlist_id)
# Get recent feedback/logs from player
recent_logs = PlayerFeedback.query.filter_by(player_id=player_id)\
.order_by(PlayerFeedback.timestamp.desc())\
.limit(20)\
.all()
# Get edited media history from player
from app.models.player_edit import PlayerEdit
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
.order_by(PlayerEdit.created_at.desc())\
.limit(20)\
.all()
# Get player status
status_info = get_player_status_info(player_id)
return render_template('players/manage_player.html',
player=player,
playlists=playlists,
current_playlist=current_playlist,
recent_logs=recent_logs,
edited_media=edited_media,
status_info=status_info)
@players_bp.route('/<int:player_id>/edited-media')
@login_required
def edited_media(player_id: int):
"""Display all edited media files from this player."""
try:
player = Player.query.get_or_404(player_id)
# Get all edited media history from player
from app.models.player_edit import PlayerEdit
from app.models.player_user import PlayerUser
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
.order_by(PlayerEdit.created_at.desc())\
.all()
# Get original content files for each edited media
content_files = {}
for edit in edited_media:
if edit.content_id not in content_files:
content = Content.query.get(edit.content_id)
if content:
content_files[edit.content_id] = content
# Get user mappings for display names
user_mappings = {}
for edit in edited_media:
if edit.user and edit.user not in user_mappings:
player_user = PlayerUser.query.filter_by(user_code=edit.user).first()
if player_user:
user_mappings[edit.user] = player_user.user_name or edit.user
else:
user_mappings[edit.user] = edit.user
return render_template('players/edited_media.html',
player=player,
edited_media=edited_media,
content_files=content_files,
user_mappings=user_mappings)
except Exception as e:
log_action('error', f'Error loading edited media for player {player_id}: {str(e)}')
flash('Error loading edited media.', 'danger')
return redirect(url_for('players.manage_player', player_id=player_id))
@players_bp.route('/<int:player_id>/fullscreen')
def player_fullscreen(player_id: int):
"""Display player fullscreen view (no authentication required for players)."""
try:
player = Player.query.get_or_404(player_id)
# Verify auth code if provided
auth_code = request.args.get('auth')
if auth_code and auth_code != player.auth_code:
log_action('warning', f'Invalid auth code attempt for player {player_id}')
return "Invalid authentication code", 403
# Get player's playlist
playlist = get_player_playlist(player_id)
return render_template('players/player_fullscreen.html',
player=player,
playlist=playlist)
except Exception as e:
log_action('error', f'Error loading player fullscreen: {str(e)}')
return "Error loading player", 500
@cache.memoize(timeout=300) # Cache for 5 minutes
def get_player_playlist(player_id: int) -> List[dict]:
"""Get playlist for a player based on their assigned playlist.
Args:
player_id: The player's database ID
Returns:
List of content dictionaries with url, type, duration, and position
"""
player = Player.query.get(player_id)
if not player or not player.playlist_id:
return []
# Get the player's assigned playlist
playlist_obj = Playlist.query.get(player.playlist_id)
if not playlist_obj:
return []
# Get ordered content from the playlist
ordered_content = playlist_obj.get_content_ordered()
# Build playlist
playlist = []
for content in ordered_content:
playlist.append({
'id': content.id,
'url': url_for('static', filename=f'uploads/{content.filename}'),
'type': content.content_type,
'duration': getattr(content, '_playlist_duration', content.duration or 10),
'position': getattr(content, '_playlist_position', 0),
'muted': getattr(content, '_playlist_muted', True),
'filename': content.filename
})
return playlist
@players_bp.route('/<int:player_id>/reorder', methods=['POST'])
@login_required
def reorder_content(player_id: int):
"""Legacy endpoint - Content reordering now handled in playlist management."""
return jsonify({
'success': False,
'error': 'Content reordering is now managed through playlists. Use the Playlists page to reorder content.'
}), 400
@players_bp.route('/bulk/delete', methods=['POST'])
@login_required
def bulk_delete_players():
"""Delete multiple players at once."""
try:
player_ids = request.json.get('player_ids', [])
if not player_ids:
return jsonify({'success': False, 'error': 'No players selected'}), 400
# Delete players
deleted_count = 0
for player_id in player_ids:
player = Player.query.get(player_id)
if player:
# Delete associated feedback
PlayerFeedback.query.filter_by(player_id=player_id).delete()
db.session.delete(player)
cache.delete_memoized(get_player_playlist, player_id)
deleted_count += 1
db.session.commit()
log_action('info', f'Bulk deleted {deleted_count} players')
return jsonify({'success': True, 'deleted': deleted_count})
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk deleting players: {str(e)}')
return jsonify({'success': False, 'error': str(e)}), 500
@players_bp.route('/bulk/assign-playlist', methods=['POST'])
@login_required
def bulk_assign_playlist():
"""Assign multiple players to a playlist."""
try:
player_ids = request.json.get('player_ids', [])
playlist_id = request.json.get('playlist_id')
if not player_ids:
return jsonify({'success': False, 'error': 'No players selected'}), 400
# Validate playlist
if playlist_id:
playlist = Playlist.query.get(playlist_id)
if not playlist:
return jsonify({'success': False, 'error': 'Invalid playlist'}), 400
# Assign players
updated_count = 0
for player_id in player_ids:
player = Player.query.get(player_id)
if player:
player.playlist_id = playlist_id
cache.delete_memoized(get_player_playlist, player_id)
updated_count += 1
db.session.commit()
log_action('info', f'Bulk assigned {updated_count} players to playlist {playlist_id}')
return jsonify({'success': True, 'updated': updated_count})
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk assigning players: {str(e)}')
return jsonify({'success': False, 'error': str(e)}), 500
@players_bp.route('/<int:player_id>/playlist/reorder', methods=['POST'])
@login_required
def reorder_playlist(player_id: int):
"""Reorder items in player's playlist."""
try:
data = request.get_json()
content_id = data.get('content_id')
direction = data.get('direction') # 'up' or 'down'
if not content_id or not direction:
return jsonify({'success': False, 'message': 'Missing parameters'}), 400
# Get the content item
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if not content:
return jsonify({'success': False, 'message': 'Content not found'}), 404
# Get all content for this player, ordered by position
all_content = Content.query.filter_by(player_id=player_id)\
.order_by(Content.position, Content.uploaded_at).all()
# Find current index
current_index = None
for idx, item in enumerate(all_content):
if item.id == content_id:
current_index = idx
break
if current_index is None:
return jsonify({'success': False, 'message': 'Content not in playlist'}), 404
# Swap positions
if direction == 'up' and current_index > 0:
# Swap with previous item
all_content[current_index].position, all_content[current_index - 1].position = \
all_content[current_index - 1].position, all_content[current_index].position
elif direction == 'down' and current_index < len(all_content) - 1:
# Swap with next item
all_content[current_index].position, all_content[current_index + 1].position = \
all_content[current_index + 1].position, all_content[current_index].position
db.session.commit()
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Reordered playlist for player {player_id}')
return jsonify({'success': True})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@players_bp.route('/<int:player_id>/playlist/remove', methods=['POST'])
@login_required
def remove_from_playlist(player_id: int):
"""Remove content from player's playlist."""
try:
data = request.get_json()
content_id = data.get('content_id')
if not content_id:
return jsonify({'success': False, 'message': 'Missing content_id'}), 400
# Get the content item
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if not content:
return jsonify({'success': False, 'message': 'Content not found'}), 404
filename = content.filename
# Delete from database
db.session.delete(content)
# Increment playlist version
player = Player.query.get(player_id)
if player:
player.playlist_version += 1
db.session.commit()
# Clear cache
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Removed "{filename}" from player {player_id} playlist (version {player.playlist_version})')
return jsonify({'success': True, 'message': f'Removed "{filename}" from playlist'})
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing from playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
+310
View File
@@ -0,0 +1,310 @@
"""Playlist blueprint for managing player playlists."""
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app)
from flask_login import login_required
from sqlalchemy import desc, update
import os
from app.extensions import db, cache
from app.models import Player, Content, Playlist
from app.models.playlist import playlist_content
from app.utils.logger import log_action
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
@playlist_bp.route('/<int:player_id>')
@login_required
def manage_playlist(player_id: int):
"""Legacy route - redirect to new content management area."""
player = Player.query.get_or_404(player_id)
if player.playlist_id:
# Redirect to the new content management interface
return redirect(url_for('content.manage_playlist_content', playlist_id=player.playlist_id))
else:
# Player has no playlist assigned
flash('This player has no playlist assigned.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
@login_required
def add_to_playlist(player_id: int):
"""Add content to player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
content_id = request.form.get('content_id', type=int)
duration = request.form.get('duration', type=int, default=10)
if not content_id:
flash('Please select content.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
content = Content.query.get_or_404(content_id)
playlist = Playlist.query.get(player.playlist_id)
# Get max position
from sqlalchemy import select, func
max_pos = db.session.execute(
select(func.max(playlist_content.c.position)).where(
playlist_content.c.playlist_id == playlist.id
)
).scalar() or 0
# Add to playlist_content association table
stmt = playlist_content.insert().values(
playlist_id=playlist.id,
content_id=content.id,
position=max_pos + 1,
duration=duration
)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Added "{content.filename}" to playlist for player "{player.name}"')
flash(f'Added "{content.filename}" to playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error adding to playlist: {str(e)}')
flash('Error adding to playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
@playlist_bp.route('/<int:player_id>/remove/<int:content_id>', methods=['POST'])
@login_required
def remove_from_playlist(player_id: int, content_id: int):
"""Remove content from player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
content = Content.query.get_or_404(content_id)
playlist = Playlist.query.get(player.playlist_id)
filename = content.filename
# Remove from playlist_content association table
from sqlalchemy import delete
stmt = delete(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
)
db.session.execute(stmt)
# Reorder remaining content
from sqlalchemy import select
remaining = db.session.execute(
select(playlist_content.c.content_id, playlist_content.c.position).where(
playlist_content.c.playlist_id == playlist.id
).order_by(playlist_content.c.position)
).fetchall()
for idx, row in enumerate(remaining, start=1):
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == row.content_id)
).values(position=idx)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Removed "{filename}" from playlist for player "{player.name}"')
flash(f'Removed "{filename}" from playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing from playlist: {str(e)}')
flash('Error removing from playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
@playlist_bp.route('/<int:player_id>/reorder', methods=['POST'])
@login_required
def reorder_playlist(player_id: int):
"""Reorder playlist items."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try:
playlist = Playlist.query.get(player.playlist_id)
# Get new order from JSON
data = request.get_json()
content_ids = data.get('content_ids', [])
if not content_ids:
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
# Update positions in association table
for idx, content_id in enumerate(content_ids, start=1):
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(position=idx)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Reordered playlist for player "{player.name}" (version {playlist.version})')
return jsonify({
'success': True,
'message': 'Playlist reordered successfully',
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/update-duration/<int:content_id>', methods=['POST'])
@login_required
def update_duration(player_id: int, content_id: int):
"""Update content duration in playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try:
playlist = Playlist.query.get(player.playlist_id)
content = Content.query.get_or_404(content_id)
duration = request.form.get('duration', type=int)
if not duration or duration < 1:
return jsonify({'success': False, 'message': 'Invalid duration'}), 400
# Update duration in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(duration=duration)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated duration for "{content.filename}" in player "{player.name}" playlist')
return jsonify({
'success': True,
'message': 'Duration updated',
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating duration: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/update-muted/<int:content_id>', methods=['POST'])
@login_required
def update_muted(player_id: int, content_id: int):
"""Update content muted setting in playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try:
playlist = Playlist.query.get(player.playlist_id)
content = Content.query.get_or_404(content_id)
muted = request.form.get('muted', 'true').lower() == 'true'
# Update muted in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(muted=muted)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated muted={muted} for "{content.filename}" in player "{player.name}" playlist')
return jsonify({
'success': True,
'message': 'Audio setting updated',
'muted': muted,
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating muted setting: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/clear', methods=['POST'])
@login_required
def clear_playlist(player_id: int):
"""Clear all content from player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
playlist = Playlist.query.get(player.playlist_id)
# Delete all content from playlist
from sqlalchemy import delete
stmt = delete(playlist_content).where(
playlist_content.c.playlist_id == playlist.id
)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Cleared playlist for player "{player.name}"')
flash('Playlist cleared successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error clearing playlist: {str(e)}')
flash('Error clearing playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
+138
View File
@@ -0,0 +1,138 @@
"""
Configuration settings for DigiServer v2
Environment-based configuration with sensible defaults
"""
import os
from datetime import timedelta
class Config:
"""Base configuration"""
# Basic Flask config
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
# Database
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False
# File Upload - use absolute paths
MAX_CONTENT_LENGTH = 2048 * 1024 * 1024 # 2GB
_basedir = os.path.abspath(os.path.dirname(__file__))
UPLOAD_FOLDER = os.path.join(_basedir, 'static', 'uploads')
UPLOAD_FOLDERLOGO = os.path.join(_basedir, 'static', 'resurse')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'mp4', 'avi', 'mkv', 'mov', 'webm', 'pdf', 'ppt', 'pptx'}
# Session
PERMANENT_SESSION_LIFETIME = timedelta(minutes=30)
SESSION_COOKIE_SECURE = False # Set to True in production with HTTPS
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
# Reverse proxy trust (for Nginx/Caddy with ProxyFix middleware)
# These are set by werkzeug.middleware.proxy_fix
TRUSTED_PROXIES = os.getenv('TRUSTED_PROXIES', '127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16')
PREFERRED_URL_SCHEME = os.getenv('PREFERRED_URL_SCHEME', 'https')
# Cache
SEND_FILE_MAX_AGE_DEFAULT = 300 # 5 minutes for static files
# Server Info
SERVER_VERSION = "2.0.0"
BUILD_DATE = "2025-11-12"
# Pagination
ITEMS_PER_PAGE = 20
# Admin defaults
DEFAULT_ADMIN_USER = os.getenv('ADMIN_USER', 'admin')
DEFAULT_ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'Initial01!')
# Portal internal sync secret — must match INTERNAL_SYNC_SECRET in portal config
INTERNAL_SYNC_SECRET = os.getenv('INTERNAL_SYNC_SECRET', 'change-this-internal-secret')
# URL of the portal login page — users are redirected here if they try to
# access DigiServer directly without a portal session.
PORTAL_LOGIN_URL = os.getenv('PORTAL_LOGIN_URL', 'http://localhost:8080/login')
# Set to True to disable DigiServer's own user registration and management UI.
# When True, all user accounts are managed exclusively through the portal.
PORTAL_MANAGED_USERS = True
class DevelopmentConfig(Config):
"""Development configuration"""
DEBUG = True
TESTING = False
# Database - construct absolute path
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
SQLALCHEMY_DATABASE_URI = os.getenv(
'DATABASE_URL',
f'sqlite:///{os.path.join(_basedir, "instance", "dev.db")}'
)
# Cache (simple in-memory for development)
CACHE_TYPE = 'simple'
CACHE_DEFAULT_TIMEOUT = 60
# Security (relaxed for development)
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = None
class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
TESTING = False
TEMPLATES_AUTO_RELOAD = True # Force template reload
# Database - construct absolute path
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
SQLALCHEMY_DATABASE_URI = os.getenv(
'DATABASE_URL',
f'sqlite:///{os.path.join(_basedir, "instance", "dashboard.db")}'
)
# Cache - use simple cache instead of Redis
CACHE_TYPE = 'simple'
CACHE_DEFAULT_TIMEOUT = 300
# Security
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax'
WTF_CSRF_ENABLED = True
class TestingConfig(Config):
"""Testing configuration"""
DEBUG = True
TESTING = True
# Database (in-memory for tests)
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
# Cache (simple for tests)
CACHE_TYPE = 'simple'
# Security (disabled for tests)
WTF_CSRF_ENABLED = False
# Configuration dictionary
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig
}
def get_config(env=None):
"""Get configuration based on environment"""
if env is None:
env = os.getenv('FLASK_ENV', 'development')
return config.get(env, config['default'])
+23
View File
@@ -0,0 +1,23 @@
"""
Flask extensions initialization
Centralized extension management for the application
"""
from flask_sqlalchemy import SQLAlchemy
from flask_bcrypt import Bcrypt
from flask_login import LoginManager
from flask_migrate import Migrate
from flask_caching import Cache
from flask_cors import CORS
# Initialize extensions (will be bound to app in create_app)
db = SQLAlchemy()
bcrypt = Bcrypt()
login_manager = LoginManager()
migrate = Migrate()
cache = Cache()
cors = CORS()
# Configure login manager
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'info'
+26
View File
@@ -0,0 +1,26 @@
"""Models package for digiserver-v2."""
from app.models.user import User
from app.models.player import Player
from app.models.group import Group, group_content
from app.models.playlist import Playlist, playlist_content
from app.models.content import Content
from app.models.server_log import ServerLog
from app.models.player_feedback import PlayerFeedback
from app.models.player_edit import PlayerEdit
from app.models.player_user import PlayerUser
from app.models.https_config import HTTPSConfig
__all__ = [
'User',
'Player',
'Group',
'Playlist',
'Content',
'ServerLog',
'PlayerFeedback',
'PlayerEdit',
'PlayerUser',
'HTTPSConfig',
'group_content',
'playlist_content',
]
+63
View File
@@ -0,0 +1,63 @@
"""Content model for media files."""
from datetime import datetime
from typing import Optional, List
from app.extensions import db
class Content(db.Model):
"""Content model representing media files for display.
Attributes:
id: Primary key
filename: Original filename
content_type: Type of content (image, video, pdf, presentation, other)
duration: Default display duration in seconds
file_size: File size in bytes
description: Optional content description
uploaded_at: Upload timestamp
"""
__tablename__ = 'content'
id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255), nullable=False, unique=True, index=True)
content_type = db.Column(db.String(50), nullable=False, index=True)
duration = db.Column(db.Integer, default=10, nullable=True)
file_size = db.Column(db.BigInteger, nullable=True)
description = db.Column(db.Text, nullable=True)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow,
nullable=False, index=True)
# Relationships - many-to-many with playlists
playlists = db.relationship('Playlist', secondary='playlist_content',
back_populates='contents', lazy='dynamic')
groups = db.relationship('Group', secondary='group_content',
back_populates='contents', lazy='dynamic')
def __repr__(self) -> str:
"""String representation of Content."""
return f'<Content {self.filename} (Type={self.content_type})>'
@property
def file_size_mb(self) -> float:
"""Get file size in megabytes."""
if self.file_size:
return round(self.file_size / (1024 * 1024), 2)
return 0.0
@property
def group_count(self) -> int:
"""Get number of groups containing this content."""
return self.groups.count()
def is_image(self) -> bool:
"""Check if content is an image."""
return self.content_type == 'image'
def is_video(self) -> bool:
"""Check if content is a video."""
return self.content_type == 'video'
def is_pdf(self) -> bool:
"""Check if content is a PDF."""
return self.content_type == 'pdf'
+66
View File
@@ -0,0 +1,66 @@
"""Group model for organizing players and content."""
from datetime import datetime
from typing import List, Optional
from app.extensions import db
# Association table for many-to-many relationship between groups and content
group_content = db.Table('group_content',
db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True),
db.Column('content_id', db.Integer, db.ForeignKey('content.id'), primary_key=True)
)
class Group(db.Model):
"""Group model for organizing players with shared content.
Attributes:
id: Primary key
name: Unique group name
description: Optional group description
created_at: Group creation timestamp
updated_at: Last modification timestamp
"""
__tablename__ = 'group'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True, index=True)
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
# Relationships
contents = db.relationship('Content', secondary=group_content,
back_populates='groups', lazy='dynamic')
def __repr__(self) -> str:
"""String representation of Group."""
return f'<Group {self.name} (ID={self.id})>'
@property
def content_count(self) -> int:
"""Get number of content items in this group."""
return self.contents.count()
def add_player(self, player) -> None:
"""Add a player to this group.
Args:
player: Player instance to add
"""
player.group_id = self.id
self.updated_at = datetime.utcnow()
def remove_player(self, player) -> None:
"""Remove a player from this group.
Args:
player: Player instance to remove
"""
if player.group_id == self.id:
player.group_id = None
self.updated_at = datetime.utcnow()
+104
View File
@@ -0,0 +1,104 @@
"""HTTPS Configuration model."""
from datetime import datetime
from typing import Optional
from app.extensions import db
class HTTPSConfig(db.Model):
"""HTTPS configuration model for managing secure connections.
Attributes:
id: Primary key
https_enabled: Whether HTTPS is enabled
hostname: Server hostname (e.g., 'digiserver')
domain: Full domain name (e.g., 'digiserver.sibiusb.harting.intra')
ip_address: IP address for direct access
email: Email address for SSL certificate notifications
port: HTTPS port (default 443)
created_at: Creation timestamp
updated_at: Last update timestamp
updated_by: User who made the last update
"""
__tablename__ = 'https_config'
id = db.Column(db.Integer, primary_key=True)
https_enabled = db.Column(db.Boolean, default=False, nullable=False)
hostname = db.Column(db.String(255), nullable=True)
domain = db.Column(db.String(255), nullable=True)
ip_address = db.Column(db.String(45), nullable=True) # Support IPv6
email = db.Column(db.String(255), nullable=True)
port = db.Column(db.Integer, default=443, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
updated_by = db.Column(db.String(255), nullable=True)
def __repr__(self) -> str:
"""String representation of HTTPSConfig."""
status = 'ENABLED' if self.https_enabled else 'DISABLED'
return f'<HTTPSConfig [{status}] {self.domain or "N/A"}>'
@classmethod
def get_config(cls) -> Optional['HTTPSConfig']:
"""Get the current HTTPS configuration.
Returns:
HTTPSConfig instance or None if not configured
"""
return cls.query.first()
@classmethod
def create_or_update(cls, https_enabled: bool, hostname: str = None,
domain: str = None, ip_address: str = None,
email: str = None, port: int = 443,
updated_by: str = None) -> 'HTTPSConfig':
"""Create or update HTTPS configuration.
Args:
https_enabled: Whether HTTPS is enabled
hostname: Server hostname
domain: Full domain name
ip_address: IP address
email: Email for SSL certificates
port: HTTPS port
updated_by: Username of who made the update
Returns:
HTTPSConfig instance
"""
config = cls.get_config()
if not config:
config = cls()
config.https_enabled = https_enabled
config.hostname = hostname
config.domain = domain
config.ip_address = ip_address
config.email = email
config.port = port
config.updated_by = updated_by
config.updated_at = datetime.utcnow()
db.session.add(config)
db.session.commit()
return config
def to_dict(self) -> dict:
"""Convert configuration to dictionary.
Returns:
Dictionary representation of config
"""
return {
'id': self.id,
'https_enabled': self.https_enabled,
'hostname': self.hostname,
'domain': self.domain,
'ip_address': self.ip_address,
'email': self.email,
'port': self.port,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'updated_by': self.updated_by,
}
+138
View File
@@ -0,0 +1,138 @@
"""Player model for digital signage players."""
from datetime import datetime
from typing import Optional, List
from app.extensions import db
class Player(db.Model):
"""Player model representing a digital signage device.
Attributes:
id: Primary key
name: Display name for the player
hostname: Unique hostname/identifier for the player
location: Physical location description
auth_code: Authentication code for API access (legacy)
password_hash: Hashed password for player authentication
quickconnect_code: Hashed quick connect code for easy pairing
orientation: Display orientation (Landscape/Portrait)
status: Current player status (online, offline, error)
last_seen: Last activity timestamp
playlist_version: Version number for playlist synchronization
created_at: Player creation timestamp
"""
__tablename__ = 'player'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
hostname = db.Column(db.String(255), unique=True, nullable=False, index=True)
location = db.Column(db.String(255), nullable=True)
auth_code = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
quickconnect_code = db.Column(db.String(255), nullable=True)
orientation = db.Column(db.String(16), default='Landscape', nullable=False)
status = db.Column(db.String(50), default='offline', index=True)
last_seen = db.Column(db.DateTime, nullable=True, index=True)
last_heartbeat = db.Column(db.DateTime, nullable=True, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Playlist assignment
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
nullable=True, index=True)
# Relationships
playlist = db.relationship('Playlist', back_populates='players')
feedback = db.relationship('PlayerFeedback', back_populates='player',
cascade='all, delete-orphan', lazy='dynamic')
def __repr__(self) -> str:
"""String representation of Player."""
return f'<Player {self.name} (ID={self.id}, Status={self.status})>'
@property
def is_online(self) -> bool:
"""Check if player is online (seen in last 5 minutes)."""
if not self.last_seen:
return False
delta = datetime.utcnow() - self.last_seen
return delta.total_seconds() < 300 # 5 minutes
def update_status(self, status: str) -> None:
"""Update player status and last seen timestamp.
Args:
status: New status value
"""
self.status = status
self.last_seen = datetime.utcnow()
def set_password(self, password: str) -> None:
"""Set player password with bcrypt hashing.
Args:
password: Plain text password
"""
from app.extensions import bcrypt
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password: str) -> bool:
"""Verify player password.
Args:
password: Plain text password to check
Returns:
True if password matches, False otherwise
"""
from app.extensions import bcrypt
return bcrypt.check_password_hash(self.password_hash, password)
def set_quickconnect_code(self, code: str) -> None:
"""Set quick connect code with bcrypt hashing.
Args:
code: Plain text quick connect code
"""
from app.extensions import bcrypt
self.quickconnect_code = bcrypt.generate_password_hash(code).decode('utf-8')
def check_quickconnect_code(self, code: str) -> bool:
"""Verify quick connect code.
Args:
code: Plain text code to check
Returns:
True if code matches, False otherwise
"""
if not self.quickconnect_code:
return False
from app.extensions import bcrypt
return bcrypt.check_password_hash(self.quickconnect_code, code)
@staticmethod
def authenticate(hostname: str, password: str = None, quickconnect_code: str = None) -> Optional['Player']:
"""Authenticate a player by hostname and password or quickconnect code.
Args:
hostname: Player hostname
password: Player password (optional if using quickconnect)
quickconnect_code: Quick connect code (optional if using password)
Returns:
Player instance if authentication successful, None otherwise
"""
player = Player.query.filter_by(hostname=hostname).first()
if not player:
return None
# Try password authentication first
if password and player.check_password(password):
return player
# Try quickconnect code authentication
if quickconnect_code and player.check_quickconnect_code(quickconnect_code):
return player
return None
+60
View File
@@ -0,0 +1,60 @@
"""Player edit model for tracking media edited on players."""
from datetime import datetime
from typing import Optional
from app.extensions import db
class PlayerEdit(db.Model):
"""Player edit model for tracking media files edited on player devices.
Attributes:
id: Primary key
player_id: Foreign key to player
content_id: Foreign key to content that was edited
original_name: Original filename
new_name: New filename after editing
version: Edit version number (v1, v2, etc.)
user: User who made the edit (from player)
time_of_modification: When the edit was made
metadata_path: Path to the metadata JSON file
edited_file_path: Path to the edited file
created_at: Record creation timestamp
"""
__tablename__ = 'player_edit'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('player.id', ondelete='CASCADE'), nullable=False, index=True)
content_id = db.Column(db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), nullable=False, index=True)
original_name = db.Column(db.String(255), nullable=False)
new_name = db.Column(db.String(255), nullable=False)
version = db.Column(db.Integer, default=1, nullable=False)
user = db.Column(db.String(255), nullable=True)
time_of_modification = db.Column(db.DateTime, nullable=True)
metadata_path = db.Column(db.String(512), nullable=True)
edited_file_path = db.Column(db.String(512), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
# Relationships
player = db.relationship('Player', backref=db.backref('edits', lazy='dynamic', cascade='all, delete-orphan'))
content = db.relationship('Content', backref=db.backref('edits', lazy='dynamic', cascade='all, delete-orphan'))
def __repr__(self) -> str:
"""String representation of PlayerEdit."""
return f'<PlayerEdit {self.original_name} v{self.version} by {self.user or "unknown"}>'
def to_dict(self) -> dict:
"""Convert to dictionary for API responses."""
return {
'id': self.id,
'player_id': self.player_id,
'player_name': self.player.name if self.player else None,
'content_id': self.content_id,
'original_name': self.original_name,
'new_name': self.new_name,
'version': self.version,
'user': self.user,
'time_of_modification': self.time_of_modification.isoformat() if self.time_of_modification else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'edited_file_path': self.edited_file_path
}
@@ -0,0 +1,64 @@
"""Player feedback model for tracking player status and errors."""
from datetime import datetime
from typing import Optional
from app.extensions import db
class PlayerFeedback(db.Model):
"""Player feedback model for tracking player status updates.
Attributes:
id: Primary key
player_id: Foreign key to player
status: Current status (playing, paused, error, unknown)
current_content_id: ID of currently playing content
message: Optional status message
error: Optional error message
timestamp: Feedback timestamp
"""
__tablename__ = 'player_feedback'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('player.id'),
nullable=False, index=True)
status = db.Column(db.String(50), nullable=False, default='unknown')
current_content_id = db.Column(db.Integer, db.ForeignKey('content.id'),
nullable=True)
message = db.Column(db.Text, nullable=True)
error = db.Column(db.Text, nullable=True)
timestamp = db.Column(db.DateTime, default=datetime.utcnow,
nullable=False, index=True)
# Relationships
player = db.relationship('Player', back_populates='feedback')
content = db.relationship('Content', backref='feedback_entries')
def __repr__(self) -> str:
"""String representation of PlayerFeedback."""
return f'<PlayerFeedback Player={self.player_id} Status={self.status}>'
@property
def is_error(self) -> bool:
"""Check if feedback indicates an error."""
return self.status == 'error' or self.error is not None
@property
def age_seconds(self) -> float:
"""Get age of feedback in seconds."""
delta = datetime.utcnow() - self.timestamp
return delta.total_seconds()
@classmethod
def get_latest_for_player(cls, player_id: int) -> Optional['PlayerFeedback']:
"""Get most recent feedback for a player.
Args:
player_id: Player ID to query
Returns:
Latest PlayerFeedback instance or None
"""
return cls.query.filter_by(player_id=player_id)\
.order_by(cls.timestamp.desc())\
.first()
+37
View File
@@ -0,0 +1,37 @@
"""Player user model for managing user codes and names."""
from datetime import datetime
from app.extensions import db
class PlayerUser(db.Model):
"""Player user model for managing user codes and names globally.
Attributes:
id: Primary key
user_code: User code received from player (unique)
user_name: Display name for the user
created_at: Record creation timestamp
updated_at: Record update timestamp
"""
__tablename__ = 'player_user'
id = db.Column(db.Integer, primary_key=True)
user_code = db.Column(db.String(255), nullable=False, unique=True, index=True)
user_name = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __repr__(self) -> str:
"""String representation of PlayerUser."""
return f'<PlayerUser {self.user_code} -> {self.user_name or "Unnamed"}>'
def to_dict(self) -> dict:
"""Convert to dictionary for API responses."""
return {
'id': self.id,
'user_code': self.user_code,
'user_name': self.user_name,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
+103
View File
@@ -0,0 +1,103 @@
"""Playlist model for managing content collections."""
from datetime import datetime
from typing import List, Optional
from app.extensions import db
# Association table for many-to-many relationship between playlists and content
playlist_content = db.Table('playlist_content',
db.Column('playlist_id', db.Integer, db.ForeignKey('playlist.id', ondelete='CASCADE'), primary_key=True),
db.Column('content_id', db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), primary_key=True),
db.Column('position', db.Integer, default=0),
db.Column('duration', db.Integer, default=10),
db.Column('muted', db.Boolean, default=True),
db.Column('edit_on_player_enabled', db.Boolean, default=False)
)
class Playlist(db.Model):
"""Playlist model representing a collection of content.
Attributes:
id: Primary key
name: Unique playlist name
description: Optional playlist description
version: Version number for synchronization
is_active: Whether playlist is active
created_at: Playlist creation timestamp
updated_at: Last modification timestamp
"""
__tablename__ = 'playlist'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True, index=True)
description = db.Column(db.Text, nullable=True)
orientation = db.Column(db.String(20), default='Landscape', nullable=False)
version = db.Column(db.Integer, default=1, nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
# Relationships
players = db.relationship('Player', back_populates='playlist', lazy='dynamic')
contents = db.relationship('Content', secondary=playlist_content,
back_populates='playlists', lazy='dynamic')
def __repr__(self) -> str:
"""String representation of Playlist."""
return f'<Playlist {self.name} (ID={self.id}, Version={self.version})>'
@property
def player_count(self) -> int:
"""Get number of players assigned to this playlist."""
return self.players.count()
@property
def content_count(self) -> int:
"""Get number of content items in this playlist."""
return self.contents.count()
@property
def total_duration(self) -> int:
"""Calculate total duration of all content in seconds."""
total = 0
for content in self.contents:
total += content.duration or 10
return total
def increment_version(self) -> None:
"""Increment playlist version for sync detection."""
self.version += 1
self.updated_at = datetime.utcnow()
def get_content_ordered(self) -> List:
"""Get content items ordered by position."""
# Query through association table to get position
from sqlalchemy import select
stmt = select(playlist_content.c.content_id,
playlist_content.c.position,
playlist_content.c.duration,
playlist_content.c.muted,
playlist_content.c.edit_on_player_enabled).where(
playlist_content.c.playlist_id == self.id
).order_by(playlist_content.c.position)
results = db.session.execute(stmt).fetchall()
ordered_content = []
for row in results:
content = db.session.get(Content, row.content_id)
if content:
content._playlist_position = row.position
content._playlist_duration = row.duration
content._playlist_muted = row.muted if len(row) > 3 else True
content._playlist_edit_on_player_enabled = row.edit_on_player_enabled if len(row) > 4 else False
ordered_content.append(content)
return ordered_content
# Import Content here to avoid circular import
from app.models.content import Content
+72
View File
@@ -0,0 +1,72 @@
"""Server log model for audit trail."""
from datetime import datetime
from typing import Optional
from app.extensions import db
class ServerLog(db.Model):
"""Server log model for tracking system events.
Attributes:
id: Primary key
level: Log level (info, warning, error)
message: Log message content
timestamp: Event timestamp
"""
__tablename__ = 'server_log'
id = db.Column(db.Integer, primary_key=True)
level = db.Column(db.String(20), nullable=False, index=True, default='info')
message = db.Column(db.Text, nullable=False)
timestamp = db.Column(db.DateTime, default=datetime.utcnow,
nullable=False, index=True)
def __repr__(self) -> str:
"""String representation of ServerLog."""
return f'<ServerLog [{self.level.upper()}] {self.message[:50]}>'
@classmethod
def log_info(cls, message: str) -> 'ServerLog':
"""Create an info level log entry.
Args:
message: Log message
Returns:
ServerLog instance
"""
log = cls(level='info', message=message)
db.session.add(log)
db.session.commit()
return log
@classmethod
def log_warning(cls, message: str) -> 'ServerLog':
"""Create a warning level log entry.
Args:
message: Log message
Returns:
ServerLog instance
"""
log = cls(level='warning', message=message)
db.session.add(log)
db.session.commit()
return log
@classmethod
def log_error(cls, message: str) -> 'ServerLog':
"""Create an error level log entry.
Args:
message: Log message
Returns:
ServerLog instance
"""
log = cls(level='error', message=message)
db.session.add(log)
db.session.commit()
return log
+43
View File
@@ -0,0 +1,43 @@
"""User model for authentication and authorization."""
from datetime import datetime
from typing import Optional
from flask_login import UserMixin
from app.extensions import db
class User(db.Model, UserMixin):
"""User model for application authentication.
Attributes:
id: Primary key
username: Unique username for login
password: Bcrypt hashed password
role: User role (user or admin)
theme: UI theme preference (light or dark)
created_at: Account creation timestamp
last_login: Last successful login timestamp
"""
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
password = db.Column(db.String(120), nullable=False)
role = db.Column(db.String(20), nullable=False, default='user', index=True)
theme = db.Column(db.String(20), default='light')
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
last_login = db.Column(db.DateTime, nullable=True)
def __repr__(self) -> str:
"""String representation of User."""
return f'<User {self.username} (role={self.role})>'
@property
def is_admin(self) -> bool:
"""Check if user has admin role."""
return self.role == 'admin'
def update_last_login(self) -> None:
"""Update last login timestamp."""
self.last_login = datetime.utcnow()
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 294 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 311 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>

After

Width:  |  Height:  |  Size: 329 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="7" width="20" height="15" rx="2" ry="2"></rect>
<polyline points="17 2 12 7 7 2"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 301 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>

After

Width:  |  Height:  |  Size: 257 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 259 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>

After

Width:  |  Height:  |  Size: 651 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>

After

Width:  |  Height:  |  Size: 345 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>

After

Width:  |  Height:  |  Size: 396 B

@@ -0,0 +1,256 @@
{% extends "base.html" %}
{% block title %}Admin Panel - DigiServer v2{% endblock %}
{% block content %}
<h1>Admin Panel</h1>
<div class="dashboard-grid">
<!-- System Overview Card -->
<div class="card">
<h2>📊 System Overview</h2>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-icon">👥</div>
<div class="stat-content">
<span class="stat-label">Total Users</span>
<span class="stat-value">{{ total_users or 0 }}</span>
</div>
</div>
<div class="stat-item">
<div class="stat-icon">🖥️</div>
<div class="stat-content">
<span class="stat-label">Total Players</span>
<span class="stat-value">{{ total_players or 0 }}</span>
</div>
</div>
<div class="stat-item">
<div class="stat-icon">📋</div>
<div class="stat-content">
<span class="stat-label">Total Playlists</span>
<span class="stat-value">{{ total_playlists or 0 }}</span>
</div>
</div>
<div class="stat-item">
<div class="stat-icon">📁</div>
<div class="stat-content">
<span class="stat-label">Media Files</span>
<span class="stat-value">{{ total_content or 0 }}</span>
</div>
</div>
<div class="stat-item">
<div class="stat-icon">💾</div>
<div class="stat-content">
<span class="stat-label">Storage Used</span>
<span class="stat-value">{{ storage_mb or 0 }} MB</span>
</div>
</div>
</div>
</div>
{% if current_user.is_admin %}
<!-- User Management Card (Admin Only) -->
<div class="card management-card">
<h2>👥 User Management</h2>
<p>Manage application users, roles and permissions</p>
<div class="card-actions">
<a href="{{ url_for('admin.user_management') }}" class="btn btn-primary">
Manage Users
</a>
</div>
</div>
{% endif %}
<!-- Editing Users Card -->
<div class="card management-card">
<h2>✏️ Editing Users</h2>
<p>Manage user codes from players that edit images on-screen</p>
<div class="card-actions">
<a href="{{ url_for('admin.manage_editing_users') }}" class="btn btn-primary">
Manage Editing Users
</a>
</div>
</div>
<!-- Leftover Media Management Card -->
<div class="card management-card">
<h2>🗑️ Manage Leftover Media</h2>
<p>Clean up media files not assigned to any playlist</p>
<div class="card-actions">
<a href="{{ url_for('admin.leftover_media') }}" class="btn btn-warning">
Manage Leftover Files
</a>
</div>
</div>
{% if current_user.is_admin %}
<!-- System Dependencies Card (Admin Only) -->
<div class="card management-card" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<h2>🔧 System Dependencies</h2>
<p>Check and install required software dependencies</p>
<div class="card-actions">
<a href="{{ url_for('admin.dependencies') }}" class="btn btn-primary">
View Dependencies
</a>
</div>
</div>
<!-- Logo Customization Card (Admin Only) -->
<div class="card management-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
<h2>🎨 Logo Customization</h2>
<p>Upload custom logos for header and login page</p>
<div class="card-actions">
<a href="{{ url_for('admin.customize_logos') }}" class="btn btn-primary">
Customize Logos
</a>
</div>
</div>
<!-- HTTPS Configuration Card (Admin Only) -->
<div class="card management-card" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<h2>🔒 HTTPS Configuration</h2>
<p>Manage SSL/HTTPS settings, domain, and access points</p>
<div class="card-actions">
<a href="{{ url_for('admin.https_config') }}" class="btn btn-primary">
Configure HTTPS
</a>
</div>
</div>
{% endif %}
<!-- Quick Actions Card -->
<div class="card">
<h2>⚡ Quick Actions</h2>
<div class="quick-actions">
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">🖥️ View Players</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📋 View Playlists</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📁 View Media Library</a>
</div>
</div>
</div>
<style>
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.stats-grid {
display: grid;
gap: 10px;
margin-top: 15px;
}
.stat-item {
display: flex;
align-items: center;
gap: 12px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e2e8f0;
transition: all 0.2s;
}
.stat-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
body.dark-mode .stat-item {
background: #1a202c;
border-color: #4a5568;
}
body.dark-mode .stat-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.stat-icon {
font-size: 2rem;
line-height: 1;
}
.stat-content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.stat-label {
font-weight: 500;
font-size: 0.85rem;
color: #666;
}
body.dark-mode .stat-label {
color: #a0aec0;
}
.stat-value {
font-weight: bold;
font-size: 1.5rem;
color: #2c3e50;
}
body.dark-mode .stat-value {
color: #e2e8f0;
}
.management-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.management-card h2 {
color: white;
}
.management-card p {
color: rgba(255, 255, 255, 0.9);
}
.card-actions {
margin-top: 20px;
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 15px;
}
.btn {
padding: 10px 20px;
text-decoration: none;
border-radius: 4px;
text-align: center;
font-weight: 500;
display: inline-block;
border: none;
cursor: pointer;
}
.btn-primary {
background: white;
color: #667eea;
}
.btn-primary:hover {
background: #f0f0f0;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
</style>
{% endblock %}
@@ -0,0 +1,101 @@
{% extends "base.html" %}
{% block title %}Logo Customization - DigiServer{% endblock %}
{% block content %}
<div class="container" style="max-width: 900px;">
<h1 style="margin-bottom: 25px;">🎨 Logo Customization</h1>
<div class="card" style="margin-bottom: 20px;">
<h2 style="margin-bottom: 20px;">📸 Upload Custom Logos</h2>
<!-- Header Logo -->
<div style="margin-bottom: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px;">
<h3 style="margin-bottom: 15px;">Header Logo (Small)</h3>
<p style="color: #666; margin-bottom: 15px;">
This logo appears in the top header next to "DigiServer" text.<br>
<strong>Recommended:</strong> 150x40 pixels (or similar aspect ratio), transparent background PNG
</p>
<div style="display: flex; align-items: center; gap: 20px; margin-bottom: 15px;">
<div style="flex: 1;">
<img src="{{ url_for('static', filename='uploads/header_logo.png') }}?v={{ version }}"
alt="Current Header Logo"
style="max-height: 50px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 10px; border-radius: 4px;"
onerror="this.src='{{ url_for('static', filename='icons/monitor.svg') }}'; this.style.filter='brightness(0) invert(1)'; this.style.maxWidth='50px';">
<p style="margin-top: 5px; font-size: 0.9rem; color: #888;">Current Header Logo</p>
</div>
</div>
<form method="POST" action="{{ url_for('admin.upload_header_logo') }}" enctype="multipart/form-data">
<div style="margin-bottom: 10px;">
<input type="file" name="header_logo" accept="image/png,image/jpeg,image/svg+xml" required
style="padding: 10px; border: 2px solid #ddd; border-radius: 4px; width: 100%;">
</div>
<button type="submit" class="btn btn-primary">📤 Upload Header Logo</button>
</form>
</div>
<!-- Login Logo -->
<div style="margin-bottom: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px;">
<h3 style="margin-bottom: 15px;">Login Page Logo (Large)</h3>
<p style="color: #666; margin-bottom: 15px;">
This logo appears on the left side of the login page (2/3 of screen).<br>
<strong>Recommended:</strong> 800x600 pixels (or similar), transparent background PNG
</p>
<div style="display: flex; align-items: center; gap: 20px; margin-bottom: 15px;">
<div style="flex: 1;">
<img src="{{ url_for('static', filename='uploads/login_logo.png') }}?v={{ version }}"
alt="Current Login Logo"
style="max-width: 300px; max-height: 200px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px;"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
<p style="margin-top: 10px; font-size: 0.9rem; color: #888; display: none;">No login logo uploaded yet</p>
</div>
</div>
<form method="POST" action="{{ url_for('admin.upload_login_logo') }}" enctype="multipart/form-data">
<div style="margin-bottom: 10px;">
<input type="file" name="login_logo" accept="image/png,image/jpeg,image/svg+xml" required
style="padding: 10px; border: 2px solid #ddd; border-radius: 4px; width: 100%;">
</div>
<button type="submit" class="btn btn-primary">📤 Upload Login Logo</button>
</form>
</div>
<div style="padding: 15px; background: #e7f3ff; border-radius: 8px; border-left: 4px solid #0066cc;">
<h4 style="margin: 0 0 10px 0;">️ Logo Guidelines</h4>
<ul style="margin: 5px 0; padding-left: 25px; color: #555;">
<li><strong>Header Logo:</strong> Keep it simple and small (max 200px width recommended)</li>
<li><strong>Login Logo:</strong> Can be larger and more detailed (800x600px works great)</li>
<li><strong>Format:</strong> PNG with transparent background recommended, or JPG/SVG</li>
<li><strong>File Size:</strong> Keep under 2MB for optimal performance</li>
<li>Logos are cached - clear browser cache if changes don't appear immediately</li>
</ul>
</div>
</div>
<div style="margin-top: 20px;">
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
← Back to Admin Panel
</a>
</div>
</div>
<style>
body.dark-mode div[style*="background: #f8f9fa"] {
background: #2d3748 !important;
}
body.dark-mode div[style*="background: #e7f3ff"] {
background: #1e3a5f !important;
border-left-color: #64b5f6 !important;
}
body.dark-mode p[style*="color: #666"],
body.dark-mode p[style*="color: #888"],
body.dark-mode ul[style*="color: #555"] {
color: #cbd5e0 !important;
}
</style>
{% endblock %}
@@ -0,0 +1,176 @@
{% extends "base.html" %}
{% block title %}System Dependencies - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 1000px;">
<h1 style="margin-bottom: 25px;">🔧 System Dependencies</h1>
<div class="card" style="margin-bottom: 20px;">
<h2 style="margin-bottom: 20px;">📦 Installed Dependencies</h2>
<!-- LibreOffice -->
<div class="dependency-card" style="background: {% if libreoffice_installed %}#d4edda{% else %}#f8d7da{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if libreoffice_installed %}#28a745{% else %}#dc3545{% endif %};">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1;">
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
{% if libreoffice_installed %}
<span style="font-size: 24px;"></span>
{% else %}
<span style="font-size: 24px;"></span>
{% endif %}
LibreOffice
</h3>
<p style="margin: 5px 0; color: #555;">
<strong>Purpose:</strong> Required for PowerPoint (PPTX/PPT) to image conversion
</p>
<p style="margin: 5px 0; color: #555;">
<strong>Status:</strong> {{ libreoffice_version }}
</p>
{% if not libreoffice_installed %}
<p style="margin: 10px 0 0 0; color: #721c24;">
⚠️ Without LibreOffice, you cannot upload or convert PowerPoint presentations.
</p>
{% endif %}
</div>
{% if not libreoffice_installed %}
<form method="POST" action="{{ url_for('admin.install_libreoffice') }}" style="margin-left: 20px;">
<button type="submit" class="btn btn-success" onclick="return confirm('Install LibreOffice? This may take 2-5 minutes.');">
📥 Install LibreOffice
</button>
</form>
{% endif %}
</div>
</div>
<!-- Poppler Utils -->
<div class="dependency-card" style="background: {% if poppler_installed %}#d4edda{% else %}#fff3cd{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if poppler_installed %}#28a745{% else %}#ffc107{% endif %};">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1;">
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
{% if poppler_installed %}
<span style="font-size: 24px;"></span>
{% else %}
<span style="font-size: 24px;">⚠️</span>
{% endif %}
Poppler Utils
</h3>
<p style="margin: 5px 0; color: #555;">
<strong>Purpose:</strong> Required for PDF to image conversion
</p>
<p style="margin: 5px 0; color: #555;">
<strong>Status:</strong> {{ poppler_version }}
</p>
</div>
</div>
</div>
<!-- FFmpeg -->
<div class="dependency-card" style="background: {% if ffmpeg_installed %}#d4edda{% else %}#fff3cd{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if ffmpeg_installed %}#28a745{% else %}#ffc107{% endif %};">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1;">
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
{% if ffmpeg_installed %}
<span style="font-size: 24px;"></span>
{% else %}
<span style="font-size: 24px;">⚠️</span>
{% endif %}
FFmpeg
</h3>
<p style="margin: 5px 0; color: #555;">
<strong>Purpose:</strong> Required for video processing and validation
</p>
<p style="margin: 5px 0; color: #555;">
<strong>Status:</strong> {{ ffmpeg_version }}
</p>
</div>
</div>
</div>
<!-- Emoji Fonts -->
<div class="dependency-card" style="background: {% if emoji_installed %}#d4edda{% else %}#fff3cd{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if emoji_installed %}#28a745{% else %}#ffc107{% endif %};">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1;">
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
{% if emoji_installed %}
<span style="font-size: 24px;"></span>
{% else %}
<span style="font-size: 24px;">⚠️</span>
{% endif %}
Emoji Fonts
</h3>
<p style="margin: 5px 0; color: #555;">
<strong>Purpose:</strong> Better emoji display in UI (optional, mainly for Raspberry Pi)
</p>
<p style="margin: 5px 0; color: #555;">
<strong>Status:</strong> {{ emoji_version }}
</p>
{% if not emoji_installed %}
<p style="margin: 10px 0 0 0; color: #856404;">
️ Optional: Improves emoji rendering on systems without native emoji support.
</p>
{% endif %}
</div>
{% if not emoji_installed %}
<form method="POST" action="{{ url_for('admin.install_emoji_fonts') }}" style="margin-left: 20px;">
<button type="submit" class="btn btn-warning" onclick="return confirm('Install emoji fonts? This may take 1-2 minutes.');">
📥 Install Emoji Fonts
</button>
</form>
{% endif %}
</div>
</div>
<div style="margin-top: 25px; padding: 15px; background: #e7f3ff; border-radius: 8px; border-left: 4px solid #0066cc;">
<h4 style="margin: 0 0 10px 0;">️ Installation Notes</h4>
<ul style="margin: 5px 0; padding-left: 25px; color: #555;">
<li>LibreOffice can be installed using the button above (requires sudo access)</li>
<li>Emoji fonts improve UI display, especially on Raspberry Pi systems</li>
<li>Installation may take 1-5 minutes depending on your internet connection</li>
<li>After installation, refresh this page to verify the status</li>
<li>Docker containers may require rebuilding to include dependencies</li>
</ul>
</div>
</div>
<div style="margin-top: 20px;">
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
← Back to Admin Panel
</a>
</div>
</div>
<style>
body.dark-mode .dependency-card {
color: #e2e8f0 !important;
}
body.dark-mode .dependency-card p {
color: #cbd5e0 !important;
}
body.dark-mode .dependency-card[style*="#d4edda"] {
background: #1e4620 !important;
border-left-color: #48bb78 !important;
}
body.dark-mode .dependency-card[style*="#f8d7da"] {
background: #5a1e1e !important;
border-left-color: #ef5350 !important;
}
body.dark-mode .dependency-card[style*="#fff3cd"] {
background: #5a4a1e !important;
border-left-color: #ffc107 !important;
}
body.dark-mode div[style*="#e7f3ff"] {
background: #1e3a5f !important;
border-left-color: #64b5f6 !important;
}
body.dark-mode div[style*="#e7f3ff"] ul {
color: #cbd5e0 !important;
}
</style>
{% endblock %}
@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}Manage Editing Users{% endblock %}
{% block content %}
<div style="margin-bottom: 2rem;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
<div style="display: flex; align-items: center; gap: 1rem;">
<a href="{{ url_for('admin.admin_panel') }}"
class="btn"
style="background: #6c757d; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px;">
← Back to Admin
</a>
<h1 style="margin: 0;">👤 Manage Editing Users</h1>
</div>
</div>
<p style="color: #6c757d;">Manage users who edit images on players. User codes are automatically created from player metadata.</p>
</div>
{% if users %}
<div class="card">
<div class="card-header">
<h3 style="margin: 0;">Editing Users ({{ users|length }})</h3>
</div>
<div class="card-body" style="padding: 0;">
<table class="table" style="margin: 0;">
<thead>
<tr>
<th style="width: 30%;">User Code</th>
<th style="width: 30%;">Display Name</th>
<th style="width: 15%;">Edits Count</th>
<th style="width: 15%;">Created</th>
<th style="width: 10%;">Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td style="font-family: monospace; font-weight: 600;">{{ user.user_code }}</td>
<td>
<form method="POST" action="{{ url_for('admin.update_editing_user', user_id=user.id) }}" style="display: flex; gap: 0.5rem; align-items: center;">
<input type="text"
name="user_name"
value="{{ user.user_name or '' }}"
placeholder="Enter display name"
class="form-control"
style="flex: 1;">
<button type="submit" class="btn btn-sm btn-primary">Save</button>
</form>
</td>
<td>
<span class="badge badge-info">{{ user_stats.get(user.user_code, 0) }} edits</span>
</td>
<td>{{ user.created_at | localtime('%Y-%m-%d %H:%M') }}</td>
<td>
<form method="POST"
action="{{ url_for('admin.delete_editing_user', user_id=user.id) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this user? This will not delete their edit history.');">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="card" style="text-align: center; padding: 4rem 2rem;">
<div style="font-size: 4rem; margin-bottom: 1rem; opacity: 0.5;">👤</div>
<h2 style="color: #6c757d; margin-bottom: 1rem;">No Editing Users Yet</h2>
<p style="color: #6c757d; font-size: 1.1rem;">
User codes will appear here automatically when players edit media files.
</p>
</div>
{% endif %}
<style>
body.dark-mode .table {
color: #e2e8f0;
}
body.dark-mode .table thead th {
background: #2d3748;
color: #e2e8f0;
border-color: #4a5568;
}
body.dark-mode .table tbody tr {
border-color: #4a5568;
}
body.dark-mode .table tbody tr:hover {
background: #2d3748;
}
body.dark-mode .form-control {
background: #2d3748;
color: #e2e8f0;
border-color: #4a5568;
}
</style>
{% endblock %}
@@ -0,0 +1,654 @@
{% extends "base.html" %}
{% block title %}HTTPS Configuration - DigiServer v2{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<a href="{{ url_for('admin.admin_panel') }}" class="back-link">← Back to Admin Panel</a>
<h1>🔒 HTTPS Configuration</h1>
</div>
<div class="https-config-container">
<!-- Status Display -->
<div class="card status-card">
<h2>Current Status</h2>
<!-- Real-time HTTPS detection -->
<div class="status-detection">
<p class="detection-info">
<strong>🔍 Detected Connection:</strong>
{% if is_https_active %}
<span class="badge badge-success">🔒 HTTPS ({{ request.scheme.upper() }})</span>
{% else %}
<span class="badge badge-warning">🔓 HTTP</span>
{% endif %}
<br>
<small>Current host: <code>{{ current_host }}</code> via {{ request.host }}</small>
</p>
</div>
{% if config and config.https_enabled %}
<div class="status-enabled">
<span class="status-badge">✅ HTTPS ENABLED</span>
<div class="status-details">
<p><strong>Domain:</strong> {{ config.domain }}</p>
<p><strong>Hostname:</strong> {{ config.hostname }}</p>
<p><strong>Email:</strong> {{ config.email }}</p>
<p><strong>IP Address:</strong> {{ config.ip_address }}</p>
<p><strong>Port:</strong> {{ config.port }}</p>
<p><strong>Access URL:</strong> <code>https://{{ config.domain }}</code></p>
<p><strong>Last Updated:</strong> {{ config.updated_at.strftime('%Y-%m-%d %H:%M:%S') }} by {{ config.updated_by }}</p>
</div>
</div>
{% else %}
<div class="status-disabled">
<span class="status-badge-inactive">⚠️ HTTPS DISABLED</span>
{% if is_https_active %}
<p style="color: #156b2e; background: #d1f0e0; padding: 10px; border-radius: 4px; margin: 10px 0;">
<strong>Note:</strong> You are currently accessing this page via HTTPS, but the configuration shows as disabled.
This configuration will be automatically updated. Please refresh the page.
</p>
{% else %}
<p>The application is currently running on HTTP only (port 80)</p>
<p>Enable HTTPS below to secure your application.</p>
{% endif %}
</div>
{% endif %}
</div>
<!-- Configuration Form -->
<div class="card config-card">
<h2>Configure HTTPS Settings</h2>
<p class="info-text">
💡 <strong>Workflow:</strong> First, the app runs on HTTP (port 80). After you configure the HTTPS settings below,
the application will be available over HTTPS (port 443) using the domain and hostname you specify.
</p>
<form method="POST" action="{{ url_for('admin.update_https_config') }}" class="https-form">
<!-- Enable HTTPS Toggle -->
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" name="https_enabled" id="https_enabled"
{% if config and config.https_enabled %}checked{% endif %}
class="toggle-input">
<span class="toggle-slider"></span>
<span class="toggle-text">Enable HTTPS</span>
</label>
<p class="form-hint">Check this box to enable HTTPS/SSL for your application</p>
</div>
<!-- Hostname Field -->
<div class="form-group">
<label for="hostname">Hostname <span class="required">*</span></label>
<input type="text" id="hostname" name="hostname"
value="{{ config.hostname or 'digiserver' }}"
placeholder="e.g., digiserver"
class="form-input"
required>
<p class="form-hint">Short name for your server (e.g., 'digiserver')</p>
</div>
<!-- Domain Field -->
<div class="form-group">
<label for="domain">Full Domain Name <span class="required">*</span></label>
<input type="text" id="domain" name="domain"
value="{{ config.domain or 'digiserver.sibiusb.harting.intra' }}"
placeholder="e.g., digiserver.sibiusb.harting.intra"
class="form-input"
required>
<p class="form-hint">Complete domain name (e.g., digiserver.sibiusb.harting.intra)</p>
</div>
<!-- IP Address Field -->
<div class="form-group">
<label for="ip_address">IP Address <span class="required">*</span></label>
<input type="text" id="ip_address" name="ip_address"
value="{{ config.ip_address or '10.76.152.164' }}"
placeholder="e.g., 10.76.152.164"
class="form-input"
required>
<p class="form-hint">Server's IP address for direct access (e.g., 10.76.152.164)</p>
</div>
<!-- Email Field -->
<div class="form-group">
<label for="email">Email Address <span class="required">*</span></label>
<input type="email" id="email" name="email"
value="{{ config.email or '' }}"
placeholder="e.g., admin@example.com"
class="form-input"
required>
<p class="form-hint">Email address for SSL certificate notifications and Let's Encrypt communications</p>
</div>
<!-- Port Field -->
<div class="form-group">
<label for="port">HTTPS Port</label>
<input type="number" id="port" name="port"
value="{{ config.port or 443 }}"
placeholder="443"
min="1" max="65535"
class="form-input">
<p class="form-hint">Port for HTTPS connections (default: 443)</p>
</div>
<!-- Preview Section -->
<div class="preview-section">
<h3>Access Points After Configuration:</h3>
<ul class="access-points">
<li>
<strong>HTTPS (Recommended):</strong>
<code>https://<span id="preview-domain">digiserver.sibiusb.harting.intra</span></code>
</li>
<li>
<strong>HTTP (Fallback):</strong>
<code>http://<span id="preview-ip">10.76.152.164</span></code>
</li>
</ul>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button type="submit" class="btn btn-primary btn-lg">
💾 Save HTTPS Configuration
</button>
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
Cancel
</a>
</div>
</form>
</div>
<!-- Nginx Status Card -->
<div class="card nginx-status-card">
<h2>🔧 Nginx Reverse Proxy Status</h2>
{% if nginx_status.available %}
<div class="nginx-status-content">
<div class="status-item">
<strong>Status:</strong>
<span class="badge badge-success">✅ Nginx Configured</span>
</div>
<div class="status-item">
<strong>Configuration Path:</strong>
<code>{{ nginx_status.path }}</code>
</div>
{% if nginx_status.ssl_enabled %}
<div class="status-item">
<strong>SSL/TLS:</strong>
<span class="badge badge-success">🔒 Enabled</span>
</div>
{% else %}
<div class="status-item">
<strong>SSL/TLS:</strong>
<span class="badge badge-warning">⚠️ Not Configured</span>
</div>
{% endif %}
{% if nginx_status.http_ports %}
<div class="status-item">
<strong>HTTP Ports:</strong>
<code>{{ nginx_status.http_ports|join(', ') }}</code>
</div>
{% endif %}
{% if nginx_status.https_ports %}
<div class="status-item">
<strong>HTTPS Ports:</strong>
<code>{{ nginx_status.https_ports|join(', ') }}</code>
</div>
{% endif %}
{% if nginx_status.server_names %}
<div class="status-item">
<strong>Server Names:</strong>
{% for name in nginx_status.server_names %}
<code>{{ name }}</code>{% if not loop.last %}<br>{% endif %}
{% endfor %}
</div>
{% endif %}
{% if nginx_status.upstream_servers %}
<div class="status-item">
<strong>Upstream Servers:</strong>
{% for server in nginx_status.upstream_servers %}
<code>{{ server }}</code>{% if not loop.last %}<br>{% endif %}
{% endfor %}
</div>
{% endif %}
{% if nginx_status.ssl_protocols %}
<div class="status-item">
<strong>SSL Protocols:</strong>
<code>{{ nginx_status.ssl_protocols|join(', ') }}</code>
</div>
{% endif %}
{% if nginx_status.client_max_body_size %}
<div class="status-item">
<strong>Max Body Size:</strong>
<code>{{ nginx_status.client_max_body_size }}</code>
</div>
{% endif %}
{% if nginx_status.gzip_enabled %}
<div class="status-item">
<strong>Gzip Compression:</strong>
<span class="badge badge-success">✅ Enabled</span>
</div>
{% endif %}
</div>
{% else %}
<div class="status-disabled">
<p>⚠️ <strong>Nginx configuration not accessible</strong></p>
<p>Error: {{ nginx_status.error|default('Unknown error') }}</p>
<p style="font-size: 12px; color: #666;">Path checked: {{ nginx_status.path }}</p>
</div>
{% endif %}
</div>
<!-- Information Section -->
<div class="card info-card">
<h2>️ Important Information</h2>
<div class="info-sections">
<div class="info-section">
<h3>📝 Before You Start</h3>
<ul>
<li>Ensure your DNS is configured to resolve the domain to your server</li>
<li>Verify the IP address matches your server's actual network interface</li>
<li>Check that ports 80, 443, and 443/UDP are open for traffic</li>
</ul>
</div>
<div class="info-section">
<h3>🔐 HTTPS Setup</h3>
<ul>
<li>SSL certificates are automatically managed by Caddy</li>
<li>Certificates are obtained from Let's Encrypt</li>
<li>Automatic renewal is handled by the system</li>
</ul>
</div>
<div class="info-section">
<h3>✅ After Configuration</h3>
<ul>
<li>Your app will restart with the new settings</li>
<li>Both HTTP and HTTPS access points will be available</li>
<li>HTTP requests will be redirected to HTTPS</li>
<li>Check the status above for current configuration</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<style>
.https-config-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.page-header {
margin-bottom: 30px;
}
.back-link {
display: inline-block;
margin-bottom: 15px;
color: #0066cc;
text-decoration: none;
font-weight: 500;
}
.back-link:hover {
text-decoration: underline;
}
.status-detection {
background: #f0f7ff;
border-left: 4px solid #0066cc;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.detection-info {
margin: 0;
font-size: 14px;
line-height: 1.6;
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-left: 8px;
}
.badge-success {
background: #d1f0e0;
color: #156b2e;
}
.badge-warning {
background: #fff3cd;
color: #856404;
}
.status-card {
margin-bottom: 30px;
border-left: 5px solid #ddd;
}
.status-enabled {
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
border-radius: 8px;
padding: 20px;
border-left: 5px solid #28a745;
}
.status-disabled {
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
border-radius: 8px;
padding: 20px;
border-left: 5px solid #ffc107;
}
.status-badge {
display: inline-block;
background: #28a745;
color: white;
padding: 8px 16px;
border-radius: 20px;
font-weight: bold;
margin-bottom: 15px;
font-size: 14px;
}
.status-badge-inactive {
display: inline-block;
background: #ffc107;
color: #333;
padding: 8px 16px;
border-radius: 20px;
font-weight: bold;
margin-bottom: 15px;
font-size: 14px;
}
.status-details {
margin-top: 15px;
}
.status-details p {
margin: 8px 0;
font-size: 14px;
}
.status-details code {
background: rgba(0,0,0,0.1);
padding: 4px 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
.config-card {
margin-bottom: 30px;
}
.info-text {
background: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 25px;
font-size: 14px;
}
.https-form {
padding: 20px 0;
}
.form-group {
margin-bottom: 25px;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
.required {
color: #dc3545;
}
.form-input {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: all 0.2s;
}
.form-input:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
.form-hint {
font-size: 13px;
color: #666;
margin-top: 6px;
}
/* Toggle Switch Styling */
.toggle-label {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
}
.toggle-input {
display: none;
}
.toggle-slider {
display: inline-block;
width: 50px;
height: 28px;
background: #ccc;
border-radius: 14px;
position: relative;
transition: all 0.3s;
}
.toggle-slider::after {
content: '';
position: absolute;
width: 24px;
height: 24px;
background: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: all 0.3s;
}
.toggle-input:checked + .toggle-slider {
background: #28a745;
}
.toggle-input:checked + .toggle-slider::after {
left: 24px;
}
.toggle-text {
font-weight: 600;
color: #333;
}
.preview-section {
background: #f8f9fa;
border: 2px dashed #0066cc;
border-radius: 8px;
padding: 20px;
margin: 25px 0;
}
.preview-section h3 {
margin-top: 0;
color: #0066cc;
}
.access-points {
list-style: none;
padding: 0;
margin: 0;
}
.access-points li {
padding: 10px;
background: white;
border-radius: 4px;
margin-bottom: 8px;
border-left: 4px solid #0066cc;
}
.access-points code {
background: #e7f3ff;
padding: 6px 10px;
border-radius: 4px;
font-family: 'Courier New', monospace;
color: #0066cc;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 30px;
padding-top: 25px;
border-top: 1px solid #ddd;
}
.btn-lg {
padding: 12px 30px;
font-size: 16px;
font-weight: 600;
}
.info-card {
background: linear-gradient(135deg, #e7f3ff 0%, #f0f7ff 100%);
}
.info-card h2 {
color: #0066cc;
}
.nginx-status-card {
background: linear-gradient(135deg, #f0f7ff 0%, #e7f3ff 100%);
border-left: 5px solid #0066cc;
margin-bottom: 30px;
}
.nginx-status-card h2 {
color: #0066cc;
margin-top: 0;
}
.nginx-status-content {
padding: 10px 0;
}
.status-item {
padding: 12px;
background: white;
border-radius: 4px;
margin-bottom: 10px;
border-left: 3px solid #0066cc;
font-size: 14px;
}
.status-item strong {
display: inline-block;
min-width: 150px;
color: #333;
}
.status-item code {
background: #f0f7ff;
padding: 4px 8px;
border-radius: 3px;
font-family: 'Courier New', monospace;
color: #0066cc;
word-break: break-all;
}
.info-sections {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-top: 20px;
}
.info-section h3 {
color: #0066cc;
margin-top: 0;
font-size: 16px;
}
.info-section ul {
padding-left: 20px;
margin: 0;
}
.info-section li {
margin-bottom: 8px;
font-size: 14px;
color: #555;
}
@media (max-width: 768px) {
.https-config-container {
padding: 10px;
}
.info-sections {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
.btn-lg {
width: 100%;
}
}
</style>
<script>
// Update preview in real-time
document.getElementById('domain').addEventListener('input', function() {
document.getElementById('preview-domain').textContent = this.value || 'digiserver.sibiusb.harting.intra';
});
document.getElementById('ip_address').addEventListener('input', function() {
document.getElementById('preview-ip').textContent = this.value || '10.76.152.164';
});
// Load initial preview
document.getElementById('preview-domain').textContent = document.getElementById('domain').value || 'digiserver.sibiusb.harting.intra';
document.getElementById('preview-ip').textContent = document.getElementById('ip_address').value || '10.76.152.164';
</script>
{% endblock %}
@@ -0,0 +1,215 @@
{% extends "base.html" %}
{% block title %}Leftover Media - Admin - DigiServer v2{% endblock %}
{% block content %}
<div style="max-width: 1400px; margin: 0 auto;">
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center;">
<h1 style="display: flex; align-items: center; gap: 0.5rem;">
🗑️ Manage Leftover Media
</h1>
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
← Back to Admin
</a>
</div>
<!-- Overview Stats -->
<div class="card" style="margin-bottom: 30px;">
<h2>📊 Overview</h2>
<p style="color: #6c757d; margin-bottom: 20px;">
Media files that are not assigned to any playlist. These can be safely deleted to free up storage.
</p>
<div class="stats-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Leftover Files</div>
<div style="font-size: 24px; font-weight: bold;">{{ total_leftover }}</div>
</div>
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Size</div>
<div style="font-size: 24px; font-weight: bold;">{{ "%.2f"|format(total_leftover_size_mb) }} MB</div>
</div>
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Images</div>
<div style="font-size: 24px; font-weight: bold;">{{ leftover_images|length }} ({{ "%.2f"|format(images_size_mb) }} MB)</div>
</div>
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Videos</div>
<div style="font-size: 24px; font-weight: bold;">{{ leftover_videos|length }} ({{ "%.2f"|format(videos_size_mb) }} MB)</div>
</div>
</div>
</div>
<!-- Images Section -->
<div class="card" style="margin-bottom: 30px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>📷 Leftover Images ({{ leftover_images|length }})</h2>
{% if leftover_images %}
<form method="POST" action="{{ url_for('admin.delete_leftover_images') }}"
onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_images|length }} leftover images? This cannot be undone!');"
style="display: inline;">
<button type="submit" class="btn btn-danger">
🗑️ Delete All Images
</button>
</form>
{% endif %}
</div>
{% if leftover_images %}
<div style="max-height: 400px; overflow-y: auto;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6; width: 80px;">Action</th>
</tr>
</thead>
<tbody>
{% for img in leftover_images %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">📷 {{ img.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(img.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ img.duration }}s</td>
<td style="padding: 10px;">{{ img.uploaded_at | localtime if img.uploaded_at else 'N/A' }}</td>
<td style="padding: 10px;">
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=img.id) }}" style="display: inline;" onsubmit="return confirm('Delete this image?');">
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px; margin-bottom: 15px;"></div>
<p>No leftover images found. All images are assigned to playlists!</p>
</div>
{% endif %}
</div>
<!-- Videos Section -->
<div class="card" style="margin-bottom: 30px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h2 style="margin: 0;">🎥 Leftover Videos ({{ leftover_videos|length }})</h2>
{% if leftover_videos %}
<form method="POST" action="{{ url_for('admin.delete_leftover_videos') }}" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_videos|length }} leftover videos? This action cannot be undone!');">
<button type="submit" class="btn btn-danger" style="padding: 8px 16px; font-size: 14px;">
🗑️ Delete All Videos
</button>
</form>
{% endif %}
</div>
{% if leftover_videos %}
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6; width: 80px;">Action</th>
</tr>
</thead>
<tbody>
{% for video in leftover_videos %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">🎥 {{ video.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(video.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ video.duration }}s</td>
<td style="padding: 10px;">{{ video.uploaded_at | localtime if video.uploaded_at else 'N/A' }}</td>
<td style="padding: 10px;">
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=video.id) }}" style="display: inline;" onsubmit="return confirm('Delete this video?');">
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px; margin-bottom: 15px;"></div>
<p>No leftover videos found. All videos are assigned to playlists!</p>
</div>
{% endif %}
</div>
<!-- PDFs Section -->
<div class="card" style="margin-bottom: 30px;">
<h2>📄 Leftover PDFs ({{ leftover_pdfs|length }})</h2>
{% if leftover_pdfs %}
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
</tr>
</thead>
<tbody>
{% for pdf in leftover_pdfs %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">📄 {{ pdf.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(pdf.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ pdf.duration }}s</td>
<td style="padding: 10px;">{{ pdf.uploaded_at | localtime if pdf.uploaded_at else 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px; margin-bottom: 15px;"></div>
<p>No leftover PDFs found. All PDFs are assigned to playlists!</p>
</div>
{% endif %}
</div>
</div>
<style>
body.dark-mode .card {
background: #2d3748;
color: #e2e8f0;
}
body.dark-mode h1,
body.dark-mode h2 {
color: #e2e8f0;
}
body.dark-mode .stat-item {
background: #1a202c !important;
color: #e2e8f0;
}
body.dark-mode .table thead {
background: #1a202c !important;
}
body.dark-mode .table th,
body.dark-mode .table td {
color: #e2e8f0;
border-color: #4a5568 !important;
}
body.dark-mode .table tr {
border-color: #4a5568 !important;
}
body.dark-mode .table tr:hover {
background: #1a202c;
}
</style>
{% endblock %}
@@ -0,0 +1,524 @@
{% extends "base.html" %}
{% block title %}User Management - DigiServer v2{% endblock %}
{% block content %}
<div class="page-header">
<h1>👥 User Management</h1>
<button class="btn btn-primary" onclick="showCreateUserModal()">+ Create New User</button>
</div>
<!-- Users Table -->
<div class="card">
<h2>All Users</h2>
<div class="table-responsive">
<table class="user-table">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Created At</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if users %}
{% for user in users %}
<tr>
<td>
<strong>{{ user.username }}</strong>
{% if user.id == current_user.id %}
<span class="badge badge-info">You</span>
{% endif %}
</td>
<td>
<span class="badge badge-{{ 'success' if user.role == 'admin' else 'secondary' }}">
{{ user.role|capitalize }}
</span>
</td>
<td>{{ user.created_at | localtime if user.created_at else 'N/A' }}</td>
<td>{{ user.last_login | localtime if user.last_login else 'Never' }}</td>
<td class="actions">
{% if user.id != current_user.id %}
<button class="btn btn-sm btn-warning" onclick="showEditUserModal({{ user.id }}, '{{ user.username }}', '{{ user.role }}')">
Edit Role
</button>
<button class="btn btn-sm btn-secondary" onclick="showResetPasswordModal({{ user.id }}, '{{ user.username }}')">
Reset Password
</button>
<button class="btn btn-sm btn-danger" onclick="confirmDeleteUser({{ user.id }}, '{{ user.username }}')">
Delete
</button>
{% else %}
<span class="text-muted">Current User</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="text-center">No users found</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Role Descriptions -->
<div class="card">
<h2>📖 Role Descriptions</h2>
<div class="role-descriptions">
<div class="role-item">
<h3>👤 Normal User</h3>
<ul>
<li>Upload media content</li>
<li>Add/remove media from playlists</li>
<li>Edit media in playlists</li>
<li>Set display time for media items</li>
<li>View players and groups</li>
</ul>
</div>
<div class="role-item">
<h3>👑 Admin User</h3>
<ul>
<li>All normal user permissions</li>
<li>Create and manage users</li>
<li>Manage players and groups</li>
<li>Delete content</li>
<li>Access system settings</li>
<li>View system logs</li>
</ul>
</div>
</div>
</div>
<!-- Create User Modal -->
<div id="createUserModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Create New User</h2>
<span class="close" onclick="closeModal('createUserModal')">&times;</span>
</div>
<form method="POST" action="{{ url_for('admin.create_user') }}">
<div class="form-group">
<label for="username">Username *</label>
<input type="text" id="username" name="username" required minlength="3"
placeholder="Enter username" class="form-control">
<small>Minimum 3 characters</small>
</div>
<div class="form-group">
<label for="password">Password *</label>
<input type="password" id="password" name="password" required minlength="6"
placeholder="Enter password" class="form-control">
<small>Minimum 6 characters</small>
</div>
<div class="form-group">
<label for="role">Role *</label>
<select id="role" name="role" required class="form-control">
<option value="user">Normal User</option>
<option value="admin">Admin User</option>
</select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('createUserModal')">Cancel</button>
<button type="submit" class="btn btn-primary">Create User</button>
</div>
</form>
</div>
</div>
<!-- Edit Role Modal -->
<div id="editRoleModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Edit User Role</h2>
<span class="close" onclick="closeModal('editRoleModal')">&times;</span>
</div>
<form method="POST" id="editRoleForm">
<div class="form-group">
<label>Username</label>
<input type="text" id="edit_username" readonly class="form-control">
</div>
<div class="form-group">
<label for="edit_role">New Role *</label>
<select id="edit_role" name="role" required class="form-control">
<option value="user">Normal User</option>
<option value="admin">Admin User</option>
</select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('editRoleModal')">Cancel</button>
<button type="submit" class="btn btn-primary">Update Role</button>
</div>
</form>
</div>
</div>
<!-- Reset Password Modal -->
<div id="resetPasswordModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Reset User Password</h2>
<span class="close" onclick="closeModal('resetPasswordModal')">&times;</span>
</div>
<form method="POST" id="resetPasswordForm">
<div class="form-group">
<label>Username</label>
<input type="text" id="reset_username" readonly class="form-control">
</div>
<div class="form-group">
<label for="new_password">New Password *</label>
<input type="password" id="new_password" name="password" required minlength="6"
placeholder="Enter new password" class="form-control">
<small>Minimum 6 characters</small>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('resetPasswordModal')">Cancel</button>
<button type="submit" class="btn btn-primary">Reset Password</button>
</div>
</form>
</div>
</div>
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header .btn-primary {
background: #667eea;
color: white;
border: none;
}
.page-header .btn-primary:hover {
background: #5568d3;
}
body.dark-mode .page-header .btn-primary {
background: #7c3aed;
}
body.dark-mode .page-header .btn-primary:hover {
background: #6d28d9;
}
.table-responsive {
overflow-x: auto;
}
.user-table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
.user-table th,
.user-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.user-table th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
.user-table tbody tr:hover {
background: #f8f9fa;
}
body.dark-mode .user-table th,
body.dark-mode .user-table td {
border-bottom: 1px solid #4a5568;
}
body.dark-mode .user-table th {
background: #1a202c;
color: #e2e8f0;
}
body.dark-mode .user-table tbody tr:hover {
background: #1a202c;
}
.badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
display: inline-block;
}
.badge-success {
background: #28a745;
color: white;
}
.badge-secondary {
background: #6c757d;
color: white;
}
.badge-info {
background: #17a2b8;
color: white;
margin-left: 8px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn-sm {
padding: 6px 12px;
font-size: 14px;
}
.btn-warning {
background: #ffc107;
color: #000;
}
.btn-warning:hover {
background: #e0a800;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.role-descriptions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 15px;
}
.role-item {
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.role-item h3 {
margin-bottom: 10px;
color: #495057;
}
.role-item ul {
list-style-position: inside;
padding-left: 0;
}
.role-item li {
padding: 5px 0;
color: #666;
}
body.dark-mode .role-item {
background: #1a202c;
border: 1px solid #4a5568;
}
body.dark-mode .role-item h3 {
color: #e2e8f0;
}
body.dark-mode .role-item li {
color: #a0aec0;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: #fff;
margin: 5% auto;
padding: 0;
border-radius: 8px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
body.dark-mode .modal-content {
background-color: #2d3748;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
}
body.dark-mode .modal-header {
border-bottom: 1px solid #4a5568;
}
.modal-header h2 {
margin: 0;
font-size: 20px;
}
body.dark-mode .modal-header h2 {
color: #e2e8f0;
}
.close {
font-size: 28px;
font-weight: bold;
color: #aaa;
cursor: pointer;
}
.close:hover {
color: #000;
}
.modal form {
padding: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #495057;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
.form-control {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
background: white;
color: #2d3748;
}
.form-control:focus {
outline: none;
border-color: #667eea;
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .form-control:focus {
border-color: #7c3aed;
}
.form-group small {
display: block;
margin-top: 5px;
color: #6c757d;
font-size: 12px;
}
body.dark-mode .form-group small {
color: #a0aec0;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
margin-top: 20px;
}
body.dark-mode .modal-footer {
border-top: 1px solid #4a5568;
}
.text-muted {
color: #6c757d;
}
.text-center {
text-align: center;
}
</style>
<script>
function showCreateUserModal() {
document.getElementById('createUserModal').style.display = 'block';
}
function showEditUserModal(userId, username, currentRole) {
document.getElementById('edit_username').value = username;
document.getElementById('edit_role').value = currentRole;
document.getElementById('editRoleForm').action = `/admin/user/${userId}/role`;
document.getElementById('editRoleModal').style.display = 'block';
}
function showResetPasswordModal(userId, username) {
document.getElementById('reset_username').value = username;
document.getElementById('resetPasswordForm').action = `/admin/user/${userId}/password`;
document.getElementById('resetPasswordModal').style.display = 'block';
}
function closeModal(modalId) {
document.getElementById(modalId).style.display = 'none';
}
function confirmDeleteUser(userId, username) {
if (confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
const form = document.createElement('form');
form.method = 'POST';
form.action = `/admin/user/${userId}/delete`;
document.body.appendChild(form);
form.submit();
}
}
// Close modal when clicking outside
window.onclick = function(event) {
const modals = document.getElementsByClassName('modal');
for (let modal of modals) {
if (event.target === modal) {
modal.style.display = 'none';
}
}
}
</script>
{% endblock %}
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Change Password{% endblock %}
{% block content %}
<div class="container" style="max-width: 500px; margin-top: 50px;">
<h2>Change Password</h2>
<form method="POST">
<div class="form-group">
<label>Current Password</label>
<input type="password" name="current_password" class="form-control" required>
</div>
<div class="form-group">
<label>New Password</label>
<input type="password" name="new_password" class="form-control" required>
</div>
<div class="form-group">
<label>Confirm New Password</label>
<input type="password" name="confirm_password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">Change Password</button>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">Cancel</a>
</form>
</div>
{% endblock %}
+229
View File
@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - DigiServer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
height: 100vh;
display: flex;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-container {
display: flex;
width: 100%;
height: 100vh;
}
.logo-section {
flex: 2;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
}
.logo-section img {
max-width: 70%;
max-height: 70%;
object-fit: contain;
filter: drop-shadow(0 10px 30px rgba(0, 0, 0, 0.3));
}
.form-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: white;
padding: 2rem;
}
.login-form {
width: 100%;
max-width: 400px;
}
.login-form h2 {
color: #2d3748;
margin-bottom: 2rem;
font-size: 2rem;
text-align: center;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #4a5568;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="password"] {
width: 100%;
padding: 0.75rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s;
}
.form-group input[type="text"]:focus,
.form-group input[type="password"]:focus {
outline: none;
border-color: #667eea;
}
.remember-me {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
}
.remember-me input {
margin-right: 0.5rem;
}
.remember-me label {
color: #4a5568;
cursor: pointer;
}
.btn-login {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
}
.register-link {
margin-top: 1.5rem;
text-align: center;
color: #718096;
}
.register-link a {
color: #667eea;
text-decoration: none;
font-weight: 600;
}
.register-link a:hover {
text-decoration: underline;
}
.flash-messages {
margin-bottom: 1.5rem;
}
.alert {
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 0.5rem;
}
.alert-danger {
background: #fed7d7;
color: #c53030;
border: 1px solid #fc8181;
}
.alert-success {
background: #c6f6d5;
color: #2f855a;
border: 1px solid #68d391;
}
.alert-warning {
background: #feebc8;
color: #c05621;
border: 1px solid #f6ad55;
}
@media (max-width: 768px) {
.login-container {
flex-direction: column;
}
.logo-section {
flex: 1;
min-height: 200px;
}
.form-section {
flex: 2;
}
}
</style>
</head>
<body>
<div class="login-container">
<!-- Logo Section (Left - 2/3) -->
<div class="logo-section">
<img src="{{ url_for('static', filename='uploads/login_logo.png') }}?v={{ range(1, 999999) | random }}"
alt="DigiServer Logo"
onerror="this.style.display='none';">
</div>
<!-- Form Section (Right - 1/3) -->
<div class="form-section">
<div class="login-form">
<h2>Welcome Back</h2>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="alert alert-{{ category }}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.login') }}">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div class="remember-me">
<input type="checkbox" id="remember" name="remember" value="yes">
<label for="remember">Remember me</label>
</div>
<button type="submit" class="btn-login">Login</button>
</form>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Register - DigiServer v2{% endblock %}
{% block content %}
<div class="card" style="max-width: 400px; margin: 2rem auto;">
<h2>Register</h2>
<form method="POST" action="{{ url_for('auth.register') }}">
<div style="margin-bottom: 1rem;">
<label for="username" style="display: block; margin-bottom: 0.5rem;">Username</label>
<input type="text" id="username" name="username" required minlength="3"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<small style="color: #7f8c8d;">Minimum 3 characters</small>
</div>
<div style="margin-bottom: 1rem;">
<label for="password" style="display: block; margin-bottom: 0.5rem;">Password</label>
<input type="password" id="password" name="password" required minlength="6"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<small style="color: #7f8c8d;">Minimum 6 characters</small>
</div>
<button type="submit" class="btn btn-success" style="width: 100%;">Register</button>
</form>
<p style="margin-top: 1rem; text-align: center;">
Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a>
</p>
</div>
{% endblock %}
+463
View File
@@ -0,0 +1,463 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}DigiServer v2{% endblock %}</title>
<style>
/* Ensure emoji font support */
@supports (font-family: "Apple Color Emoji") {
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
}
}
:root {
/* Light Mode Colors */
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--primary-color: #667eea;
--primary-dark: #5568d3;
--secondary-color: #764ba2;
--bg-color: #f5f7fa;
--card-bg: #ffffff;
--text-color: #2d3748;
--text-secondary: #718096;
--border-color: #e2e8f0;
--header-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.1);
}
body.dark-mode {
/* Dark Mode Colors */
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--primary-color: #7c3aed;
--primary-dark: #6d28d9;
--secondary-color: #8b5cf6;
--bg-color: #1a202c;
--card-bg: #2d3748;
--text-color: #e2e8f0;
--text-secondary: #a0aec0;
--border-color: #4a5568;
--header-bg: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%);
--shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.4);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--bg-color);
transition: background 0.3s, color 0.3s;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background: var(--header-bg);
color: white;
padding: 1rem 0;
margin-bottom: 2rem;
box-shadow: var(--shadow);
}
header .container {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
header h1 {
font-size: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
nav {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 6px;
transition: all 0.3s;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
nav a:hover {
background: rgba(255,255,255,0.2);
transform: translateY(-2px);
}
nav a img {
width: 18px;
height: 18px;
filter: brightness(0) invert(1);
}
/* Mobile Responsive */
@media (max-width: 768px) {
header .container {
flex-direction: column;
align-items: stretch;
padding: 1rem;
}
header h1 {
font-size: 1.25rem;
justify-content: center;
text-align: center;
}
nav {
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
nav a {
justify-content: center;
width: 100%;
padding: 0.75rem 1rem;
}
.dark-mode-toggle {
margin-left: 0;
width: 100%;
padding: 0.75rem;
}
.container {
padding: 15px;
}
.card {
padding: 1rem;
margin-bottom: 1rem;
}
.card-header {
padding: 15px;
margin: -1rem -1rem 1rem -1rem;
}
}
@media (max-width: 480px) {
header h1 {
font-size: 1.1rem;
}
nav a {
font-size: 0.9rem;
padding: 0.6rem 0.8rem;
}
nav a img {
width: 16px;
height: 16px;
}
}
.dark-mode-toggle {
background: rgba(255,255,255,0.2);
border: 2px solid rgba(255,255,255,0.3);
color: white;
cursor: pointer;
padding: 0.5rem;
border-radius: 6px;
transition: all 0.3s;
margin-left: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.dark-mode-toggle:hover {
background: rgba(255,255,255,0.3);
transform: scale(1.1);
}
.dark-mode-toggle img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
/* Emoji fallback for systems without emoji fonts */
.emoji-fallback::before {
content: attr(data-emoji);
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
}
.alert {
padding: 1rem;
margin-bottom: 1rem;
border-radius: 8px;
border-left: 4px solid;
transition: all 0.3s;
}
.alert-success {
background: #d4edda;
border-color: #28a745;
color: #155724;
}
body.dark-mode .alert-success {
background: rgba(40, 167, 69, 0.2);
border-color: #28a745;
color: #7ce3a3;
}
.alert-danger {
background: #f8d7da;
border-color: #dc3545;
color: #721c24;
}
body.dark-mode .alert-danger {
background: rgba(220, 53, 69, 0.2);
border-color: #dc3545;
color: #f88f9a;
}
.alert-warning {
background: #fff3cd;
border-color: #ffc107;
color: #856404;
}
body.dark-mode .alert-warning {
background: rgba(255, 193, 7, 0.2);
border-color: #ffc107;
color: #ffd454;
}
.alert-info {
background: #d1ecf1;
border-color: #17a2b8;
color: #0c5460;
}
body.dark-mode .alert-info {
background: rgba(23, 162, 184, 0.2);
border-color: #17a2b8;
color: #7dd3e0;
}
.card {
background: var(--card-bg);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow);
transition: all 0.3s;
border: 1px solid var(--border-color);
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.card h2 {
margin-bottom: 1rem;
color: var(--text-color);
font-weight: 600;
}
.card h3 {
color: var(--text-color);
}
/* Card with gradient header */
.card-header {
background: var(--primary-gradient);
color: white;
padding: 20px;
border-radius: 12px 12px 0 0;
margin: -1.5rem -1.5rem 1.5rem -1.5rem;
}
.card-header h2 {
margin: 0;
color: white;
}
.btn {
display: inline-block;
padding: 0.5rem 1rem;
background: var(--primary-color);
color: white;
text-decoration: none;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.3s;
font-weight: 500;
}
.btn:hover {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-primary {
background: var(--primary-gradient);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.btn-danger { background: #e74c3c; }
.btn-danger:hover {
background: #c0392b;
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.4);
}
.btn-success { background: #27ae60; }
.btn-success:hover {
background: #229954;
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);
}
.btn-info {
background: #3498db;
}
.btn-info:hover {
background: #2980b9;
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
/* Form Inputs */
input, textarea, select {
background: var(--card-bg);
color: var(--text-color);
border: 1px solid var(--border-color);
transition: all 0.3s;
}
input:focus, textarea:focus, select:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
body.dark-mode input:focus,
body.dark-mode textarea:focus,
body.dark-mode select:focus {
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.3);
}
/* Tables */
table {
background: var(--card-bg);
color: var(--text-color);
}
th {
background: var(--bg-color);
color: var(--text-color);
}
tr {
border-bottom: 1px solid var(--border-color);
}
/* Code blocks */
code, pre {
background: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
}
footer {
margin-top: 2rem;
padding: 1rem 0;
text-align: center;
color: var(--text-secondary);
font-size: 0.9rem;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<header>
<div class="container">
<h1>
<img src="{{ url_for('static', filename='uploads/header_logo.png?v=1') }}" alt="DigiServer" style="height: 32px; width: auto; margin-right: 8px;" onerror="this.style.display='none';" onload="this.style.display='inline';">
DigiServer
</h1>
<nav>
{% if current_user.is_authenticated %}
<a href="{{ url_for('main.dashboard') }}">
<img src="{{ url_for('static', filename='icons/home.svg') }}" alt="">
Dashboard
</a>
<a href="{{ url_for('players.list') }}">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="">
Players
</a>
<a href="{{ url_for('content.content_list') }}">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="">
Playlists
</a>
<a href="{{ url_for('admin.admin_panel') }}">Admin</a>
<a href="{{ url_for('auth.logout') }}">Logout ({{ current_user.username }})</a>
<button class="dark-mode-toggle" onclick="toggleDarkMode()" title="Toggle Dark Mode">
<img id="theme-icon" src="{{ url_for('static', filename='icons/moon.svg') }}" alt="Toggle theme">
</button>
{% else %}
<a href="{{ url_for('auth.login') }}">Login</a>
<a href="{{ url_for('auth.register') }}">Register</a>
{% endif %}
</nav>
</div>
</header>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<footer>
<div class="container">
DigiServer v2.0.0-alpha | Blueprint Architecture | {{ server_version if server_version else 'Development' }}
</div>
</footer>
<script>
// Dark Mode Toggle
function toggleDarkMode() {
const body = document.body;
const themeIcon = document.getElementById('theme-icon');
body.classList.toggle('dark-mode');
// Update icon
if (body.classList.contains('dark-mode')) {
themeIcon.src = "{{ url_for('static', filename='icons/sun.svg') }}";
localStorage.setItem('darkMode', 'enabled');
} else {
themeIcon.src = "{{ url_for('static', filename='icons/moon.svg') }}";
localStorage.setItem('darkMode', 'disabled');
}
}
// Load saved theme preference
document.addEventListener('DOMContentLoaded', function() {
const darkMode = localStorage.getItem('darkMode');
const themeIcon = document.getElementById('theme-icon');
if (darkMode === 'enabled') {
document.body.classList.add('dark-mode');
if (themeIcon) {
themeIcon.src = "{{ url_for('static', filename='icons/sun.svg') }}";
}
}
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
@@ -0,0 +1,205 @@
{% extends "base.html" %}
{% block title %}Content Library - DigiServer v2{% endblock %}
{% block content %}
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Content Library</h1>
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success">+ Upload Content</a>
</div>
{% if content_list %}
<div class="card">
<div style="margin-bottom: 15px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
<strong>Total Files:</strong> {{ content_list|length }} |
<strong>Total Assignments:</strong> {% set total = namespace(count=0) %}{% for item in content_list %}{% set total.count = total.count + item.player_count %}{% endfor %}{{ total.count }}
</div>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">File Name</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Type</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Assigned To</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Uploaded</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody>
{% for item in content_list %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px;">
<strong>{{ item.filename }}</strong>
</td>
<td style="padding: 12px;">
{% if item.content_type == 'image' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📷 Image</span>
{% elif item.content_type == 'video' %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">🎬 Video</span>
{% elif item.content_type == 'pdf' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📄 PDF</span>
{% elif item.content_type == 'presentation' %}
<span style="background: #ffc107; color: black; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📊 PPT</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📁 Other</span>
{% endif %}
</td>
<td style="padding: 12px;">
{{ item.duration }}s
</td>
<td style="padding: 12px;">
{{ item.file_size }} MB
</td>
<td style="padding: 12px;">
{% if item.player_count == 0 %}
<span style="color: #6c757d; font-style: italic;">Not assigned</span>
{% else %}
<div style="max-height: 100px; overflow-y: auto;">
{% for player in item.players %}
<div style="margin-bottom: 5px;">
<strong>{{ player.name }}</strong>
{% if player.group %}
<span style="color: #6c757d; font-size: 12px;">({{ player.group }})</span>
{% endif %}
</div>
{% endfor %}
</div>
<div style="margin-top: 5px;">
<span style="background: #007bff; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
{{ item.player_count }} player{% if item.player_count != 1 %}s{% endif %}
</span>
</div>
{% endif %}
</td>
<td style="padding: 12px;">
<small style="color: #6c757d;">{{ item.uploaded_at | localtime }}</small>
</td>
<td style="padding: 12px;">
{% if item.player_count > 0 %}
{% set first_player = item.players[0] %}
<a href="{{ url_for('players.player_page', player_id=first_player.id) }}"
class="btn btn-primary btn-sm"
title="Manage Playlist for {{ first_player.name }}"
style="margin-bottom: 5px;">
📝 Manage Playlist
</a>
{% if item.player_count > 1 %}
<button onclick="showAllPlayers('{{ item.filename|replace("'", "\\'") }}', {{ item.players|tojson }})"
class="btn btn-info btn-sm"
title="View all players with this content">
👥 View All ({{ item.player_count }})
</button>
{% endif %}
{% endif %}
<button onclick="deleteContent('{{ item.filename|replace("'", "\\'") }}')"
class="btn btn-danger btn-sm"
title="Delete this content from all playlists"
style="margin-top: 5px;">
🗑️ Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px;">
️ No content uploaded yet. <a href="{{ url_for('content.upload_content') }}" style="color: #0c5460; text-decoration: underline;">Upload your first content</a>
</div>
{% endif %}
</div>
<!-- Modal for viewing all players -->
<div id="playersModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 600px; margin: 100px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<h2 id="modalTitle" style="margin-bottom: 20px; color: #2c3e50;">Players with this content</h2>
<div id="playersList" style="max-height: 400px; overflow-y: auto;"></div>
<div style="text-align: center; margin-top: 20px;">
<button type="button" class="btn" onclick="closePlayersModal()">Close</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
overflow-y: auto;
}
</style>
<script>
function showAllPlayers(filename, players) {
document.getElementById('modalTitle').textContent = 'Players with: ' + filename;
const playersList = document.getElementById('playersList');
playersList.innerHTML = '<table style="width: 100%; border-collapse: collapse;">';
playersList.innerHTML += '<thead><tr style="background: #f8f9fa;"><th style="padding: 10px; text-align: left;">Player Name</th><th style="padding: 10px; text-align: left;">Group</th><th style="padding: 10px; text-align: left;">Action</th></tr></thead><tbody>';
players.forEach(player => {
playersList.innerHTML += `
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;"><strong>${player.name}</strong></td>
<td style="padding: 10px;">${player.group || '-'}</td>
<td style="padding: 10px;">
<a href="/players/${player.id}" class="btn btn-sm" style="background: #007bff; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px;">
Manage Playlist
</a>
</td>
</tr>
`;
});
playersList.innerHTML += '</tbody></table>';
document.getElementById('playersModal').style.display = 'block';
}
function closePlayersModal() {
document.getElementById('playersModal').style.display = 'none';
}
function deleteContent(filename) {
if (confirm(`Are you sure you want to delete "${filename}"?\n\nThis will remove it from ALL player playlists!`)) {
fetch('/content/delete-by-filename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename: filename
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Successfully deleted "${filename}" from ${data.deleted_count} playlist(s)`);
location.reload();
} else {
alert('Error deleting content: ' + data.message);
}
})
.catch(error => {
alert('Error deleting content: ' + error);
});
}
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('playersModal');
if (event.target == modal) {
closePlayersModal();
}
}
</script>
{% endblock %}
@@ -0,0 +1,550 @@
{% extends "base.html" %}
{% block title %}Playlist Management - DigiServer v2{% endblock %}
{% block content %}
<style>
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.full-width {
grid-column: 1 / -1;
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 8px 8px 0 0;
margin: -1.5rem -1.5rem 1.5rem -1.5rem;
}
.card-header h2 {
margin: 0;
color: white;
}
.playlist-list {
max-height: 400px;
overflow-y: auto;
}
.playlist-item {
background: #f8f9fa;
padding: 15px;
margin-bottom: 10px;
border-radius: 6px;
border-left: 4px solid #667eea;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.playlist-item:hover {
background: #e9ecef;
transform: translateX(5px);
}
body.dark-mode .playlist-item {
background: #1a202c;
border-left-color: #7c3aed;
}
body.dark-mode .playlist-item:hover {
background: #2d3748;
}
.playlist-info h3 {
margin: 0 0 5px 0;
font-size: 18px;
}
.playlist-stats {
font-size: 14px;
color: #6c757d;
}
body.dark-mode .playlist-stats {
color: #a0aec0;
}
.playlist-actions {
display: flex;
gap: 10px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
body.dark-mode small {
color: #a0aec0;
}
.form-control {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
background: white;
color: #2d3748;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .form-control:focus {
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
}
textarea.form-control {
resize: vertical;
min-height: 80px;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-sm {
padding: 5px 10px;
font-size: 13px;
}
.upload-zone {
border: 2px dashed #dee2e6;
border-radius: 8px;
padding: 30px;
text-align: center;
background: #f8f9fa;
transition: all 0.3s;
}
.upload-zone:hover {
border-color: #667eea;
background: #f0f2ff;
}
.upload-zone.drag-over {
border-color: #667eea;
background: #e7e9ff;
}
.file-input-wrapper {
position: relative;
overflow: hidden;
display: inline-block;
}
.file-input-wrapper input[type=file] {
position: absolute;
left: -9999px;
}
.media-library {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
margin-top: 20px;
}
.media-item {
background: white;
border: 2px solid #dee2e6;
border-radius: 8px;
padding: 10px;
text-align: center;
transition: all 0.2s;
cursor: pointer;
}
.media-item:hover {
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
body.dark-mode .media-item {
background: #2d3748;
border-color: #4a5568;
}
body.dark-mode .media-item:hover {
border-color: #7c3aed;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
.media-thumbnail {
background: #f0f0f0;
}
body.dark-mode .media-thumbnail {
background: #1a202c;
}
.media-icon {
font-size: 48px;
margin-bottom: 10px;
}
.media-name {
font-size: 12px;
word-break: break-word;
}
body.dark-mode .media-name {
color: #e2e8f0;
}
/* Table styling for dark mode */
body.dark-mode table thead tr {
background: #1a202c !important;
}
body.dark-mode table th {
color: #e2e8f0;
border-bottom-color: #4a5568 !important;
}
body.dark-mode table tbody tr {
border-bottom-color: #4a5568 !important;
}
body.dark-mode table td {
color: #e2e8f0;
}
body.dark-mode code {
background: #1a202c !important;
color: #e2e8f0;
}
/* Dark mode for upload section */
body.dark-mode .card > div[style*="background: #f8f9fa"] {
background: #2d3748 !important;
border: 1px solid #4a5568;
}
</style>
<div class="container" style="max-width: 1400px;">
<h1 style="margin-bottom: 25px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 32px; height: 32px;">
Playlist Management
</h1>
<div class="main-grid">
<!-- Create Playlist Card -->
<div class="card">
<div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Create New Playlist
</h2>
</div>
<!-- Create New Playlist Form -->
<form method="POST" action="{{ url_for('content.create_playlist') }}">
<div class="form-group">
<label for="playlist_name">Playlist Name *</label>
<input type="text" name="name" id="playlist_name" class="form-control" required
placeholder="e.g., Main Lobby Display">
</div>
<div class="form-group">
<label for="playlist_orientation">Content Orientation *</label>
<select name="orientation" id="playlist_orientation" class="form-control" required>
<option value="Landscape">Landscape (Horizontal)</option>
<option value="Portrait">Portrait (Vertical)</option>
</select>
<small style="color: #6c757d; font-size: 12px; display: block; margin-top: 5px;">
Select the orientation that matches your display screens
</small>
</div>
<div class="form-group">
<label for="playlist_description">Description (Optional)</label>
<textarea name="description" id="playlist_description" class="form-control"
placeholder="Describe the purpose of this playlist..."></textarea>
</div>
<button type="submit" class="btn btn-primary">
Create Playlist
</button>
</form>
</div>
<!-- Upload Media Card -->
<div class="card">
<div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Media Library
</h2>
</div>
<!-- Compact Upload Section -->
<div style="text-align: center; padding: 20px; background: #f8f9fa; border-radius: 8px; margin-bottom: 20px;">
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
Upload New Media
</a>
</div>
<!-- Media Library with Thumbnails -->
<h3 style="margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between;">
<span>📚 Last 3 Added Media</span>
<small style="color: #6c757d; font-size: 0.85rem;">Total: {{ total_media_count }}</small>
</h3>
<div class="media-library" style="max-height: 350px; overflow-y: auto;">
{% if media_files %}
{% for media in media_files %}
<div class="media-item" title="{{ media.filename }}">
{% if media.content_type == 'image' %}
<div class="media-thumbnail" style="width: 100%; height: 100px; overflow: hidden; border-radius: 6px; margin-bottom: 8px; background: #f0f0f0; display: flex; align-items: center; justify-content: center;">
<img src="{{ url_for('static', filename='uploads/' + media.filename) }}"
alt="{{ media.filename }}"
style="max-width: 100%; max-height: 100%; object-fit: cover;"
onerror="this.style.display='none'; this.parentElement.innerHTML='<span style=\'font-size: 48px;\'>📷</span>'">
</div>
{% elif media.content_type == 'video' %}
<div class="media-icon" style="height: 100px; display: flex; align-items: center; justify-content: center; background: #f0f0f0; border-radius: 6px; margin-bottom: 8px;">
🎥
</div>
{% elif media.content_type == 'pdf' %}
<div class="media-icon" style="height: 100px; display: flex; align-items: center; justify-content: center; background: #f0f0f0; border-radius: 6px; margin-bottom: 8px;">
📄
</div>
{% else %}
<div class="media-icon" style="height: 100px; display: flex; align-items: center; justify-content: center; background: #f0f0f0; border-radius: 6px; margin-bottom: 8px;">
📁
</div>
{% endif %}
<div class="media-name" style="font-size: 11px; line-height: 1.3;">{{ media.filename[:25] }}{% if media.filename|length > 25 %}...{% endif %}</div>
<div style="font-size: 10px; color: #999; margin-top: 4px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
</div>
{% endfor %}
{% else %}
<div style="text-align: center; padding: 40px; color: #999; grid-column: 1 / -1;">
<div style="font-size: 48px; margin-bottom: 10px;">📭</div>
<p>No media files yet. Upload your first file!</p>
</div>
{% endif %}
</div>
<!-- View All Media Button -->
{% if total_media_count > 3 %}
<div style="text-align: center; padding: 15px; border-top: 1px solid #dee2e6; margin-top: 10px;">
<a href="{{ url_for('content.media_library') }}" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<span>📚</span>
View All Media ({{ total_media_count }} files)
</a>
</div>
{% endif %}
</div>
</div>
<!-- Existing Playlists Card -->
<div class="card full-width">
<div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Existing Playlists
</h2>
</div>
<div class="playlist-list">
{% if playlists %}
{% for playlist in playlists %}
<div class="playlist-item">
<div class="playlist-info">
<h3>{{ playlist.name }}</h3>
<div class="playlist-stats">
📊 {{ playlist.content_count }} items |
👥 {{ playlist.player_count }} players |
🔄 v{{ playlist.version }}
</div>
</div>
<div class="playlist-actions">
<a href="{{ url_for('content.manage_playlist_content', playlist_id=playlist.id) }}"
class="btn btn-primary btn-sm">
✏️ Manage
</a>
<form method="POST"
action="{{ url_for('content.delete_playlist', playlist_id=playlist.id) }}"
style="display: inline;"
onsubmit="return confirm('Delete playlist {{ playlist.name }}?');">
<button type="submit" class="btn btn-danger btn-sm" style="display: flex; align-items: center; gap: 0.3rem;">
<img src="{{ url_for('static', filename='icons/trash.svg') }}" alt="" style="width: 14px; height: 14px; filter: brightness(0) invert(1);">
Delete
</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 64px; height: 64px; opacity: 0.3; margin-bottom: 10px;">
<p>No playlists yet. Create your first playlist above!</p>
</div>
{% endif %}
</div>
</div>
<!-- Assign Players to Playlists Card -->
<div class="card full-width">
<div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Player Assignments
</h2>
</div>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Player Name</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Hostname</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Location</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Assigned Playlist</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody>
{% for player in players %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px;"><strong>{{ player.name }}</strong></td>
<td style="padding: 12px;">
<code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">
{{ player.hostname }}
</code>
</td>
<td style="padding: 12px;">{{ player.location or '-' }}</td>
<td style="padding: 12px;">
<form method="POST" action="{{ url_for('content.assign_player_to_playlist', player_id=player.id) }}"
style="display: inline;">
<select name="playlist_id" class="form-control" style="width: auto; display: inline-block;"
onchange="this.form.submit()">
<option value="">No Playlist</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}"
{% if player.playlist_id == playlist.id %}selected{% endif %}>
{{ playlist.name }}
</option>
{% endfor %}
</select>
</form>
</td>
<td style="padding: 12px;">
{% if player.is_online %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
🟢 Online
</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
⚫ Offline
</span>
{% endif %}
</td>
<td style="padding: 12px;">
<div style="display: flex; gap: 0.5rem;">
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
class="btn btn-primary btn-sm">
⚙️ Manage
</a>
{% if player.playlist_id %}
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
class="btn btn-success btn-sm" target="_blank"
title="View live content preview">
🖥️ Live
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script>
// File upload handling
const fileInput = document.getElementById('file-input');
const uploadZone = document.getElementById('upload-zone');
const fileList = document.getElementById('file-list');
const uploadBtn = document.getElementById('upload-btn');
fileInput.addEventListener('change', handleFiles);
// Drag and drop
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('drag-over');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('drag-over');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('drag-over');
fileInput.files = e.dataTransfer.files;
handleFiles();
});
function handleFiles() {
const files = fileInput.files;
fileList.innerHTML = '';
if (files.length > 0) {
uploadBtn.disabled = false;
const ul = document.createElement('ul');
ul.style.cssText = 'list-style: none; padding: 0;';
for (let file of files) {
const li = document.createElement('li');
li.style.cssText = 'padding: 8px; background: #f8f9fa; margin-bottom: 5px; border-radius: 4px;';
li.textContent = `📎 ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`;
ul.appendChild(li);
}
fileList.appendChild(ul);
} else {
uploadBtn.disabled = true;
}
}
</script>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Edit Content{% endblock %}
{% block content %}
<div class="container">
<h2>Edit Content</h2>
<p>Edit content functionality - placeholder</p>
<a href="{{ url_for('content.list') }}" class="btn btn-secondary">Back to Content</a>
</div>
{% endblock %}
@@ -0,0 +1,719 @@
{% extends "base.html" %}
{% block title %}Manage {{ playlist.name }} - DigiServer v2{% endblock %}
{% block content %}
<style>
.playlist-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 12px;
margin-bottom: 25px;
}
.playlist-header h1 {
margin: 0 0 10px 0;
}
.stats-row {
display: flex;
gap: 30px;
margin-top: 15px;
}
.stat-item {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 12px;
opacity: 0.9;
}
.stat-value {
font-size: 24px;
font-weight: bold;
}
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 20px;
}
.playlist-table {
width: 100%;
border-collapse: collapse;
}
.playlist-table th {
background: #f8f9fa;
padding: 12px;
text-align: left;
border-bottom: 2px solid #dee2e6;
}
.playlist-table td {
padding: 12px;
border-bottom: 1px solid #dee2e6;
}
.draggable-row {
cursor: move;
}
.draggable-row:hover {
background: #f8f9fa;
}
.drag-handle {
cursor: grab;
font-size: 18px;
color: #999;
}
.available-content {
max-height: 500px;
overflow-y: auto;
}
.content-item {
background: #f8f9fa;
padding: 12px;
margin-bottom: 10px;
border-radius: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Audio toggle styles */
.audio-toggle {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
}
/* Duration spinner control */
.duration-spinner {
display: flex;
align-items: center;
gap: 8px;
pointer-events: auto;
}
.duration-display {
min-width: 60px;
text-align: center;
font-weight: 500;
font-size: 16px;
padding: 6px 12px;
background: #f5f5f5;
border-radius: 4px;
border: 1px solid #ddd;
pointer-events: auto;
}
.duration-spinner button {
width: 36px;
height: 36px;
padding: 0;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
pointer-events: auto;
}
.duration-spinner button:hover {
background: #f0f0f0;
border-color: #999;
}
.duration-spinner button:active {
background: #e0e0e0;
transform: scale(0.95);
}
.duration-spinner button.btn-increase {
color: #28a745;
}
.duration-spinner button.btn-decrease {
color: #dc3545;
}
.audio-toggle {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.audio-checkbox {
display: none;
}
.audio-label {
font-size: 20px;
transition: all 0.3s ease;
}
.audio-checkbox + .audio-label .audio-on {
display: none;
}
.audio-checkbox + .audio-label .audio-off {
display: inline;
}
.audio-checkbox:checked + .audio-label .audio-on {
display: inline;
}
.audio-checkbox:checked + .audio-label .audio-off {
display: none;
}
.audio-label:hover {
transform: scale(1.2);
}
/* Dark mode support */
body.dark-mode .playlist-table th {
background: #1a202c;
color: #cbd5e0;
border-bottom-color: #4a5568;
}
body.dark-mode .playlist-table td {
border-bottom-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .draggable-row:hover {
background: #1a202c;
}
body.dark-mode .drag-handle {
color: #718096;
}
body.dark-mode .content-item {
background: #1a202c;
color: #e2e8f0;
}
body.dark-mode .available-content {
color: #e2e8f0;
}
/* Dark mode for duration spinner */
body.dark-mode .duration-display {
background: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .duration-spinner button {
background: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .duration-spinner button:hover {
background: #4a5568;
border-color: #718096;
}
body.dark-mode .duration-spinner button:active {
background: #5a6a78;
}
body.dark-mode .duration-spinner button.btn-increase {
color: #48bb78;
}
body.dark-mode .duration-spinner button.btn-decrease {
color: #f56565;
}
</style>
<div class="container" style="max-width: 1400px;">
<div class="playlist-header">
<h1>🎬 {{ playlist.name }}</h1>
{% if playlist.description %}
<p style="margin: 5px 0; opacity: 0.9;">{{ playlist.description }}</p>
{% endif %}
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">Content Items</span>
<span class="stat-value">{{ playlist_content|length }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Total Duration</span>
<span class="stat-value">{{ playlist.total_duration }}s</span>
</div>
<div class="stat-item">
<span class="stat-label">Version</span>
<span class="stat-value">{{ playlist.version }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Players Assigned</span>
<span class="stat-value">{{ playlist.player_count }}</span>
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">
← Back to Playlists
</a>
</div>
<div class="content-grid">
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">📋 Playlist Content (Drag to Reorder)</h2>
<button id="bulk-delete-btn" class="btn btn-danger" style="display: none;" onclick="bulkDeleteSelected()">
🗑️ Delete Selected (<span id="selected-count">0</span>)
</button>
</div>
{% if playlist_content %}
<table class="playlist-table" id="playlist-table">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" title="Select all">
</th>
<th style="width: 40px;"></th>
<th style="width: 50px;">#</th>
<th>Filename</th>
<th style="width: 100px;">Type</th>
<th style="width: 100px;">Duration</th>
<th style="width: 80px;">Audio</th>
<th style="width: 80px;">Edit</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody id="playlist-tbody">
{% for content in playlist_content %}
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
<td>
<input type="checkbox" class="content-checkbox" data-content-id="{{ content.id }}" onchange="updateBulkDeleteButton()">
</td>
<td><span class="drag-handle">⋮⋮</span></td>
<td>{{ loop.index }}</td>
<td>{{ content.filename }}</td>
<td>
{% if content.content_type == 'image' %}📷 Image
{% elif content.content_type == 'video' %}🎥 Video
{% elif content.content_type == 'pdf' %}📄 PDF
{% else %}📁 Other{% endif %}
</td>
<td>
<div class="duration-spinner">
<button type="button"
class="btn-decrease"
onclick="event.stopPropagation(); changeDuration({{ content.id }}, -1)"
onmousedown="event.stopPropagation()"
title="Decrease duration by 1 second">
⬇️
</button>
<div class="duration-display" id="duration-display-{{ content.id }}">
{{ content._playlist_duration or content.duration }}s
</div>
<button type="button"
class="btn-increase"
onclick="event.stopPropagation(); changeDuration({{ content.id }}, 1)"
onmousedown="event.stopPropagation()"
title="Increase duration by 1 second">
⬆️
</button>
</div>
</td>
<td>
{% if content.content_type == 'video' %}
<label class="audio-toggle">
<input type="checkbox"
class="audio-checkbox"
data-content-id="{{ content.id }}"
{{ 'checked' if not content._playlist_muted else '' }}
onchange="toggleAudio({{ content.id }}, this.checked)">
<span class="audio-label">
<span class="audio-on">🔊</span>
<span class="audio-off">🔇</span>
</span>
</label>
{% else %}
<span style="color: #999;"></span>
{% endif %}
</td>
<td>
{% if content.content_type in ['image', 'pdf'] %}
<label class="audio-toggle">
<input type="checkbox"
class="edit-checkbox"
data-content-id="{{ content.id }}"
{{ 'checked' if content._playlist_edit_on_player_enabled else '' }}
onchange="toggleEdit({{ content.id }}, this.checked)">
<span class="audio-label">
<span class="audio-on">✏️</span>
<span class="audio-off">🔒</span>
</span>
</label>
{% else %}
<span style="color: #999;"></span>
{% endif %}
</td>
<td>
<form method="POST"
action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}"
style="display: inline;"
onsubmit="return confirm('Remove from playlist?');">
<button type="submit" class="btn btn-danger btn-sm">
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px;">📭</div>
<p>No content in playlist yet. Add content from the right panel.</p>
</div>
{% endif %}
</div>
<div class="card">
<h2 style="margin-bottom: 20px;"> Add Content</h2>
{% if available_content %}
<div class="available-content">
{% for content in available_content %}
<div class="content-item">
<div>
<div>
{% if content.content_type == 'image' %}📷
{% elif content.content_type == 'video' %}🎥
{% elif content.content_type == 'pdf' %}📄
{% else %}📁{% endif %}
{{ content.filename }}
</div>
<div style="font-size: 12px; color: #999;">
{{ content.file_size_mb }} MB
</div>
</div>
<form method="POST"
action="{{ url_for('content.add_content_to_playlist', playlist_id=playlist.id) }}"
style="display: inline;">
<input type="hidden" name="content_id" value="{{ content.id }}">
<input type="hidden" name="duration" value="{{ content.duration }}">
<button type="submit" class="btn btn-success btn-sm">
+ Add
</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<p>All available content has been added to this playlist!</p>
</div>
{% endif %}
</div>
</div>
</div>
<script>
let draggedElement = null;
document.addEventListener('DOMContentLoaded', function() {
const tbody = document.getElementById('playlist-tbody');
if (!tbody) return;
const rows = tbody.querySelectorAll('.draggable-row');
rows.forEach(row => {
row.addEventListener('dragstart', handleDragStart);
row.addEventListener('dragover', handleDragOver);
row.addEventListener('drop', handleDrop);
row.addEventListener('dragend', handleDragEnd);
});
});
function handleDragStart(e) {
draggedElement = this;
this.style.opacity = '0.5';
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
return false;
}
function handleDrop(e) {
if (e.stopPropagation) {
e.stopPropagation();
}
if (draggedElement !== this) {
const tbody = document.getElementById('playlist-tbody');
const allRows = [...tbody.querySelectorAll('.draggable-row')];
const draggedIndex = allRows.indexOf(draggedElement);
const targetIndex = allRows.indexOf(this);
if (draggedIndex < targetIndex) {
this.parentNode.insertBefore(draggedElement, this.nextSibling);
} else {
this.parentNode.insertBefore(draggedElement, this);
}
updateRowNumbers();
saveOrder();
}
return false;
}
function handleDragEnd(e) {
this.style.opacity = '1';
}
function updateRowNumbers() {
const rows = document.querySelectorAll('#playlist-tbody tr');
rows.forEach((row, index) => {
row.querySelector('td:nth-child(2)').textContent = index + 1;
});
}
function saveOrder() {
const rows = document.querySelectorAll('#playlist-tbody .draggable-row');
const contentIds = Array.from(rows).map(row => parseInt(row.dataset.contentId));
fetch('{{ url_for("content.reorder_playlist_content", playlist_id=playlist.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content_ids: contentIds })
})
.then(response => response.json())
.then(data => {
if (!data.success) {
alert('Error reordering: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
});
}
// Change duration with spinner buttons
function changeDuration(contentId, change) {
const displayElement = document.getElementById(`duration-display-${contentId}`);
const currentText = displayElement.textContent;
const currentDuration = parseInt(currentText);
const newDuration = currentDuration + change;
// Validate duration (minimum 1 second)
if (newDuration < 1) {
alert('Duration must be at least 1 second');
return;
}
// Update display immediately for visual feedback
displayElement.style.opacity = '0.7';
displayElement.textContent = newDuration + 's';
// Save to server
const playlistId = {{ playlist.id }};
const url = `/content/playlist/${playlistId}/update-duration/${contentId}`;
const formData = new FormData();
formData.append('duration', newDuration);
fetch(url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Duration updated successfully');
displayElement.style.opacity = '1';
displayElement.style.color = '#28a745';
setTimeout(() => {
displayElement.style.color = '';
}, 1000);
} else {
// Revert on error
displayElement.textContent = currentDuration + 's';
displayElement.style.opacity = '1';
alert('Error updating duration: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
// Revert on error
displayElement.textContent = currentDuration + 's';
displayElement.style.opacity = '1';
console.error('Error:', error);
alert('Error updating duration');
});
}
function toggleAudio(contentId, enabled) {
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
const playlistId = {{ playlist.id }};
const url = `/content/playlist/${playlistId}/update-muted/${contentId}`;
const formData = new FormData();
formData.append('muted', muted ? 'true' : 'false');
fetch(url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Audio setting updated:', enabled ? 'Enabled' : 'Muted');
} else {
alert('Error updating audio setting: ' + data.message);
// Revert checkbox on error
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
}
})
.catch(error => {
console.error('Error:', error);
alert('Error updating audio setting');
// Revert checkbox on error
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
});
}
function toggleEdit(contentId, enabled) {
const playlistId = {{ playlist.id }};
const url = `/content/playlist/${playlistId}/update-edit-enabled/${contentId}`;
const formData = new FormData();
formData.append('edit_enabled', enabled ? 'true' : 'false');
fetch(url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Edit setting updated:', enabled ? 'Enabled' : 'Disabled');
} else {
alert('Error updating edit setting: ' + data.message);
// Revert checkbox on error
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
}
})
.catch(error => {
console.error('Error:', error);
alert('Error updating edit setting');
// Revert checkbox on error
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
});
}
function toggleSelectAll(checkbox) {
const checkboxes = document.querySelectorAll('.content-checkbox');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
updateBulkDeleteButton();
}
function updateBulkDeleteButton() {
const checkboxes = document.querySelectorAll('.content-checkbox:checked');
const count = checkboxes.length;
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
const selectedCount = document.getElementById('selected-count');
if (count > 0) {
bulkDeleteBtn.style.display = 'block';
selectedCount.textContent = count;
} else {
bulkDeleteBtn.style.display = 'none';
}
// Update select-all checkbox state
const allCheckboxes = document.querySelectorAll('.content-checkbox');
const selectAllCheckbox = document.getElementById('select-all');
if (selectAllCheckbox) {
selectAllCheckbox.checked = allCheckboxes.length > 0 && count === allCheckboxes.length;
selectAllCheckbox.indeterminate = count > 0 && count < allCheckboxes.length;
}
}
function bulkDeleteSelected() {
const checkboxes = document.querySelectorAll('.content-checkbox:checked');
const contentIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.contentId));
if (contentIds.length === 0) {
alert('No items selected');
return;
}
const confirmMsg = `Are you sure you want to remove ${contentIds.length} item(s) from this playlist?`;
if (!confirm(confirmMsg)) {
return;
}
// Show loading state
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
const originalText = bulkDeleteBtn.innerHTML;
bulkDeleteBtn.disabled = true;
bulkDeleteBtn.innerHTML = '⏳ Removing...';
fetch('{{ url_for("content.bulk_remove_from_playlist", playlist_id=playlist.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content_ids: contentIds })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Reload page to show updated playlist
window.location.reload();
} else {
alert('Error removing items: ' + data.message);
bulkDeleteBtn.disabled = false;
bulkDeleteBtn.innerHTML = originalText;
}
})
.catch(error => {
console.error('Error:', error);
alert('Error removing items from playlist');
bulkDeleteBtn.disabled = false;
bulkDeleteBtn.innerHTML = originalText;
});
}
</script>
{% endblock %}
@@ -0,0 +1,806 @@
{% extends "base.html" %}
{% block title %}Media Library - DigiServer v2{% endblock %}
{% block content %}
<style>
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
margin-top: 20px;
}
.media-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 10px;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
}
.media-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
body.dark-mode .media-card {
background: #1a202c;
border-color: #4a5568;
}
.media-thumbnail {
width: 100%;
height: 150px;
border-radius: 6px;
overflow: hidden;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
}
body.dark-mode .media-thumbnail {
background: #2d3748;
}
.media-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-icon {
font-size: 64px;
}
.media-info {
font-size: 12px;
color: #6c757d;
}
body.dark-mode .media-info {
color: #a0aec0;
}
.media-filename {
font-weight: 500;
margin-bottom: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body.dark-mode .media-filename {
color: #e2e8f0;
}
.delete-btn {
position: absolute;
top: 5px;
right: 5px;
background: #dc3545;
color: white;
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
opacity: 0;
transition: opacity 0.3s ease;
}
.media-card:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: #a02834;
}
.playlist-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
margin-top: 5px;
}
.playlist-badge.in-use {
background: #ffc107;
color: #000;
}
.playlist-badge.unused {
background: #28a745;
color: white;
}
body.dark-mode .playlist-badge.in-use {
background: #856404;
color: #ffc107;
}
body.dark-mode .upload-section {
background: #2d3748 !important;
border: 1px solid #4a5568;
}
body.dark-mode .playlist-badge.unused {
background: #1a4d2e;
color: #86efac;
}
.type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.type-badge.image { background: #d4edda; color: #155724; }
.type-badge.video { background: #cce5ff; color: #004085; }
.type-badge.pdf { background: #fff3cd; color: #856404; }
.type-badge.pptx { background: #f8d7da; color: #721c24; }
body.dark-mode .type-badge.image { background: #1a4d2e; color: #86efac; }
body.dark-mode .type-badge.video { background: #1e3a5f; color: #93c5fd; }
body.dark-mode .type-badge.pdf { background: #4a3800; color: #fbbf24; }
body.dark-mode .type-badge.pptx { background: #4a1a1a; color: #fca5a5; }
.stats-box {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.stat-item {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
text-align: center;
}
body.dark-mode .stat-item {
background: #1a202c;
border-color: #4a5568;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #7c3aed;
}
body.dark-mode .stat-value {
color: #a78bfa;
}
.stat-label {
font-size: 14px;
color: #6c757d;
margin-top: 5px;
}
body.dark-mode .stat-label {
color: #a0aec0;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin: 30px 0 15px 0;
padding-bottom: 10px;
border-bottom: 2px solid #7c3aed;
}
body.dark-mode .section-header {
border-bottom-color: #a78bfa;
}
.section-header h2 {
margin: 0;
font-size: 24px;
}
body.dark-mode .section-header h2 {
color: #e2e8f0;
}
</style>
<div style="margin-bottom: 2rem;">
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 32px; height: 32px;">
📚 Media Library
</h1>
<a href="{{ url_for('content.content_list') }}" class="btn" style="float: right; display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Back to Playlists
</a>
</div>
<!-- Statistics -->
<div class="stats-box">
<div class="stat-item">
<div class="stat-value">{{ media_files|length }}</div>
<div class="stat-label">Total Files</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ images|length }}</div>
<div class="stat-label">Images</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ videos|length }}</div>
<div class="stat-label">Videos</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ pdfs|length }}</div>
<div class="stat-label">PDFs</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ presentations|length }}</div>
<div class="stat-label">Presentations</div>
</div>
</div>
<!-- Upload Button -->
<div class="upload-section" style="text-align: center; padding: 20px; background: #f8f9fa; border-radius: 8px; margin-bottom: 30px;">
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload New Media
</a>
</div>
<!-- Images Section -->
{% if images %}
<div class="section-header">
<span style="font-size: 32px;">📷</span>
<h2>Images ({{ images|length }})</h2>
</div>
<div class="media-grid">
{% for media in images %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<img src="{{ url_for('static', filename='uploads/' + media.filename) }}"
alt="{{ media.filename }}"
onerror="this.style.display='none'; this.parentElement.innerHTML='<span class=\'media-icon\'>📷</span>'">
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge image">Image</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Videos Section -->
{% if videos %}
<div class="section-header">
<span style="font-size: 32px;">🎥</span>
<h2>Videos ({{ videos|length }})</h2>
</div>
<div class="media-grid">
{% for media in videos %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">🎥</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge video">Video</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- PDFs Section -->
{% if pdfs %}
<div class="section-header">
<span style="font-size: 32px;">📄</span>
<h2>PDFs ({{ pdfs|length }})</h2>
</div>
<div class="media-grid">
{% for media in pdfs %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">📄</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge pdf">PDF</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Presentations Section -->
{% if presentations %}
<div class="section-header">
<span style="font-size: 32px;">📊</span>
<h2>Presentations ({{ presentations|length }})</h2>
</div>
<div class="media-grid">
{% for media in presentations %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">📊</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge pptx">PPTX</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Others Section -->
{% if others %}
<div class="section-header">
<span style="font-size: 32px;">📁</span>
<h2>Other Files ({{ others|length }})</h2>
</div>
<div class="media-grid">
{% for media in others %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">📁</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge">{{ media.content_type }}</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if not media_files %}
<div style="text-align: center; padding: 80px 20px;">
<div style="font-size: 96px; margin-bottom: 20px;">📭</div>
<h2 style="color: #6c757d;">No Media Files Yet</h2>
<p style="color: #999; margin-bottom: 30px;">Start by uploading your first media file!</p>
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload Media
</a>
</div>
{% endif %}
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="delete-modal">
<div class="delete-modal-content">
<div class="delete-modal-header">
<span class="delete-icon">⚠️</span>
<h2>Confirm Delete</h2>
</div>
<div class="delete-modal-body">
<p class="delete-question">
Are you sure you want to delete <strong id="deleteFilename" class="delete-filename"></strong>?
</p>
<div id="playlistWarning" class="warning-box warning-playlist">
<div class="warning-header">
<span>⚠️</span>
<strong>Playlist Warning</strong>
</div>
<p>This file is used in <strong id="playlistCount"></strong> playlist(s). Deleting it will remove it from all playlists and increment their version numbers.</p>
</div>
<div id="editWarning" class="warning-box warning-edit">
<div class="warning-header">
<span>✏️</span>
<strong>Edited Versions</strong>
</div>
<p>This file has <strong id="editCount"></strong> edited version(s) from player devices. All edited versions and their metadata will also be permanently deleted.</p>
</div>
<div class="delete-final-warning">
<strong>⚠️ This action cannot be undone!</strong>
</div>
</div>
<div class="delete-modal-footer">
<button onclick="closeDeleteModal()" class="btn btn-cancel">
Cancel
</button>
<form id="deleteForm" method="POST">
<button type="submit" class="btn btn-delete">
Yes, Delete File
</button>
</form>
</div>
</div>
</div>
<style>
/* Delete Modal - Light Mode Styles */
.delete-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.delete-modal-content {
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
margin: 8% auto;
padding: 0;
border-radius: 16px;
width: 90%;
max-width: 550px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideDown 0.3s ease-out;
border: 1px solid rgba(0, 0, 0, 0.1);
}
@keyframes slideDown {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.delete-modal-header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
color: white;
padding: 24px 30px;
border-radius: 16px 16px 0 0;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
}
.delete-icon {
font-size: 2rem;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.delete-modal-header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.delete-modal-body {
padding: 30px;
}
.delete-question {
font-size: 1.1rem;
margin: 0 0 20px 0;
color: #2d3748;
line-height: 1.6;
}
.delete-filename {
color: #dc3545;
word-break: break-word;
}
.warning-box {
display: none;
padding: 16px;
margin: 16px 0;
border-radius: 10px;
border-left: 4px solid;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: transform 0.2s ease;
}
.warning-box:hover {
transform: translateX(4px);
}
.warning-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.warning-header span {
font-size: 1.2rem;
}
.warning-header strong {
font-size: 1rem;
font-weight: 600;
}
.warning-box p {
margin: 0;
line-height: 1.5;
font-size: 0.95rem;
}
.warning-playlist {
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
border-color: #ffc107;
}
.warning-playlist .warning-header,
.warning-playlist p {
color: #856404;
}
.warning-edit {
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
border-color: #7c3aed;
}
.warning-edit .warning-header,
.warning-edit p {
color: #5b21b6;
}
.delete-final-warning {
background: linear-gradient(135deg, #fee 0%, #fdd 100%);
border: 2px solid #dc3545;
border-radius: 8px;
padding: 12px 16px;
margin: 20px 0 0 0;
text-align: center;
}
.delete-final-warning strong {
color: #dc3545;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.delete-modal-footer {
padding: 20px 30px;
display: flex;
gap: 12px;
justify-content: flex-end;
background: rgba(0, 0, 0, 0.02);
border-radius: 0 0 16px 16px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.delete-modal-footer form {
margin: 0;
}
.btn-cancel {
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
color: white;
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.btn-cancel:hover {
background: linear-gradient(135deg, #5a6268 0%, #4e555b 100%);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
transform: translateY(-2px);
}
.btn-delete {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
color: white;
padding: 12px 28px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(220, 53, 69, 0.3);
}
.btn-delete:hover {
background: linear-gradient(135deg, #c82333 0%, #bd2130 100%);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.5);
transform: translateY(-2px);
}
/* Delete Modal - Dark Mode Styles */
body.dark-mode .delete-modal {
background-color: rgba(0, 0, 0, 0.8);
}
body.dark-mode .delete-modal-content {
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
border: 1px solid #4a5568;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}
body.dark-mode .delete-modal-header {
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.4);
}
body.dark-mode .delete-question {
color: #e2e8f0;
}
body.dark-mode .delete-filename {
color: #f87171;
}
body.dark-mode .warning-playlist {
background: linear-gradient(135deg, #422006 0%, #713f12 100%);
border-color: #fbbf24;
}
body.dark-mode .warning-playlist .warning-header,
body.dark-mode .warning-playlist p {
color: #fde68a;
}
body.dark-mode .warning-edit {
background: linear-gradient(135deg, #3b0764 0%, #581c87 100%);
border-color: #a78bfa;
}
body.dark-mode .warning-edit .warning-header,
body.dark-mode .warning-edit p {
color: #ddd6fe;
}
body.dark-mode .delete-final-warning {
background: linear-gradient(135deg, #450a0a 0%, #7f1d1d 100%);
border-color: #f87171;
}
body.dark-mode .delete-final-warning strong {
color: #fca5a5;
}
body.dark-mode .delete-modal-footer {
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid #4a5568;
}
body.dark-mode .btn-cancel {
background: linear-gradient(135deg, #374151 0%, #1f2937 100%);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
body.dark-mode .btn-cancel:hover {
background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
body.dark-mode .btn-delete {
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
box-shadow: 0 2px 6px rgba(185, 28, 28, 0.4);
}
body.dark-mode .btn-delete:hover {
background: linear-gradient(135deg, #991b1b 0%, #7f1d1d 100%);
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.6);
}
</style>
<script>
let deleteMediaId = null;
function confirmDelete(mediaId, filename, playlistCount, editCount) {
deleteMediaId = mediaId;
document.getElementById('deleteFilename').textContent = filename;
document.getElementById('deleteForm').action = "{{ url_for('content.delete_media', media_id=0) }}".replace('/0', '/' + mediaId);
// Show playlist warning if file is in use
if (playlistCount > 0) {
document.getElementById('playlistWarning').style.display = 'block';
document.getElementById('playlistCount').textContent = playlistCount;
} else {
document.getElementById('playlistWarning').style.display = 'none';
}
// Show edit versions warning if file has been edited
if (editCount > 0) {
document.getElementById('editWarning').style.display = 'block';
document.getElementById('editCount').textContent = editCount;
} else {
document.getElementById('editWarning').style.display = 'none';
}
document.getElementById('deleteModal').style.display = 'block';
}
function closeDeleteModal() {
document.getElementById('deleteModal').style.display = 'none';
deleteMediaId = null;
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('deleteModal');
if (event.target == modal) {
closeDeleteModal();
}
}
// Close modal with ESC key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeDeleteModal();
}
});
</script>
{% endblock %}
@@ -0,0 +1,278 @@
{% extends "base.html" %}
{% block title %}Upload Content - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 1200px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Upload Content</h1>
</div>
<form id="upload-form" method="POST" enctype="multipart/form-data" onsubmit="handleFormSubmit(event)">
<input type="hidden" name="return_url" value="{{ return_url or url_for('content.content_list') }}">
<div class="card" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Select Player</h3>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Player:</label>
<select name="player_id" id="player_id" class="form-control" required>
<option value="" disabled {% if not selected_player_id %}selected{% endif %}>Select a Player</option>
{% for player in players %}
<option value="{{ player.id }}" {% if selected_player_id == player.id %}selected{% endif %}>
{{ player.name }} - {{ player.location or 'No location' }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="card" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Media Details</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Media Type:</label>
<select name="media_type" id="media_type" class="form-control" required onchange="handleMediaTypeChange()">
<option value="image">Image (JPG, PNG, GIF)</option>
<option value="video">Video (MP4, AVI, MOV)</option>
<option value="pdf">PDF Document</option>
<option value="ppt">PowerPoint (PPT/PPTX)</option>
</select>
<small style="color: #6c757d; display: block; margin-top: 5px;" id="media-type-hint">
Images will be displayed as-is
</small>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Duration (seconds):</label>
<input type="number" name="duration" id="duration" class="form-control" required min="1" value="10">
<small style="color: #6c757d; display: block; margin-top: 5px;">
How long to display each image/slide (videos use actual length)
</small>
</div>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Files:</label>
<input type="file" name="files" id="files" class="form-control" multiple required
accept="image/*,video/*,.pdf,.ppt,.pptx" onchange="handleFileChange()">
<small style="color: #6c757d; display: block; margin-top: 5px;">
Select multiple files. Supported: JPG, PNG, GIF, MP4, PDF, PPT, PPTX
</small>
<div id="file-list" style="margin-top: 10px;"></div>
</div>
</div>
<div style="text-align: center;">
<button type="submit" id="submit-button" class="btn btn-success" style="padding: 10px 30px; font-size: 16px;">
📤 Upload Files
</button>
<a href="{{ return_url or url_for('content.content_list') }}" class="btn" style="padding: 10px 30px; font-size: 16px;">
← Back
</a>
</div>
</form>
</div>
<!-- Modal for Status Updates -->
<div id="statusModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 800px; margin: 50px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<h2 style="margin-bottom: 20px; color: #2c3e50;">Processing Files</h2>
<div style="margin-bottom: 20px;">
<p id="status-message" style="font-size: 16px; color: #555;">Uploading and processing your files. Please wait...</p>
</div>
<!-- Progress Bar -->
<div style="margin-bottom: 30px;">
<label style="display: block; margin-bottom: 10px; font-weight: bold;">File Processing Progress</label>
<div style="width: 100%; height: 30px; background: #e9ecef; border-radius: 5px; overflow: hidden;">
<div id="progress-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #007bff, #0056b3); transition: width 0.3s ease; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 14px;">
0%
</div>
</div>
</div>
<div style="text-align: center; margin-top: 20px;">
<button type="button" class="btn" onclick="closeModal()" disabled id="close-modal-btn">Close</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
overflow-y: auto;
}
</style>
<script>
let progressInterval = null;
let sessionId = null;
let returnUrl = '{{ return_url or url_for("content.content_list") }}';
function generateSessionId() {
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
function handleFormSubmit(event) {
event.preventDefault();
sessionId = generateSessionId();
const form = document.getElementById('upload-form');
let sessionInput = document.getElementById('session_id_input');
if (!sessionInput) {
sessionInput = document.createElement('input');
sessionInput.type = 'hidden';
sessionInput.name = 'session_id';
sessionInput.id = 'session_id_input';
form.appendChild(sessionInput);
}
sessionInput.value = sessionId;
showStatusModal();
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Upload failed');
}
console.log('Form submitted successfully');
})
.catch(error => {
console.error('Form submission error:', error);
document.getElementById('status-message').textContent = 'Upload failed: ' + error.message;
document.getElementById('progress-bar').style.background = '#dc3545';
document.getElementById('close-modal-btn').disabled = false;
});
}
function showStatusModal() {
const modal = document.getElementById('statusModal');
modal.style.display = 'block';
const mediaType = document.getElementById('media_type').value;
const statusMessage = document.getElementById('status-message');
switch(mediaType) {
case 'image':
statusMessage.textContent = 'Uploading images...';
break;
case 'video':
statusMessage.textContent = 'Uploading and converting video. This may take several minutes...';
break;
case 'pdf':
statusMessage.textContent = 'Uploading and converting PDF to images...';
break;
case 'ppt':
statusMessage.textContent = 'Uploading and converting PowerPoint to images...';
break;
default:
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
}
pollUploadProgress();
}
function closeModal() {
const modal = document.getElementById('statusModal');
modal.style.display = 'none';
if (progressInterval) {
clearInterval(progressInterval);
}
window.location.href = returnUrl;
}
function pollUploadProgress() {
progressInterval = setInterval(() => {
fetch(`/api/upload-progress/${sessionId}`)
.then(response => response.json())
.then(data => {
const progressBar = document.getElementById('progress-bar');
progressBar.style.width = `${data.progress}%`;
progressBar.textContent = `${data.progress}%`;
document.getElementById('status-message').textContent = data.message;
if (data.status === 'complete' || data.status === 'error') {
clearInterval(progressInterval);
progressInterval = null;
const closeBtn = document.getElementById('close-modal-btn');
closeBtn.disabled = false;
if (data.status === 'complete') {
progressBar.style.background = '#28a745';
setTimeout(() => closeModal(), 2000);
} else if (data.status === 'error') {
progressBar.style.background = '#dc3545';
}
}
})
.catch(error => console.error('Error fetching progress:', error));
}, 500);
}
function handleMediaTypeChange() {
const mediaType = document.getElementById('media_type').value;
const hint = document.getElementById('media-type-hint');
switch(mediaType) {
case 'image':
hint.textContent = 'Images will be displayed as-is';
break;
case 'video':
hint.textContent = 'Videos will be converted to optimized format';
break;
case 'pdf':
hint.textContent = 'PDF will be converted to images (one per page)';
break;
case 'ppt':
hint.textContent = 'PowerPoint will be converted to images (one per slide)';
break;
}
}
function handleFileChange() {
const filesInput = document.getElementById('files');
const fileList = document.getElementById('file-list');
const mediaType = document.getElementById('media_type').value;
const durationInput = document.getElementById('duration');
fileList.innerHTML = '';
if (filesInput.files.length > 0) {
fileList.innerHTML = '<strong>Selected files:</strong><ul style="margin: 5px 0; padding-left: 20px;">';
for (let i = 0; i < filesInput.files.length; i++) {
const file = filesInput.files[i];
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
fileList.innerHTML += `<li>${file.name} (${sizeMB} MB)</li>`;
}
fileList.innerHTML += '</ul>';
}
if (mediaType === 'video' && filesInput.files.length > 0) {
const file = filesInput.files[0];
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = function() {
window.URL.revokeObjectURL(video.src);
const duration = Math.round(video.duration);
durationInput.value = duration;
};
video.src = URL.createObjectURL(file);
}
}
</script>
{% endblock %}
@@ -0,0 +1,514 @@
{% extends "base.html" %}
{% block title %}Upload Media - DigiServer v2{% endblock %}
{% block content %}
<style>
.upload-container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.upload-zone {
border: 2px dashed #ced4da;
border-radius: 8px;
padding: 25px 20px;
text-align: center;
background: #f8f9fa;
transition: all 0.3s;
cursor: pointer;
}
body.dark-mode .upload-zone {
background: #1a202c;
border-color: #4a5568;
}
.upload-zone:hover {
border-color: #667eea;
background: #f0f2ff;
}
body.dark-mode .upload-zone:hover {
border-color: #7c3aed;
background: #2d3748;
}
.upload-zone.dragover {
border-color: #667eea;
background: #e8ebff;
transform: scale(1.02);
}
body.dark-mode .upload-zone.dragover {
border-color: #7c3aed;
background: #2d3748;
}
.file-input-wrapper {
margin: 20px 0;
}
.file-input-wrapper input[type="file"] {
display: none;
}
.file-list {
margin-top: 15px;
max-height: 200px;
overflow-y: auto;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 4px;
margin-bottom: 6px;
font-size: 13px;
}
body.dark-mode .file-item {
background: #2d3748;
border-color: #4a5568;
}
.file-info {
display: flex;
align-items: center;
gap: 10px;
}
.file-icon {
font-size: 20px;
}
.remove-file {
cursor: pointer;
color: #dc3545;
font-weight: bold;
padding: 5px 10px;
}
.remove-file:hover {
background: #dc3545;
color: white;
border-radius: 4px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
.form-control {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 14px;
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
body.dark-mode .form-control:focus {
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
}
.btn-upload {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px 40px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
width: 100%;
}
.btn-upload:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.btn-upload:disabled {
background: #6c757d;
cursor: not-allowed;
transform: none;
}
/* Dark mode text colors */
body.dark-mode h1,
body.dark-mode h2,
body.dark-mode h3 {
color: #e2e8f0;
}
body.dark-mode p,
body.dark-mode small {
color: #a0aec0;
}
body.dark-mode .file-info > div > div {
color: #e2e8f0;
}
body.dark-mode .file-info > div > div:last-child {
color: #718096;
}
.playlist-selector {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
border: 2px solid #dee2e6;
}
body.dark-mode .playlist-selector {
background: #1a202c;
border-color: #4a5568;
}
.playlist-selector.selected {
border-color: #667eea;
background: #f0f2ff;
}
body.dark-mode .playlist-selector.selected {
border-color: #7c3aed;
background: #2d3748;
}
</style>
<div class="upload-container">
<div style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<h1 style="display: flex; align-items: center; gap: 0.5rem; font-size: 24px; margin: 0;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 28px; height: 28px;">
Upload Media
</h1>
<a href="{{ url_for('content.content_list') }}" class="btn" style="display: flex; align-items: center; gap: 0.5rem; padding: 8px 16px;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
Back to Playlists
</a>
</div>
<form id="upload-form" method="POST" action="{{ url_for('content.upload_media') }}" enctype="multipart/form-data">
<!-- Compact Two-Column Layout -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<!-- Left Column: Upload Zone -->
<div class="card">
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px;">
Select Files
</h2>
<div class="upload-zone" id="upload-zone">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 48px; height: 48px; opacity: 0.3; margin-bottom: 10px;">
<h3 style="margin-bottom: 8px; font-size: 16px;">Drag & Drop</h3>
<p style="color: #6c757d; margin: 8px 0; font-size: 13px;">or</p>
<div class="file-input-wrapper">
<label for="file-input" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem; padding: 8px 16px; font-size: 14px;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
Browse
</label>
<input type="file" id="file-input" name="files" multiple
accept="image/*,video/*,.pdf,.ppt,.pptx">
</div>
<p style="font-size: 11px; color: #999; margin-top: 12px; line-height: 1.4;">
<strong>Supported:</strong> JPG, PNG, GIF, MP4, AVI, MOV, PDF, PPT
</p>
</div>
<div id="file-list" class="file-list"></div>
</div>
<!-- Right Column: Settings -->
<div class="card">
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px;">
Upload Settings
</h2>
<div class="form-group">
<label for="playlist_id">Target Playlist (Optional)</label>
<select name="playlist_id" id="playlist_id" class="form-control">
<option value="">-- Media Library Only --</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}">
{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items
</option>
{% endfor %}
</select>
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
💡 Add to playlists later if needed
</small>
</div>
<div class="form-group">
<label for="content_type">Media Type</label>
<select name="content_type" id="content_type" class="form-control">
<option value="image">Image</option>
<option value="video">Video</option>
<option value="pdf">PDF</option>
<option value="pptx">PPTX</option>
</select>
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
Auto-detected from file extension
</small>
</div>
<div class="form-group">
<label for="duration">Default Duration (seconds)</label>
<input type="number" name="duration" id="duration" class="form-control"
value="10" min="1" max="300">
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
Display time for images and PDFs
</small>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; cursor: pointer; user-select: none;">
<input type="checkbox" name="edit_on_player_enabled" id="edit_on_player_enabled"
value="1" style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
<span>Allow editing on player (PDF, Images, PPTX)</span>
</label>
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
✏️ Enable local editing of this media on the player device
</small>
</div>
<!-- Upload Button -->
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload Files
</button>
</div>
</div>
</form>
</div>
<script>
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const fileList = document.getElementById('file-list');
const uploadBtn = document.getElementById('upload-btn');
const uploadForm = document.getElementById('upload-form');
let selectedFiles = [];
// Prevent default drag behaviors
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
uploadZone.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// Highlight drop zone when item is dragged over it
['dragenter', 'dragover'].forEach(eventName => {
uploadZone.addEventListener(eventName, () => {
uploadZone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(eventName => {
uploadZone.addEventListener(eventName, () => {
uploadZone.classList.remove('dragover');
});
});
// Handle dropped files
uploadZone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
handleFiles(files);
});
// Handle file input
fileInput.addEventListener('change', (e) => {
console.log('File input changed, files:', e.target.files.length);
if (e.target.files.length > 0) {
handleFiles(e.target.files);
}
});
// Click upload zone to trigger file input (but not if clicking on the label)
uploadZone.addEventListener('click', (e) => {
// Don't trigger if clicking on the label or button
if (e.target.tagName === 'LABEL' || e.target.closest('label') || e.target.tagName === 'INPUT') {
return;
}
fileInput.click();
});
function handleFiles(files) {
console.log('handleFiles called with', files.length, 'file(s)');
selectedFiles = Array.from(files);
displayFiles();
uploadBtn.disabled = selectedFiles.length === 0;
// Auto-detect media type and duration from first file
if (selectedFiles.length > 0) {
console.log('Auto-detecting for first file:', selectedFiles[0].name);
autoDetectMediaType(selectedFiles[0]);
autoDetectDuration(selectedFiles[0]);
}
}
function autoDetectMediaType(file) {
const ext = file.name.split('.').pop().toLowerCase();
const contentTypeSelect = document.getElementById('content_type');
console.log('Auto-detecting media type for:', file.name, 'Extension:', ext);
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
contentTypeSelect.value = 'image';
console.log('Set type to: image');
} else if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
contentTypeSelect.value = 'video';
console.log('Set type to: video');
} else if (ext === 'pdf') {
contentTypeSelect.value = 'pdf';
console.log('Set type to: pdf');
} else if (['ppt', 'pptx'].includes(ext)) {
contentTypeSelect.value = 'pptx';
console.log('Set type to: pptx');
}
}
function autoDetectDuration(file) {
const ext = file.name.split('.').pop().toLowerCase();
const durationInput = document.getElementById('duration');
console.log('Auto-detecting duration for:', file.name, 'Extension:', ext);
// For videos, try to get actual duration
if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
console.log('Processing as video...');
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = function() {
window.URL.revokeObjectURL(video.src);
const duration = Math.ceil(video.duration);
console.log('Video duration detected:', duration, 'seconds');
if (duration && duration > 0) {
durationInput.value = duration;
}
};
video.onerror = function() {
console.log('Video loading error, using default 30s');
window.URL.revokeObjectURL(video.src);
durationInput.value = 30; // Default for videos if can't read duration
};
video.src = URL.createObjectURL(file);
} else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
// Images: default 10 seconds
console.log('Setting image duration: 10s');
durationInput.value = 10;
} else if (ext === 'pdf') {
// PDFs: default 15 seconds per page (estimate)
console.log('Setting PDF duration: 15s');
durationInput.value = 15;
} else if (['ppt', 'pptx'].includes(ext)) {
// Presentations: default 20 seconds per slide (estimate)
console.log('Setting presentation duration: 20s');
durationInput.value = 20;
}
}
function displayFiles() {
fileList.innerHTML = '';
if (selectedFiles.length === 0) {
return;
}
selectedFiles.forEach((file, index) => {
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
const ext = file.name.split('.').pop().toLowerCase();
let icon = '📁';
if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(ext)) icon = '📷';
else if (['mp4', 'avi', 'mov', 'mkv', 'webm'].includes(ext)) icon = '🎥';
else if (ext === 'pdf') icon = '📄';
else if (['ppt', 'pptx'].includes(ext)) icon = '📊';
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
fileItem.innerHTML = `
<div class="file-info">
<span class="file-icon">${icon}</span>
<div>
<div style="font-weight: 600;">${file.name}</div>
<div style="font-size: 12px; color: #6c757d;">${sizeInMB} MB</div>
</div>
</div>
<span class="remove-file" onclick="removeFile(${index})"></span>
`;
fileList.appendChild(fileItem);
});
}
function removeFile(index) {
selectedFiles.splice(index, 1);
displayFiles();
uploadBtn.disabled = selectedFiles.length === 0;
// Update file input
const dt = new DataTransfer();
selectedFiles.forEach(file => dt.items.add(file));
fileInput.files = dt.files;
}
// Form submission
uploadForm.addEventListener('submit', (e) => {
if (selectedFiles.length === 0) {
e.preventDefault();
alert('Please select files to upload');
return;
}
uploadBtn.disabled = true;
uploadBtn.innerHTML = '⏳ Uploading...';
});
</script>
{% endblock %}
+140
View File
@@ -0,0 +1,140 @@
{% extends "base.html" %}
{% block title %}Dashboard - DigiServer v2{% endblock %}
{% block content %}
<h1 style="margin-bottom: 2rem;">Dashboard</h1>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
<div class="card">
<h3 style="color: #3498db; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 24px; height: 24px;">
Players
</h3>
<p style="font-size: 2rem; font-weight: bold;">{{ total_players or 0 }}</p>
<a href="{{ url_for('players.list') }}" class="btn" style="margin-top: 1rem;">View Players</a>
</div>
<div class="card">
<h3 style="color: #9b59b6; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
Playlists
</h3>
<p style="font-size: 2rem; font-weight: bold;">{{ total_playlists or 0 }}</p>
<a href="{{ url_for('content.content_list') }}" class="btn" style="margin-top: 1rem;">Manage Playlists</a>
</div>
<div class="card">
<h3 style="color: #e67e22; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px;">
Media Library
</h3>
<p style="font-size: 2rem; font-weight: bold;">{{ total_content or 0 }}</p>
<p class="secondary-text" style="font-size: 0.9rem; margin-top: 0.5rem;">Unique media files</p>
</div>
<div class="card">
<h3 style="color: #27ae60; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
Storage
</h3>
<p style="font-size: 2rem; font-weight: bold;">{{ storage_mb or 0 }} MB</p>
<p class="secondary-text" style="font-size: 0.9rem; margin-top: 0.5rem;">Total uploads</p>
</div>
</div>
<div class="card">
<h2>Quick Actions</h2>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem;">
<a href="{{ url_for('players.add_player') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Add Player
</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Create Playlist
</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload Media
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin.admin_panel') }}" class="btn">Admin Panel</a>
{% endif %}
</div>
</div>
<div class="card">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
Workflow Guide
</h2>
<div class="workflow-guide">
<ol style="line-height: 2; margin: 0; padding-left: 1.5rem;">
<li><strong>Create a Playlist</strong> - Group your content into themed collections</li>
<li><strong>Upload Media</strong> - Add images, videos, or PDFs to your media library</li>
<li><strong>Add Content to Playlist</strong> - Build your playlist with drag-and-drop ordering</li>
<li><strong>Add Player</strong> - Register physical display devices</li>
<li><strong>Assign Playlist</strong> - Connect players to their playlists</li>
<li><strong>Players Auto-Download</strong> - Devices fetch and display content automatically</li>
</ol>
</div>
</div>
<style>
.workflow-guide {
margin-top: 1rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #e2e8f0;
}
body.dark-mode .workflow-guide {
background: #1a202c;
border: 1px solid #4a5568;
}
.secondary-text {
color: #7f8c8d;
}
body.dark-mode .secondary-text {
color: #a0aec0;
}
.log-item {
padding: 0.5rem;
border-bottom: 1px solid #e2e8f0;
}
body.dark-mode .log-item {
border-bottom: 1px solid #4a5568;
}
</style>
{% if recent_logs %}
<div class="card">
<h2>Recent Activity</h2>
<div style="margin-top: 1rem;">
{% for log in recent_logs %}
<div class="log-item">
<span style="color: {% if log.level == 'error' %}#e74c3c{% elif log.level == 'warning' %}#f39c12{% else %}#27ae60{% endif %}; font-weight: bold;">
[{{ log.level.upper() }}]
</span>
{{ log.message }}
<small class="secondary-text" style="float: right;">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="card">
<h2>System Status</h2>
<p>✅ All systems operational</p>
<p> Playlist-centric architecture active</p>
<p>🔄 Groups removed - Streamlined workflow</p>
<p>⚡ DigiServer v2.0</p>
</div>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}403 - Access Denied{% endblock %}
{% block content %}
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
<h1 style="font-size: 72px; color: #ffc107;">403</h1>
<h2>Access Denied</h2>
<p style="color: #6c757d; margin: 20px 0;">You don't have permission to access this resource.</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
</div>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}404 - Page Not Found{% endblock %}
{% block content %}
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
<h1 style="font-size: 72px; color: #dc3545;">404</h1>
<h2>Page Not Found</h2>
<p style="color: #6c757d; margin: 20px 0;">The page you're looking for doesn't exist.</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
</div>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}500 - Server Error{% endblock %}
{% block content %}
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
<h1 style="font-size: 72px; color: #dc3545;">500</h1>
<h2>Internal Server Error</h2>
<p style="color: #6c757d; margin: 20px 0;">Something went wrong on our end. Please try again later.</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
</div>
{% endblock %}
@@ -0,0 +1,235 @@
{% extends "base.html" %}
{% block title %}Add Player - DigiServer v2{% endblock %}
{% block content %}
<style>
.form-group {
margin-bottom: 1rem;
}
.form-group label {
font-weight: bold;
display: block;
margin-bottom: 0.5rem;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .form-control:focus {
border-color: #7c3aed;
outline: none;
}
.form-help {
color: #6c757d;
font-size: 0.875rem;
}
body.dark-mode .form-help {
color: #718096;
}
.section-header {
margin-top: 2rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid;
}
.section-header.blue {
border-color: #007bff;
}
body.dark-mode .section-header.blue {
border-color: #667eea;
}
.section-header.green {
border-color: #28a745;
}
body.dark-mode .section-header.green {
border-color: #48bb78;
}
.section-header.yellow {
border-color: #ffc107;
}
body.dark-mode .section-header.yellow {
border-color: #ecc94b;
}
body.dark-mode h1,
body.dark-mode h3,
body.dark-mode h4 {
color: #e2e8f0;
}
body.dark-mode p {
color: #a0aec0;
}
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #007bff;
padding: 1rem;
margin: 2rem 0;
}
body.dark-mode .info-box {
background-color: #1a365d;
border-left-color: #667eea;
}
.info-box h4 {
margin-top: 0;
color: #007bff;
}
body.dark-mode .info-box h4 {
color: #667eea;
}
.info-box code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
}
body.dark-mode .info-box code {
background: #2d3748;
color: #e2e8f0;
}
body.dark-mode small {
color: #718096;
}
</style>
<div class="container" style="max-width: 800px; margin-top: 2rem;">
<h1>Add New Player</h1>
<p style="color: #6c757d; margin-bottom: 2rem;">
Create a new digital signage player with authentication credentials
</p>
<div class="card">
<form method="POST">
<h3 class="section-header blue" style="margin-top: 0;">
Basic Information
</h3>
<div class="form-group">
<label>Display Name *</label>
<input type="text" name="name" required class="form-control"
placeholder="e.g., Office Reception Player">
<small class="form-help">Friendly name for the player</small>
</div>
<div class="form-group">
<label>Hostname *</label>
<input type="text" name="hostname" required class="form-control"
placeholder="e.g., office-player-001">
<small class="form-help">
Unique identifier for this player (must match screen_name in player config)
</small>
</div>
<div class="form-group">
<label>Location</label>
<input type="text" name="location" class="form-control"
placeholder="e.g., Main Office - Reception Area">
<small class="form-help">Physical location of the player (optional)</small>
</div>
<h3 class="section-header green">
Authentication
</h3>
<p class="form-help" style="margin-bottom: 1rem;">
Choose one authentication method (Quick Connect recommended for easy setup)
</p>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" id="password" class="form-control"
placeholder="Leave empty to use Quick Connect only">
<small class="form-help">
Secure password for player authentication (optional if using Quick Connect)
</small>
</div>
<div class="form-group">
<label>Quick Connect Code *</label>
<input type="text" name="quickconnect_code" required class="form-control"
placeholder="e.g., OFFICE123">
<small class="form-help">
Easy pairing code for quick setup (must match quickconnect_key in player config)
</small>
</div>
<h3 class="section-header yellow">
Display Settings
</h3>
<div class="form-group">
<label>Orientation</label>
<select name="orientation" class="form-control">
<option value="Landscape" selected>Landscape</option>
<option value="Portrait">Portrait</option>
</select>
<small class="form-help">Display orientation for the player</small>
</div>
<div class="form-group">
<label>Assign Playlist</label>
<select name="playlist_id" class="form-control">
<option value="">No Playlist (Unassigned)</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}">{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items</option>
{% endfor %}
</select>
<small class="form-help">Assign player to a playlist (optional)</small>
</div>
<div class="info-box">
<h4>📋 Setup Instructions</h4>
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
<li>Create the player with the form above</li>
<li>Note the generated <strong>Auth Code</strong> (shown after creation)</li>
<li>Configure the player's <code>app_config.json</code> with:
<ul style="margin-top: 0.5rem;">
<li><code>server_ip</code>: Your server address</li>
<li><code>screen_name</code>: Same as <strong>Hostname</strong> above</li>
<li><code>quickconnect_key</code>: Same as <strong>Quick Connect Code</strong> above</li>
</ul>
</li>
<li>Start the player - it will authenticate automatically</li>
</ol>
</div>
<div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd;">
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
✓ Create Player
</button>
<a href="{{ url_for('players.list') }}" class="btn" style="padding: 0.75rem 2rem; margin-left: 1rem;">
Cancel
</a>
</div>
</form>
</div>
</div>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Edit Player{% endblock %}
{% block content %}
<div class="container">
<h2>Edit Player</h2>
<p>Edit player functionality - placeholder</p>
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">Back to Players</a>
</div>
{% endblock %}
@@ -0,0 +1,525 @@
{% extends "base.html" %}
{% block title %}Edited Media - {{ player.name }}{% endblock %}
{% block content %}
<style>
.expandable-card {
width: 100%;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border: 2px solid #cbd5e1;
border-radius: 12px;
margin-bottom: 1.5rem;
overflow: hidden;
transition: all 0.3s ease;
cursor: pointer;
}
.expandable-card:hover {
border-color: #7c3aed;
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.expandable-card.expanded {
border-color: #7c3aed;
cursor: default;
}
.card-header {
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.3s ease;
}
.expandable-card:not(.expanded) .card-header:hover {
background: rgba(124, 58, 237, 0.05);
}
.card-header-title {
display: flex;
align-items: center;
gap: 1rem;
font-size: 1.2rem;
font-weight: 600;
color: #1a202c;
}
.card-header-icon {
font-size: 1.5rem;
transition: transform 0.3s ease;
}
.expandable-card.expanded .card-header-icon {
transform: rotate(90deg);
}
.card-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.4s ease;
}
.expandable-card.expanded .card-content {
max-height: 800px;
}
.card-body {
padding: 0 1.5rem 1.5rem 1.5rem;
display: grid;
grid-template-columns: 350px 1fr;
gap: 2rem;
}
.preview-area {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.preview-container {
width: 100%;
aspect-ratio: 16/9;
background: #000;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.preview-container img {
width: 100%;
height: 100%;
object-fit: contain;
}
.preview-info {
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
font-size: 0.9rem;
}
.preview-info-item {
padding: 0.5rem 0;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: start;
}
.preview-info-item:last-child {
border-bottom: none;
}
.preview-info-label {
font-weight: 600;
color: #64748b;
min-width: 80px;
}
.preview-info-value {
color: #1a202c;
text-align: right;
word-break: break-word;
flex: 1;
}
.versions-area {
display: flex;
flex-direction: column;
}
.versions-title {
font-size: 1.1rem;
font-weight: 600;
color: #7c3aed;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.versions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
max-height: 600px;
overflow-y: auto;
padding-right: 0.5rem;
}
.version-item {
background: white;
border: 2px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
transition: all 0.2s ease;
cursor: pointer;
}
.version-item:hover {
border-color: #7c3aed;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.2);
}
.version-item.active {
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
}
.version-thumbnail {
width: 100%;
aspect-ratio: 16/9;
background: #000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.version-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.version-label {
padding: 0.75rem;
text-align: center;
font-weight: 600;
color: #1a202c;
background: #f8f9fa;
}
.version-item.active .version-label {
background: #7c3aed;
color: white;
}
.version-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
margin-left: 0.5rem;
}
.badge-latest {
background: #7c3aed;
color: white;
}
.badge-original {
background: #10b981;
color: white;
}
.download-btn {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-decoration: none;
}
.download-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(124, 58, 237, 0.4);
}
/* Dark mode styles */
body.dark-mode .expandable-card {
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
border-color: #4a5568;
}
body.dark-mode .expandable-card:hover,
body.dark-mode .expandable-card.expanded {
border-color: #7c3aed;
}
body.dark-mode .card-header-title {
color: #e2e8f0;
}
body.dark-mode .expandable-card:not(.expanded) .card-header:hover {
background: rgba(124, 58, 237, 0.15);
}
body.dark-mode .preview-info {
background: #1a202c;
}
body.dark-mode .preview-info-item {
border-bottom-color: #4a5568;
}
body.dark-mode .preview-info-label {
color: #94a3b8;
}
body.dark-mode .preview-info-value {
color: #e2e8f0;
}
body.dark-mode .versions-title {
color: #a78bfa;
}
body.dark-mode .version-item {
background: #2d3748;
border-color: #4a5568;
}
body.dark-mode .version-item:hover,
body.dark-mode .version-item.active {
border-color: #7c3aed;
}
body.dark-mode .version-label {
background: #1a202c;
color: #e2e8f0;
}
body.dark-mode .version-item.active .version-label {
background: #7c3aed;
color: white;
}
</style>
<div style="margin-bottom: 2rem;">
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
class="btn"
style="background: #6c757d; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; display: inline-flex; align-items: center; gap: 0.5rem;">
← Back to Player
</a>
<h1 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 32px; height: 32px;">
Edited Media - {{ player.name }}
</h1>
</div>
<p style="color: #6c757d; font-size: 1rem;">Complete history of media files edited on this player</p>
</div>
{% if edited_media %}
{% set edited_by_content = {} %}
{% for edit in edited_media %}
{% if edit.content_id not in edited_by_content %}
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'versions': []}}) %}
{% endif %}
{% set _ = edited_by_content[edit.content_id]['versions'].append(edit) %}
{% endfor %}
<!-- Expandable Cards -->
{% for content_id, data in edited_by_content.items() %}
{% set original_content = content_files.get(content_id) %}
<div class="expandable-card" id="card-{{ content_id }}" onclick="toggleCard({{ content_id }})">
<div class="card-header">
<div class="card-header-title">
<span class="card-header-icon"></span>
<span>📄 {{ original_content.filename if original_content else data.original_name }}</span>
<span style="font-size: 0.9rem; color: #64748b; font-weight: normal;">
({{ data.versions|length + 1 }} version{{ 's' if (data.versions|length + 1) > 1 else '' }})
</span>
</div>
</div>
<div class="card-content">
<div class="card-body">
<!-- Preview Area (Left Column) -->
<div class="preview-area">
<div class="preview-container" id="preview-{{ content_id }}">
{% set latest = data.versions|sort(attribute='version', reverse=True)|first %}
{% if latest.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
alt="{{ latest.new_name }}"
id="preview-img-{{ content_id }}">
{% else %}
<div style="color: white; text-align: center;">
<div style="font-size: 3rem; margin-bottom: 0.5rem;">📄</div>
<div>No preview available</div>
</div>
{% endif %}
</div>
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
download
class="download-btn"
id="download-btn-{{ content_id }}">
💾 Download File
</a>
<div class="preview-info" id="info-{{ content_id }}">
<div class="preview-info-item">
<span class="preview-info-label">📄 Filename:</span>
<span class="preview-info-value" id="info-filename-{{ content_id }}">{{ latest.new_name }}</span>
</div>
<div class="preview-info-item">
<span class="preview-info-label">📦 Version:</span>
<span class="preview-info-value" id="info-version-{{ content_id }}">v{{ latest.version }}</span>
</div>
<div class="preview-info-item">
<span class="preview-info-label">👤 Edited by:</span>
<span class="preview-info-value" id="info-user-{{ content_id }}">{{ user_mappings.get(latest.user, latest.user or 'Unknown') }}</span>
</div>
<div class="preview-info-item">
<span class="preview-info-label">🕒 Modified:</span>
<span class="preview-info-value" id="info-date-{{ content_id }}">{{ latest.time_of_modification | localtime('%Y-%m-%d %H:%M') if latest.time_of_modification else 'N/A' }}</span>
</div>
<div class="preview-info-item">
<span class="preview-info-label">📅 Uploaded:</span>
<span class="preview-info-value" id="info-created-{{ content_id }}">{{ latest.created_at | localtime('%Y-%m-%d %H:%M') }}</span>
</div>
</div>
</div>
<!-- Versions Area (Right Column) -->
<div class="versions-area">
<div class="versions-title">
📚 All Versions
</div>
<div class="versions-grid">
{% for edit in data.versions|sort(attribute='version', reverse=True) %}
<div class="version-item {% if loop.first %}active{% endif %}"
id="version-{{ content_id }}-{{ edit.version }}"
onclick="event.stopPropagation(); selectVersion({{ content_id }}, {{ edit.version }}, '{{ edit.new_name }}', '{{ user_mappings.get(edit.user, edit.user or 'Unknown') }}', '{{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') if edit.time_of_modification else 'N/A' }}', '{{ edit.created_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}')">
<div class="version-thumbnail">
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
alt="Version {{ edit.version }}">
{% else %}
<div style="color: white; font-size: 2rem;">📄</div>
{% endif %}
</div>
<div class="version-label">
v{{ edit.version }}
{% if loop.first %}
<span class="version-badge badge-latest">Latest</span>
{% endif %}
</div>
</div>
{% endfor %}
<!-- Original File -->
{% if original_content %}
<div class="version-item"
id="version-{{ content_id }}-original"
onclick="event.stopPropagation(); selectVersion({{ content_id }}, 'original', '{{ original_content.filename }}', 'System', 'N/A', '{{ original_content.uploaded_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/' ~ original_content.filename) }}')">
<div class="version-thumbnail">
{% if original_content.filename.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
<img src="{{ url_for('static', filename='uploads/' ~ original_content.filename) }}"
alt="Original">
{% else %}
<div style="color: white; font-size: 2rem;">📄</div>
{% endif %}
</div>
<div class="version-label">
Original
<span class="version-badge badge-original">Source</span>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
<!-- Summary Statistics -->
<div class="card" style="margin-top: 2rem; background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%); color: white; border-radius: 12px; overflow: hidden;">
<div style="padding: 1.5rem; display: flex; justify-content: space-around; align-items: center; flex-wrap: wrap; gap: 2rem;">
<div style="text-align: center;">
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_by_content|length }}</div>
<div style="font-size: 0.9rem; opacity: 0.9;">Total Files Edited</div>
</div>
<div style="text-align: center;">
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_media|length }}</div>
<div style="font-size: 0.9rem; opacity: 0.9;">Total Versions</div>
</div>
<div style="text-align: center;">
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ ((edited_media|length / edited_by_content|length) | round(1)) if edited_by_content else 0 }}</div>
<div style="font-size: 0.9rem; opacity: 0.9;">Avg Versions per File</div>
</div>
</div>
</div>
{% else %}
<div class="card" style="text-align: center; padding: 4rem 2rem;">
<div style="font-size: 4rem; margin-bottom: 1rem; opacity: 0.5;">📭</div>
<h2 style="color: #6c757d; margin-bottom: 1rem;">No Edited Media Yet</h2>
<p style="color: #6c757d; font-size: 1.1rem;">
This player hasn't edited any media files yet. When the player edits content,<br>
all versions will be tracked and displayed here.
</p>
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
class="btn"
style="margin-top: 2rem; display: inline-block; background: #7c3aed; color: white; padding: 0.75rem 1.5rem; text-decoration: none; border-radius: 6px; font-size: 1rem;">
← Back to Player Management
</a>
</div>
{% endif %}
<script>
function toggleCard(contentId) {
const card = document.getElementById('card-' + contentId);
const wasExpanded = card.classList.contains('expanded');
// Close all cards
document.querySelectorAll('.expandable-card').forEach(c => {
c.classList.remove('expanded');
});
// If this card wasn't expanded, expand it
if (!wasExpanded) {
card.classList.add('expanded');
}
}
function selectVersion(contentId, version, filename, user, modifiedDate, createdDate, fileUrl) {
// Update preview image
const previewImg = document.getElementById('preview-img-' + contentId);
if (previewImg) {
previewImg.src = fileUrl;
}
// Update download button
const downloadBtn = document.getElementById('download-btn-' + contentId);
if (downloadBtn) {
downloadBtn.href = fileUrl;
}
// Update metadata
document.getElementById('info-filename-' + contentId).textContent = filename;
document.getElementById('info-version-' + contentId).textContent = 'v' + version;
document.getElementById('info-user-' + contentId).textContent = user;
document.getElementById('info-date-' + contentId).textContent = modifiedDate;
document.getElementById('info-created-' + contentId).textContent = createdDate;
// Update active state on version items
document.querySelectorAll('.version-item').forEach(item => {
if (item.id.startsWith('version-' + contentId + '-')) {
item.classList.remove('active');
}
});
document.getElementById('version-' + contentId + '-' + version).classList.add('active');
}
</script>
{% endblock %}
@@ -0,0 +1,770 @@
{% extends "base.html" %}
{% block title %}Manage Player - {{ player.name }}{% endblock %}
{% block content %}
<style>
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .form-control:focus {
border-color: #7c3aed;
outline: none;
}
.info-box {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.info-box.neutral {
background: #f8f9fa;
}
body.dark-mode .info-box.neutral {
background: #1a202c;
border: 1px solid #4a5568;
}
.info-box.success {
background: #d4edda;
color: #155724;
}
body.dark-mode .info-box.success {
background: #1a4d2e;
color: #86efac;
border: 1px solid #48bb78;
}
.info-box.warning {
background: #fff3cd;
color: #856404;
}
body.dark-mode .info-box.warning {
background: #4a3800;
color: #fbbf24;
border: 1px solid #ecc94b;
}
.log-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
border-radius: 4px;
background: #f8f9fa;
border-left: 4px solid;
}
body.dark-mode .log-item {
background: #2d3748;
}
.log-item pre {
background: #fff;
border: 1px solid #ddd;
}
body.dark-mode .log-item pre {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode h1,
body.dark-mode h2,
body.dark-mode h3,
body.dark-mode h4 {
color: #e2e8f0;
}
body.dark-mode p {
color: #a0aec0;
}
body.dark-mode strong {
color: #e2e8f0;
}
body.dark-mode code {
background: #1a202c;
color: #e2e8f0;
padding: 2px 6px;
border-radius: 3px;
}
body.dark-mode small {
color: #718096;
}
.credential-item {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e2e8f0;
}
body.dark-mode .credential-item {
border-bottom-color: #4a5568;
}
.credential-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.credential-label {
font-weight: 600;
display: block;
margin-bottom: 0.25rem;
font-size: 0.85rem;
color: #495057;
}
body.dark-mode .credential-label {
color: #a0aec0;
}
.credential-value {
font-family: 'Courier New', monospace;
background: #f8f9fa;
padding: 0.5rem;
border-radius: 4px;
word-break: break-all;
font-size: 0.85rem;
border: 1px solid #dee2e6;
}
body.dark-mode .credential-value {
background: #0d1117;
border-color: #4a5568;
color: #e2e8f0;
}
.status-card {
margin-bottom: 2rem;
}
.status-card.online {
background: #d4edda;
}
body.dark-mode .status-card.online {
background: #1a4d2e;
border: 1px solid #48bb78;
}
.status-card.offline {
background: #f8d7da;
}
body.dark-mode .status-card.offline {
background: #4a1a1a;
border: 1px solid #dc3545;
}
.status-card.other {
background: #fff3cd;
}
body.dark-mode .status-card.other {
background: #4a3800;
border: 1px solid #ecc94b;
}
.playlist-stats {
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
}
body.dark-mode .playlist-stats {
background: #1a202c;
border: 1px solid #4a5568;
}
body.dark-mode .playlist-stats > div > div:first-child {
color: #a0aec0;
}
/* Player Logs Dark Mode */
body.dark-mode .card p[style*="color: #6c757d"] {
color: #a0aec0 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] {
background: #2d3748 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] p {
color: #e2e8f0 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] p[style*="color: #6c757d"] {
color: #a0aec0 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] small {
color: #a0aec0 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] details summary {
color: #f87171 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] pre {
background: #1a202c !important;
border-color: #4a5568 !important;
color: #e2e8f0 !important;
}
body.dark-mode .card > div[style*="text-align: center"] {
color: #a0aec0 !important;
}
/* Edited Media Cards Dark Mode */
body.dark-mode .card[style*="border: 2px solid #7c3aed"] {
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%) !important;
border-color: #8b5cf6 !important;
}
body.dark-mode .card[style*="border: 2px solid #7c3aed"] h3 {
color: #a78bfa !important;
}
body.dark-mode .card[style*="border: 2px solid #7c3aed"] div[style*="background: white"] {
background: #374151 !important;
}
body.dark-mode .card[style*="border: 2px solid #7c3aed"] strong {
color: #a78bfa !important;
}
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #475569"],
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #64748b"] {
color: #cbd5e1 !important;
}
body.dark-mode div[style*="background: #f8f9fa; border-radius: 8px"] {
background: #2d3748 !important;
}
body.dark-mode .card > div[style*="text-align: center"] p {
color: #a0aec0 !important;
}
</style>
<div style="margin-bottom: 2rem; display: flex; justify-content: space-between; align-items: flex-start;">
<h1 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
Manage Player: {{ player.name }}
</h1>
<a href="{{ url_for('players.list') }}" class="btn" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Back to Players
</a>
</div>
<!-- Delete Confirmation Modal -->
<div id="deleteModal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4);">
<div style="background-color: #fefefe; margin: 15% auto; padding: 20px; border: 1px solid #888; border-radius: 8px; width: 90%; max-width: 500px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<h2 style="margin-top: 0; color: #dc3545; display: flex; align-items: center; gap: 0.5rem;">
<span style="font-size: 1.5rem;">⚠️</span>
Confirm Delete
</h2>
<p style="font-size: 1.1rem; margin: 1.5rem 0;">
Are you sure you want to delete player <strong>"{{ player.name }}"</strong>?
</p>
<p style="color: #dc3545; margin: 1rem 0;">
<strong>Warning:</strong> This action cannot be undone. All feedback logs for this player will also be deleted.
</p>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 2rem;">
<button onclick="closeDeleteModal()" class="btn" style="background: #6c757d;">
Cancel
</button>
<form method="POST" action="{{ url_for('players.delete_player', player_id=player.id) }}" style="margin: 0;">
<button type="submit" class="btn" style="background: #dc3545;">
Yes, Delete Player
</button>
</form>
</div>
</div>
</div>
<style>
body.dark-mode #deleteModal > div {
background-color: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode #deleteModal h2 {
color: #f87171;
}
body.dark-mode #deleteModal p {
color: #e2e8f0;
}
body.dark-mode #deleteModal p strong {
color: #fbbf24;
}
</style>
<script>
function confirmDelete() {
document.getElementById('deleteModal').style.display = 'block';
}
function closeDeleteModal() {
document.getElementById('deleteModal').style.display = 'none';
}
// Close modal when clicking outside of it
window.onclick = function(event) {
const modal = document.getElementById('deleteModal');
if (event.target == modal) {
closeDeleteModal();
}
}
// Close modal with ESC key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeDeleteModal();
}
});
</script>
<!-- Player Status Overview -->
<div class="card status-card {% if player.status == 'online' %}online{% elif player.status == 'offline' %}offline{% else %}other{% endif %}">
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
Status:
{% if player.status == 'online' %}
<span style="color: #28a745; display: flex; align-items: center; gap: 0.3rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px; color: #28a745;">
Online
</span>
{% elif player.status == 'offline' %}
<span style="color: #dc3545; display: flex; align-items: center; gap: 0.3rem;">
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 20px; height: 20px; color: #dc3545;">
Offline
</span>
{% else %}
<span style="color: #ffc107; display: flex; align-items: center; gap: 0.3rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px; color: #ffc107;">
{{ player.status|title }}
</span>
{% endif %}
</h3>
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
<p><strong>Last Seen:</strong>
{% if player.last_seen %}
{{ player.last_seen | localtime('%Y-%m-%d %H:%M:%S') }}
{% else %}
Never
{% endif %}
</p>
<p><strong>Assigned Playlist:</strong>
{% if current_playlist %}
<span style="color: #28a745; font-weight: bold;">{{ current_playlist.name }} (v{{ current_playlist.version }})</span>
{% else %}
<span style="color: #dc3545;">No playlist assigned</span>
{% endif %}
</p>
</div>
<!-- Playlist Overview Card -->
{% if current_playlist %}
<div class="card" style="margin-bottom: 2rem;">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
Current Playlist: {{ current_playlist.name }}
</h2>
<div class="playlist-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-top: 1rem;">
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Total Items</div>
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.contents.count() }}</div>
</div>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Playlist Version</div>
<div style="font-size: 1.5rem; font-weight: bold;">v{{ current_playlist.version }}</div>
</div>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Last Updated</div>
<div style="font-size: 1rem; font-weight: bold;">{{ current_playlist.updated_at | localtime }}</div>
</div>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Orientation</div>
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.orientation }}</div>
</div>
</div>
<div style="margin-top: 1rem; display: flex; gap: 0.5rem;">
<a href="{{ url_for('content.manage_playlist_content', playlist_id=current_playlist.id) }}"
class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Edit Playlist Content
</a>
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;"
target="_blank">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
View Live Content
</a>
</div>
</div>
{% endif %}
<!-- Three Column Layout -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1.5rem;">
<!-- Card 1: Edit Credentials -->
<div class="card">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 24px; height: 24px;">
Edit Credentials
</h2>
<form method="POST" style="margin-top: 1rem;">
<input type="hidden" name="action" value="update_credentials">
<div class="form-group">
<label for="name">Player Name *</label>
<input type="text" id="name" name="name" value="{{ player.name }}"
required minlength="3" class="form-control">
</div>
<div class="form-group">
<label for="location">Location</label>
<input type="text" id="location" name="location" value="{{ player.location or '' }}"
placeholder="e.g., Main Lobby" class="form-control">
</div>
<div class="form-group">
<label for="orientation">Orientation</label>
<select id="orientation" name="orientation" class="form-control">
<option value="Landscape" {% if player.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
<option value="Portrait" {% if player.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
</select>
</div>
<div style="border-top: 1px solid #ddd; margin: 1.5rem 0; padding-top: 1.5rem;">
<h4 style="margin: 0 0 1rem 0; font-size: 0.95rem;">🔑 Authentication Settings</h4>
<div class="form-group">
<label for="hostname">Hostname *</label>
<input type="text" id="hostname" name="hostname" value="{{ player.hostname }}"
required minlength="3" class="form-control"
placeholder="e.g., tv-terasa">
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
️ This is the unique identifier for the player
</small>
</div>
<div class="form-group">
<label for="password">New Password (leave blank to keep current)</label>
<input type="password" id="password" name="password" class="form-control"
placeholder="Enter new password">
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
🔒 Optional: Set a new password for player authentication
</small>
</div>
<div class="form-group">
<label for="quickconnect_code">Quick Connect Code</label>
<input type="text" id="quickconnect_code" name="quickconnect_code" class="form-control"
placeholder="e.g., 8887779">
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
🔗 Enter the plain text code (e.g., 8887779) - will be hashed automatically
</small>
</div>
</div>
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Save Changes
</button>
</form>
</div>
<!-- Card 2: Assign Playlist -->
<div class="card">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
Assign Playlist
</h2>
<form method="POST" style="margin-top: 1rem;">
<input type="hidden" name="action" value="assign_playlist">
<div class="form-group">
<label for="playlist_id">Select Playlist</label>
<select id="playlist_id" name="playlist_id" class="form-control">
<option value="">-- No Playlist (Unassign) --</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}"
{% if player.playlist_id == playlist.id %}selected{% endif %}>
{{ playlist.name }} (v{{ playlist.version }}) - {{ playlist.contents.count() }} items
</option>
{% endfor %}
</select>
</div>
{% if current_playlist %}
<div class="info-box success">
<h4 style="margin: 0 0 0.5rem 0;">Currently Assigned:</h4>
<p style="margin: 0;"><strong>{{ current_playlist.name }}</strong></p>
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Version: {{ current_playlist.version }}</p>
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Content Items: {{ current_playlist.contents.count() }}</p>
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem; color: #6c757d;">
Updated: {{ current_playlist.updated_at | localtime }}
</p>
</div>
{% else %}
<div class="info-box warning">
<p style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 18px; height: 18px;">
No playlist currently assigned to this player.
</p>
</div>
{% endif %}
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Assign Playlist
</button>
</form>
<div style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid #ddd;">
<h4>Quick Actions:</h4>
<a href="{{ url_for('content.content_list') }}" class="btn" style="width: 100%; margin-top: 0.5rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Create New Playlist
</a>
{% if current_playlist %}
<a href="{{ url_for('content.manage_playlist_content', playlist_id=current_playlist.id) }}"
class="btn" style="width: 100%; margin-top: 0.5rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Edit Current Playlist
</a>
{% endif %}
<button onclick="confirmDelete()" class="btn" style="width: 100%; margin-top: 0.5rem; background: #dc3545; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
<span style="font-size: 1.2rem;">🗑️</span>
Delete Player
</button>
</div>
</div>
<!-- Card 3: Player Logs -->
<div class="card">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
Player Logs
</h2>
<p style="color: #6c757d; font-size: 0.9rem;">Recent feedback from the player device</p>
<div style="max-height: 500px; overflow-y: auto; margin-top: 1rem;">
{% if recent_logs %}
{% for log in recent_logs %}
<div style="padding: 0.75rem; margin-bottom: 0.5rem; border-left: 4px solid
{% if log.status == 'error' %}#dc3545
{% elif log.status == 'warning' %}#ffc107
{% elif log.status == 'playing' %}#28a745
{% else %}#17a2b8{% endif %};
background: #f8f9fa; border-radius: 4px;">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1;">
<strong style="color:
{% if log.status == 'error' %}#dc3545
{% elif log.status == 'warning' %}#ffc107
{% elif log.status == 'playing' %}#28a745
{% else %}#17a2b8{% endif %};">
{% if log.status == 'error' %}❌
{% elif log.status == 'warning' %}⚠️
{% elif log.status == 'playing' %}▶️
{% elif log.status == 'restarting' %}🔄
{% else %}️{% endif %}
{{ log.status|upper }}
</strong>
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">{{ log.message }}</p>
{% if log.playlist_version %}
<p style="margin: 0.25rem 0 0 0; font-size: 0.85rem; color: #6c757d;">
Playlist v{{ log.playlist_version }}
</p>
{% endif %}
{% if log.error_details %}
<details style="margin-top: 0.5rem;">
<summary style="cursor: pointer; font-size: 0.85rem; color: #dc3545;">Error Details</summary>
<pre style="margin: 0.5rem 0 0 0; padding: 0.5rem; background: #fff; border: 1px solid #ddd; border-radius: 4px; font-size: 0.8rem; overflow-x: auto;">{{ log.error_details }}</pre>
</details>
{% endif %}
</div>
<small style="color: #6c757d; white-space: nowrap; margin-left: 1rem;">
{{ log.timestamp | localtime('%m/%d %H:%M') }}
</small>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align: center; padding: 2rem; color: #6c757d;">
<p>📭 No logs received yet</p>
<p style="font-size: 0.9rem;">Logs will appear here once the player starts sending feedback</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Edited Media Section - Full Width -->
<div class="card" style="margin-top: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<h2 style="display: flex; align-items: center; gap: 0.5rem; margin: 0;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 24px; height: 24px;">
Edited Media on the Player
</h2>
{% if edited_media %}
<a href="{{ url_for('players.edited_media', player_id=player.id) }}"
class="btn"
style="background: #7c3aed; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; transition: background 0.2s;"
onmouseover="this.style.background='#6d28d9'"
onmouseout="this.style.background='#7c3aed'">
📋 View All Edited Media
</a>
{% endif %}
</div>
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.5rem;">Latest 3 edited files with their most recent versions</p>
{% if edited_media %}
{% set edited_by_content = {} %}
{% for edit in edited_media %}
{% if edit.content_id not in edited_by_content %}
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'latest_version': edit}}) %}
{% endif %}
{% endfor %}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;">
{% for content_id, data in edited_by_content.items() %}
{% if loop.index <= 3 %}
<div class="card" style="background: linear-gradient(135deg, #f8f9fa 0%, #fff 100%); border: 2px solid #7c3aed; box-shadow: 0 2px 8px rgba(124, 58, 237, 0.1);">
{% set edit = data.latest_version %}
<!-- Image Preview if it's an image -->
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
<div style="width: 100%; height: 200px; overflow: hidden; border-radius: 8px 8px 0 0; background: #000;">
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
alt="{{ edit.new_name }}"
style="width: 100%; height: 100%; object-fit: contain;">
</div>
{% endif %}
<div style="padding: 1rem;">
<h3 style="margin: 0 0 0.75rem 0; color: #7c3aed; font-size: 1rem; display: flex; align-items: center; gap: 0.5rem;">
✏️ {{ data.original_name }}
</h3>
<div style="padding: 0.75rem; background: white; border-radius: 6px; border-left: 4px solid #7c3aed; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
<strong style="color: #7c3aed; font-size: 0.95rem;">
Version {{ edit.version }}
<span style="background: #7c3aed; color: white; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; margin-left: 0.5rem;">Latest</span>
</strong>
<small style="color: #6c757d; white-space: nowrap;">
{{ edit.created_at | localtime('%m/%d %H:%M') }}
</small>
</div>
<p style="margin: 0.25rem 0; font-size: 0.85rem; color: #475569;">
📄 {{ edit.new_name }}
</p>
{% if edit.user %}
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
👤 {{ edit.user }}
</p>
{% endif %}
{% if edit.time_of_modification %}
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
🕒 {{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') }}
</p>
{% endif %}
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
target="_blank"
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #7c3aed; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
onmouseover="this.style.background='#6d28d9'"
onmouseout="this.style.background='#7c3aed'">
📥 View File
</a>
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
download
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #64748b; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
onmouseover="this.style.background='#475569'"
onmouseout="this.style.background='#64748b'">
💾 Download
</a>
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% else %}
<div style="text-align: center; padding: 3rem; color: #6c757d; background: #f8f9fa; border-radius: 8px; margin-top: 1.5rem;">
<p style="font-size: 2rem; margin: 0;">📝</p>
<p style="margin: 0.5rem 0 0 0; font-size: 1.1rem; font-weight: 500;">No edited media yet</p>
<p style="font-size: 0.9rem; margin: 0.5rem 0 0 0;">Media edits will appear here once the player sends edited files</p>
</div>
{% endif %}
</div>
<!-- Additional Info Section -->
<div class="card" style="margin-top: 2rem;">
<h2>️ Player Information</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-top: 1rem;">
<div>
<p><strong>Player ID:</strong> {{ player.id }}</p>
<p><strong>Created:</strong> {{ (player.created_at | localtime) if player.created_at else 'N/A' }}</p>
</div>
<div>
<p><strong>Orientation:</strong> {{ player.orientation }}</p>
<p><strong>Location:</strong> {{ player.location or 'Not set' }}</p>
</div>
<div>
<p><strong>Last Heartbeat:</strong>
{% if player.last_heartbeat %}
{{ player.last_heartbeat | localtime('%Y-%m-%d %H:%M:%S') }}
{% else %}
Never
{% endif %}
</p>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,235 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ player.name }} - Live Preview</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000;
overflow: hidden;
font-family: Arial, sans-serif;
}
#player-container {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
#media-display {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
object-fit: contain;
display: none;
}
#media-display.active {
display: block;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 24px;
text-align: center;
}
.info-overlay {
position: fixed;
bottom: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
z-index: 1000;
}
.controls {
position: fixed;
top: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 1000;
transition: opacity 0.3s ease;
}
.controls button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 10px 15px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.controls button:hover {
background: rgba(255, 255, 255, 0.3);
}
.no-content {
color: white;
text-align: center;
font-size: 24px;
}
</style>
</head>
<body>
<div id="player-container">
<div class="loading" id="loading">Loading playlist...</div>
<img id="media-display" alt="Content">
<video id="video-display" muted autoplay playsinline style="display: none; max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain;"></video>
<div class="no-content" id="no-content" style="display: none;">
<p>💭 No content in playlist</p>
<p style="font-size: 16px; margin-top: 10px; opacity: 0.7;">Add content to the playlist to preview</p>
</div>
</div>
<div class="info-overlay" id="info-overlay" style="display: none;">
<div id="current-item">Item: -</div>
<div id="playlist-info">Playlist: {{ playlist|length }} items</div>
</div>
<div class="controls">
<button onclick="toggleFullscreen()">🔳 Fullscreen</button>
<button onclick="restartPlaylist()">🔄 Restart</button>
<button onclick="window.close()">✖️ Close</button>
</div>
<script>
const playlist = {{ playlist|tojson }};
let currentIndex = 0;
let timer = null;
let inactivityTimer = null;
const imgDisplay = document.getElementById('media-display');
const videoDisplay = document.getElementById('video-display');
const loading = document.getElementById('loading');
const noContent = document.getElementById('no-content');
const infoOverlay = document.getElementById('info-overlay');
const currentItemDiv = document.getElementById('current-item');
const controls = document.querySelector('.controls');
function playNext() {
if (!playlist || playlist.length === 0) {
loading.style.display = 'none';
noContent.style.display = 'block';
return;
}
const item = playlist[currentIndex];
loading.style.display = 'none';
infoOverlay.style.display = 'block';
// Update info
currentItemDiv.textContent = `Item ${currentIndex + 1}/${playlist.length}: ${item.filename}`;
// Hide both displays
imgDisplay.style.display = 'none';
videoDisplay.style.display = 'none';
videoDisplay.pause();
if (item.type === 'video') {
videoDisplay.src = item.url;
videoDisplay.muted = item.muted !== false; // Muted unless explicitly set to false
videoDisplay.style.display = 'block';
videoDisplay.play();
// When video ends, move to next
videoDisplay.onended = () => {
currentIndex = (currentIndex + 1) % playlist.length;
playNext();
};
} else {
// Image or PDF
imgDisplay.src = item.url;
imgDisplay.style.display = 'block';
imgDisplay.classList.add('active');
// Clear any existing timer
if (timer) clearTimeout(timer);
// Show for specified duration
timer = setTimeout(() => {
currentIndex = (currentIndex + 1) % playlist.length;
playNext();
}, (item.duration || 10) * 1000);
}
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
}
function restartPlaylist() {
currentIndex = 0;
if (timer) clearTimeout(timer);
videoDisplay.pause();
playNext();
}
// Auto-hide controls after 5 seconds of inactivity
function resetInactivityTimer() {
// Show controls
controls.style.opacity = '1';
controls.style.pointerEvents = 'auto';
// Clear existing timer
if (inactivityTimer) {
clearTimeout(inactivityTimer);
}
// Set new timer to hide controls after 5 seconds
inactivityTimer = setTimeout(() => {
controls.style.opacity = '0';
controls.style.pointerEvents = 'none';
}, 5000);
}
// Track user activity to show/hide controls
document.addEventListener('mousemove', resetInactivityTimer);
document.addEventListener('mousedown', resetInactivityTimer);
document.addEventListener('keydown', resetInactivityTimer);
document.addEventListener('touchstart', resetInactivityTimer);
// Start playing when page loads
window.addEventListener('load', () => {
playNext();
resetInactivityTimer(); // Start inactivity timer
});
// Handle keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'f' || e.key === 'F') {
toggleFullscreen();
} else if (e.key === 'r' || e.key === 'R') {
restartPlaylist();
} else if (e.key === 'Escape' && document.fullscreenElement) {
document.exitFullscreen();
}
});
</script>
</body>
</html>
@@ -0,0 +1,227 @@
{% extends "base.html" %}
{% block title %}{{ player.name }} - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 1400px;">
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<div>
<h1>{{ player.name }}</h1>
<div style="margin-top: 10px;">
{% if status_info.online %}
<span style="background: #28a745; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
🟢 Online
</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
⚫ Offline
</span>
{% endif %}
<span style="color: #6c757d; font-size: 14px;">
Last seen: {{ status_info.last_seen_ago }}
</span>
</div>
</div>
<div>
<a href="{{ url_for('players.edit_player', player_id=player.id) }}" class="btn btn-primary">
✏️ Edit Player
</a>
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}" class="btn btn-success">
🎬 Manage Playlist
</a>
<a href="{{ url_for('players.list') }}" class="btn">
← Back to Players
</a>
</div>
</div>
<!-- Main Content Grid -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<!-- Player Information Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
📋 Player Information
</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold; width: 40%;">Display Name:</td>
<td style="padding: 10px;">{{ player.name }}</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Hostname:</td>
<td style="padding: 10px;">
<code style="background: #f8f9fa; padding: 3px 8px; border-radius: 3px;">{{ player.hostname }}</code>
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Location:</td>
<td style="padding: 10px;">{{ player.location or '-' }}</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Orientation:</td>
<td style="padding: 10px;">{{ player.orientation or 'Landscape' }}</td>
</tr>
<tr>
<td style="padding: 10px; font-weight: bold;">Created:</td>
<td style="padding: 10px;">{{ player.created_at | localtime }}</td>
</tr>
</table>
</div>
<!-- Authentication Details Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
🔐 Authentication Details
</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold; width: 40%;">Password Set:</td>
<td style="padding: 10px;">
{% if player.password_hash %}
<span style="color: #28a745;">✓ Yes</span>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Quick Connect Code:</td>
<td style="padding: 10px;">
{% if player.quickconnect_code %}
<span style="color: #28a745;">✓ Yes</span>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Auth Code:</td>
<td style="padding: 10px;">
{% if player.auth_code %}
<span style="color: #28a745;">✓ Yes</span>
<form method="POST" action="{{ url_for('players.regenerate_auth_code', player_id=player.id) }}" style="display: inline; margin-left: 10px;">
<button type="submit" class="btn btn-sm" style="background: #ffc107; padding: 3px 8px;"
onclick="return confirm('Regenerate auth code? The player will need to authenticate again.')">
🔄 Regenerate
</button>
</form>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr>
<td colspan="2" style="padding: 15px 10px;">
<a href="{{ url_for('players.edit_player', player_id=player.id) }}"
class="btn btn-primary" style="width: 100%; text-align: center;">
✏️ Edit Authentication Settings
</a>
</td>
</tr>
</table>
</div>
</div>
<!-- Playlist Management Card -->
<div class="card" style="margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">🎬 Playlist Management</h3>
</div>
{% if playlist %}
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 15px;">
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px;">
<div>
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Items</div>
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ playlist|length }}</div>
</div>
<div>
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Duration</div>
<div style="font-size: 24px; font-weight: bold; color: #333;">
{% set total_duration = namespace(value=0) %}
{% for item in playlist %}
{% set total_duration.value = total_duration.value + (item.duration or 10) %}
{% endfor %}
{{ total_duration.value }}s
</div>
</div>
<div>
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Playlist Version</div>
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ player.playlist_version }}</div>
</div>
</div>
</div>
{% endif %}
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}"
class="btn btn-primary"
style="display: inline-block; width: 100%; text-align: center; padding: 15px; font-size: 16px;">
🎬 Open Playlist Manager
</a>
{% if not playlist %}
<div style="background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 15px; border-radius: 5px; text-align: center; margin-top: 15px;">
⚠️ No content in playlist. Open the playlist manager to add content.
</div>
{% endif %}
</div>
<!-- Player Activity Log Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
📊 Recent Activity & Feedback
</h3>
{% if recent_feedback %}
<div style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: white;">
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Time</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Message</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Error</th>
</tr>
</thead>
<tbody>
{% for feedback in recent_feedback %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; white-space: nowrap;">
<small style="color: #6c757d;">{{ feedback.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
</td>
<td style="padding: 10px;">
{% if feedback.status == 'playing' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">▶️ Playing</span>
{% elif feedback.status == 'idle' %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">⏸️ Idle</span>
{% elif feedback.status == 'error' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">❌ Error</span>
{% else %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">{{ feedback.status }}</span>
{% endif %}
</td>
<td style="padding: 10px;">
{{ feedback.message or '-' }}
</td>
<td style="padding: 10px;">
{% if feedback.error %}
<span style="color: #dc3545; font-family: monospace; font-size: 12px;">{{ feedback.error[:50] }}...</span>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px; text-align: center;">
️ No activity logs yet. The player will send feedback once it starts playing content.
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,195 @@
{% extends "base.html" %}
{% block title %}Players - DigiServer v2{% endblock %}
{% block content %}
<style>
body.dark-mode h1 {
color: #e2e8f0;
}
.players-table {
width: 100%;
border-collapse: collapse;
}
.players-table thead tr {
background: #f8f9fa;
text-align: left;
}
body.dark-mode .players-table thead tr {
background: #1a202c;
}
.players-table th {
padding: 12px;
border-bottom: 2px solid #dee2e6;
font-weight: 600;
color: #495057;
}
body.dark-mode .players-table th {
border-bottom-color: #4a5568;
color: #e2e8f0;
}
.players-table tbody tr {
border-bottom: 1px solid #dee2e6;
}
body.dark-mode .players-table tbody tr {
border-bottom-color: #4a5568;
}
.players-table tbody tr:hover {
background: #f8f9fa;
}
body.dark-mode .players-table tbody tr:hover {
background: #2d3748;
}
.players-table td {
padding: 12px;
color: #2d3748;
}
body.dark-mode .players-table td {
color: #e2e8f0;
}
.players-table td strong {
color: #2d3748;
}
body.dark-mode .players-table td strong {
color: #e2e8f0;
}
.players-table code {
background: #f8f9fa;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
body.dark-mode .players-table code {
background: #1a202c;
color: #e2e8f0;
}
.status-badge {
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
color: white;
}
.status-badge.online {
background: #28a745;
}
.status-badge.offline {
background: #6c757d;
}
.text-muted {
color: #6c757d;
}
body.dark-mode .text-muted {
color: #718096;
}
.info-box {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
padding: 15px;
border-radius: 5px;
}
body.dark-mode .info-box {
background: #1a365d;
border-color: #2c5282;
color: #90cdf4;
}
.info-box a {
color: #0c5460;
text-decoration: underline;
}
body.dark-mode .info-box a {
color: #90cdf4;
}
</style>
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Players</h1>
<a href="{{ url_for('players.add_player') }}" class="btn btn-success">+ Add New Player</a>
</div>
{% if players %}
<div class="card">
<table class="players-table">
<thead>
<tr>
<th>Name</th>
<th>Hostname</th>
<th>Location</th>
<th>Orientation</th>
<th>Status</th>
<th>Last Seen</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for player in players %}
<tr>
<td>
<strong>{{ player.name }}</strong>
</td>
<td>
<code>{{ player.hostname }}</code>
</td>
<td>
{{ player.location or '-' }}
</td>
<td>
{{ player.orientation or 'Landscape' }}
</td>
<td>
{% if player.is_online %}
<span class="status-badge online">Online</span>
{% else %}
<span class="status-badge offline">Offline</span>
{% endif %}
</td>
<td>
{% if player.last_seen %}
{{ player.last_seen | localtime }}
{% else %}
<span class="text-muted">Never</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
class="btn btn-info btn-sm" title="Manage Player">
⚙️ Manage
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="info-box">
️ No players yet. <a href="{{ url_for('players.add_player') }}">Add your first player</a>
</div>
{% endif %}
</div>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
"""Utils package for digiserver-v2."""
from app.utils.logger import log_action, log_info, log_warning, log_error, get_recent_logs
from app.utils.uploads import (
save_uploaded_file,
process_video_file,
process_pdf_file,
get_upload_progress,
set_upload_progress,
clear_upload_progress,
get_file_size,
delete_file
)
from app.utils.group_player_management import (
get_player_status_info,
get_group_statistics,
assign_player_to_group,
bulk_assign_players_to_group,
get_online_players_count,
get_players_by_status
)
from app.utils.pptx_converter import pptx_to_pdf_libreoffice, validate_pptx_file
__all__ = [
# Logger
'log_action',
'log_info',
'log_warning',
'log_error',
'get_recent_logs',
# Uploads
'save_uploaded_file',
'process_video_file',
'process_pdf_file',
'get_upload_progress',
'set_upload_progress',
'clear_upload_progress',
'get_file_size',
'delete_file',
# Group/Player Management
'get_player_status_info',
'get_group_statistics',
'assign_player_to_group',
'bulk_assign_players_to_group',
'get_online_players_count',
'get_players_by_status',
# PPTX Converter
'pptx_to_pdf_libreoffice',
'validate_pptx_file',
]
+154
View File
@@ -0,0 +1,154 @@
"""Caddy configuration generator and manager."""
import os
from typing import Optional
from app.models.https_config import HTTPSConfig
class CaddyConfigGenerator:
"""Generate Caddyfile configuration based on HTTPSConfig."""
@staticmethod
def generate_caddyfile(config: Optional[HTTPSConfig] = None) -> str:
"""Generate complete Caddyfile content.
Args:
config: HTTPSConfig instance or None
Returns:
Complete Caddyfile content as string
"""
# Get config from database if not provided
if config is None:
config = HTTPSConfig.get_config()
# Base configuration
email = "admin@localhost"
if config and config.email:
email = config.email
base_config = f"""{{
# Global options
email {email}
# Admin API for configuration management (listen on all interfaces)
admin 0.0.0.0:2019
# Uncomment for testing to avoid rate limits
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}}
# Shared reverse proxy configuration
(reverse_proxy_config) {{
reverse_proxy digiserver-app:5000 {{
header_up Host {{host}}
header_up X-Real-IP {{remote_host}}
header_up X-Forwarded-Proto {{scheme}}
# Timeouts for large uploads
transport http {{
read_timeout 300s
write_timeout 300s
}}
}}
# File upload size limit (2GB)
request_body {{
max_size 2GB
}}
# Security headers
header {{
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
}}
# Logging
log {{
output file /var/log/caddy/access.log
}}
}}
# Localhost (development/local access)
http://localhost {{
import reverse_proxy_config
}}
"""
# Add main domain/IP configuration if HTTPS is enabled
if config and config.https_enabled and config.domain and config.ip_address:
# Internal domain configuration
domain_config = f"""
# Internal domain (HTTP only - internal use)
http://{config.domain} {{
import reverse_proxy_config
}}
# Handle IP address access
http://{config.ip_address} {{
import reverse_proxy_config
}}
"""
base_config += domain_config
else:
# Default fallback configuration
base_config += """
# Internal domain (HTTP only - internal use)
http://digiserver.sibiusb.harting.intra {
import reverse_proxy_config
}
# Handle IP address access
http://10.76.152.164 {
import reverse_proxy_config
}
"""
# Add catch-all for any other HTTP requests
base_config += """
# Catch-all for any other HTTP requests
http://* {
import reverse_proxy_config
}
"""
return base_config
@staticmethod
def write_caddyfile(caddyfile_content: str, path: str = '/app/Caddyfile') -> bool:
"""Write Caddyfile to disk.
Args:
caddyfile_content: Content to write
path: Path to Caddyfile
Returns:
True if successful, False otherwise
"""
try:
with open(path, 'w') as f:
f.write(caddyfile_content)
return True
except Exception as e:
print(f"Error writing Caddyfile: {str(e)}")
return False
@staticmethod
def reload_caddy() -> bool:
"""Reload Caddy configuration without restart.
Note: Caddy monitoring is handled via file watching. After writing the Caddyfile,
Caddy should automatically reload. If it doesn't, you may need to restart the
Caddy container manually.
Returns:
True if configuration was written successfully (Caddy will auto-reload)
"""
try:
# Just verify that Caddy is reachable
import urllib.request
response = urllib.request.urlopen('http://caddy:2019/config/', timeout=2)
return response.status == 200
except Exception as e:
# Caddy might not be reachable, but Caddyfile was already written
# Caddy should reload automatically when it detects file changes
print(f"Note: Caddy reload check returned: {str(e)}")
return True # Return True anyway since Caddyfile was written
@@ -0,0 +1,209 @@
"""Group and player management utilities."""
from typing import Dict, List, Optional
from datetime import datetime, timedelta
from app.extensions import db
from app.models import Player, Group, PlayerFeedback
from app.utils.logger import log_action
def get_player_status_info(player_id: int) -> Dict:
"""Get comprehensive status information for a player.
Args:
player_id: Player ID to query
Returns:
Dictionary with status information
"""
player = Player.query.get(player_id)
if not player:
return {
'online': False,
'status': 'unknown',
'last_seen': None,
'latest_feedback': None
}
# Check if player is online (seen in last 5 minutes)
is_online = False
if player.last_seen:
delta = datetime.utcnow() - player.last_seen
is_online = delta.total_seconds() < 300
# Get latest feedback
latest_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\
.order_by(PlayerFeedback.timestamp.desc())\
.first()
return {
'online': is_online,
'status': player.status,
'last_seen': player.last_seen.isoformat() if player.last_seen else None,
'last_seen_ago': _format_time_ago(player.last_seen) if player.last_seen else 'Never',
'latest_feedback': {
'status': latest_feedback.status,
'message': latest_feedback.message,
'error': latest_feedback.error,
'timestamp': latest_feedback.timestamp.isoformat()
} if latest_feedback else None
}
def get_group_statistics(group_id: int) -> Dict:
"""Get statistics for a group.
Args:
group_id: Group ID to query
Returns:
Dictionary with group statistics
"""
group = Group.query.get(group_id)
if not group:
return {
'total_players': 0,
'online_players': 0,
'total_content': 0,
'error_count': 0
}
total_players = group.player_count
total_content = group.content_count
# Count online players
online_players = 0
error_count = 0
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
for player in group.players:
if player.last_seen and player.last_seen >= five_min_ago:
online_players += 1
if player.status == 'error':
error_count += 1
return {
'group_id': group_id,
'group_name': group.name,
'total_players': total_players,
'online_players': online_players,
'offline_players': total_players - online_players,
'total_content': total_content,
'error_count': error_count
}
def assign_player_to_group(player_id: int, group_id: Optional[int]) -> bool:
"""Assign a player to a group or unassign if group_id is None.
Args:
player_id: Player ID to assign
group_id: Group ID to assign to, or None to unassign
Returns:
True if successful, False otherwise
"""
try:
player = Player.query.get(player_id)
if not player:
log_action('error', f'Player {player_id} not found')
return False
old_group_id = player.group_id
player.group_id = group_id
db.session.commit()
if group_id:
group = Group.query.get(group_id)
log_action('info', f'Player "{player.name}" assigned to group "{group.name}"')
else:
log_action('info', f'Player "{player.name}" unassigned from group')
return True
except Exception as e:
db.session.rollback()
log_action('error', f'Error assigning player to group: {str(e)}')
return False
def bulk_assign_players_to_group(player_ids: List[int], group_id: Optional[int]) -> int:
"""Assign multiple players to a group.
Args:
player_ids: List of player IDs to assign
group_id: Group ID to assign to, or None to unassign
Returns:
Number of players successfully assigned
"""
count = 0
try:
for player_id in player_ids:
player = Player.query.get(player_id)
if player:
player.group_id = group_id
count += 1
db.session.commit()
if group_id:
group = Group.query.get(group_id)
log_action('info', f'Bulk assigned {count} players to group "{group.name}"')
else:
log_action('info', f'Bulk unassigned {count} players from groups')
return count
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk assigning players: {str(e)}')
return 0
def get_online_players_count() -> int:
"""Get count of online players (seen in last 5 minutes).
Returns:
Number of online players
"""
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
return Player.query.filter(Player.last_seen >= five_min_ago).count()
def get_players_by_status(status: str) -> List[Player]:
"""Get all players with a specific status.
Args:
status: Status to filter by
Returns:
List of Player instances
"""
return Player.query.filter_by(status=status).all()
def _format_time_ago(dt: datetime) -> str:
"""Format datetime as 'time ago' string.
Args:
dt: Datetime to format
Returns:
Formatted string like '5 minutes ago'
"""
delta = datetime.utcnow() - dt
seconds = delta.total_seconds()
if seconds < 60:
return f'{int(seconds)} seconds ago'
elif seconds < 3600:
return f'{int(seconds / 60)} minutes ago'
elif seconds < 86400:
return f'{int(seconds / 3600)} hours ago'
else:
return f'{int(seconds / 86400)} days ago'
+78
View File
@@ -0,0 +1,78 @@
"""Logging utility for tracking system events."""
from typing import Optional
from datetime import datetime, timedelta
from app.extensions import db
from app.models.server_log import ServerLog
def log_action(level: str, message: str) -> None:
"""Log an action to the database with specified level.
Args:
level: Log level (info, warning, error)
message: Log message content
"""
try:
new_log = ServerLog(level=level, message=message)
db.session.add(new_log)
db.session.commit()
print(f"[{level.upper()}] {message}")
except Exception as e:
print(f"Error logging action: {e}")
db.session.rollback()
def get_recent_logs(limit: int = 20, level: Optional[str] = None) -> list:
"""Get the most recent log entries.
Args:
limit: Maximum number of logs to return
level: Optional filter by log level
Returns:
List of ServerLog instances
"""
query = ServerLog.query
if level:
query = query.filter_by(level=level)
return query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
def clear_old_logs(days: int = 30) -> int:
"""Delete logs older than specified days.
Args:
days: Number of days to keep
Returns:
Number of logs deleted
"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days)
deleted = ServerLog.query.filter(ServerLog.timestamp < cutoff_date).delete()
db.session.commit()
log_action('info', f'Deleted {deleted} old log entries (older than {days} days)')
return deleted
except Exception as e:
db.session.rollback()
print(f"Error clearing old logs: {e}")
return 0
# Convenience functions for specific log levels
def log_info(message: str) -> None:
"""Log an info level message."""
log_action('info', message)
def log_warning(message: str) -> None:
"""Log a warning level message."""
log_action('warning', message)
def log_error(message: str) -> None:
"""Log an error level message."""
log_action('error', message)
@@ -0,0 +1,120 @@
"""Nginx configuration reader utility."""
import os
import re
from typing import Dict, List, Optional, Any
class NginxConfigReader:
"""Read and parse Nginx configuration files."""
def __init__(self, config_path: str = '/etc/nginx/nginx.conf'):
"""Initialize Nginx config reader."""
self.config_path = config_path
self.config_content = None
self.is_available = os.path.exists(config_path)
if self.is_available:
try:
with open(config_path, 'r') as f:
self.config_content = f.read()
except Exception as e:
self.is_available = False
self.error = str(e)
def get_status(self) -> Dict[str, Any]:
"""Get Nginx configuration status."""
if not self.is_available:
return {
'available': False,
'error': 'Nginx configuration not found',
'path': self.config_path
}
return {
'available': True,
'path': self.config_path,
'file_exists': os.path.exists(self.config_path),
'ssl_enabled': self._check_ssl_enabled(),
'http_ports': self._extract_http_ports(),
'https_ports': self._extract_https_ports(),
'upstream_servers': self._extract_upstream_servers(),
'server_names': self._extract_server_names(),
'ssl_protocols': self._extract_ssl_protocols(),
'client_max_body_size': self._extract_client_max_body_size(),
'gzip_enabled': self._check_gzip_enabled(),
}
def _check_ssl_enabled(self) -> bool:
"""Check if SSL is enabled."""
if not self.config_content:
return False
return 'ssl_certificate' in self.config_content
def _extract_http_ports(self) -> List[int]:
"""Extract HTTP listening ports."""
if not self.config_content:
return []
pattern = r'listen\s+(\d+)'
matches = re.findall(pattern, self.config_content)
return sorted(list(set(int(p) for p in matches if int(p) < 1000)))
def _extract_https_ports(self) -> List[int]:
"""Extract HTTPS listening ports."""
if not self.config_content:
return []
pattern = r'listen\s+(\d+).*ssl'
matches = re.findall(pattern, self.config_content)
return sorted(list(set(int(p) for p in matches)))
def _extract_upstream_servers(self) -> List[str]:
"""Extract upstream servers."""
if not self.config_content:
return []
upstream_match = re.search(r'upstream\s+\w+\s*{([^}]+)}', self.config_content)
if upstream_match:
upstream_content = upstream_match.group(1)
servers = re.findall(r'server\s+([^\s;]+)', upstream_content)
return servers
return []
def _extract_server_names(self) -> List[str]:
"""Extract server names."""
if not self.config_content:
return []
pattern = r'server_name\s+([^;]+);'
matches = re.findall(pattern, self.config_content)
result = []
for match in matches:
names = match.strip().split()
result.extend(names)
return result
def _extract_ssl_protocols(self) -> List[str]:
"""Extract SSL protocols."""
if not self.config_content:
return []
pattern = r'ssl_protocols\s+([^;]+);'
match = re.search(pattern, self.config_content)
if match:
return match.group(1).strip().split()
return []
def _extract_client_max_body_size(self) -> Optional[str]:
"""Extract client max body size."""
if not self.config_content:
return None
pattern = r'client_max_body_size\s+([^;]+);'
match = re.search(pattern, self.config_content)
return match.group(1).strip() if match else None
def _check_gzip_enabled(self) -> bool:
"""Check if gzip is enabled."""
if not self.config_content:
return False
return bool(re.search(r'gzip\s+on\s*;', self.config_content))
def get_nginx_status() -> Dict[str, Any]:
"""Get Nginx configuration status."""
reader = NginxConfigReader()
return reader.get_status()
+57
View File
@@ -0,0 +1,57 @@
"""
Portal SSO middleware for DigiServer v2.
When the umbrella nginx verifies the portal JWT it sets two headers:
X-Auth-Username the portal username
X-Auth-Role 'admin' or 'user'
This before_request handler reads those headers and auto-logs in the
corresponding local DigiServer user, creating them on first access if
needed. The local session is then maintained normally by Flask-Login.
"""
import secrets
from flask import request
from flask_login import login_user, current_user
def init_portal_sso(app):
"""Register the SSO before_request handler on the given Flask app."""
@app.before_request
def _portal_sso():
if current_user.is_authenticated:
return
username = request.headers.get('X-Auth-Username', '').strip()
if not username:
return
role = request.headers.get('X-Auth-Role', 'user').strip()
user = _get_or_create_user(username, role)
if user:
login_user(user, remember=False)
def _get_or_create_user(username, role):
from app.models.user import User
from app.extensions import db, bcrypt
try:
target_role = 'admin' if role == 'admin' else 'user'
user = User.query.filter_by(username=username).first()
if not user:
hashed_pw = bcrypt.generate_password_hash(secrets.token_hex(32)).decode('utf-8')
user = User(
username=username,
password=hashed_pw,
role=target_role,
)
db.session.add(user)
db.session.commit()
elif user.role != target_role:
# Role changed in portal → sync it here immediately
user.role = target_role
db.session.commit()
return user
except Exception:
return None
+106
View File
@@ -0,0 +1,106 @@
"""PowerPoint to PDF converter using LibreOffice."""
import os
import subprocess
import time
import logging
from typing import Optional
logger = logging.getLogger(__name__)
def cleanup_libreoffice_processes() -> None:
"""Clean up any hanging LibreOffice processes."""
try:
subprocess.run(['pkill', '-f', 'soffice'], capture_output=True, timeout=10)
time.sleep(1) # Give processes time to terminate
except Exception as e:
logger.warning(f"Failed to cleanup LibreOffice processes: {e}")
def pptx_to_pdf_libreoffice(pptx_path: str, output_dir: str) -> Optional[str]:
"""Convert PPTX to PDF using LibreOffice for highest quality.
This function is the core component of the PPTX processing workflow:
PPTX PDF (this function) JPG images (handled in uploads.py)
Args:
pptx_path: Path to the PPTX file
output_dir: Directory to save the PDF
Returns:
Path to the generated PDF file, or None if conversion failed
"""
try:
# Clean up any existing LibreOffice processes
cleanup_libreoffice_processes()
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
# Use LibreOffice to convert PPTX to PDF
cmd = [
'libreoffice',
'--headless',
'--convert-to', 'pdf',
'--outdir', output_dir,
'--invisible',
'--nodefault',
pptx_path
]
logger.info(f"Converting PPTX to PDF using LibreOffice: {pptx_path}")
# Increase timeout to 300 seconds (5 minutes) for large presentations
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
logger.error(f"LibreOffice conversion failed: {result.stderr}")
logger.error(f"LibreOffice stdout: {result.stdout}")
cleanup_libreoffice_processes()
return None
# Find the generated PDF file
base_name = os.path.splitext(os.path.basename(pptx_path))[0]
pdf_path = os.path.join(output_dir, f"{base_name}.pdf")
if os.path.exists(pdf_path):
logger.info(f"PDF conversion successful: {pdf_path}")
cleanup_libreoffice_processes()
return pdf_path
else:
logger.error(f"PDF file not found after conversion: {pdf_path}")
cleanup_libreoffice_processes()
return None
except subprocess.TimeoutExpired:
logger.error("LibreOffice conversion timed out (300s)")
cleanup_libreoffice_processes()
return None
except Exception as e:
logger.error(f"Error in PPTX to PDF conversion: {e}")
cleanup_libreoffice_processes()
return None
def validate_pptx_file(filepath: str) -> bool:
"""Validate if file is a valid PowerPoint file.
Args:
filepath: Path to file to validate
Returns:
True if valid PPTX file, False otherwise
"""
if not os.path.exists(filepath):
return False
# Check file extension
if not filepath.lower().endswith(('.ppt', '.pptx')):
return False
# Check file size (must be > 0)
if os.path.getsize(filepath) == 0:
return False
return True
@@ -0,0 +1,26 @@
"""
ScriptNameFix WSGI middleware.
When nginx strips the path prefix before forwarding to a Flask app it also
sets the X-Script-Name header (e.g. /digiserver). This middleware reads
that header and sets SCRIPT_NAME in the WSGI environ so that Flask's
url_for() generates absolute URLs with the correct prefix.
Usage in the app factory:
from app.utils.script_name_fix import ScriptNameFix
app.wsgi_app = ScriptNameFix(app.wsgi_app)
"""
class ScriptNameFix:
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
script_name = environ.get('HTTP_X_SCRIPT_NAME', '').rstrip('/')
if script_name:
environ['SCRIPT_NAME'] = script_name
path_info = environ.get('PATH_INFO', '/')
if path_info.startswith(script_name):
environ['PATH_INFO'] = path_info[len(script_name):] or '/'
return self.app(environ, start_response)
+243
View File
@@ -0,0 +1,243 @@
"""Upload utilities for handling file uploads and processing."""
import os
import subprocess
import shutil
import tempfile
from typing import Optional, Dict, Tuple
from werkzeug.utils import secure_filename
from werkzeug.datastructures import FileStorage
from app.utils.logger import log_action
# In-memory storage for upload progress (use Redis in production)
_upload_progress: Dict[str, Dict] = {}
def get_upload_progress(upload_id: str) -> Dict:
"""Get upload progress for a specific upload ID.
Args:
upload_id: Unique upload identifier
Returns:
Progress dictionary with status, progress, and message
"""
return _upload_progress.get(upload_id, {
'status': 'unknown',
'progress': 0,
'message': 'Upload not found'
})
def set_upload_progress(upload_id: str, progress: int, message: str,
status: str = 'processing') -> None:
"""Set upload progress for a specific upload ID.
Args:
upload_id: Unique upload identifier
progress: Progress percentage (0-100)
message: Status message
status: Status string (uploading, processing, complete, error)
"""
_upload_progress[upload_id] = {
'status': status,
'progress': progress,
'message': message
}
def clear_upload_progress(upload_id: str) -> None:
"""Clear upload progress for a specific upload ID.
Args:
upload_id: Unique upload identifier
"""
if upload_id in _upload_progress:
del _upload_progress[upload_id]
def save_uploaded_file(file: FileStorage, upload_folder: str,
filename: Optional[str] = None) -> Tuple[bool, str, str]:
"""Save an uploaded file to the upload folder.
Args:
file: FileStorage object from request.files
upload_folder: Path to upload directory
filename: Optional custom filename (will be secured)
Returns:
Tuple of (success, filepath, message)
"""
try:
if not filename:
filename = secure_filename(file.filename)
else:
filename = secure_filename(filename)
# Ensure upload folder exists
os.makedirs(upload_folder, exist_ok=True)
filepath = os.path.join(upload_folder, filename)
# Save file
file.save(filepath)
log_action('info', f'File saved: {filename}')
return True, filepath, 'File saved successfully'
except Exception as e:
log_action('error', f'Error saving file: {str(e)}')
return False, '', f'Error saving file: {str(e)}'
def process_video_file(filepath: str, upload_id: Optional[str] = None) -> Tuple[bool, str]:
"""Process video file for optimization (H.264, 30fps, max 1080p).
Args:
filepath: Path to video file
upload_id: Optional upload ID for progress tracking
Returns:
Tuple of (success, message)
"""
try:
if upload_id:
set_upload_progress(upload_id, 60, 'Converting video to optimized format...')
# Prepare temp output file
temp_dir = tempfile.gettempdir()
temp_output = os.path.join(temp_dir, f"optimized_{os.path.basename(filepath)}")
# ffmpeg command for Raspberry Pi optimization
ffmpeg_cmd = [
'ffmpeg', '-y', '-i', filepath,
'-c:v', 'libx264',
'-preset', 'medium',
'-profile:v', 'main',
'-crf', '23',
'-maxrate', '8M',
'-bufsize', '12M',
'-vf', 'scale=\'min(1920,iw)\':\'min(1080,ih)\':force_original_aspect_ratio=decrease,fps=30',
'-r', '30',
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart',
temp_output
]
if upload_id:
set_upload_progress(upload_id, 70, 'Processing video (this may take a few minutes)...')
# Run ffmpeg
result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, timeout=1800)
if result.returncode != 0:
error_msg = f'Video conversion failed: {result.stderr[:200]}'
log_action('error', error_msg)
if upload_id:
set_upload_progress(upload_id, 0, error_msg, 'error')
# Remove original file if conversion failed
if os.path.exists(filepath):
os.remove(filepath)
return False, error_msg
if upload_id:
set_upload_progress(upload_id, 85, 'Replacing with optimized video...')
# Replace original with optimized version
shutil.move(temp_output, filepath)
log_action('info', f'Video optimized successfully: {os.path.basename(filepath)}')
return True, 'Video optimized successfully'
except subprocess.TimeoutExpired:
error_msg = 'Video conversion timed out (30 minutes)'
log_action('error', error_msg)
if upload_id:
set_upload_progress(upload_id, 0, error_msg, 'error')
return False, error_msg
except Exception as e:
error_msg = f'Error processing video: {str(e)}'
log_action('error', error_msg)
if upload_id:
set_upload_progress(upload_id, 0, error_msg, 'error')
return False, error_msg
def process_pdf_file(filepath: str, upload_id: Optional[str] = None) -> Tuple[bool, str]:
"""Process PDF file (convert to images for display).
Args:
filepath: Path to PDF file
upload_id: Optional upload ID for progress tracking
Returns:
Tuple of (success, message)
"""
try:
if upload_id:
set_upload_progress(upload_id, 60, 'Converting PDF to images...')
# This would use pdf2image or similar
# For now, just log the action
log_action('info', f'PDF processing requested for: {os.path.basename(filepath)}')
if upload_id:
set_upload_progress(upload_id, 85, 'PDF processed successfully')
return True, 'PDF processed successfully'
except Exception as e:
error_msg = f'Error processing PDF: {str(e)}'
log_action('error', error_msg)
if upload_id:
set_upload_progress(upload_id, 0, error_msg, 'error')
return False, error_msg
def get_file_size(filepath: str) -> int:
"""Get file size in bytes.
Args:
filepath: Path to file
Returns:
File size in bytes, or 0 if file doesn't exist
"""
try:
return os.path.getsize(filepath)
except Exception:
return 0
def delete_file(filepath: str) -> Tuple[bool, str]:
"""Delete a file from disk.
Args:
filepath: Path to file
Returns:
Tuple of (success, message)
"""
try:
if os.path.exists(filepath):
os.remove(filepath)
log_action('info', f'File deleted: {os.path.basename(filepath)}')
return True, 'File deleted successfully'
else:
return False, 'File not found'
except Exception as e:
error_msg = f'Error deleting file: {str(e)}'
log_action('error', error_msg)
return False, error_msg
+206
View File
@@ -0,0 +1,206 @@
#!/bin/bash
# Automated deployment script for DigiServer on a new PC
# Run this script to completely set up DigiServer with all configurations
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ DigiServer Automated Deployment ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
echo ""
# Check if docker compose is available
if ! docker compose version &> /dev/null; then
echo -e "${RED}❌ docker compose not found!${NC}"
echo "Please install docker compose first"
exit 1
fi
# Check if we're in the project directory
if [ ! -f "docker-compose.yml" ]; then
echo -e "${RED}❌ docker-compose.yml not found!${NC}"
echo "Please run this script from the digiserver-v2 directory"
exit 1
fi
# ============================================================================
# INITIALIZATION: Create data directories and copy nginx configs
# ============================================================================
echo -e "${YELLOW}📁 Initializing data directories...${NC}"
# Create necessary data directories
mkdir -p data/instance
mkdir -p data/uploads
mkdir -p data/nginx-ssl
mkdir -p data/nginx-logs
mkdir -p data/certbot
# Copy nginx configuration files from repo root to data folder
if [ -f "nginx.conf" ]; then
cp nginx.conf data/nginx.conf
echo -e " ${GREEN}${NC} nginx.conf copied to data/"
else
echo -e " ${RED}❌ nginx.conf not found in repo root!${NC}"
exit 1
fi
if [ -f "nginx-custom-domains.conf" ]; then
cp nginx-custom-domains.conf data/nginx-custom-domains.conf
echo -e " ${GREEN}${NC} nginx-custom-domains.conf copied to data/"
else
echo -e " ${RED}❌ nginx-custom-domains.conf not found in repo root!${NC}"
exit 1
fi
echo -e "${GREEN}✅ Data directories initialized${NC}"
echo ""
# ============================================================================
# CONFIGURATION VARIABLES
# ============================================================================
HOSTNAME="${HOSTNAME:-digiserver}"
DOMAIN="${DOMAIN:-digiserver.sibiusb.harting.intra}"
IP_ADDRESS="${IP_ADDRESS:-10.76.152.164}"
EMAIL="${EMAIL:-admin@example.com}"
PORT="${PORT:-443}"
echo -e "${BLUE}Configuration:${NC}"
echo " Hostname: $HOSTNAME"
echo " Domain: $DOMAIN"
echo " IP Address: $IP_ADDRESS"
echo " Email: $EMAIL"
echo " Port: $PORT"
echo ""
# ============================================================================
# STEP 1: Start containers
# ============================================================================
echo -e "${YELLOW}📦 [1/6] Starting containers...${NC}"
docker compose up -d
echo -e "${YELLOW}⏳ Waiting for containers to be healthy...${NC}"
sleep 10
# Verify containers are running
if ! docker compose ps | grep -q "Up"; then
echo -e "${RED}❌ Containers failed to start!${NC}"
docker compose logs
exit 1
fi
echo -e "${GREEN}✅ Containers started successfully${NC}"
echo ""
# ============================================================================
# STEP 2: Run database migrations
# ============================================================================
echo -e "${YELLOW}📊 [2/6] Running database migrations...${NC}"
echo -e " • Creating https_config table..."
docker compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
echo -e " • Creating player_user table..."
docker compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
echo -e " • Adding email to https_config..."
docker compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
echo -e " • Migrating player_user global settings..."
docker compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
echo -e "${GREEN}✅ All database migrations completed${NC}"
echo ""
# ============================================================================
# STEP 3: Configure HTTPS
# ============================================================================
echo -e "${YELLOW}🔒 [3/6] Configuring HTTPS...${NC}"
docker compose exec -T digiserver-app python /app/https_manager.py enable \
"$HOSTNAME" \
"$DOMAIN" \
"$EMAIL" \
"$IP_ADDRESS" \
"$PORT"
echo -e "${GREEN}✅ HTTPS configured successfully${NC}"
echo ""
# ============================================================================
# STEP 4: Verify database setup
# ============================================================================
echo -e "${YELLOW}🔍 [4/6] Verifying database setup...${NC}"
docker compose exec -T digiserver-app python -c "
from app.app import create_app
from sqlalchemy import inspect
app = create_app()
with app.app_context():
inspector = inspect(app.extensions.db.engine)
tables = inspector.get_table_names()
print(' Database tables:')
for table in sorted(tables):
print(f' ✓ {table}')
print(f'')
print(f' ✅ Total tables: {len(tables)}')
" 2>/dev/null || echo " ⚠️ Database verification skipped"
echo ""
# ============================================================================
# STEP 5: Verify Caddy configuration
# ============================================================================
echo -e "${YELLOW}🔧 [5/6] Verifying Caddy configuration...${NC}"
docker compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1
if [ $? -eq 0 ]; then
echo -e " ${GREEN}✅ Caddy configuration is valid${NC}"
else
echo -e " ${YELLOW}⚠️ Caddy validation skipped${NC}"
fi
echo ""
# ============================================================================
# STEP 6: Display summary
# ============================================================================
echo -e "${YELLOW}📋 [6/6] Displaying configuration summary...${NC}"
echo ""
docker compose exec -T digiserver-app python /app/https_manager.py status
echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ 🎉 Deployment Complete! ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BLUE}📍 Access Points:${NC}"
echo " 🔒 https://$HOSTNAME"
echo " 🔒 https://$IP_ADDRESS"
echo " 🔒 https://$DOMAIN"
echo ""
echo -e "${BLUE}📝 Default Credentials:${NC}"
echo " Username: admin"
echo " Password: admin123 (⚠️ CHANGE IN PRODUCTION)"
echo ""
echo -e "${BLUE}📚 Documentation:${NC}"
echo " • DEPLOYMENT_COMMANDS.md - Detailed docker exec commands"
echo " • HTTPS_CONFIGURATION.md - HTTPS setup details"
echo " • setup_https.sh - Manual configuration script"
echo ""
echo -e "${YELLOW}Next Steps:${NC}"
echo "1. Access the application at one of the URLs above"
echo "2. Log in with admin credentials"
echo "3. Change the default password immediately"
echo "4. Configure your players and content"
echo ""
echo -e "${BLUE}📞 Support:${NC}"
echo "For troubleshooting, see DEPLOYMENT_COMMANDS.md section 7"
echo ""
@@ -0,0 +1,246 @@
#!/bin/bash
# DigiServer v2 Production Deployment Commands Reference
# Use this file as a reference for all deployment-related operations
echo "📋 DigiServer v2 Production Deployment Reference"
echo "================================================="
echo ""
echo "QUICK START:"
echo " 1. Set environment variables"
echo " 2. Create .env file"
echo " 3. Run: docker-compose up -d"
echo ""
echo "Available commands below (copy/paste as needed):"
echo ""
# ============================================================================
# SECTION: INITIAL SETUP
# ============================================================================
echo "=== SECTION 1: INITIAL SETUP ==="
echo ""
echo "Generate Secret Key:"
echo ' python -c "import secrets; print(secrets.token_urlsafe(32))"'
echo ""
echo "Create environment file from template:"
echo " cp .env.example .env"
echo " nano .env # Edit with your values"
echo ""
echo "Required .env variables:"
echo " SECRET_KEY=<generated-32-char-key>"
echo " ADMIN_USERNAME=admin"
echo " ADMIN_PASSWORD=<strong-password>"
echo " ADMIN_EMAIL=admin@company.com"
echo " DOMAIN=your-domain.com"
echo " EMAIL=admin@your-domain.com"
echo ""
# ============================================================================
# SECTION: DOCKER OPERATIONS
# ============================================================================
echo "=== SECTION 2: DOCKER OPERATIONS ==="
echo ""
echo "Build images:"
echo " docker-compose build"
echo ""
echo "Start services:"
echo " docker-compose up -d"
echo ""
echo "Stop services:"
echo " docker-compose down"
echo ""
echo "Restart services:"
echo " docker-compose restart"
echo ""
echo "View container status:"
echo " docker-compose ps"
echo ""
echo "View logs (live):"
echo " docker-compose logs -f digiserver-app"
echo ""
echo "View logs (last 100 lines):"
echo " docker-compose logs --tail=100 digiserver-app"
echo ""
# ============================================================================
# SECTION: DATABASE OPERATIONS
# ============================================================================
echo "=== SECTION 3: DATABASE OPERATIONS ==="
echo ""
echo "Initialize database (first deployment only):"
echo " docker-compose exec digiserver-app flask db upgrade"
echo ""
echo "Run database migrations:"
echo " docker-compose exec digiserver-app flask db upgrade head"
echo ""
echo "Create new migration (after model changes):"
echo " docker-compose exec digiserver-app flask db migrate -m 'description'"
echo ""
echo "Backup database:"
echo " docker-compose exec digiserver-app cp instance/dashboard.db /backup/dashboard.db.bak"
echo ""
echo "Restore database:"
echo " docker-compose exec digiserver-app cp /backup/dashboard.db.bak instance/dashboard.db"
echo ""
# ============================================================================
# SECTION: VERIFICATION & TESTING
# ============================================================================
echo "=== SECTION 4: VERIFICATION & TESTING ==="
echo ""
echo "Health check:"
echo " curl -k https://your-domain.com/api/health"
echo ""
echo "Check CORS headers (should see Access-Control-Allow-*):"
echo " curl -i -k https://your-domain.com/api/playlists"
echo ""
echo "Check HTTPS only (should redirect):"
echo " curl -i http://your-domain.com/"
echo ""
echo "Test certificate:"
echo " openssl s_client -connect your-domain.com:443 -showcerts"
echo ""
echo "Check SSL certificate expiry:"
echo " openssl x509 -enddate -noout -in data/nginx-ssl/cert.pem"
echo ""
# ============================================================================
# SECTION: TROUBLESHOOTING
# ============================================================================
echo "=== SECTION 5: TROUBLESHOOTING ==="
echo ""
echo "View full container logs:"
echo " docker-compose logs digiserver-app"
echo ""
echo "Execute command in container:"
echo " docker-compose exec digiserver-app bash"
echo ""
echo "Check container resources:"
echo " docker stats"
echo ""
echo "Remove and rebuild from scratch:"
echo " docker-compose down -v"
echo " docker-compose build --no-cache"
echo " docker-compose up -d"
echo ""
echo "Check disk space:"
echo " du -sh data/"
echo ""
echo "View network configuration:"
echo " docker-compose exec digiserver-app netstat -tuln"
echo ""
# ============================================================================
# SECTION: MAINTENANCE
# ============================================================================
echo "=== SECTION 6: MAINTENANCE ==="
echo ""
echo "Clean up unused Docker resources:"
echo " docker system prune -a"
echo ""
echo "Backup entire application:"
echo " tar -czf digiserver-backup-\$(date +%Y%m%d).tar.gz ."
echo ""
echo "Update Docker images:"
echo " docker-compose pull"
echo " docker-compose up -d"
echo ""
echo "Rebuild and redeploy:"
echo " docker-compose down"
echo " docker-compose build --no-cache"
echo " docker-compose up -d"
echo ""
# ============================================================================
# SECTION: MONITORING
# ============================================================================
echo "=== SECTION 7: MONITORING ==="
echo ""
echo "Monitor containers in real-time:"
echo " watch -n 1 docker-compose ps"
echo ""
echo "Monitor resource usage:"
echo " docker stats --no-stream"
echo ""
echo "Check application errors:"
echo " docker-compose logs --since 10m digiserver-app | grep ERROR"
echo ""
# ============================================================================
# SECTION: GIT OPERATIONS
# ============================================================================
echo "=== SECTION 8: GIT OPERATIONS ==="
echo ""
echo "Check deployment status:"
echo " git status"
echo ""
echo "View deployment history:"
echo " git log --oneline -5"
echo ""
echo "Commit deployment changes:"
echo " git add ."
echo " git commit -m 'Deployment configuration'"
echo ""
echo "Tag release:"
echo " git tag -a v2.0.0 -m 'Production release'"
echo " git push --tags"
echo ""
# ============================================================================
# SECTION: EMERGENCY PROCEDURES
# ============================================================================
echo "=== SECTION 9: EMERGENCY PROCEDURES ==="
echo ""
echo "Kill stuck container:"
echo " docker-compose kill digiserver-app"
echo ""
echo "Restore from backup:"
echo " docker-compose down"
echo " cp /backup/dashboard.db.bak data/instance/dashboard.db"
echo " docker-compose up -d"
echo ""
echo "Rollback to previous version:"
echo " git checkout v1.9.0"
echo " docker-compose down"
echo " docker-compose build"
echo " docker-compose up -d"
echo ""
# ============================================================================
# SECTION: QUICK REFERENCE
# ============================================================================
echo "=== SECTION 10: QUICK REFERENCE ALIASES ==="
echo ""
echo "Add these to your ~/.bashrc for quick access:"
echo ""
cat << 'EOF'
alias ds-start='docker-compose up -d'
alias ds-stop='docker-compose down'
alias ds-logs='docker-compose logs -f digiserver-app'
alias ds-health='curl -k https://your-domain/api/health'
alias ds-status='docker-compose ps'
alias ds-bash='docker-compose exec digiserver-app bash'
EOF
echo ""
# ============================================================================
# DONE
# ============================================================================
echo "=== END OF REFERENCE ==="
echo ""
echo "For detailed documentation, see:"
echo " - PRODUCTION_DEPLOYMENT_GUIDE.md"
echo " - DEPLOYMENT_READINESS_SUMMARY.md"
echo " - old_code_documentation/"
echo ""
+61
View File
@@ -0,0 +1,61 @@
#version: '3.8'
services:
digiserver-app:
build: .
container_name: digiserver-v2
# Don't expose directly; use Caddy reverse proxy instead
expose:
- "5000"
volumes:
# Code is in the Docker image - no volume mount needed
# Only mount persistent data folders:
- ./data/instance:/app/instance
- ./data/uploads:/app/app/static/uploads
environment:
- FLASK_ENV=production
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/').read()"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- digiserver-network
# Nginx reverse proxy with HTTPS support
nginx:
image: nginx:alpine
container_name: digiserver-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./data/nginx.conf:/etc/nginx/nginx.conf:ro
- ./data/nginx-custom-domains.conf:/etc/nginx/conf.d/custom-domains.conf:rw
- ./data/nginx-ssl:/etc/nginx/ssl:ro
- ./data/nginx-logs:/var/log/nginx
- ./data/certbot:/var/www/certbot:ro # For Let's Encrypt ACME challenges
environment:
- DOMAIN=${DOMAIN:-localhost}
- EMAIL=${EMAIL:-admin@localhost}
depends_on:
digiserver-app:
condition: service_started
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- digiserver-network
networks:
digiserver-network:
driver: bridge
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
set -e
echo "Starting DigiServer v2..."
# Create necessary directories
mkdir -p /app/instance
mkdir -p /app/app/static/uploads
# Initialize database if it doesn't exist
if [ ! -f /app/instance/dashboard.db ]; then
echo "Initializing database..."
python -c "
from app.app import create_app
from app.extensions import db, bcrypt
from app.models import User
app = create_app('production')
with app.app_context():
db.create_all()
# Create or update admin user from environment variables
import os
admin_username = os.getenv('ADMIN_USERNAME', 'admin')
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
admin = User.query.filter_by(username=admin_username).first()
if not admin:
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
admin = User(username=admin_username, password=hashed, role='admin')
db.session.add(admin)
db.session.commit()
print(f'✅ Admin user created ({admin_username})')
else:
# Update password if it exists
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
admin.password = hashed
db.session.commit()
print(f'✅ Admin user password updated ({admin_username})')
"
echo "Database initialized!"
fi
# Start the application
echo "Starting Gunicorn..."
exec gunicorn \
--bind 0.0.0.0:5000 \
--workers 4 \
--timeout 120 \
--access-logfile - \
--error-logfile - \
"app.app:create_app('production')"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Fix player_user table schema by dropping and recreating it."""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from app.models.player_user import PlayerUser
def main():
app = create_app('production')
with app.app_context():
# Drop the old table
print("Dropping player_user table...")
db.session.execute(db.text('DROP TABLE IF EXISTS player_user'))
db.session.commit()
# Recreate with new schema
print("Creating player_user table with new schema...")
PlayerUser.__table__.create(db.engine)
print("Done! player_user table recreated successfully.")
if __name__ == '__main__':
main()
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Generate self-signed SSL certificates for Nginx
# Usage: ./generate_nginx_certs.sh [domain] [days]
DOMAIN=${1:-localhost}
DAYS=${2:-365}
CERT_DIR="./data/nginx-ssl"
echo "🔐 Generating self-signed SSL certificate for Nginx"
echo "Domain: $DOMAIN"
echo "Valid for: $DAYS days"
echo "Certificate directory: $CERT_DIR"
# Create directory if it doesnt exist
mkdir -p "$CERT_DIR"
# Generate private key and certificate
openssl req -x509 -nodes -days "$DAYS" \
-newkey rsa:2048 \
-keyout "$CERT_DIR/key.pem" \
-out "$CERT_DIR/cert.pem" \
-subj "/CN=$DOMAIN/O=DigiServer/C=US"
# Set proper permissions
chmod 644 "$CERT_DIR/cert.pem"
chmod 600 "$CERT_DIR/key.pem"
echo "✅ Certificates generated successfully!"
echo "Certificate: $CERT_DIR/cert.pem"
echo "Key: $CERT_DIR/key.pem"
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# Install emoji fonts for Raspberry Pi
echo "Installing emoji font support for Raspberry Pi..."
apt-get update -qq
apt-get install -y fonts-noto-color-emoji fonts-noto-emoji
echo "✅ Emoji fonts installed!"
echo "Please restart your browser to see the changes."
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# LibreOffice installation script for DigiServer v2
# This script installs LibreOffice for PPTX to image conversion
set -e
echo "======================================"
echo "LibreOffice Installation Script"
echo "======================================"
echo ""
# Check if already installed
if command -v libreoffice &> /dev/null; then
VERSION=$(libreoffice --version 2>/dev/null || echo "Unknown")
echo "✅ LibreOffice is already installed: $VERSION"
exit 0
fi
echo "📦 Installing LibreOffice..."
echo ""
# Update package list
echo "Updating package list..."
apt-get update -qq
# Install LibreOffice
echo "Installing LibreOffice (this may take a few minutes)..."
apt-get install -y libreoffice libreoffice-impress
# Verify installation
if command -v libreoffice &> /dev/null; then
VERSION=$(libreoffice --version 2>/dev/null || echo "Installed")
echo ""
echo "✅ LibreOffice successfully installed: $VERSION"
echo ""
echo "You can now upload and convert PowerPoint presentations (PPTX files)."
exit 0
else
echo ""
echo "❌ LibreOffice installation failed"
exit 1
fi
+153
View File
@@ -0,0 +1,153 @@
#!/bin/bash
# Network Migration Script for DigiServer
# Use this when moving the server to a new network with a different IP address
# Example: ./migrate_network.sh 10.55.150.160
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Check arguments
if [ $# -lt 1 ]; then
echo -e "${RED}❌ Usage: ./migrate_network.sh <new_ip_address> [hostname]${NC}"
echo ""
echo " Example: ./migrate_network.sh 10.55.150.160"
echo " Example: ./migrate_network.sh 10.55.150.160 digiserver-secured"
echo ""
exit 1
fi
NEW_IP="$1"
HOSTNAME="${2:-digiserver}"
EMAIL="${EMAIL:-admin@example.com}"
PORT="${PORT:-443}"
# Validate IP format
if ! [[ "$NEW_IP" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo -e "${RED}❌ Invalid IP address format: $NEW_IP${NC}"
exit 1
fi
echo -e "${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ DigiServer Network Migration ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BLUE}Migration Settings:${NC}"
echo " New IP Address: $NEW_IP"
echo " Hostname: $HOSTNAME"
echo " Email: $EMAIL"
echo " Port: $PORT"
echo ""
# Check if containers are running
echo -e "${YELLOW}🔍 [1/4] Checking containers...${NC}"
if ! docker compose ps | grep -q "digiserver-app"; then
echo -e "${RED}❌ digiserver-app container not running!${NC}"
echo "Please start containers with: docker compose up -d"
exit 1
fi
echo -e "${GREEN}✅ Containers are running${NC}"
echo ""
# Step 1: Regenerate SSL certificates for new IP
echo -e "${YELLOW}🔐 [2/4] Regenerating SSL certificates for new IP...${NC}"
echo " Generating self-signed certificate for $NEW_IP..."
CERT_DIR="./data/nginx-ssl"
mkdir -p "$CERT_DIR"
openssl req -x509 -nodes -days 365 \
-newkey rsa:2048 \
-keyout "$CERT_DIR/key.pem" \
-out "$CERT_DIR/cert.pem" \
-subj "/CN=$NEW_IP/O=DigiServer/C=US" >/dev/null 2>&1
chmod 644 "$CERT_DIR/cert.pem"
chmod 600 "$CERT_DIR/key.pem"
echo -e " ${GREEN}${NC} Certificates regenerated for $NEW_IP"
echo -e "${GREEN}✅ SSL certificates updated${NC}"
echo ""
# Step 2: Update HTTPS configuration in database
echo -e "${YELLOW}🔧 [3/4] Updating HTTPS configuration in database...${NC}"
docker compose exec -T digiserver-app python << EOF
from app.app import create_app
from app.models.https_config import HTTPSConfig
from app.extensions import db
app = create_app('production')
with app.app_context():
# Update or create HTTPS config for the new IP
https_config = HTTPSConfig.query.first()
if https_config:
https_config.hostname = '$HOSTNAME'
https_config.ip_address = '$NEW_IP'
https_config.email = '$EMAIL'
https_config.port = $PORT
https_config.enabled = True
db.session.commit()
print(f" ✓ HTTPS configuration updated")
print(f" Hostname: {https_config.hostname}")
print(f" IP: {https_config.ip_address}")
print(f" Port: {https_config.port}")
else:
print(" ⚠️ No existing HTTPS config found")
print(" This will be created on next app startup")
EOF
echo -e "${GREEN}✅ Database configuration updated${NC}"
echo ""
# Step 3: Restart containers
echo -e "${YELLOW}🔄 [4/4] Restarting containers...${NC}"
docker compose restart nginx digiserver-app
sleep 3
if ! docker compose ps | grep -q "Up"; then
echo -e "${RED}❌ Containers failed to restart!${NC}"
docker compose logs | tail -20
exit 1
fi
echo -e "${GREEN}✅ Containers restarted successfully${NC}"
echo ""
# Verification
echo -e "${YELLOW}🔍 Verifying HTTPS connectivity...${NC}"
sleep 2
if curl -s -k -I https://$NEW_IP 2>/dev/null | grep -q "HTTP"; then
echo -e "${GREEN}✅ HTTPS connection verified${NC}"
else
echo -e "${YELLOW}⚠️ HTTPS verification pending (containers warming up)${NC}"
fi
echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ✅ Network Migration Complete! ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BLUE}📍 New Access Points:${NC}"
echo " 🔒 https://$NEW_IP"
echo " 🔒 https://$HOSTNAME.local (if mDNS enabled)"
echo ""
echo -e "${BLUE}📋 Changes Made:${NC}"
echo " ✓ SSL certificates regenerated for $NEW_IP"
echo " ✓ Database HTTPS config updated"
echo " ✓ Nginx and app containers restarted"
echo ""
echo -e "${YELLOW}⏳ Allow 30 seconds for containers to become fully healthy${NC}"
echo ""
@@ -0,0 +1,33 @@
"""Add email field to https_config table."""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from sqlalchemy import text
app = create_app()
with app.app_context():
print("Adding email column to https_config table...")
try:
# Check if column already exists
inspector = db.inspect(db.engine)
columns = [col['name'] for col in inspector.get_columns('https_config')]
if 'email' not in columns:
# Add the column
with db.engine.connect() as conn:
conn.execute(text('ALTER TABLE https_config ADD COLUMN email VARCHAR(255)'))
conn.commit()
print("✓ Email column added to https_config table!")
else:
print("✓ Email column already exists in https_config table.")
except Exception as e:
print(f"️ Note: If table doesn't exist, run add_https_config_table.py first")
print(f"Error details: {str(e)}")
print("\nAlternatively, you can reset the database:")
print(" rm instance/digiserver.db")
print(" python migrations/add_https_config_table.py")
@@ -0,0 +1,14 @@
"""Add https_config table for HTTPS configuration management."""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from app.models.https_config import HTTPSConfig
app = create_app()
with app.app_context():
print("Creating https_config table...")
db.create_all()
print("✓ https_config table created successfully!")
@@ -0,0 +1,14 @@
"""Add player_user table for user code mappings."""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from app.models.player_user import PlayerUser
app = create_app()
with app.app_context():
print("Creating player_user table...")
db.create_all()
print("✓ player_user table created successfully!")
@@ -0,0 +1,24 @@
"""Migrate player_user table to remove player_id and make user_code unique globally."""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from sqlalchemy import text
app = create_app('production')
with app.app_context():
print("Migrating player_user table...")
# Drop existing table and recreate with new schema
db.session.execute(text('DROP TABLE IF EXISTS player_user'))
db.session.commit()
# Create new table
db.create_all()
print("✓ player_user table migrated successfully!")
print(" - Removed player_id foreign key")
print(" - Made user_code unique globally")
print(" - user_name is now nullable")
+21
View File
@@ -0,0 +1,21 @@
# Nginx configuration for custom HTTPS domains
# This file will be dynamically generated based on HTTPSConfig database entries
# Include this in your nginx.conf with: include /etc/nginx/conf.d/custom-domains.conf;
# Example entry for custom domain:
# server {
# listen 443 ssl http2;
# listen [::]:443 ssl http2;
# server_name digiserver.example.com;
#
# ssl_certificate /etc/nginx/ssl/custom/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/custom/key.pem;
#
# location / {
# proxy_pass http://digiserver_app;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }
+129
View File
@@ -0,0 +1,129 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 2048M;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml;
# Upstream to Flask application
upstream digiserver_app {
server digiserver-app:5000;
keepalive 32;
}
# HTTP Server - redirect to HTTPS
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# Allow ACME challenges for Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect HTTP to HTTPS for non-ACME requests
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS Server (with self-signed cert by default)
server {
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
server_name localhost;
# SSL certificate paths (will be volume-mounted)
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
# SSL Configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# CORS Headers for API endpoints (allows player device connections)
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
add_header 'Access-Control-Max-Age' '3600' always;
# Handle OPTIONS requests for CORS preflight
if ($request_method = 'OPTIONS') {
return 204;
}
# Proxy settings
location / {
proxy_pass http://digiserver_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Forwarded-Port $server_port;
# Timeouts for large uploads
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# Buffering
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
# Static files caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
proxy_pass http://digiserver_app;
proxy_cache_valid 200 60d;
expires 60d;
add_header Cache-Control "public, immutable";
}
}
# Additional server blocks for custom domains can be included here
include /etc/nginx/conf.d/*.conf;
}
@@ -0,0 +1,21 @@
# Flask Environment
FLASK_APP=app.py
FLASK_ENV=development
# Security
SECRET_KEY=change-this-to-a-random-secret-key
# Domain & SSL (for HTTPS with Caddy)
DOMAIN=your-domain.com
EMAIL=admin@your-domain.com
# Database
DATABASE_URL=sqlite:///instance/dev.db
# Admin User Credentials (used during initial Docker deployment)
# These credentials are set when the database is first created
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-this-secure-password
# Optional: Sentry for error tracking
# SENTRY_DSN=your-sentry-dsn-here
@@ -0,0 +1,295 @@
# Caddy Dynamic Configuration Management
## Overview
The HTTPS configuration system now automatically generates and manages the Caddy configuration in real-time. When an admin updates settings through the admin panel, the Caddyfile is regenerated and reloaded without requiring a full container restart.
## How It Works
### 1. **Configuration Generation**
When admin saves HTTPS settings:
1. Settings are saved to database (HTTPSConfig table)
2. `CaddyConfigGenerator` creates a new Caddyfile based on settings
3. Generated Caddyfile is written to disk
### 2. **Configuration Reload**
After Caddyfile is written:
1. Caddy reload API is called via `docker-compose exec`
2. Caddy validates and applies new configuration
3. No downtime - live configuration update
### 3. **Fallback Configuration**
If HTTPS is disabled:
1. System uses default hardcoded configuration
2. Supports localhost, internal domain, and IP address
3. Catch-all configuration for any other requests
## Files Involved
### New Files
- **`app/utils/caddy_manager.py`** - CaddyConfigGenerator class with:
- `generate_caddyfile()` - Generates Caddyfile content
- `write_caddyfile()` - Writes to disk
- `reload_caddy()` - Reloads via Docker
### Updated Files
- **`app/blueprints/admin.py`** - HTTPS config route now:
- Generates new Caddyfile
- Writes to disk
- Reloads Caddy automatically
- Reports status to user
## Admin Panel Workflow
### Step 1: User Fills Form
```
Admin Panel → HTTPS Configuration
- Hostname: digiserver
- Domain: digiserver.sibiusb.harting.intra
- Email: admin@example.com
- IP: 10.76.152.164
- Port: 443
```
### Step 2: Admin Saves Configuration
- POST /admin/https-config/update
- Settings validated and saved to database
- Caddyfile generated dynamically
- Caddy reloaded with new configuration
### Step 3: User Sees Confirmation
```
✅ HTTPS configuration saved successfully!
✅ Caddy configuration updated successfully!
Server available at https://digiserver.sibiusb.harting.intra
```
### Step 4: Configuration Live
- New domain/IP immediately active
- No container restart needed
- Caddy applying new routes in real-time
## Generated Caddyfile Structure
**When HTTPS Enabled:**
```caddyfile
{
email admin@example.com
}
(reverse_proxy_config) {
reverse_proxy digiserver-app:5000 { ... }
request_body { max_size 2GB }
header { ... }
log { ... }
}
http://localhost { import reverse_proxy_config }
http://digiserver.sibiusb.harting.intra { import reverse_proxy_config }
http://10.76.152.164 { import reverse_proxy_config }
http://* { import reverse_proxy_config }
```
**When HTTPS Disabled:**
```caddyfile
{
email admin@localhost
}
(reverse_proxy_config) { ... }
http://localhost { import reverse_proxy_config }
http://digiserver.sibiusb.harting.intra { import reverse_proxy_config }
http://10.76.152.164 { import reverse_proxy_config }
http://* { import reverse_proxy_config }
```
## Key Features
### ✅ No Restart Required
- Caddyfile changes applied without restarting containers
- Caddy reload API handles configuration hot-swap
- Zero downtime configuration updates
### ✅ Dynamic Configuration
- Settings in admin panel → Generated Caddyfile
- Database is source of truth
- Easy to modify in admin UI
### ✅ Automatic Fallbacks
- Catch-all `http://*` handles any host
- Always has localhost access
- Always has IP address access
### ✅ User Feedback
- Admin sees status of Caddy reload
- Error messages if Caddy reload fails
- Logging of all changes
### ✅ Safe Updates
- Caddyfile validation before reload
- Graceful error handling
- Falls back to previous config if reload fails
## Error Handling
If Caddy reload fails:
1. Database still has updated settings
2. Old Caddyfile may still be in use
3. User sees warning with status
4. Admin can manually restart: `docker-compose restart caddy`
## Admin Panel Status Messages
### Success (✅)
```
✅ HTTPS configuration saved successfully!
✅ Caddy configuration updated successfully!
Server available at https://domain.local
```
### Partial Success (⚠️)
```
✅ HTTPS configuration saved successfully!
⚠️ Caddyfile updated but reload failed. Please restart containers.
Server available at https://domain.local
```
### Configuration Saved, Update Failed (⚠️)
```
⚠️ Configuration saved but Caddy update failed: [error details]
```
## Testing Configuration
### Check Caddyfile Content
```bash
cat /srv/digiserver-v2/Caddyfile
```
### Manually Reload Caddy
```bash
docker-compose exec caddy caddy reload --config /etc/caddy/Caddyfile
```
### Check Caddy Status
```bash
docker-compose logs caddy --tail=20
```
### Test Access Points
```bash
# Test all configured domains/IPs
curl http://localhost
curl http://digiserver.sibiusb.harting.intra
curl http://10.76.152.164
```
## Configuration Database
Settings stored in `https_config` table:
```
https_enabled: boolean
hostname: string
domain: string
ip_address: string
email: string
port: integer
updated_at: datetime
updated_by: string
```
When admin updates form → Database updated → Caddyfile regenerated → Caddy reloaded
## Workflow Diagram
```
┌─────────────────────┐
│ Admin Panel Form │
│ (HTTPS Config) │
└──────────┬──────────┘
│ Submit
┌─────────────────────┐
│ Validate Input │
└──────────┬──────────┘
│ Valid
┌─────────────────────┐
│ Save to Database │
│ (HTTPSConfig) │
└──────────┬──────────┘
│ Saved
┌─────────────────────┐
│ Generate Caddyfile │
│ (CaddyConfigGen) │
└──────────┬──────────┘
│ Generated
┌─────────────────────┐
│ Write to Disk │
│ (/Caddyfile) │
└──────────┬──────────┘
│ Written
┌─────────────────────┐
│ Reload Caddy │
│ (Docker exec) │
└──────────┬──────────┘
│ Reloaded
┌─────────────────────┐
│ Show Status to │
│ Admin (Success) │
└─────────────────────┘
```
## Implementation Details
### CaddyConfigGenerator Class
**generate_caddyfile(config)**
- Takes HTTPSConfig from database
- Generates complete Caddyfile content
- Uses shared reverse proxy configuration template
- Returns full Caddyfile as string
**write_caddyfile(content, path)**
- Writes generated content to disk
- Path defaults to /srv/digiserver-v2/Caddyfile
- Returns True on success, False on error
**reload_caddy()**
- Runs: `docker-compose exec -T caddy caddy reload`
- Validates config and applies live
- Returns True on success, False on error
## Advantages Over Manual Configuration
| Manual | Dynamic |
|--------|---------|
| Edit Caddyfile manually | Change via admin panel |
| Restart container | No restart needed |
| Risk of syntax errors | Validated generation |
| No audit trail | Logged with username |
| Each change is manual | One-time setup |
## Future Enhancements
Potential improvements:
- Configuration history/backup
- Rollback to previous config
- Health check after reload
- Automatic backup before update
- Configuration templates
- Multi-domain support
## Support
For issues:
1. Check admin panel messages for Caddy reload status
2. Review logs: `docker-compose logs caddy`
3. Check Caddyfile: `cat /srv/digiserver-v2/Caddyfile`
4. Manual reload: `docker-compose exec caddy caddy reload --config /etc/caddy/Caddyfile`
5. Full restart: `docker-compose restart caddy`
@@ -0,0 +1,75 @@
# Data Folder Deployment Guide
## Overview
The `./data` folder is the **persistent data storage** for the DigiServer deployment. It is **NOT committed to the repository** but contains all necessary files copied from the repo during deployment.
## Structure
```
data/
├── app/ # Complete application code (copied from ./app)
├── Caddyfile # Reverse proxy configuration (copied from root)
├── instance/ # Flask instance folder (database, configs)
├── uploads/ # User file uploads
├── caddy-data/ # Caddy SSL certificates and cache
└── caddy-config/ # Caddy configuration data
```
## Deployment Process
### Step 1: Initialize Data Folder
Run this script to copy all necessary files from the repository to `./data`:
```bash
./init-data.sh
```
This will:
- Create the `./data` directory structure
- Copy `./app` folder to `./data/app`
- Copy `Caddyfile` to `./data/Caddyfile`
- Set proper permissions for all files and folders
### Step 2: Start Docker Containers
```bash
docker-compose up -d --build
```
### Step 3: Run Migrations (First Time Only)
```bash
sudo bash deploy.sh
```
## Important Notes
- **./data is NOT in git**: The `./data` folder is listed in `.gitignore` and will not be committed
- **All persistent data here**: Database files, uploads, certificates, and configurations are stored in `./data`
- **Easy backups**: To backup the entire deployment, backup the `./data` folder
- **Easy troubleshooting**: Check the `./data` folder to verify all required files are present
- **Updates**: When you pull new changes, run `./init-data.sh` to update app files in `./data`
## Deployment Checklist
✓ All volumes in docker-compose.yml point to `./data`
`./data` folder contains: app/, Caddyfile, instance/, uploads/, caddy-data/, caddy-config/
✓ Files are copied from repository to `./data` via init-data.sh
✓ Permissions are correctly set for Docker container user
## Verification
Before starting:
```bash
ls -la data/
# Should show: app/, Caddyfile, instance/, uploads/, caddy-data/, caddy-config/
```
After deployment check data folder for:
```bash
data/instance/*.db # Database files
data/uploads/ # User uploads
data/caddy-data/*.pem # SSL certificates
```
@@ -0,0 +1,201 @@
# Dockerfile vs init-data.sh Analysis
**Date:** January 17, 2026
## Current Architecture
### Current Workflow
```
1. Run init-data.sh (on host)
2. Copies app code → data/app/
3. Docker build creates image
4. Docker run mounts ./data:/app
5. Container runs with host's data/ folder
```
### Current Docker Setup
- **Dockerfile**: Copies code from build context to `/app` inside image
- **docker-compose**: Mounts `./data:/app` **OVERRIDING** the Dockerfile copy
- **Result**: Code in image is replaced by volume mount to host's `./data` folder
---
## Problem with Current Approach
1. **Code Duplication**
- Code exists in: Host `./app/` folder
- Code copied to: Host `./data/app/` folder
- Code in Docker image: Ignored/overridden
2. **Extra Deployment Step**
- Must run `init-data.sh` before deployment
- Manual file copying required
- Room for sync errors
3. **No Dockerfile Optimization**
- Dockerfile copies code but it's never used
- Volume mount replaces everything
- Wastes build time and image space
---
## Proposed Solution: Two Options
### **Option 1: Use Dockerfile Copy (Recommended)**
**Change Dockerfile:**
```dockerfile
# Copy everything to /app inside image
COPY . /app/
# No need for volume mount - image contains all code
```
**Change docker-compose.yml:**
```yaml
volumes:
# REMOVE the ./data:/app volume mount
# Keep only data-specific mounts:
- ./data/instance:/app/instance # Database
- ./data/uploads:/app/app/static/uploads # User uploads
```
**Benefits:**
- ✅ Single source of truth (Dockerfile)
- ✅ Code is immutable in image
- ✅ No init-data.sh needed
- ✅ Faster deployment (no file copying)
- ✅ Cleaner architecture
- ✅ Can upgrade code by rebuilding image
**Drawbacks:**
- Code changes require docker-compose rebuild
- Can't edit code in container (which is good for production)
---
### **Option 2: Keep Current (With Improvements)**
**Keep:**
- init-data.sh for copying code to data/
- Volume mount at ./data:/app
**Improve:**
- Add validation that init-data.sh ran successfully
- Check file sync status before starting app
- Add automated sync on container restart
**Benefits:**
- ✅ Dev-friendly (can edit code, restart container)
- ✅ Faster iteration during development
**Drawbacks:**
- ❌ Production anti-pattern (code changes without rebuild)
- ❌ Extra deployment complexity
- ❌ Manual init-data.sh step required
---
## Current Production Setup Evaluation
**Current System:** Option 2 (with volume mount override)
### Why This Setup Exists
The current architecture with `./data:/app` volume mount suggests:
1. **Development-focused** - Allows code editing and hot-reload
2. **Host-based persistence** - All data on host machine
3. **Easy backup** - Just backup the `./data/` folder
### Is This Actually Used?
- ✅ Code updates via `git pull` in `/app/` folder
- ✅ Then `cp -r app/* data/app/` copies to running container
- ✅ Allows live code updates without container rebuild
---
## Recommendation
### For Production
**Use Option 1 (Dockerfile-based):**
- Build immutable images
- No init-data.sh needed
- Cleaner deployment pipeline
- Better for CI/CD
### For Development
**Keep Option 2 (current approach):**
- Code editing and hot-reload
- Faster iteration
---
## Implementation Steps for Option 1
### 1. **Update Dockerfile**
```dockerfile
# Instead of: COPY . .
# Change docker-compose volume mount pattern
```
### 2. **Update docker-compose.yml**
```yaml
volumes:
# Remove: ./data:/app
# Keep only:
- ./data/instance:/app/instance
- ./data/uploads:/app/app/static/uploads
```
### 3. **Update deploy.sh**
```bash
# Remove: bash init-data.sh
# Just build and run:
docker-compose build
docker-compose up -d
```
### 4. **Add Migration Path**
```bash
# For existing deployments:
# Copy any instance/database data from data/instance to new location
```
---
## Data Persistence Strategy (Post-Migration)
```
Current: After Option 1:
./data/app/ (code) → /app/ (in image)
./data/instance/ (db) → ./data/instance/ (volume mount)
./data/uploads/ (files) → ./data/uploads/ (volume mount)
```
---
## Risk Assessment
### Option 1 (Dockerfile-only)
- **Risk Level:** LOW ✅
- **Data Loss Risk:** NONE (instance & uploads still mounted)
- **Rollback:** Can use old image tag
### Option 2 (Current)
- **Risk Level:** MEDIUM
- **Data Loss Risk:** Manual copying errors
- **Rollback:** Manual file restore
---
## Conclusion
**Recommendation: Option 1 (Dockerfile-based)** for production deployment
- Simpler architecture
- Better practices
- Faster deployment
- Cleaner code management
Would you like to implement this change?
@@ -0,0 +1,272 @@
# DigiServer Deployment Commands
This document contains all necessary `docker exec` commands to deploy and configure DigiServer on a new PC with the same settings as the production system.
## Prerequisites
```bash
# Ensure you're in the project directory
cd /path/to/digiserver-v2
# Start the containers
docker-compose up -d
```
## 1. Database Initialization and Migrations
### Run all database migrations in sequence:
```bash
# Create https_config table
docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
# Create player_user table
docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
# Add email to https_config table
docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
# Migrate player_user global settings
docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
```
**Note:** The `-T` flag prevents Docker from allocating a pseudo-terminal, which is useful for automated deployments.
## 2. HTTPS Configuration via CLI
### Check HTTPS Configuration Status:
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py status
```
### Enable HTTPS with Production Settings:
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py enable \
digiserver \
digiserver.sibiusb.harting.intra \
admin@example.com \
10.76.152.164 \
443
```
### Show Detailed Configuration:
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py show
```
## 3. Admin User Setup
### Create/Reset Admin User (if needed):
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from app.models.user import User
from app.extensions import db
app = create_app()
with app.app_context():
# Check if admin exists
admin = User.query.filter_by(username='admin').first()
if admin:
print('✅ Admin user already exists')
else:
# Create new admin user
admin = User(username='admin', email='admin@example.com')
admin.set_password('admin123') # Change this password!
admin.is_admin = True
db.session.add(admin)
db.session.commit()
print('✅ Admin user created with username: admin')
"
```
## 4. Database Verification
### Check Database Tables:
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from app.extensions import db
from sqlalchemy import inspect
app = create_app()
with app.app_context():
inspector = inspect(db.engine)
tables = inspector.get_table_names()
print('📊 Database Tables:')
for table in sorted(tables):
print(f' ✓ {table}')
print(f'\\n✅ Total tables: {len(tables)}')
"
```
### Check HTTPS Configuration in Database:
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from app.models.https_config import HTTPSConfig
app = create_app()
with app.app_context():
config = HTTPSConfig.get_config()
if config:
print('✅ HTTPS Configuration Found:')
print(f' Status: {\"ENABLED\" if config.https_enabled else \"DISABLED\"}')
print(f' Hostname: {config.hostname}')
print(f' Domain: {config.domain}')
print(f' IP Address: {config.ip_address}')
print(f' Port: {config.port}')
else:
print('⚠️ No HTTPS configuration found')
"
```
## 5. Health Checks
### Test Caddy Configuration:
```bash
docker-compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile
```
### Test Flask Application Health:
```bash
docker-compose exec -T digiserver-app python -c "
import urllib.request
try:
response = urllib.request.urlopen('http://localhost:5000/health', timeout=5)
print('✅ Application is responding')
print(f' Status: {response.status}')
except Exception as e:
print(f'❌ Application health check failed: {e}')
"
```
### Check Docker Container Logs:
```bash
# Flask app logs
docker-compose logs digiserver-app | tail -50
# Caddy logs
docker-compose logs caddy | tail -50
```
## 6. Complete Deployment Script
Create a file called `deploy.sh` to run all steps automatically:
```bash
#!/bin/bash
set -e
echo "🚀 DigiServer Deployment Script"
echo "=================================="
echo ""
# Change to project directory
cd /path/to/digiserver-v2
# Step 1: Start containers
echo "📦 Starting containers..."
docker-compose up -d
sleep 5
# Step 2: Run migrations
echo "📊 Running database migrations..."
docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
# Step 3: Configure HTTPS
echo "🔒 Configuring HTTPS..."
docker-compose exec -T digiserver-app python /app/https_manager.py enable \
digiserver \
digiserver.sibiusb.harting.intra \
admin@example.com \
10.76.152.164 \
443
# Step 4: Verify setup
echo "✅ Verifying setup..."
docker-compose exec -T digiserver-app python /app/https_manager.py status
echo ""
echo "🎉 Deployment Complete!"
echo "=================================="
echo "Access your application at:"
echo " - https://digiserver"
echo " - https://10.76.152.164"
echo " - https://digiserver.sibiusb.harting.intra"
echo ""
echo "Login with:"
echo " Username: admin"
echo " Password: (check your password settings)"
```
Make it executable:
```bash
chmod +x deploy.sh
```
Run it:
```bash
./deploy.sh
```
## 7. Troubleshooting
### Restart Services:
```bash
# Restart all containers
docker-compose restart
# Restart just the app
docker-compose restart digiserver-app
# Restart just Caddy
docker-compose restart caddy
```
### View Caddy Configuration:
```bash
docker-compose exec -T caddy cat /etc/caddy/Caddyfile
```
### Test HTTPS Endpoints:
```bash
# Test from host machine (if accessible)
curl -k https://digiserver.sibiusb.harting.intra
# Test from within containers
docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/ | head -20
```
### Clear Caddy Cache (if certificate issues occur):
```bash
docker volume rm digiserver-v2_caddy-data
docker volume rm digiserver-v2_caddy-config
docker-compose restart caddy
```
## Important Notes
- Always use `-T` flag with `docker-compose exec` in automated scripts to prevent TTY issues
- Change default passwords (`admin123`) in production environments
- Adjust email address in HTTPS configuration as needed
- For different network setups, modify the IP address and domain in the enable HTTPS command
- Keep database backups before running migrations
- Test all three access points after deployment
@@ -0,0 +1,278 @@
# 📚 DigiServer Deployment Documentation Index
Complete documentation for deploying and maintaining DigiServer. Choose your path below:
---
## 🚀 I Want to Deploy Now!
### Quick Start (2 minutes)
```bash
cd /path/to/digiserver-v2
./deploy.sh
```
→ See [DEPLOYMENT_README.md](DEPLOYMENT_README.md)
### Or Step-by-Step Setup
```bash
./setup_https.sh
```
---
## 📖 Documentation Files
### 1. **[DEPLOYMENT_README.md](DEPLOYMENT_README.md)** ⭐ START HERE
- **Size**: 9.4 KB
- **Purpose**: Complete deployment guide for beginners
- **Contains**:
- Quick start instructions
- Prerequisites checklist
- 3 deployment methods (auto, semi-auto, manual)
- Verification procedures
- First access setup
- Troubleshooting guide
- **Read time**: 15-20 minutes
### 2. **[DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md)** ⭐ REFERENCE GUIDE
- **Size**: 7.6 KB
- **Purpose**: Quick reference for all docker exec commands
- **Contains**:
- Database migrations
- HTTPS configuration
- User management
- Database inspection
- Health checks
- Maintenance commands
- Troubleshooting commands
- **Use when**: You need a specific command
- **Read time**: 5-10 minutes (or search for what you need)
### 3. **[DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md)**
- **Size**: 6.8 KB
- **Purpose**: Detailed deployment command explanations
- **Contains**:
- Individual command explanations
- Complete deployment script template
- Health check procedures
- Verification steps
- Advanced troubleshooting
- **Read time**: 20-30 minutes
---
## 🔧 Executable Scripts
### 1. **[deploy.sh](deploy.sh)** - Fully Automated
- **Size**: 6.7 KB
- **Purpose**: One-command deployment
- **Does**:
1. Starts Docker containers
2. Runs all migrations
3. Configures HTTPS
4. Verifies setup
5. Shows access URLs
- **Usage**:
```bash
./deploy.sh
```
- **With custom settings**:
```bash
HOSTNAME=server1 DOMAIN=server1.internal ./deploy.sh
```
### 2. **[setup_https.sh](setup_https.sh)** - Semi-Automated
- **Size**: 5.9 KB
- **Purpose**: Setup script that works in or outside Docker
- **Does**:
- Detects environment (Docker container or host)
- Runs migrations
- Configures HTTPS
- Shows status
- **Usage**:
```bash
./setup_https.sh
```
---
## 🎯 Quick Navigation by Task
### "I need to deploy on a new PC"
1. Read: [DEPLOYMENT_README.md](DEPLOYMENT_README.md#prerequisites)
2. Run: `./deploy.sh`
3. Access: https://digiserver.sibiusb.harting.intra
### "I need a specific docker exec command"
→ Search [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md)
### "I want to understand what's being deployed"
→ Read [DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md#prerequisites)
### "Something went wrong, help!"
→ See [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-troubleshooting) or [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#-troubleshooting)
### "I need to configure custom settings"
→ Read [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-environment-variables)
### "I want to manage HTTPS"
→ See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#-https-configuration-management)
---
## 📋 Deployment Checklist
- [ ] Docker and Docker Compose installed
- [ ] Project files copied to new PC
- [ ] Run `./deploy.sh` or `./setup_https.sh`
- [ ] Verify with `docker-compose ps`
- [ ] Access https://digiserver.sibiusb.harting.intra
- [ ] Log in with admin/admin123
- [ ] Change default password
- [ ] Configure players and content
---
## 🔑 Configuration Options
### Default Settings
```
Hostname: digiserver
Domain: digiserver.sibiusb.harting.intra
IP Address: 10.76.152.164
Port: 443
Email: admin@example.com
Username: admin
Password: admin123
```
### Customize During Deployment
```bash
HOSTNAME=myserver \
DOMAIN=myserver.internal \
IP_ADDRESS=192.168.1.100 \
EMAIL=admin@myserver.com \
./deploy.sh
```
---
## 🆘 Common Tasks
| Task | Command |
|------|---------|
| **Start containers** | `docker-compose up -d` |
| **Stop containers** | `docker-compose stop` |
| **View logs** | `docker-compose logs -f` |
| **Check HTTPS status** | `docker-compose exec -T digiserver-app python /app/https_manager.py status` |
| **Reset password** | See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#reset-admin-password) |
| **View all tables** | See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#list-all-tables) |
| **Create admin user** | See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#create-admin-user) |
---
## 📊 File Structure
```
digiserver-v2/
├── DEPLOYMENT_README.md ..................... Main deployment guide
├── DOCKER_EXEC_COMMANDS.md ................. Quick reference (BEST FOR COMMANDS)
├── DEPLOYMENT_COMMANDS.md .................. Detailed explanations
├── deploy.sh ............................. Fully automated deployment
├── setup_https.sh ......................... Semi-automated setup
├── docker-compose.yml ..................... Docker services config
├── Caddyfile .............................. Reverse proxy config
├── requirements.txt ....................... Python dependencies
├── app/
│ ├── app.py ............................ Flask application
│ ├── models/ ........................... Database models
│ │ ├── https_config.py
│ │ ├── user.py
│ │ └── ...
│ └── ...
├── migrations/
│ ├── add_https_config_table.py
│ ├── add_player_user_table.py
│ ├── add_email_to_https_config.py
│ └── migrate_player_user_global.py
└── old_code_documentation/
├── HTTPS_CONFIGURATION.md
└── ...
```
---
## 🚀 Deployment Methods Comparison
| Method | Time | Effort | Best For |
|--------|------|--------|----------|
| `./deploy.sh` | 2-3 min | Click & wait | First-time setup, automation |
| `./setup_https.sh` | 3-5 min | Manual review | Learning, step debugging |
| Manual commands | 10-15 min | Full control | Advanced users, scripting |
---
## ✨ What Gets Deployed
✅ Flask web application with admin dashboard
✅ HTTPS with self-signed certificates
✅ Caddy reverse proxy for routing
✅ SQLite database with all tables
✅ User management system
✅ HTTPS configuration management
✅ Player and content management
✅ Group and playlist management
✅ Admin audit trail
---
## 🎓 Learning Path
1. **Total Beginner?**
- Start: [DEPLOYMENT_README.md](DEPLOYMENT_README.md)
- Run: `./deploy.sh`
- Learn: Browse [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md) for available commands
2. **Want to Understand Everything?**
- Read: [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-deployment-methods) (all 3 methods)
- Study: [DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md)
- Reference: [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md)
3. **Need to Troubleshoot?**
- Check: [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-troubleshooting)
- Or: [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#-troubleshooting)
---
## 💡 Pro Tips
1. **Use `-T` flag** in docker-compose exec for scripts (prevents TTY issues)
2. **Keep backups** before major changes
3. **Check logs often**: `docker-compose logs -f`
4. **Use environment variables** for custom deployments
5. **Verify after deployment** using health check commands
---
## 🔗 Related Documentation
- **HTTPS Setup**: `old_code_documentation/HTTPS_CONFIGURATION.md`
- **Admin Features**: Check admin panel after login
- **API Documentation**: See `old_code_documentation/PLAYER_EDIT_MEDIA_API.md`
---
## 📞 Support
- Logs: `docker-compose logs digiserver-app`
- Health Check: See [DOCKER_EXEC_COMMANDS.md#-health-checks](DOCKER_EXEC_COMMANDS.md#-health-checks)
- Troubleshooting: See [DEPLOYMENT_README.md#-troubleshooting](DEPLOYMENT_README.md#-troubleshooting)
---
**Ready? Start with:** `./deploy.sh` 🚀
Or read [DEPLOYMENT_README.md](DEPLOYMENT_README.md) for the full guide.
@@ -0,0 +1,433 @@
# DigiServer Deployment Guide
Complete guide for deploying DigiServer on a new PC with automatic or manual configuration.
## 📋 Table of Contents
1. [Quick Start](#quick-start)
2. [Prerequisites](#prerequisites)
3. [Deployment Methods](#deployment-methods)
4. [Verification](#verification)
5. [Documentation Files](#documentation-files)
6. [Troubleshooting](#troubleshooting)
---
## 🚀 Quick Start
The fastest way to deploy DigiServer on a new PC:
```bash
# 1. Clone or copy the project to your new PC
cd /path/to/digiserver-v2
# 2. Run the automated deployment script
./deploy.sh
```
That's it! The script will:
- ✅ Start all Docker containers
- ✅ Run all database migrations
- ✅ Configure HTTPS with self-signed certificates
- ✅ Verify the setup
- ✅ Display access URLs
---
## 📋 Prerequisites
Before deploying, ensure you have:
### 1. Docker & Docker Compose
```bash
# Check Docker installation
docker --version
# Check Docker Compose installation
docker-compose --version
```
If not installed, follow the official guides:
- [Docker Installation](https://docs.docker.com/install/)
- [Docker Compose Installation](https://docs.docker.com/compose/install/)
### 2. Project Files
```bash
# You should have these files in the project directory:
ls -la
# Caddyfile - Reverse proxy configuration
# docker-compose.yml - Docker services definition
# setup_https.sh - Manual setup script
# deploy.sh - Automated deployment script
# requirements.txt - Python dependencies
```
### 3. Sufficient Disk Space
- ~2GB for Docker images and volumes
- Additional space for your content/uploads
### 4. Network Access
- Ports 80, 443 available (or configure in docker-compose.yml)
- Port 2019 for Caddy admin API (internal only)
---
## 🎯 Deployment Methods
### Method 1: Fully Automated (Recommended)
```bash
cd /path/to/digiserver-v2
./deploy.sh
```
**What it does:**
1. Starts Docker containers
2. Runs all migrations
3. Configures HTTPS
4. Verifies setup
5. Shows access URLs
**Configuration variables** (can be customized):
```bash
# Use environment variables to customize
HOSTNAME=digiserver \
DOMAIN=digiserver.sibiusb.harting.intra \
IP_ADDRESS=10.76.152.164 \
EMAIL=admin@example.com \
PORT=443 \
./deploy.sh
```
---
### Method 2: Semi-Automated Setup
```bash
cd /path/to/digiserver-v2
./setup_https.sh
```
**What it does:**
1. Starts containers (if needed)
2. Runs all migrations
3. Configures HTTPS with production settings
4. Shows status
---
### Method 3: Manual Step-by-Step
#### Step 1: Start Containers
```bash
cd /path/to/digiserver-v2
docker-compose up -d
```
Wait for containers to be ready (check with `docker-compose ps`).
#### Step 2: Run Migrations
```bash
# Migration 1: HTTPS Config
docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
# Migration 2: Player User
docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
# Migration 3: Email
docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
# Migration 4: Player User Global
docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
```
#### Step 3: Configure HTTPS
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py enable \
digiserver \
digiserver.sibiusb.harting.intra \
admin@example.com \
10.76.152.164 \
443
```
#### Step 4: Verify Status
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py status
```
---
## ✅ Verification
### Check Container Status
```bash
docker-compose ps
```
Expected output:
```
NAME SERVICE STATUS PORTS
digiserver-v2 digiserver-app Up (healthy) 5000/tcp
digiserver-caddy caddy Up 80, 443, 2019/tcp
```
### Test HTTPS Access
```bash
# From the same network (if DNS configured)
curl -k https://digiserver.sibiusb.harting.intra
# Or from container
docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/ | head -10
```
### Expected Response
Should show HTML login page with "DigiServer" in the title.
### Check Database
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from sqlalchemy import inspect
app = create_app()
with app.app_context():
inspector = inspect(app.extensions.db.engine)
tables = inspector.get_table_names()
print('Database tables:', len(tables))
for t in sorted(tables):
print(f' ✓ {t}')
"
```
---
## 📚 Documentation Files
### 1. `DOCKER_EXEC_COMMANDS.md` ⭐ **START HERE**
Quick reference for all docker exec commands
- Database operations
- User management
- HTTPS configuration
- Health checks
- Maintenance tasks
### 2. `DEPLOYMENT_COMMANDS.md`
Comprehensive deployment guide
- Prerequisites
- Each deployment step explained
- Complete deployment script template
- Troubleshooting section
### 3. `deploy.sh`
Automated deployment script (executable)
- Runs all steps automatically
- Shows progress with colors
- Configurable via environment variables
### 4. `setup_https.sh`
Semi-automated setup script (executable)
- Detects if running in Docker or on host
- Manual configuration option
- Detailed output
### 5. `Caddyfile`
Reverse proxy configuration
- HTTPS certificate management
- Domain routing
- Security headers
### 6. `docker-compose.yml`
Docker services definition
- Flask application
- Caddy reverse proxy
- Volumes and networks
---
## 🔐 First Access
After deployment:
1. **Access the application**
- https://digiserver.sibiusb.harting.intra
- https://10.76.152.164
- https://digiserver
2. **Log in with default credentials**
```
Username: admin
Password: admin123
```
3. **⚠️ IMPORTANT: Change the password immediately**
- Click on admin user settings
- Change default password to a strong password
4. **Configure your system**
- Set up players
- Upload content
- Create groups
- Configure playlists
---
## 🆘 Troubleshooting
### Containers Won't Start
```bash
# Check logs
docker-compose logs
# Try rebuilding
docker-compose down
docker-compose up -d --build
```
### Migration Fails
```bash
# Check database connection
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
app = create_app()
print('Database OK')
"
# Check if tables already exist
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from sqlalchemy import inspect
app = create_app()
with app.app_context():
inspector = inspect(app.extensions.db.engine)
print('Existing tables:', inspector.get_table_names())
"
```
### HTTPS Certificate Issues
```bash
# Clear Caddy certificate cache
docker volume rm digiserver-v2_caddy-data
docker volume rm digiserver-v2_caddy-config
# Restart Caddy
docker-compose restart caddy
```
### Port 80/443 Already in Use
```bash
# Find what's using the port
lsof -i :80 # For port 80
lsof -i :443 # For port 443
# Stop the conflicting service or change ports in docker-compose.yml
```
### Can't Access via IP Address
```bash
# Verify Caddy is listening
docker-compose exec -T caddy netstat -tlnp 2>/dev/null | grep -E ':(80|443)'
# Test from container
docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/
```
### Database Corruption
```bash
# Backup current database
docker-compose exec -T digiserver-app cp /app/instance/digiserver.db /app/instance/digiserver.db.backup
# Reset database (CAUTION: This deletes all data)
docker-compose exec -T digiserver-app rm /app/instance/digiserver.db
# Restart and re-run migrations
docker-compose restart digiserver-app
./setup_https.sh
```
---
## 📞 More Help
See the detailed documentation files:
- **Quick Commands**: `DOCKER_EXEC_COMMANDS.md`
- **Full Guide**: `DEPLOYMENT_COMMANDS.md`
- **HTTPS Details**: `old_code_documentation/HTTPS_CONFIGURATION.md`
---
## 🔄 Deployment on Different PC
To deploy on a different PC:
1. **Copy project files** to the new PC (or clone from git)
2. **Ensure Docker and Docker Compose are installed**
3. **Run deployment script**:
```bash
cd /path/to/digiserver-v2
./deploy.sh
```
4. **Access the application** on the new PC at the configured URLs
All settings will be automatically configured! 🎉
---
## 📋 Environment Variables
You can customize deployment using environment variables:
```bash
# Customize hostname
HOSTNAME=myserver ./deploy.sh
# Customize domain
DOMAIN=myserver.example.com ./deploy.sh
# Customize IP address
IP_ADDRESS=192.168.1.100 ./deploy.sh
# Customize email
EMAIL=admin@myserver.com ./deploy.sh
# Customize port
PORT=8443 ./deploy.sh
# All together
HOSTNAME=server1 \
DOMAIN=server1.internal \
IP_ADDRESS=192.168.1.100 \
EMAIL=admin@server1.com \
PORT=443 \
./deploy.sh
```
---
## ✨ Features
✅ Automated HTTPS with self-signed certificates
✅ Multi-access (hostname, domain, IP address)
✅ Automatic reverse proxy with Caddy
✅ Docker containerized (easy deployment)
✅ Complete database schema with migrations
✅ Admin dashboard for configuration
✅ User management
✅ Player management
✅ Content/Playlist management
✅ Group management
---
## 📝 Notes
- Default SSL certificates are **self-signed** (internal use)
- For production with Let's Encrypt, edit the Caddyfile
- Keep database backups before major changes
- Default credentials are in the code; change them in production
- All logs available via `docker-compose logs`
---
**Ready to deploy? Run:** `./deploy.sh` 🚀

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