1238 lines
52 KiB
Python
1238 lines
52 KiB
Python
"""Content blueprint - New playlist-centric workflow."""
|
|
from flask import (Blueprint, render_template, request, redirect, url_for,
|
|
flash, jsonify, current_app)
|
|
from flask_login import login_required
|
|
from werkzeug.utils import secure_filename
|
|
from typing import Optional
|
|
import os
|
|
import threading
|
|
|
|
from app.extensions import db, cache
|
|
from app.models import Content, Playlist, Player
|
|
from app.models.playlist import playlist_content
|
|
from app.utils.logger import log_action
|
|
from app.utils.uploads import process_video_file, set_upload_progress
|
|
|
|
# Store for background processing status
|
|
_background_tasks = {}
|
|
|
|
content_bp = Blueprint('content', __name__, url_prefix='/content')
|
|
|
|
|
|
@content_bp.route('/')
|
|
@login_required
|
|
def content_list():
|
|
"""Main playlist management page."""
|
|
playlists = Playlist.query.order_by(Playlist.created_at.desc()).all()
|
|
media_files = Content.query.order_by(Content.uploaded_at.desc()).limit(3).all() # Only last 3
|
|
total_media_count = Content.query.count() # Total count for display
|
|
players = Player.query.order_by(Player.name).all()
|
|
|
|
return render_template('content/content_list_new.html',
|
|
playlists=playlists,
|
|
media_files=media_files,
|
|
total_media_count=total_media_count,
|
|
players=players)
|
|
|
|
|
|
@content_bp.route('/media-library')
|
|
@login_required
|
|
def media_library():
|
|
"""View all media files in the library."""
|
|
media_files = Content.query.order_by(Content.uploaded_at.desc()).all()
|
|
|
|
# Group by content type
|
|
images = [m for m in media_files if m.content_type == 'image']
|
|
videos = [m for m in media_files if m.content_type == 'video']
|
|
pdfs = [m for m in media_files if m.content_type == 'pdf']
|
|
presentations = [m for m in media_files if m.content_type == 'pptx']
|
|
others = [m for m in media_files if m.content_type not in ['image', 'video', 'pdf', 'pptx']]
|
|
|
|
return render_template('content/media_library.html',
|
|
media_files=media_files,
|
|
images=images,
|
|
videos=videos,
|
|
pdfs=pdfs,
|
|
presentations=presentations,
|
|
others=others)
|
|
|
|
|
|
@content_bp.route('/media/<int:media_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def delete_media(media_id: int):
|
|
"""Delete a media file and remove it from all playlists."""
|
|
try:
|
|
media = Content.query.get_or_404(media_id)
|
|
filename = media.filename
|
|
|
|
# Get all playlists containing this media
|
|
affected_playlists = list(media.playlists.all())
|
|
|
|
# Delete physical file
|
|
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], media.filename)
|
|
if os.path.exists(file_path):
|
|
os.remove(file_path)
|
|
log_action('info', f'Deleted physical file: {filename}')
|
|
|
|
# Remove from all playlists (this will cascade properly)
|
|
db.session.delete(media)
|
|
|
|
# Increment version for all affected playlists
|
|
for playlist in affected_playlists:
|
|
playlist.version += 1
|
|
log_action('info', f'Playlist "{playlist.name}" version updated to {playlist.version} (media removed)')
|
|
|
|
db.session.commit()
|
|
|
|
# Clear cache for affected playlists
|
|
from app.blueprints.players import get_player_playlist
|
|
from app.extensions import cache
|
|
for playlist in affected_playlists:
|
|
for player in playlist.players:
|
|
cache.delete_memoized(get_player_playlist, player.id)
|
|
|
|
if affected_playlists:
|
|
flash(f'Deleted "{filename}" and removed from {len(affected_playlists)} playlist(s). Playlist versions updated.', 'success')
|
|
else:
|
|
flash(f'Deleted "{filename}" successfully.', 'success')
|
|
|
|
log_action('info', f'Media deleted: {filename} (affected {len(affected_playlists)} playlists)')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error deleting media: {str(e)}')
|
|
flash(f'Error deleting media: {str(e)}', 'danger')
|
|
|
|
return redirect(url_for('content.media_library'))
|
|
|
|
|
|
@content_bp.route('/playlist/create', methods=['POST'])
|
|
@login_required
|
|
def create_playlist():
|
|
"""Create a new playlist."""
|
|
try:
|
|
name = request.form.get('name', '').strip()
|
|
description = request.form.get('description', '').strip()
|
|
orientation = request.form.get('orientation', 'Landscape')
|
|
|
|
if not name:
|
|
flash('Playlist name is required.', 'warning')
|
|
return redirect(url_for('content.content_list'))
|
|
|
|
# Check if playlist name exists
|
|
existing = Playlist.query.filter_by(name=name).first()
|
|
if existing:
|
|
flash(f'Playlist "{name}" already exists.', 'warning')
|
|
return redirect(url_for('content.content_list'))
|
|
|
|
playlist = Playlist(
|
|
name=name,
|
|
description=description or None,
|
|
orientation=orientation
|
|
)
|
|
db.session.add(playlist)
|
|
db.session.commit()
|
|
|
|
log_action('info', f'Created playlist: {name}')
|
|
flash(f'Playlist "{name}" created successfully!', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error creating playlist: {str(e)}')
|
|
flash('Error creating playlist.', 'danger')
|
|
|
|
return redirect(url_for('content.content_list'))
|
|
|
|
|
|
@content_bp.route('/playlist/<int:playlist_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def delete_playlist(playlist_id: int):
|
|
"""Delete a playlist."""
|
|
playlist = Playlist.query.get_or_404(playlist_id)
|
|
|
|
try:
|
|
name = playlist.name
|
|
|
|
# Unassign all players from this playlist
|
|
Player.query.filter_by(playlist_id=playlist_id).update({'playlist_id': None})
|
|
|
|
db.session.delete(playlist)
|
|
db.session.commit()
|
|
cache.clear()
|
|
|
|
log_action('info', f'Deleted playlist: {name}')
|
|
flash(f'Playlist "{name}" deleted successfully.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error deleting playlist: {str(e)}')
|
|
flash('Error deleting playlist.', 'danger')
|
|
|
|
return redirect(url_for('content.content_list'))
|
|
|
|
|
|
@content_bp.route('/playlist/<int:playlist_id>/manage')
|
|
@login_required
|
|
def manage_playlist_content(playlist_id: int):
|
|
"""Manage content in a specific playlist."""
|
|
playlist = Playlist.query.get_or_404(playlist_id)
|
|
|
|
# Get content in playlist (ordered)
|
|
playlist_content = playlist.get_content_ordered()
|
|
|
|
# Get all available content not in this playlist
|
|
all_content = Content.query.all()
|
|
playlist_content_ids = {c.id for c in playlist_content}
|
|
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
|
|
|
return render_template('content/manage_playlist_content.html',
|
|
playlist=playlist,
|
|
playlist_content=playlist_content,
|
|
available_content=available_content)
|
|
|
|
|
|
@content_bp.route('/playlist/<int:playlist_id>/add-content', methods=['POST'])
|
|
@login_required
|
|
def add_content_to_playlist(playlist_id: int):
|
|
"""Add content to playlist."""
|
|
playlist = Playlist.query.get_or_404(playlist_id)
|
|
|
|
try:
|
|
content_id = request.form.get('content_id', type=int)
|
|
duration = request.form.get('duration', type=int, default=10)
|
|
|
|
if not content_id:
|
|
flash('Please select content to add.', 'warning')
|
|
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
|
|
|
content = Content.query.get_or_404(content_id)
|
|
|
|
# Get max position
|
|
from sqlalchemy import select, func
|
|
from app.models.playlist import playlist_content
|
|
|
|
max_pos = db.session.execute(
|
|
select(func.max(playlist_content.c.position)).where(
|
|
playlist_content.c.playlist_id == playlist_id
|
|
)
|
|
).scalar() or 0
|
|
|
|
# Add to playlist
|
|
stmt = playlist_content.insert().values(
|
|
playlist_id=playlist_id,
|
|
content_id=content_id,
|
|
position=max_pos + 1,
|
|
duration=duration
|
|
)
|
|
db.session.execute(stmt)
|
|
|
|
playlist.increment_version()
|
|
db.session.commit()
|
|
cache.clear()
|
|
|
|
log_action('info', f'Added "{content.filename}" to playlist "{playlist.name}"')
|
|
flash(f'Added "{content.filename}" to playlist.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error adding content to playlist: {str(e)}')
|
|
flash('Error adding content to playlist.', 'danger')
|
|
|
|
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
|
|
|
|
|
@content_bp.route('/playlist/<int:playlist_id>/remove-content/<int:content_id>', methods=['POST'])
|
|
@login_required
|
|
def remove_content_from_playlist(playlist_id: int, content_id: int):
|
|
"""Remove content from playlist."""
|
|
playlist = Playlist.query.get_or_404(playlist_id)
|
|
|
|
try:
|
|
from app.models.playlist import playlist_content
|
|
|
|
# Remove from playlist
|
|
stmt = playlist_content.delete().where(
|
|
(playlist_content.c.playlist_id == playlist_id) &
|
|
(playlist_content.c.content_id == content_id)
|
|
)
|
|
db.session.execute(stmt)
|
|
|
|
playlist.increment_version()
|
|
db.session.commit()
|
|
cache.clear()
|
|
|
|
log_action('info', f'Removed content from playlist "{playlist.name}"')
|
|
flash('Content removed from playlist.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error removing content from playlist: {str(e)}')
|
|
flash('Error removing content from playlist.', 'danger')
|
|
|
|
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
|
|
|
|
|
@content_bp.route('/playlist/<int:playlist_id>/bulk-remove', methods=['POST'])
|
|
@login_required
|
|
def bulk_remove_from_playlist(playlist_id: int):
|
|
"""Remove multiple content items from playlist."""
|
|
playlist = Playlist.query.get_or_404(playlist_id)
|
|
|
|
try:
|
|
data = request.get_json()
|
|
content_ids = data.get('content_ids', [])
|
|
|
|
if not content_ids:
|
|
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
|
|
|
|
from app.models.playlist import playlist_content
|
|
|
|
# Remove all selected items
|
|
stmt = playlist_content.delete().where(
|
|
(playlist_content.c.playlist_id == playlist_id) &
|
|
(playlist_content.c.content_id.in_(content_ids))
|
|
)
|
|
result = db.session.execute(stmt)
|
|
|
|
# Increment version
|
|
playlist.increment_version()
|
|
db.session.commit()
|
|
cache.clear()
|
|
|
|
removed_count = result.rowcount if hasattr(result, 'rowcount') else len(content_ids)
|
|
log_action('info', f'Bulk removed {removed_count} items from playlist "{playlist.name}"')
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Removed {removed_count} item(s) from playlist',
|
|
'removed_count': removed_count
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error bulk removing from playlist: {str(e)}')
|
|
return jsonify({'success': False, 'message': str(e)}), 500
|
|
|
|
|
|
@content_bp.route('/playlist/<int:playlist_id>/reorder', methods=['POST'])
|
|
@login_required
|
|
def reorder_playlist_content(playlist_id: int):
|
|
"""Reorder content in playlist."""
|
|
playlist = Playlist.query.get_or_404(playlist_id)
|
|
|
|
try:
|
|
data = request.get_json()
|
|
content_ids = data.get('content_ids', [])
|
|
|
|
if not content_ids:
|
|
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
|
|
|
|
from app.models.playlist import playlist_content
|
|
|
|
# Update positions
|
|
for idx, content_id in enumerate(content_ids, start=1):
|
|
stmt = playlist_content.update().where(
|
|
(playlist_content.c.playlist_id == playlist_id) &
|
|
(playlist_content.c.content_id == content_id)
|
|
).values(position=idx)
|
|
db.session.execute(stmt)
|
|
|
|
playlist.increment_version()
|
|
db.session.commit()
|
|
cache.clear()
|
|
|
|
log_action('info', f'Reordered playlist "{playlist.name}"')
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Playlist reordered successfully',
|
|
'version': playlist.version
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error reordering playlist: {str(e)}')
|
|
return jsonify({'success': False, 'message': str(e)}), 500
|
|
|
|
|
|
@content_bp.route('/playlist/<int:playlist_id>/update-muted/<int:content_id>', methods=['POST'])
|
|
@login_required
|
|
def update_playlist_content_muted(playlist_id: int, content_id: int):
|
|
"""Update content muted setting in playlist."""
|
|
playlist = Playlist.query.get_or_404(playlist_id)
|
|
|
|
try:
|
|
content = Content.query.get_or_404(content_id)
|
|
muted = request.form.get('muted', 'true').lower() == 'true'
|
|
|
|
from app.models.playlist import playlist_content
|
|
from sqlalchemy import update
|
|
|
|
# Update muted in association table
|
|
stmt = update(playlist_content).where(
|
|
(playlist_content.c.playlist_id == playlist_id) &
|
|
(playlist_content.c.content_id == content_id)
|
|
).values(muted=muted)
|
|
db.session.execute(stmt)
|
|
|
|
# Increment playlist version
|
|
playlist.increment_version()
|
|
|
|
db.session.commit()
|
|
cache.clear()
|
|
|
|
log_action('info', f'Updated muted={muted} for "{content.filename}" in playlist "{playlist.name}"')
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Audio setting updated',
|
|
'muted': muted,
|
|
'version': playlist.version
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error updating muted setting: {str(e)}')
|
|
return jsonify({'success': False, 'message': str(e)}), 500
|
|
|
|
|
|
@content_bp.route('/upload-media-page')
|
|
@login_required
|
|
def upload_media_page():
|
|
"""Display upload media page."""
|
|
playlists = Playlist.query.order_by(Playlist.name).all()
|
|
return render_template('content/upload_media.html', playlists=playlists)
|
|
|
|
|
|
def process_image_file(filepath: str, filename: str) -> tuple[bool, str]:
|
|
"""Process and optimize image files."""
|
|
try:
|
|
from PIL import Image
|
|
|
|
# Open and optimize image
|
|
img = Image.open(filepath)
|
|
|
|
# Convert RGBA to RGB for JPEGs
|
|
if img.mode == 'RGBA' and filename.lower().endswith(('.jpg', '.jpeg')):
|
|
rgb_img = Image.new('RGB', img.size, (255, 255, 255))
|
|
rgb_img.paste(img, mask=img.split()[3])
|
|
img = rgb_img
|
|
|
|
# Resize if too large (max 1920x1080 for display efficiency)
|
|
max_size = (1920, 1080)
|
|
if img.width > max_size[0] or img.height > max_size[1]:
|
|
img.thumbnail(max_size, Image.Resampling.LANCZOS)
|
|
img.save(filepath, optimize=True, quality=85)
|
|
log_action('info', f'Optimized image: {filename}')
|
|
|
|
return True, "Image processed successfully"
|
|
except Exception as e:
|
|
return False, f"Image processing error: {str(e)}"
|
|
|
|
|
|
def process_video_file_extended(filepath: str, filename: str) -> tuple[bool, str]:
|
|
"""Process and optimize video files for Raspberry Pi playback."""
|
|
try:
|
|
# Basic video validation
|
|
import subprocess
|
|
|
|
# Check if video is playable
|
|
result = subprocess.run(
|
|
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
|
|
'-show_entries', 'stream=codec_name,width,height',
|
|
'-of', 'default=noprint_wrappers=1', filepath],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
log_action('info', f'Video validated: {filename}')
|
|
return True, "Video validated successfully"
|
|
else:
|
|
return False, "Video validation failed"
|
|
except Exception as e:
|
|
# If ffprobe not available, just accept the video
|
|
log_action('warning', f'Video validation skipped (ffprobe unavailable): {filename}')
|
|
return True, "Video accepted without validation"
|
|
|
|
|
|
def process_pdf_file(filepath: str, filename: str) -> tuple[bool, str]:
|
|
"""Process PDF files by converting each page to PNG images."""
|
|
try:
|
|
from pdf2image import convert_from_path
|
|
from pathlib import Path
|
|
|
|
# Basic PDF validation - check if it's a valid PDF
|
|
with open(filepath, 'rb') as f:
|
|
header = f.read(5)
|
|
if header != b'%PDF-':
|
|
return False, "Invalid PDF file"
|
|
|
|
log_action('info', f'Converting PDF to images: {filename}')
|
|
|
|
# Convert PDF pages to images at high DPI for quality
|
|
images = convert_from_path(
|
|
filepath,
|
|
dpi=300, # 300 DPI for sharp rendering
|
|
fmt='png'
|
|
)
|
|
|
|
if not images:
|
|
return False, "No pages found in PDF"
|
|
|
|
# Generate base filename without extension
|
|
base_filename = Path(filename).stem
|
|
upload_folder = os.path.dirname(filepath)
|
|
|
|
# Save each page with proper aspect ratio preservation
|
|
converted_files = []
|
|
for idx, image in enumerate(images, start=1):
|
|
# Create filename for this page
|
|
page_filename = f"{base_filename}_page{idx:03d}.png"
|
|
page_filepath = os.path.join(upload_folder, page_filename)
|
|
|
|
# Determine orientation and resize maintaining aspect ratio
|
|
width, height = image.size
|
|
is_portrait = height > width
|
|
|
|
# Define Full HD dimensions based on orientation
|
|
if is_portrait:
|
|
# Portrait: max height 1920, max width 1080 (rotated Full HD)
|
|
max_size = (1080, 1920)
|
|
else:
|
|
# Landscape: max width 1920, max height 1080 (standard Full HD)
|
|
max_size = (1920, 1080)
|
|
|
|
# Resize maintaining aspect ratio (thumbnail maintains ratio)
|
|
from PIL import Image as PILImage
|
|
image.thumbnail(max_size, PILImage.Resampling.LANCZOS)
|
|
|
|
# Save the optimized image
|
|
image.save(page_filepath, 'PNG', optimize=True, quality=95)
|
|
|
|
converted_files.append((page_filepath, page_filename))
|
|
log_action('info', f'Converted PDF page {idx}/{len(images)} ({width}x{height} -> {image.size[0]}x{image.size[1]}): {page_filename}')
|
|
|
|
log_action('info', f'PDF converted successfully: {len(images)} pages from {filename}')
|
|
|
|
# Return success with file info for later processing
|
|
return True, f"PDF converted to {len(images)} images"
|
|
|
|
except ImportError:
|
|
return False, "pdf2image library not installed. Install with: pip install pdf2image"
|
|
except Exception as e:
|
|
import traceback
|
|
error_details = traceback.format_exc()
|
|
log_action('error', f'PDF processing error: {str(e)}\n{error_details}')
|
|
return False, f"PDF processing error: {str(e)}"
|
|
|
|
|
|
def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
|
|
duration: int, playlist_id: Optional[int], task_id: str):
|
|
"""Process large files (PDF, PPTX, Video) in background thread."""
|
|
with app.app_context():
|
|
try:
|
|
_background_tasks[task_id] = {'status': 'processing', 'message': f'Processing {filename}...'}
|
|
|
|
upload_folder = app.config['UPLOAD_FOLDER']
|
|
processing_success = True
|
|
processing_message = ""
|
|
detected_type = file_ext
|
|
|
|
# Process based on file type
|
|
if file_ext == 'pdf':
|
|
processing_success, processing_message = process_pdf_file(filepath, filename)
|
|
|
|
if processing_success and "converted to" in processing_message.lower():
|
|
base_name = os.path.splitext(filename)[0]
|
|
page_pattern = f"{base_name}_page*.png"
|
|
import glob
|
|
page_files = sorted(glob.glob(os.path.join(upload_folder, page_pattern)))
|
|
|
|
if page_files:
|
|
max_position = 0
|
|
if playlist_id:
|
|
playlist = Playlist.query.get(playlist_id)
|
|
max_position = db.session.query(db.func.max(playlist_content.c.position))\
|
|
.filter(playlist_content.c.playlist_id == playlist_id)\
|
|
.scalar() or 0
|
|
|
|
for page_file in page_files:
|
|
page_filename = os.path.basename(page_file)
|
|
page_content = Content(
|
|
filename=page_filename,
|
|
content_type='image',
|
|
duration=duration,
|
|
file_size=os.path.getsize(page_file)
|
|
)
|
|
db.session.add(page_content)
|
|
db.session.flush()
|
|
|
|
if playlist_id:
|
|
max_position += 1
|
|
stmt = playlist_content.insert().values(
|
|
playlist_id=playlist_id,
|
|
content_id=page_content.id,
|
|
position=max_position,
|
|
duration=duration
|
|
)
|
|
db.session.execute(stmt)
|
|
|
|
if playlist_id and page_files:
|
|
playlist = Playlist.query.get(playlist_id)
|
|
if playlist:
|
|
playlist.version += 1
|
|
|
|
db.session.commit()
|
|
cache.clear()
|
|
|
|
if os.path.exists(filepath):
|
|
os.remove(filepath)
|
|
|
|
_background_tasks[task_id] = {
|
|
'status': 'complete',
|
|
'message': f'PDF converted to {len(page_files)} images successfully!'
|
|
}
|
|
log_action('info', f'Background: PDF {filename} converted to {len(page_files)} pages')
|
|
return
|
|
|
|
elif file_ext in ['ppt', 'pptx']:
|
|
processing_success, processing_message = process_presentation_file(filepath, filename)
|
|
|
|
if processing_success and "converted to" in processing_message.lower():
|
|
base_name = os.path.splitext(filename)[0]
|
|
slide_pattern = f"{base_name}_slide_*.png"
|
|
import glob
|
|
slide_files = sorted(glob.glob(os.path.join(upload_folder, slide_pattern)))
|
|
|
|
if slide_files:
|
|
max_position = 0
|
|
if playlist_id:
|
|
playlist = Playlist.query.get(playlist_id)
|
|
max_position = db.session.query(db.func.max(playlist_content.c.position))\
|
|
.filter(playlist_content.c.playlist_id == playlist_id)\
|
|
.scalar() or 0
|
|
|
|
for slide_file in slide_files:
|
|
slide_filename = os.path.basename(slide_file)
|
|
slide_content = Content(
|
|
filename=slide_filename,
|
|
content_type='image',
|
|
duration=duration,
|
|
file_size=os.path.getsize(slide_file)
|
|
)
|
|
db.session.add(slide_content)
|
|
db.session.flush()
|
|
|
|
if playlist_id:
|
|
max_position += 1
|
|
stmt = playlist_content.insert().values(
|
|
playlist_id=playlist_id,
|
|
content_id=slide_content.id,
|
|
position=max_position,
|
|
duration=duration
|
|
)
|
|
db.session.execute(stmt)
|
|
|
|
if playlist_id and slide_files:
|
|
playlist = Playlist.query.get(playlist_id)
|
|
if playlist:
|
|
playlist.version += 1
|
|
|
|
db.session.commit()
|
|
cache.clear()
|
|
|
|
if os.path.exists(filepath):
|
|
os.remove(filepath)
|
|
|
|
_background_tasks[task_id] = {
|
|
'status': 'complete',
|
|
'message': f'Presentation converted to {len(slide_files)} slides successfully!'
|
|
}
|
|
log_action('info', f'Background: PPTX {filename} converted to {len(slide_files)} slides')
|
|
return
|
|
|
|
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']:
|
|
processing_success, processing_message = process_video_file_extended(filepath, filename)
|
|
detected_type = 'video'
|
|
|
|
# If file still exists, add as regular content
|
|
if processing_success and os.path.exists(filepath):
|
|
content = Content(
|
|
filename=filename,
|
|
content_type=detected_type,
|
|
duration=duration,
|
|
file_size=os.path.getsize(filepath)
|
|
)
|
|
db.session.add(content)
|
|
db.session.flush()
|
|
|
|
if playlist_id:
|
|
playlist = Playlist.query.get(playlist_id)
|
|
if playlist:
|
|
max_position = db.session.query(db.func.max(playlist_content.c.position))\
|
|
.filter(playlist_content.c.playlist_id == playlist_id)\
|
|
.scalar() or 0
|
|
|
|
stmt = playlist_content.insert().values(
|
|
playlist_id=playlist_id,
|
|
content_id=content.id,
|
|
position=max_position + 1,
|
|
duration=duration
|
|
)
|
|
db.session.execute(stmt)
|
|
playlist.version += 1
|
|
|
|
db.session.commit()
|
|
cache.clear()
|
|
_background_tasks[task_id] = {'status': 'complete', 'message': f'{filename} processed successfully!'}
|
|
log_action('info', f'Background: {filename} processed successfully')
|
|
else:
|
|
_background_tasks[task_id] = {
|
|
'status': 'error',
|
|
'message': f'Failed to process {filename}: {processing_message}'
|
|
}
|
|
log_action('error', f'Background: Failed to process {filename}')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
_background_tasks[task_id] = {'status': 'error', 'message': f'Error: {str(e)}'}
|
|
log_action('error', f'Background processing error for {filename}: {str(e)}')
|
|
|
|
|
|
def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
|
|
"""Process PowerPoint presentation files by converting slides to images."""
|
|
try:
|
|
import subprocess
|
|
import tempfile
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
# Basic validation - check file exists and has content
|
|
file_size = os.path.getsize(filepath)
|
|
if file_size < 1024: # Less than 1KB is suspicious
|
|
return False, "File too small to be a valid presentation"
|
|
|
|
# Check if LibreOffice is available
|
|
libreoffice_paths = [
|
|
'/usr/bin/libreoffice',
|
|
'/usr/bin/soffice',
|
|
'/snap/bin/libreoffice',
|
|
'libreoffice', # Try in PATH
|
|
'soffice'
|
|
]
|
|
|
|
libreoffice_cmd = None
|
|
for cmd in libreoffice_paths:
|
|
try:
|
|
result = subprocess.run([cmd, '--version'],
|
|
capture_output=True,
|
|
timeout=5)
|
|
if result.returncode == 0:
|
|
libreoffice_cmd = cmd
|
|
log_action('info', f'Found LibreOffice at: {cmd}')
|
|
break
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
continue
|
|
|
|
if not libreoffice_cmd:
|
|
log_action('warning', f'LibreOffice not found, cannot convert: {filename}')
|
|
return False, "LibreOffice is not installed. Please install it from the Admin Panel → System Dependencies to upload PowerPoint files."
|
|
|
|
# Create temporary directory for conversion
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Copy presentation to temp directory
|
|
temp_ppt = temp_path / filename
|
|
shutil.copy2(filepath, temp_ppt)
|
|
|
|
# Convert presentation to PDF first (for better quality)
|
|
convert_cmd = [
|
|
libreoffice_cmd,
|
|
'--headless',
|
|
'--convert-to', 'pdf',
|
|
'--outdir', str(temp_path),
|
|
str(temp_ppt)
|
|
]
|
|
|
|
log_action('info', f'Converting presentation to PDF: {filename}')
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
convert_cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120 # 2 minutes timeout
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
log_action('error', f'LibreOffice conversion failed: {result.stderr}')
|
|
return True, "Presentation accepted without conversion (conversion failed)"
|
|
|
|
# Find generated PDF file
|
|
pdf_files = list(temp_path.glob('*.pdf'))
|
|
if not pdf_files:
|
|
log_action('warning', f'No PDF generated from presentation: {filename}')
|
|
return True, "Presentation accepted without conversion"
|
|
|
|
pdf_file = pdf_files[0]
|
|
log_action('info', f'Converting PDF to images at Full HD resolution: {pdf_file.name}')
|
|
|
|
# Convert PDF to images using pdftoppm at Full HD resolution (1920x1080)
|
|
# Calculate DPI for Full HD output (assuming standard presentation is 10x7.5 inches)
|
|
# 1920/10 = 192 DPI for width, use 192 DPI for best quality
|
|
pdftoppm_cmd = [
|
|
'pdftoppm',
|
|
'-png',
|
|
'-r', '300', # High DPI for quality
|
|
'-scale-to', '1920', # Scale width to 1920px
|
|
str(pdf_file),
|
|
str(temp_path / 'slide')
|
|
]
|
|
|
|
result = subprocess.run(
|
|
pdftoppm_cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
log_action('error', f'pdftoppm conversion failed: {result.stderr}')
|
|
return True, "Presentation accepted without conversion (image conversion failed)"
|
|
|
|
# Find generated PNG files
|
|
png_files = sorted(temp_path.glob('slide-*.png'))
|
|
|
|
if not png_files:
|
|
log_action('warning', f'No images generated from presentation: {filename}')
|
|
return True, "Presentation accepted without images"
|
|
|
|
# Get upload folder from app config
|
|
upload_folder = current_app.config['UPLOAD_FOLDER']
|
|
base_name = os.path.splitext(filename)[0]
|
|
|
|
# Move converted images to upload folder and resize to exact Full HD
|
|
slide_count = 0
|
|
for idx, png_file in enumerate(png_files, start=1):
|
|
# Create descriptive filename
|
|
slide_filename = f"{base_name}_slide_{idx:03d}.png"
|
|
destination = os.path.join(upload_folder, slide_filename)
|
|
|
|
shutil.move(str(png_file), destination)
|
|
|
|
# Resize to exact Full HD dimensions (1920x1080) maintaining aspect ratio
|
|
resize_image_to_fullhd(destination)
|
|
|
|
slide_count += 1
|
|
|
|
log_action('info', f'Converted {slide_count} slides from {filename} to images')
|
|
|
|
# Remove original PPTX file as we now have the images
|
|
os.remove(filepath)
|
|
|
|
return True, f"Presentation converted to {slide_count} Full HD images"
|
|
|
|
except subprocess.TimeoutExpired:
|
|
log_action('error', f'LibreOffice conversion timeout for: {filename}')
|
|
return True, "Presentation accepted without conversion (timeout)"
|
|
|
|
except Exception as e:
|
|
log_action('error', f'Presentation processing error: {str(e)}')
|
|
return False, f"Presentation processing error: {str(e)}"
|
|
|
|
|
|
def create_fullhd_image(img):
|
|
"""Create a Full HD (1920x1080) image from PIL Image object, centered on white background."""
|
|
from PIL import Image as PILImage
|
|
|
|
target_size = (1920, 1080)
|
|
|
|
# Resize maintaining aspect ratio
|
|
img_copy = img.copy()
|
|
img_copy.thumbnail(target_size, PILImage.Resampling.LANCZOS)
|
|
|
|
# Create canvas with white background
|
|
fullhd_img = PILImage.new('RGB', target_size, (255, 255, 255))
|
|
|
|
# Center the image
|
|
x = (target_size[0] - img_copy.width) // 2
|
|
y = (target_size[1] - img_copy.height) // 2
|
|
|
|
if img_copy.mode == 'RGBA':
|
|
fullhd_img.paste(img_copy, (x, y), img_copy)
|
|
else:
|
|
fullhd_img.paste(img_copy, (x, y))
|
|
|
|
return fullhd_img
|
|
|
|
|
|
def resize_image_to_fullhd(filepath: str) -> bool:
|
|
"""Resize image to exactly Full HD (1920x1080) maintaining aspect ratio with centered crop or padding."""
|
|
try:
|
|
from PIL import Image
|
|
|
|
img = Image.open(filepath)
|
|
target_width = 1920
|
|
target_height = 1080
|
|
|
|
# Calculate aspect ratios
|
|
img_aspect = img.width / img.height
|
|
target_aspect = target_width / target_height
|
|
|
|
if abs(img_aspect - target_aspect) < 0.01:
|
|
# Aspect ratio is very close, just resize
|
|
img_resized = img.resize((target_width, target_height), Image.Resampling.LANCZOS)
|
|
elif img_aspect > target_aspect:
|
|
# Image is wider than target, fit height and crop/pad width
|
|
new_height = target_height
|
|
new_width = int(target_height * img_aspect)
|
|
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
|
|
# Crop to center if wider
|
|
if new_width > target_width:
|
|
left = (new_width - target_width) // 2
|
|
img_resized = img_resized.crop((left, 0, left + target_width, target_height))
|
|
else:
|
|
# Pad with white if narrower
|
|
result = Image.new('RGB', (target_width, target_height), (255, 255, 255))
|
|
offset = (target_width - new_width) // 2
|
|
result.paste(img_resized, (offset, 0))
|
|
img_resized = result
|
|
else:
|
|
# Image is taller than target, fit width and crop/pad height
|
|
new_width = target_width
|
|
new_height = int(target_width / img_aspect)
|
|
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
|
|
# Crop to center if taller
|
|
if new_height > target_height:
|
|
top = (new_height - target_height) // 2
|
|
img_resized = img_resized.crop((0, top, target_width, top + target_height))
|
|
else:
|
|
# Pad with white if shorter
|
|
result = Image.new('RGB', (target_width, target_height), (255, 255, 255))
|
|
offset = (target_height - new_height) // 2
|
|
result.paste(img_resized, (0, offset))
|
|
img_resized = result
|
|
|
|
# Save optimized image
|
|
img_resized.save(filepath, 'PNG', optimize=True, quality=95)
|
|
|
|
return True
|
|
except Exception as e:
|
|
log_action('error', f'Image resize error: {str(e)}')
|
|
return False
|
|
|
|
|
|
def optimize_image_to_fullhd(filepath: str) -> bool:
|
|
"""Optimize and resize image file to Full HD (1920x1080) maintaining aspect ratio."""
|
|
try:
|
|
from PIL import Image
|
|
|
|
img = Image.open(filepath)
|
|
fullhd_img = create_fullhd_image(img)
|
|
fullhd_img.save(filepath, 'PNG', optimize=True)
|
|
|
|
return True
|
|
except Exception as e:
|
|
log_action('error', f'Image optimization error: {str(e)}')
|
|
return False
|
|
|
|
|
|
@content_bp.route('/upload-media', methods=['POST'])
|
|
@login_required
|
|
def upload_media():
|
|
"""Upload media files to library with type-specific processing."""
|
|
try:
|
|
files = request.files.getlist('files')
|
|
content_type = request.form.get('content_type', 'image')
|
|
duration = request.form.get('duration', type=int, default=10)
|
|
playlist_id = request.form.get('playlist_id', type=int)
|
|
|
|
if not files or files[0].filename == '':
|
|
flash('No files provided.', 'warning')
|
|
return redirect(url_for('content.upload_media_page'))
|
|
|
|
upload_folder = current_app.config['UPLOAD_FOLDER']
|
|
os.makedirs(upload_folder, exist_ok=True)
|
|
|
|
uploaded_count = 0
|
|
background_count = 0
|
|
processing_errors = []
|
|
|
|
for file in files:
|
|
if file.filename == '':
|
|
continue
|
|
|
|
filename = secure_filename(file.filename)
|
|
filepath = os.path.join(upload_folder, filename)
|
|
|
|
# Check if file already exists
|
|
existing = Content.query.filter_by(filename=filename).first()
|
|
if existing:
|
|
log_action('warning', f'File {filename} already exists, skipping')
|
|
continue
|
|
|
|
# Save file first
|
|
file.save(filepath)
|
|
|
|
# Determine content type from extension
|
|
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
|
|
|
# Check if file needs background processing (large files)
|
|
needs_background = file_ext in ['pdf', 'ppt', 'pptx', 'mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']
|
|
|
|
if needs_background:
|
|
# Process in background thread
|
|
import uuid
|
|
task_id = str(uuid.uuid4())
|
|
_background_tasks[task_id] = {'status': 'queued', 'message': f'Queued {filename} for processing...'}
|
|
|
|
thread = threading.Thread(
|
|
target=process_file_in_background,
|
|
args=(current_app._get_current_object(), filepath, filename, file_ext, duration, playlist_id, task_id)
|
|
)
|
|
thread.daemon = True
|
|
thread.start()
|
|
|
|
background_count += 1
|
|
log_action('info', f'Queued {filename} for background processing (task: {task_id})')
|
|
continue
|
|
|
|
# Process file based on type
|
|
processing_success = True
|
|
processing_message = ""
|
|
|
|
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']:
|
|
detected_type = 'image'
|
|
processing_success, processing_message = process_image_file(filepath, filename)
|
|
|
|
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']:
|
|
detected_type = 'video'
|
|
processing_success, processing_message = process_video_file_extended(filepath, filename)
|
|
|
|
elif file_ext == 'pdf':
|
|
detected_type = 'pdf'
|
|
processing_success, processing_message = process_pdf_file(filepath, filename)
|
|
|
|
# For PDFs, pages are converted to individual images
|
|
# We need to add each page image as a separate content item
|
|
if processing_success and "converted to" in processing_message.lower():
|
|
# Find all page images that were created
|
|
base_name = os.path.splitext(filename)[0]
|
|
page_pattern = f"{base_name}_page*.png"
|
|
import glob
|
|
page_files = sorted(glob.glob(os.path.join(upload_folder, page_pattern)))
|
|
|
|
if page_files:
|
|
max_position = 0
|
|
if playlist_id:
|
|
playlist = Playlist.query.get(playlist_id)
|
|
max_position = db.session.query(db.func.max(playlist_content.c.position))\
|
|
.filter(playlist_content.c.playlist_id == playlist_id)\
|
|
.scalar() or 0
|
|
|
|
# Add each page as separate content
|
|
for page_file in page_files:
|
|
page_filename = os.path.basename(page_file)
|
|
|
|
# Create content record for page
|
|
page_content = Content(
|
|
filename=page_filename,
|
|
content_type='image',
|
|
duration=duration,
|
|
file_size=os.path.getsize(page_file)
|
|
)
|
|
db.session.add(page_content)
|
|
db.session.flush()
|
|
|
|
# Add to playlist if specified
|
|
if playlist_id:
|
|
max_position += 1
|
|
stmt = playlist_content.insert().values(
|
|
playlist_id=playlist_id,
|
|
content_id=page_content.id,
|
|
position=max_position,
|
|
duration=duration
|
|
)
|
|
db.session.execute(stmt)
|
|
|
|
uploaded_count += 1
|
|
|
|
# Increment playlist version if pages were added
|
|
if playlist_id and page_files:
|
|
playlist.increment_version()
|
|
|
|
# Delete original PDF file
|
|
if os.path.exists(filepath):
|
|
os.remove(filepath)
|
|
log_action('info', f'Removed original PDF after conversion: {filename}')
|
|
|
|
continue # Skip normal content creation below
|
|
|
|
elif file_ext in ['ppt', 'pptx']:
|
|
detected_type = 'pptx'
|
|
processing_success, processing_message = process_presentation_file(filepath, filename)
|
|
|
|
# For presentations, slides are converted to individual images
|
|
# We need to add each slide image as a separate content item
|
|
if processing_success and "converted to" in processing_message.lower():
|
|
# Find all slide images that were created
|
|
base_name = os.path.splitext(filename)[0]
|
|
slide_pattern = f"{base_name}_slide_*.png"
|
|
import glob
|
|
slide_files = sorted(glob.glob(os.path.join(upload_folder, slide_pattern)))
|
|
|
|
if slide_files:
|
|
max_position = 0
|
|
if playlist_id:
|
|
playlist = Playlist.query.get(playlist_id)
|
|
max_position = db.session.query(db.func.max(playlist_content.c.position))\
|
|
.filter(playlist_content.c.playlist_id == playlist_id)\
|
|
.scalar() or 0
|
|
|
|
# Add each slide as separate content
|
|
for slide_file in slide_files:
|
|
slide_filename = os.path.basename(slide_file)
|
|
|
|
# Create content record for slide
|
|
slide_content = Content(
|
|
filename=slide_filename,
|
|
content_type='image',
|
|
duration=duration,
|
|
file_size=os.path.getsize(slide_file)
|
|
)
|
|
db.session.add(slide_content)
|
|
db.session.flush()
|
|
|
|
# Add to playlist if specified
|
|
if playlist_id:
|
|
max_position += 1
|
|
stmt = playlist_content.insert().values(
|
|
playlist_id=playlist_id,
|
|
content_id=slide_content.id,
|
|
position=max_position,
|
|
duration=duration
|
|
)
|
|
db.session.execute(stmt)
|
|
|
|
uploaded_count += 1
|
|
|
|
# Increment playlist version if slides were added
|
|
if playlist_id and slide_files:
|
|
playlist.increment_version()
|
|
|
|
# Delete original PPTX file
|
|
if os.path.exists(filepath):
|
|
os.remove(filepath)
|
|
log_action('info', f'Removed original PPTX after conversion: {filename}')
|
|
|
|
continue # Skip normal content creation below
|
|
|
|
else:
|
|
detected_type = 'other'
|
|
|
|
if not processing_success:
|
|
processing_errors.append(f"{filename}: {processing_message}")
|
|
if os.path.exists(filepath):
|
|
os.remove(filepath) # Remove failed file
|
|
log_action('error', f'Processing failed for {filename}: {processing_message}')
|
|
continue
|
|
|
|
# Create content record (for non-presentation files or failed conversions)
|
|
if os.path.exists(filepath):
|
|
content = Content(
|
|
filename=filename,
|
|
content_type=detected_type,
|
|
duration=duration,
|
|
file_size=os.path.getsize(filepath)
|
|
)
|
|
db.session.add(content)
|
|
db.session.flush() # Get content ID
|
|
|
|
# Add to playlist if specified
|
|
if playlist_id:
|
|
playlist = Playlist.query.get(playlist_id)
|
|
if playlist:
|
|
# Get max position
|
|
max_position = db.session.query(db.func.max(playlist_content.c.position))\
|
|
.filter(playlist_content.c.playlist_id == playlist_id)\
|
|
.scalar() or 0
|
|
|
|
# Add to playlist
|
|
stmt = playlist_content.insert().values(
|
|
playlist_id=playlist_id,
|
|
content_id=content.id,
|
|
position=max_position + 1,
|
|
duration=duration
|
|
)
|
|
db.session.execute(stmt)
|
|
|
|
# Increment playlist version
|
|
playlist.increment_version()
|
|
|
|
uploaded_count += 1
|
|
|
|
db.session.commit()
|
|
cache.clear()
|
|
|
|
log_action('info', f'Uploaded {uploaded_count} media files, {background_count} files processing in background')
|
|
|
|
# Show appropriate flash message
|
|
if processing_errors:
|
|
error_summary = '; '.join(processing_errors[:3])
|
|
if len(processing_errors) > 3:
|
|
error_summary += f' and {len(processing_errors) - 3} more...'
|
|
flash(f'Uploaded {uploaded_count} file(s). Errors: {error_summary}', 'warning')
|
|
elif background_count > 0:
|
|
bg_msg = f'{background_count} file(s) are being processed in the background (PDF, PPTX, or large videos). '
|
|
bg_msg += 'They will appear in the media library/playlist automatically when conversion completes. '
|
|
bg_msg += 'This may take a few minutes.'
|
|
if uploaded_count > 0:
|
|
flash(f'✅ Uploaded {uploaded_count} file(s) immediately. ⏳ {bg_msg}', 'info')
|
|
else:
|
|
flash(f'⏳ {bg_msg}', 'info')
|
|
elif playlist_id:
|
|
playlist = Playlist.query.get(playlist_id)
|
|
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')
|
|
else:
|
|
flash(f'Successfully uploaded {uploaded_count} file(s) to media library!', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error uploading media: {str(e)}')
|
|
flash('Error uploading media files.', 'danger')
|
|
|
|
return redirect(url_for('content.upload_media_page'))
|
|
|
|
|
|
@content_bp.route('/player/<int:player_id>/assign-playlist', methods=['POST'])
|
|
@login_required
|
|
def assign_player_to_playlist(player_id: int):
|
|
"""Assign a player to a playlist."""
|
|
player = Player.query.get_or_404(player_id)
|
|
|
|
try:
|
|
playlist_id = request.form.get('playlist_id', type=int)
|
|
|
|
if playlist_id:
|
|
playlist = Playlist.query.get_or_404(playlist_id)
|
|
player.playlist_id = playlist_id
|
|
log_action('info', f'Assigned player "{player.name}" to playlist "{playlist.name}"')
|
|
flash(f'Player "{player.name}" assigned to playlist "{playlist.name}".', 'success')
|
|
else:
|
|
player.playlist_id = None
|
|
log_action('info', f'Unassigned player "{player.name}" from playlist')
|
|
flash(f'Player "{player.name}" unassigned from playlist.', 'success')
|
|
|
|
db.session.commit()
|
|
cache.clear()
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error assigning player to playlist: {str(e)}')
|
|
flash('Error assigning player to playlist.', 'danger')
|
|
|
|
return redirect(url_for('content.content_list'))
|