- Added PlayerEdit model to track edited media history - Created /api/player-edit-media endpoint for receiving edited files from players - Implemented versioned storage: edited_media/<content_id>/<filename_vN.ext> - Automatic playlist update when edited media is received - Updated content.filename to reference versioned file in playlist - Added 'Edited Media on the Player' card to player management page - UI shows version history grouped by original file - Each edit preserves previous versions in archive folder - Includes dark mode support for new UI elements - Documentation: PLAYER_EDIT_MEDIA_API.md
569 lines
22 KiB
Python
569 lines
22 KiB
Python
"""Players blueprint for player management and display."""
|
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
|
from flask_login import login_required
|
|
from werkzeug.security import generate_password_hash
|
|
import secrets
|
|
from typing import Optional, List
|
|
|
|
from app.extensions import db, cache
|
|
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
|
|
|
|
players_bp = Blueprint('players', __name__, url_prefix='/players')
|
|
|
|
|
|
@players_bp.route('/')
|
|
@players_bp.route('/list')
|
|
@login_required
|
|
def list():
|
|
"""Display list of all players."""
|
|
try:
|
|
players = Player.query.order_by(Player.name).all()
|
|
playlists = Playlist.query.all()
|
|
|
|
# Get player status for each player
|
|
player_statuses = {}
|
|
for player in players:
|
|
status_info = get_player_status_info(player.id)
|
|
player_statuses[player.id] = status_info
|
|
|
|
return render_template('players/players_list.html',
|
|
players=players,
|
|
playlists=playlists,
|
|
player_statuses=player_statuses)
|
|
except Exception as e:
|
|
log_action('error', f'Error loading players list: {str(e)}')
|
|
flash('Error loading players list.', 'danger')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
|
|
@players_bp.route('/add', methods=['GET', 'POST'])
|
|
@login_required
|
|
def add_player():
|
|
"""Add a new player."""
|
|
if request.method == 'GET':
|
|
playlists = Playlist.query.filter_by(is_active=True).order_by(Playlist.name).all()
|
|
return render_template('players/add_player.html', playlists=playlists)
|
|
|
|
try:
|
|
name = request.form.get('name', '').strip()
|
|
hostname = request.form.get('hostname', '').strip()
|
|
location = request.form.get('location', '').strip()
|
|
password = request.form.get('password', '').strip()
|
|
quickconnect_code = request.form.get('quickconnect_code', '').strip()
|
|
orientation = request.form.get('orientation', 'Landscape')
|
|
playlist_id = request.form.get('playlist_id', '').strip()
|
|
|
|
# Validation
|
|
if not name or len(name) < 3:
|
|
flash('Player name must be at least 3 characters long.', 'warning')
|
|
return redirect(url_for('players.add_player'))
|
|
|
|
if not hostname or len(hostname) < 3:
|
|
flash('Hostname must be at least 3 characters long.', 'warning')
|
|
return redirect(url_for('players.add_player'))
|
|
|
|
# Check if hostname already exists
|
|
existing_player = Player.query.filter_by(hostname=hostname).first()
|
|
if existing_player:
|
|
flash(f'A player with hostname "{hostname}" already exists.', 'warning')
|
|
return redirect(url_for('players.add_player'))
|
|
|
|
if not quickconnect_code:
|
|
flash('Quick Connect Code is required.', 'warning')
|
|
return redirect(url_for('players.add_player'))
|
|
|
|
# Generate unique auth code
|
|
auth_code = secrets.token_urlsafe(32)
|
|
|
|
# Create player
|
|
new_player = Player(
|
|
name=name,
|
|
hostname=hostname,
|
|
location=location or None,
|
|
auth_code=auth_code,
|
|
orientation=orientation,
|
|
playlist_id=int(playlist_id) if playlist_id else None
|
|
)
|
|
|
|
# Set password if provided
|
|
if password:
|
|
new_player.set_password(password)
|
|
else:
|
|
# Use quickconnect code as default password
|
|
new_player.set_password(quickconnect_code)
|
|
|
|
# Set quickconnect code
|
|
new_player.set_quickconnect_code(quickconnect_code)
|
|
|
|
db.session.add(new_player)
|
|
db.session.commit()
|
|
|
|
log_action('info', f'Player "{name}" (hostname: {hostname}) created')
|
|
|
|
# Flash detailed success message
|
|
success_msg = f'''
|
|
Player "{name}" created successfully!<br>
|
|
<strong>Auth Code:</strong> {auth_code}<br>
|
|
<strong>Hostname:</strong> {hostname}<br>
|
|
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
|
<small>Configure the player with these credentials in app_config.json</small>
|
|
'''
|
|
flash(success_msg, 'success')
|
|
|
|
return redirect(url_for('players.list'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error creating player: {str(e)}')
|
|
flash('Error creating player. Please try again.', 'danger')
|
|
return redirect(url_for('players.add_player'))
|
|
|
|
|
|
@players_bp.route('/<int:player_id>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
def edit_player(player_id: int):
|
|
"""Edit player details."""
|
|
player = Player.query.get_or_404(player_id)
|
|
|
|
if request.method == 'GET':
|
|
return render_template('players/edit_player.html', player=player)
|
|
|
|
try:
|
|
name = request.form.get('name', '').strip()
|
|
location = request.form.get('location', '').strip()
|
|
|
|
# Validation
|
|
if not name or len(name) < 3:
|
|
flash('Player name must be at least 3 characters long.', 'warning')
|
|
return redirect(url_for('players.edit_player', player_id=player_id))
|
|
|
|
# Update player
|
|
player.name = name
|
|
player.location = location or None
|
|
db.session.commit()
|
|
|
|
# Clear cache for this player
|
|
cache.delete_memoized(get_player_playlist, player_id)
|
|
|
|
log_action('info', f'Player "{name}" (ID: {player_id}) updated')
|
|
flash(f'Player "{name}" updated successfully.', 'success')
|
|
|
|
return redirect(url_for('players.list'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error updating player: {str(e)}')
|
|
flash('Error updating player. Please try again.', 'danger')
|
|
return redirect(url_for('players.edit_player', player_id=player_id))
|
|
|
|
|
|
@players_bp.route('/<int:player_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def delete_player(player_id: int):
|
|
"""Delete a player."""
|
|
try:
|
|
player = Player.query.get_or_404(player_id)
|
|
player_name = player.name
|
|
|
|
# Delete associated feedback
|
|
PlayerFeedback.query.filter_by(player_id=player_id).delete()
|
|
|
|
db.session.delete(player)
|
|
db.session.commit()
|
|
|
|
# Clear cache
|
|
cache.delete_memoized(get_player_playlist, player_id)
|
|
|
|
log_action('info', f'Player "{player_name}" (ID: {player_id}) deleted')
|
|
flash(f'Player "{player_name}" deleted successfully.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error deleting player: {str(e)}')
|
|
flash('Error deleting player. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('players.list'))
|
|
|
|
|
|
@players_bp.route('/<int:player_id>/regenerate-auth', methods=['POST'])
|
|
@login_required
|
|
def regenerate_auth_code(player_id: int):
|
|
"""Regenerate authentication code for a player."""
|
|
try:
|
|
player = Player.query.get_or_404(player_id)
|
|
|
|
# Generate new auth code
|
|
new_auth_code = secrets.token_urlsafe(16)
|
|
player.auth_code = new_auth_code
|
|
db.session.commit()
|
|
|
|
log_action('info', f'Auth code regenerated for player "{player.name}" (ID: {player_id})')
|
|
flash(f'New auth code for "{player.name}": {new_auth_code}', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error regenerating auth code: {str(e)}')
|
|
flash('Error regenerating auth code. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('players.list'))
|
|
|
|
|
|
@players_bp.route('/<int:player_id>')
|
|
@login_required
|
|
def player_page(player_id: int):
|
|
"""Redirect to manage player page (combined view)."""
|
|
return redirect(url_for('players.manage_player', player_id=player_id))
|
|
|
|
|
|
@players_bp.route('/<int:player_id>/manage', methods=['GET', 'POST'])
|
|
@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, and authentication
|
|
name = request.form.get('name', '').strip()
|
|
location = request.form.get('location', '').strip()
|
|
orientation = request.form.get('orientation', 'Landscape')
|
|
hostname = request.form.get('hostname', '').strip()
|
|
password = request.form.get('password', '').strip()
|
|
quickconnect_code = request.form.get('quickconnect_code', '').strip()
|
|
|
|
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))
|
|
|
|
if not hostname or len(hostname) < 3:
|
|
flash('Hostname must be at least 3 characters long.', 'warning')
|
|
return redirect(url_for('players.manage_player', player_id=player_id))
|
|
|
|
# Check if hostname is taken by another player
|
|
if hostname != player.hostname:
|
|
existing = Player.query.filter_by(hostname=hostname).first()
|
|
if existing:
|
|
flash(f'Hostname "{hostname}" is already in use by another player.', 'warning')
|
|
return redirect(url_for('players.manage_player', player_id=player_id))
|
|
|
|
# Update basic info
|
|
player.name = name
|
|
player.hostname = hostname
|
|
player.location = location or None
|
|
player.orientation = orientation
|
|
|
|
# Update password if provided
|
|
if password:
|
|
player.set_password(password)
|
|
log_action('info', f'Password updated for player "{name}"')
|
|
|
|
# Update quickconnect code if provided
|
|
if quickconnect_code:
|
|
player.set_quickconnect_code(quickconnect_code)
|
|
log_action('info', f'QuickConnect code updated for player "{name}" to: {quickconnect_code}')
|
|
|
|
db.session.commit()
|
|
|
|
log_action('info', f'Player "{name}" (hostname: {hostname}) 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 edited media history from player
|
|
from app.models.player_edit import PlayerEdit
|
|
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
|
|
.order_by(PlayerEdit.created_at.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,
|
|
edited_media=edited_media,
|
|
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)."""
|
|
try:
|
|
player = Player.query.get_or_404(player_id)
|
|
|
|
# Verify auth code if provided
|
|
auth_code = request.args.get('auth')
|
|
if auth_code and auth_code != player.auth_code:
|
|
log_action('warning', f'Invalid auth code attempt for player {player_id}')
|
|
return "Invalid authentication code", 403
|
|
|
|
# Get player's playlist
|
|
playlist = get_player_playlist(player_id)
|
|
|
|
return render_template('players/player_fullscreen.html',
|
|
player=player,
|
|
playlist=playlist)
|
|
except Exception as e:
|
|
log_action('error', f'Error loading player fullscreen: {str(e)}')
|
|
return "Error loading player", 500
|
|
|
|
|
|
@cache.memoize(timeout=300) # Cache for 5 minutes
|
|
def get_player_playlist(player_id: int) -> List[dict]:
|
|
"""Get playlist for a player based on their assigned playlist.
|
|
|
|
Args:
|
|
player_id: The player's database ID
|
|
|
|
Returns:
|
|
List of content dictionaries with url, type, duration, and position
|
|
"""
|
|
player = Player.query.get(player_id)
|
|
if not player or not player.playlist_id:
|
|
return []
|
|
|
|
# Get the player's assigned playlist
|
|
playlist_obj = Playlist.query.get(player.playlist_id)
|
|
if not playlist_obj:
|
|
return []
|
|
|
|
# Get ordered content from the playlist
|
|
ordered_content = playlist_obj.get_content_ordered()
|
|
|
|
# Build playlist
|
|
playlist = []
|
|
for content in ordered_content:
|
|
playlist.append({
|
|
'id': content.id,
|
|
'url': url_for('static', filename=f'uploads/{content.filename}'),
|
|
'type': content.content_type,
|
|
'duration': getattr(content, '_playlist_duration', content.duration or 10),
|
|
'position': getattr(content, '_playlist_position', 0),
|
|
'muted': getattr(content, '_playlist_muted', True),
|
|
'filename': content.filename
|
|
})
|
|
|
|
return playlist
|
|
|
|
|
|
@players_bp.route('/<int:player_id>/reorder', methods=['POST'])
|
|
@login_required
|
|
def reorder_content(player_id: int):
|
|
"""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'])
|
|
@login_required
|
|
def bulk_delete_players():
|
|
"""Delete multiple players at once."""
|
|
try:
|
|
player_ids = request.json.get('player_ids', [])
|
|
|
|
if not player_ids:
|
|
return jsonify({'success': False, 'error': 'No players selected'}), 400
|
|
|
|
# Delete players
|
|
deleted_count = 0
|
|
for player_id in player_ids:
|
|
player = Player.query.get(player_id)
|
|
if player:
|
|
# Delete associated feedback
|
|
PlayerFeedback.query.filter_by(player_id=player_id).delete()
|
|
db.session.delete(player)
|
|
cache.delete_memoized(get_player_playlist, player_id)
|
|
deleted_count += 1
|
|
|
|
db.session.commit()
|
|
|
|
log_action('info', f'Bulk deleted {deleted_count} players')
|
|
return jsonify({'success': True, 'deleted': deleted_count})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error bulk deleting players: {str(e)}')
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
@players_bp.route('/bulk/assign-playlist', methods=['POST'])
|
|
@login_required
|
|
def bulk_assign_playlist():
|
|
"""Assign multiple players to a playlist."""
|
|
try:
|
|
player_ids = request.json.get('player_ids', [])
|
|
playlist_id = request.json.get('playlist_id')
|
|
|
|
if not player_ids:
|
|
return jsonify({'success': False, 'error': 'No players selected'}), 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.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 playlist {playlist_id}')
|
|
return jsonify({'success': True, 'updated': updated_count})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error bulk assigning players: {str(e)}')
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
@players_bp.route('/<int:player_id>/playlist/reorder', methods=['POST'])
|
|
@login_required
|
|
def reorder_playlist(player_id: int):
|
|
"""Reorder items in player's playlist."""
|
|
try:
|
|
data = request.get_json()
|
|
content_id = data.get('content_id')
|
|
direction = data.get('direction') # 'up' or 'down'
|
|
|
|
if not content_id or not direction:
|
|
return jsonify({'success': False, 'message': 'Missing parameters'}), 400
|
|
|
|
# Get the content item
|
|
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
|
|
if not content:
|
|
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
|
|
|
# Get all content for this player, ordered by position
|
|
all_content = Content.query.filter_by(player_id=player_id)\
|
|
.order_by(Content.position, Content.uploaded_at).all()
|
|
|
|
# Find current index
|
|
current_index = None
|
|
for idx, item in enumerate(all_content):
|
|
if item.id == content_id:
|
|
current_index = idx
|
|
break
|
|
|
|
if current_index is None:
|
|
return jsonify({'success': False, 'message': 'Content not in playlist'}), 404
|
|
|
|
# Swap positions
|
|
if direction == 'up' and current_index > 0:
|
|
# Swap with previous item
|
|
all_content[current_index].position, all_content[current_index - 1].position = \
|
|
all_content[current_index - 1].position, all_content[current_index].position
|
|
elif direction == 'down' and current_index < len(all_content) - 1:
|
|
# Swap with next item
|
|
all_content[current_index].position, all_content[current_index + 1].position = \
|
|
all_content[current_index + 1].position, all_content[current_index].position
|
|
|
|
db.session.commit()
|
|
cache.delete_memoized(get_player_playlist, player_id)
|
|
|
|
log_action('info', f'Reordered playlist for player {player_id}')
|
|
return jsonify({'success': True})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error reordering playlist: {str(e)}')
|
|
return jsonify({'success': False, 'message': str(e)}), 500
|
|
|
|
|
|
@players_bp.route('/<int:player_id>/playlist/remove', methods=['POST'])
|
|
@login_required
|
|
def remove_from_playlist(player_id: int):
|
|
"""Remove content from player's playlist."""
|
|
try:
|
|
data = request.get_json()
|
|
content_id = data.get('content_id')
|
|
|
|
if not content_id:
|
|
return jsonify({'success': False, 'message': 'Missing content_id'}), 400
|
|
|
|
# Get the content item
|
|
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
|
|
if not content:
|
|
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
|
|
|
filename = content.filename
|
|
|
|
# Delete from database
|
|
db.session.delete(content)
|
|
|
|
# Increment playlist version
|
|
player = Player.query.get(player_id)
|
|
if player:
|
|
player.playlist_version += 1
|
|
|
|
db.session.commit()
|
|
|
|
# Clear cache
|
|
cache.delete_memoized(get_player_playlist, player_id)
|
|
|
|
log_action('info', f'Removed "{filename}" from player {player_id} playlist (version {player.playlist_version})')
|
|
return jsonify({'success': True, 'message': f'Removed "{filename}" from playlist'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error removing from playlist: {str(e)}')
|
|
return jsonify({'success': False, 'message': str(e)}), 500
|
|
|