updated features to upload pptx files
This commit is contained in:
@@ -8,7 +8,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.extensions import db, bcrypt
|
||||
from app.models import User, Player, Group, Content, ServerLog
|
||||
from app.models import User, Player, Group, Content, ServerLog, Playlist
|
||||
from app.utils.logger import log_action
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
@@ -38,7 +38,7 @@ def admin_panel():
|
||||
# Get statistics
|
||||
total_users = User.query.count()
|
||||
total_players = Player.query.count()
|
||||
total_groups = Group.query.count()
|
||||
total_playlists = Playlist.query.count()
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Get recent logs
|
||||
@@ -62,7 +62,7 @@ def admin_panel():
|
||||
return render_template('admin/admin.html',
|
||||
total_users=total_users,
|
||||
total_players=total_players,
|
||||
total_groups=total_groups,
|
||||
total_playlists=total_playlists,
|
||||
total_content=total_content,
|
||||
storage_mb=storage_mb,
|
||||
users=users,
|
||||
@@ -347,3 +347,146 @@ def system_info():
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting system info: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@admin_bp.route('/leftover-media')
|
||||
@login_required
|
||||
@admin_required
|
||||
def leftover_media():
|
||||
"""Display leftover media files not assigned to any playlist."""
|
||||
from app.models.playlist import playlist_content
|
||||
from sqlalchemy import select
|
||||
|
||||
try:
|
||||
# Get all content IDs that are in playlists
|
||||
stmt = select(playlist_content.c.content_id).distinct()
|
||||
content_in_playlists = set(row[0] for row in db.session.execute(stmt))
|
||||
|
||||
# Get all content
|
||||
all_content = Content.query.all()
|
||||
|
||||
# Filter content not in any playlist
|
||||
leftover_content = [c for c in all_content if c.id not in content_in_playlists]
|
||||
|
||||
# Separate by type
|
||||
leftover_images = [c for c in leftover_content if c.content_type == 'image']
|
||||
leftover_videos = [c for c in leftover_content if c.content_type == 'video']
|
||||
leftover_pdfs = [c for c in leftover_content if c.content_type == 'pdf']
|
||||
leftover_pptx = [c for c in leftover_content if c.content_type == 'pptx']
|
||||
|
||||
# Calculate storage
|
||||
total_leftover_size = sum(c.file_size for c in leftover_content)
|
||||
images_size = sum(c.file_size for c in leftover_images)
|
||||
videos_size = sum(c.file_size for c in leftover_videos)
|
||||
pdfs_size = sum(c.file_size for c in leftover_pdfs)
|
||||
pptx_size = sum(c.file_size for c in leftover_pptx)
|
||||
|
||||
return render_template('admin/leftover_media.html',
|
||||
leftover_images=leftover_images,
|
||||
leftover_videos=leftover_videos,
|
||||
leftover_pdfs=leftover_pdfs,
|
||||
leftover_pptx=leftover_pptx,
|
||||
total_leftover=len(leftover_content),
|
||||
total_leftover_size_mb=total_leftover_size / (1024 * 1024),
|
||||
images_size_mb=images_size / (1024 * 1024),
|
||||
videos_size_mb=videos_size / (1024 * 1024),
|
||||
pdfs_size_mb=pdfs_size / (1024 * 1024),
|
||||
pptx_size_mb=pptx_size / (1024 * 1024))
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading leftover media: {str(e)}')
|
||||
flash('Error loading leftover media.', 'danger')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/delete-leftover-images', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_leftover_images():
|
||||
"""Delete all leftover images that are not part of any playlist"""
|
||||
from app.models.playlist import playlist_content
|
||||
|
||||
try:
|
||||
# Find all leftover image content
|
||||
leftover_images = db.session.query(Content).filter(
|
||||
Content.media_type == 'image',
|
||||
~Content.id.in_(
|
||||
db.session.query(playlist_content.c.content_id)
|
||||
)
|
||||
).all()
|
||||
|
||||
deleted_count = 0
|
||||
errors = []
|
||||
|
||||
for content in leftover_images:
|
||||
try:
|
||||
# Delete physical file
|
||||
if content.file_path:
|
||||
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
# Delete database record
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Error deleting {content.file_path}: {str(e)}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if errors:
|
||||
flash(f'Deleted {deleted_count} images with {len(errors)} errors', 'warning')
|
||||
else:
|
||||
flash(f'Successfully deleted {deleted_count} leftover images', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error deleting leftover images: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.leftover_media'))
|
||||
|
||||
@admin_bp.route('/delete-leftover-videos', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_leftover_videos():
|
||||
"""Delete all leftover videos that are not part of any playlist"""
|
||||
from app.models.playlist import playlist_content
|
||||
|
||||
try:
|
||||
# Find all leftover video content
|
||||
leftover_videos = db.session.query(Content).filter(
|
||||
Content.media_type == 'video',
|
||||
~Content.id.in_(
|
||||
db.session.query(playlist_content.c.content_id)
|
||||
)
|
||||
).all()
|
||||
|
||||
deleted_count = 0
|
||||
errors = []
|
||||
|
||||
for content in leftover_videos:
|
||||
try:
|
||||
# Delete physical file
|
||||
if content.file_path:
|
||||
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
# Delete database record
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Error deleting {content.file_path}: {str(e)}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if errors:
|
||||
flash(f'Deleted {deleted_count} videos with {len(errors)} errors', 'warning')
|
||||
else:
|
||||
flash(f'Successfully deleted {deleted_count} leftover videos', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error deleting leftover videos: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.leftover_media'))
|
||||
|
||||
@@ -243,10 +243,222 @@ def upload_media_page():
|
||||
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."""
|
||||
try:
|
||||
# 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'PDF validated: {filename}')
|
||||
return True, "PDF processed successfully"
|
||||
except Exception as e:
|
||||
return False, f"PDF processing error: {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, skipping slide conversion for: {filename}')
|
||||
return True, "Presentation accepted without conversion (LibreOffice unavailable)"
|
||||
|
||||
# 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 images (PNG format)
|
||||
# Using LibreOffice headless mode with custom resolution
|
||||
convert_cmd = [
|
||||
libreoffice_cmd,
|
||||
'--headless',
|
||||
'--convert-to', 'png',
|
||||
'--outdir', str(temp_path),
|
||||
str(temp_ppt)
|
||||
]
|
||||
|
||||
log_action('info', f'Converting presentation to images: {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 PNG files
|
||||
png_files = sorted(temp_path.glob('*.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
|
||||
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)
|
||||
|
||||
# Optimize the image to Full HD (1920x1080)
|
||||
optimize_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 optimize_image_to_fullhd(filepath: str) -> bool:
|
||||
"""Optimize and resize image to Full HD (1920x1080) maintaining aspect ratio."""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(filepath)
|
||||
|
||||
# Target Full HD resolution
|
||||
target_size = (1920, 1080)
|
||||
|
||||
# Calculate resize maintaining aspect ratio
|
||||
img.thumbnail(target_size, Image.Resampling.LANCZOS)
|
||||
|
||||
# Create Full HD canvas with white background
|
||||
fullhd_img = Image.new('RGB', target_size, (255, 255, 255))
|
||||
|
||||
# Center the image on the canvas
|
||||
x = (target_size[0] - img.width) // 2
|
||||
y = (target_size[1] - img.height) // 2
|
||||
|
||||
if img.mode == 'RGBA':
|
||||
fullhd_img.paste(img, (x, y), img)
|
||||
else:
|
||||
fullhd_img.paste(img, (x, y))
|
||||
|
||||
# Save optimized image
|
||||
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."""
|
||||
"""Upload media files to library with type-specific processing."""
|
||||
try:
|
||||
files = request.files.getlist('files')
|
||||
content_type = request.form.get('content_type', 'image')
|
||||
@@ -261,6 +473,7 @@ def upload_media():
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
uploaded_count = 0
|
||||
processing_errors = []
|
||||
|
||||
for file in files:
|
||||
if file.filename == '':
|
||||
@@ -275,64 +488,138 @@ def upload_media():
|
||||
log_action('warning', f'File {filename} already exists, skipping')
|
||||
continue
|
||||
|
||||
# Save file
|
||||
# Save file first
|
||||
file.save(filepath)
|
||||
|
||||
# Determine content type from extension
|
||||
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||
|
||||
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
|
||||
# Process file based on type
|
||||
processing_success = True
|
||||
processing_message = ""
|
||||
|
||||
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']:
|
||||
detected_type = 'image'
|
||||
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
||||
processing_success, processing_message = process_image_file(filepath, filename)
|
||||
|
||||
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']:
|
||||
detected_type = 'video'
|
||||
# Process video for Raspberry Pi
|
||||
success, message = process_video_file(filepath, os.urandom(8).hex())
|
||||
if not success:
|
||||
log_action('error', f'Video processing failed: {message}')
|
||||
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)
|
||||
|
||||
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.version += 1
|
||||
|
||||
continue # Skip normal content creation below
|
||||
|
||||
else:
|
||||
detected_type = 'other'
|
||||
|
||||
# Create content record
|
||||
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
|
||||
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
|
||||
|
||||
# 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.version += 1
|
||||
|
||||
uploaded_count += 1
|
||||
# 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.version += 1
|
||||
|
||||
uploaded_count += 1
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Uploaded {uploaded_count} media files')
|
||||
|
||||
if playlist_id:
|
||||
# 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 playlist_id:
|
||||
playlist = Playlist.query.get(playlist_id)
|
||||
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')
|
||||
else:
|
||||
|
||||
@@ -213,31 +213,8 @@ def regenerate_auth_code(player_id: int):
|
||||
@players_bp.route('/<int:player_id>')
|
||||
@login_required
|
||||
def player_page(player_id: int):
|
||||
"""Display player page with content and controls."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
# Get player's playlist
|
||||
playlist = get_player_playlist(player_id)
|
||||
|
||||
# Get player status
|
||||
status_info = get_player_status_info(player_id)
|
||||
|
||||
# Get recent feedback
|
||||
recent_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\
|
||||
.order_by(PlayerFeedback.timestamp.desc())\
|
||||
.limit(10)\
|
||||
.all()
|
||||
|
||||
return render_template('players/player_page.html',
|
||||
player=player,
|
||||
playlist=playlist,
|
||||
status_info=status_info,
|
||||
recent_feedback=recent_feedback)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading player page: {str(e)}')
|
||||
flash('Error loading player page.', 'danger')
|
||||
return redirect(url_for('players.list'))
|
||||
"""Redirect to manage player page (combined view)."""
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/manage', methods=['GET', 'POST'])
|
||||
@@ -347,7 +324,7 @@ def player_fullscreen(player_id: int):
|
||||
|
||||
@cache.memoize(timeout=300) # Cache for 5 minutes
|
||||
def get_player_playlist(player_id: int) -> List[dict]:
|
||||
"""Get playlist for a player based on their direct content assignment.
|
||||
"""Get playlist for a player based on their assigned playlist.
|
||||
|
||||
Args:
|
||||
player_id: The player's database ID
|
||||
@@ -356,23 +333,26 @@ def get_player_playlist(player_id: int) -> List[dict]:
|
||||
List of content dictionaries with url, type, duration, and position
|
||||
"""
|
||||
player = Player.query.get(player_id)
|
||||
if not player:
|
||||
if not player or not player.playlist_id:
|
||||
return []
|
||||
|
||||
# Get content directly assigned to this player
|
||||
contents = Content.query.filter_by(player_id=player_id)\
|
||||
.order_by(Content.position, Content.uploaded_at)\
|
||||
.all()
|
||||
# Get the player's assigned playlist
|
||||
playlist_obj = Playlist.query.get(player.playlist_id)
|
||||
if not playlist_obj:
|
||||
return []
|
||||
|
||||
# Get ordered content from the playlist
|
||||
ordered_content = playlist_obj.get_content_ordered()
|
||||
|
||||
# Build playlist
|
||||
playlist = []
|
||||
for content in contents:
|
||||
for content in ordered_content:
|
||||
playlist.append({
|
||||
'id': content.id,
|
||||
'url': url_for('static', filename=f'uploads/{content.filename}'),
|
||||
'type': content.content_type,
|
||||
'duration': content.duration or 10, # Default 10 seconds if not set
|
||||
'position': content.position,
|
||||
'duration': getattr(content, '_playlist_duration', content.duration or 10),
|
||||
'position': getattr(content, '_playlist_position', 0),
|
||||
'filename': content.filename
|
||||
})
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
flash, jsonify, current_app)
|
||||
from flask_login import login_required
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy import desc, update
|
||||
import os
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Player, Content
|
||||
from app.models import Player, Content, Playlist
|
||||
from app.models.playlist import playlist_content
|
||||
from app.utils.logger import log_action
|
||||
|
||||
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
|
||||
@@ -18,20 +19,22 @@ def manage_playlist(player_id: int):
|
||||
"""Manage playlist for a specific player."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
# Get all content for this player, ordered by position
|
||||
playlist_content = Content.query.filter_by(
|
||||
player_id=player_id
|
||||
).order_by(Content.position).all()
|
||||
# Get content from player's assigned playlist
|
||||
playlist_items = []
|
||||
if player.playlist_id:
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
if playlist:
|
||||
playlist_items = playlist.get_content_ordered()
|
||||
|
||||
# Get available content (files not already in this player's playlist)
|
||||
all_files = db.session.query(Content.filename).distinct().all()
|
||||
playlist_filenames = {c.filename for c in playlist_content}
|
||||
available_files = [f[0] for f in all_files if f[0] not in playlist_filenames]
|
||||
# Get available content (all content not in current playlist)
|
||||
all_content = Content.query.all()
|
||||
playlist_content_ids = {item.id for item in playlist_items}
|
||||
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
||||
|
||||
return render_template('playlist/manage_playlist.html',
|
||||
player=player,
|
||||
playlist_content=playlist_content,
|
||||
available_files=available_files)
|
||||
playlist_content=playlist_items,
|
||||
available_content=available_content)
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
|
||||
@@ -40,44 +43,46 @@ def add_to_playlist(player_id: int):
|
||||
"""Add content to player's playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if not player.playlist_id:
|
||||
flash('Player has no playlist assigned.', 'warning')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
try:
|
||||
filename = request.form.get('filename')
|
||||
content_id = request.form.get('content_id', type=int)
|
||||
duration = request.form.get('duration', type=int, default=10)
|
||||
|
||||
if not filename:
|
||||
flash('Please provide a filename.', 'warning')
|
||||
if not content_id:
|
||||
flash('Please select content.', 'warning')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
content = Content.query.get_or_404(content_id)
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
|
||||
# Get max position
|
||||
max_position = db.session.query(db.func.max(Content.position)).filter_by(
|
||||
player_id=player_id
|
||||
from sqlalchemy import select, func
|
||||
max_pos = db.session.execute(
|
||||
select(func.max(playlist_content.c.position)).where(
|
||||
playlist_content.c.playlist_id == playlist.id
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# Get file info from existing content
|
||||
existing_content = Content.query.filter_by(filename=filename).first()
|
||||
if not existing_content:
|
||||
flash('File not found.', 'danger')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
# Create new content entry for this player
|
||||
new_content = Content(
|
||||
filename=filename,
|
||||
content_type=existing_content.content_type,
|
||||
duration=duration,
|
||||
file_size=existing_content.file_size,
|
||||
player_id=player_id,
|
||||
position=max_position + 1
|
||||
# Add to playlist_content association table
|
||||
stmt = playlist_content.insert().values(
|
||||
playlist_id=playlist.id,
|
||||
content_id=content.id,
|
||||
position=max_pos + 1,
|
||||
duration=duration
|
||||
)
|
||||
db.session.add(new_content)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
playlist.increment_version()
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Added "{filename}" to playlist for player "{player.name}"')
|
||||
flash(f'Added "{filename}" to playlist.', 'success')
|
||||
log_action('info', f'Added "{content.filename}" to playlist for player "{player.name}"')
|
||||
flash(f'Added "{content.filename}" to playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
@@ -92,28 +97,41 @@ def add_to_playlist(player_id: int):
|
||||
def remove_from_playlist(player_id: int, content_id: int):
|
||||
"""Remove content from player's playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if content.player_id != player_id:
|
||||
flash('Content does not belong to this player.', 'danger')
|
||||
if not player.playlist_id:
|
||||
flash('Player has no playlist assigned.', 'danger')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
filename = content.filename
|
||||
|
||||
# Delete content
|
||||
db.session.delete(content)
|
||||
# Remove from playlist_content association table
|
||||
from sqlalchemy import delete
|
||||
stmt = delete(playlist_content).where(
|
||||
(playlist_content.c.playlist_id == playlist.id) &
|
||||
(playlist_content.c.content_id == content_id)
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Reorder remaining content
|
||||
remaining_content = Content.query.filter_by(
|
||||
player_id=player_id
|
||||
).order_by(Content.position).all()
|
||||
from sqlalchemy import select
|
||||
remaining = db.session.execute(
|
||||
select(playlist_content.c.content_id, playlist_content.c.position).where(
|
||||
playlist_content.c.playlist_id == playlist.id
|
||||
).order_by(playlist_content.c.position)
|
||||
).fetchall()
|
||||
|
||||
for idx, item in enumerate(remaining_content, start=1):
|
||||
item.position = idx
|
||||
for idx, row in enumerate(remaining, start=1):
|
||||
stmt = update(playlist_content).where(
|
||||
(playlist_content.c.playlist_id == playlist.id) &
|
||||
(playlist_content.c.content_id == row.content_id)
|
||||
).values(position=idx)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
playlist.increment_version()
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
@@ -135,7 +153,12 @@ def reorder_playlist(player_id: int):
|
||||
"""Reorder playlist items."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if not player.playlist_id:
|
||||
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
|
||||
|
||||
try:
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
|
||||
# Get new order from JSON
|
||||
data = request.get_json()
|
||||
content_ids = data.get('content_ids', [])
|
||||
@@ -143,24 +166,26 @@ def reorder_playlist(player_id: int):
|
||||
if not content_ids:
|
||||
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
|
||||
|
||||
# Update positions
|
||||
# Update positions in association table
|
||||
for idx, content_id in enumerate(content_ids, start=1):
|
||||
content = Content.query.get(content_id)
|
||||
if content and content.player_id == player_id:
|
||||
content.position = idx
|
||||
stmt = update(playlist_content).where(
|
||||
(playlist_content.c.playlist_id == playlist.id) &
|
||||
(playlist_content.c.content_id == content_id)
|
||||
).values(position=idx)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
playlist.increment_version()
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Reordered playlist for player "{player.name}" (version {player.playlist_version})')
|
||||
log_action('info', f'Reordered playlist for player "{player.name}" (version {playlist.version})')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Playlist reordered successfully',
|
||||
'version': player.playlist_version
|
||||
'version': playlist.version
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -174,19 +199,28 @@ def reorder_playlist(player_id: int):
|
||||
def update_duration(player_id: int, content_id: int):
|
||||
"""Update content duration in playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if content.player_id != player_id:
|
||||
return jsonify({'success': False, 'message': 'Content does not belong to this player'}), 403
|
||||
if not player.playlist_id:
|
||||
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
|
||||
|
||||
try:
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
duration = request.form.get('duration', type=int)
|
||||
|
||||
if not duration or duration < 1:
|
||||
return jsonify({'success': False, 'message': 'Invalid duration'}), 400
|
||||
|
||||
content.duration = duration
|
||||
player.playlist_version += 1
|
||||
# Update duration in association table
|
||||
stmt = update(playlist_content).where(
|
||||
(playlist_content.c.playlist_id == playlist.id) &
|
||||
(playlist_content.c.content_id == content_id)
|
||||
).values(duration=duration)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Increment playlist version
|
||||
playlist.increment_version()
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
@@ -196,7 +230,7 @@ def update_duration(player_id: int, content_id: int):
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Duration updated',
|
||||
'version': player.playlist_version
|
||||
'version': playlist.version
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -211,12 +245,22 @@ def clear_playlist(player_id: int):
|
||||
"""Clear all content from player's playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if not player.playlist_id:
|
||||
flash('Player has no playlist assigned.', 'warning')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
try:
|
||||
# Delete all content for this player
|
||||
Content.query.filter_by(player_id=player_id).delete()
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
|
||||
# Delete all content from playlist
|
||||
from sqlalchemy import delete
|
||||
stmt = delete(playlist_content).where(
|
||||
playlist_content.c.playlist_id == playlist.id
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
playlist.increment_version()
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
Reference in New Issue
Block a user