Files
digiserver-v2/app/blueprints/players.py
T
ske087 6b0e51898c Make player token reset non-breaking (deferred rotation)
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.
2026-10-14 23:14:11 +03:00

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;">&#8987; 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