feat: v1.1.0 - Production-Ready Docker Deployment

🚀 Major Release: DigiServer v1.1.0 Production Deployment

## 📁 Project Restructure
- Moved all application code to app/ directory for Docker containerization
- Centralized persistent data in data/ directory with volume mounting
- Removed development artifacts and cleaned up project structure

## 🐳 Docker Integration
- Added production-ready Dockerfile with LibreOffice and poppler-utils
- Updated docker-compose.yml for production deployment
- Added .dockerignore for optimized build context
- Created automated deployment script (deploy-docker.sh)
- Added cleanup script (cleanup-docker.sh)

## 📄 Document Processing Enhancements
- Integrated LibreOffice for professional PPTX to PDF conversion
- Implemented PPTX → PDF → 4K JPG workflow for optimal quality
- Added poppler-utils for enhanced PDF processing
- Simplified PDF conversion to 300 DPI for reliability

## 🔧 File Management Improvements
- Fixed absolute path resolution for containerized deployment
- Updated all file deletion functions with proper path handling
- Enhanced bulk delete functions for players and groups
- Improved file upload workflow with consistent path management

## 🛠️ Code Quality & Stability
- Cleaned up pptx_converter.py from 442 to 86 lines
- Removed all Python cache files (__pycache__/, *.pyc)
- Updated file operations for production reliability
- Enhanced error handling and logging

## 📚 Documentation Updates
- Updated README.md with Docker deployment instructions
- Added comprehensive DEPLOYMENT.md guide
- Included production deployment best practices
- Added automated deployment workflow documentation

## 🔐 Security & Production Features
- Environment-based configuration
- Health checks and container monitoring
- Automated admin user creation
- Volume-mounted persistent data
- Production logging and error handling

##  Ready for Production
- Clean project structure optimized for Docker
- Automated deployment with ./deploy-docker.sh
- Professional document processing pipeline
- Reliable file management system
- Complete documentation and deployment guides

Access: http://localhost:8880 | Admin: admin/Initial01!
This commit is contained in:
2025-08-05 18:04:02 -04:00
parent 4e5aff1c02
commit 1eb0aa3658
71 changed files with 2017 additions and 379 deletions

45
.dockerignore Normal file
View File

@@ -0,0 +1,45 @@
# Git
.git
.gitignore
# Documentation
README.md
*.md
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
env.bak/
venv.bak/
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Data folders (these will be mounted as volumes)
data/
# Logs
*.log
# Environment files
.env
.env.local
.env.example
# Temporary files
tmp/
temp/

View File

@@ -1,21 +1,41 @@
# .env - Flask environment variables
# DigiServer Environment Configuration
# Copy this file to .env and modify the values as needed
# Flask secret key (change this to something secure in production)
SECRET_KEY=Ana_Are_Multe_Mere-Si_Nu_Are_Pere
# Flask Configuration
FLASK_APP=app.py
FLASK_RUN_HOST=0.0.0.0
FLASK_ENV=production
# Flask environment: development or production
FLASK_ENV=development
# Security
SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
# Change this to a secure random string in production!
# Database location (optional, defaults to instance/dashboard.db)
# Default Admin User
ADMIN_USER=admin
ADMIN_PASSWORD=Initial01!
# Change the default password after first login!
# Database Configuration
# SQLite database file will be created in data/instance/dashboard.db
# SQLALCHEMY_DATABASE_URI=sqlite:///instance/dashboard.db
# Default admin user credentials (used for auto-creation)
DEFAULT_USER=admin
DEFAULT_PASSWORD=1234
# Application Settings
MAX_CONTENT_LENGTH=2147483648 # 2GB in bytes
UPLOAD_FOLDER=static/uploads
UPLOAD_FOLDERLOGO=static/resurse
# Flask server settings
# Server Information
SERVER_VERSION=1.1.0
BUILD_DATE=2025-06-29
# Docker Configuration (for docker-compose.yml)
DIGISERVER_PORT=8880
CONTAINER_NAME=digiserver
# Flask server settings (for development)
HOST=0.0.0.0
PORT=5000
# Maximum upload size (in bytes, 2GB)
MAX_CONTENT_LENGTH=2147483648
# Optional: External Database (for advanced users)
# DATABASE_URL=postgresql://user:password@localhost/digiserver
# DATABASE_URL=mysql://user:password@localhost/digiserver

41
.gitignore vendored
View File

@@ -1,4 +1,41 @@
digiscreen/
.env
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
env.bak/
venv.bak/
# Environment files
.env
.env.local
venv/
# Data directories (persistent storage)
data/
instance/
instance.bak/
# Legacy directories (can be removed after migration)
digiscreen/
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Backups
backups/
# Temporary files
tmp/
temp/

92
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,92 @@
# DigiServer v1.1.0 - Production Deployment Guide
## 🎯 Ready for Deployment
Your DigiServer application has been cleaned and prepared for Docker deployment.
### ✅ What's Been Prepared
1. **Application Cleaned**
- Python cache files removed (`__pycache__/`, `*.pyc`)
- Development artifacts cleaned
- Production-ready structure
2. **Docker Configuration**
- Dockerfile optimized with LibreOffice and poppler-utils
- docker-compose.yml configured for production
- .dockerignore updated to exclude development files
- Data persistence configured via volumes
3. **Deployment Scripts**
- `deploy-docker.sh` - Automated deployment script
- `cleanup-docker.sh` - Complete cleanup script
- Both scripts use modern `docker compose` syntax
4. **Data Structure**
- `./data/instance/` - Database files
- `./data/uploads/` - Media uploads
- `./data/resurse/` - System resources
- All directories auto-created and volume-mounted
### 🚀 Quick Deployment
```bash
# Deploy DigiServer
./deploy-docker.sh
# Access at: http://localhost:8880
# Username: admin
# Password: Initial01!
```
### 📋 Features Ready
-**Document Processing**: LibreOffice + poppler-utils integrated
-**File Uploads**: PPTX → PDF → 4K JPG workflow
-**Path Resolution**: Absolute path handling for containerized deployment
-**File Management**: Bulk delete functions with physical file cleanup
-**User Management**: Admin user auto-creation
-**Data Persistence**: Volume-mounted data directories
-**Health Checks**: Container health monitoring
-**Production Logging**: Structured output and error handling
### 🔧 System Requirements
- Docker Engine 20.10+
- Docker Compose v2 (plugin)
- 2GB RAM minimum
- 10GB disk space
### 📁 Deployment Structure
```
digiserver/
├── 📁 app/ # Application code
├── 📁 data/ # Persistent data (auto-created)
│ ├── 📁 instance/ # Database
│ ├── 📁 uploads/ # Media files
│ └── 📁 resurse/ # Resources
├── 🐳 Dockerfile # Production image
├── 🔧 docker-compose.yml # Container orchestration
├── 🚀 deploy-docker.sh # Deployment script
├── 🧹 cleanup-docker.sh # Cleanup script
└── 📖 README.md # Documentation
```
### 🔐 Security Notes
- Change default password after first login
- SECRET_KEY configured for session security
- File upload restrictions in place
- Container runs with proper permissions
### 📊 Monitoring
- Health checks configured (30s intervals)
- Container auto-restart on failure
- Logs available via `docker compose logs -f`
- Status monitoring with `docker compose ps`
---
**Next Step**: Run `./deploy-docker.sh` to deploy your DigiServer! 🚀

View File

