"""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!
Auth Code: {auth_code}
Hostname: {hostname}
Quick Connect: {quickconnect_code}
Configure the player with these credentials in app_config.json ''' 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('//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('//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('//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('/') @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('//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('//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('//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('//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('//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