checked for updates

This commit is contained in:
2025-07-16 11:19:31 +03:00
parent c36ba9dc64
commit 94fad22d85
12 changed files with 1303 additions and 354 deletions

View File

@@ -142,6 +142,71 @@ def health_check():
'version': '2.0.0' 'version': '2.0.0'
}) })
@bp.route('/content/<int:content_id>/remove-from-player', methods=['POST'])
def remove_content_from_player(content_id):
"""Remove content from a specific player"""
from flask_login import login_required, current_user
# Require authentication for this operation
if not current_user.is_authenticated:
return jsonify({'error': 'Authentication required'}), 401
data = request.get_json()
if not data or 'player_id' not in data:
return jsonify({'error': 'Player ID required'}), 400
player_id = data.get('player_id')
# Find the content item
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if not content:
return jsonify({'error': 'Content not found for this player'}), 404
# Remove the content
try:
db.session.delete(content)
db.session.commit()
return jsonify({
'success': True,
'message': f'Content {content.file_name} removed from player'
})
except Exception as e:
db.session.rollback()
return jsonify({'error': f'Failed to remove content: {str(e)}'}), 500
@bp.route('/player/<int:player_id>/heartbeat', methods=['POST'])
def player_heartbeat(player_id):
"""Update player heartbeat/last seen timestamp"""
try:
player = Player.query.get_or_404(player_id)
player.last_seen = db.func.current_timestamp()
db.session.commit()
return jsonify({
'success': True,
'timestamp': player.last_seen.isoformat() if player.last_seen else None
})
except Exception as e:
db.session.rollback()
return jsonify({'error': f'Failed to update heartbeat: {str(e)}'}), 500
@bp.route('/player/<int:player_id>/content', methods=['GET'])
def get_player_content_status(player_id):
"""Get player content status for checking updates"""
try:
player = Player.query.get_or_404(player_id)
content_count = Content.query.filter_by(player_id=player_id).count()
return jsonify({
'player_id': player_id,
'playlist_version': player.playlist_version,
'content_count': content_count,
'updated': False # Could implement version checking logic here
})
except Exception as e:
return jsonify({'error': f'Failed to get content status: {str(e)}'}), 500
@bp.errorhandler(404) @bp.errorhandler(404)
def api_not_found(error): def api_not_found(error):
"""API 404 handler""" """API 404 handler"""

View File

@@ -72,12 +72,16 @@ def upload():
players = Player.query.order_by(Player.username).all() players = Player.query.order_by(Player.username).all()
groups = Group.query.order_by(Group.name).all() groups = Group.query.order_by(Group.name).all()
# Convert players and groups to dictionaries for JSON serialization
players_data = [{'id': p.id, 'username': p.username, 'hostname': p.hostname} for p in players]
groups_data = [{'id': g.id, 'name': g.name, 'description': g.description, 'player_count': len(g.players)} for g in groups]
return render_template( return render_template(
'content/upload.html', 'content/upload.html',
target_type=target_type, target_type=target_type,
target_id=target_id, target_id=target_id,
players=players, players=players_data,
groups=groups, groups=groups_data,
return_url=return_url return_url=return_url
) )

View File

@@ -18,6 +18,10 @@ def index():
players = Player.query.order_by(Player.username).all() players = Player.query.order_by(Player.username).all()
groups = Group.query.order_by(Group.name).all() groups = Group.query.order_by(Group.name).all()
# Calculate statistics
total_content = sum(len(player.content) for player in players)
active_players = sum(1 for player in players if player.is_active)
# Check if logo exists # Check if logo exists
from flask import current_app from flask import current_app
logo_path = os.path.join(current_app.static_folder, 'assets', 'logo.png') logo_path = os.path.join(current_app.static_folder, 'assets', 'logo.png')
@@ -31,5 +35,7 @@ def index():
players=players, players=players,
groups=groups, groups=groups,
logo_exists=logo_exists, logo_exists=logo_exists,
server_logs=server_logs server_logs=server_logs,
total_content=total_content,
active_players=active_players
) )

BIN
app/static/uploads/123.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

View File