@@ -1,31 +1,59 @@
# Use Python 3.11 slim image
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies, including Rust and build tools
# Install system dependencies including LibreOffice and poppler-utils
RUN apt-get update && apt-get install -y \
libreoffice poppler-utils ffmpeg \
libpoppler-cpp-dev libmagic1 \
libffi-dev libssl-dev g++ curl libjpeg-dev zlib1g-dev \
libxml2-dev libxslt-dev build-essential cargo \
&& rm -rf /var/lib/apt/lists/*
poppler-utils \
libreoffice \
ffmpeg \
libpoppler-cpp-dev \
libmagic1 \
libffi-dev \
libssl-dev \
g++ \
curl \
libjpeg-dev \
zlib1g-dev \
libxml2-dev \
libxslt-dev \
build-essential \
cargo \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Debug: Verify Rust installation
RUN rustc --version && cargo --version
# Copy application files
COPY . /app
# Verify LibreOffice and poppler-utils installation
RUN libreoffice --version && pdftoppm -v
# Copy entrypoint script and make it executable
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Copy requirements first for better layer caching
COPY app/requirements.txt .
# Upgrade pip and install Python dependencies (using piwheels for ARM)
# Upgrade pip and install Python dependencies
RUN python -m pip install --upgrade pip && \
pip install --no-cache-dir --extra-index-url https://www.piwheels.org/simple -r requirements.txt
pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ .
# Make entrypoint script executable
RUN chmod +x entrypoint.sh
# Create necessary directories for volumes
RUN mkdir -p /app/static/uploads /app/static/resurse /app/instance
# Expose the application port
EXPOSE 5000
# Set environment variables
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
ENV PYTHONPATH=/app
# Use entrypoint script
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["./entrypoint.sh"]

258
README.md Normal file
View File

@@ -0,0 +1,258 @@
# DigiServer - Digital Signage Management Platform
![Version](https://img.shields.io/badge/version-1.1.0-blue.svg)
![Python](https://img.shields.io/badge/python-3.11-green.svg)
![Flask](https://img.shields.io/badge/flask-3.0-red.svg)
![Docker](https://img.shields.io/badge/docker-supported-blue.svg)
DigiServer is a comprehensive digital signage management platform built with Flask. It allows you to manage digital displays, create playlists, organize content into groups, and control multiple players from a centralized dashboard.
## 🚀 Features
- **Multi-Player Management**: Control multiple digital signage players from a single dashboard
- **Group Management**: Organize players into groups for synchronized content
- **Content Management**: Upload and manage various media types (images, videos, PDFs, PowerPoint presentations)
- **Real-time Updates**: Players automatically sync with the latest content
- **User Management**: Admin and user role-based access control
- **Orientation Support**: Configure display orientation (Landscape/Portrait) per player and group
- **API Integration**: RESTful API for player authentication and playlist retrieval
- **Docker Support**: Easy deployment with Docker containers
## 📋 Requirements
- Docker and Docker Compose
- Python 3.11+ (if running without Docker)
- FFmpeg (for video processing)
- LibreOffice (for document conversion)
## 📁 Project Structure
```
digiserver/
├── app/ # Application code
│ ├── models/ # Database models
│ ├── templates/ # HTML templates
│ ├── utils/ # Utility functions
│ ├── app.py # Main Flask application
│ ├── extensions.py # Flask extensions
│ ├── requirements.txt # Python dependencies
│ └── entrypoint.sh # Container entry point
├── data/ # Persistent data (created on first run)
│ ├── instance/ # Database files
│ ├── uploads/ # Media uploads
│ └── resurse/ # System resources (logos, etc.)
├── docker-compose.yml # Docker Compose configuration
├── Dockerfile # Docker image definition
└── README.md # This file
```
## 🐳 Quick Start with Docker
### Automated Deployment (Recommended)
1. **Clone the repository**
```bash
git clone <repository-url>
cd digiserver
```
2. **Deploy with automated script**
```bash
./deploy-docker.sh
```
This script will:
- Check Docker requirements
- Build the DigiServer image
- Create necessary data directories
- Start the containers
- Display access information
3. **Access the application**
- Open your browser and navigate to `http://localhost:8880`
- Default admin credentials:
- Username: `admin`
- Password: `Initial01!`
### Manual Docker Commands
Alternatively, you can use Docker commands directly:
```bash
# Build and start
docker compose up -d
# Stop
docker compose down
# View logs
docker compose logs -f
# Check status
docker compose ps
```
### Clean Up
To completely remove DigiServer containers and images:
```bash
./cleanup-docker.sh
```
## 🔧 Configuration
### Environment Variables
You can customize the application by modifying the environment variables in `docker-compose.yml`:
- `ADMIN_USER`: Default admin username (default: admin)
- `ADMIN_PASSWORD`: Default admin password (default: Initial01!)
- `SECRET_KEY`: Flask secret key for session security
- `FLASK_APP`: Flask application entry point
- `FLASK_RUN_HOST`: Host to bind the Flask application
### Data Persistence
All persistent data is stored in the `data/` folder:
- `data/instance/`: SQLite database files
- `data/uploads/`: Uploaded media files
- `data/resurse/`: System resources (logo, login images)
This folder will be created automatically on first run and persists between container restarts.
## 💻 Manual Installation (Development)
If you prefer to run without Docker:
1. **Install system dependencies**
```bash
# Ubuntu/Debian
sudo apt-get update
sudo apt-get install python3.11 python3-pip libreoffice ffmpeg
# CentOS/RHEL
sudo yum install python3.11 python3-pip libreoffice ffmpeg
```
2. **Install Python dependencies**
```bash
cd app/
pip install -r requirements.txt
```
3. **Run the application**
```bash
python app.py
```
## 🎮 Usage
### Managing Players
1. **Add a Player**: Navigate to the dashboard and click "Add Player"
2. **Configure Player**: Set username, hostname, passwords, and orientation
3. **Upload Content**: Upload media files to the player's playlist
4. **Player Authentication**: Players can authenticate using hostname and password/quickconnect code
### Managing Groups
1. **Create Group**: Group multiple players for synchronized content
2. **Assign Players**: Add/remove players from groups
3. **Upload Group Content**: Upload content that will be shared across all players in the group
4. **Group Display**: View group content in fullscreen mode
### Content Types Supported
- **Images**: JPG, PNG, GIF
- **Videos**: MP4, AVI, MOV (automatically converted to MP4)
- **Documents**: PDF (converted to images)
- **Presentations**: PPTX (converted to images)
## 🔌 API Endpoints
### Player API
- `GET /api/playlists?hostname={hostname}&quickconnect_code={code}`: Get player playlist
- `GET /api/playlist_version?hostname={hostname}&quickconnect_code={code}`: Get playlist version
- `GET /media/{filename}`: Serve media files
### Authentication
Players authenticate using:
- **Hostname**: Unique identifier for the player
- **Password**: Primary authentication method
- **Quickconnect Code**: Alternative authentication method
## 🛠️ Development
### Building the Docker Image
```bash
docker build -t digiserver:latest .
```
### Running Tests
```bash
# Install test dependencies
pip install pytest pytest-flask
# Run tests
pytest
```
### Database Management
The application uses SQLite with Flask-Migrate for database management:
```bash
# Initialize database
flask db init
# Create migration
flask db migrate -m "Description of changes"
# Apply migration
flask db upgrade
```
## 🔒 Security
- **User Authentication**: Role-based access control (admin/user)
- **Player Authentication**: Secure hostname and password-based authentication
- **File Upload Security**: Secure filename handling and file type validation
- **Session Management**: Secure session handling with configurable secret key
## 📊 Monitoring
- **Server Logs**: View recent server activities from the dashboard
- **Health Check**: Docker health check endpoint for monitoring
- **Content Management**: Track content usage and cleanup unused files
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## 📝 License
This project is licensed under the MIT License - see the LICENSE file for details.
## 🆘 Support
For support and questions:
- Create an issue in the repository
- Check the documentation in the `docs/` folder
- Review the application logs for troubleshooting
## 🔄 Version History
- **1.1.0** (2025-06-29): Added orientation support, improved group management
- **1.0.0**: Initial release with basic digital signage functionality
---
**Note**: Make sure to change the default admin password after first login for security purposes.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,10 @@
import os
import click
from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, send_from_directory
import psutil
import shutil
import zipfile
import tempfile
from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, send_from_directory, send_file
from flask_migrate import Migrate
import subprocess
from werkzeug.utils import secure_filename
@@ -46,7 +50,15 @@ from utils.uploads import (
SERVER_VERSION = "1.1.0"
BUILD_DATE = "2025-06-29"
app = Flask(__name__, instance_relative_config=True)
# Get the absolute path of the app directory
app_dir = os.path.dirname(os.path.abspath(__file__))
template_dir = os.path.join(app_dir, 'templates')
static_dir = os.path.join(app_dir, 'static')
app = Flask(__name__,
instance_relative_config=True,
template_folder=template_dir,
static_folder=static_dir)
# Set the secret key from environment variable or use a default value
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'Ana_Are_Multe_Mere-Si_Nu_Are_Pere')
@@ -95,6 +107,52 @@ def admin_required(f):
return f(*args, **kwargs)
return decorated_function
def get_system_info():
"""Get system monitoring information"""
try:
# CPU information
cpu_percent = psutil.cpu_percent(interval=1)
cpu_count = psutil.cpu_count()
# Memory information
memory = psutil.virtual_memory()
memory_percent = memory.percent
memory_used = round(memory.used / (1024**3), 2) # GB
memory_total = round(memory.total / (1024**3), 2) # GB
# Disk information
disk = psutil.disk_usage('/')
disk_percent = round((disk.used / disk.total) * 100, 1)
disk_used = round(disk.used / (1024**3), 2) # GB
disk_total = round(disk.total / (1024**3), 2) # GB
disk_free = round(disk.free / (1024**3), 2) # GB
# Upload folder size
upload_folder_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):
upload_folder_size += os.path.getsize(filepath)
upload_folder_size_gb = round(upload_folder_size / (1024**3), 2)
return {
'cpu_percent': cpu_percent,
'cpu_count': cpu_count,
'memory_percent': memory_percent,
'memory_used': memory_used,
'memory_total': memory_total,
'disk_percent': disk_percent,
'disk_used': disk_used,
'disk_total': disk_total,
'disk_free': disk_free,
'upload_folder_size': upload_folder_size_gb
}
except Exception as e:
print(f"Error getting system info: {e}")
return None
@app.route('/')
@login_required
def dashboard():
@@ -167,9 +225,12 @@ def upload_content():
players = [{'id': player.id, 'username': player.username} for player in Player.query.all()]
groups = [{'id': group.id, 'name': group.name} for group in Group.query.all()]
# Get system information for monitoring
system_info = get_system_info()
return render_template('upload_content.html', target_type=target_type, target_id=target_id,
players=players, groups=groups, return_url=return_url)
players=players, groups=groups, return_url=return_url, system_info=system_info)
@app.route('/admin')
@login_required
@@ -178,13 +239,18 @@ def admin():
logo_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'logo.png'))
login_picture_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'login_picture.png'))
users = User.query.all()
# Get system information for monitoring
system_info = get_system_info()
return render_template(
'admin.html',
users=users,
logo_exists=logo_exists,
login_picture_exists=login_picture_exists,
server_version=SERVER_VERSION,
build_date=BUILD_DATE
build_date=BUILD_DATE,
system_info=system_info
)
@app.route('/admin/change_role/<int:user_id>', methods=['POST'])
@@ -266,6 +332,61 @@ def delete_content(content_id):
db.session.commit()
return redirect(url_for('player_page', player_id=player_id))
@app.route('/player/<int:player_id>/bulk_delete', methods=['POST'])
@login_required
def bulk_delete_player_content(player_id):
"""Bulk delete selected media files from player"""
player = Player.query.get_or_404(player_id)
# Check if player is in a group (should be managed at group level)
if player.groups:
flash('Cannot delete media from players that are in groups. Manage media at the group level.', 'warning')
return redirect(url_for('player_page', player_id=player_id))
selected_content_ids = request.form.getlist('selected_content')
if not selected_content_ids:
flash('No media files selected for deletion.', 'warning')
return redirect(url_for('player_page', player_id=player_id))
try:
deleted_files = []
deleted_count = 0
for content_id in selected_content_ids:
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if content:
# Delete file from filesystem using absolute path
upload_folder = app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
file_path = os.path.join(upload_folder, content.file_name)
if os.path.exists(file_path):
try:
os.remove(file_path)
deleted_files.append(content.file_name)
print(f"Deleted file: {file_path}")
except OSError as e:
print(f"Error deleting file {file_path}: {e}")
# Delete from database
db.session.delete(content)
deleted_count += 1
# Update playlist version for the player
player.playlist_version += 1
db.session.commit()
flash(f'Successfully deleted {deleted_count} media file(s). Playlist updated to version {player.playlist_version}.', 'success')
except Exception as e:
db.session.rollback()
print(f"Error in bulk delete: {e}")
flash('An error occurred while deleting media files.', 'danger')
return redirect(url_for('player_page', player_id=player_id))
@app.route('/player/<int:player_id>/fullscreen', methods=['GET', 'POST'])
def player_fullscreen(player_id):
player = Player.query.get_or_404(player_id)
@@ -404,11 +525,15 @@ def clean_unused_files():
print("Used files:", used_files)
print("Unused files:", unused_files)
# Delete unused files
# Delete unused files using absolute path
upload_folder = app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
for file_name in unused_files:
file_path = os.path.join(app.config['UPLOAD_FOLDER'], file_name)
file_path = os.path.join(upload_folder, file_name)
if os.path.isfile(file_path):
print(f"Deleting file: {file_path}") # Debugging: Print the file being deleted
print(f"Deleting unused file: {file_path}")
os.remove(file_path)
flash('Unused files have been cleaned.', 'success')
@@ -566,6 +691,63 @@ def delete_group_media_route(group_id, content_id):
return redirect(url_for('manage_group', group_id=group_id))
@app.route('/group/<int:group_id>/bulk_delete', methods=['POST'])
@login_required
@admin_required
def bulk_delete_group_content(group_id):
"""Bulk delete selected media files from group"""
group = Group.query.get_or_404(group_id)
selected_content_ids = request.form.getlist('selected_content')
if not selected_content_ids:
flash('No media files selected for deletion.', 'warning')
return redirect(url_for('manage_group', group_id=group_id))
try:
deleted_files = []
deleted_count = 0
player_ids = [player.id for player in group.players]
for content_id in selected_content_ids:
content = Content.query.filter(
Content.id == content_id,
Content.player_id.in_(player_ids)
).first()
if content:
# Delete file from filesystem using absolute path
upload_folder = app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
file_path = os.path.join(upload_folder, content.file_name)
if os.path.exists(file_path):
try:
os.remove(file_path)
deleted_files.append(content.file_name)
print(f"Deleted file: {file_path}")
except OSError as e:
print(f"Error deleting file {file_path}: {e}")
# Delete from database
db.session.delete(content)
deleted_count += 1
# Update playlist version for all players in the group
for player in group.players:
player.playlist_version += 1
db.session.commit()
flash(f'Successfully deleted {deleted_count} media file(s) from group. All player playlists updated.', 'success')
except Exception as e:
db.session.rollback()
print(f"Error in group bulk delete: {e}")
flash('An error occurred while deleting media files.', 'danger')
return redirect(url_for('manage_group', group_id=group_id))
@app.route('/api/playlist_version', methods=['GET'])
def get_playlist_version():
hostname = request.args.get('hostname')
@@ -586,6 +768,17 @@ def get_playlist_version():
'hashed_quickconnect': player.quickconnect_password
})
@app.route('/api/system_info', methods=['GET'])
@login_required
@admin_required
def api_system_info():
"""API endpoint to get real-time system information"""
system_info = get_system_info()
if system_info:
return jsonify(system_info)
else:
return jsonify({'error': 'Could not retrieve system information'}), 500
@app.route('/player/<int:player_id>/update_order', methods=['POST'])
@login_required
def update_content_order(player_id):

View File

@@ -21,7 +21,6 @@ greenlet==3.1.1
# File Processing
pdf2image==1.17.0
PyPDF2==3.0.1
python-pptx==0.6.21
Pillow==10.0.1
cairosvg==2.7.0
ffmpeg-python==0.2.0
@@ -39,6 +38,7 @@ gevent==23.9.1
# Monitoring & Performance
prometheus-flask-exporter==0.22.4
sentry-sdk[flask]==1.40.0
psutil==6.1.0
# Utilities
typing_extensions==4.12.2

View File

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 153 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -207,6 +207,94 @@
<p><strong>Date of Build:</strong> {{ build_date }}</p>
</div>
</div>
<!-- System Monitoring Card -->
{% if system_info %}
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header">
<h2>📊 System Monitoring</h2>
</div>
<div class="card-body">
<div class="row">
<!-- CPU Information -->
<div class="col-md-3 col-6 text-center mb-3">
<div class="h6">CPU Usage</div>
<div class="progress mb-2" style="height: 25px;">
<div class="progress-bar
{% if system_info.cpu_percent < 50 %}bg-success
{% elif system_info.cpu_percent < 80 %}bg-warning
{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {{ system_info.cpu_percent }}%;">
{{ system_info.cpu_percent }}%
</div>
</div>
<small class="text-muted">{{ system_info.cpu_count }} cores available</small>
</div>
<!-- Memory Information -->
<div class="col-md-3 col-6 text-center mb-3">
<div class="h6">Memory Usage</div>
<div class="progress mb-2" style="height: 25px;">
<div class="progress-bar
{% if system_info.memory_percent < 60 %}bg-success
{% elif system_info.memory_percent < 85 %}bg-warning
{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {{ system_info.memory_percent }}%;">
{{ system_info.memory_percent }}%
</div>
</div>
<small class="text-muted">{{ system_info.memory_used }}GB / {{ system_info.memory_total }}GB</small>
</div>
<!-- Disk Information -->
<div class="col-md-3 col-6 text-center mb-3">
<div class="h6">Disk Usage</div>
<div class="progress mb-2" style="height: 25px;">
<div class="progress-bar
{% if system_info.disk_percent < 70 %}bg-success
{% elif system_info.disk_percent < 90 %}bg-warning
{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {{ system_info.disk_percent }}%;">
{{ system_info.disk_percent }}%
</div>
</div>
<small class="text-muted">{{ system_info.disk_used }}GB / {{ system_info.disk_total }}GB</small>
</div>
<!-- Upload Folder Size -->
<div class="col-md-3 col-6 text-center mb-3">
<div class="h6">Media Storage</div>
<div class="text-primary display-6">{{ system_info.upload_folder_size }}GB</div>
<small class="text-muted">Total media files</small>
</div>
</div>
<!-- System Details -->
<div class="row mt-3">
<div class="col-12">
<hr>
<div class="row text-center">
<div class="col-md-4 col-12 mb-2">
<strong>Available Disk Space:</strong><br>
<span class="text-success">{{ system_info.disk_free }}GB free</span>
</div>
<div class="col-md-4 col-12 mb-2">
<strong>Total Disk Space:</strong><br>
<span class="text-info">{{ system_info.disk_total }}GB total</span>
</div>
<div class="col-md-4 col-12 mb-2">
<strong>Last Updated:</strong><br>
<span class="text-muted" id="last-update-admin">Just now</span>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
@@ -227,6 +315,77 @@
popup.style.display = 'none';
}, 5000);
}
// Auto-refresh system monitoring every 15 seconds
{% if system_info %}
function updateAdminSystemInfo() {
fetch('/api/system_info')
.then(response => response.json())
.then(data => {
if (data.error) {
console.warn('Could not fetch system info:', data.error);
return;
}
// Update progress bars and their colors
const progressBars = document.querySelectorAll('.progress-bar');
if (progressBars.length >= 3) {
// CPU Bar
progressBars[0].style.width = data.cpu_percent + '%';
progressBars[0].textContent = data.cpu_percent + '%';
progressBars[0].className = 'progress-bar ' +
(data.cpu_percent < 50 ? 'bg-success' :
data.cpu_percent < 80 ? 'bg-warning' : 'bg-danger');
// Memory Bar
progressBars[1].style.width = data.memory_percent + '%';
progressBars[1].textContent = data.memory_percent + '%';
progressBars[1].className = 'progress-bar ' +
(data.memory_percent < 60 ? 'bg-success' :
data.memory_percent < 85 ? 'bg-warning' : 'bg-danger');
// Disk Bar
progressBars[2].style.width = data.disk_percent + '%';
progressBars[2].textContent = data.disk_percent + '%';
progressBars[2].className = 'progress-bar ' +
(data.disk_percent < 70 ? 'bg-success' :
data.disk_percent < 90 ? 'bg-warning' : 'bg-danger');
}
// Update text values
const smallTexts = document.querySelectorAll('.text-muted');
smallTexts.forEach((text, index) => {
if (index === 1) text.textContent = data.memory_used + 'GB / ' + data.memory_total + 'GB';
if (index === 2) text.textContent = data.disk_used + 'GB / ' + data.disk_total + 'GB';
});
// Update storage size
const storageDisplay = document.querySelector('.display-6');
if (storageDisplay) {
storageDisplay.textContent = data.upload_folder_size + 'GB';
}
// Update disk space info
const diskFree = document.querySelector('.text-success');
const diskTotal = document.querySelector('.text-info');
if (diskFree) diskFree.textContent = data.disk_free + 'GB free';
if (diskTotal) diskTotal.textContent = data.disk_total + 'GB total';
// Update timestamp
const lastUpdate = document.getElementById('last-update-admin');
if (lastUpdate) {
lastUpdate.textContent = new Date().toLocaleTimeString();
}
})
.catch(error => {
console.warn('Admin system monitoring update failed:', error);
});
}
// Update every 15 seconds
setInterval(updateAdminSystemInfo, 15000);
{% endif %}
</script>
</body>
</html>

View File

@@ -91,12 +91,37 @@
</div>
<div class="card-body">
{% if content %}
<!-- Bulk Actions Controls -->
<div class="row mb-3">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAll">
<label class="form-check-label" for="selectAll">
Select All
</label>
</div>
</div>
<div class="col-md-6 text-end">
<button type="button" class="btn btn-danger" id="bulkDeleteBtn" style="display:none;" onclick="confirmBulkDelete()">
<i class="bi bi-trash"></i> Delete Selected
</button>
</div>
</div>
<ul class="list-group sortable-list" id="groupMediaList">
{% for media in content %}
<li class="list-group-item d-flex align-items-center {{ 'dark-mode' if theme == 'dark' else '' }}"
draggable="true"
data-id="{{ media.id }}"
data-position="{{ loop.index0 }}">
<!-- Checkbox for bulk selection -->
<div class="me-2">
<input class="form-check-input media-checkbox"
type="checkbox"
name="selected_content"
value="{{ media.id }}">
</div>
<!-- Drag handle -->
<div class="drag-handle me-2" title="Drag to reorder">
<i class="bi bi-grip-vertical"></i>
@@ -219,7 +244,75 @@ document.addEventListener('DOMContentLoaded', function() {
item.dataset.position = index;
});
}
// Bulk selection functionality
const selectAllCheckbox = document.getElementById('selectAll');
const mediaCheckboxes = document.querySelectorAll('.media-checkbox');
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
// Select all functionality
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
mediaCheckboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
updateBulkDeleteButton();
});
}
// Individual checkbox change
mediaCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateSelectAllState();
updateBulkDeleteButton();
});
});
function updateSelectAllState() {
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
if (selectAllCheckbox) {
selectAllCheckbox.checked = checkedBoxes.length === mediaCheckboxes.length && mediaCheckboxes.length > 0;
selectAllCheckbox.indeterminate = checkedBoxes.length > 0 && checkedBoxes.length < mediaCheckboxes.length;
}
}
function updateBulkDeleteButton() {
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
if (bulkDeleteBtn) {
bulkDeleteBtn.style.display = checkedBoxes.length > 0 ? 'inline-block' : 'none';
}
}
});
function confirmBulkDelete() {
const checkedBoxes = Array.from(document.querySelectorAll('.media-checkbox:checked'));
if (checkedBoxes.length === 0) {
alert('No media files selected.');
return;
}
const count = checkedBoxes.length;
const message = `Are you sure you want to delete ${count} selected media file${count > 1 ? 's' : ''}? This action cannot be undone.`;
if (confirm(message)) {
// Create a form with selected IDs
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ url_for("bulk_delete_group_content", group_id=group.id) }}';
checkedBoxes.forEach(checkbox => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_content';
input.value = checkbox.value;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
}
</script>
</body>
</html>

View File

@@ -93,6 +93,28 @@
</div>
<div class="card-body">
{% if content %}
<!-- Bulk Actions Controls -->
<div class="row mb-3">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAll" {% if player.groups %}disabled{% endif %}>
<label class="form-check-label" for="selectAll">
Select All
</label>
</div>
</div>
<div class="col-md-6 text-end">
<button type="button" class="btn btn-danger" id="bulkDeleteBtn" {% if player.groups %}disabled{% endif %} style="display:none;" onclick="confirmBulkDelete()">
<i class="bi bi-trash"></i> Delete Selected
</button>
</div>
</div>
<!-- Bulk Delete Form -->
<form id="bulkDeleteForm" action="{{ url_for('bulk_delete_player_content', player_id=player.id) }}" method="post" style="display:none;">
<input type="hidden" name="selected_content_ids" id="selectedContentIds">
</form>
<ul class="list-group sortable-list" id="mediaList">
{% for media in content %}
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}"
@@ -100,6 +122,15 @@
data-id="{{ media.id }}"
data-position="{{ loop.index0 }}">
<div class="d-flex flex-column flex-md-row align-items-md-center">
<!-- Checkbox for bulk selection -->
<div class="me-2">
<input class="form-check-input media-checkbox"
type="checkbox"
name="selected_content"
value="{{ media.id }}"
{% if player.groups %}disabled{% endif %}>
</div>
<!-- Drag handle -->
<div class="drag-handle me-2" title="Drag to reorder">
<i class="bi bi-grip-vertical"></i>
@@ -235,7 +266,78 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
}
// Bulk selection functionality
const selectAllCheckbox = document.getElementById('selectAll');
const mediaCheckboxes = document.querySelectorAll('.media-checkbox');
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
// Select all functionality
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
mediaCheckboxes.forEach(checkbox => {
if (!checkbox.disabled) {
checkbox.checked = this.checked;
}
});
updateBulkDeleteButton();
});
}
// Individual checkbox change
mediaCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateSelectAllState();
updateBulkDeleteButton();
});
});
function updateSelectAllState() {
const enabledCheckboxes = Array.from(mediaCheckboxes).filter(cb => !cb.disabled);
const checkedBoxes = enabledCheckboxes.filter(cb => cb.checked);
if (selectAllCheckbox) {
selectAllCheckbox.checked = checkedBoxes.length === enabledCheckboxes.length && enabledCheckboxes.length > 0;
selectAllCheckbox.indeterminate = checkedBoxes.length > 0 && checkedBoxes.length < enabledCheckboxes.length;
}
}
function updateBulkDeleteButton() {
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
if (bulkDeleteBtn) {
bulkDeleteBtn.style.display = checkedBoxes.length > 0 ? 'inline-block' : 'none';
}
}
});
function confirmBulkDelete() {
const checkedBoxes = Array.from(document.querySelectorAll('.media-checkbox:checked'));
if (checkedBoxes.length === 0) {
alert('No media files selected.');
return;
}
const count = checkedBoxes.length;
const message = `Are you sure you want to delete ${count} selected media file${count > 1 ? 's' : ''}? This action cannot be undone.`;
if (confirm(message)) {
// Create a form with selected IDs
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ url_for("bulk_delete_player_content", player_id=player.id) }}';
checkedBoxes.forEach(checkbox => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_content';
input.value = checkbox.value;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
}
</script>
</body>
</html>

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>Upload Content</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.dark-mode label, .dark-mode th, .dark-mode td {
color: #ffffff;
}
.logo {
max-height: 100px;
margin-right: 20px;
}
/* Modal styling for dark mode */
.modal-content.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.modal-header.dark-mode {
border-bottom: 1px solid #444;
}
.modal-footer.dark-mode {
border-top: 1px solid #444;
}
.progress-bar {
background-color: #007bff;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
.card {
margin-bottom: 1rem;
}
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<div class="d-flex justify-content-start align-items-center mb-4">
{% if logo_exists %}
<img src="{{ url_for('static', filename='uploads/logo.png') }}" alt="Logo" class="logo">
{% endif %}
<h1 class="mb-0">Upload Content</h1>
</div>
<form id="upload-form" action="{{ url_for('upload_content') }}" method="post" enctype="multipart/form-data" onsubmit="showStatusModal()">
<input type="hidden" name="return_url" value="{{ return_url }}">
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="target_type" class="form-label">Target Type:</label>
<select name="target_type" id="target_type" class="form-select" required onchange="updateTargetIdOptions()">
<option value="" disabled selected>Select Target Type</option>
<option value="player" {% if target_type == 'player' %}selected{% endif %}>Player</option>
<option value="group" {% if target_type == 'group' %}selected{% endif %}>Group</option>
</select>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="target_id" class="form-label">Target ID:</label>
<select name="target_id" id="target_id" class="form-select" required>
{% if target_type == 'player' %}
<optgroup label="Players">
{% for player in players %}
<option value="{{ player.id }}" {% if target_id == player.id %}selected{% endif %}>{{ player.username }}</option>
{% endfor %}
</optgroup>
{% elif target_type == 'group' %}
<optgroup label="Groups">
{% for group in groups %}
<option value="{{ group.id }}" {% if target_id == group.id %}selected{% endif %}>{{ group.name }}</option>
{% endfor %}
</optgroup>
{% else %}
<option value="" disabled selected>Select a Target ID</option>
{% endif %}
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="media_type" class="form-label">Media Type:</label>
<select name="media_type" id="media_type" class="form-select" required>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="pdf">PDF</option>
<option value="ppt">PPT/PPTX</option>
</select>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="files" class="form-label">Files:</label>
<input type="file" name="files" id="files" class="form-control" multiple required onchange="handleFileChange()">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="duration" class="form-label">Duration (seconds):</label>
<input type="number" name="duration" id="duration" class="form-control" required>
</div>
</div>
</div>
<div class="text-center">
<button type="submit" id="submit-button" class="btn btn-primary">Upload</button>
<a href="{{ return_url }}" class="btn btn-secondary mt-3">Back</a>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
</div>
</form>
<!-- Modal for Status Updates -->
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="modal-header {{ 'dark-mode' if theme == 'dark' else '' }}">
<h5 class="modal-title" id="statusModalLabel">Processing Files</h5>
</div>
<div class="modal-body">
<p id="status-message">Uploading and processing your files. Please wait...</p>
<!-- File Processing Progress -->
<div class="mb-3">
<label class="form-label fw-bold">File Processing Progress</label>
<div class="progress" style="height: 25px;">
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
<!-- System Monitoring in Modal -->
{% if system_info %}
<div class="mt-4">
<h6 class="mb-3">📊 Server Performance During Upload</h6>
<div class="row">
<!-- CPU Usage -->
<div class="col-md-4 col-12 mb-3">
<label class="form-label">CPU Usage</label>
<div class="progress" style="height: 20px;">
<div id="cpu-progress" class="progress-bar
{% if system_info.cpu_percent < 50 %}bg-success
{% elif system_info.cpu_percent < 80 %}bg-warning
{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {{ system_info.cpu_percent }}%;">
{{ system_info.cpu_percent }}%
</div>
</div>
<small class="text-muted">{{ system_info.cpu_count }} cores available</small>
</div>
<!-- Memory Usage -->
<div class="col-md-4 col-12 mb-3">
<label class="form-label">Memory Usage</label>
<div class="progress" style="height: 20px;">
<div id="memory-progress" class="progress-bar
{% if system_info.memory_percent < 60 %}bg-success
{% elif system_info.memory_percent < 85 %}bg-warning
{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {{ system_info.memory_percent }}%;">
{{ system_info.memory_percent }}%
</div>
</div>
<small class="text-muted" id="memory-text">{{ system_info.memory_used }}GB / {{ system_info.memory_total }}GB</small>
</div>
<!-- Disk Usage -->
<div class="col-md-4 col-12 mb-3">
<label class="form-label">Disk Space</label>
<div class="progress" style="height: 20px;">
<div id="disk-progress" class="progress-bar
{% if system_info.disk_percent < 70 %}bg-success
{% elif system_info.disk_percent < 90 %}bg-warning
{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {{ system_info.disk_percent }}%;">
{{ system_info.disk_percent }}%
</div>
</div>
<small class="text-muted" id="disk-text">{{ system_info.disk_free }}GB free</small>
</div>
</div>
<!-- Storage Summary -->
<div class="row mt-2">
<div class="col-md-6 col-12 text-center">
<strong>Current Media Storage:</strong>
<span class="text-primary" id="storage-size">{{ system_info.upload_folder_size }}GB</span>
</div>
<div class="col-md-6 col-12 text-center">
<strong>Last Updated:</strong>
<span class="text-muted" id="modal-last-update">Just now</span>
</div>
</div>
</div>
{% endif %}
</div>
<div class="modal-footer {{ 'dark-mode' if theme == 'dark' else '' }}">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" disabled>Close</button>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
function showStatusModal() {
console.log("Processing popup triggered");
const statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
statusModal.show();
// Update status message based on media type
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 processing video. This may take a while...';
break;
case 'pdf':
statusMessage.textContent = 'Converting PDF to 4K images. This may take a while...';
break;
case 'ppt':
statusMessage.textContent = 'Converting PowerPoint to 4K images. This may take a while...';
break;
default:
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
}
// Start system monitoring updates in modal
{% if system_info %}
startModalSystemMonitoring();
{% endif %}
// Simulate progress updates
const progressBar = document.getElementById('progress-bar');
let progress = 0;
const interval = setInterval(() => {
// For slow processes, increment more slowly
const increment = (mediaType === 'image') ? 20 : 5;
progress += increment;
if (progress >= 100) {
clearInterval(interval);
statusMessage.textContent = 'Files uploaded and processed successfully!';
// Stop system monitoring updates
{% if system_info %}
stopModalSystemMonitoring();
{% endif %}
// Enable the close button
document.querySelector('[data-bs-dismiss="modal"]').disabled = false;
} else {
progressBar.style.width = `${progress}%`;
progressBar.setAttribute('aria-valuenow', progress);
}
}, 500);
}
{% if system_info %}
let modalSystemInterval;
function updateModalSystemInfo() {
fetch('/api/system_info')
.then(response => response.json())
.then(data => {
if (data.error) {
console.warn('Could not fetch system info:', data.error);
return;
}
// Update CPU
const cpuProgress = document.getElementById('cpu-progress');
if (cpuProgress) {
cpuProgress.style.width = data.cpu_percent + '%';
cpuProgress.textContent = data.cpu_percent + '%';
cpuProgress.className = 'progress-bar ' +
(data.cpu_percent < 50 ? 'bg-success' :
data.cpu_percent < 80 ? 'bg-warning' : 'bg-danger');
}
// Update Memory
const memoryProgress = document.getElementById('memory-progress');
const memoryText = document.getElementById('memory-text');
if (memoryProgress) {
memoryProgress.style.width = data.memory_percent + '%';
memoryProgress.textContent = data.memory_percent + '%';
memoryProgress.className = 'progress-bar ' +
(data.memory_percent < 60 ? 'bg-success' :
data.memory_percent < 85 ? 'bg-warning' : 'bg-danger');
}
if (memoryText) {
memoryText.textContent = data.memory_used + 'GB / ' + data.memory_total + 'GB';
}
// Update Disk
const diskProgress = document.getElementById('disk-progress');
const diskText = document.getElementById('disk-text');
if (diskProgress) {
diskProgress.style.width = data.disk_percent + '%';
diskProgress.textContent = data.disk_percent + '%';
diskProgress.className = 'progress-bar ' +
(data.disk_percent < 70 ? 'bg-success' :
data.disk_percent < 90 ? 'bg-warning' : 'bg-danger');
}
if (diskText) {
diskText.textContent = data.disk_free + 'GB free';
}
// Update storage size
const storageSize = document.getElementById('storage-size');
if (storageSize) {
storageSize.textContent = data.upload_folder_size + 'GB';
}
// Update timestamp
const lastUpdate = document.getElementById('modal-last-update');
if (lastUpdate) {
lastUpdate.textContent = new Date().toLocaleTimeString();
}
})
.catch(error => {
console.warn('Modal system monitoring update failed:', error);
});
}
function startModalSystemMonitoring() {
// Update immediately
updateModalSystemInfo();
// Then update every 3 seconds for real-time monitoring during upload
modalSystemInterval = setInterval(updateModalSystemInfo, 3000);
}
function stopModalSystemMonitoring() {
if (modalSystemInterval) {
clearInterval(modalSystemInterval);
modalSystemInterval = null;
}
}
{% endif %}
function updateTargetIdOptions() {
const targetType = document.getElementById('target_type').value;
const targetIdSelect = document.getElementById('target_id');
targetIdSelect.innerHTML = ''; // Clear existing options
if (targetType === 'player') {
const players = {{ players|tojson }};
const optgroup = document.createElement('optgroup');
optgroup.label = 'Players';
players.forEach(player => {
const option = document.createElement('option');
option.value = player.id;
option.textContent = player.username;
optgroup.appendChild(option);
});
targetIdSelect.appendChild(optgroup);
} else if (targetType === 'group') {
const groups = {{ groups|tojson }};
const optgroup = document.createElement('optgroup');
optgroup.label = 'Groups';
groups.forEach(group => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
optgroup.appendChild(option);
});
targetIdSelect.appendChild(optgroup);
}
}
function handleFileChange() {
const mediaType = document.getElementById('media_type').value;
const filesInput = document.getElementById('files');
const durationInput = document.getElementById('duration');
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; // Set the duration in the input field
};
video.src = URL.createObjectURL(file);
}
}
function showStatusModal() {
console.log("Processing popup triggered");
const statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
statusModal.show();
// Update status message based on media type
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 processing video. This may take a while...';
break;
case 'pdf':
statusMessage.textContent = 'Converting PDF to images. This may take a while...';
break;
case 'ppt':
statusMessage.textContent = 'Converting PowerPoint to images. This may take a while...';
break;
default:
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
}
// Simulate progress updates
const progressBar = document.getElementById('progress-bar');
let progress = 0;
const interval = setInterval(() => {
// For slow processes, increment more slowly
const increment = (mediaType === 'image') ? 20 : 5;
progress += increment;
if (progress >= 100) {
clearInterval(interval);
statusMessage.textContent = 'Files uploaded and processed successfully!';
// Enable the close button
document.querySelector('[data-bs-dismiss="modal"]').disabled = false;
} else {
progressBar.style.width = `${progress}%`;
progressBar.setAttribute('aria-valuenow', progress);
}
}, 500);
}
</script>
</body>
</html>

