Compare commits

...

12 Commits

Author SHA1 Message Date
1eb0aa3658 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!
2025-08-05 18:04:02 -04:00
4e5aff1c02 Updated .gitignore to exclude instance directory 2025-08-05 11:29:44 -04:00
318f783de3 Fix orientation parameter handling and template URL endpoints
- Add orientation parameter support to create_group and edit_group functions
- Fix manage_group.html template URL endpoint from 'update_group_content_order' to 'update_group_content_order_route'
- Add orientation field and filtering to edit_group.html template with JavaScript functionality
- Update group_player_management.py to handle orientation validation in create and edit operations
- Fix docker-compose.yml to include build directive and correct volume paths
- Update entrypoint.sh to handle fresh deployments without migrations
- Ensure orientation consistency across group and player management

These changes resolve:
- Internal Server Error on manage_group page
- Missing orientation parameter in group creation/editing
- Template URL endpoint mismatches
- Docker deployment issues with fresh installations
2025-08-01 15:15:59 -04:00
70d76f45e7 updated 2025-08-01 13:22:37 -04:00
1326543418 updated to 4k images from pptx 2025-08-01 10:23:38 +03:00
c8bbbebb48 updated solution 2025-07-31 16:37:54 +03:00
756f9052b5 Fix Docker deployment issues and add fresh database support
- Fix: Add missing 'click' import to app.py to resolve startup error
- Fix: Update docker-compose.yml volume mappings to use correct persistent storage paths (/opt/digi-s)
- Improve: Enhanced entrypoint.sh for better database initialization
- Update: Configuration files for improved deployment

This resolves the Docker container startup issues and ensures proper persistent storage.
2025-07-18 15:40:57 -04:00
da57e066ae final update 2025-06-29 17:04:23 +03:00
68cc47882c updated versions and players 2025-06-29 16:48:15 +03:00
73c41303a9 updated logs and players page 2025-06-29 16:37:59 +03:00
f20a606183 updated the order resulted in playlist 2025-06-29 15:13:21 +03:00
1800c9c310 updated functions 2025-06-27 17:01:30 +03:00
71 changed files with 3343 additions and 1040 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/

41
.env.example Normal file
View File

@@ -0,0 +1,41 @@
# DigiServer Environment Configuration
# Copy this file to .env and modify the values as needed
# Flask Configuration
FLASK_APP=app.py
FLASK_RUN_HOST=0.0.0.0
FLASK_ENV=production
# Security
SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
# Change this to a secure random string in production!
# 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
# Application Settings
MAX_CONTENT_LENGTH=2147483648 # 2GB in bytes
UPLOAD_FOLDER=static/uploads
UPLOAD_FOLDERLOGO=static/resurse
# 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
# Optional: External Database (for advanced users)
# DATABASE_URL=postgresql://user:password@localhost/digiserver
# DATABASE_URL=mysql://user:password@localhost/digiserver

38
.gitignore vendored Normal file → Executable file
View File

