checked for updates
This commit is contained in:
@@ -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"""
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
BIN
app/static/uploads/123.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 537 KiB |
173
app/templates/content/upload.html
Normal file
173
app/templates/content/upload.html
Normal 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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
83
app/templates/player/add.html
Normal file
83
app/templates/player/add.html
Normal 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 %}
|
||||||
59
app/templates/player/auth.html
Normal file
59
app/templates/player/auth.html
Normal 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 %}
|
||||||
168
app/templates/player/edit.html
Normal file
168
app/templates/player/edit.html
Normal 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 %}
|
||||||
273
app/templates/player/fullscreen.html
Normal file
273
app/templates/player/fullscreen.html
Normal 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>
|
||||||
271
app/templates/player/view.html
Normal file
271
app/templates/player/view.html
Normal 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 %}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user