Final: Complete modernization - Option 1 deployment, unified persistence, migration scripts

- Implement Docker image-based deployment (Option 1)
  * Code immutable in image, no volume override
  * Eliminated init-data.sh manual step
  * Simplified deployment process

- Unified persistence in data/ folder
  * Moved nginx.conf and nginx-custom-domains.conf to data/
  * All runtime configs and data in single location
  * Clear separation: repo (source) vs data/ (runtime)

- Archive legacy features
  * Groups blueprint and templates removed
  * Legacy playlist routes redirected to content area
  * Organized in old_code_documentation/

- Added network migration support
  * New migrate_network.sh script for IP changes
  * Regenerates SSL certs for new IP
  * Updates database configuration
  * Tested workflow: clone → deploy → migrate

- Enhanced deploy.sh
  * Creates data directories
  * Copies nginx configs from repo to data/
  * Validates file existence before deployment
  * Prevents incomplete deployments

- Updated documentation
  * QUICK_DEPLOYMENT.md shows 4-step workflow
  * Complete deployment workflow documented
  * Migration procedures included

- Production ready deployment workflow:
  1. Clone & setup (.env configuration)
  2. Deploy (./deploy.sh)
  3. Migrate network (./migrate_network.sh if needed)
  4. Normal operations (docker compose restart)
This commit is contained in:
Deployment System
2026-01-17 10:30:42 +02:00
parent d235c8e057
commit 49393d9a73
30 changed files with 1646 additions and 112 deletions

View File

@@ -80,7 +80,6 @@ def register_blueprints(app):
from app.blueprints.auth import auth_bp
from app.blueprints.admin import admin_bp
from app.blueprints.players import players_bp
from app.blueprints.groups import groups_bp
from app.blueprints.content import content_bp
from app.blueprints.playlist import playlist_bp
from app.blueprints.api import api_bp
@@ -90,7 +89,6 @@ def register_blueprints(app):
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(players_bp)
app.register_blueprint(groups_bp)
app.register_blueprint(content_bp)
app.register_blueprint(playlist_bp)
app.register_blueprint(api_bp)

View File

@@ -8,7 +8,7 @@ from datetime import datetime
from typing import Optional
from app.extensions import db, bcrypt
from app.models import User, Player, Group, Content, ServerLog, Playlist, HTTPSConfig
from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig
from app.utils.logger import log_action
from app.utils.caddy_manager import CaddyConfigGenerator
from app.utils.nginx_config_reader import get_nginx_status

View File

