6b0e51898c
The maintenance-page reset used to rotate auth_code immediately. Because the token also guards the playlist endpoints, that stopped a player from syncing content the instant the button was pressed -- before the player ever had a chance to react -- and clients that do not understand the heartbeat's 401 auth_required would have stayed broken until redeployed. Split the model behaviour: * request_reauth() -- sets the flag only (used by the admin reset endpoints and the automatic interval). The current token stays valid, so there is no outage while the player is being asked to re-authenticate. * issue_new_token() -- replaces the token now (used by the existing "regenerate auth code" action, where the admin is shown the new value). * mark_authenticated() -- if a reset was pending, this is where the old token is retired: a new token is issued and returned to the player in the /api/auth/player response. Net effect: the reset is a request the player acts on, never a forced logout. A player that ignores it keeps working and shows as "awaiting re-auth" in the UI; one that handles it gets a fresh token and the old one stops working. Maintenance page wording updated to match (request vs reset; "awaiting re-auth" badge), plus a themed info note explaining it is not a forced logout. Adds docs/player-auth-reset.md describing the protocol and exactly what the player client (Kiwy-Signage) still needs to implement: detect 401 / auth_required on the heartbeat (and on the token-guarded endpoints), re-run POST /api/auth/player with the stored quick-connect code, save the new auth_code and retry. No password or SSH redeploy required. Verified end-to-end: token unchanged and still accepted after the request (200), heartbeat answers 401 auth_required=token_reset, re-auth issues a different token, the old token is then rejected (403) and the new one accepted (200), flag cleared; the interval path only flags without rotating.
684 lines
28 KiB
Python
684 lines
28 KiB
Python
"""Players blueprint for player management and display."""
|
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
|
|
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
|
|
|
|
# Build a JSON-safe dict of deployment statuses for the polling JS
|
|
import json
|
|
from datetime import datetime as dt
|
|
player_statuses_json = {}
|
|
for player in players:
|
|
player_statuses_json[str(player.id)] = {
|
|
'deployment_status': player.deployment_status,
|
|
'last_deployment_status': player.last_deployment_status,
|
|
'last_deployment_at': player.last_deployment_at.isoformat() if player.last_deployment_at else None,
|
|
'last_deployment_message': player.last_deployment_message,
|
|
}
|
|
|
|
return render_template('players/players_list.html',
|
|
players=players,
|
|
playlists=playlists,
|
|
player_statuses=player_statuses,
|
|
player_statuses_json=json.dumps(player_statuses_json))
|
|
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 with optional SSH deployment."""
|
|
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()
|
|
|
|
# Get SSH deployment info if provided
|
|
ssh_hostname = request.form.get('ssh_hostname', '').strip()
|
|
ssh_username = request.form.get('ssh_username', '').strip()
|
|
ssh_password = request.form.get('ssh_password', '').strip()
|
|
ssh_port = int(request.form.get('ssh_port', '22')) if request.form.get('ssh_port') else 22
|
|
deploy_player = request.form.get('deploy_player', '').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')
|
|
|
|
# If deployment requested and SSH credentials provided, trigger background deployment
|
|
deployment_initiated = False
|
|
if deploy_player and ssh_hostname and ssh_username and ssh_password:
|
|
try:
|
|
from app.utils.background_tasks import background_player_deployment, run_background_task
|
|
|
|
# Determine the server address the player should contact.
|
|
from flask import request as flask_request
|
|
from app.models.https_config import HTTPSConfig
|
|
|
|
server_url = None
|
|
try:
|
|
import os
|
|
from app.utils.player_build import get_player_server_settings, BUILD_META_FILENAME
|
|
meta_path = os.path.join(current_app.instance_path, BUILD_META_FILENAME)
|
|
build_srv = get_player_server_settings(meta_path)
|
|
if build_srv:
|
|
scheme = 'https' if build_srv['use_https'] else 'http'
|
|
server_url = f"{scheme}://{build_srv['server_ip']}:{build_srv['port']}"
|
|
except Exception:
|
|
server_url = None
|
|
|
|
if not server_url:
|
|
https_cfg = HTTPSConfig.get_config()
|
|
if https_cfg and https_cfg.https_enabled and (https_cfg.domain or https_cfg.ip_address):
|
|
host = https_cfg.domain or https_cfg.ip_address
|
|
cfg_port = https_cfg.port or 443
|
|
server_url = f"https://{host}:{cfg_port}"
|
|
else:
|
|
host = flask_request.host
|
|
hostname_only = host.split(':')[0]
|
|
if hostname_only in ('localhost', '127.0.0.1', '') or hostname_only.startswith('127.'):
|
|
from app.utils.ssh_deploy import detect_server_ip
|
|
detected_ip = detect_server_ip()
|
|
if detected_ip:
|
|
port_part = host.split(':', 1)[1] if ':' in host else ''
|
|
host = f"{detected_ip}:{port_part}" if port_part else detected_ip
|
|
server_url = f"{flask_request.scheme}://{host}"
|
|
|
|
# Mark deployment as "in progress" immediately so the UI polling picks it up
|
|
from datetime import datetime
|
|
new_player.deployment_status = 'deploying'
|
|
new_player.last_deployment_at = datetime.utcnow()
|
|
new_player.last_deployment_message = 'Deployment in progress...'
|
|
db.session.commit()
|
|
|
|
# Generate API key for player authentication
|
|
import hashlib
|
|
api_key = hashlib.sha256(f'{name}:{hostname}'.encode()).hexdigest()[:32]
|
|
|
|
# Start deployment in background thread
|
|
run_background_task(
|
|
background_player_deployment,
|
|
hostname=ssh_hostname,
|
|
username=ssh_username,
|
|
password=ssh_password,
|
|
player_name=name,
|
|
player_id=new_player.id,
|
|
port=ssh_port,
|
|
server_url=server_url,
|
|
server_api_key=api_key,
|
|
player_hostname=hostname,
|
|
quickconnect_code=quickconnect_code,
|
|
orientation=orientation
|
|
)
|
|
deployment_initiated = True
|
|
log_action('info', f'Background deployment initiated for player "{name}" on {ssh_hostname}')
|
|
except Exception as deploy_err:
|
|
log_action('error', f'Failed to initiate background deployment for player "{name}": {str(deploy_err)}')
|
|
|
|
# Flash detailed success message
|
|
if deployment_initiated:
|
|
success_msg = f'''
|
|
Player "{name}" created successfully!<br>
|
|
<strong>Auth Code:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{auth_code}</code><br>
|
|
<strong>Hostname:</strong> {hostname}<br>
|
|
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
|
<br>
|
|
<strong style="color: #0275d8;">⌛ Deployment in Progress</strong><br>
|
|
Deploying to <strong>{ssh_hostname}</strong> in background...<br>
|
|
<small>The deployment status will update automatically on the players list.</small>
|
|
'''
|
|
flash(success_msg, 'success')
|
|
else:
|
|
success_msg = f'''
|
|
Player "{name}" created successfully!<br>
|
|
<strong>Auth Code:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{auth_code}</code><br>
|
|
<strong>Hostname:</strong> {hostname}<br>
|
|
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
|
<br>
|
|
<small>Configure the player with these credentials in app_config.json</small>
|
|
'''
|
|
flash(success_msg, 'success')
|
|
if deploy_player:
|
|
flash('Player was created but deployment could not be started. You can deploy manually from the Manage page.', 'warning')
|
|
|
|
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)
|
|
|
|
# Issue a new auth code immediately and stamp the issue time so the
|
|
# maintenance page's reset interval measures age from this point. The
|
|
# admin is shown the new value, so an immediate swap is intended here —
|
|
# unlike the maintenance page, which only asks the player to re-auth.
|
|
new_auth_code = player.issue_new_token()
|
|
player.auth_reset_required = False
|
|
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>/edited-media')
|
|
@login_required
|
|
def edited_media(player_id: int):
|
|
"""Display all edited media files from this player."""
|
|
try:
|
|
player = Player.query.get_or_404(player_id)
|
|
|
|
# Get all edited media history from player
|
|
from app.models.player_edit import PlayerEdit
|
|
from app.models.player_user import PlayerUser
|
|
|
|
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
|
|
.order_by(PlayerEdit.created_at.desc())\
|
|
.all()
|
|
|
|
# Get original content files for each edited media
|
|
content_files = {}
|
|
for edit in edited_media:
|
|
if edit.content_id not in content_files:
|
|
content = Content.query.get(edit.content_id)
|
|
if content:
|
|
content_files[edit.content_id] = content
|
|
|
|
# Get user mappings for display names
|
|
user_mappings = {}
|
|
for edit in edited_media:
|
|
if edit.user and edit.user not in user_mappings:
|
|
player_user = PlayerUser.query.filter_by(user_code=edit.user).first()
|
|
if player_user:
|
|
user_mappings[edit.user] = player_user.user_name or edit.user
|
|
else:
|
|
user_mappings[edit.user] = edit.user
|
|
|
|
return render_template('players/edited_media.html',
|
|
player=player,
|
|
edited_media=edited_media,
|
|
content_files=content_files,
|
|
user_mappings=user_mappings)
|
|
except Exception as e:
|
|
log_action('error', f'Error loading edited media for player {player_id}: {str(e)}')
|
|
flash('Error loading edited media.', 'danger')
|
|
return redirect(url_for('players.manage_player', player_id=player_id))
|
|
|
|
|
|
@players_bp.route('/<int:player_id>/edited-media-report')
|
|
@login_required
|
|
def edited_media_report(player_id: int):
|
|
"""Display a tabular report of all edited media from this player."""
|
|
try:
|
|
player = Player.query.get_or_404(player_id)
|
|
|
|
from app.models.player_edit import PlayerEdit
|
|
from app.models.player_user import PlayerUser
|
|
|
|
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
|
|
.order_by(PlayerEdit.created_at.desc())\
|
|
.all()
|
|
|
|
# Build user display name mapping
|
|
user_mappings = {}
|
|
for edit in edited_media:
|
|
if edit.user and edit.user not in user_mappings:
|
|
player_user = PlayerUser.query.filter_by(user_code=edit.user).first()
|
|
if player_user and player_user.user_name:
|
|
user_mappings[edit.user] = player_user.user_name
|
|
else:
|
|
user_mappings[edit.user] = edit.user
|
|
|
|
return render_template('players/edited_media_report.html',
|
|
player=player,
|
|
edited_media=edited_media,
|
|
user_mappings=user_mappings)
|
|
except Exception as e:
|
|
log_action('error', f'Error loading edited media report for player {player_id}: {str(e)}')
|
|
flash('Error loading edited media report.', 'danger')
|
|
return redirect(url_for('players.manage_player', player_id=player_id))
|
|
|
|
|
|
@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:
|
|
# For weblinks, serve the actual URL directly; for files, serve static path
|
|
if content.content_type == 'weblink' and content.url:
|
|
item_url = content.url
|
|
else:
|
|
item_url = url_for('static', filename=f'uploads/{content.filename}')
|
|
playlist.append({
|
|
'id': content.id,
|
|
'url': item_url,
|
|
'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),
|
|
'audio': 'off' if getattr(content, '_playlist_muted', True) else 'on',
|
|
'filename': content.filename
|
|
})
|
|
|
|
return playlist
|
|
|
|
|
|
|
|
|
|
@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('/deployment-status')
|
|
@login_required
|
|
def deployment_status():
|
|
"""Return deployment status for all players (used by polling JS)."""
|
|
try:
|
|
players = Player.query.with_entities(
|
|
Player.id, Player.deployment_status,
|
|
Player.last_deployment_status, Player.last_deployment_at,
|
|
Player.last_deployment_message
|
|
).all()
|
|
data = {}
|
|
for p in players:
|
|
data[p.id] = {
|
|
'deployment_status': p.deployment_status,
|
|
'last_deployment_status': p.last_deployment_status,
|
|
'last_deployment_at': p.last_deployment_at.isoformat() if p.last_deployment_at else None,
|
|
'last_deployment_message': p.last_deployment_message,
|
|
}
|
|
return jsonify(data)
|
|
except Exception as e:
|
|
log_action('error', f'Error fetching deployment status: {str(e)}')
|
|
return jsonify({'error': str(e)}), 500
|