View File

@@ -333,7 +333,7 @@ def edit_group_media(group_id, content_id, new_duration):
def delete_group_media(group_id, content_id):
"""
Delete a media item from all players in a group.
Delete a media item from all players in a group and remove the physical file.
Args:
group_id (int): ID of the group
@@ -344,6 +344,8 @@ def delete_group_media(group_id, content_id):
"""
from models import Group, Content
from extensions import db
from flask import current_app
import os
group = Group.query.get_or_404(group_id)
content = Content.query.get(content_id)
@@ -358,6 +360,19 @@ def delete_group_media(group_id, content_id):
db.session.delete(content)
count += 1
# Delete the physical file using absolute path
upload_folder = current_app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
file_path = os.path.join(upload_folder, file_name)
if os.path.exists(file_path):
try:
os.remove(file_path)
print(f"Deleted physical file: {file_path}")
except OSError as e:
print(f"Error deleting file {file_path}: {e}")
db.session.commit()
# Log the content deletion
@@ -366,4 +381,5 @@ def delete_group_media(group_id, content_id):
return True
except Exception as e:
db.session.rollback()
print(f"Error in delete_group_media: {e}")
return False

View File

@@ -0,0 +1,86 @@
"""
PPTX to PDF converter using LibreOffice for high-quality conversion
This module provides the essential function to convert PowerPoint presentations to PDF
using LibreOffice headless mode for professional-grade quality.
The converted PDF is then processed by the main upload workflow for 4K image generation.
"""
import os
import subprocess
import logging
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def pptx_to_pdf_libreoffice(pptx_path, output_dir):
"""
Convert PPTX to PDF using LibreOffice for highest quality.
This function is the core component of the PPTX processing workflow:
PPTX → PDF (this function) → 4K JPG images (handled in uploads.py)
Args:
pptx_path (str): Path to the PPTX file
output_dir (str): Directory to save the PDF
Returns:
str: Path to the generated PDF file, or None if conversion failed
"""
try:
# 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,
pptx_path
]
logger.info(f"Converting PPTX to PDF using LibreOffice: {pptx_path}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
logger.error(f"LibreOffice conversion failed: {result.stderr}")
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}")
return pdf_path
else:
logger.error(f"PDF file not found after conversion: {pdf_path}")
return None
except subprocess.TimeoutExpired:
logger.error("LibreOffice conversion timed out (120s)")
return None
except Exception as e:
logger.error(f"Error in PPTX to PDF conversion: {e}")
return None
if __name__ == "__main__":
# Test the converter
import sys
if len(sys.argv) > 1:
test_pptx = sys.argv[1]
if os.path.exists(test_pptx):
output_dir = "test_output"
pdf_result = pptx_to_pdf_libreoffice(test_pptx, output_dir)
if pdf_result:
print(f"Successfully converted PPTX to PDF: {pdf_result}")
else:
print("PPTX to PDF conversion failed")
else:
print(f"File not found: {test_pptx}")
else:
print("Usage: python pptx_converter.py <pptx_file>")