@@ -0,0 +1,173 @@
{% extends "base.html" %}
{% block title %}Upload Content - SKE Digital Signage{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Page Header -->
<div class="row mb-4">
<div class="col">
<h1><i class="bi bi-cloud-upload"></i> Upload Content</h1>
<p class="text-muted">Upload images, videos, or documents to your players and groups</p>
</div>
<div class="col-auto">
<a href="{{ return_url }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back
</a>
</div>
</div>
<!-- Upload Form -->
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-upload"></i> Upload Files</h5>
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<!-- Target Selection -->
<div class="row mb-3">
<div class="col-md-6">
<label for="target_type" class="form-label">Target Type</label>
<select class="form-select" id="target_type" name="target_type" required>
<option value="">Select target type...</option>
<option value="player" {% if target_type == 'player' %}selected{% endif %}>Player</option>
<option value="group" {% if target_type == 'group' %}selected{% endif %}>Group</option>
</select>
</div>
<div class="col-md-6">
<label for="target_id" class="form-label">Target</label>
<select class="form-select" id="target_id" name="target_id" required>
<option value="">Select target...</option>
{% if target_type == 'player' and target_id %}
{% for player in players %}
<option value="{{ player.id }}"
{% if player.id|string == target_id|string %}selected{% endif %}>
{{ player.username }} ({{ player.hostname }})
</option>
{% endfor %}
{% elif target_type == 'group' and target_id %}
{% for group in groups %}
<option value="{{ group.id }}"
{% if group.id|string == target_id|string %}selected{% endif %}>
{{ group.name }} ({{ group.player_count }} players)
</option>
{% endfor %}
{% endif %}
</select>
</div>
</div>
<!-- File Selection -->
<div class="mb-3">
<label for="files" class="form-label">Files</label>
<input type="file" class="form-control" id="files" name="files" multiple required
accept="image/*,video/*,.pdf,.ppt,.pptx">
<div class="form-text">
Supported formats: Images (PNG, JPG, GIF), Videos (MP4, AVI, MOV), Documents (PDF, PowerPoint)
</div>
</div>
<!-- Duration Setting -->
<div class="mb-3">
<label for="duration" class="form-label">Display Duration (seconds)</label>
<input type="number" class="form-control" id="duration" name="duration"
value="10" min="1" max="3600" required>
<div class="form-text">
How long each item should be displayed (for images and documents)
</div>
</div>
<!-- Hidden fields -->
<input type="hidden" name="return_url" value="{{ return_url }}">
<!-- Submit Button -->
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-cloud-upload"></i> Upload Files
</button>
</div>
</form>
</div>
</div>
<!-- Upload Guidelines -->
<div class="card mt-4">
<div class="card-header">
<h6><i class="bi bi-info-circle"></i> Upload Guidelines</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<h6>Images</h6>
<ul class="small">
<li>PNG, JPG, GIF, BMP, WebP</li>
<li>Will be optimized automatically</li>
<li>Best: 1920x1080 resolution</li>
</ul>
</div>
<div class="col-md-4">
<h6>Videos</h6>
<ul class="small">
<li>MP4, AVI, MOV, WMV, WebM</li>
<li>Converted to web-compatible MP4</li>
<li>Duration setting is ignored</li>
</ul>
</div>
<div class="col-md-4">
<h6>Documents</h6>
<ul class="small">
<li>PDF, PowerPoint (PPT/PPTX)</li>
<li>Converted to images per page/slide</li>
<li>Each page uses duration setting</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const targetTypeSelect = document.getElementById('target_type');
const targetIdSelect = document.getElementById('target_id');
// Players and groups data
const players = {{ players|tojson }};
const groups = {{ groups|tojson }};
function updateTargetOptions() {
const targetType = targetTypeSelect.value;
targetIdSelect.innerHTML = '<option value="">Select target...</option>';
if (targetType === 'player') {
players.forEach(player => {
const option = document.createElement('option');
option.value = player.id;
option.textContent = `${player.username} (${player.hostname})`;
targetIdSelect.appendChild(option);
});
} else if (targetType === 'group') {
groups.forEach(group => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = `${group.name} (${group.player_count} players)`;
targetIdSelect.appendChild(option);
});
}
}
targetTypeSelect.addEventListener('change', updateTargetOptions);
// Initialize if target_type is already selected
if (targetTypeSelect.value) {
updateTargetOptions();
}
});
</script>
{% endblock %}

View File

@@ -59,7 +59,7 @@
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div> <div>
<h5 class="card-title">Active Players</h5> <h5 class="card-title">Active Players</h5>
<h2>{{ players|selectattr('is_active')|list|length }}</h2> <h2>{{ active_players }}</h2>
</div> </div>
<div class="align-self-center"> <div class="align-self-center">
<i class="bi bi-play-circle display-6"></i> <i class="bi bi-play-circle display-6"></i>
@@ -75,7 +75,7 @@
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div> <div>
<h5 class="card-title">Total Content</h5> <h5 class="card-title">Total Content</h5>
<h2>{{ players|sum(attribute='content')|length }}</h2> <h2>{{ total_content }}</h2>
</div> </div>
<div class="align-self-center"> <div class="align-self-center">
<i class="bi bi-file-earmark-play display-6"></i> <i class="bi bi-file-earmark-play display-6"></i>

