Replace emoji icons with local SVG files for consistent rendering
- Created 10 SVG icon files in app/static/icons/ (Feather Icons style) - Updated base.html with SVG icons in navigation and dark mode toggle - Updated dashboard.html with icons in stats cards and quick actions - Updated content_list_new.html (playlist management) with SVG icons - Updated upload_media.html with upload-related icons - Updated manage_player.html with player management icons - Icons use currentColor for automatic theme adaptation - Removed emoji dependency for better Raspberry Pi compatibility - Added ICON_INTEGRATION.md documentation
This commit is contained in:
@@ -3,6 +3,7 @@ from flask import Blueprint, request, jsonify, current_app
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
import bcrypt
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from app.extensions import db, cache
|
||||
@@ -142,7 +143,7 @@ def authenticate_player():
|
||||
'player_name': player.name,
|
||||
'hostname': player.hostname,
|
||||
'auth_code': player.auth_code,
|
||||
'group_id': player.group_id,
|
||||
'playlist_id': player.playlist_id,
|
||||
'orientation': player.orientation,
|
||||
'status': player.status
|
||||
}
|
||||
@@ -186,7 +187,7 @@ def verify_auth_code():
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'hostname': player.hostname,
|
||||
'group_id': player.group_id,
|
||||
'playlist_id': player.playlist_id,
|
||||
'orientation': player.orientation,
|
||||
'status': player.status
|
||||
}
|
||||
@@ -194,6 +195,103 @@ def verify_auth_code():
|
||||
return jsonify(response), 200
|
||||
|
||||
|
||||
@api_bp.route('/playlists', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def get_playlist_by_quickconnect():
|
||||
"""Get playlist using hostname and quickconnect code (Kivy player compatible).
|
||||
|
||||
Query parameters:
|
||||
hostname: Player hostname/identifier
|
||||
quickconnect_code: Quick connect code for authentication
|
||||
|
||||
Returns:
|
||||
JSON with playlist, playlist_version, and hashed_quickconnect
|
||||
"""
|
||||
try:
|
||||
import bcrypt
|
||||
|
||||
hostname = request.args.get('hostname')
|
||||
quickconnect_code = request.args.get('quickconnect_code')
|
||||
|
||||
if not hostname or not quickconnect_code:
|
||||
return jsonify({
|
||||
'error': 'hostname and quickconnect_code are required',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 400
|
||||
|
||||
# Find player by hostname and validate quickconnect
|
||||
player = Player.query.filter_by(hostname=hostname).first()
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Player not found with hostname: {hostname}')
|
||||
return jsonify({
|
||||
'error': 'Player not found',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 404
|
||||
|
||||
# Validate quickconnect code
|
||||
if not player.quickconnect_code:
|
||||
log_action('warning', f'Player {hostname} has no quickconnect code set')
|
||||
return jsonify({
|
||||
'error': 'Quickconnect not configured',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 403
|
||||
|
||||
# Check if quickconnect matches
|
||||
if player.quickconnect_code != quickconnect_code:
|
||||
log_action('warning', f'Invalid quickconnect code for player: {hostname}')
|
||||
return jsonify({
|
||||
'error': 'Invalid quickconnect code',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 403
|
||||
|
||||
# Get playlist (with caching)
|
||||
playlist = get_cached_playlist(player.id)
|
||||
|
||||
# Update player's last seen timestamp and status
|
||||
player.last_seen = datetime.utcnow()
|
||||
player.status = 'online'
|
||||
db.session.commit()
|
||||
|
||||
# Get playlist version from the assigned playlist
|
||||
playlist_version = 1
|
||||
if player.playlist_id:
|
||||
from app.models import Playlist
|
||||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
||||
if assigned_playlist:
|
||||
playlist_version = assigned_playlist.version
|
||||
|
||||
# Hash the quickconnect code for validation on client side
|
||||
hashed_quickconnect = bcrypt.hashpw(
|
||||
quickconnect_code.encode('utf-8'),
|
||||
bcrypt.gensalt()
|
||||
).decode('utf-8')
|
||||
|
||||
log_action('info', f'Playlist fetched for player: {player.name} ({hostname})')
|
||||
|
||||
return jsonify({
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'playlist_id': player.playlist_id,
|
||||
'playlist_version': playlist_version,
|
||||
'playlist': playlist,
|
||||
'hashed_quickconnect': hashed_quickconnect,
|
||||
'count': len(playlist)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting playlist: {str(e)}')
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
@verify_player_auth
|
||||
@@ -216,11 +314,19 @@ def get_player_playlist(player_id: int):
|
||||
player.last_seen = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Get playlist version from the assigned playlist
|
||||
playlist_version = 1
|
||||
if player.playlist_id:
|
||||
from app.models import Playlist
|
||||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
||||
if assigned_playlist:
|
||||
playlist_version = assigned_playlist.version
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'player_name': player.name,
|
||||
'group_id': player.group_id,
|
||||
'playlist_version': player.playlist_version,
|
||||
'playlist_id': player.playlist_id,
|
||||
'playlist_version': playlist_version,
|
||||
'playlist': playlist,
|
||||
'count': len(playlist)
|
||||
})
|
||||
@@ -263,78 +369,110 @@ def get_playlist_version(player_id: int):
|
||||
|
||||
@cache.memoize(timeout=300)
|
||||
def get_cached_playlist(player_id: int) -> List[Dict]:
|
||||
"""Get cached playlist for a player."""
|
||||
"""Get cached playlist for a player based on assigned playlist."""
|
||||
from flask import url_for
|
||||
from app.models import Playlist
|
||||
|
||||
player = Player.query.get(player_id)
|
||||
if not player:
|
||||
if not player or not player.playlist_id:
|
||||
return []
|
||||
|
||||
# Get content based on group assignment
|
||||
if player.group_id:
|
||||
group = Group.query.get(player.group_id)
|
||||
contents = group.contents.order_by(Content.position).all() if group else []
|
||||
else:
|
||||
# Show all content if not in a group
|
||||
contents = Content.query.order_by(Content.position).all()
|
||||
# Get the playlist assigned to this player
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
if not playlist:
|
||||
return []
|
||||
|
||||
# Build playlist
|
||||
playlist = []
|
||||
for content in contents:
|
||||
playlist.append({
|
||||
# Get content from playlist (ordered)
|
||||
content_list = playlist.get_content_ordered()
|
||||
|
||||
# Build playlist response
|
||||
playlist_data = []
|
||||
for idx, content in enumerate(content_list, start=1):
|
||||
# Generate full URL for content
|
||||
from flask import request as current_request
|
||||
# Get server base URL
|
||||
server_base = current_request.host_url.rstrip('/')
|
||||
content_url = f"{server_base}/static/uploads/{content.filename}"
|
||||
|
||||
playlist_data.append({
|
||||
'id': content.id,
|
||||
'filename': content.filename,
|
||||
'file_name': content.filename, # Player expects 'file_name' not 'filename'
|
||||
'type': content.content_type,
|
||||
'duration': content.duration or 10,
|
||||
'position': content.position,
|
||||
'url': f"/static/uploads/{content.filename}",
|
||||
'duration': content._playlist_duration or content.duration or 10,
|
||||
'position': content._playlist_position or idx,
|
||||
'url': content_url, # Full URL for downloads
|
||||
'description': content.description
|
||||
})
|
||||
|
||||
return playlist
|
||||
return playlist_data
|
||||
|
||||
|
||||
@api_bp.route('/player-feedback', methods=['POST'])
|
||||
@rate_limit(max_requests=100, window=60)
|
||||
@verify_player_auth
|
||||
def receive_player_feedback():
|
||||
"""Receive feedback/status updates from players.
|
||||
"""Receive feedback/status updates from players (Kivy player compatible).
|
||||
|
||||
Expected JSON payload:
|
||||
{
|
||||
"status": "playing|paused|error",
|
||||
"current_content_id": 123,
|
||||
"message": "Optional status message",
|
||||
"error": "Optional error message"
|
||||
"player_name": "Screen1",
|
||||
"quickconnect_code": "ABC123",
|
||||
"status": "playing|paused|error|restarting",
|
||||
"message": "Status message",
|
||||
"playlist_version": 1,
|
||||
"error_details": "Optional error details",
|
||||
"timestamp": "ISO timestamp"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
player = request.player
|
||||
data = request.json
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
player_name = data.get('player_name')
|
||||
quickconnect_code = data.get('quickconnect_code')
|
||||
|
||||
if not player_name or not quickconnect_code:
|
||||
return jsonify({'error': 'player_name and quickconnect_code required'}), 400
|
||||
|
||||
# Find player by name and validate quickconnect
|
||||
player = Player.query.filter_by(name=player_name).first()
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Player feedback from unknown player: {player_name}')
|
||||
return jsonify({'error': 'Player not found'}), 404
|
||||
|
||||
# Validate quickconnect code
|
||||
if player.quickconnect_code != quickconnect_code:
|
||||
log_action('warning', f'Invalid quickconnect in feedback from: {player_name}')
|
||||
return jsonify({'error': 'Invalid quickconnect code'}), 403
|
||||
|
||||
# Create feedback record
|
||||
status = data.get('status', 'unknown')
|
||||
message = data.get('message', '')
|
||||
error_details = data.get('error_details')
|
||||
|
||||
feedback = PlayerFeedback(
|
||||
player_id=player.id,
|
||||
status=data.get('status', 'unknown'),
|
||||
current_content_id=data.get('current_content_id'),
|
||||
message=data.get('message'),
|
||||
error=data.get('error')
|
||||
status=status,
|
||||
message=message,
|
||||
error=error_details
|
||||
)
|
||||
db.session.add(feedback)
|
||||
|
||||
# Update player's last seen
|
||||
# Update player's last seen and status
|
||||
player.last_seen = datetime.utcnow()
|
||||
player.status = data.get('status', 'unknown')
|
||||
player.status = status
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Feedback received from {player_name}: {status} - {message}')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Feedback received'
|
||||
})
|
||||
'message': 'Feedback received',
|
||||
'player_id': player.id
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
|
||||
@@ -1,532 +1,376 @@
|
||||
"""Content blueprint for media upload and management."""
|
||||
"""Content blueprint - New playlist-centric workflow."""
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
flash, jsonify, current_app, send_from_directory)
|
||||
flash, jsonify, current_app)
|
||||
from flask_login import login_required
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
from typing import Optional, Dict
|
||||
import json
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Content, Group
|
||||
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 (
|
||||
save_uploaded_file,
|
||||
process_video_file,
|
||||
process_pdf_file,
|
||||
get_upload_progress,
|
||||
set_upload_progress
|
||||
)
|
||||
from app.utils.uploads import process_video_file, set_upload_progress
|
||||
|
||||
content_bp = Blueprint('content', __name__, url_prefix='/content')
|
||||
|
||||
|
||||
# In-memory storage for upload progress (for simple demo; use Redis in production)
|
||||
upload_progress = {}
|
||||
|
||||
|
||||
@content_bp.route('/')
|
||||
@login_required
|
||||
def content_list():
|
||||
"""Display list of all content."""
|
||||
try:
|
||||
# Get all unique content files (by filename)
|
||||
from sqlalchemy import func
|
||||
|
||||
# Get content with player information
|
||||
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
|
||||
|
||||
# Group content by filename to show which players have each file
|
||||
content_map = {}
|
||||
for content in contents:
|
||||
if content.filename not in content_map:
|
||||
content_map[content.filename] = {
|
||||
'content': content,
|
||||
'players': [],
|
||||
'groups': []
|
||||
}
|
||||
|
||||
# Add player info if assigned to a player
|
||||
if content.player_id:
|
||||
from app.models import Player
|
||||
player = Player.query.get(content.player_id)
|
||||
if player:
|
||||
content_map[content.filename]['players'].append({
|
||||
'id': player.id,
|
||||
'name': player.name,
|
||||
'group': player.group.name if player.group else None
|
||||
})
|
||||
|
||||
# Convert to list for template
|
||||
content_list = []
|
||||
for filename, data in content_map.items():
|
||||
content_list.append({
|
||||
'filename': filename,
|
||||
'content_type': data['content'].content_type,
|
||||
'duration': data['content'].duration,
|
||||
'file_size': data['content'].file_size_mb,
|
||||
'uploaded_at': data['content'].uploaded_at,
|
||||
'players': data['players'],
|
||||
'player_count': len(data['players'])
|
||||
})
|
||||
|
||||
# Sort by upload date
|
||||
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
|
||||
|
||||
return render_template('content/content_list.html',
|
||||
content_list=content_list)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading content list: {str(e)}')
|
||||
flash('Error loading content list.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
"""Main playlist management page."""
|
||||
playlists = Playlist.query.order_by(Playlist.created_at.desc()).all()
|
||||
media_files = Content.query.order_by(Content.uploaded_at.desc()).all()
|
||||
players = Player.query.order_by(Player.name).all()
|
||||
|
||||
return render_template('content/content_list_new.html',
|
||||
playlists=playlists,
|
||||
media_files=media_files,
|
||||
players=players)
|
||||
|
||||
|
||||
@content_bp.route('/upload', methods=['GET', 'POST'])
|
||||
@content_bp.route('/playlist/create', methods=['POST'])
|
||||
@login_required
|
||||
def upload_content():
|
||||
"""Upload new content."""
|
||||
if request.method == 'GET':
|
||||
# Get parameters for return URL and pre-selection
|
||||
target_type = request.args.get('target_type')
|
||||
target_id = request.args.get('target_id', type=int)
|
||||
return_url = request.args.get('return_url', url_for('content.content_list'))
|
||||
|
||||
# Get all players and groups for selection
|
||||
from app.models import Player
|
||||
players = [{'id': p.id, 'name': p.name} for p in Player.query.order_by(Player.name).all()]
|
||||
groups = [{'id': g.id, 'name': g.name} for g in Group.query.order_by(Group.name).all()]
|
||||
|
||||
return render_template('content/upload_content.html',
|
||||
players=players,
|
||||
groups=groups,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
return_url=return_url)
|
||||
|
||||
def create_playlist():
|
||||
"""Create a new playlist."""
|
||||
try:
|
||||
# Get form data
|
||||
target_type = request.form.get('target_type')
|
||||
target_id = request.form.get('target_id', type=int)
|
||||
media_type = request.form.get('media_type', 'image')
|
||||
duration = request.form.get('duration', type=int, default=10)
|
||||
session_id = request.form.get('session_id', os.urandom(8).hex())
|
||||
return_url = request.form.get('return_url', url_for('content.content_list'))
|
||||
|
||||
# Get files
|
||||
files = request.files.getlist('files')
|
||||
|
||||
if not files or files[0].filename == '':
|
||||
flash('No files provided.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
if not target_type or not target_id:
|
||||
flash('Please select a target type and target ID.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
# Initialize progress tracking using shared utility
|
||||
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
|
||||
|
||||
# Process each file
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
processed_count = 0
|
||||
total_files = len(files)
|
||||
|
||||
for idx, file in enumerate(files):
|
||||
if file.filename == '':
|
||||
continue
|
||||
|
||||
# Update progress
|
||||
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
|
||||
set_upload_progress(session_id, progress_pct,
|
||||
f'Processing file {idx + 1} of {total_files}...', 'processing')
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
|
||||
# Save file
|
||||
file.save(filepath)
|
||||
|
||||
# Determine content type
|
||||
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||
|
||||
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
|
||||
content_type = 'image'
|
||||
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
||||
content_type = 'video'
|
||||
# Process video (convert to Raspberry Pi optimized format)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
|
||||
success, message = process_video_file(filepath, session_id)
|
||||
if not success:
|
||||
log_action('error', f'Video optimization failed: {message}')
|
||||
continue # Skip this file and move to next
|
||||
elif file_ext == 'pdf':
|
||||
content_type = 'pdf'
|
||||
# Process PDF (convert to images)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Converting PDF {idx + 1}...', 'processing')
|
||||
# process_pdf_file(filepath, session_id)
|
||||
elif file_ext in ['ppt', 'pptx']:
|
||||
content_type = 'presentation'
|
||||
# Process presentation (convert to PDF then images)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Converting PowerPoint {idx + 1}...', 'processing')
|
||||
# This would call pptx_converter utility
|
||||
else:
|
||||
content_type = 'other'
|
||||
|
||||
# Create content record
|
||||
new_content = Content(
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(filepath)
|
||||
)
|
||||
|
||||
# Link to target (player or group)
|
||||
if target_type == 'player':
|
||||
from app.models import Player
|
||||
player = Player.query.get(target_id)
|
||||
if player:
|
||||
# Add content directly to player's playlist
|
||||
new_content.player_id = target_id
|
||||
db.session.add(new_content)
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
|
||||
|
||||
elif target_type == 'group':
|
||||
group = Group.query.get(target_id)
|
||||
if group:
|
||||
# For groups, create separate content entry for EACH player in the group
|
||||
# This matches the old app behavior
|
||||
for player in group.players:
|
||||
player_content = Content(
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(filepath),
|
||||
player_id=player.id
|
||||
)
|
||||
db.session.add(player_content)
|
||||
# Increment each player's playlist version
|
||||
player.playlist_version += 1
|
||||
|
||||
log_action('info', f'Content "{filename}" added to {len(group.players)} players in group "{group.name}"')
|
||||
# Don't add the original new_content since we created per-player entries
|
||||
new_content = None
|
||||
|
||||
if new_content:
|
||||
db.session.add(new_content)
|
||||
|
||||
processed_count += 1
|
||||
|
||||
# Commit all changes
|
||||
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
|
||||
db.session.commit()
|
||||
|
||||
# Complete
|
||||
set_upload_progress(session_id, 100,
|
||||
f'Successfully uploaded {processed_count} file(s)!', 'complete')
|
||||
|
||||
# Clear all playlist caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
|
||||
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
|
||||
|
||||
return redirect(return_url)
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
|
||||
# Update progress to error state
|
||||
if 'session_id' in locals():
|
||||
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
|
||||
|
||||
log_action('error', f'Error uploading content: {str(e)}')
|
||||
flash('Error uploading content. Please try again.', 'danger')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_content(content_id: int):
|
||||
"""Edit content metadata."""
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('content/edit_content.html', content=content)
|
||||
|
||||
try:
|
||||
duration = request.form.get('duration', type=int)
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
|
||||
# Update content
|
||||
if duration is not None:
|
||||
content.duration = duration
|
||||
content.description = description or None
|
||||
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()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
|
||||
flash(f'Content "{content.filename}" updated successfully.', 'success')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
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 updating content: {str(e)}')
|
||||
flash('Error updating content. Please try again.', 'danger')
|
||||
return redirect(url_for('content.edit_content', content_id=content_id))
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_content(content_id: int):
|
||||
"""Delete content and associated file."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
filename = content.filename
|
||||
|
||||
# Delete file from disk
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
# Delete from database
|
||||
db.session.delete(content)
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
|
||||
flash(f'Content "{filename}" deleted successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting content: {str(e)}')
|
||||
flash('Error deleting content. Please try again.', 'danger')
|
||||
log_action('error', f'Error creating playlist: {str(e)}')
|
||||
flash('Error creating playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
|
||||
@content_bp.route('/delete-by-filename', methods=['POST'])
|
||||
@content_bp.route('/playlist/<int:playlist_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_by_filename():
|
||||
"""Delete all content entries with a specific filename."""
|
||||
def delete_playlist(playlist_id: int):
|
||||
"""Delete a playlist."""
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
filename = data.get('filename')
|
||||
name = playlist.name
|
||||
|
||||
if not filename:
|
||||
return jsonify({'success': False, 'message': 'No filename provided'}), 400
|
||||
|
||||
# Find all content entries with this filename
|
||||
contents = Content.query.filter_by(filename=filename).all()
|
||||
|
||||
if not contents:
|
||||
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
||||
|
||||
deleted_count = len(contents)
|
||||
|
||||
# Delete file from disk (only once)
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
log_action('info', f'Deleted file from disk: {filename}')
|
||||
|
||||
# Delete all database entries
|
||||
for content in contents:
|
||||
db.session.delete(content)
|
||||
# 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()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
|
||||
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>/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': f'Content deleted from {deleted_count} playlist(s)',
|
||||
'deleted_count': deleted_count
|
||||
'message': 'Playlist reordered successfully',
|
||||
'version': playlist.version
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting content by filename: {str(e)}')
|
||||
log_action('error', f'Error reordering playlist: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/bulk/delete', methods=['POST'])
|
||||
@content_bp.route('/upload-media-page')
|
||||
@login_required
|
||||
def bulk_delete_content():
|
||||
"""Delete multiple content items at once."""
|
||||
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)
|
||||
|
||||
|
||||
@content_bp.route('/upload-media', methods=['POST'])
|
||||
@login_required
|
||||
def upload_media():
|
||||
"""Upload media files to library."""
|
||||
try:
|
||||
content_ids = request.json.get('content_ids', [])
|
||||
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 content_ids:
|
||||
return jsonify({'success': False, 'error': 'No content selected'}), 400
|
||||
if not files or files[0].filename == '':
|
||||
flash('No files provided.', 'warning')
|
||||
return redirect(url_for('content.upload_media_page'))
|
||||
|
||||
# Delete content
|
||||
deleted_count = 0
|
||||
for content_id in content_ids:
|
||||
content = Content.query.get(content_id)
|
||||
if content:
|
||||
# Delete file
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
uploaded_count = 0
|
||||
|
||||
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
|
||||
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']:
|
||||
detected_type = 'image'
|
||||
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
||||
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}')
|
||||
elif file_ext == 'pdf':
|
||||
detected_type = 'pdf'
|
||||
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
|
||||
|
||||
# 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()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Bulk deleted {deleted_count} content items')
|
||||
return jsonify({'success': True, 'deleted': deleted_count})
|
||||
log_action('info', f'Uploaded {uploaded_count} media files')
|
||||
|
||||
if 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 bulk deleting content: {str(e)}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
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('/upload-progress/<upload_id>')
|
||||
@content_bp.route('/player/<int:player_id>/assign-playlist', methods=['POST'])
|
||||
@login_required
|
||||
def upload_progress_status(upload_id: str):
|
||||
"""Get upload progress for a specific upload."""
|
||||
progress = get_upload_progress(upload_id)
|
||||
return jsonify(progress)
|
||||
|
||||
|
||||
@content_bp.route('/preview/<int:content_id>')
|
||||
@login_required
|
||||
def preview_content(content_id: int):
|
||||
"""Preview content in browser."""
|
||||
def assign_player_to_playlist(player_id: int):
|
||||
"""Assign a player to a playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
playlist_id = request.form.get('playlist_id', type=int)
|
||||
|
||||
# Serve file from uploads folder
|
||||
return send_from_directory(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
content.filename,
|
||||
as_attachment=False
|
||||
)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error previewing content: {str(e)}')
|
||||
return "Error loading content", 500
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/download')
|
||||
@login_required
|
||||
def download_content(content_id: int):
|
||||
"""Download content file."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
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')
|
||||
|
||||
log_action('info', f'Content "{content.filename}" downloaded')
|
||||
|
||||
return send_from_directory(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
content.filename,
|
||||
as_attachment=True
|
||||
)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error downloading content: {str(e)}')
|
||||
return "Error downloading content", 500
|
||||
|
||||
|
||||
@content_bp.route('/statistics')
|
||||
@login_required
|
||||
def content_statistics():
|
||||
"""Get content statistics."""
|
||||
try:
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Count by type
|
||||
type_counts = {}
|
||||
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
|
||||
count = Content.query.filter_by(content_type=content_type).count()
|
||||
type_counts[content_type] = count
|
||||
|
||||
# Calculate total storage
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
total_size = 0
|
||||
if os.path.exists(upload_folder):
|
||||
for dirpath, dirnames, filenames in os.walk(upload_folder):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
if os.path.exists(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
|
||||
return jsonify({
|
||||
'total': total_content,
|
||||
'by_type': type_counts,
|
||||
'total_size_mb': round(total_size / (1024 * 1024), 2)
|
||||
})
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting content statistics: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/check-duplicates')
|
||||
@login_required
|
||||
def check_duplicates():
|
||||
"""Check for duplicate filenames."""
|
||||
try:
|
||||
# Get all filenames
|
||||
all_content = Content.query.all()
|
||||
filename_counts = {}
|
||||
|
||||
for content in all_content:
|
||||
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
|
||||
|
||||
# Find duplicates
|
||||
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
|
||||
|
||||
return jsonify({
|
||||
'has_duplicates': len(duplicates) > 0,
|
||||
'duplicates': duplicates
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error checking duplicates: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/groups')
|
||||
@login_required
|
||||
def content_groups_info(content_id: int):
|
||||
"""Get groups that contain this content."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
groups_data = []
|
||||
for group in content.groups:
|
||||
groups_data.append({
|
||||
'id': group.id,
|
||||
'name': group.name,
|
||||
'description': group.description,
|
||||
'player_count': group.players.count()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'content_id': content_id,
|
||||
'filename': content.filename,
|
||||
'groups': groups_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting content groups: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
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'))
|
||||
|
||||
500
app/blueprints/content_old.py
Normal file
500
app/blueprints/content_old.py
Normal file
@@ -0,0 +1,500 @@
|
||||
"""Content blueprint for media upload and management."""
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
flash, jsonify, current_app, send_from_directory)
|
||||
from flask_login import login_required
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
from typing import Optional, Dict
|
||||
import json
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Content, Group
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.uploads import (
|
||||
save_uploaded_file,
|
||||
process_video_file,
|
||||
process_pdf_file,
|
||||
get_upload_progress,
|
||||
set_upload_progress
|
||||
)
|
||||
|
||||
content_bp = Blueprint('content', __name__, url_prefix='/content')
|
||||
|
||||
|
||||
# In-memory storage for upload progress (for simple demo; use Redis in production)
|
||||
upload_progress = {}
|
||||
|
||||
|
||||
@content_bp.route('/')
|
||||
@login_required
|
||||
def content_list():
|
||||
"""Display list of all content."""
|
||||
try:
|
||||
# Get all unique content files (by filename)
|
||||
from sqlalchemy import func
|
||||
|
||||
# Get content with player information
|
||||
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
|
||||
|
||||
# Group content by filename to show which players have each file
|
||||
content_map = {}
|
||||
for content in contents:
|
||||
if content.filename not in content_map:
|
||||
content_map[content.filename] = {
|
||||
'content': content,
|
||||
'players': [],
|
||||
'groups': []
|
||||
}
|
||||
|
||||
# Add player info if assigned to a player
|
||||
if content.player_id:
|
||||
from app.models import Player
|
||||
player = Player.query.get(content.player_id)
|
||||
if player:
|
||||
content_map[content.filename]['players'].append({
|
||||
'id': player.id,
|
||||
'name': player.name,
|
||||
'group': player.group.name if player.group else None
|
||||
})
|
||||
|
||||
# Convert to list for template
|
||||
content_list = []
|
||||
for filename, data in content_map.items():
|
||||
content_list.append({
|
||||
'filename': filename,
|
||||
'content_type': data['content'].content_type,
|
||||
'duration': data['content'].duration,
|
||||
'file_size': data['content'].file_size_mb,
|
||||
'uploaded_at': data['content'].uploaded_at,
|
||||
'players': data['players'],
|
||||
'player_count': len(data['players'])
|
||||
})
|
||||
|
||||
# Sort by upload date
|
||||
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
|
||||
|
||||
return render_template('content/content_list.html',
|
||||
content_list=content_list)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading content list: {str(e)}')
|
||||
flash('Error loading content list.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@content_bp.route('/upload', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def upload_content():
|
||||
"""Upload new content."""
|
||||
if request.method == 'GET':
|
||||
# Get parameters for return URL and pre-selection
|
||||
player_id = request.args.get('player_id', type=int)
|
||||
return_url = request.args.get('return_url', url_for('content.content_list'))
|
||||
|
||||
# Get all players for selection
|
||||
from app.models import Player
|
||||
players = Player.query.order_by(Player.name).all()
|
||||
|
||||
return render_template('content/upload_content.html',
|
||||
players=players,
|
||||
selected_player_id=player_id,
|
||||
return_url=return_url)
|
||||
|
||||
try:
|
||||
# Get form data
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
media_type = request.form.get('media_type', 'image')
|
||||
duration = request.form.get('duration', type=int, default=10)
|
||||
session_id = request.form.get('session_id', os.urandom(8).hex())
|
||||
return_url = request.form.get('return_url', url_for('content.content_list'))
|
||||
|
||||
# Get files
|
||||
files = request.files.getlist('files')
|
||||
|
||||
if not files or files[0].filename == '':
|
||||
flash('No files provided.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
if not player_id:
|
||||
flash('Please select a player.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
# Initialize progress tracking using shared utility
|
||||
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
|
||||
|
||||
# Process each file
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
processed_count = 0
|
||||
total_files = len(files)
|
||||
|
||||
for idx, file in enumerate(files):
|
||||
if file.filename == '':
|
||||
continue
|
||||
|
||||
# Update progress
|
||||
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
|
||||
set_upload_progress(session_id, progress_pct,
|
||||
f'Processing file {idx + 1} of {total_files}...', 'processing')
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
|
||||
# Save file
|
||||
file.save(filepath)
|
||||
|
||||
# Determine content type
|
||||
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||
|
||||
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
|
||||
content_type = 'image'
|
||||
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
||||
content_type = 'video'
|
||||
# Process video (convert to Raspberry Pi optimized format)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
|
||||
success, message = process_video_file(filepath, session_id)
|
||||
if not success:
|
||||
log_action('error', f'Video optimization failed: {message}')
|
||||
continue # Skip this file and move to next
|
||||
elif file_ext == 'pdf':
|
||||
content_type = 'pdf'
|
||||
# Process PDF (convert to images)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Converting PDF {idx + 1}...', 'processing')
|
||||
# process_pdf_file(filepath, session_id)
|
||||
elif file_ext in ['ppt', 'pptx']:
|
||||
content_type = 'presentation'
|
||||
# Process presentation (convert to PDF then images)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Converting PowerPoint {idx + 1}...', 'processing')
|
||||
# This would call pptx_converter utility
|
||||
else:
|
||||
content_type = 'other'
|
||||
|
||||
# Create content record linked to player
|
||||
from app.models import Player
|
||||
player = Player.query.get(player_id)
|
||||
if player:
|
||||
new_content = Content(
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(filepath),
|
||||
player_id=player_id
|
||||
)
|
||||
db.session.add(new_content)
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
|
||||
|
||||
processed_count += 1
|
||||
|
||||
# Commit all changes
|
||||
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
|
||||
db.session.commit()
|
||||
|
||||
# Complete
|
||||
set_upload_progress(session_id, 100,
|
||||
f'Successfully uploaded {processed_count} file(s)!', 'complete')
|
||||
|
||||
# Clear all playlist caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
|
||||
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
|
||||
|
||||
return redirect(return_url)
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
|
||||
# Update progress to error state
|
||||
if 'session_id' in locals():
|
||||
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
|
||||
|
||||
log_action('error', f'Error uploading content: {str(e)}')
|
||||
flash('Error uploading content. Please try again.', 'danger')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_content(content_id: int):
|
||||
"""Edit content metadata."""
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('content/edit_content.html', content=content)
|
||||
|
||||
try:
|
||||
duration = request.form.get('duration', type=int)
|
||||
description = request.form.get('description', '').strip()
|
||||
|
||||
# Update content
|
||||
if duration is not None:
|
||||
content.duration = duration
|
||||
content.description = description or None
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
|
||||
flash(f'Content "{content.filename}" updated successfully.', 'success')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating content: {str(e)}')
|
||||
flash('Error updating content. Please try again.', 'danger')
|
||||
return redirect(url_for('content.edit_content', content_id=content_id))
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_content(content_id: int):
|
||||
"""Delete content and associated file."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
filename = content.filename
|
||||
|
||||
# Delete file from disk
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
# Delete from database
|
||||
db.session.delete(content)
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
|
||||
flash(f'Content "{filename}" deleted successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting content: {str(e)}')
|
||||
flash('Error deleting content. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
|
||||
@content_bp.route('/delete-by-filename', methods=['POST'])
|
||||
@login_required
|
||||
def delete_by_filename():
|
||||
"""Delete all content entries with a specific filename."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
filename = data.get('filename')
|
||||
|
||||
if not filename:
|
||||
return jsonify({'success': False, 'message': 'No filename provided'}), 400
|
||||
|
||||
# Find all content entries with this filename
|
||||
contents = Content.query.filter_by(filename=filename).all()
|
||||
|
||||
if not contents:
|
||||
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
||||
|
||||
deleted_count = len(contents)
|
||||
|
||||
# Delete file from disk (only once)
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
log_action('info', f'Deleted file from disk: {filename}')
|
||||
|
||||
# Delete all database entries
|
||||
for content in contents:
|
||||
db.session.delete(content)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Content deleted from {deleted_count} playlist(s)',
|
||||
'deleted_count': deleted_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting content by filename: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/bulk/delete', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_delete_content():
|
||||
"""Delete multiple content items at once."""
|
||||
try:
|
||||
content_ids = request.json.get('content_ids', [])
|
||||
|
||||
if not content_ids:
|
||||
return jsonify({'success': False, 'error': 'No content selected'}), 400
|
||||
|
||||
# Delete content
|
||||
deleted_count = 0
|
||||
for content_id in content_ids:
|
||||
content = Content.query.get(content_id)
|
||||
if content:
|
||||
# Delete file
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Bulk deleted {deleted_count} content items')
|
||||
return jsonify({'success': True, 'deleted': deleted_count})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error bulk deleting content: {str(e)}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/upload-progress/<upload_id>')
|
||||
@login_required
|
||||
def upload_progress_status(upload_id: str):
|
||||
"""Get upload progress for a specific upload."""
|
||||
progress = get_upload_progress(upload_id)
|
||||
return jsonify(progress)
|
||||
|
||||
|
||||
@content_bp.route('/preview/<int:content_id>')
|
||||
@login_required
|
||||
def preview_content(content_id: int):
|
||||
"""Preview content in browser."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
# Serve file from uploads folder
|
||||
return send_from_directory(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
content.filename,
|
||||
as_attachment=False
|
||||
)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error previewing content: {str(e)}')
|
||||
return "Error loading content", 500
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/download')
|
||||
@login_required
|
||||
def download_content(content_id: int):
|
||||
"""Download content file."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
log_action('info', f'Content "{content.filename}" downloaded')
|
||||
|
||||
return send_from_directory(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
content.filename,
|
||||
as_attachment=True
|
||||
)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error downloading content: {str(e)}')
|
||||
return "Error downloading content", 500
|
||||
|
||||
|
||||
@content_bp.route('/statistics')
|
||||
@login_required
|
||||
def content_statistics():
|
||||
"""Get content statistics."""
|
||||
try:
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Count by type
|
||||
type_counts = {}
|
||||
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
|
||||
count = Content.query.filter_by(content_type=content_type).count()
|
||||
type_counts[content_type] = count
|
||||
|
||||
# Calculate total storage
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
total_size = 0
|
||||
if os.path.exists(upload_folder):
|
||||
for dirpath, dirnames, filenames in os.walk(upload_folder):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
if os.path.exists(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
|
||||
return jsonify({
|
||||
'total': total_content,
|
||||
'by_type': type_counts,
|
||||
'total_size_mb': round(total_size / (1024 * 1024), 2)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting content statistics: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/check-duplicates')
|
||||
@login_required
|
||||
def check_duplicates():
|
||||
"""Check for duplicate filenames."""
|
||||
try:
|
||||
# Get all filenames
|
||||
all_content = Content.query.all()
|
||||
filename_counts = {}
|
||||
|
||||
for content in all_content:
|
||||
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
|
||||
|
||||
# Find duplicates
|
||||
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
|
||||
|
||||
return jsonify({
|
||||
'has_duplicates': len(duplicates) > 0,
|
||||
'duplicates': duplicates
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error checking duplicates: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/groups')
|
||||
@login_required
|
||||
def content_groups_info(content_id: int):
|
||||
"""Get groups that contain this content."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
groups_data = []
|
||||
for group in content.groups:
|
||||
groups_data.append({
|
||||
'id': group.id,
|
||||
'name': group.name,
|
||||
'description': group.description,
|
||||
'player_count': group.players.count()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'content_id': content_id,
|
||||
'filename': content.filename,
|
||||
'groups': groups_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting content groups: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -5,8 +5,10 @@ from flask import Blueprint, render_template, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db, cache
|
||||
from app.models.player import Player
|
||||
from app.models.group import Group
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.content import Content
|
||||
from app.utils.logger import get_recent_logs
|
||||
import os
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@@ -16,15 +18,30 @@ main_bp = Blueprint('main', __name__)
|
||||
@cache.cached(timeout=60, unless=lambda: current_user.role != 'viewer')
|
||||
def dashboard():
|
||||
"""Main dashboard page"""
|
||||
players = Player.query.all()
|
||||
groups = Group.query.all()
|
||||
# Get statistics
|
||||
total_players = Player.query.count()
|
||||
total_playlists = Playlist.query.count()
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Calculate storage usage
|
||||
upload_folder = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads')
|
||||
storage_mb = 0
|
||||
if os.path.exists(upload_folder):
|
||||
for filename in os.listdir(upload_folder):
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
if os.path.isfile(filepath):
|
||||
storage_mb += os.path.getsize(filepath)
|
||||
storage_mb = round(storage_mb / (1024 * 1024), 2) # Convert to MB
|
||||
|
||||
server_logs = get_recent_logs(20)
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
players=players,
|
||||
groups=groups,
|
||||
server_logs=server_logs
|
||||
total_players=total_players,
|
||||
total_playlists=total_playlists,
|
||||
total_content=total_content,
|
||||
storage_mb=storage_mb,
|
||||
recent_logs=server_logs
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import secrets
|
||||
from typing import Optional, List
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Player, Group, Content, PlayerFeedback
|
||||
from app.models import Player, Content, PlayerFeedback, Playlist
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.group_player_management import get_player_status_info
|
||||
|
||||
@@ -20,7 +20,7 @@ def list():
|
||||
"""Display list of all players."""
|
||||
try:
|
||||
players = Player.query.order_by(Player.name).all()
|
||||
groups = Group.query.all()
|
||||
playlists = Playlist.query.all()
|
||||
|
||||
# Get player status for each player
|
||||
player_statuses = {}
|
||||
@@ -30,7 +30,7 @@ def list():
|
||||
|
||||
return render_template('players/players_list.html',
|
||||
players=players,
|
||||
groups=groups,
|
||||
playlists=playlists,
|
||||
player_statuses=player_statuses)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading players list: {str(e)}')
|
||||
@@ -43,8 +43,7 @@ def list():
|
||||
def add_player():
|
||||
"""Add a new player."""
|
||||
if request.method == 'GET':
|
||||
groups = Group.query.order_by(Group.name).all()
|
||||
return render_template('players/add_player.html', groups=groups)
|
||||
return render_template('players/add_player.html')
|
||||
|
||||
try:
|
||||
name = request.form.get('name', '').strip()
|
||||
@@ -53,7 +52,6 @@ def add_player():
|
||||
password = request.form.get('password', '').strip()
|
||||
quickconnect_code = request.form.get('quickconnect_code', '').strip()
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
group_id = request.form.get('group_id')
|
||||
|
||||
# Validation
|
||||
if not name or len(name) < 3:
|
||||
@@ -83,8 +81,7 @@ def add_player():
|
||||
hostname=hostname,
|
||||
location=location or None,
|
||||
auth_code=auth_code,
|
||||
orientation=orientation,
|
||||
group_id=int(group_id) if group_id else None
|
||||
orientation=orientation
|
||||
)
|
||||
|
||||
# Set password if provided
|
||||
@@ -128,13 +125,11 @@ def edit_player(player_id: int):
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
groups = Group.query.order_by(Group.name).all()
|
||||
return render_template('players/edit_player.html', player=player, groups=groups)
|
||||
return render_template('players/edit_player.html', player=player)
|
||||
|
||||
try:
|
||||
name = request.form.get('name', '').strip()
|
||||
location = request.form.get('location', '').strip()
|
||||
group_id = request.form.get('group_id')
|
||||
|
||||
# Validation
|
||||
if not name or len(name) < 3:
|
||||
@@ -144,7 +139,6 @@ def edit_player(player_id: int):
|
||||
# Update player
|
||||
player.name = name
|
||||
player.location = location or None
|
||||
player.group_id = int(group_id) if group_id else None
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache for this player
|
||||
@@ -243,6 +237,88 @@ def player_page(player_id: int):
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/manage', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def manage_player(player_id: int):
|
||||
"""Manage player - edit credentials, assign playlist, view logs."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
try:
|
||||
if action == 'update_credentials':
|
||||
# Update player name, location, orientation
|
||||
name = request.form.get('name', '').strip()
|
||||
location = request.form.get('location', '').strip()
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
|
||||
if not name or len(name) < 3:
|
||||
flash('Player name must be at least 3 characters long.', 'warning')
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
player.name = name
|
||||
player.location = location or None
|
||||
player.orientation = orientation
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Player "{name}" credentials updated')
|
||||
flash(f'Player "{name}" updated successfully.', 'success')
|
||||
|
||||
elif action == 'assign_playlist':
|
||||
# Assign playlist to player
|
||||
playlist_id = request.form.get('playlist_id')
|
||||
|
||||
if playlist_id:
|
||||
playlist = Playlist.query.get(int(playlist_id))
|
||||
if playlist:
|
||||
player.playlist_id = int(playlist_id)
|
||||
db.session.commit()
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
log_action('info', f'Player "{player.name}" assigned to playlist "{playlist.name}"')
|
||||
flash(f'Player assigned to playlist "{playlist.name}".', 'success')
|
||||
else:
|
||||
flash('Invalid playlist selected.', 'warning')
|
||||
else:
|
||||
# Unassign playlist
|
||||
player.playlist_id = None
|
||||
db.session.commit()
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
log_action('info', f'Player "{player.name}" unassigned from playlist')
|
||||
flash('Player unassigned from playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error managing player: {str(e)}')
|
||||
flash('Error updating player. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
# GET request - show manage page
|
||||
playlists = Playlist.query.order_by(Playlist.name).all()
|
||||
|
||||
# Get player's current playlist
|
||||
current_playlist = None
|
||||
if player.playlist_id:
|
||||
current_playlist = Playlist.query.get(player.playlist_id)
|
||||
|
||||
# Get recent feedback/logs from player
|
||||
recent_logs = PlayerFeedback.query.filter_by(player_id=player_id)\
|
||||
.order_by(PlayerFeedback.timestamp.desc())\
|
||||
.limit(20)\
|
||||
.all()
|
||||
|
||||
# Get player status
|
||||
status_info = get_player_status_info(player_id)
|
||||
|
||||
return render_template('players/manage_player.html',
|
||||
player=player,
|
||||
playlists=playlists,
|
||||
current_playlist=current_playlist,
|
||||
recent_logs=recent_logs,
|
||||
status_info=status_info)
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/fullscreen')
|
||||
def player_fullscreen(player_id: int):
|
||||
"""Display player fullscreen view (no authentication required for players)."""
|
||||
@@ -303,35 +379,11 @@ def get_player_playlist(player_id: int) -> List[dict]:
|
||||
@players_bp.route('/<int:player_id>/reorder', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_content(player_id: int):
|
||||
"""Reorder content for a player's group."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if not player.group_id:
|
||||
flash('Player is not assigned to a group.', 'warning')
|
||||
return redirect(url_for('players.player_page', player_id=player_id))
|
||||
|
||||
# Get new order from request
|
||||
content_order = request.json.get('order', [])
|
||||
|
||||
# Update positions
|
||||
for idx, content_id in enumerate(content_order):
|
||||
content = Content.query.get(content_id)
|
||||
if content and content in player.group.contents:
|
||||
content.position = idx
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
|
||||
log_action('info', f'Content reordered for player {player_id}')
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error reordering content: {str(e)}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
"""Legacy endpoint - Content reordering now handled in playlist management."""
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Content reordering is now managed through playlists. Use the Playlists page to reorder content.'
|
||||
}), 400
|
||||
|
||||
|
||||
@players_bp.route('/bulk/delete', methods=['POST'])
|
||||
@@ -366,35 +418,35 @@ def bulk_delete_players():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@players_bp.route('/bulk/assign-group', methods=['POST'])
|
||||
@players_bp.route('/bulk/assign-playlist', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_assign_group():
|
||||
"""Assign multiple players to a group."""
|
||||
def bulk_assign_playlist():
|
||||
"""Assign multiple players to a playlist."""
|
||||
try:
|
||||
player_ids = request.json.get('player_ids', [])
|
||||
group_id = request.json.get('group_id')
|
||||
playlist_id = request.json.get('playlist_id')
|
||||
|
||||
if not player_ids:
|
||||
return jsonify({'success': False, 'error': 'No players selected'}), 400
|
||||
|
||||
# Validate group
|
||||
if group_id:
|
||||
group = Group.query.get(group_id)
|
||||
if not group:
|
||||
return jsonify({'success': False, 'error': 'Invalid group'}), 400
|
||||
# Validate playlist
|
||||
if playlist_id:
|
||||
playlist = Playlist.query.get(playlist_id)
|
||||
if not playlist:
|
||||
return jsonify({'success': False, 'error': 'Invalid playlist'}), 400
|
||||
|
||||
# Assign players
|
||||
updated_count = 0
|
||||
for player_id in player_ids:
|
||||
player = Player.query.get(player_id)
|
||||
if player:
|
||||
player.group_id = group_id
|
||||
player.playlist_id = playlist_id
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
updated_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Bulk assigned {updated_count} players to group {group_id}')
|
||||
log_action('info', f'Bulk assigned {updated_count} players to playlist {playlist_id}')
|
||||
return jsonify({'success': True, 'updated': updated_count})
|
||||
|
||||
except Exception as e:
|
||||
|
||||
232
app/blueprints/playlist.py
Normal file
232
app/blueprints/playlist.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Playlist blueprint for managing player playlists."""
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
flash, jsonify, current_app)
|
||||
from flask_login import login_required
|
||||
from sqlalchemy import desc
|
||||
import os
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Player, Content
|
||||
from app.utils.logger import log_action
|
||||
|
||||
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>')
|
||||
@login_required
|
||||
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 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]
|
||||
|
||||
return render_template('playlist/manage_playlist.html',
|
||||
player=player,
|
||||
playlist_content=playlist_content,
|
||||
available_files=available_files)
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_to_playlist(player_id: int):
|
||||
"""Add content to player's playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
try:
|
||||
filename = request.form.get('filename')
|
||||
duration = request.form.get('duration', type=int, default=10)
|
||||
|
||||
if not filename:
|
||||
flash('Please provide a filename.', 'warning')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
# Get max position
|
||||
max_position = db.session.query(db.func.max(Content.position)).filter_by(
|
||||
player_id=player_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
|
||||
)
|
||||
db.session.add(new_content)
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Added "{filename}" to playlist for player "{player.name}"')
|
||||
flash(f'Added "{filename}" to playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error adding to playlist: {str(e)}')
|
||||
flash('Error adding to playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/remove/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
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')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
try:
|
||||
filename = content.filename
|
||||
|
||||
# Delete content
|
||||
db.session.delete(content)
|
||||
|
||||
# Reorder remaining content
|
||||
remaining_content = Content.query.filter_by(
|
||||
player_id=player_id
|
||||
).order_by(Content.position).all()
|
||||
|
||||
for idx, item in enumerate(remaining_content, start=1):
|
||||
item.position = idx
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Removed "{filename}" from playlist for player "{player.name}"')
|
||||
flash(f'Removed "{filename}" from playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error removing from playlist: {str(e)}')
|
||||
flash('Error removing from playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/reorder', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_playlist(player_id: int):
|
||||
"""Reorder playlist items."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
try:
|
||||
# Get new order from JSON
|
||||
data = request.get_json()
|
||||
content_ids = data.get('content_ids', [])
|
||||
|
||||
if not content_ids:
|
||||
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
|
||||
|
||||
# Update positions
|
||||
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
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Reordered playlist for player "{player.name}" (version {player.playlist_version})')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Playlist reordered successfully',
|
||||
'version': player.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
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/update-duration/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
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
|
||||
|
||||
try:
|
||||
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
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Updated duration for "{content.filename}" in player "{player.name}" playlist')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Duration updated',
|
||||
'version': player.playlist_version
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating duration: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/clear', methods=['POST'])
|
||||
@login_required
|
||||
def clear_playlist(player_id: int):
|
||||
"""Clear all content from player's playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
try:
|
||||
# Delete all content for this player
|
||||
Content.query.filter_by(player_id=player_id).delete()
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Cleared playlist for player "{player.name}"')
|
||||
flash('Playlist cleared successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error clearing playlist: {str(e)}')
|
||||
flash('Error clearing playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
Reference in New Issue
Block a user