View File

@@ -12,10 +12,25 @@ def add_image_to_playlist(app, file, filename, duration, target_type, target_id)
"""
Save the image file and add it to the playlist database.
"""
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
# Ensure we use absolute path for upload folder
upload_folder = app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
# Ensure upload folder exists
if not os.path.exists(upload_folder):
os.makedirs(upload_folder, exist_ok=True)
print(f"Created upload folder: {upload_folder}")
file_path = os.path.join(upload_folder, filename)
print(f"Saving image to: {file_path}")
# Only save if file does not already exist
if not os.path.exists(file_path):
file.save(file_path)
print(f"Image saved successfully: {file_path}")
else:
print(f"File already exists: {file_path}")
print(f"Adding image to playlist: {filename}, Target Type: {target_type}, Target ID: {target_id}")
@@ -40,12 +55,19 @@ def convert_video(input_file, output_folder):
"""
Converts a video file to MP4 format with H.264 codec.
"""
# Ensure we use absolute path for output folder
if not os.path.isabs(output_folder):
output_folder = os.path.abspath(output_folder)
print(f"Converted output folder to absolute path: {output_folder}")
if not os.path.exists(output_folder):
os.makedirs(output_folder)
os.makedirs(output_folder, exist_ok=True)
print(f"Created output folder: {output_folder}")
# Generate the output file path
base_name = os.path.splitext(os.path.basename(input_file))[0]
output_file = os.path.join(output_folder, f"{base_name}.mp4")
print(f"Converting video: {input_file} -> {output_file}")
# FFmpeg command to convert the video
command = [
@@ -75,7 +97,14 @@ def convert_video_and_update_playlist(app, file_path, original_filename, target_
Converts a video and updates the playlist database.
"""
print(f"Starting video conversion for: {file_path}")
converted_file = convert_video(file_path, app.config['UPLOAD_FOLDER'])
# Ensure we use absolute path for upload folder
upload_folder = app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
print(f"Converted upload folder to absolute path: {upload_folder}")
converted_file = convert_video(file_path, upload_folder)
if converted_file:
converted_filename = os.path.basename(converted_file)
print(f"Video converted successfully: {converted_filename}")
@@ -105,39 +134,79 @@ def convert_video_and_update_playlist(app, file_path, original_filename, target_
print(f"Video conversion failed for: {file_path}")
# PDF conversion functions
def convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True, dpi=600):
def convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True, dpi=300):
"""
Convert a PDF file to images in sequential order at high resolution (4K).
Convert a PDF file to high-quality JPG images in sequential order.
Uses standard 300 DPI for reliable conversion.
"""
print(f"Converting PDF to images: {pdf_file} at {dpi} DPI")
print(f"Converting PDF to JPG images: {pdf_file} at {dpi} DPI")
print(f"Original output folder: {output_folder}")
# Force absolute path resolution to ensure we use the app directory
if not os.path.isabs(output_folder):
# If relative path, resolve from the current working directory
output_folder = os.path.abspath(output_folder)
print(f"Converted relative path to absolute: {output_folder}")
else:
print(f"Using provided absolute path: {output_folder}")
# Ensure we're using the app static folder, not workspace root
if output_folder.endswith('static/uploads'):
# Check if we're accidentally using workspace root instead of app folder
expected_app_path = '/opt/digiserver/app/static/uploads'
if output_folder != expected_app_path:
print(f"WARNING: Correcting path from {output_folder} to {expected_app_path}")
output_folder = expected_app_path
print(f"Final output folder: {output_folder}")
try:
# Convert PDF to images
# Ensure output folder exists
if not os.path.exists(output_folder):
os.makedirs(output_folder, exist_ok=True)
print(f"Created output folder: {output_folder}")
# Convert PDF to images using pdf2image
print("Starting PDF conversion...")
images = convert_from_path(pdf_file, dpi=dpi)
print(f"PDF converted to {len(images)} page(s)")
if not images:
print("ERROR: No images generated from PDF")
return []
base_name = os.path.splitext(os.path.basename(pdf_file))[0]
image_filenames = []
# Save each page as an image with zero-padded page numbers for proper sorting
# Save each page as JPG image
for i, image in enumerate(images):
# Use consistent naming with zero-padded page numbers (e.g., page_001.jpg)
# Convert to RGB if necessary
if image.mode != 'RGB':
image = image.convert('RGB')
# Simple naming with page numbers
page_num = str(i + 1).zfill(3) # e.g., 001, 002, etc.
image_filename = f"{base_name}_page_{page_num}.jpg"
image_path = os.path.join(output_folder, image_filename)
image.save(image_path, 'JPEG')
# Save as JPG
image.save(image_path, 'JPEG', quality=85, optimize=True)
image_filenames.append(image_filename)
print(f"Saved page {i + 1} as image: {image_path}")
print(f"Saved page {i + 1} to: {image_path}")
# Verify all pages were saved
print(f"PDF conversion complete. {len(image_filenames)} pages saved.")
print(f"Images in order: {image_filenames}")
# Delete the PDF file if requested
if delete_pdf and os.path.exists(pdf_file):
print(f"PDF conversion complete. {len(image_filenames)} JPG images saved to {output_folder}")
# Delete the PDF file if requested and conversion was successful
if delete_pdf and os.path.exists(pdf_file) and image_filenames:
os.remove(pdf_file)
print(f"PDF file deleted: {pdf_file}")
return image_filenames
except Exception as e:
print(f"Error converting PDF to images: {e}")
print(f"Error converting PDF to JPG images: {e}")
import traceback
traceback.print_exc()
return []
def update_playlist_with_files(image_filenames, duration, target_type, target_id):
@@ -194,21 +263,35 @@ def process_pdf(input_file, output_folder, duration, target_type, target_id):
Returns:
bool: True if successful, False otherwise
"""
print(f"Processing PDF file: {input_file}")
print(f"Output folder: {output_folder}")
# Ensure we have absolute path for output folder
if not os.path.isabs(output_folder):
output_folder = os.path.abspath(output_folder)
print(f"Converted output folder to absolute path: {output_folder}")
# Ensure output folder exists
if not os.path.exists(output_folder):
os.makedirs(output_folder)
os.makedirs(output_folder, exist_ok=True)
print(f"Created output folder: {output_folder}")
# Convert PDF to images
image_filenames = convert_pdf_to_images(input_file, output_folder)
# Convert PDF to images using standard quality (delete PDF after successful conversion)
image_filenames = convert_pdf_to_images(input_file, output_folder, delete_pdf=True, dpi=300)
# Update playlist with generated images
if image_filenames:
return update_playlist_with_files(image_filenames, duration, target_type, target_id)
return False
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
if success:
print(f"Successfully processed PDF: {len(image_filenames)} images added to playlist")
return success
else:
print("Failed to convert PDF to images")
return False
def process_pptx(input_file, output_folder, duration, target_type, target_id):
"""
Process a PPTX file: convert to PDF, then to images, and update playlist in sequential order.
Process a PPTX file: convert to PDF first, then to JPG images (same workflow as PDF).
Args:
input_file (str): Path to the PPTX file
@@ -220,51 +303,55 @@ def process_pptx(input_file, output_folder, duration, target_type, target_id):
Returns:
bool: True if successful, False otherwise
"""
print(f"Processing PPTX file using PDF workflow: {input_file}")
print(f"Output folder: {output_folder}")
# Ensure we have absolute path for output folder
if not os.path.isabs(output_folder):
output_folder = os.path.abspath(output_folder)
print(f"Converted output folder to absolute path: {output_folder}")
# Ensure output folder exists
if not os.path.exists(output_folder):
os.makedirs(output_folder)
os.makedirs(output_folder, exist_ok=True)
print(f"Created output folder: {output_folder}")
# Step 1: Convert PPTX to PDF using LibreOffice
pdf_file = os.path.join(output_folder, os.path.splitext(os.path.basename(input_file))[0] + ".pdf")
command = [
'libreoffice',
'--headless',
'--convert-to', 'pdf',
'--outdir', output_folder,
'--printer-resolution', '600',
input_file
]
print(f"Running LibreOffice command: {' '.join(command)}")
try:
result = subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
print(f"PPTX file converted to PDF: {pdf_file}")
print(f"LibreOffice output: {result.stdout.decode()}")
print(f"LibreOffice errors (if any): {result.stderr.decode()}")
# Step 1: Convert PPTX to PDF using LibreOffice for vector quality
from utils.pptx_converter import pptx_to_pdf_libreoffice
pdf_file = pptx_to_pdf_libreoffice(input_file, output_folder)
# Step 2: Convert PDF to images and update playlist
image_filenames = convert_pdf_to_images(pdf_file, output_folder, True, dpi=600)
# Verify we got images
if not image_filenames:
print("Error: No images were generated from the PDF")
if not pdf_file:
print("Error: Failed to convert PPTX to PDF")
return False
print(f"Generated {len(image_filenames)} images for PPTX")
print(f"PPTX successfully converted to PDF: {pdf_file}")
# Step 3: Delete the original PPTX file
# Step 2: Use the same PDF to images workflow as direct PDF uploads
# Convert PDF to JPG images (300 DPI, same as PDF workflow)
image_filenames = convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True, dpi=300)
if not image_filenames:
print("Error: Failed to convert PDF to images")
return False
print(f"Generated {len(image_filenames)} JPG images from PPTX → PDF")
# Step 3: Delete the original PPTX file after successful conversion
if os.path.exists(input_file):
os.remove(input_file)
print(f"Original PPTX file deleted: {input_file}")
# Step 4: Update playlist with generated images in sequential order
return update_playlist_with_files(image_filenames, duration, target_type, target_id)
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
if success:
print(f"Successfully processed PPTX: {len(image_filenames)} images added to playlist")
return success
except subprocess.CalledProcessError as e:
print(f"Error converting PPTX to PDF: {e.stderr.decode() if hasattr(e, 'stderr') else str(e)}")
return False
except Exception as e:
print(f"Error processing PPTX file: {e}")
import traceback
traceback.print_exc()
return False
def process_uploaded_files(app, files, media_type, duration, target_type, target_id):
@@ -289,8 +376,20 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target
try:
# Generate a secure filename and save the file
filename = secure_filename(file.filename)
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
# Ensure we use absolute path for upload folder
upload_folder = app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
# Ensure upload folder exists
if not os.path.exists(upload_folder):
os.makedirs(upload_folder, exist_ok=True)
print(f"Created upload folder: {upload_folder}")
file_path = os.path.join(upload_folder, filename)
file.save(file_path)
print(f"File saved to: {file_path}")
print(f"Processing file: {filename}, Media Type: {media_type}")
result = {'filename': filename, 'success': True, 'message': ''}
@@ -316,7 +415,7 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target
player.playlist_version += 1
db.session.commit()
# Start background conversion
# Start background conversion using absolute path
import threading
threading.Thread(target=convert_video_and_update_playlist,
args=(app, file_path, filename, target_type, target_id, duration)).start()
@@ -324,8 +423,8 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target
log_upload('video', filename, target_type, target_id)
elif media_type == 'pdf':
# For PDFs, convert to images and update playlist
success = process_pdf(file_path, app.config['UPLOAD_FOLDER'],
# For PDFs, convert to images and update playlist using absolute path
success = process_pdf(file_path, upload_folder,
duration, target_type, target_id)
if success:
result['message'] = f"PDF {filename} processed successfully"
@@ -335,8 +434,8 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target
result['message'] = f"Error processing PDF file: {filename}"
elif media_type == 'ppt':
# For PPT/PPTX, convert to PDF, then to images, and update playlist
success = process_pptx(file_path, app.config['UPLOAD_FOLDER'],
# For PPT/PPTX, convert to PDF, then to images, and update playlist using absolute path
success = process_pptx(file_path, upload_folder,
duration, target_type, target_id)
if success:
result['message'] = f"PowerPoint {filename} processed successfully"

64
cleanup-docker.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/bin/bash
# DigiServer Docker Cleanup Script
# Version: 1.1.0
set -e
echo "🧹 DigiServer Docker Cleanup"
echo "============================"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Confirm cleanup
print_warning "This will stop and remove all DigiServer containers and images."
print_warning "Your data in the ./data directory will be preserved."
echo ""
read -p "Are you sure you want to continue? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_status "Cleanup cancelled."
exit 0
fi
# Stop and remove containers
print_status "Stopping DigiServer containers..."
docker compose down
# Remove DigiServer images
print_status "Removing DigiServer images..."
docker rmi digiserver:latest 2>/dev/null || print_warning "DigiServer image not found"
# Clean up unused Docker resources
print_status "Cleaning up unused Docker resources..."
docker system prune -f
# Clean up development cache files
print_status "Cleaning up development cache files..."
find ./app -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
find ./app -name "*.pyc" -delete 2>/dev/null || true
print_success "Cleanup completed!"
print_status "Data directory preserved at: ./data"
print_status "To redeploy, run: ./deploy-docker.sh"

109
deploy-docker.sh Executable file
View File

@@ -0,0 +1,109 @@
#!/bin/bash
# DigiServer Docker Deployment Script
# Version: 1.1.0
set -e
echo "🚀 DigiServer Docker Deployment"
echo "================================"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if Docker is running
if ! docker info >/dev/null 2>&1; then
print_error "Docker is not running. Please start Docker and try again."
exit 1
fi
print_status "Docker is running ✓"
# Check if docker compose is available
if ! docker compose version >/dev/null 2>&1; then
print_error "docker compose is not available. Please install Docker Compose and try again."
exit 1
fi
print_status "docker compose is available ✓"
# Stop existing containers if running
print_status "Stopping existing containers..."
docker compose down 2>/dev/null || true
# Remove old images (optional)
read -p "Do you want to remove old DigiServer images? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
print_status "Removing old images..."
docker image prune -f --filter label=app=digiserver 2>/dev/null || true
docker rmi digiserver:latest 2>/dev/null || true
fi
# Create data directories if they don't exist
print_status "Creating data directories..."
mkdir -p data/instance data/uploads data/resurse
# Build the Docker image
print_status "Building DigiServer Docker image..."
docker compose build
# Check if build was successful
if [ $? -eq 0 ]; then
print_success "Docker image built successfully!"
else
print_error "Docker build failed!"
exit 1
fi
# Start the containers
print_status "Starting DigiServer containers..."
docker compose up -d
# Wait a moment for containers to start
sleep 10
# Check if containers are running
if docker compose ps | grep -q "Up"; then
print_success "DigiServer is now running!"
echo ""
echo "🌐 Access your DigiServer at: http://localhost:8880"
echo "📊 Admin Panel: http://localhost:8880/admin"
echo ""
echo "Default credentials:"
echo "Username: admin"
echo "Password: Initial01!"
echo ""
print_warning "Please change the default password after first login!"
echo ""
echo "📝 To view logs: docker compose logs -f"
echo "🛑 To stop: docker compose down"
echo "📊 To check status: docker compose ps"
else
print_error "Failed to start DigiServer containers!"
echo ""
echo "Check logs with: docker compose logs"
exit 1
fi
print_success "Deployment completed successfully! 🎉"

32
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,32 @@
# Development Docker Compose Configuration
# Use this for development with hot reloading
services:
digiserver-dev:
build: .
image: digiserver:dev
container_name: digiserver-dev
ports:
- "5000:5000"
environment:
- FLASK_APP=app.py
- FLASK_RUN_HOST=0.0.0.0
- FLASK_ENV=development
- FLASK_DEBUG=1
- ADMIN_USER=admin
- ADMIN_PASSWORD=Initial01!
- SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
volumes:
# Mount app code for hot reloading
- ./app:/app
# Persistent data volumes
- ./data/instance:/app/instance
- ./data/uploads:/app/static/uploads
- ./data/resurse:/app/static/resurse
restart: unless-stopped
networks:
- digiserver-dev-network
networks:
digiserver-dev-network:
driver: bridge

View File

@@ -1,17 +1,35 @@
#version: '"1.1.0"'
# DigiServer - Digital Signage Management Platform
# Version: 1.1.0
# Build Date: 2025-06-29
services:
web:
digiserver:
build: .
image: digiserver:latest
container_name: digiserver
ports:
- "8880:5000"
environment:
- FLASK_APP=app.py
- FLASK_RUN_HOST=0.0.0.0
- ADMIN_USER=admin
- ADMIN_PASSWORD=Initial01!
- DEFAULT_USER=admin
- DEFAULT_PASSWORD=Initial01!
- SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
volumes:
- /opt/digi-s/instance:/app/instance
- /opt/digi-s/uploads:/app/static/uploads
# Persistent data volumes
- ./data/instance:/app/instance
- ./data/uploads:/app/static/uploads
- ./data/resurse:/app/static/resurse
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- digiserver-network
networks:
digiserver-network:
driver: bridge

View File

@@ -1,25 +0,0 @@
python3 -m venv digiscreen
source digiscreen/bin/activate
pip install flask sqlalchemy flask-sqlalchemy
pip install flask-login flask-bcrypt
python3 setup.py sdist
python3 setup.py bdist_wheel flask
for installing all the requirements
pip install -r requirements.txt
sudo apt-get update
sudo apt-get install -y \
ffmpeg \
libpoppler-cpp-dev \
poppler-utils \
libreoffice \
libmagic1

View File

@@ -1,251 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload Content</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.dark-mode label, .dark-mode th, .dark-mode td {
color: #ffffff;
}
.logo {
max-height: 100px;
margin-right: 20px;
}
/* Modal styling for dark mode */
.modal-content.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.modal-header.dark-mode {
border-bottom: 1px solid #444;
}
.modal-footer.dark-mode {
border-top: 1px solid #444;
}
.progress-bar {
background-color: #007bff;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
.card {
margin-bottom: 1rem;
}
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<div class="d-flex justify-content-start align-items-center mb-4">
{% if logo_exists %}
<img src="{{ url_for('static', filename='uploads/logo.png') }}" alt="Logo" class="logo">
{% endif %}
<h1 class="mb-0">Upload Content</h1>
</div>
<form id="upload-form" action="{{ url_for('upload_content') }}" method="post" enctype="multipart/form-data" onsubmit="showStatusModal()">
<input type="hidden" name="return_url" value="{{ return_url }}">
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="target_type" class="form-label">Target Type:</label>
<select name="target_type" id="target_type" class="form-select" required onchange="updateTargetIdOptions()">
<option value="" disabled selected>Select Target Type</option>
<option value="player" {% if target_type == 'player' %}selected{% endif %}>Player</option>
<option value="group" {% if target_type == 'group' %}selected{% endif %}>Group</option>
</select>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="target_id" class="form-label">Target ID:</label>
<select name="target_id" id="target_id" class="form-select" required>
{% if target_type == 'player' %}
<optgroup label="Players">
{% for player in players %}
<option value="{{ player.id }}" {% if target_id == player.id %}selected{% endif %}>{{ player.username }}</option>
{% endfor %}
</optgroup>
{% elif target_type == 'group' %}
<optgroup label="Groups">
{% for group in groups %}
<option value="{{ group.id }}" {% if target_id == group.id %}selected{% endif %}>{{ group.name }}</option>
{% endfor %}
</optgroup>
{% else %}
<option value="" disabled selected>Select a Target ID</option>
{% endif %}
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="media_type" class="form-label">Media Type:</label>
<select name="media_type" id="media_type" class="form-select" required>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="pdf">PDF</option>
<option value="ppt">PPT/PPTX</option>
</select>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="files" class="form-label">Files:</label>
<input type="file" name="files" id="files" class="form-control" multiple required onchange="handleFileChange()">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="duration" class="form-label">Duration (seconds):</label>
<input type="number" name="duration" id="duration" class="form-control" required>
</div>
</div>
</div>
<div class="text-center">
<button type="submit" id="submit-button" class="btn btn-primary">Upload</button>
<a href="{{ return_url }}" class="btn btn-secondary mt-3">Back</a>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
</div>
</form>
<!-- Modal for Status Updates -->
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="modal-header {{ 'dark-mode' if theme == 'dark' else '' }}">
<h5 class="modal-title" id="statusModalLabel">Processing Files</h5>
</div>
<div class="modal-body">
<p id="status-message">Uploading and processing your files. Please wait...</p>
<div class="progress">
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
<div class="modal-footer {{ 'dark-mode' if theme == 'dark' else '' }}">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" disabled>Close</button>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
function updateTargetIdOptions() {
const targetType = document.getElementById('target_type').value;
const targetIdSelect = document.getElementById('target_id');
targetIdSelect.innerHTML = ''; // Clear existing options
if (targetType === 'player') {
const players = {{ players|tojson }};
const optgroup = document.createElement('optgroup');
optgroup.label = 'Players';
players.forEach(player => {
const option = document.createElement('option');
option.value = player.id;
option.textContent = player.username;
optgroup.appendChild(option);
});
targetIdSelect.appendChild(optgroup);
} else if (targetType === 'group') {
const groups = {{ groups|tojson }};
const optgroup = document.createElement('optgroup');
optgroup.label = 'Groups';
groups.forEach(group => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
optgroup.appendChild(option);
});
targetIdSelect.appendChild(optgroup);
}
}
function handleFileChange() {
const mediaType = document.getElementById('media_type').value;
const filesInput = document.getElementById('files');
const durationInput = document.getElementById('duration');
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; // Set the duration in the input field
};
video.src = URL.createObjectURL(file);
}
}
function showStatusModal() {
console.log("Processing popup triggered");
const statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
statusModal.show();
// Update status message based on media type
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 processing video. This may take a while...';
break;
case 'pdf':
statusMessage.textContent = 'Converting PDF to images. This may take a while...';
break;
case 'ppt':
statusMessage.textContent = 'Converting PowerPoint to images. This may take a while...';
break;
default:
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
}
// Simulate progress updates
const progressBar = document.getElementById('progress-bar');
let progress = 0;
const interval = setInterval(() => {
// For slow processes, increment more slowly
const increment = (mediaType === 'image') ? 20 : 5;
progress += increment;
if (progress >= 100) {
clearInterval(interval);
statusMessage.textContent = 'Files uploaded and processed successfully!';
// Enable the close button
document.querySelector('[data-bs-dismiss="modal"]').disabled = false;
} else {
progressBar.style.width = `${progress}%`;
progressBar.setAttribute('aria-valuenow', progress);
}
}, 500);
}
</script>
</body>
</html>