feat: v1.1.0 - Production-Ready Docker Deployment
🚀 Major Release: DigiServer v1.1.0 Production Deployment ## 📁 Project Restructure - Moved all application code to app/ directory for Docker containerization - Centralized persistent data in data/ directory with volume mounting - Removed development artifacts and cleaned up project structure ## 🐳 Docker Integration - Added production-ready Dockerfile with LibreOffice and poppler-utils - Updated docker-compose.yml for production deployment - Added .dockerignore for optimized build context - Created automated deployment script (deploy-docker.sh) - Added cleanup script (cleanup-docker.sh) ## 📄 Document Processing Enhancements - Integrated LibreOffice for professional PPTX to PDF conversion - Implemented PPTX → PDF → 4K JPG workflow for optimal quality - Added poppler-utils for enhanced PDF processing - Simplified PDF conversion to 300 DPI for reliability ## 🔧 File Management Improvements - Fixed absolute path resolution for containerized deployment - Updated all file deletion functions with proper path handling - Enhanced bulk delete functions for players and groups - Improved file upload workflow with consistent path management ## 🛠️ Code Quality & Stability - Cleaned up pptx_converter.py from 442 to 86 lines - Removed all Python cache files (__pycache__/, *.pyc) - Updated file operations for production reliability - Enhanced error handling and logging ## 📚 Documentation Updates - Updated README.md with Docker deployment instructions - Added comprehensive DEPLOYMENT.md guide - Included production deployment best practices - Added automated deployment workflow documentation ## 🔐 Security & Production Features - Environment-based configuration - Health checks and container monitoring - Automated admin user creation - Volume-mounted persistent data - Production logging and error handling ## ✅ Ready for Production - Clean project structure optimized for Docker - Automated deployment with ./deploy-docker.sh - Professional document processing pipeline - Reliable file management system - Complete documentation and deployment guides Access: http://localhost:8880 | Admin: admin/Initial01!
This commit is contained in:
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
385
app/utils/group_player_management.py
Normal file
385
app/utils/group_player_management.py
Normal 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
|
||||
84
app/utils/logger.py
Normal file
84
app/utils/logger.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import datetime
|
||||
from extensions import db
|
||||
from models import ServerLog
|
||||
|
||||
def log_action(action):
|
||||
"""
|
||||
Log an action to the server log database
|
||||
"""
|
||||
try:
|
||||
new_log = ServerLog(action=action)
|
||||
db.session.add(new_log)
|
||||
db.session.commit()
|
||||
print(f"Logged action: {action}")
|
||||
except Exception as e:
|
||||
print(f"Error logging action: {e}")
|
||||
db.session.rollback()
|
||||
|
||||
def get_recent_logs(limit=20):
|
||||
"""
|
||||
Get the most recent log entries
|
||||
"""
|
||||
return ServerLog.query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
|
||||
|
||||
# Helper functions for common log actions
|
||||
def log_upload(file_type, file_name, target_type, target_name):
|
||||
log_action(f"{file_type.upper()} file '{file_name}' uploaded for {target_type} '{target_name}'")
|
||||
|
||||
def log_process(file_type, file_name, target_type, target_name):
|
||||
log_action(f"{file_type.upper()} file '{file_name}' processed for {target_type} '{target_name}'")
|
||||
|
||||
def log_player_created(username, hostname):
|
||||
log_action(f"Player '{username}' with hostname '{hostname}' was created")
|
||||
|
||||
def log_player_edited(username):
|
||||
log_action(f"Player '{username}' was edited")
|
||||
|
||||
def log_player_deleted(username):
|
||||
log_action(f"Player '{username}' was deleted")
|
||||
|
||||
def log_group_created(name):
|
||||
log_action(f"Group '{name}' was created")
|
||||
|
||||
def log_group_edited(name):
|
||||
log_action(f"Group '{name}' was edited")
|
||||
|
||||
def log_group_deleted(name):
|
||||
log_action(f"Group '{name}' was deleted")
|
||||
|
||||
def log_user_created(username, role):
|
||||
log_action(f"User '{username}' with role '{role}' was created")
|
||||
|
||||
def log_user_role_changed(username, new_role):
|
||||
log_action(f"User '{username}' role changed to '{new_role}'")
|
||||
|
||||
def log_user_deleted(username):
|
||||
log_action(f"User '{username}' was deleted")
|
||||
|
||||
def log_content_deleted(content_name, target_type, target_name):
|
||||
log_action(f"Content '{content_name}' removed from {target_type} '{target_name}'")
|
||||
|
||||
def log_settings_changed(setting_name):
|
||||
log_action(f"Setting '{setting_name}' was changed")
|
||||
|
||||
def log_files_cleaned(count):
|
||||
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}'")
|
||||
86
app/utils/pptx_converter.py
Normal file
86
app/utils/pptx_converter.py
Normal 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>")
|
||||
457
app/utils/uploads.py
Normal file
457
app/utils/uploads.py
Normal file
@@ -0,0 +1,457 @@
|
||||
import os
|
||||
import subprocess
|
||||
from flask import Flask
|
||||
from werkzeug.utils import secure_filename
|
||||
from pdf2image import convert_from_path
|
||||
from extensions import db
|
||||
from models import Content, Player, Group
|
||||
from utils.logger import log_content_added, log_upload, log_process
|
||||
|
||||
# Function to add image to playlist
|
||||
def add_image_to_playlist(app, file, filename, duration, target_type, target_id):
|
||||
"""
|
||||
Save the image file and add it to the playlist database.
|
||||
"""
|
||||
# Ensure we use absolute path for upload folder
|
||||
upload_folder = app.config['UPLOAD_FOLDER']
|
||||
if not os.path.isabs(upload_folder):
|
||||
upload_folder = os.path.abspath(upload_folder)
|
||||
|
||||
# Ensure upload folder exists
|
||||
if not os.path.exists(upload_folder):
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
print(f"Created upload folder: {upload_folder}")
|
||||
|
||||
file_path = os.path.join(upload_folder, filename)
|
||||
print(f"Saving image to: {file_path}")
|
||||
|
||||
# Only save if file does not already exist
|
||||
if not os.path.exists(file_path):
|
||||
file.save(file_path)
|
||||
print(f"Image saved successfully: {file_path}")
|
||||
else:
|
||||
print(f"File already exists: {file_path}")
|
||||
|
||||
print(f"Adding image to playlist: {filename}, Target Type: {target_type}, Target ID: {target_id}")
|
||||
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
for player in group.players:
|
||||
new_content = Content(file_name=filename, duration=duration, player_id=player.id)
|
||||
db.session.add(new_content)
|
||||
log_content_added(filename, target_type, group.name)
|
||||
elif target_type == 'player':
|
||||
player = Player.query.get_or_404(target_id)
|
||||
new_content = Content(file_name=filename, duration=duration, player_id=target_id)
|
||||
db.session.add(new_content)
|
||||
log_content_added(filename, target_type, player.username)
|
||||
|
||||
db.session.commit()
|
||||
log_upload('image', filename, target_type, target_id)
|
||||
return True
|
||||
|
||||
# Video conversion functions
|
||||
def convert_video(input_file, output_folder):
|
||||
"""
|
||||
Converts a video file to MP4 format with H.264 codec.
|
||||
"""
|
||||
# Ensure we use absolute path for output folder
|
||||
if not os.path.isabs(output_folder):
|
||||
output_folder = os.path.abspath(output_folder)
|
||||
print(f"Converted output folder to absolute path: {output_folder}")
|
||||
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
print(f"Created output folder: {output_folder}")
|
||||
|
||||
# Generate the output file path
|
||||
base_name = os.path.splitext(os.path.basename(input_file))[0]
|
||||
output_file = os.path.join(output_folder, f"{base_name}.mp4")
|
||||
print(f"Converting video: {input_file} -> {output_file}")
|
||||
|
||||
# FFmpeg command to convert the video
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-i", input_file, # Input file
|
||||
"-c:v", "libx264", # Video codec: H.264
|
||||
"-preset", "fast", # Encoding speed/quality tradeoff
|
||||
"-crf", "23", # Constant Rate Factor (quality, lower is better)
|
||||
"-vf", "scale=-1:1080", # Scale video to 1080p (preserve aspect ratio)
|
||||
"-r", "30", # Frame rate: 30 FPS
|
||||
"-c:a", "aac", # Audio codec: AAC
|
||||
"-b:a", "128k", # Audio bitrate
|
||||
output_file # Output file
|
||||
]
|
||||
|
||||
try:
|
||||
# Run the FFmpeg command
|
||||
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
print(f"Video converted successfully: {output_file}")
|
||||
return output_file
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error converting video: {e.stderr.decode()}")
|
||||
return None
|
||||
|
||||
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}")
|
||||
|
||||
# Ensure we use absolute path for upload folder
|
||||
upload_folder = app.config['UPLOAD_FOLDER']
|
||||
if not os.path.isabs(upload_folder):
|
||||
upload_folder = os.path.abspath(upload_folder)
|
||||
print(f"Converted upload folder to absolute path: {upload_folder}")
|
||||
|
||||
converted_file = convert_video(file_path, upload_folder)
|
||||
if converted_file:
|
||||
converted_filename = os.path.basename(converted_file)
|
||||
print(f"Video converted successfully: {converted_filename}")
|
||||
|
||||
# Use the application context to interact with the database
|
||||
with app.app_context():
|
||||
# Update the database with the converted filename
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
for player in group.players:
|
||||
content = Content.query.filter_by(player_id=player.id, file_name=original_filename).first()
|
||||
if content:
|
||||
content.file_name = converted_filename
|
||||
elif target_type == 'player':
|
||||
content = Content.query.filter_by(player_id=target_id, file_name=original_filename).first()
|
||||
if content:
|
||||
content.file_name = converted_filename
|
||||
|
||||
db.session.commit()
|
||||
print(f"Database updated with converted video: {converted_filename}")
|
||||
|
||||
# Delete the original file only if it exists
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
print(f"Original file deleted: {file_path}")
|
||||
else:
|
||||
print(f"Video conversion failed for: {file_path}")
|
||||
|
||||
# PDF conversion functions
|
||||
def convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True, dpi=300):
|
||||
"""
|
||||
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}")
|
||||
|
||||
try:
|
||||
# Ensure output folder exists
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
print(f"Created output folder: {output_folder}")
|
||||
|
||||
# Convert PDF to images using pdf2image
|
||||
print("Starting PDF conversion...")
|
||||
images = convert_from_path(pdf_file, dpi=dpi)
|
||||
print(f"PDF converted to {len(images)} page(s)")
|
||||
|
||||
if not images:
|
||||
print("ERROR: No images generated from PDF")
|
||||
return []
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(pdf_file))[0]
|
||||
image_filenames = []
|
||||
|
||||
# Save each page as JPG image
|
||||
for i, image in enumerate(images):
|
||||
# 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)
|
||||
|
||||
# Save as JPG
|
||||
image.save(image_path, 'JPEG', quality=85, optimize=True)
|
||||
image_filenames.append(image_filename)
|
||||
print(f"Saved page {i + 1} to: {image_path}")
|
||||
|
||||
print(f"PDF conversion complete. {len(image_filenames)} JPG images saved to {output_folder}")
|
||||
|
||||
# Delete the PDF file if requested and conversion was successful
|
||||
if delete_pdf and os.path.exists(pdf_file) and image_filenames:
|
||||
os.remove(pdf_file)
|
||||
print(f"PDF file deleted: {pdf_file}")
|
||||
|
||||
return image_filenames
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error converting PDF to JPG images: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
def update_playlist_with_files(image_filenames, duration, target_type, target_id):
|
||||
"""
|
||||
Add files to a player or group playlist and update version numbers.
|
||||
|
||||
Args:
|
||||
image_filenames (list): List of filenames to add to playlist
|
||||
duration (int): Duration in seconds for each file
|
||||
target_type (str): 'player' or 'group'
|
||||
target_id (int): ID of the player or group
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
for player in group.players:
|
||||
for image_filename in image_filenames:
|
||||
new_content = Content(file_name=image_filename, duration=duration, player_id=player.id)
|
||||
db.session.add(new_content)
|
||||
player.playlist_version += 1
|
||||
group.playlist_version += 1
|
||||
elif target_type == 'player':
|
||||
player = Player.query.get_or_404(target_id)
|
||||
for image_filename in image_filenames:
|
||||
new_content = Content(file_name=image_filename, duration=duration, player_id=target_id)
|
||||
db.session.add(new_content)
|
||||
player.playlist_version += 1
|
||||
else:
|
||||
print(f"Invalid target type: {target_type}")
|
||||
return False
|
||||
|
||||
db.session.commit()
|
||||
print(f"Added {len(image_filenames)} files to playlist")
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error updating playlist: {e}")
|
||||
return False
|
||||
|
||||
def process_pdf(input_file, output_folder, duration, target_type, target_id):
|
||||
"""
|
||||
Process a PDF file: convert to images and update playlist.
|
||||
|
||||
Args:
|
||||
input_file (str): Path to the PDF file
|
||||
output_folder (str): Path to save the images
|
||||
duration (int): Duration in seconds for each image
|
||||
target_type (str): 'player' or 'group'
|
||||
target_id (int): ID of the player or group
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
print(f"Processing PDF file: {input_file}")
|
||||
print(f"Output folder: {output_folder}")
|
||||
|
||||
# Ensure we have absolute path for output folder
|
||||
if not os.path.isabs(output_folder):
|
||||
output_folder = os.path.abspath(output_folder)
|
||||
print(f"Converted output folder to absolute path: {output_folder}")
|
||||
|
||||
# Ensure output folder exists
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
print(f"Created output folder: {output_folder}")
|
||||
|
||||
# Convert PDF to images using standard quality (delete PDF after successful conversion)
|
||||
image_filenames = convert_pdf_to_images(input_file, output_folder, delete_pdf=True, dpi=300)
|
||||
|
||||
# Update playlist with generated images
|
||||
if image_filenames:
|
||||
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
||||
if success:
|
||||
print(f"Successfully processed PDF: {len(image_filenames)} images added to playlist")
|
||||
return success
|
||||
else:
|
||||
print("Failed to convert PDF to images")
|
||||
return False
|
||||
|
||||
def process_pptx(input_file, output_folder, duration, target_type, target_id):
|
||||
"""
|
||||
Process a PPTX file: convert to PDF first, then to JPG images (same workflow as PDF).
|
||||
|
||||
Args:
|
||||
input_file (str): Path to the PPTX file
|
||||
output_folder (str): Path to save the images
|
||||
duration (int): Duration in seconds for each image
|
||||
target_type (str): 'player' or 'group'
|
||||
target_id (int): ID of the player or group
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
print(f"Processing PPTX file using PDF workflow: {input_file}")
|
||||
print(f"Output folder: {output_folder}")
|
||||
|
||||
# Ensure we have absolute path for output folder
|
||||
if not os.path.isabs(output_folder):
|
||||
output_folder = os.path.abspath(output_folder)
|
||||
print(f"Converted output folder to absolute path: {output_folder}")
|
||||
|
||||
# Ensure output folder exists
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
print(f"Created output folder: {output_folder}")
|
||||
|
||||
try:
|
||||
# Step 1: Convert PPTX to PDF using LibreOffice for vector quality
|
||||
from utils.pptx_converter import pptx_to_pdf_libreoffice
|
||||
pdf_file = pptx_to_pdf_libreoffice(input_file, output_folder)
|
||||
|
||||
if not pdf_file:
|
||||
print("Error: Failed to convert PPTX to PDF")
|
||||
return False
|
||||
|
||||
print(f"PPTX successfully converted to PDF: {pdf_file}")
|
||||
|
||||
# Step 2: Use the same PDF to images workflow as direct PDF uploads
|
||||
# Convert PDF to JPG images (300 DPI, same as PDF workflow)
|
||||
image_filenames = convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True, dpi=300)
|
||||
|
||||
if not image_filenames:
|
||||
print("Error: Failed to convert PDF to images")
|
||||
return False
|
||||
|
||||
print(f"Generated {len(image_filenames)} JPG images from PPTX → PDF")
|
||||
|
||||
# Step 3: Delete the original PPTX file after successful conversion
|
||||
if os.path.exists(input_file):
|
||||
os.remove(input_file)
|
||||
print(f"Original PPTX file deleted: {input_file}")
|
||||
|
||||
# Step 4: Update playlist with generated images in sequential order
|
||||
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
||||
if success:
|
||||
print(f"Successfully processed PPTX: {len(image_filenames)} images added to playlist")
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing PPTX file: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def process_uploaded_files(app, files, media_type, duration, target_type, target_id):
|
||||
"""
|
||||
Process uploaded files based on media type and add them to playlists.
|
||||
|
||||
Returns:
|
||||
list: List of result dictionaries with success status and messages
|
||||
"""
|
||||
results = []
|
||||
|
||||
# Get target name for logging
|
||||
target_name = ""
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
target_name = group.name
|
||||
elif target_type == 'player':
|
||||
player = Player.query.get_or_404(target_id)
|
||||
target_name = player.username
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
# Generate a secure filename and save the file
|
||||
filename = secure_filename(file.filename)
|
||||
|
||||
# Ensure we use absolute path for upload folder
|
||||
upload_folder = app.config['UPLOAD_FOLDER']
|
||||
if not os.path.isabs(upload_folder):
|
||||
upload_folder = os.path.abspath(upload_folder)
|
||||
|
||||
# Ensure upload folder exists
|
||||
if not os.path.exists(upload_folder):
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
print(f"Created upload folder: {upload_folder}")
|
||||
|
||||
file_path = os.path.join(upload_folder, filename)
|
||||
file.save(file_path)
|
||||
print(f"File saved to: {file_path}")
|
||||
|
||||
print(f"Processing file: {filename}, Media Type: {media_type}")
|
||||
result = {'filename': filename, 'success': True, 'message': ''}
|
||||
|
||||
if media_type == 'image':
|
||||
add_image_to_playlist(app, file, filename, duration, target_type, target_id)
|
||||
result['message'] = f"Image {filename} added to playlist"
|
||||
log_upload('image', filename, target_type, target_id)
|
||||
|
||||
elif media_type == 'video':
|
||||
# For videos, add to playlist then start conversion in background
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
for player in group.players:
|
||||
new_content = Content(file_name=filename, duration=duration, player_id=player.id)
|
||||
db.session.add(new_content)
|
||||
player.playlist_version += 1
|
||||
group.playlist_version += 1
|
||||
elif target_type == 'player':
|
||||
player = Player.query.get_or_404(target_id)
|
||||
new_content = Content(file_name=filename, duration=duration, player_id=target_id)
|
||||
db.session.add(new_content)
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
# Start background conversion using absolute path
|
||||
import threading
|
||||
threading.Thread(target=convert_video_and_update_playlist,
|
||||
args=(app, file_path, filename, target_type, target_id, duration)).start()
|
||||
result['message'] = f"Video {filename} added to playlist and being processed"
|
||||
log_upload('video', filename, target_type, target_id)
|
||||
|
||||
elif media_type == 'pdf':
|
||||
# For PDFs, convert to images and update playlist using absolute path
|
||||
success = process_pdf(file_path, upload_folder,
|
||||
duration, target_type, target_id)
|
||||
if success:
|
||||
result['message'] = f"PDF {filename} processed successfully"
|
||||
log_process('pdf', filename, target_type, target_id)
|
||||
else:
|
||||
result['success'] = False
|
||||
result['message'] = f"Error processing PDF file: {filename}"
|
||||
|
||||
elif media_type == 'ppt':
|
||||
# For PPT/PPTX, convert to PDF, then to images, and update playlist using absolute path
|
||||
success = process_pptx(file_path, upload_folder,
|
||||
duration, target_type, target_id)
|
||||
if success:
|
||||
result['message'] = f"PowerPoint {filename} processed successfully"
|
||||
log_process('ppt', filename, target_type, target_id)
|
||||
else:
|
||||
result['success'] = False
|
||||
result['message'] = f"Error processing PowerPoint file: {filename}"
|
||||
|
||||
results.append(result)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing file {file.filename}: {e}")
|
||||
results.append({
|
||||
'filename': file.filename,
|
||||
'success': False,
|
||||
'message': f"Error processing file {file.filename}: {str(e)}"
|
||||
})
|
||||
|
||||
return results
|
||||
Reference in New Issue
Block a user