updated digiserver 2
This commit is contained in:
@@ -59,7 +59,7 @@ def admin_panel():
|
||||
|
||||
storage_mb = round(total_size / (1024 * 1024), 2)
|
||||
|
||||
return render_template('admin.html',
|
||||
return render_template('admin/admin.html',
|
||||
total_users=total_users,
|
||||
total_players=total_players,
|
||||
total_groups=total_groups,
|
||||
|
||||
@@ -94,6 +94,106 @@ def health_check():
|
||||
})
|
||||
|
||||
|
||||
@api_bp.route('/auth/player', methods=['POST'])
|
||||
@rate_limit(max_requests=10, window=60)
|
||||
def authenticate_player():
|
||||
"""Authenticate a player and return auth code and configuration.
|
||||
|
||||
Request JSON:
|
||||
hostname: Player hostname/identifier (required)
|
||||
password: Player password (optional if using quickconnect)
|
||||
quickconnect_code: Quick connect code (optional if using password)
|
||||
|
||||
Returns:
|
||||
JSON with auth_code, player_id, group_id, and configuration
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
hostname = data.get('hostname')
|
||||
password = data.get('password')
|
||||
quickconnect_code = data.get('quickconnect_code')
|
||||
|
||||
if not hostname:
|
||||
return jsonify({'error': 'Hostname is required'}), 400
|
||||
|
||||
if not password and not quickconnect_code:
|
||||
return jsonify({'error': 'Password or quickconnect code required'}), 400
|
||||
|
||||
# Authenticate player
|
||||
player = Player.authenticate(hostname, password, quickconnect_code)
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Failed authentication attempt for hostname: {hostname}')
|
||||
return jsonify({'error': 'Invalid credentials'}), 401
|
||||
|
||||
# Update player status
|
||||
player.update_status('online')
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Player authenticated: {player.name} ({player.hostname})')
|
||||
|
||||
# Return authentication response
|
||||
response = {
|
||||
'success': True,
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'hostname': player.hostname,
|
||||
'auth_code': player.auth_code,
|
||||
'group_id': player.group_id,
|
||||
'orientation': player.orientation,
|
||||
'status': player.status
|
||||
}
|
||||
|
||||
return jsonify(response), 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/verify', methods=['POST'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def verify_auth_code():
|
||||
"""Verify an auth code and return player information.
|
||||
|
||||
Request JSON:
|
||||
auth_code: Player authentication code
|
||||
|
||||
Returns:
|
||||
JSON with player information if valid
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
auth_code = data.get('auth_code')
|
||||
|
||||
if not auth_code:
|
||||
return jsonify({'error': 'Auth code is required'}), 400
|
||||
|
||||
# Find player with this auth code
|
||||
player = Player.query.filter_by(auth_code=auth_code).first()
|
||||
|
||||
if not player:
|
||||
return jsonify({'error': 'Invalid auth code'}), 401
|
||||
|
||||
# Update last seen
|
||||
player.update_status(player.status)
|
||||
db.session.commit()
|
||||
|
||||
response = {
|
||||
'valid': True,
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'hostname': player.hostname,
|
||||
'group_id': player.group_id,
|
||||
'orientation': player.orientation,
|
||||
'status': player.status
|
||||
}
|
||||
|
||||
return jsonify(response), 200
|
||||
|
||||
|
||||
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
@verify_player_auth
|
||||
@@ -120,6 +220,7 @@ def get_player_playlist(player_id: int):
|
||||
'player_id': player_id,
|
||||
'player_name': player.name,
|
||||
'group_id': player.group_id,
|
||||
'playlist_version': player.playlist_version,
|
||||
'playlist': playlist,
|
||||
'count': len(playlist)
|
||||
})
|
||||
@@ -129,6 +230,37 @@ def get_player_playlist(player_id: int):
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/playlist-version/<int:player_id>', methods=['GET'])
|
||||
@verify_player_auth
|
||||
@rate_limit(max_requests=60, window=60)
|
||||
def get_playlist_version(player_id: int):
|
||||
"""Get current playlist version for a player.
|
||||
|
||||
Lightweight endpoint for players to check if playlist needs updating.
|
||||
Requires player authentication via Bearer token.
|
||||
"""
|
||||
try:
|
||||
# Verify the authenticated player matches the requested player_id
|
||||
if request.player.id != player_id:
|
||||
return jsonify({'error': 'Unauthorized access to this player'}), 403
|
||||
|
||||
player = request.player
|
||||
|
||||
# Update last seen
|
||||
player.last_seen = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'playlist_version': player.playlist_version,
|
||||
'content_count': Content.query.filter_by(player_id=player_id).count()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting playlist version for player {player_id}: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@cache.memoize(timeout=300)
|
||||
def get_cached_playlist(player_id: int) -> List[Dict]:
|
||||
"""Get cached playlist for a player."""
|
||||
|
||||
@@ -3,9 +3,9 @@ Authentication Blueprint - Login, Logout, Register
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from extensions import db, bcrypt
|
||||
from models.user import User
|
||||
from utils.logger import log_action, log_user_created
|
||||
from app.extensions import db, bcrypt, login_manager
|
||||
from app.models import User
|
||||
from app.utils.logger import log_action
|
||||
from typing import Optional
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
@@ -34,7 +34,7 @@ def login():
|
||||
# Verify credentials
|
||||
if user and bcrypt.check_password_hash(user.password, password):
|
||||
login_user(user, remember=remember)
|
||||
log_action(f'User {username} logged in')
|
||||
log_action('info', f'User {username} logged in')
|
||||
|
||||
# Redirect to next page or dashboard
|
||||
next_page = request.args.get('next')
|
||||
@@ -43,7 +43,7 @@ def login():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
else:
|
||||
flash('Invalid username or password.', 'danger')
|
||||
log_action(f'Failed login attempt for username: {username}')
|
||||
log_action('warning', f'Failed login attempt for username: {username}')
|
||||
|
||||
# Check for logo
|
||||
import os
|
||||
@@ -60,7 +60,7 @@ def logout():
|
||||
"""User logout"""
|
||||
username = current_user.username
|
||||
logout_user()
|
||||
log_action(f'User {username} logged out')
|
||||
log_action('info', f'User {username} logged out')
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@@ -140,7 +140,7 @@ def change_password():
|
||||
current_user.password = bcrypt.generate_password_hash(new_password).decode('utf-8')
|
||||
db.session.commit()
|
||||
|
||||
log_action(f'User {current_user.username} changed password')
|
||||
log_action('info', f'User {current_user.username} changed password')
|
||||
flash('Password changed successfully.', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@@ -30,16 +30,51 @@ upload_progress = {}
|
||||
def content_list():
|
||||
"""Display list of all content."""
|
||||
try:
|
||||
contents = Content.query.order_by(Content.uploaded_at.desc()).all()
|
||||
# Get all unique content files (by filename)
|
||||
from sqlalchemy import func
|
||||
|
||||
# Get group info for each content
|
||||
content_groups = {}
|
||||
# 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:
|
||||
content_groups[content.id] = content.groups.count()
|
||||
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
|
||||
})
|
||||
|
||||
return render_template('content_list.html',
|
||||
contents=contents,
|
||||
content_groups=content_groups)
|
||||
# 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')
|
||||
@@ -51,86 +86,166 @@ def content_list():
|
||||
def upload_content():
|
||||
"""Upload new content."""
|
||||
if request.method == 'GET':
|
||||
return render_template('upload_content.html')
|
||||
# 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)
|
||||
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
flash('No file provided.', 'warning')
|
||||
# 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'))
|
||||
|
||||
file = request.files['file']
|
||||
|
||||
if file.filename == '':
|
||||
flash('No file selected.', 'warning')
|
||||
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'))
|
||||
|
||||
# Get optional parameters
|
||||
duration = request.form.get('duration', type=int)
|
||||
description = request.form.get('description', '').strip()
|
||||
# Initialize progress tracking using shared utility
|
||||
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
|
||||
|
||||
# Generate unique upload ID for progress tracking
|
||||
upload_id = os.urandom(16).hex()
|
||||
|
||||
# Save file with progress tracking
|
||||
filename = secure_filename(file.filename)
|
||||
# Process each file
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
processed_count = 0
|
||||
total_files = len(files)
|
||||
|
||||
# Save file with progress updates
|
||||
set_upload_progress(upload_id, 0, 'Uploading file...')
|
||||
file.save(filepath)
|
||||
set_upload_progress(upload_id, 50, 'File uploaded, processing...')
|
||||
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
|
||||
|
||||
# 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, optimize, extract metadata)
|
||||
set_upload_progress(upload_id, 60, 'Processing video...')
|
||||
process_video_file(filepath, upload_id)
|
||||
elif file_ext == 'pdf':
|
||||
content_type = 'pdf'
|
||||
# Process PDF (convert to images)
|
||||
set_upload_progress(upload_id, 60, 'Processing PDF...')
|
||||
process_pdf_file(filepath, upload_id)
|
||||
elif file_ext in ['ppt', 'pptx']:
|
||||
content_type = 'presentation'
|
||||
# Process presentation (convert to PDF then images)
|
||||
set_upload_progress(upload_id, 60, 'Processing presentation...')
|
||||
# This would call pptx_converter utility
|
||||
else:
|
||||
content_type = 'other'
|
||||
|
||||
set_upload_progress(upload_id, 90, 'Creating database entry...')
|
||||
|
||||
# Create content record
|
||||
new_content = Content(
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
duration=duration,
|
||||
description=description or None,
|
||||
file_size=os.path.getsize(filepath)
|
||||
)
|
||||
db.session.add(new_content)
|
||||
# Commit all changes
|
||||
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
|
||||
db.session.commit()
|
||||
|
||||
set_upload_progress(upload_id, 100, 'Complete!')
|
||||
# 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'Content "{filename}" uploaded successfully (Type: {content_type})')
|
||||
flash(f'Content "{filename}" uploaded successfully.', 'success')
|
||||
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
|
||||
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
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'))
|
||||
@@ -143,7 +258,7 @@ def edit_content(content_id: int):
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('edit_content.html', content=content)
|
||||
return render_template('content/edit_content.html', content=content)
|
||||
|
||||
try:
|
||||
duration = request.form.get('duration', type=int)
|
||||
@@ -201,6 +316,54 @@ def delete_content(content_id: int):
|
||||
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():
|
||||
|
||||
@@ -24,7 +24,7 @@ def groups_list():
|
||||
stats = get_group_statistics(group.id)
|
||||
group_stats[group.id] = stats
|
||||
|
||||
return render_template('groups_list.html',
|
||||
return render_template('groups/groups_list.html',
|
||||
groups=groups,
|
||||
group_stats=group_stats)
|
||||
except Exception as e:
|
||||
@@ -39,7 +39,7 @@ def create_group():
|
||||
"""Create a new group."""
|
||||
if request.method == 'GET':
|
||||
available_content = Content.query.order_by(Content.filename).all()
|
||||
return render_template('create_group.html', available_content=available_content)
|
||||
return render_template('groups/create_group.html', available_content=available_content)
|
||||
|
||||
try:
|
||||
name = request.form.get('name', '').strip()
|
||||
@@ -93,7 +93,7 @@ def edit_group(group_id: int):
|
||||
|
||||
if request.method == 'GET':
|
||||
available_content = Content.query.order_by(Content.filename).all()
|
||||
return render_template('edit_group.html',
|
||||
return render_template('groups/edit_group.html',
|
||||
group=group,
|
||||
available_content=available_content)
|
||||
|
||||
@@ -197,7 +197,7 @@ def manage_group(group_id: int):
|
||||
# Get available content (not in this group)
|
||||
all_content = Content.query.order_by(Content.filename).all()
|
||||
|
||||
return render_template('manage_group.html',
|
||||
return render_template('groups/manage_group.html',
|
||||
group=group,
|
||||
players=players,
|
||||
player_statuses=player_statuses,
|
||||
@@ -225,7 +225,7 @@ def group_fullscreen(group_id: int):
|
||||
status_info = get_player_status_info(player.id)
|
||||
player_statuses[player.id] = status_info
|
||||
|
||||
return render_template('group_fullscreen.html',
|
||||
return render_template('groups/group_fullscreen.html',
|
||||
group=group,
|
||||
players=players,
|
||||
player_statuses=player_statuses)
|
||||
|
||||
@@ -3,10 +3,10 @@ Main Blueprint - Dashboard and Home Routes
|
||||
"""
|
||||
from flask import Blueprint, render_template, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from extensions import db, cache
|
||||
from models.player import Player
|
||||
from models.group import Group
|
||||
from utils.logger import get_recent_logs
|
||||
from app.extensions import db, cache
|
||||
from app.models.player import Player
|
||||
from app.models.group import Group
|
||||
from app.utils.logger import get_recent_logs
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ players_bp = Blueprint('players', __name__, url_prefix='/players')
|
||||
|
||||
|
||||
@players_bp.route('/')
|
||||
@players_bp.route('/list')
|
||||
@login_required
|
||||
def players_list():
|
||||
def list():
|
||||
"""Display list of all players."""
|
||||
try:
|
||||
players = Player.query.order_by(Player.name).all()
|
||||
@@ -27,7 +28,7 @@ def players_list():
|
||||
status_info = get_player_status_info(player.id)
|
||||
player_statuses[player.id] = status_info
|
||||
|
||||
return render_template('players_list.html',
|
||||
return render_template('players/players_list.html',
|
||||
players=players,
|
||||
groups=groups,
|
||||
player_statuses=player_statuses)
|
||||
@@ -43,11 +44,15 @@ def add_player():
|
||||
"""Add a new player."""
|
||||
if request.method == 'GET':
|
||||
groups = Group.query.order_by(Group.name).all()
|
||||
return render_template('add_player.html', groups=groups)
|
||||
return render_template('players/add_player.html', groups=groups)
|
||||
|
||||
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')
|
||||
group_id = request.form.get('group_id')
|
||||
|
||||
# Validation
|
||||
@@ -55,23 +60,59 @@ def add_player():
|
||||
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(16)
|
||||
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,
|
||||
group_id=int(group_id) if group_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}" created with auth code {auth_code}')
|
||||
flash(f'Player "{name}" created successfully. Auth code: {auth_code}', 'success')
|
||||
log_action('info', f'Player "{name}" (hostname: {hostname}) created')
|
||||
|
||||
return redirect(url_for('players.players_list'))
|
||||
# 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()
|
||||
@@ -88,7 +129,7 @@ def edit_player(player_id: int):
|
||||
|
||||
if request.method == 'GET':
|
||||
groups = Group.query.order_by(Group.name).all()
|
||||
return render_template('edit_player.html', player=player, groups=groups)
|
||||
return render_template('players/edit_player.html', player=player, groups=groups)
|
||||
|
||||
try:
|
||||
name = request.form.get('name', '').strip()
|
||||
@@ -112,7 +153,7 @@ def edit_player(player_id: int):
|
||||
log_action('info', f'Player "{name}" (ID: {player_id}) updated')
|
||||
flash(f'Player "{name}" updated successfully.', 'success')
|
||||
|
||||
return redirect(url_for('players.players_list'))
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
@@ -146,7 +187,7 @@ def delete_player(player_id: int):
|
||||
log_action('error', f'Error deleting player: {str(e)}')
|
||||
flash('Error deleting player. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('players.players_list'))
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/regenerate-auth', methods=['POST'])
|
||||
@@ -169,7 +210,7 @@ def regenerate_auth_code(player_id: int):
|
||||
log_action('error', f'Error regenerating auth code: {str(e)}')
|
||||
flash('Error regenerating auth code. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('players.players_list'))
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>')
|
||||
@@ -191,7 +232,7 @@ def player_page(player_id: int):
|
||||
.limit(10)\
|
||||
.all()
|
||||
|
||||
return render_template('player_page.html',
|
||||
return render_template('players/player_page.html',
|
||||
player=player,
|
||||
playlist=playlist,
|
||||
status_info=status_info,
|
||||
@@ -199,7 +240,7 @@ def player_page(player_id: int):
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading player page: {str(e)}')
|
||||
flash('Error loading player page.', 'danger')
|
||||
return redirect(url_for('players.players_list'))
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/fullscreen')
|
||||
@@ -217,7 +258,7 @@ def player_fullscreen(player_id: int):
|
||||
# Get player's playlist
|
||||
playlist = get_player_playlist(player_id)
|
||||
|
||||
return render_template('player_fullscreen.html',
|
||||
return render_template('players/player_fullscreen.html',
|
||||
player=player,
|
||||
playlist=playlist)
|
||||
except Exception as e:
|
||||
@@ -227,7 +268,7 @@ def player_fullscreen(player_id: int):
|
||||
|
||||
@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 group assignment.
|
||||
"""Get playlist for a player based on their direct content assignment.
|
||||
|
||||
Args:
|
||||
player_id: The player's database ID
|
||||
@@ -239,16 +280,10 @@ def get_player_playlist(player_id: int) -> List[dict]:
|
||||
if not player:
|
||||
return []
|
||||
|
||||
# Get content from player's group
|
||||
if player.group_id:
|
||||
group = Group.query.get(player.group_id)
|
||||
if group:
|
||||
contents = group.contents.order_by(Content.position).all()
|
||||
else:
|
||||
contents = []
|
||||
else:
|
||||
# Player not in a group - show all content
|
||||
contents = Content.query.order_by(Content.position).all()
|
||||
# Get content directly assigned to this player
|
||||
contents = Content.query.filter_by(player_id=player_id)\
|
||||
.order_by(Content.position, Content.uploaded_at)\
|
||||
.all()
|
||||
|
||||
# Build playlist
|
||||
playlist = []
|
||||
@@ -366,3 +401,97 @@ def bulk_assign_group():
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user