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:
ske087
2025-11-13 21:00:07 +02:00
parent e5a00d19a5
commit 498c03ef00
37 changed files with 4240 additions and 840 deletions

View File

@@ -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()

View File

@@ -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'))

View 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

View File

@@ -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
)

View File

@@ -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
View 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))