updated digiserver 2

This commit is contained in:
ske087
2025-11-12 16:07:03 +02:00
parent 2deb398fd8
commit e5a00d19a5
44 changed files with 2656 additions and 230 deletions

View File

@@ -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,

View File

@@ -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."""

View File

@@ -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'))

View File

@@ -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():

View File

@@ -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)

View File

@@ -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__)

View File

@@ -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