View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Add Player - SKE Digital Signage{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Page Header -->
<div class="row mb-4">
<div class="col">
<h1><i class="bi bi-plus-circle"></i> Add Player</h1>
<p class="text-muted">Create a new digital signage player</p>
</div>
<div class="col-auto">
<a href="{{ url_for('dashboard.index') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
<!-- Add Player Form -->
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-display"></i> Player Information</h5>
</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="username" class="form-label">Username *</label>
<input type="text" class="form-control" id="username" name="username"
required placeholder="Enter player username">
<div class="form-text">Display name for this player</div>
</div>
<div class="mb-3">
<label for="hostname" class="form-label">Hostname *</label>
<input type="text" class="form-control" id="hostname" name="hostname"
required placeholder="Enter unique hostname">
<div class="form-text">Unique identifier for API access (e.g., display-001)</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password *</label>
<input type="password" class="form-control" id="password" name="password"
required placeholder="Enter secure password">
<div class="form-text">Password for manual authentication</div>
</div>
<div class="mb-3">
<label for="quickconnect_password" class="form-label">Quick Connect Code</label>
<input type="password" class="form-control" id="quickconnect_password"
name="quickconnect_password" placeholder="Enter quick connect code">
<div class="form-text">Optional code for quick API access</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="bi bi-plus"></i> Create Player
</button>
</div>
</form>
</div>
</div>
<!-- Help Card -->
<div class="card mt-4">
<div class="card-header">
<h6><i class="bi bi-question-circle"></i> Player Setup Guide</h6>
</div>
<div class="card-body">
<ol class="small">
<li><strong>Username:</strong> Choose a descriptive name (e.g., "Lobby Display", "Conference Room")</li>
<li><strong>Hostname:</strong> Must be unique across all players (e.g., "lobby-01", "conf-room-a")</li>
<li><strong>Password:</strong> Used for manual authentication in fullscreen mode</li>
<li><strong>Quick Connect:</strong> Optional code for automated player client connections</li>
</ol>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Player Authentication - SKE Digital Signage{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card">
<div class="card-header text-center">
<h4><i class="bi bi-shield-lock"></i> Player Authentication</h4>
<p class="text-muted mb-0">{{ player.username }}</p>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="POST">
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control form-control-lg"
id="password" name="password" required autofocus
placeholder="Enter player password">
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-unlock"></i> Authenticate
</button>
</div>
</form>
<hr>
<div class="text-center">
<small class="text-muted">
<i class="bi bi-info-circle"></i>
Enter the password to access this player's display
</small>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.card {
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
border: none;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,168 @@
{% extends "base.html" %}
{% block title %}Edit Player - SKE Digital Signage{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Page Header -->
<div class="row mb-4">
<div class="col">
<h1><i class="bi bi-pencil-square"></i> Edit Player: {{ player.username }}</h1>
<p class="text-muted">Modify player settings</p>
</div>
<div class="col-auto">
<a href="{{ url_for('dashboard.index') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
<div class="row">
<!-- Edit Form -->
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-gear"></i> Player Settings</h5>
</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="username" class="form-label">Username *</label>
<input type="text" class="form-control" id="username" name="username"
value="{{ player.username }}" required>
</div>
<div class="mb-3">
<label for="hostname" class="form-label">Hostname *</label>
<input type="text" class="form-control" id="hostname" name="hostname"
value="{{ player.hostname }}" required>
<div class="form-text">Must be unique across all players</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password"
placeholder="Leave blank to keep current password">
</div>
<div class="mb-3">
<label for="quickconnect_password" class="form-label">Quick Connect Code</label>
<input type="password" class="form-control" id="quickconnect_password"
name="quickconnect_password" value="{{ player.quickconnect_password or '' }}"
placeholder="Optional quick connect code">
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="is_active" name="is_active"
{{ 'checked' if player.is_active else '' }}>
<label class="form-check-label" for="is_active">
Active Player
</label>
</div>
<div class="form-text">Inactive players cannot receive content updates</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check"></i> Update Player
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Player Info -->
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-info-circle"></i> Player Details</h5>
</div>
<div class="card-body">
<div class="row mb-2">
<div class="col-sm-4"><strong>ID:</strong></div>
<div class="col-sm-8">{{ player.id }}</div>
</div>
<div class="row mb-2">
<div class="col-sm-4"><strong>Created:</strong></div>
<div class="col-sm-8">{{ player.created_at.strftime('%Y-%m-%d %H:%M') if player.created_at else 'N/A' }}</div>
</div>
<div class="row mb-2">
<div class="col-sm-4"><strong>Last Seen:</strong></div>
<div class="col-sm-8">
{% if player.last_seen %}
{{ player.last_seen.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span class="text-muted">Never</span>
{% endif %}
</div>
</div>
<div class="row mb-2">
<div class="col-sm-4"><strong>Status:</strong></div>
<div class="col-sm-8">
{% if player.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</div>
</div>
<div class="row mb-2">
<div class="col-sm-4"><strong>Groups:</strong></div>
<div class="col-sm-8">
{% if player.groups %}
{% for group in player.groups %}
<span class="badge bg-info">{{ group.name }}</span>
{% endfor %}
{% else %}
<span class="text-muted">No groups assigned</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="card mt-3">
<div class="card-header">
<h6><i class="bi bi-tools"></i> Actions</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('player.fullscreen', player_id=player.id) }}"
class="btn btn-info" target="_blank">
<i class="bi bi-fullscreen"></i> Open Fullscreen Display
</a>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">
<i class="bi bi-trash"></i> Delete Player
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete player <strong>{{ player.username }}</strong>?</p>
<p class="text-danger small">This action cannot be undone and will remove all player data.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ url_for('player.delete', player_id=player.id) }}" style="display:inline;">
<button type="submit" class="btn btn-danger">Delete Player</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,273 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ player.username }} - Digital Signage Display</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000;
color: #fff;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
overflow: hidden;
cursor: none;
}
.fullscreen-container {
width: 100vw;
height: 100vh;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.content-item {
width: 100%;
height: 100%;
display: none;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
}
.content-item.active {
display: flex;
}
.content-item img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.content-item video {
width: 100%;
height: 100%;
object-fit: cover;
}
.no-content {
text-align: center;
color: #666;
}
.no-content h1 {
font-size: 3rem;
margin-bottom: 1rem;
}
.no-content p {
font-size: 1.5rem;
}
.player-info {
position: absolute;
bottom: 20px;
right: 20px;
background: rgba(0,0,0,0.7);
padding: 10px 15px;
border-radius: 5px;
font-size: 0.9rem;
z-index: 1000;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 2rem;
color: #666;
}
.error-message {
color: #ff6b6b;
text-align: center;
font-size: 1.5rem;
}
</style>
</head>
<body>
<div class="fullscreen-container">
<div id="loading" class="loading">
<i></i> Loading content...
</div>
{% if content %}
{% for item in content %}
<div class="content-item" data-duration="{{ item.duration }}" data-id="{{ item.id }}">
{% if item.content_type.startswith('image/') %}
<img src="{{ url_for('static', filename='uploads/' + item.file_name) }}"
alt="{{ item.original_name or item.file_name }}">
{% elif item.content_type.startswith('video/') %}
<video muted autoplay>
<source src="{{ url_for('static', filename='uploads/' + item.file_name) }}"
type="{{ item.content_type }}">
</video>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="no-content">
<h1>📺</h1>
<h2>{{ player.username }}</h2>
<p>No content available</p>
<p style="margin-top: 2rem; font-size: 1rem; color: #888;">
Waiting for content assignment...
</p>
</div>
{% endif %}
<div class="player-info">
<div>{{ player.username }}</div>
<div style="font-size: 0.8rem; color: #ccc;">{{ player.hostname }}</div>
<div style="font-size: 0.7rem; color: #999;">Last updated: <span id="lastUpdate">--:--:--</span></div>
</div>
</div>
<script>
class DigitalSignagePlayer {
constructor() {
this.contentItems = document.querySelectorAll('.content-item');
this.currentIndex = 0;
this.isPlaying = false;
this.refreshInterval = 30000; // 30 seconds
this.playerId = {{ player.id|tojson }};
this.init();
}
init() {
// Hide loading
document.getElementById('loading').style.display = 'none';
// Set initial time
this.updateDisplayTime();
if (this.contentItems.length > 0) {
this.startSlideshow();
} else {
console.log('No content available');
}
// Set up content refresh
setInterval(() => this.checkForUpdates(), this.refreshInterval);
// Update last update time
setInterval(() => this.updateLastSeen(), 60000);
}
startSlideshow() {
if (this.contentItems.length === 0) return;
this.isPlaying = true;
this.showContent(0);
}
showContent(index) {
if (index >= this.contentItems.length) {
index = 0;
}
// Hide all content
this.contentItems.forEach(item => {
item.classList.remove('active');
const video = item.querySelector('video');
if (video) {
video.pause();
video.currentTime = 0;
}
});
// Show current content
const currentItem = this.contentItems[index];
currentItem.classList.add('active');
const video = currentItem.querySelector('video');
if (video) {
video.play();
}
// Get duration and schedule next
const duration = parseInt(currentItem.dataset.duration) * 1000;
setTimeout(() => {
this.currentIndex = (index + 1) % this.contentItems.length;
this.showContent(this.currentIndex);
}, duration);
}
checkForUpdates() {
fetch(`/api/player/${this.playerId}/content`)
.then(response => response.json())
.then(data => {
if (data.updated) {
console.log('Content updated, reloading...');
window.location.reload();
}
})
.catch(error => {
console.error('Error checking for updates:', error);
});
}
updateLastSeen() {
fetch(`/api/player/${this.playerId}/heartbeat`, {
method: 'POST'
}).catch(error => {
console.error('Error updating last seen:', error);
});
// Update display time
this.updateDisplayTime();
}
updateDisplayTime() {
const now = new Date();
document.getElementById('lastUpdate').textContent =
now.toLocaleTimeString('en-US', { hour12: false });
}
}
// Start the player when page loads
document.addEventListener('DOMContentLoaded', () => {
new DigitalSignagePlayer();
});
// Prevent right-click context menu
document.addEventListener('contextmenu', e => e.preventDefault());
// Handle keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'F11') {
// Allow F11 for fullscreen toggle
return;
}
if (e.key === 'Escape') {
// Allow Escape to exit fullscreen
return;
}
// Prevent other keys
e.preventDefault();
});
// Auto-enter fullscreen on load
document.addEventListener('DOMContentLoaded', () => {
if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch(err => {
console.log('Fullscreen not supported or denied');
});
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,271 @@
{% extends "base.html" %}
{% block title %}{{ player.username }} - Player View{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Page Header -->
<div class="row mb-4">
<div class="col">
<h1><i class="bi bi-display"></i> {{ player.username }}</h1>
<p class="text-muted">Player details and content management</p>
</div>
<div class="col-auto">
<a href="{{ url_for('dashboard.index') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Dashboard
</a>
<a href="{{ url_for('player.edit', player_id=player.id) }}" class="btn btn-primary">
<i class="bi bi-pencil"></i> Edit Player
</a>
</div>
</div>
<div class="row">
<!-- Player Information -->
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-info-circle"></i> Player Information</h5>
</div>
<div class="card-body">
<div class="row mb-2">
<div class="col-sm-5"><strong>Username:</strong></div>
<div class="col-sm-7">{{ player.username }}</div>
</div>
<div class="row mb-2">
<div class="col-sm-5"><strong>Hostname:</strong></div>
<div class="col-sm-7"><code>{{ player.hostname }}</code></div>
</div>
<div class="row mb-2">
<div class="col-sm-5"><strong>Status:</strong></div>
<div class="col-sm-7">
{% if player.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</div>
</div>
<div class="row mb-2">
<div class="col-sm-5"><strong>Created:</strong></div>
<div class="col-sm-7">{{ player.created_at.strftime('%Y-%m-%d %H:%M') if player.created_at else 'N/A' }}</div>
</div>
<div class="row mb-2">
<div class="col-sm-5"><strong>Last Seen:</strong></div>
<div class="col-sm-7">
{% if player.last_seen %}
{{ player.last_seen.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span class="text-muted">Never</span>
{% endif %}
</div>
</div>
<div class="row mb-2">
<div class="col-sm-5"><strong>Groups:</strong></div>
<div class="col-sm-7">
{% if player.groups %}
{% for group in player.groups %}
<span class="badge bg-info me-1">{{ group.name }}</span>
{% endfor %}
{% else %}
<span class="text-muted">No groups</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card mt-3">
<div class="card-header">
<h6><i class="bi bi-lightning"></i> Quick Actions</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('player.fullscreen', player_id=player.id) }}"
class="btn btn-info" target="_blank">
<i class="bi bi-fullscreen"></i> Open Display
</a>
<button type="button" class="btn btn-success" onclick="refreshPlayer()">
<i class="bi bi-arrow-clockwise"></i> Refresh Content
</button>
</div>
</div>
</div>
</div>
<!-- Content Management -->
<div class="col-md-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5><i class="bi bi-collection-play"></i> Player Content</h5>
<a href="{{ url_for('content.upload') }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus"></i> Add Content
</a>
</div>
<div class="card-body">
{% if content %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Position</th>
<th>Filename</th>
<th>Type</th>
<th>Duration</th>
<th>Uploaded</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in content %}
<tr>
<td>
<span class="badge bg-primary">{{ item.position }}</span>
</td>
<td>
<strong>{{ item.file_name }}</strong>
{% if item.original_name %}
<br><small class="text-muted">{{ item.original_name }}</small>
{% endif %}
</td>
<td>
{% if item.content_type.startswith('image/') %}
<span class="badge bg-success"><i class="bi bi-image"></i> Image</span>
{% elif item.content_type.startswith('video/') %}
<span class="badge bg-info"><i class="bi bi-play-circle"></i> Video</span>
{% else %}
<span class="badge bg-secondary">{{ item.content_type }}</span>
{% endif %}
</td>
<td>{{ item.duration }}s</td>
<td>{{ item.uploaded_at.strftime('%m/%d %H:%M') if item.uploaded_at else 'N/A' }}</td>
<td>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary"
onclick="previewContent('{{ item.id }}', '{{ item.file_name }}', '{{ item.content_type }}')">
<i class="bi bi-eye"></i>
</button>
<button type="button" class="btn btn-outline-danger"
onclick="removeContent('{{ item.id }}', '{{ item.file_name }}')">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="bi bi-collection-play text-muted" style="font-size: 3rem;"></i>
<h5 class="text-muted mt-2">No Content Available</h5>
<p class="text-muted">This player doesn't have any content assigned yet.</p>
<a href="{{ url_for('content.upload') }}" class="btn btn-primary">
<i class="bi bi-plus"></i> Upload First Content
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Content Preview Modal -->
<div class="modal fade" id="previewModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Content Preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center" id="previewContent">
<!-- Content will be loaded here -->
</div>
</div>
</div>
</div>
<!-- Remove Content Modal -->
<div class="modal fade" id="removeModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Remove Content</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to remove <strong id="removeFilename"></strong> from this player?</p>
<p class="text-warning small">This will only remove the content from this player, not delete the file.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" onclick="confirmRemoveContent()">Remove Content</button>
</div>
</div>
</div>
</div>
<script>
let currentContentId = null;
function refreshPlayer() {
// Placeholder for content refresh functionality
alert('Content refresh signal sent to player!');
}
function previewContent(contentId, filename, contentType) {
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
const previewDiv = document.getElementById('previewContent');
if (contentType.startsWith('image/')) {
previewDiv.innerHTML = `<img src="/static/uploads/${filename}" class="img-fluid" alt="${filename}">`;
} else if (contentType.startsWith('video/')) {
previewDiv.innerHTML = `<video controls class="w-100" style="max-height: 400px;">
<source src="/static/uploads/${filename}" type="${contentType}">
Your browser does not support the video tag.
</video>`;
} else {
previewDiv.innerHTML = `<p>Preview not available for this content type: ${contentType}</p>`;
}
modal.show();
}
function removeContent(contentId, filename) {
currentContentId = contentId;
document.getElementById('removeFilename').textContent = filename;
const modal = new bootstrap.Modal(document.getElementById('removeModal'));
modal.show();
}
function confirmRemoveContent() {
if (currentContentId) {
// Make API call to remove content from player
fetch(`/api/content/${currentContentId}/remove-from-player`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
player_id: {{ player.id|tojson }}
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error removing content: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error removing content');
});
}
}
</script>
{% endblock %}

View File

@@ -1,382 +1,229 @@
""" """
File upload processing utilities File upload and processing utilities
""" """
import os import os
import subprocess import subprocess
import shutil from flask import current_app
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from pdf2image import convert_from_path
from PIL import Image
from app.extensions import db from app.extensions import db
from app.models.content import Content from app.models.content import Content
from app.models.player import Player
from app.models.group import Group
from app.utils.logger import log_upload, log_process, log_content_added from app.utils.logger import log_upload, log_process, log_content_added
def allowed_file(filename, file_type='all'):
"""
Check if file extension is allowed
Args:
filename (str): Name of the file
file_type (str): Type of file to check ('images', 'videos', 'documents', 'all')
Returns:
bool: True if file is allowed
"""
from flask import current_app
if '.' not in filename:
return False
ext = filename.rsplit('.', 1)[1].lower()
allowed_extensions = current_app.config['ALLOWED_EXTENSIONS']
if file_type == 'all':
all_extensions = set()
for extensions in allowed_extensions.values():
all_extensions.update(extensions)
return ext in all_extensions
return ext in allowed_extensions.get(file_type, set())
def get_file_type(filename):
"""
Determine file type based on extension
Args:
filename (str): Name of the file
Returns:
str: File type ('image', 'video', 'document')
"""
from flask import current_app
if '.' not in filename:
return 'unknown'
ext = filename.rsplit('.', 1)[1].lower()
allowed_extensions = current_app.config['ALLOWED_EXTENSIONS']
for file_type, extensions in allowed_extensions.items():
if ext in extensions:
return file_type.rstrip('s') # Remove 's' from 'images', 'videos', etc.
return 'unknown'
def save_uploaded_file(file, upload_folder):
"""
Save uploaded file to disk
Args:
file: FileStorage object from request
upload_folder (str): Path to upload folder
Returns:
tuple: (success, filename, error_message)
"""
try:
if not file or file.filename == '':
return False, None, "No file selected"
if not allowed_file(file.filename):
return False, None, f"File type not allowed: {file.filename}"
# Generate secure filename
original_filename = file.filename
filename = secure_filename(original_filename)
# Handle duplicate filenames
base_name, ext = os.path.splitext(filename)
counter = 1
while os.path.exists(os.path.join(upload_folder, filename)):
filename = f"{base_name}_{counter}{ext}"
counter += 1
# Save file
file_path = os.path.join(upload_folder, filename)
file.save(file_path)
return True, filename, None
except Exception as e:
return False, None, str(e)
def process_image(file_path, max_width=1920, max_height=1080):
"""
Process and optimize image file
Args:
file_path (str): Path to image file
max_width (int): Maximum width for resizing
max_height (int): Maximum height for resizing
Returns:
tuple: (width, height) of processed image
"""
try:
with Image.open(file_path) as img:
# Get original dimensions
original_width, original_height = img.size
# Calculate new dimensions while maintaining aspect ratio
if original_width > max_width or original_height > max_height:
img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
img.save(file_path, optimize=True, quality=85)
return img.size
except Exception as e:
print(f"Error processing image {file_path}: {e}")
return None, None
def process_video(file_path, output_path=None):
"""
Process video file (convert to web-compatible format)
Args:
file_path (str): Path to input video file
output_path (str): Path for output file (optional)
Returns:
tuple: (success, output_filename, error_message)
"""
try:
if output_path is None:
base_name = os.path.splitext(file_path)[0]
output_path = f"{base_name}_converted.mp4"
# Use FFmpeg to convert video
cmd = [
'ffmpeg', '-i', file_path,
'-c:v', 'libx264',
'-preset', 'medium',
'-crf', '23',
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart',
'-y', # Overwrite output file
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
# Remove original file if conversion successful
if os.path.exists(output_path) and file_path != output_path:
os.remove(file_path)
return True, os.path.basename(output_path), None
else:
return False, None, result.stderr
except Exception as e:
return False, None, str(e)
def process_pdf(file_path, output_folder):
"""
Convert PDF to images
Args:
file_path (str): Path to PDF file
output_folder (str): Folder to save converted images
Returns:
list: List of generated image filenames
"""
try:
images = convert_from_path(file_path, dpi=150)
base_name = os.path.splitext(os.path.basename(file_path))[0]
image_files = []
for i, image in enumerate(images):
image_filename = f"{base_name}_page_{i+1}.png"
image_path = os.path.join(output_folder, image_filename)
image.save(image_path, 'PNG')
image_files.append(image_filename)
# Remove original PDF
os.remove(file_path)
return image_files
except Exception as e:
print(f"Error processing PDF {file_path}: {e}")
return []
def process_pptx(file_path, output_folder):
"""
Convert PowerPoint to images using LibreOffice
Args:
file_path (str): Path to PPTX file
output_folder (str): Folder to save converted images
Returns:
list: List of generated image filenames
"""
try:
# Use LibreOffice to convert PPTX to PDF first
temp_dir = os.path.join(output_folder, 'temp')
os.makedirs(temp_dir, exist_ok=True)
cmd = [
'libreoffice', '--headless',
'--convert-to', 'pdf',
'--outdir', temp_dir,
file_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
# Find the generated PDF
base_name = os.path.splitext(os.path.basename(file_path))[0]
pdf_path = os.path.join(temp_dir, f"{base_name}.pdf")
if os.path.exists(pdf_path):
# Convert PDF to images
image_files = process_pdf(pdf_path, output_folder)
# Clean up
shutil.rmtree(temp_dir)
os.remove(file_path)
return image_files
return []
except Exception as e:
print(f"Error processing PPTX {file_path}: {e}")
return []
def process_uploaded_files(app, files, duration, target_type, target_id): def process_uploaded_files(app, files, duration, target_type, target_id):
""" """
Process uploaded files and add them to the database Process uploaded files and add them to playlists
Args: Args:
app: Flask application instance app: Flask application instance
files: List of uploaded files files: List of uploaded files
duration (int): Duration for each file in seconds duration: Display duration in seconds
target_type (str): 'player' or 'group' target_type: 'player' or 'group'
target_id (int): ID of the target player or group target_id: Target ID
Returns: Returns:
dict: Results of processing dict: Results with success and error lists
""" """
results = {'success': [], 'errors': []}
upload_folder = os.path.join(app.static_folder, 'uploads') upload_folder = os.path.join(app.static_folder, 'uploads')
results = { os.makedirs(upload_folder, exist_ok=True)
'success': [],
'errors': [],
'processed': 0
}
for file in files: for file in files:
if not file or file.filename == '': if file and file.filename:
continue try:
# Secure the filename
try: filename = secure_filename(file.filename)
# Save the file if not filename:
success, filename, error = save_uploaded_file(file, upload_folder) results['errors'].append(f"Invalid filename: {file.filename}")
if not success:
results['errors'].append(f"{file.filename}: {error}")
continue
file_path = os.path.join(upload_folder, filename)
file_type = get_file_type(filename)
# Process based on file type
processed_files = []
if file_type == 'image':
width, height = process_image(file_path)
processed_files = [filename]
elif file_type == 'video':
success, converted_filename, error = process_video(file_path)
if success:
processed_files = [converted_filename]
else:
results['errors'].append(f"{filename}: Video conversion failed - {error}")
continue
elif file_type == 'document':
if filename.lower().endswith('.pdf'):
processed_files = process_pdf(file_path, upload_folder)
elif filename.lower().endswith(('.pptx', '.ppt')):
processed_files = process_pptx(file_path, upload_folder)
if not processed_files:
results['errors'].append(f"{filename}: Document conversion failed")
continue
# Add processed files to database
from app.models.player import Player
if target_type == 'player':
player = Player.query.get(target_id)
if not player:
results['errors'].append(f"Player {target_id} not found")
continue continue
# Get max position for ordering # Get file extension and determine content type
max_position = db.session.query(db.func.max(Content.position)).filter_by(player_id=target_id).scalar() or 0 file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
content_type = get_content_type(file_ext)
for processed_file in processed_files: if not content_type:
content = Content( results['errors'].append(f"Unsupported file type: {file_ext}")
file_name=processed_file, continue
# Save file
file_path = os.path.join(upload_folder, filename)
file.save(file_path)
# Get file size
file_size = os.path.getsize(file_path)
# Process based on target type
if target_type == 'player':
success = add_content_to_player(
player_id=target_id,
filename=filename,
original_name=file.filename, original_name=file.filename,
duration=duration, duration=duration,
player_id=target_id, content_type=content_type,
content_type=file_type, file_size=file_size
position=max_position + 1
) )
db.session.add(content) elif target_type == 'group':
max_position += 1 success = add_content_to_group(
group_id=target_id,
# Update playlist version filename=filename,
player.increment_playlist_version() original_name=file.filename,
log_content_added(file.filename, 'player', player.username) duration=duration,
content_type=content_type,
elif target_type == 'group': file_size=file_size
from app.models.group import Group )
group = Group.query.get(target_id) else:
if not group: results['errors'].append(f"Invalid target type: {target_type}")
results['errors'].append(f"Group {target_id} not found")
continue continue
# Add content to all players in the group if success:
for player in group.players: results['success'].append(filename)
max_position = db.session.query(db.func.max(Content.position)).filter_by(player_id=player.id).scalar() or 0 log_upload(content_type, filename, target_type, str(target_id))
else:
results['errors'].append(f"Failed to add {filename} to {target_type}")
for processed_file in processed_files: except Exception as e:
content = Content( results['errors'].append(f"Error processing {file.filename}: {str(e)}")
file_name=processed_file,
original_name=file.filename,
duration=duration,
player_id=player.id,
content_type=file_type,
position=max_position + 1
)
db.session.add(content)
max_position += 1
player.increment_playlist_version()
log_content_added(file.filename, 'group', group.name)
results['success'].append(file.filename)
results['processed'] += len(processed_files)
# Log the upload
log_upload(file_type, file.filename, target_type, target_id)
except Exception as e:
results['errors'].append(f"{file.filename}: {str(e)}")
# Commit all changes
try:
db.session.commit()
except Exception as e:
db.session.rollback()
results['errors'].append(f"Database error: {str(e)}")
return results return results
def get_content_type(file_ext):
"""Determine content type from file extension"""
image_extensions = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'}
video_extensions = {'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'}
document_extensions = {'pdf', 'pptx', 'ppt'}
if file_ext in image_extensions:
return 'image'
elif file_ext in video_extensions:
return 'video'
elif file_ext in document_extensions:
return 'document'
else:
return None
def add_content_to_player(player_id, filename, original_name, duration, content_type, file_size):
"""Add content to a specific player"""
try:
player = Player.query.get(player_id)
if not player:
return False
# Get next position
max_position = db.session.query(db.func.max(Content.position)).filter_by(player_id=player_id).scalar() or 0
# Create content entry
content = Content(
file_name=filename,
original_name=original_name,
duration=duration,
position=max_position + 1,
player_id=player_id,
content_type=content_type,
file_size=file_size
)
db.session.add(content)
player.increment_playlist_version()
db.session.commit()
log_content_added(filename, 'player', player.username)
return True
except Exception as e:
db.session.rollback()
print(f"Error adding content to player: {e}")
return False
def add_content_to_group(group_id, filename, original_name, duration, content_type, file_size):
"""Add content to all players in a group"""
try:
group = Group.query.get(group_id)
if not group:
return False
# Add content to all players in the group
for player in group.players:
# Get next position for this player
max_position = db.session.query(db.func.max(Content.position)).filter_by(player_id=player.id).scalar() or 0
# Create content entry
content = Content(
file_name=filename,
original_name=original_name,
duration=duration,
position=max_position + 1,
player_id=player.id,
content_type=content_type,
file_size=file_size
)
db.session.add(content)
# Update playlist version for group
group.increment_playlist_version()
db.session.commit()
log_content_added(filename, 'group', group.name)
return True
except Exception as e:
db.session.rollback()
print(f"Error adding content to group: {e}")
return False
def allowed_file(filename, allowed_extensions=None):
"""Check if file has an allowed extension"""
if allowed_extensions is None:
allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp',
'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv',
'pdf', 'pptx', 'ppt'}
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in allowed_extensions
def get_file_info(file_path):
"""Get basic file information"""
try:
stat = os.stat(file_path)
return {
'size': stat.st_size,
'modified': stat.st_mtime,
'exists': True
}
except OSError:
return {'exists': False}
def cleanup_orphaned_files(upload_folder):
"""Remove files that are not referenced in the database"""
try:
# Get all filenames from database
db_files = {content.file_name for content in Content.query.all()}
# Get all files in upload folder
if os.path.exists(upload_folder):
disk_files = set(os.listdir(upload_folder))
# Find orphaned files
orphaned = disk_files - db_files
# Remove orphaned files
removed_count = 0
for filename in orphaned:
file_path = os.path.join(upload_folder, filename)
if os.path.isfile(file_path):
try:
os.remove(file_path)
removed_count += 1
except OSError as e:
print(f"Error removing {file_path}: {e}")
return removed_count
return 0
except Exception as e:
print(f"Error during cleanup: {e}")
return 0