@@ -1,3 +1,41 @@
# 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/ 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,30 +1,59 @@
# Use Python 3.11 slim image
FROM python:3.11-slim FROM python:3.11-slim
# Set working directory
WORKDIR /app 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 \ RUN apt-get update && apt-get install -y \
libreoffice poppler-utils ffmpeg \ poppler-utils \
libffi-dev libssl-dev g++ curl libjpeg-dev zlib1g-dev \ libreoffice \
libxml2-dev libxslt-dev build-essential cargo \ ffmpeg \
&& rm -rf /var/lib/apt/lists/* 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 # Debug: Verify Rust installation
RUN rustc --version && cargo --version RUN rustc --version && cargo --version
# Copy application files # Verify LibreOffice and poppler-utils installation
COPY . /app RUN libreoffice --version && pdftoppm -v
# Copy entrypoint script and make it executable # Copy requirements first for better layer caching
COPY entrypoint.sh /entrypoint.sh COPY app/requirements.txt .
RUN chmod +x /entrypoint.sh
# Upgrade pip and install Python dependencies (using piwheels for ARM) # Upgrade pip and install Python dependencies
RUN python -m pip install --upgrade pip && \ 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 the application port
EXPOSE 5000 EXPOSE 5000
# Set environment variables
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
ENV PYTHONPATH=/app
# Use entrypoint script # 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.

380
app.py → app/app.py Normal file → Executable file
View File

@@ -1,17 +1,27 @@
import os import os
from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, send_from_directory import click
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 from flask_migrate import Migrate
import subprocess import subprocess
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from functools import wraps from functools import wraps
from extensions import db, bcrypt, login_manager from extensions import db, bcrypt, login_manager
from sqlalchemy import text
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# First import models # First import models
from models import User, Player, Content, Group, ServerLog from models import User, Player, Group, Content, ServerLog, group_player
# Then import utilities that use the models # Then import utilities that use the models
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from utils.logger import get_recent_logs, log_action, log_upload, log_process from utils.logger import get_recent_logs, log_action, log_upload, log_process, log_user_deleted, log_user_created
from utils.group_player_management import ( from utils.group_player_management import (
create_group as create_group_util, create_group as create_group_util,
edit_group as edit_group_util, edit_group as edit_group_util,
@@ -19,7 +29,12 @@ from utils.group_player_management import (
add_player as add_player_util, add_player as add_player_util,
edit_player as edit_player_util, edit_player as edit_player_util,
delete_player as delete_player_util, delete_player as delete_player_util,
get_group_content get_group_content,
get_player_content,
update_player_content_order,
update_group_content_order,
edit_group_media,
delete_group_media
) )
# Finally, import modules that depend on both models and logger # Finally, import modules that depend on both models and logger
@@ -32,16 +47,26 @@ from utils.uploads import (
) )
# Define global variables for server version and build date # Define global variables for server version and build date
SERVER_VERSION = "1.0.0" SERVER_VERSION = "1.1.0"
BUILD_DATE = "2025-06-25" 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 # 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') app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'Ana_Are_Multe_Mere-Si_Nu_Are_Pere')
# Configure the database location to be in the instance folder instance_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'instance'))
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(app.instance_path, 'dashboard.db') os.makedirs(instance_dir, exist_ok=True)
db_path = os.path.join(instance_dir, 'dashboard.db')
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Set maximum content length to 1GB # Set maximum content length to 1GB
@@ -49,6 +74,7 @@ app.config['MAX_CONTENT_LENGTH'] = 2048 * 2048 * 2048 # 2GB, adjust as needed
# Ensure the instance folder exists # Ensure the instance folder exists
os.makedirs(app.instance_path, exist_ok=True) os.makedirs(app.instance_path, exist_ok=True)
os.makedirs(instance_dir, exist_ok=True)
db.init_app(app) db.init_app(app)
bcrypt.init_app(app) bcrypt.init_app(app)
@@ -61,8 +87,9 @@ app.config['UPLOAD_FOLDERLOGO'] = UPLOAD_FOLDERLOGO
# Ensure the upload folder exists # Ensure the upload folder exists
if not os.path.exists(UPLOAD_FOLDER): if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER) os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(UPLOAD_FOLDERLOGO) if not os.path.exists(UPLOAD_FOLDERLOGO):
os.makedirs(UPLOAD_FOLDERLOGO, exist_ok=True)
login_manager.login_view = 'login' login_manager.login_view = 'login'
@@ -80,6 +107,52 @@ def admin_required(f):
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function 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('/') @app.route('/')
@login_required @login_required
def dashboard(): def dashboard():
@@ -152,12 +225,12 @@ def upload_content():
players = [{'id': player.id, 'username': player.username} for player in Player.query.all()] 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()] 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, 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') @app.route('/admin')
@login_required @login_required
@@ -166,13 +239,18 @@ def admin():
logo_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'logo.png')) 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')) login_picture_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'login_picture.png'))
users = User.query.all() users = User.query.all()
# Get system information for monitoring
system_info = get_system_info()
return render_template( return render_template(
'admin.html', 'admin.html',
users=users, users=users,
logo_exists=logo_exists, logo_exists=logo_exists,
login_picture_exists=login_picture_exists, login_picture_exists=login_picture_exists,
server_version=SERVER_VERSION, 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']) @app.route('/admin/change_role/<int:user_id>', methods=['POST'])
@@ -216,7 +294,7 @@ def create_user():
@login_required @login_required
def player_page(player_id): def player_page(player_id):
player = db.session.get(Player, player_id) player = db.session.get(Player, player_id)
content = Content.query.filter_by(player_id=player_id).all() content = get_player_content(player_id)
return render_template('player_page.html', player=player, content=content) return render_template('player_page.html', player=player, content=content)
@app.route('/player/<int:player_id>/upload', methods=['POST']) @app.route('/player/<int:player_id>/upload', methods=['POST'])
@@ -254,6 +332,61 @@ def delete_content(content_id):
db.session.commit() db.session.commit()
return redirect(url_for('player_page', player_id=player_id)) 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']) @app.route('/player/<int:player_id>/fullscreen', methods=['GET', 'POST'])
def player_fullscreen(player_id): def player_fullscreen(player_id):
player = Player.query.get_or_404(player_id) player = Player.query.get_or_404(player_id)
@@ -286,17 +419,7 @@ def player_fullscreen(player_id):
@login_required @login_required
@admin_required @admin_required
def delete_player(player_id): def delete_player(player_id):
player = Player.query.get_or_404(player_id) delete_player_util(player_id)
# Delete all media related to the player
media_items = Content.query.filter_by(player_id=player_id).all()
for media in media_items:
db.session.delete(media)
# Delete the player
db.session.delete(player)
db.session.commit()
return redirect(url_for('dashboard')) return redirect(url_for('dashboard'))
# Update the add_player function # Update the add_player function
@@ -309,7 +432,8 @@ def add_player():
hostname = request.form['hostname'] hostname = request.form['hostname']
password = bcrypt.generate_password_hash(request.form['password']).decode('utf-8') password = bcrypt.generate_password_hash(request.form['password']).decode('utf-8')
quickconnect_password = bcrypt.generate_password_hash(request.form['quickconnect_password']).decode('utf-8') quickconnect_password = bcrypt.generate_password_hash(request.form['quickconnect_password']).decode('utf-8')
add_player_util(username, hostname, password, quickconnect_password) orientation = request.form.get('orientation', 'Landscape') # <-- Get orientation
add_player_util(username, hostname, password, quickconnect_password, orientation) # <-- Pass orientation
flash(f'Player "{username}" added successfully.', 'success') flash(f'Player "{username}" added successfully.', 'success')
return redirect(url_for('dashboard')) return redirect(url_for('dashboard'))
return render_template('add_player.html') return render_template('add_player.html')
@@ -324,7 +448,8 @@ def edit_player(player_id):
hostname = request.form['hostname'] hostname = request.form['hostname']
password = request.form['password'] if request.form['password'] else None password = request.form['password'] if request.form['password'] else None
quickconnect_password = request.form['quickconnect_password'] if request.form['quickconnect_password'] else None quickconnect_password = request.form['quickconnect_password'] if request.form['quickconnect_password'] else None
edit_player_util(player_id, username, hostname, password, quickconnect_password) orientation = request.form.get('orientation', player.orientation) # <-- Get orientation
edit_player_util(player_id, username, hostname, password, quickconnect_password, orientation) # <-- Pass orientation
flash(f'Player "{username}" updated successfully.', 'success') flash(f'Player "{username}" updated successfully.', 'success')
return redirect(url_for('player_page', player_id=player.id)) return redirect(url_for('player_page', player_id=player.id))
@@ -400,11 +525,15 @@ def clean_unused_files():
print("Used files:", used_files) print("Used files:", used_files)
print("Unused files:", unused_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: 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): 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) os.remove(file_path)
flash('Unused files have been cleaned.', 'success') flash('Unused files have been cleaned.', 'success')
@@ -484,7 +613,8 @@ def create_group():
if request.method == 'POST': if request.method == 'POST':
group_name = request.form['name'] group_name = request.form['name']
player_ids = request.form.getlist('players') player_ids = request.form.getlist('players')
create_group_util(group_name, player_ids) orientation = request.form.get('orientation', 'Landscape')
create_group_util(group_name, player_ids, orientation)
flash(f'Group "{group_name}" created successfully.', 'success') flash(f'Group "{group_name}" created successfully.', 'success')
return redirect(url_for('dashboard')) return redirect(url_for('dashboard'))
players = Player.query.all() players = Player.query.all()
@@ -496,6 +626,10 @@ def create_group():
def manage_group(group_id): def manage_group(group_id):
group = Group.query.get_or_404(group_id) group = Group.query.get_or_404(group_id)
content = get_group_content(group_id) content = get_group_content(group_id)
# Debug content ordering
print("Group content positions before sorting:", [(c.id, c.file_name, c.position) for c in content])
content = sorted(content, key=lambda c: c.position)
print("Group content positions after sorting:", [(c.id, c.file_name, c.position) for c in content])
return render_template('manage_group.html', group=group, content=content) return render_template('manage_group.html', group=group, content=content)
@app.route('/group/<int:group_id>/edit', methods=['GET', 'POST']) @app.route('/group/<int:group_id>/edit', methods=['GET', 'POST'])
@@ -506,7 +640,8 @@ def edit_group(group_id):
if request.method == 'POST': if request.method == 'POST':
name = request.form['name'] name = request.form['name']
player_ids = request.form.getlist('players') player_ids = request.form.getlist('players')
edit_group_util(group_id, name, player_ids) orientation = request.form.get('orientation', group.orientation)
edit_group_util(group_id, name, player_ids, orientation)
flash(f'Group "{name}" updated successfully.', 'success') flash(f'Group "{name}" updated successfully.', 'success')
return redirect(url_for('dashboard')) return redirect(url_for('dashboard'))
players = Player.query.all() players = Player.query.all()
@@ -526,39 +661,91 @@ def delete_group(group_id):
@login_required @login_required
def group_fullscreen(group_id): def group_fullscreen(group_id):
group = Group.query.get_or_404(group_id) group = Group.query.get_or_404(group_id)
content = Content.query.filter(Content.player_id.in_([player.id for player in group.players])).all() content = Content.query.filter(Content.player_id.in_([player.id for player in group.players])).order_by(Content.position).all()
return render_template('group_fullscreen.html', group=group, content=content) return render_template('group_fullscreen.html', group=group, content=content)
@app.route('/group/<int:group_id>/media/<int:content_id>/edit', methods=['POST']) @app.route('/group/<int:group_id>/media/<int:content_id>/edit', methods=['POST'])
@login_required @login_required
@admin_required @admin_required
def edit_group_media(group_id, content_id): def edit_group_media_route(group_id, content_id):
group = Group.query.get_or_404(group_id)
new_duration = int(request.form['duration']) new_duration = int(request.form['duration'])
success = edit_group_media(group_id, content_id, new_duration)
# Update the duration for all players in the group if success:
for player in group.players: flash('Media duration updated successfully.', 'success')
content = Content.query.filter_by(player_id=player.id, file_name=Content.query.get(content_id).file_name).first() else:
if content: flash('Error updating media duration.', 'danger')
content.duration = new_duration
db.session.commit()
return redirect(url_for('manage_group', group_id=group_id)) return redirect(url_for('manage_group', group_id=group_id))
@app.route('/group/<int:group_id>/media/<int:content_id>/delete', methods=['POST']) @app.route('/group/<int:group_id>/media/<int:content_id>/delete', methods=['POST'])
@login_required @login_required
@admin_required @admin_required
def delete_group_media(group_id, content_id): def delete_group_media_route(group_id, content_id):
success = delete_group_media(group_id, content_id)
if success:
flash('Media deleted successfully.', 'success')
else:
flash('Error deleting media.', 'danger')
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) group = Group.query.get_or_404(group_id)
file_name = Content.query.get(content_id).file_name selected_content_ids = request.form.getlist('selected_content')
# Delete the media for all players in the group if not selected_content_ids:
for player in group.players: flash('No media files selected for deletion.', 'warning')
content = Content.query.filter_by(player_id=player.id, file_name=file_name).first() return redirect(url_for('manage_group', group_id=group_id))
if content:
db.session.delete(content) 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')
db.session.commit()
return redirect(url_for('manage_group', group_id=group_id)) return redirect(url_for('manage_group', group_id=group_id))
@app.route('/api/playlist_version', methods=['GET']) @app.route('/api/playlist_version', methods=['GET'])
@@ -581,5 +768,92 @@ def get_playlist_version():
'hashed_quickconnect': player.quickconnect_password '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):
if not request.is_json:
return jsonify({'success': False, 'error': 'Invalid request format'}), 400
player = Player.query.get_or_404(player_id)
if player.groups and current_user.role != 'admin':
return jsonify({'success': False, 'error': 'Cannot reorder playlist for players in groups'}), 403
items = request.json.get('items', [])
success, error, new_version = update_player_content_order(player_id, items)
if success:
return jsonify({'success': True, 'new_version': new_version})
else:
return jsonify({'success': False, 'error': error}), 500
@app.route('/group/<int:group_id>/update_order', methods=['POST'])
@login_required
@admin_required
def update_group_content_order_route(group_id):
if not request.is_json:
return jsonify({'success': False, 'error': 'Invalid request format'}), 400
items = request.json.get('items', [])
success, error = update_group_content_order(group_id, items)
if success:
return jsonify({'success': True})
else:
return jsonify({'success': False, 'error': error}), 500
@app.route('/debug/content_positions/<int:group_id>')
@login_required
@admin_required
def debug_content_positions(group_id):
group = Group.query.get_or_404(group_id)
player_ids = [p.id for p in group.players]
# Query directly with SQL to see positions
sql = text("SELECT id, file_name, position, player_id FROM content WHERE player_id IN :player_ids ORDER BY position")
result = db.session.execute(sql, {"player_ids": tuple(player_ids)})
content_data = [{"id": row.id, "file_name": row.file_name, "position": row.position, "player_id": row.player_id} for row in result]
return jsonify(content_data)
@app.cli.command("create-admin")
@click.option("--username", default="admin", help="Admin username")
@click.option("--password", help="Admin password")
def create_admin(username, password):
"""Create an admin user."""
from models import User
from extensions import bcrypt
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
user = User(username=username, password=hashed_password, role='admin')
db.session.add(user)
db.session.commit()
print(f"Admin user '{username}' created successfully.")
from models.create_default_user import create_default_user
if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
with app.app_context():
try:
db.session.execute(db.select(User).limit(1))
except Exception as e:
print("Database not initialized or missing tables. Re-initializing...")
db.create_all()
create_default_user(db, User, bcrypt)
# Add this at the end of app.py
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0') app.run(debug=True, host='0.0.0.0', port=5000)

29
app/entrypoint.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
set -e
# Create necessary directories
mkdir -p static/uploads static/resurse
mkdir -p instance
# Check if database exists
if [ ! -f instance/dashboard.db ]; then
echo "No database found, creating fresh database..."
# Create admin user if environment variables are set
if [ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASSWORD" ]; then
echo "Creating admin user: $ADMIN_USER"
flask create-admin --username "$ADMIN_USER" --password "$ADMIN_PASSWORD"
else
echo "Warning: ADMIN_USER or ADMIN_PASSWORD not set, skipping admin creation"
fi
else
echo "Existing database found, skipping initialization..."
echo "Creating admin user if needed..."
if [ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASSWORD" ]; then
flask create-admin --username "$ADMIN_USER" --password "$ADMIN_PASSWORD" 2>/dev/null || echo "Default user '$ADMIN_USER' already exists."
fi
fi
echo "Starting DigiServer..."
# Start the application
exec flask run --host=0.0.0.0

5
app/models/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .user import User
from .player import Player
from .group import Group, group_player
from .content import Content
from .server_log import ServerLog

21
app/models/clear_db.py Normal file
View File

@@ -0,0 +1,21 @@
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
# Ensure the instance directory exists (relative to project root)
instance_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'instance'))
os.makedirs(instance_dir, exist_ok=True)
# Set the correct database URI
db_path = os.path.join(instance_dir, 'dashboard.db')
print(f"Using database at: {db_path}")
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
with app.app_context():
db.reflect() # This loads all tables from the database
db.drop_all()
print("Dropped all tables successfully.")

8
app/models/content.py Normal file
View File

@@ -0,0 +1,8 @@
from extensions import db
class Content(db.Model):
id = db.Column(db.Integer, primary_key=True)
file_name = db.Column(db.String(255), nullable=False)
duration = db.Column(db.Integer, nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('player.id'), nullable=False)
position = db.Column(db.Integer, default=0)

View File

@@ -0,0 +1,18 @@
#from app import app, db, User, bcrypt
import os
def create_default_user(db, User, bcrypt):
username = os.getenv('DEFAULT_USER', 'admin')
password = os.getenv('DEFAULT_PASSWORD', '1234')
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
existing_user = User.query.filter_by(username=username).first()
if not existing_user:
default_user = User(username=username, password=hashed_password, role='admin')
db.session.add(default_user)
db.session.commit()
print(f"Default user '{username}' created with password '{password}'")
else:
print(f"Default user '{username}' already exists.")
#with app.app_context():
# create_default_user(db, User, bcrypt)

13
app/models/group.py Normal file
View File

@@ -0,0 +1,13 @@
from extensions import db
class Group(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
orientation = db.Column(db.String(16), nullable=False, default='Landscape') # <-- Add this line
players = db.relationship('Player', secondary='group_player', backref='groups')
playlist_version = db.Column(db.Integer, default=0)
group_player = db.Table('group_player',
db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True),
db.Column('player_id', db.Integer, db.ForeignKey('player.id'), primary_key=True)
)

18
app/models/player.py Normal file
View File

@@ -0,0 +1,18 @@
from extensions import db
from flask_bcrypt import Bcrypt
bcrypt = Bcrypt()
class Player(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(255), nullable=False)
hostname = db.Column(db.String(255), nullable=False)
password = db.Column(db.String(255), nullable=False)
quickconnect_password = db.Column(db.String(255), nullable=True)
playlist_version = db.Column(db.Integer, default=1)
locked_to_group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True)
locked_to_group = db.relationship('Group', foreign_keys=[locked_to_group_id], backref='locked_players')
orientation = db.Column(db.String(16), nullable=False, default='Landscape') # <-- Add this line
def verify_quickconnect_code(self, code):
return bcrypt.check_password_hash(self.quickconnect_password, code)

10
app/models/server_log.py Normal file
View File

@@ -0,0 +1,10 @@
from extensions import db
import datetime
class ServerLog(db.Model):
id = db.Column(db.Integer, primary_key=True)
action = db.Column(db.String(255), nullable=False)
timestamp = db.Column(db.DateTime, default=datetime.datetime.utcnow)
def __repr__(self):
return f"<ServerLog {self.action}>"

33
app/models/user.py Normal file
View File

@@ -0,0 +1,33 @@
from extensions import db
from flask_bcrypt import Bcrypt
from flask_login import UserMixin
bcrypt = Bcrypt()
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password = db.Column(db.String(120), nullable=False)
role = db.Column(db.String(80), nullable=False)
theme = db.Column(db.String(80), default='light')
def set_password(self, password):
self.password = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password):
return bcrypt.check_password_hash(self.password, password)
@property
def is_active(self):
return True
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return str(self.id)

46
app/requirements.txt Executable file
View File

@@ -0,0 +1,46 @@
# Core Flask
Flask==3.1.0
Werkzeug==3.1.3
Jinja2==3.1.5
itsdangerous==2.2.0
click==8.1.8
blinker==1.9.0
# Flask Extensions
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.1.0
Flask-Bcrypt==1.0.1
Flask-Login==0.6.3
# Database
SQLAlchemy==2.0.37
alembic==1.14.1
Mako==1.3.8
greenlet==3.1.1
# File Processing
pdf2image==1.17.0
PyPDF2==3.0.1
Pillow==10.0.1
cairosvg==2.7.0
ffmpeg-python==0.2.0
python-magic==0.4.27
# Security
bcrypt==4.2.1
Flask-Talisman==1.1.0
Flask-Cors==4.0.0
# Production Server
gunicorn==20.1.0
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
MarkupSafe==3.0.2
python-dotenv==1.0.1

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

@@ -60,6 +60,17 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="orientation" class="form-label">Orientation</label>
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
<option value="Landscape" selected>Landscape</option>
<option value="Portret">Portret</option>
</select>
</div>
</div>
</div>
<div class="text-center"> <div class="text-center">
<button type="submit" class="btn btn-primary">Add Player</button> <button type="submit" class="btn btn-primary">Add Player</button>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a> <a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>

View File

@@ -207,6 +207,94 @@
<p><strong>Date of Build:</strong> {{ build_date }}</p> <p><strong>Date of Build:</strong> {{ build_date }}</p>
</div> </div>
</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>
</div> </div>
@@ -227,6 +315,77 @@
popup.style.display = 'none'; popup.style.display = 'none';
}, 5000); }, 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> </script>
</body> </body>
</html> </html>

View File

@@ -49,11 +49,23 @@
</select> </select>
</div> </div>
</div> </div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="orientation" class="form-label">Group Orientation</label>
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
<option value="Landscape" selected>Landscape</option>
<option value="Portret">Portret</option>
</select>
</div>
</div>
</div> </div>
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
<strong>Warning:</strong> Adding players to a group will delete their individual playlists. <strong>Warning:</strong> Adding players to a group will delete their individual playlists.
All players in a group will share the same content. All players in a group will share the same content.
</div> </div>
<div id="orientation-warning" class="alert alert-danger d-none" role="alert">
No players with the selected orientation are available.
</div>
<div class="text-center"> <div class="text-center">
<button type="submit" class="btn btn-primary">Create Group</button> <button type="submit" class="btn btn-primary">Create Group</button>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a> <a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
@@ -61,5 +73,38 @@
</form> </form>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Get all players and their orientations from the backend
const players = [
{% for player in players %}
{id: {{ player.id }}, username: "{{ player.username }}", orientation: "{{ player.orientation }}"},
{% endfor %}
];
const orientationSelect = document.getElementById('orientation');
const playersSelect = document.getElementById('players');
const orientationWarning = document.getElementById('orientation-warning');
function filterPlayers() {
const selectedOrientation = orientationSelect.value;
playersSelect.innerHTML = '';
let compatibleCount = 0;
players.forEach(player => {
if (player.orientation === selectedOrientation) {
const option = document.createElement('option');
option.value = player.id;
option.textContent = player.username;
playersSelect.appendChild(option);
compatibleCount++;
}
});
document.getElementById('orientation-warning').classList.toggle('d-none', compatibleCount > 0);
}
orientationSelect.addEventListener('change', filterPlayers);
// Initial filter on page load
filterPlayers();
</script>
</body> </body>
</html> </html>

View File

@@ -49,12 +49,24 @@
</select> </select>
</div> </div>
</div> </div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="orientation" class="form-label">Group Orientation</label>
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
<option value="Landscape" {% if group.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
<option value="Portret" {% if group.orientation == 'Portret' %}selected{% endif %}>Portret</option>
</select>
</div>
</div>
</div> </div>
<!-- Add this above the player selection --> <!-- Add this above the player selection -->
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
<strong>Warning:</strong> Adding new players to this group will delete their individual playlists. <strong>Warning:</strong> Adding new players to this group will delete their individual playlists.
Removing players from the group will allow them to have their own playlists again. Removing players from the group will allow them to have their own playlists again.
</div> </div>
<div id="orientation-warning" class="alert alert-danger d-none" role="alert">
No players with the selected orientation are available.
</div>
<div class="text-center"> <div class="text-center">
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a> <a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
@@ -62,5 +74,45 @@
</form> </form>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Get all players and their orientations from the backend
const players = [
{% for player in players %}
{id: {{ player.id }}, username: "{{ player.username }}", orientation: "{{ player.orientation }}", inGroup: {% if player in group.players %}true{% else %}false{% endif %}},
{% endfor %}
];
const orientationSelect = document.getElementById('orientation');
const playersSelect = document.getElementById('players');
const orientationWarning = document.getElementById('orientation-warning');
function filterPlayers() {
const selectedOrientation = orientationSelect.value;
const currentSelection = Array.from(playersSelect.selectedOptions).map(option => option.value);
playersSelect.innerHTML = '';
let compatibleCount = 0;
players.forEach(player => {
if (player.orientation === selectedOrientation) {
const option = document.createElement('option');
option.value = player.id;
option.textContent = player.username;
// Re-select if it was previously selected
if (currentSelection.includes(player.id.toString()) || player.inGroup) {
option.selected = true;
}
playersSelect.appendChild(option);
compatibleCount++;
}
});
orientationWarning.classList.toggle('d-none', compatibleCount > 0);
}
orientationSelect.addEventListener('change', filterPlayers);
// Initial filter on page load
filterPlayers();
</script>
</body> </body>
</html> </html>

View File

@@ -60,6 +60,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mb-3">
<label for="orientation" class="form-label">Orientation</label>
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
<option value="Landscape" {% if player.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
<option value="Portret" {% if player.orientation == 'Portret' %}selected{% endif %}>Portret</option>
</select>
</div>
<div class="text-center"> <div class="text-center">
<button type="submit" class="btn btn-primary">Update Player</button> <button type="submit" class="btn btn-primary">Update Player</button>
<a href="{{ return_url }}" class="btn btn-secondary mt-3">Back to Player Page</a> <a href="{{ return_url }}" class="btn btn-secondary mt-3">Back to Player Page</a>

View File

@@ -0,0 +1,318 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage Group</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;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
.card {
margin-bottom: 1rem;
}
}
.sortable-list li {
cursor: move;
transition: background-color 0.2s ease;
}
.sortable-list li.dragging {
opacity: 0.5;
background-color: #f8f9fa;
}
.drag-handle {
cursor: grab;
color: #aaa;
font-size: 1.2rem;
}
.drag-over {
border-top: 2px solid #0d6efd;
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<h1 class="text-center mb-4">Manage Group: {{ group.name }}</h1>
<!-- Group Information Card -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-info text-white">
<h2>Group Info</h2>
</div>
<div class="card-body">
<p><strong>Group Name:</strong> {{ group.name }}</p>
<p><strong>Number of Players:</strong> {{ group.players|length }}</p>
</div>
</div>
<!-- List of Players in the Group -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-secondary text-white">
<h2>Players in Group</h2>
</div>
<div class="card-body">
<ul class="list-group">
{% for player in group.players %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ player.username }}</strong> ({{ player.hostname }})
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
<!-- Manage Media Section -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-info text-white">
<h2>Manage Media</h2>
</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>
&#9776;
</div>
<div class="flex-grow-1">
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
</div>
<form action="{{ url_for('edit_group_media', group_id=group.id, content_id=media.id) }}" method="post" class="d-flex align-items-center">
<div class="input-group me-2">
<span class="input-group-text">seconds</span>
<input type="number" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" name="duration" value="{{ media.duration }}" required>
</div>
<button type="submit" class="btn btn-warning me-2">Edit</button>
</form>
<form action="{{ url_for('delete_group_media', group_id=group.id, content_id=media.id) }}" method="post" style="display:inline;">
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
</form>
</li>
{% endfor %}
</ul>
<!-- Add a save button for the reordering -->
<button id="saveGroupOrder" class="btn btn-success mt-3">Save Playlist Order</button>
{% else %}
<p class="text-center">No media uploaded for this group.</p>
{% endif %}
</div>
</div>
<!-- Upload Media Button -->
<div class="text-center mb-4">
<a href="{{ url_for('upload_content', target_type='group', target_id=group.id, return_url=url_for('manage_group', group_id=group.id)) }}" class="btn btn-primary btn-lg">Go to Upload Media</a>
</div>
<!-- Back to Dashboard Button -->
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const groupMediaList = document.getElementById('groupMediaList');
let draggedItem = null;
// Initialize drag events for all items
const items = groupMediaList.querySelectorAll('li');
items.forEach(item => {
// Drag start
item.addEventListener('dragstart', function(e) {
draggedItem = item;
setTimeout(() => {
item.classList.add('dragging');
}, 0);
});
// Drag end
item.addEventListener('dragend', function() {
item.classList.remove('dragging');
draggedItem = null;
updatePositions();
});
// Drag over
item.addEventListener('dragover', function(e) {
e.preventDefault();
if (item !== draggedItem) {
const rect = item.getBoundingClientRect();
const y = e.clientY - rect.top;
const height = rect.height;
if (y < height / 2) {
groupMediaList.insertBefore(draggedItem, item);
} else {
groupMediaList.insertBefore(draggedItem, item.nextSibling);
}
}
});
});
// Save button click handler
document.getElementById('saveGroupOrder').addEventListener('click', function() {
// Collect new order
const newOrder = [];
groupMediaList.querySelectorAll('li').forEach((item, index) => {
newOrder.push({
id: item.dataset.id,
position: index
});
});
// Send to server
fetch('{{ url_for("update_group_content_order_route", group_id=group.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
},
body: JSON.stringify({items: newOrder})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Playlist order updated successfully!');
console.log('Group playlist update successful:', data);
} else {
alert('Error updating playlist order: ' + (data.error || 'Unknown error'));
console.error('Failed to update group playlist:', data);
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while updating the playlist order.');
});
});
// Update positions in the UI
function updatePositions() {
groupMediaList.querySelectorAll('li').forEach((item, index) => {
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

@@ -0,0 +1,343 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Player Schedule</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;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
.card {
margin-bottom: 1rem;
}
}
.sortable-list li {
cursor: move;
transition: background-color 0.2s ease;
}
.sortable-list li.dragging {
opacity: 0.5;
background-color: #f8f9fa;
}
.drag-handle {
cursor: grab;
color: #aaa;
font-size: 1.2rem;
}
.drag-over {
border-top: 2px solid #0d6efd;
}
</style>
</head>
<body class="{% if theme == 'dark' %}dark-mode{% endif %}">
<div class="container py-5">
<h1 class="text-center mb-4">Player Schedule for {{ player.username }}</h1>
<!-- Player Info Section -->
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
<div class="card-header bg-info text-white">
<h2>Player Info</h2>
</div>
<div class="card-body">
<p><strong>Player Name:</strong> {{ player.username }}</p>
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
{% if current_user.role == 'admin' %}
<a href="{{ url_for('edit_player', player_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}" class="btn btn-warning">Update</a>
<form action="{{ url_for('delete_player', player_id=player.id) }}" method="post" style="display:inline;">
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this player?');">Delete</button>
</form>
{% endif %}
</div>
</div>
<!-- Group Membership Section -->
<div class="mb-4">
{% if player.groups %}
<h4 class="text-center">Member of Group(s):</h4>
<ul class="list-group">
{% for group in player.groups %}
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}">{{ group.name }}</li>
{% endfor %}
</ul>
{% else %}
<p class="text-center">This player is not a member of any groups.</p>
{% endif %}
</div>
<!-- Media Management Section -->
{% if current_user.role == 'admin' %}
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
<div class="card-header bg-info text-white">
<h2>Manage Media</h2>
</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 %}"
draggable="true"
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>
&#9776;
</div>
<!-- Media Thumbnail and Name -->
<div class="flex-grow-1 mb-2 mb-md-0 d-flex align-items-center">
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
alt="thumbnail"
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
onerror="this.style.display='none';">
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
</div>
<!-- Actions -->
<div class="d-flex flex-wrap justify-content-start">
<form action="{{ url_for('edit_content', content_id=media.id) }}" method="post" class="d-flex align-items-center me-2 mb-2">
<div class="input-group">
<span class="input-group-text">seconds</span>
<input type="number" class="form-control {% if theme == 'dark' %}dark-mode{% endif %}" name="duration" value="{{ media.duration }}" {% if player.groups %}disabled{% endif %} required>
</div>
<button type="submit" class="btn btn-warning ms-2" {% if player.groups %}disabled{% endif %}>Edit</button>
</form>
<form action="{{ url_for('delete_content', content_id=media.id) }}" method="post" class="mb-2">
<button type="submit" class="btn btn-danger" {% if player.groups %}disabled{% endif %} onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
</form>
</div>
</div>
</li>
{% endfor %}
</ul>
<!-- Add a save button for the reordering -->
<button id="saveOrder" class="btn btn-success mt-3" {% if player.groups %}disabled{% endif %}>Save Playlist Order</button>
{% else %}
<p class="text-center">No media uploaded for this player.</p>
{% endif %}
</div>
</div>
{% endif %}
<!-- Action Buttons -->
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
<a href="{{ url_for('player_fullscreen', player_id=player.id) }}" class="btn btn-primary">Full Screen</a>
<a href="{{ url_for('upload_content', target_type='player', target_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}"
class="btn btn-success"
{% if player.groups %}disabled onclick="return false;"{% endif %}>
{% if player.groups %}Manage Media by Group{% else %}Upload Media{% endif %}
</a>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Only enable if the player is not in a group (if the buttons are not disabled)
if (!document.querySelector('#saveOrder').hasAttribute('disabled')) {
const mediaList = document.getElementById('mediaList');
let draggedItem = null;
// Initialize drag events for all items
const items = mediaList.querySelectorAll('li');
items.forEach(item => {
// Drag start
item.addEventListener('dragstart', function(e) {
draggedItem = item;
setTimeout(() => {
item.classList.add('dragging');
}, 0);
});
// Drag end
item.addEventListener('dragend', function() {
item.classList.remove('dragging');
draggedItem = null;
updatePositions();
});
// Drag over
item.addEventListener('dragover', function(e) {
e.preventDefault();
if (item !== draggedItem) {
const rect = item.getBoundingClientRect();
const y = e.clientY - rect.top;
const height = rect.height;
if (y < height / 2) {
mediaList.insertBefore(draggedItem, item);
} else {
mediaList.insertBefore(draggedItem, item.nextSibling);
}
}
});
});
// Save button click handler
document.getElementById('saveOrder').addEventListener('click', function() {
// Collect new order
const newOrder = [];
mediaList.querySelectorAll('li').forEach((item, index) => {
newOrder.push({
id: item.dataset.id,
position: index
});
});
// Send to server
fetch('{{ url_for("update_content_order", player_id=player.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
},
body: JSON.stringify({items: newOrder})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Playlist order updated successfully!');
console.log('Playlist version updated to:', data.new_version);
} else {
alert('Error updating playlist order: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while updating the playlist order.');
});
});
// Update positions in the UI
function updatePositions() {
mediaList.querySelectorAll('li').forEach((item, index) => {
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 => {
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

@@ -0,0 +1,385 @@
from models import Player, Group, Content
from extensions import db
from utils.logger import (
log_group_created, log_group_edited, log_group_deleted,
log_player_created, log_player_edited, log_player_deleted,
log_player_added_to_group, log_player_removed_from_group,
log_player_unlocked, log_content_reordered,
log_content_duration_changed, log_content_added
)
def create_group(name, player_ids, orientation='Landscape'):
"""
Create a new group with the given name, orientation, and add selected players.
Only players with the same orientation can be added.
"""
# Check all players have the same orientation
for player_id in player_ids:
player = Player.query.get(player_id)
if player and player.orientation != orientation:
raise ValueError(f"Player '{player.username}' has orientation '{player.orientation}', which does not match group orientation '{orientation}'.")
new_group = Group(name=name, orientation=orientation)
db.session.add(new_group)
db.session.flush() # Get the group ID
# Add players to the group and lock them
for player_id in player_ids:
player = Player.query.get(player_id)
if player:
new_group.players.append(player)
Content.query.filter_by(player_id=player.id).delete()
player.locked_to_group_id = new_group.id
db.session.commit()
log_group_created(name)
return new_group
def edit_group(group_id, name, player_ids, orientation=None):
"""
Edit an existing group, updating its name, orientation, and players.
Handles locking/unlocking players appropriately.
"""
group = Group.query.get_or_404(group_id)
old_name = group.name # Store old name in case it changes
group.name = name
# Update orientation if provided
if orientation:
group.orientation = orientation
# Validate that all selected players have the matching orientation
for player_id in player_ids:
player = Player.query.get(player_id)
if player and player.orientation != orientation:
raise ValueError(f"Player '{player.username}' has orientation '{player.orientation}', which does not match group orientation '{orientation}'.")
# Get current players in the group
current_player_ids = [player.id for player in group.players]
# Determine players to add and remove
players_to_add = [pid for pid in player_ids if pid not in current_player_ids]
players_to_remove = [pid for pid in current_player_ids if pid not in player_ids]
# Handle players to add
for player_id in players_to_add:
player = Player.query.get(player_id)
if player:
# Add to group
group.players.append(player)
# Delete individual playlist
Content.query.filter_by(player_id=player.id).delete()
# Lock to group
player.locked_to_group_id = group.id
# Log this action
log_player_added_to_group(player.username, name)
# Handle players to remove
for player_id in players_to_remove:
player = Player.query.get(player_id)
if player:
# Remove from group
group.players.remove(player)
# Unlock from group
player.locked_to_group_id = None
# Log this action
log_player_removed_from_group(player.username, name)
log_player_unlocked(player.username)
db.session.commit()
# Log the group edit
if old_name != name:
log_group_edited(f"{old_name}{name}")
else:
log_group_edited(name)
return group
def delete_group(group_id):
"""
Delete a group and unlock all associated players.
"""
group = Group.query.get_or_404(group_id)
group_name = group.name
# Unlock all players in the group
for player in group.players:
player.locked_to_group_id = None
log_player_unlocked(player.username)
db.session.delete(group)
db.session.commit()
log_group_deleted(group_name)
def add_player(username, hostname, password, quickconnect_password, orientation='Landscape'):
"""
Add a new player with the given details.
"""
from flask_bcrypt import Bcrypt
bcrypt = Bcrypt()
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
hashed_quickconnect = bcrypt.generate_password_hash(quickconnect_password).decode('utf-8')
new_player = Player(
username=username,
hostname=hostname,
password=hashed_password,
quickconnect_password=hashed_quickconnect,
orientation=orientation
)
db.session.add(new_player)
db.session.commit()
log_player_created(username, hostname)
return new_player
def edit_player(player_id, username, hostname, password=None, quickconnect_password=None, orientation=None):
"""
Edit an existing player's details.
"""
from flask_bcrypt import Bcrypt
bcrypt = Bcrypt()
player = Player.query.get_or_404(player_id)
player.username = username
player.hostname = hostname
if password:
player.password = bcrypt.generate_password_hash(password).decode('utf-8')
if quickconnect_password:
player.quickconnect_password = bcrypt.generate_password_hash(quickconnect_password).decode('utf-8')
if orientation:
player.orientation = orientation
db.session.commit()
log_player_edited(username)
return player
def delete_player(player_id):
"""
Delete a player and all its content.
"""
player = Player.query.get_or_404(player_id)
username = player.username
# Delete all media related to the player
Content.query.filter_by(player_id=player_id).delete()
# Delete the player
db.session.delete(player)
db.session.commit()
log_player_deleted(username)
def get_group_content(group_id):
"""
Get content for all players in a group, ordered by position.
"""
from models import Group, Content
group = Group.query.get_or_404(group_id)
# Get all player IDs in the group
player_ids = [player.id for player in group.players]
# Get unique content based on file_name, preserving position
unique_content = {}
# For each player, get their content
for player_id in player_ids:
# Get content for this player, ordered by position
player_content = Content.query.filter_by(player_id=player_id).order_by(Content.position).all()
for content in player_content:
if content.file_name not in unique_content:
unique_content[content.file_name] = content
# Sort the unique content by position
return sorted(unique_content.values(), key=lambda c: c.position)
def get_player_content(player_id):
"""
Get content for a specific player, ordered by position.
"""
from models import Content
return Content.query.filter_by(player_id=player_id).order_by(Content.position).all()
def update_player_content_order(player_id, items):
"""
Update the order of content items for a player.
Args:
player_id (int): ID of the player
items (list): List of items with id and position
Returns:
tuple: (success, error_message, new_version)
"""
from models import Player, Content
from extensions import db
player = Player.query.get_or_404(player_id)
try:
# Update the position field for each content item
for item in items:
content_id = int(item['id'])
position = int(item['position'])
content = Content.query.get_or_404(content_id)
if content.player_id != player_id:
continue # Skip if not for this player
content.position = position
# Force increment the playlist version to trigger client refresh
player.playlist_version = (player.playlist_version or 0) + 1
db.session.commit()
# Log the reordering action
log_content_reordered("player", player.username)
return True, None, player.playlist_version
except Exception as e:
db.session.rollback()
return False, str(e), None
def update_group_content_order(group_id, items):
"""
Update the order of content items for all players in a group.
Args:
group_id (int): ID of the group
items (list): List of items with id and position
Returns:
tuple: (success, error_message)
"""
from models import Group, Content
from extensions import db
group = Group.query.get_or_404(group_id)
try:
# Get file names corresponding to the content IDs
content_files = {}
for item in items:
content_id = int(item['id'])
position = int(item['position'])
content = Content.query.get_or_404(content_id)
content_files[content.file_name] = position
# Update all content items for all players in this group
for player in group.players:
for content in Content.query.filter_by(player_id=player.id).all():
if content.file_name in content_files:
content.position = content_files[content.file_name]
# Force increment the playlist version to trigger client refresh
player.playlist_version = (player.playlist_version or 0) + 1
db.session.commit()
# Log the reordering action
log_content_reordered("group", group.name)
return True, None
except Exception as e:
db.session.rollback()
return False, str(e)
def edit_group_media(group_id, content_id, new_duration):
"""
Update the duration for all instances of a media item across all players in a group.
Args:
group_id (int): ID of the group
content_id (int): ID of the content item
new_duration (int): New duration in seconds
Returns:
bool: Success or failure
"""
from models import Group, Content
from extensions import db
group = Group.query.get_or_404(group_id)
content = Content.query.get(content_id)
file_name = content.file_name
old_duration = content.duration
try:
# Update the duration for all players in the group
for player in group.players:
content = Content.query.filter_by(player_id=player.id, file_name=file_name).first()
if content:
content.duration = new_duration
db.session.commit()
# Log the duration change
log_content_duration_changed(file_name, old_duration, new_duration, "group", group.name)
return True
except Exception as e:
db.session.rollback()
return False
def delete_group_media(group_id, content_id):
"""
Delete a media item from all players in a group and remove the physical file.
Args:
group_id (int): ID of the group
content_id (int): ID of the content item
Returns:
bool: Success or failure
"""
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)
file_name = content.file_name
try:
# Delete the media for all players in the group
count = 0
for player in group.players:
content = Content.query.filter_by(player_id=player.id, file_name=file_name).first()
if content:
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
log_content_deleted(file_name, "group", group.name)
return True
except Exception as e:
db.session.rollback()
print(f"Error in delete_group_media: {e}")
return False

View File

@@ -62,4 +62,23 @@ def log_settings_changed(setting_name):
log_action(f"Setting '{setting_name}' was changed") log_action(f"Setting '{setting_name}' was changed")
def log_files_cleaned(count): def log_files_cleaned(count):
log_action(f"{count} unused files were cleaned from storage") log_action(f"{count} unused files were cleaned from storage")
# New logging functions for more detailed activities
def log_player_added_to_group(player_name, group_name):
log_action(f"Player '{player_name}' was added to group '{group_name}'")
def log_player_removed_from_group(player_name, group_name):
log_action(f"Player '{player_name}' was removed from group '{group_name}'")
def log_player_unlocked(player_name):
log_action(f"Player '{player_name}' was unlocked from its group")
def log_content_reordered(target_type, target_name):
log_action(f"Content for {target_type} '{target_name}' was reordered")
def log_content_duration_changed(content_name, old_duration, new_duration, target_type, target_name):
log_action(f"Duration for '{content_name}' changed from {old_duration}s to {new_duration}s in {target_type} '{target_name}'")
def log_content_added(content_name, target_type, target_name):
log_action(f"Content '{content_name}' added to {target_type} '{target_name}'")

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

@@ -5,17 +5,32 @@ from werkzeug.utils import secure_filename
from pdf2image import convert_from_path from pdf2image import convert_from_path
from extensions import db from extensions import db
from models import Content, Player, Group from models import Content, Player, Group
from utils.logger import log_upload, log_process from utils.logger import log_content_added, log_upload, log_process
# Function to add image to playlist # Function to add image to playlist
def add_image_to_playlist(app, file, filename, duration, target_type, target_id): def add_image_to_playlist(app, file, filename, duration, target_type, target_id):
""" """
Save the image file and add it to the playlist database. 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 # Only save if file does not already exist
if not os.path.exists(file_path): if not os.path.exists(file_path):
file.save(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}") print(f"Adding image to playlist: {filename}, Target Type: {target_type}, Target ID: {target_id}")
@@ -24,35 +39,35 @@ def add_image_to_playlist(app, file, filename, duration, target_type, target_id)
for player in group.players: for player in group.players:
new_content = Content(file_name=filename, duration=duration, player_id=player.id) new_content = Content(file_name=filename, duration=duration, player_id=player.id)
db.session.add(new_content) db.session.add(new_content)
player.playlist_version += 1 log_content_added(filename, target_type, group.name)
group.playlist_version += 1
# Log the action
log_upload('image', filename, 'group', group.name)
elif target_type == 'player': elif target_type == 'player':
player = Player.query.get_or_404(target_id) player = Player.query.get_or_404(target_id)
new_content = Content(file_name=filename, duration=duration, player_id=target_id) new_content = Content(file_name=filename, duration=duration, player_id=target_id)
db.session.add(new_content) db.session.add(new_content)
player.playlist_version += 1 log_content_added(filename, target_type, player.username)
# Log the action
log_upload('image', filename, 'player', player.username)
db.session.commit() db.session.commit()
log_upload('image', filename, target_type, target_id)
return True
# Video conversion functions # Video conversion functions
def convert_video(input_file, output_folder): def convert_video(input_file, output_folder):
""" Converts a video file to MP4 format with H.264 codec, 720p resolution, and 30 FPS.
Args:
input_file (str): Path to the input video file.
output_folder (str): Path to the folder where the converted video will be saved.
Returns:
str: Path to the converted video file, or None if conversion fails.
""" """
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): 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 # Generate the output file path
base_name = os.path.splitext(os.path.basename(input_file))[0] base_name = os.path.splitext(os.path.basename(input_file))[0]
output_file = os.path.join(output_folder, f"{base_name}.mp4") 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 # FFmpeg command to convert the video
command = [ command = [
@@ -78,8 +93,18 @@ def convert_video(input_file, output_folder):
return None return None
def convert_video_and_update_playlist(app, file_path, original_filename, target_type, target_id, duration): def convert_video_and_update_playlist(app, file_path, original_filename, target_type, target_id, duration):
"""
Converts a video and updates the playlist database.
"""
print(f"Starting video conversion for: {file_path}") 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: if converted_file:
converted_filename = os.path.basename(converted_file) converted_filename = os.path.basename(converted_file)
print(f"Video converted successfully: {converted_filename}") print(f"Video converted successfully: {converted_filename}")
@@ -109,40 +134,79 @@ def convert_video_and_update_playlist(app, file_path, original_filename, target_
print(f"Video conversion failed for: {file_path}") print(f"Video conversion failed for: {file_path}")
# PDF conversion functions # PDF conversion functions
def convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True): def convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True, dpi=300):
""" """
Convert a PDF file to images. Convert a PDF file to high-quality JPG images in sequential order.
Uses standard 300 DPI for reliable conversion.
"""
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}")
Args:
pdf_file (str): Path to the PDF file
output_folder (str): Path to save the images
delete_pdf (bool): Whether to delete the PDF file after processing
Returns:
list: List of generated image filenames, or empty list if conversion failed
"""
print(f"Converting PDF to images: {pdf_file}")
try: try:
images = convert_from_path(pdf_file, dpi=300) # Ensure output folder exists
print(f"Number of pages in PDF: {len(images)}") 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] base_name = os.path.splitext(os.path.basename(pdf_file))[0]
image_filenames = [] image_filenames = []
# Save each page as JPG image
for i, image in enumerate(images): for i, image in enumerate(images):
image_filename = f"{base_name}_page_{i + 1}.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_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) image_filenames.append(image_filename)
print(f"Saved page {i + 1} as image: {image_path}") print(f"Saved page {i + 1} to: {image_path}")
# Delete the PDF file if requested print(f"PDF conversion complete. {len(image_filenames)} JPG images saved to {output_folder}")
if delete_pdf and os.path.exists(pdf_file):
# 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) os.remove(pdf_file)
print(f"PDF file deleted: {pdf_file}") print(f"PDF file deleted: {pdf_file}")
return image_filenames return image_filenames
except Exception as e: 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 [] return []
def update_playlist_with_files(image_filenames, duration, target_type, target_id): def update_playlist_with_files(image_filenames, duration, target_type, target_id):
@@ -199,21 +263,35 @@ def process_pdf(input_file, output_folder, duration, target_type, target_id):
Returns: Returns:
bool: True if successful, False otherwise 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 # Ensure output folder exists
if not os.path.exists(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}")
# Convert PDF to images # Convert PDF to images using standard quality (delete PDF after successful conversion)
image_filenames = convert_pdf_to_images(input_file, output_folder) image_filenames = convert_pdf_to_images(input_file, output_folder, delete_pdf=True, dpi=300)
# Update playlist with generated images # Update playlist with generated images
if image_filenames: if image_filenames:
return update_playlist_with_files(image_filenames, duration, target_type, target_id) success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
return False 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): 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. Process a PPTX file: convert to PDF first, then to JPG images (same workflow as PDF).
Args: Args:
input_file (str): Path to the PPTX file input_file (str): Path to the PPTX file
@@ -225,43 +303,55 @@ def process_pptx(input_file, output_folder, duration, target_type, target_id):
Returns: Returns:
bool: True if successful, False otherwise 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 # Ensure output folder exists
if not os.path.exists(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}")
# 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,
input_file
]
print(f"Running LibreOffice command: {' '.join(command)}")
try: try:
result = subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # Step 1: Convert PPTX to PDF using LibreOffice for vector quality
print(f"PPTX file converted to PDF: {pdf_file}") 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 if not pdf_file:
image_filenames = convert_pdf_to_images(pdf_file, output_folder, True) print("Error: Failed to convert PPTX to PDF")
return False
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
if image_filenames and os.path.exists(input_file): # 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) os.remove(input_file)
print(f"Original PPTX file deleted: {input_file}") print(f"Original PPTX file deleted: {input_file}")
# Step 4: Update playlist with generated images # Step 4: Update playlist with generated images in sequential order
if image_filenames: success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
return update_playlist_with_files(image_filenames, duration, target_type, target_id) if success:
return False 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: except Exception as e:
print(f"Error processing PPTX file: {e}") print(f"Error processing PPTX file: {e}")
import traceback
traceback.print_exc()
return False return False
def process_uploaded_files(app, files, media_type, duration, target_type, target_id): def process_uploaded_files(app, files, media_type, duration, target_type, target_id):
@@ -286,8 +376,20 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target
try: try:
# Generate a secure filename and save the file # Generate a secure filename and save the file
filename = secure_filename(file.filename) 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) file.save(file_path)
print(f"File saved to: {file_path}")
print(f"Processing file: {filename}, Media Type: {media_type}") print(f"Processing file: {filename}, Media Type: {media_type}")
result = {'filename': filename, 'success': True, 'message': ''} result = {'filename': filename, 'success': True, 'message': ''}
@@ -295,7 +397,7 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target
if media_type == 'image': if media_type == 'image':
add_image_to_playlist(app, file, filename, duration, target_type, target_id) add_image_to_playlist(app, file, filename, duration, target_type, target_id)
result['message'] = f"Image {filename} added to playlist" result['message'] = f"Image {filename} added to playlist"
log_upload('image', filename, target_type, target_name) log_upload('image', filename, target_type, target_id)
elif media_type == 'video': elif media_type == 'video':
# For videos, add to playlist then start conversion in background # For videos, add to playlist then start conversion in background
@@ -313,31 +415,31 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target
player.playlist_version += 1 player.playlist_version += 1
db.session.commit() db.session.commit()
# Start background conversion # Start background conversion using absolute path
import threading import threading
threading.Thread(target=convert_video_and_update_playlist, threading.Thread(target=convert_video_and_update_playlist,
args=(app, file_path, filename, target_type, target_id, duration)).start() args=(app, file_path, filename, target_type, target_id, duration)).start()
result['message'] = f"Video {filename} added to playlist and being processed" result['message'] = f"Video {filename} added to playlist and being processed"
log_upload('video', filename, target_type, target_name) log_upload('video', filename, target_type, target_id)
elif media_type == 'pdf': elif media_type == 'pdf':
# For PDFs, convert to images and update playlist # For PDFs, convert to images and update playlist using absolute path
success = process_pdf(file_path, app.config['UPLOAD_FOLDER'], success = process_pdf(file_path, upload_folder,
duration, target_type, target_id) duration, target_type, target_id)
if success: if success:
result['message'] = f"PDF {filename} processed successfully" result['message'] = f"PDF {filename} processed successfully"
log_process('pdf', filename, target_type, target_name) log_process('pdf', filename, target_type, target_id)
else: else:
result['success'] = False result['success'] = False
result['message'] = f"Error processing PDF file: {filename}" result['message'] = f"Error processing PDF file: {filename}"
elif media_type == 'ppt': elif media_type == 'ppt':
# For PPT/PPTX, convert to PDF, then to images, and update playlist # For PPT/PPTX, convert to PDF, then to images, and update playlist using absolute path
success = process_pptx(file_path, app.config['UPLOAD_FOLDER'], success = process_pptx(file_path, upload_folder,
duration, target_type, target_id) duration, target_type, target_id)
if success: if success:
result['message'] = f"PowerPoint {filename} processed successfully" result['message'] = f"PowerPoint {filename} processed successfully"
log_process('ppt', filename, target_type, target_name) log_process('ppt', filename, target_type, target_id)
else: else:
result['success'] = False result['success'] = False
result['message'] = f"Error processing PowerPoint file: {filename}" result['message'] = f"Error processing PowerPoint file: {filename}"

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"

View File

@@ -1,14 +0,0 @@
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
# Create a minimal Flask app just for clearing the database
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///instance/dashboard.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
with app.app_context():
db.reflect() # This loads all tables from the database
db.drop_all()
print("Dropped all tables successfully.")

View File

@@ -1,20 +0,0 @@
from app import app, db, User, bcrypt
# Create the default user
username = 'admin'
password = '1234'
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
with app.app_context():
# Delete the existing user if it exists
existing_user = User.query.filter_by(username=username).first()
if existing_user:
db.session.delete(existing_user)
db.session.commit()
# Add the new user to the database
default_user = User(username=username, password=hashed_password, role='admin')
db.session.add(default_user)
db.session.commit()
print(f"Default user '{username}' created with password '{password}'")

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

30
docker-compose.yml Normal file → Executable file
View File

@@ -1,15 +1,35 @@
# DigiServer - Digital Signage Management Platform
# Version: 1.1.0
# Build Date: 2025-06-29
services: services:
web: digiserver:
build: .
image: digiserver:latest image: digiserver:latest
container_name: digiserver
ports: ports:
- "8880:5000" - "8880:5000"
environment: environment:
- FLASK_APP=app.py - FLASK_APP=app.py
- FLASK_RUN_HOST=0.0.0.0 - FLASK_RUN_HOST=0.0.0.0
- ADMIN_USER=admin - DEFAULT_USER=admin
- ADMIN_PASSWORD=Initial01! - DEFAULT_PASSWORD=Initial01!
- SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana - SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
volumes: volumes:
- /home/pi/Desktop/digi-server/db:/app/instance # Persistent data volumes
- /home/pi/Desktop/digi-server/static:/app/static/uploads - ./data/instance:/app/instance
- ./data/uploads:/app/static/uploads
- ./data/resurse:/app/static/resurse
restart: unless-stopped 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,11 +0,0 @@
#!/bin/sh
# filepath: /home/ske087/digiserver/entrypoint.sh
# Initialize the database if it doesn't exist
if [ ! -f "/app/instance/dashboard.db" ]; then
echo "Initializing database..."
python init_db.py
fi
# Start Gunicorn
exec gunicorn -w 4 -b 0.0.0.0:5000 app:app

View File

@@ -1,15 +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

Binary file not shown.

View File

@@ -1,76 +0,0 @@
from extensions import db
from flask_bcrypt import Bcrypt
from flask_login import UserMixin
import datetime # Add this import
bcrypt = Bcrypt()
# Add this new model
class ServerLog(db.Model):
id = db.Column(db.Integer, primary_key=True)
action = db.Column(db.String(255), nullable=False)
timestamp = db.Column(db.DateTime, default=datetime.datetime.utcnow)
def __repr__(self):
return f"<ServerLog {self.action}>"
class Content(db.Model):
id = db.Column(db.Integer, primary_key=True)
file_name = db.Column(db.String(120), nullable=False)
duration = db.Column(db.Integer, nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('player.id'), nullable=True)
class Player(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), nullable=False, unique=True)
hostname = db.Column(db.String(100), nullable=False)
password = db.Column(db.String(200), nullable=False)
quickconnect_password = db.Column(db.String(200), nullable=True) # Add this field
playlist_version = db.Column(db.Integer, default=0) # Playlist version counter
locked_to_group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True)
locked_to_group = db.relationship('Group', foreign_keys=[locked_to_group_id], backref='locked_players')
def verify_quickconnect_code(self, code):
return bcrypt.check_password_hash(self.quickconnect_password, code)
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password = db.Column(db.String(120), nullable=False)
role = db.Column(db.String(80), nullable=False)
theme = db.Column(db.String(80), default='light')
def set_password(self, password):
self.password = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password):
return bcrypt.check_password_hash(self.password, password)
@property
def is_active(self):
return True
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return str(self.id)
class Group(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
players = db.relationship('Player', secondary='group_player', backref='groups')
playlist_version = db.Column(db.Integer, default=0) # Playlist version counter
# Association table for many-to-many relationship between Group and Player
group_player = db.Table('group_player',
db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True),
db.Column('player_id', db.Integer, db.ForeignKey('player.id'), primary_key=True)
)
# other models...

30
ppt
View File

@@ -1,30 +0,0 @@
def convert_ppt_to_images(input_file, output_folder):
"""
Converts a PowerPoint file (.ppt or .pptx) to images using LibreOffice.
Each slide is saved as a separate image in the output folder.
"""
if not os.path.exists(output_folder):
os.makedirs(output_folder)
# Convert the PowerPoint file to images using LibreOffice
command = [
'libreoffice',
'--headless',
'--convert-to', 'png',
'--outdir', output_folder,
input_file
]
try:
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
print(f"PPT file converted to images: {input_file}")
except subprocess.CalledProcessError as e:
print(f"Error converting PPT to images: {e.stderr.decode()}")
return False
# Rename the generated images to follow the naming convention
base_name = os.path.splitext(os.path.basename(input_file))[0]
for i, file_name in enumerate(sorted(os.listdir(output_folder))):
if file_name.endswith('.png'):
new_name = f"{base_name}_{i + 1}.png"
os.rename(os.path.join(output_folder, file_name), os.path.join(output_folder, new_name))
return True

View File

@@ -1,21 +0,0 @@
alembic==1.14.1
bcrypt==4.2.1
blinker==1.9.0
click==8.1.8
Flask==3.1.0
Flask-Bcrypt==1.0.1
Flask-Login==0.6.3
Flask-Migrate==4.1.0
Flask-SQLAlchemy==3.1.1
greenlet==3.1.1
itsdangerous==2.2.0
Jinja2==3.1.5
Mako==1.3.8
MarkupSafe==3.0.2
SQLAlchemy==2.0.37
typing_extensions==4.12.2
Werkzeug==3.1.3
gunicorn==20.1.0
pdf2image==1.17.0
python-pptx==0.6.21
cairosvg==2.7.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 KiB

View File

@@ -1,110 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage Group</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;
}
@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">
<h1 class="text-center mb-4">Manage Group: {{ group.name }}</h1>
<!-- Group Information Card -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-info text-white">
<h2>Group Info</h2>
</div>
<div class="card-body">
<p><strong>Group Name:</strong> {{ group.name }}</p>
<p><strong>Number of Players:</strong> {{ group.players|length }}</p>
</div>
</div>
<!-- List of Players in the Group -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-secondary text-white">
<h2>Players in Group</h2>
</div>
<div class="card-body">
<ul class="list-group">
{% for player in group.players %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ player.username }}</strong> ({{ player.hostname }})
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
<!-- Manage Media Section -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-info text-white">
<h2>Manage Media</h2>
</div>
<div class="card-body">
{% if content %}
<ul class="list-group">
{% for media in content %}
<li class="list-group-item d-flex align-items-center {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="flex-grow-1">
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
</div>
<form action="{{ url_for('edit_group_media', group_id=group.id, content_id=media.id) }}" method="post" class="d-flex align-items-center">
<div class="input-group me-2">
<span class="input-group-text">seconds</span>
<input type="number" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" name="duration" value="{{ media.duration }}" required>
</div>
<button type="submit" class="btn btn-warning me-2">Edit</button>
</form>
<form action="{{ url_for('delete_group_media', group_id=group.id, content_id=media.id) }}" method="post" style="display:inline;">
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-center">No media uploaded for this group.</p>
{% endif %}
</div>
</div>
<!-- Upload Media Button -->
<div class="text-center mb-4">
<a href="{{ url_for('upload_content', target_type='group', target_id=group.id, return_url=url_for('manage_group', group_id=group.id)) }}" class="btn btn-primary btn-lg">Go to Upload Media</a>
</div>
<!-- Back to Dashboard Button -->
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -1,121 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Player Schedule</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;
}
@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="{% if theme == 'dark' %}dark-mode{% endif %}">
<div class="container py-5">
<h1 class="text-center mb-4">Player Schedule for {{ player.username }}</h1>
<!-- Player Info Section -->
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
<div class="card-header bg-info text-white">
<h2>Player Info</h2>
</div>
<div class="card-body">
<p><strong>Player Name:</strong> {{ player.username }}</p>
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
{% if current_user.role == 'admin' %}
<a href="{{ url_for('edit_player', player_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}" class="btn btn-warning">Update</a>
<form action="{{ url_for('delete_player', player_id=player.id) }}" method="post" style="display:inline;">
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this player?');">Delete</button>
</form>
{% endif %}
</div>
</div>
<!-- Group Membership Section -->
<div class="mb-4">
{% if player.groups %}
<h4 class="text-center">Member of Group(s):</h4>
<ul class="list-group">
{% for group in player.groups %}
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}">{{ group.name }}</li>
{% endfor %}
</ul>
{% else %}
<p class="text-center">This player is not a member of any groups.</p>
{% endif %}
</div>
<!-- Media Management Section -->
{% if current_user.role == 'admin' %}
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
<div class="card-header bg-info text-white">
<h2>Manage Media</h2>
</div>
<div class="card-body">
{% if content %}
<ul class="list-group">
{% for media in content %}
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}">
<div class="d-flex flex-column flex-md-row align-items-md-center">
<!-- Media Name -->
<div class="flex-grow-1 mb-2 mb-md-0">
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
</div>
<!-- Actions -->
<div class="d-flex flex-wrap justify-content-start">
<form action="{{ url_for('edit_content', content_id=media.id) }}" method="post" class="d-flex align-items-center me-2 mb-2">
<div class="input-group">
<span class="input-group-text">seconds</span>
<input type="number" class="form-control {% if theme == 'dark' %}dark-mode{% endif %}" name="duration" value="{{ media.duration }}" {% if player.groups %}disabled{% endif %} required>
</div>
<button type="submit" class="btn btn-warning ms-2" {% if player.groups %}disabled{% endif %}>Edit</button>
</form>
<form action="{{ url_for('delete_content', content_id=media.id) }}" method="post" class="mb-2">
<button type="submit" class="btn btn-danger" {% if player.groups %}disabled{% endif %} onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
</form>
</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-center">No media uploaded for this player.</p>
{% endif %}
</div>
</div>
{% endif %}
<!-- Action Buttons -->
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
<a href="{{ url_for('player_fullscreen', player_id=player.id) }}" class="btn btn-primary">Full Screen</a>
<a href="{{ url_for('upload_content', target_type='player', target_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}"
class="btn btn-success"
{% if player.groups %}disabled onclick="return false;"{% endif %}>
{% if player.groups %}Manage Media by Group{% else %}Upload Media{% endif %}
</a>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

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>

View File

@@ -1,60 +0,0 @@
import requests
import os
# Replace with the actual server IP address or domain name, hostname, and quick connect code
server_ip = 'http://localhost:5000'
hostname = 'rpi-tv11'
quickconnect_code = '8887779'
# Construct the URL for the playlist API
url = f'{server_ip}/api/playlists'
params = {
'hostname': hostname,
'quickconnect_code': quickconnect_code
}
# Make the GET request to the API
response = requests.get(url, params=params)
# Print the raw response content and status code for debugging
print(f'Status Code: {response.status_code}')
print(f'Response Content: {response.text}')
# Check if the request was successful
if response.status_code == 200:
try:
# Parse the JSON response
response_data = response.json()
playlist = response_data.get('playlist', [])
playlist_version = response_data.get('playlist_version', None)
print(f'Playlist Version: {playlist_version}')
print(f'Playlist: {playlist}')
# Define the local folder for saving files
local_folder = './static/resurse'
if not os.path.exists(local_folder):
os.makedirs(local_folder)
# Download each file in the playlist
for media in playlist:
file_name = media.get('file_name', '')
file_url = media.get('url', '')
duration = media.get('duration', 10) # Default duration if not provided
local_file_path = os.path.join(local_folder, file_name)
print(f'Downloading {file_name} from {file_url}...')
try:
file_response = requests.get(file_url, timeout=10)
if file_response.status_code == 200:
with open(local_file_path, 'wb') as file:
file.write(file_response.content)
print(f'Successfully downloaded {file_name} to {local_file_path}')
else:
print(f'Failed to download {file_name}. Status Code: {file_response.status_code}')
except requests.exceptions.RequestException as e:
print(f'Error downloading {file_name}: {e}')
except requests.exceptions.JSONDecodeError as e:
print(f'Failed to parse JSON response: {e}')
else:
print(f'Failed to retrieve playlist. Status Code: {response.status_code}')

View File

@@ -1,161 +0,0 @@
from models import Player, Group, Content
from extensions import db
from utils.logger import log_group_created, log_group_edited, log_group_deleted
from utils.logger import log_player_created, log_player_edited, log_player_deleted
def create_group(name, player_ids):
"""
Create a new group with the given name and add selected players to it.
Clears individual playlists of players and locks them to the group.
"""
new_group = Group(name=name)
db.session.add(new_group)
db.session.flush() # Get the group ID
# Add players to the group and lock them
for player_id in player_ids:
player = Player.query.get(player_id)
if player:
# Add player to group
new_group.players.append(player)
# Delete player's individual playlist
Content.query.filter_by(player_id=player.id).delete()
# Lock player to this group
player.locked_to_group_id = new_group.id
db.session.commit()
log_group_created(name)
return new_group
def edit_group(group_id, name, player_ids):
"""
Edit an existing group, updating its name and players.
Handles locking/unlocking players appropriately.
"""
group = Group.query.get_or_404(group_id)
group.name = name
# Get current players in the group
current_player_ids = [player.id for player in group.players]
# Determine players to add and remove
players_to_add = [pid for pid in player_ids if pid not in current_player_ids]
players_to_remove = [pid for pid in current_player_ids if pid not in player_ids]
# Handle players to add
for player_id in players_to_add:
player = Player.query.get(player_id)
if player:
# Add to group
group.players.append(player)
# Delete individual playlist
Content.query.filter_by(player_id=player.id).delete()
# Lock to group
player.locked_to_group_id = group.id
# Handle players to remove
for player_id in players_to_remove:
player = Player.query.get(player_id)
if player:
# Remove from group
group.players.remove(player)
# Unlock from group
player.locked_to_group_id = None
db.session.commit()
log_group_edited(group.name)
return group
def delete_group(group_id):
"""
Delete a group and unlock all associated players.
"""
group = Group.query.get_or_404(group_id)
group_name = group.name
# Unlock all players in the group
for player in group.players:
player.locked_to_group_id = None
db.session.delete(group)
db.session.commit()
log_group_deleted(group_name)
def add_player(username, hostname, password, quickconnect_password):
"""
Add a new player with the given details.
"""
from flask_bcrypt import Bcrypt
bcrypt = Bcrypt()
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
hashed_quickconnect = bcrypt.generate_password_hash(quickconnect_password).decode('utf-8')
new_player = Player(
username=username,
hostname=hostname,
password=hashed_password,
quickconnect_password=hashed_quickconnect
)
db.session.add(new_player)
db.session.commit()
log_player_created(username, hostname)
return new_player
def edit_player(player_id, username, hostname, password=None, quickconnect_password=None):
"""
Edit an existing player's details.
"""
from flask_bcrypt import Bcrypt
bcrypt = Bcrypt()
player = Player.query.get_or_404(player_id)
player.username = username
player.hostname = hostname
if password:
player.password = bcrypt.generate_password_hash(password).decode('utf-8')
if quickconnect_password:
player.quickconnect_password = bcrypt.generate_password_hash(quickconnect_password).decode('utf-8')
db.session.commit()
log_player_edited(username)
return player
def delete_player(player_id):
"""
Delete a player and all its content.
"""
player = Player.query.get_or_404(player_id)
username = player.username
# Delete all media related to the player
Content.query.filter_by(player_id=player_id).delete()
# Delete the player
db.session.delete(player)
db.session.commit()
log_player_deleted(username)
def get_group_content(group_id):
"""
Get unique content for a group.
"""
group = Group.query.get_or_404(group_id)
# Get unique media files for the group
content = (
db.session.query(Content.id, Content.file_name, db.func.min(Content.duration).label('duration'))
.filter(Content.player_id.in_([player.id for player in group.players]))
.group_by(Content.file_name)
.all()
)
return content