@@ -7,7 +7,7 @@ import bcrypt
from typing import Optional, Dict, List
from app.extensions import db, cache
from app.models import Player, Group, Content, PlayerFeedback, ServerLog
from app.models import Player, Content, PlayerFeedback, ServerLog
from app.utils.logger import log_action
api_bp = Blueprint('api', __name__, url_prefix='/api')
@@ -599,31 +599,33 @@ def system_info():
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/groups', methods=['GET'])
@rate_limit(max_requests=60, window=60)
def list_groups():
"""List all groups with basic information."""
try:
groups = Group.query.order_by(Group.name).all()
groups_data = []
for group in groups:
groups_data.append({
'id': group.id,
'name': group.name,
'description': group.description,
'player_count': group.players.count(),
'content_count': group.contents.count()
})
return jsonify({
'groups': groups_data,
'count': len(groups_data)
})
except Exception as e:
log_action('error', f'Error listing groups: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
# DEPRECATED: Groups functionality has been archived
# @api_bp.route('/groups', methods=['GET'])
# @rate_limit(max_requests=60, window=60)
# def list_groups():
# """List all groups with basic information."""
# try:
# groups = Group.query.order_by(Group.name).all()
#
# groups_data = []
# for group in groups:
# groups_data.append({
# 'id': group.id,
# 'name': group.name,
# 'description': group.description,
# 'player_count': group.players.count(),
# 'content_count': group.contents.count()
# })
#
# return jsonify({
# 'groups': groups_data,
# 'count': len(groups_data)
# })
#
# except Exception as e:
# log_action('error', f'Error listing groups: {str(e)}')
# return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/content', methods=['GET'])

View File

@@ -1,401 +0,0 @@
"""Groups blueprint for group management and player assignments."""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required
from typing import List, Dict
from app.extensions import db, cache
from app.models import Group, Player, Content
from app.utils.logger import log_action
from app.utils.group_player_management import get_player_status_info, get_group_statistics
groups_bp = Blueprint('groups', __name__, url_prefix='/groups')
@groups_bp.route('/')
@login_required
def groups_list():
"""Display list of all groups."""
try:
groups = Group.query.order_by(Group.name).all()
# Get statistics for each group
group_stats = {}
for group in groups:
stats = get_group_statistics(group.id)
group_stats[group.id] = stats
return render_template('groups/groups_list.html',
groups=groups,
group_stats=group_stats)
except Exception as e:
log_action('error', f'Error loading groups list: {str(e)}')
flash('Error loading groups list.', 'danger')
return redirect(url_for('main.dashboard'))
@groups_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create_group():
"""Create a new group."""
if request.method == 'GET':
available_content = Content.query.order_by(Content.filename).all()
return render_template('groups/create_group.html', available_content=available_content)
try:
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
content_ids = request.form.getlist('content_ids')
# Validation
if not name or len(name) < 3:
flash('Group name must be at least 3 characters long.', 'warning')
return redirect(url_for('groups.create_group'))
# Check if group name exists
existing_group = Group.query.filter_by(name=name).first()
if existing_group:
flash(f'Group "{name}" already exists.', 'warning')
return redirect(url_for('groups.create_group'))
# Create group
new_group = Group(
name=name,
description=description or None
)
# Add content to group
if content_ids:
for content_id in content_ids:
content = Content.query.get(int(content_id))
if content:
new_group.contents.append(content)
db.session.add(new_group)
db.session.commit()
log_action('info', f'Group "{name}" created with {len(content_ids)} content items')
flash(f'Group "{name}" created successfully.', 'success')
return redirect(url_for('groups.groups_list'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error creating group: {str(e)}')
flash('Error creating group. Please try again.', 'danger')
return redirect(url_for('groups.create_group'))
@groups_bp.route('/<int:group_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_group(group_id: int):
"""Edit group details."""
group = Group.query.get_or_404(group_id)
if request.method == 'GET':
available_content = Content.query.order_by(Content.filename).all()
return render_template('groups/edit_group.html',
group=group,
available_content=available_content)
try:
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
content_ids = request.form.getlist('content_ids')
# Validation
if not name or len(name) < 3:
flash('Group name must be at least 3 characters long.', 'warning')
return redirect(url_for('groups.edit_group', group_id=group_id))
# Check if group name exists (excluding current group)
existing_group = Group.query.filter(Group.name == name, Group.id != group_id).first()
if existing_group:
flash(f'Group name "{name}" is already in use.', 'warning')
return redirect(url_for('groups.edit_group', group_id=group_id))
# Update group
group.name = name
group.description = description or None
# Update content
group.contents = []
if content_ids:
for content_id in content_ids:
content = Content.query.get(int(content_id))
if content:
group.contents.append(content)
db.session.commit()
# Clear cache for all players in this group
for player in group.players:
cache.delete_memoized('get_player_playlist', player.id)
log_action('info', f'Group "{name}" (ID: {group_id}) updated')
flash(f'Group "{name}" updated successfully.', 'success')
return redirect(url_for('groups.groups_list'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating group: {str(e)}')
flash('Error updating group. Please try again.', 'danger')
return redirect(url_for('groups.edit_group', group_id=group_id))
@groups_bp.route('/<int:group_id>/delete', methods=['POST'])
@login_required
def delete_group(group_id: int):
"""Delete a group."""
try:
group = Group.query.get_or_404(group_id)
group_name = group.name
# Unassign players from group
for player in group.players:
player.group_id = None
cache.delete_memoized('get_player_playlist', player.id)
db.session.delete(group)
db.session.commit()
log_action('info', f'Group "{group_name}" (ID: {group_id}) deleted')
flash(f'Group "{group_name}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting group: {str(e)}')
flash('Error deleting group. Please try again.', 'danger')
return redirect(url_for('groups.groups_list'))
@groups_bp.route('/<int:group_id>/manage')
@login_required
def manage_group(group_id: int):
"""Manage group with player status cards and content."""
try:
group = Group.query.get_or_404(group_id)
# Get all players in this group
players = group.players.order_by(Player.name).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
# Get group content
contents = group.contents.order_by(Content.position).all()
# Get available players (not in this group)
available_players = Player.query.filter(
(Player.group_id == None) | (Player.group_id != group_id)
).order_by(Player.name).all()
# Get available content (not in this group)
all_content = Content.query.order_by(Content.filename).all()
return render_template('groups/manage_group.html',
group=group,
players=players,
player_statuses=player_statuses,
contents=contents,
available_players=available_players,
all_content=all_content)
except Exception as e:
log_action('error', f'Error loading manage group page: {str(e)}')
flash('Error loading manage group page.', 'danger')
return redirect(url_for('groups.groups_list'))
@groups_bp.route('/<int:group_id>/fullscreen')
def group_fullscreen(group_id: int):
"""Display group fullscreen view with all player status cards."""
try:
group = Group.query.get_or_404(group_id)
# Get all players in this group
players = group.players.order_by(Player.name).all()
# Get player status for each player
player_statuses = {}
for player in players:
status_info = get_player_status_info(player.id)
player_statuses[player.id] = status_info
return render_template('groups/group_fullscreen.html',
group=group,
players=players,
player_statuses=player_statuses)
except Exception as e:
log_action('error', f'Error loading group fullscreen: {str(e)}')
return "Error loading group fullscreen", 500
@groups_bp.route('/<int:group_id>/add-player', methods=['POST'])
@login_required
def add_player_to_group(group_id: int):
"""Add a player to a group."""
try:
group = Group.query.get_or_404(group_id)
player_id = request.form.get('player_id')
if not player_id:
flash('No player selected.', 'warning')
return redirect(url_for('groups.manage_group', group_id=group_id))
player = Player.query.get_or_404(int(player_id))
player.group_id = group_id
db.session.commit()
# Clear cache
cache.delete_memoized('get_player_playlist', player.id)
log_action('info', f'Player "{player.name}" added to group "{group.name}"')
flash(f'Player "{player.name}" added to group successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error adding player to group: {str(e)}')
flash('Error adding player to group. Please try again.', 'danger')
return redirect(url_for('groups.manage_group', group_id=group_id))
@groups_bp.route('/<int:group_id>/remove-player/<int:player_id>', methods=['POST'])
@login_required
def remove_player_from_group(group_id: int, player_id: int):
"""Remove a player from a group."""
try:
player = Player.query.get_or_404(player_id)
if player.group_id != group_id:
flash('Player is not in this group.', 'warning')
return redirect(url_for('groups.manage_group', group_id=group_id))
player_name = player.name
player.group_id = None
db.session.commit()
# Clear cache
cache.delete_memoized('get_player_playlist', player_id)
log_action('info', f'Player "{player_name}" removed from group {group_id}')
flash(f'Player "{player_name}" removed from group successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing player from group: {str(e)}')
flash('Error removing player from group. Please try again.', 'danger')
return redirect(url_for('groups.manage_group', group_id=group_id))
@groups_bp.route('/<int:group_id>/add-content', methods=['POST'])
@login_required
def add_content_to_group(group_id: int):
"""Add content to a group."""
try:
group = Group.query.get_or_404(group_id)
content_ids = request.form.getlist('content_ids')
if not content_ids:
flash('No content selected.', 'warning')
return redirect(url_for('groups.manage_group', group_id=group_id))
# Add content
added_count = 0
for content_id in content_ids:
content = Content.query.get(int(content_id))
if content and content not in group.contents:
group.contents.append(content)
added_count += 1
db.session.commit()
# Clear cache for all players in this group
for player in group.players:
cache.delete_memoized('get_player_playlist', player.id)
log_action('info', f'{added_count} content items added to group "{group.name}"')
flash(f'{added_count} content items added successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error adding content to group: {str(e)}')
flash('Error adding content to group. Please try again.', 'danger')
return redirect(url_for('groups.manage_group', group_id=group_id))
@groups_bp.route('/<int:group_id>/remove-content/<int:content_id>', methods=['POST'])
@login_required
def remove_content_from_group(group_id: int, content_id: int):
"""Remove content from a group."""
try:
group = Group.query.get_or_404(group_id)
content = Content.query.get_or_404(content_id)
if content not in group.contents:
flash('Content is not in this group.', 'warning')
return redirect(url_for('groups.manage_group', group_id=group_id))
group.contents.remove(content)
db.session.commit()
# Clear cache for all players in this group
for player in group.players:
cache.delete_memoized('get_player_playlist', player.id)
log_action('info', f'Content "{content.filename}" removed from group "{group.name}"')
flash('Content removed from group successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing content from group: {str(e)}')
flash('Error removing content from group. Please try again.', 'danger')
return redirect(url_for('groups.manage_group', group_id=group_id))
@groups_bp.route('/<int:group_id>/reorder-content', methods=['POST'])
@login_required
def reorder_group_content(group_id: int):
"""Reorder content within a group."""
try:
group = Group.query.get_or_404(group_id)
content_order = request.json.get('order', [])
# Update positions
for idx, content_id in enumerate(content_order):
content = Content.query.get(content_id)
if content and content in group.contents:
content.position = idx
db.session.commit()
# Clear cache for all players in this group
for player in group.players:
cache.delete_memoized('get_player_playlist', player.id)
log_action('info', f'Content reordered for group "{group.name}"')
return jsonify({'success': True})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering group content: {str(e)}')
return jsonify({'success': False, 'error': str(e)}), 500
@groups_bp.route('/<int:group_id>/stats')
@login_required
def group_stats(group_id: int):
"""Get group statistics as JSON."""
try:
stats = get_group_statistics(group_id)
return jsonify(stats)
except Exception as e:
log_action('error', f'Error getting group stats: {str(e)}')
return jsonify({'error': str(e)}), 500

View File

@@ -16,25 +16,16 @@ playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
@playlist_bp.route('/<int:player_id>')
@login_required
def manage_playlist(player_id: int):
"""Manage playlist for a specific player."""
"""Legacy route - redirect to new content management area."""
player = Player.query.get_or_404(player_id)
# Get content from player's assigned playlist
playlist_items = []
if player.playlist_id:
playlist = Playlist.query.get(player.playlist_id)
if playlist:
playlist_items = playlist.get_content_ordered()
# Get available content (all content not in current playlist)
all_content = Content.query.all()
playlist_content_ids = {item.id for item in playlist_items}
available_content = [c for c in all_content if c.id not in playlist_content_ids]
return render_template('playlist/manage_playlist.html',
player=player,
playlist_content=playlist_items,
available_content=available_content)
# Redirect to the new content management interface
return redirect(url_for('content.manage_playlist_content', playlist_id=player.playlist_id))
else:
# Player has no playlist assigned
flash('This player has no playlist assigned.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])

View File

@@ -97,6 +97,67 @@
user-select: none;
}
/* Duration spinner control */
.duration-spinner {
display: flex;
align-items: center;
gap: 8px;
pointer-events: auto;
}
.duration-display {
min-width: 60px;
text-align: center;
font-weight: 500;
font-size: 16px;
padding: 6px 12px;
background: #f5f5f5;
border-radius: 4px;
border: 1px solid #ddd;
pointer-events: auto;
}
.duration-spinner button {
width: 36px;
height: 36px;
padding: 0;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
pointer-events: auto;
}
.duration-spinner button:hover {
background: #f0f0f0;
border-color: #999;
}
.duration-spinner button:active {
background: #e0e0e0;
transform: scale(0.95);
}
.duration-spinner button.btn-increase {
color: #28a745;
}
.duration-spinner button.btn-decrease {
color: #dc3545;
}
.audio-toggle {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.audio-checkbox {
display: none;
}
@@ -154,6 +215,36 @@
body.dark-mode .available-content {
color: #e2e8f0;
}
/* Dark mode for duration spinner */
body.dark-mode .duration-display {
background: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .duration-spinner button {
background: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .duration-spinner button:hover {
background: #4a5568;
border-color: #718096;
}
body.dark-mode .duration-spinner button:active {
background: #5a6a78;
}
body.dark-mode .duration-spinner button.btn-increase {
color: #48bb78;
}
body.dark-mode .duration-spinner button.btn-decrease {
color: #f56565;
}
</style>
<div class="container" style="max-width: 1400px;">
@@ -230,7 +321,27 @@
{% elif content.content_type == 'pdf' %}📄 PDF
{% else %}📁 Other{% endif %}
</td>
<td>{{ content._playlist_duration or content.duration }}s</td>
<td>
<div class="duration-spinner">
<button type="button"
class="btn-decrease"
onclick="event.stopPropagation(); changeDuration({{ content.id }}, -1)"
onmousedown="event.stopPropagation()"
title="Decrease duration by 1 second">
⬇️
</button>
<div class="duration-display" id="duration-display-{{ content.id }}">
{{ content._playlist_duration or content.duration }}s
</div>
<button type="button"
class="btn-increase"
onclick="event.stopPropagation(); changeDuration({{ content.id }}, 1)"
onmousedown="event.stopPropagation()"
title="Increase duration by 1 second">
⬆️
</button>
</div>
</td>
<td>
{% if content.content_type == 'video' %}
<label class="audio-toggle">
@@ -413,6 +524,58 @@ function saveOrder() {
});
}
// Change duration with spinner buttons
function changeDuration(contentId, change) {
const displayElement = document.getElementById(`duration-display-${contentId}`);
const currentText = displayElement.textContent;
const currentDuration = parseInt(currentText);
const newDuration = currentDuration + change;
// Validate duration (minimum 1 second)
if (newDuration < 1) {
alert('Duration must be at least 1 second');
return;
}
// Update display immediately for visual feedback
displayElement.style.opacity = '0.7';
displayElement.textContent = newDuration + 's';
// Save to server
const playlistId = {{ playlist.id }};
const url = `/content/playlist/${playlistId}/update-duration/${contentId}`;
const formData = new FormData();
formData.append('duration', newDuration);
fetch(url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Duration updated successfully');
displayElement.style.opacity = '1';
displayElement.style.color = '#28a745';
setTimeout(() => {
displayElement.style.color = '';
}, 1000);
} else {
// Revert on error
displayElement.textContent = currentDuration + 's';
displayElement.style.opacity = '1';
alert('Error updating duration: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
// Revert on error
displayElement.textContent = currentDuration + 's';
displayElement.style.opacity = '1';
console.error('Error:', error);
alert('Error updating duration');
});
}
function toggleAudio(contentId, enabled) {
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
const playlistId = {{ playlist.id }};

View File

@@ -1,21 +0,0 @@
{% extends "base.html" %}
{% block title %}Create Group - DigiServer v2{% endblock %}
{% block content %}
<h1>Create Group</h1>
<div class="card">
<form method="POST">
<div style="margin-bottom: 1rem;">
<label>Group Name</label>
<input type="text" name="name" required style="width: 100%; padding: 0.5rem;">
</div>
<div style="margin-bottom: 1rem;">
<label>Description (optional)</label>
<textarea name="description" rows="3" style="width: 100%; padding: 0.5rem;"></textarea>
</div>
<button type="submit" class="btn btn-success">Create Group</button>
<a href="{{ url_for('groups.groups_list') }}" class="btn">Cancel</a>
</form>
</div>
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% block title %}Edit Group{% endblock %}
{% block content %}
<div class="container">
<h2>Edit Group</h2>
<p>Edit group functionality - placeholder</p>
<a href="{{ url_for('groups.list') }}" class="btn btn-secondary">Back to Groups</a>
</div>
{% endblock %}

View File

@@ -1,10 +0,0 @@
{% extends "base.html" %}
{% block title %}Group Fullscreen{% endblock %}
{% block content %}
<div class="container">
<h2>Group Fullscreen View</h2>
<p>Fullscreen group view - placeholder</p>
</div>
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% block title %}Groups - DigiServer v2{% endblock %}
{% block content %}
<h1>Groups</h1>
<div class="card">
<p>Groups list view - Template in progress</p>
<a href="{{ url_for('groups.create_group') }}" class="btn btn-success">Create New Group</a>
</div>
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% block title %}Manage Group{% endblock %}
{% block content %}
<div class="container">
<h2>Manage Group</h2>
<p>Manage group functionality - placeholder</p>
<a href="{{ url_for('groups.list') }}" class="btn btn-secondary">Back to Groups</a>
</div>
{% endblock %}

View File

@@ -1,857 +0,0 @@
{% extends "base.html" %}
{% block title %}Manage Playlist - {{ player.name }} - DigiServer v2{% endblock %}
{% block content %}
<style>
.playlist-container {
max-width: 1200px;
margin: 0 auto;
}
.player-info-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 25px;
border-radius: 12px;
margin-bottom: 25px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.player-info-card h1 {
margin: 0 0 10px 0;
font-size: 28px;
}
.player-info-card p {
margin: 5px 0;
opacity: 0.9;
}
.playlist-section {
background: white;
border-radius: 12px;
padding: 25px;
margin-bottom: 25px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #f0f0f0;
}
.section-header h2 {
margin: 0;
font-size: 24px;
color: #333;
}
.playlist-table {
width: 100%;
border-collapse: collapse;
}
.playlist-table thead {
background: #f8f9fa;
}
.playlist-table th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #555;
border-bottom: 2px solid #dee2e6;
}
.playlist-table td {
padding: 12px;
border-bottom: 1px solid #dee2e6;
}
.playlist-table tr:hover {
background: #f8f9fa;
}
.draggable-row {
cursor: move;
}
.draggable-row.dragging {
opacity: 0.5;
}
.drag-handle {
cursor: grab;
font-size: 18px;
color: #999;
padding-right: 10px;
}
.drag-handle:active {
cursor: grabbing;
}
.btn {
padding: 8px 16px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #218838;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-sm {
padding: 5px 10px;
font-size: 12px;
}
.add-content-form {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 5px;
color: #333;
}
.form-control {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 14px;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
margin-top: 15px;
}
.stat-item {
background: rgba(255, 255, 255, 0.2);
padding: 12px;
border-radius: 8px;
}
.stat-label {
font-size: 12px;
opacity: 0.9;
margin-bottom: 5px;
}
.stat-value {
font-size: 24px;
font-weight: bold;
}
.empty-state {
text-align: center;
padding: 40px;
color: #999;
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 15px;
}
.duration-input {
width: 70px !important;
padding: 5px 8px !important;
text-align: center;
transition: all 0.3s ease;
background: white !important;
border: 2px solid #ced4da !important;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.duration-input:hover {
border-color: #667eea !important;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
.duration-input:focus {
border-color: #667eea !important;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
background: white !important;
outline: none;
}
.save-duration-btn {
transition: all 0.2s ease;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
.content-type-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.badge-image {
background: #e3f2fd;
color: #1976d2;
}
.badge-video {
background: #f3e5f5;
color: #7b1fa2;
}
.badge-pdf {
background: #ffebee;
color: #c62828;
}
/* Dark mode support */
body.dark-mode .playlist-section {
background: #2d3748;
color: #e2e8f0;
}
body.dark-mode .section-header h2 {
color: #e2e8f0;
}
body.dark-mode .section-header {
border-bottom-color: #4a5568;
}
body.dark-mode .playlist-table thead {
background: #1a202c;
}
body.dark-mode .playlist-table th {
color: #cbd5e0;
border-bottom-color: #4a5568;
}
body.dark-mode .playlist-table td {
border-bottom-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .playlist-table tr:hover {
background: #1a202c;
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .form-control:focus {
border-color: #667eea;
background: #2d3748;
}
body.dark-mode .add-content-form {
background: #1a202c;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
body.dark-mode .empty-state {
color: #718096;
}
body.dark-mode .drag-handle {
color: #718096;
}
body.dark-mode .duration-input {
background: #1a202c !important;
border-color: #4a5568 !important;
color: #e2e8f0 !important;
}
body.dark-mode .duration-input:hover {
border-color: #667eea !important;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
body.dark-mode .duration-input:focus {
background: #2d3748 !important;
border-color: #667eea !important;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3);
}
body.dark-mode .badge-image {
background: #1e3a5f;
color: #64b5f6;
}
body.dark-mode .badge-video {
background: #4a1e5a;
color: #ce93d8;
}
body.dark-mode .badge-pdf {
background: #5a1e1e;
color: #ef5350;
}
/* Audio toggle styles */
.audio-toggle {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.audio-checkbox {
display: none;
}
.audio-label {
font-size: 20px;
transition: all 0.3s ease;
}
.audio-checkbox + .audio-label .audio-on {
display: none;
}
.audio-checkbox + .audio-label .audio-off {
display: inline;
}
.audio-checkbox:checked + .audio-label .audio-on {
display: inline;
}
.audio-checkbox:checked + .audio-label .audio-off {
display: none;
}
.audio-label:hover {
transform: scale(1.2);
}
</style>
<div class="playlist-container">
<!-- Player Info Card -->
<div class="player-info-card">
<h1>🎬 {{ player.name }}</h1>
<p>📍 {{ player.location or 'No location' }}</p>
<p>🖥️ Hostname: {{ player.hostname }}</p>
<p>📊 Status: {{ '🟢 Online' if player.is_online else '🔴 Offline' }}</p>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">Playlist Items</div>
<div class="stat-value">{{ playlist_content|length }}</div>
</div>
<div class="stat-item">
<div class="stat-label">Playlist Version</div>
<div class="stat-value">{{ player.playlist_version }}</div>
</div>
<div class="stat-item">
<div class="stat-label">Total Duration</div>
<div class="stat-value">{{ playlist_content|sum(attribute='duration') }}s</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div style="margin-bottom: 20px; display: flex; gap: 10px;">
<a href="{{ url_for('players.player_page', player_id=player.id) }}" class="btn btn-secondary">
← Back to Player
</a>
<a href="{{ url_for('content.upload_content', player_id=player.id, return_url=url_for('playlist.manage_playlist', player_id=player.id)) }}"
class="btn btn-success">
Upload New Content
</a>
{% if playlist_content %}
<form method="POST" action="{{ url_for('playlist.clear_playlist', player_id=player.id) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to clear the entire playlist?');">
<button type="submit" class="btn btn-danger">
🗑️ Clear Playlist
</button>
</form>
{% endif %}
</div>
<!-- Current Playlist -->
<div class="playlist-section">
<div class="section-header">
<h2>📋 Current Playlist</h2>
<span style="color: #999; font-size: 14px;">
Drag and drop to reorder
</span>
</div>
{% if playlist_content %}
<table class="playlist-table" id="playlist-table">
<thead>
<tr>
<th style="width: 40px;"></th>
<th style="width: 50px;">#</th>
<th>Filename</th>
<th style="width: 100px;">Type</th>
<th style="width: 120px;">Duration (s)</th>
<th style="width: 80px;">Audio</th>
<th style="width: 100px;">Size</th>
<th style="width: 150px;">Actions</th>
</tr>
</thead>
<tbody id="playlist-tbody">
{% for content in playlist_content %}
<tr class="draggable-row" data-content-id="{{ content.id }}">
<td>
<span class="drag-handle" draggable="true">⋮⋮</span>
</td>
<td>{{ loop.index }}</td>
<td>{{ content.filename }}</td>
<td>
{% if content.content_type == 'image' %}
<span class="content-type-badge badge-image">📷 Image</span>
{% elif content.content_type == 'video' %}
<span class="content-type-badge badge-video">🎥 Video</span>
{% elif content.content_type == 'pdf' %}
<span class="content-type-badge badge-pdf">📄 PDF</span>
{% else %}
<span class="content-type-badge">📁 {{ content.content_type }}</span>
{% endif %}
</td>
<td>
<div style="display: flex; align-items: center; gap: 5px;">
<input type="number"
class="form-control duration-input"
id="duration-{{ content.id }}"
value="{{ content._playlist_duration }}"
min="1"
draggable="false"
onclick="event.stopPropagation()"
onmousedown="event.stopPropagation()"
oninput="markDurationChanged({{ content.id }})"
onkeypress="if(event.key==='Enter') saveDuration({{ content.id }})">
<button type="button"
class="btn btn-success btn-sm save-duration-btn"
id="save-btn-{{ content.id }}"
onclick="event.stopPropagation(); saveDuration({{ content.id }})"
onmousedown="event.stopPropagation()"
style="display: none;"
title="Save duration (or press Enter)">
💾
</button>
</div>
</td>
<td>
{% if content.content_type == 'video' %}
<label class="audio-toggle" onclick="event.stopPropagation()">
<input type="checkbox"
class="audio-checkbox"
data-content-id="{{ content.id }}"
{{ 'checked' if not content._playlist_muted else '' }}
onchange="toggleAudio({{ content.id }}, this.checked)"
onclick="event.stopPropagation()">
<span class="audio-label">
<span class="audio-on">🔊</span>
<span class="audio-off">🔇</span>
</span>
</label>
{% else %}
<span style="color: #999;"></span>
{% endif %}
</td>
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
<td>
<form method="POST"
action="{{ url_for('playlist.remove_from_playlist', player_id=player.id, content_id=content.id) }}"
style="display: inline;"
onsubmit="return confirm('Remove {{ content.filename }} from playlist?');">
<button type="submit" class="btn btn-danger btn-sm">
✕ Remove
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<h3>No content in playlist</h3>
<p>Upload content or add existing files to get started</p>
</div>
{% endif %}
</div>
<!-- Add Content Section -->
{% if available_content %}
<div class="playlist-section">
<div class="section-header">
<h2> Add Existing Content</h2>
</div>
<form method="POST" action="{{ url_for('playlist.add_to_playlist', player_id=player.id) }}"
class="add-content-form">
<div class="form-group">
<label for="content_id">Select Content:</label>
<select name="content_id" id="content_id" class="form-control" required>
<option value="" disabled selected>Choose content...</option>
{% for content in available_content %}
<option value="{{ content.id }}">{{ content.filename }} ({{ content.content_type }})</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="duration">Display Duration (seconds):</label>
<input type="number"
name="duration"
id="duration"
class="form-control"
value="10"
min="1"
required>
</div>
<button type="submit" class="btn btn-success">
Add to Playlist
</button>
</form>
</div>
{% endif %}
</div>
<script>
let draggedElement = null;
// Initialize drag and drop
document.addEventListener('DOMContentLoaded', function() {
const tbody = document.getElementById('playlist-tbody');
if (!tbody) return;
// Set up drag handles
const dragHandles = tbody.querySelectorAll('.drag-handle');
dragHandles.forEach(handle => {
handle.addEventListener('dragstart', handleDragStart);
});
// Set up drop zones on rows
const rows = tbody.querySelectorAll('.draggable-row');
rows.forEach(row => {
row.addEventListener('dragover', handleDragOver);
row.addEventListener('drop', handleDrop);
row.addEventListener('dragend', handleDragEnd);
});
// Prevent dragging from inputs and buttons
const inputs = document.querySelectorAll('.duration-input, button');
inputs.forEach(input => {
input.addEventListener('mousedown', (e) => {
e.stopPropagation();
});
input.addEventListener('click', (e) => {
e.stopPropagation();
});
});
});
function handleDragStart(e) {
// Get the parent row
const row = e.target.closest('.draggable-row');
if (!row) return;
draggedElement = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', row.innerHTML);
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
e.dataTransfer.dropEffect = 'move';
return false;
}
function handleDrop(e) {
if (e.stopPropagation) {
e.stopPropagation();
}
if (draggedElement !== this) {
const tbody = document.getElementById('playlist-tbody');
const allRows = [...tbody.querySelectorAll('.draggable-row')];
const draggedIndex = allRows.indexOf(draggedElement);
const targetIndex = allRows.indexOf(this);
if (draggedIndex < targetIndex) {
this.parentNode.insertBefore(draggedElement, this.nextSibling);
} else {
this.parentNode.insertBefore(draggedElement, this);
}
updateRowNumbers();
saveOrder();
}
return false;
}
function handleDragEnd(e) {
this.classList.remove('dragging');
}
function updateRowNumbers() {
const rows = document.querySelectorAll('#playlist-tbody tr');
rows.forEach((row, index) => {
row.querySelector('td:nth-child(2)').textContent = index + 1;
});
}
function saveOrder() {
const rows = document.querySelectorAll('#playlist-tbody .draggable-row');
const contentIds = Array.from(rows).map(row => parseInt(row.dataset.contentId));
fetch('{{ url_for("playlist.reorder_playlist", player_id=player.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content_ids: contentIds })
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Playlist reordered successfully');
} else {
alert('Error reordering playlist: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error reordering playlist');
});
}
function markDurationChanged(contentId) {
const saveBtn = document.getElementById(`save-btn-${contentId}`);
const input = document.getElementById(`duration-${contentId}`);
// Show save button if value changed
if (input.value !== input.defaultValue) {
saveBtn.style.display = 'inline-block';
input.style.borderColor = '#ffc107';
} else {
saveBtn.style.display = 'none';
input.style.borderColor = '';
}
}
function saveDuration(contentId) {
const inputElement = document.getElementById(`duration-${contentId}`);
const saveBtn = document.getElementById(`save-btn-${contentId}`);
const duration = parseInt(inputElement.value);
// Validate duration
if (duration < 1) {
alert('Duration must be at least 1 second');
inputElement.value = inputElement.defaultValue;
inputElement.style.borderColor = '';
saveBtn.style.display = 'none';
return;
}
const originalValue = inputElement.defaultValue;
// Visual feedback
inputElement.disabled = true;
saveBtn.disabled = true;
saveBtn.textContent = '⏳';
const formData = new FormData();
formData.append('duration', duration);
const playerId = {{ player.id }};
const url = `/playlist/${playerId}/update-duration/${contentId}`;
fetch(url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Duration updated successfully');
inputElement.style.borderColor = '#28a745';
inputElement.defaultValue = duration;
saveBtn.textContent = '✓';
// Update total duration display
updateTotalDuration();
setTimeout(() => {
inputElement.style.borderColor = '';
inputElement.disabled = false;
saveBtn.style.display = 'none';
saveBtn.textContent = '💾';
saveBtn.disabled = false;
}, 1500);
} else {
inputElement.style.borderColor = '#dc3545';
inputElement.value = originalValue;
saveBtn.textContent = '✖';
alert('Error updating duration: ' + data.message);
setTimeout(() => {
inputElement.disabled = false;
inputElement.style.borderColor = '';
saveBtn.style.display = 'none';
saveBtn.textContent = '💾';
saveBtn.disabled = false;
}, 1500);
}
})
.catch(error => {
console.error('Error:', error);
inputElement.style.borderColor = '#dc3545';
inputElement.value = originalValue;
saveBtn.textContent = '✖';
alert('Error updating duration');
setTimeout(() => {
inputElement.disabled = false;
inputElement.style.borderColor = '';
saveBtn.style.display = 'none';
saveBtn.textContent = '💾';
saveBtn.disabled = false;
}, 1500);
});
}
function updateTotalDuration() {
const durationInputs = document.querySelectorAll('.duration-input');
let total = 0;
durationInputs.forEach(input => {
total += parseInt(input.value) || 0;
});
const statValues = document.querySelectorAll('.stat-value');
statValues.forEach((element, index) => {
const label = element.parentElement.querySelector('.stat-label');
if (label && label.textContent.includes('Total Duration')) {
element.textContent = total + 's';
}
});
}
function toggleAudio(contentId, enabled) {
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
const playerId = {{ player.id }};
const url = `/playlist/${playerId}/update-muted/${contentId}`;
const formData = new FormData();
formData.append('muted', muted ? 'true' : 'false');
fetch(url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Audio setting updated:', enabled ? 'Enabled' : 'Muted');
} else {
alert('Error updating audio setting: ' + data.message);
// Revert checkbox on error
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
}
})
.catch(error => {
console.error('Error:', error);
alert('Error updating audio setting');
// Revert checkbox on error
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
});
}
</script>
{% endblock %}