diff --git a/app/blueprints/api.py b/app/blueprints/api.py index ec65b9d..a60a10e 100644 --- a/app/blueprints/api.py +++ b/app/blueprints/api.py @@ -734,75 +734,105 @@ def receive_edited_media(): """ try: player = request.player - + # Check if file is present if 'image_file' not in request.files: return jsonify({'error': 'No image file provided'}), 400 - + file = request.files['image_file'] if file.filename == '': return jsonify({'error': 'No file selected'}), 400 - + # Get metadata import json metadata_str = request.form.get('metadata') if not metadata_str: return jsonify({'error': 'No metadata provided'}), 400 - + try: metadata = json.loads(metadata_str) except json.JSONDecodeError: return jsonify({'error': 'Invalid metadata JSON'}), 400 - + # Validate required metadata fields required_fields = ['time_of_modification', 'original_name', 'new_name', 'version'] for field in required_fields: if field not in metadata: return jsonify({'error': f'Missing required field: {field}'}), 400 - - # Import required modules + import os + import shutil + from datetime import datetime from werkzeug.utils import secure_filename from app.models.player_edit import PlayerEdit - - # Find the original content by filename + original_name = metadata['original_name'] + version = metadata['version'] + new_filename = metadata['new_name'] + + # ── Content lookup ─────────────────────────────────────────────── + # If the player sends the current filename (pointing to edited_media/...), + # we find the content by its ID embedded in the path. Otherwise do a + # direct filename match. content = Content.query.filter_by(filename=original_name).first() - + if not content: + # Try to extract content_id from a path like "edited_media//file" + import re + m = re.match(r'edited_media/(\d+)/', original_name) + if m: + content = db.session.get(Content, int(m.group(1))) + if not content: + # Last resort – look up the most recent PlayerEdit for this + # content and use its content_id. + fallback_edit = PlayerEdit.query.filter_by(new_name=original_name)\ + .order_by(PlayerEdit.created_at.desc()).first() + if fallback_edit: + content = db.session.get(Content, fallback_edit.content_id) + if not content: log_action('warning', f'Player {player.name} tried to edit non-existent content: {original_name}') return jsonify({'error': f'Original content not found: {original_name}'}), 404 - - # Create versioned folder structure: edited_media// + + # ── Versionized folder ─────────────────────────────────────────── base_upload_dir = os.path.join(current_app.root_path, 'static', 'uploads') edited_media_dir = os.path.join(base_upload_dir, 'edited_media', str(content.id)) os.makedirs(edited_media_dir, exist_ok=True) - - # Save the edited file with version suffix - version = metadata['version'] - new_filename = metadata['new_name'] + + # On the very first edit (v1) move the original file into the + # versionized folder so it is never orphaned. + is_first_edit = PlayerEdit.query.filter_by(content_id=content.id).count() == 0 + if is_first_edit: + orig_upload = os.path.join(base_upload_dir, content.filename) + # content.filename might already be an edited_media/ path if + # this is a re-process; only move if it's a plain filename. + if os.path.isfile(orig_upload) and not content.filename.startswith('edited_media/'): + orig_stored = f"original_{content.filename}" + shutil.move(orig_upload, os.path.join(edited_media_dir, orig_stored)) + log_action('info', f'Moved original file "{content.filename}" to versionized folder as "{orig_stored}"') + + # ── Save the edited file ───────────────────────────────────────── edited_file_path = os.path.join(edited_media_dir, new_filename) file.save(edited_file_path) - - # Save metadata JSON file + + # Side-car metadata JSON metadata_filename = f"{os.path.splitext(new_filename)[0]}_metadata.json" metadata_path = os.path.join(edited_media_dir, metadata_filename) with open(metadata_path, 'w') as f: json.dump(metadata, f, indent=2) - - # Update the content record to reference the edited version path - # Keep original filename unchanged, point to edited_media folder + + # ── Point Content.filename to the latest edit ──────────────────── + # This tells the player to download the latest edited version. old_filename = content.filename content.filename = f"edited_media/{content.id}/{new_filename}" - - # Create edit record + + # ── Create edit record ─────────────────────────────────────────── time_of_mod = None if metadata.get('time_of_modification'): try: time_of_mod = datetime.fromisoformat(metadata['time_of_modification'].replace('Z', '+00:00')) except: time_of_mod = datetime.utcnow() - + # Auto-create PlayerUser record if user code is provided user_code = metadata.get('user_card_data') log_action('debug', f'Metadata user code: {user_code}') @@ -817,7 +847,7 @@ def receive_edited_media(): log_action('debug', f'PlayerUser already exists for code: {user_code}') else: log_action('debug', 'No user code in metadata') - + edit_record = PlayerEdit( player_id=player.id, content_id=content.id, @@ -830,22 +860,22 @@ def receive_edited_media(): edited_file_path=edited_file_path ) db.session.add(edit_record) - - # Update playlist version to force player refresh + + # ── Update playlist version to force player refresh ────────────── playlist = None if player.playlist_id: from app.models.playlist import Playlist playlist = db.session.get(Playlist, player.playlist_id) if playlist: playlist.version += 1 - + # Clear playlist cache cache.delete_memoized(get_cached_playlist, player.id) - + db.session.commit() - + log_action('info', f'Player {player.name} uploaded edited media: {old_filename} -> {new_filename} (v{version})') - + return jsonify({ 'success': True, 'message': 'Edited media received and processed', diff --git a/app/blueprints/players.py b/app/blueprints/players.py index e8bcead..6500cb0 100644 --- a/app/blueprints/players.py +++ b/app/blueprints/players.py @@ -28,10 +28,23 @@ def list(): status_info = get_player_status_info(player.id) player_statuses[player.id] = status_info + # Build a JSON-safe dict of deployment statuses for the polling JS + import json + from datetime import datetime as dt + player_statuses_json = {} + for player in players: + player_statuses_json[str(player.id)] = { + 'deployment_status': player.deployment_status, + 'last_deployment_status': player.last_deployment_status, + 'last_deployment_at': player.last_deployment_at.isoformat() if player.last_deployment_at else None, + 'last_deployment_message': player.last_deployment_message, + } + return render_template('players/players_list.html', players=players, playlists=playlists, - player_statuses=player_statuses) + player_statuses=player_statuses, + player_statuses_json=json.dumps(player_statuses_json)) except Exception as e: log_action('error', f'Error loading players list: {str(e)}') flash('Error loading players list.', 'danger') @@ -148,6 +161,13 @@ def add_player(): host = f"{detected_ip}:{port_part}" if port_part else detected_ip server_url = f"{flask_request.scheme}://{host}" + # Mark deployment as "in progress" immediately so the UI polling picks it up + from datetime import datetime + new_player.deployment_status = 'deploying' + new_player.last_deployment_at = datetime.utcnow() + new_player.last_deployment_message = 'Deployment in progress...' + db.session.commit() + # Generate API key for player authentication import hashlib api_key = hashlib.sha256(f'{name}:{hostname}'.encode()).hexdigest()[:32] @@ -173,19 +193,30 @@ def add_player(): log_action('error', f'Failed to initiate background deployment for player "{name}": {str(deploy_err)}') # Flash detailed success message - success_msg = f''' - Player "{name}" created successfully!
- Auth Code: {auth_code}
- Hostname: {hostname}
- Quick Connect: {quickconnect_code}
- ''' - if deployment_initiated: - success_msg += f'⌛ Deployment in Progress Deploying to {ssh_hostname} in background...
' - success_msg += 'Check player status to see deployment completion
' - - success_msg += 'Configure the player with these credentials in app_config.json' - flash(success_msg, 'success') + success_msg = f''' + Player "{name}" created successfully!
+ Auth Code: {auth_code}
+ Hostname: {hostname}
+ Quick Connect: {quickconnect_code}
+
+ ⌛ Deployment in Progress
+ Deploying to {ssh_hostname} in background...
+ The deployment status will update automatically on the players list. + ''' + flash(success_msg, 'success') + else: + success_msg = f''' + Player "{name}" created successfully!
+ Auth Code: {auth_code}
+ Hostname: {hostname}
+ Quick Connect: {quickconnect_code}
+
+ Configure the player with these credentials in app_config.json + ''' + flash(success_msg, 'success') + if deploy_player: + flash('Player was created but deployment could not be started. You can deploy manually from the Manage page.', 'warning') return redirect(url_for('players.list')) @@ -453,6 +484,40 @@ def edited_media(player_id: int): return redirect(url_for('players.manage_player', player_id=player_id)) +@players_bp.route('//edited-media-report') +@login_required +def edited_media_report(player_id: int): + """Display a tabular report of all edited media from this player.""" + try: + player = Player.query.get_or_404(player_id) + + from app.models.player_edit import PlayerEdit + from app.models.player_user import PlayerUser + + edited_media = PlayerEdit.query.filter_by(player_id=player_id)\ + .order_by(PlayerEdit.created_at.desc())\ + .all() + + # Build user display name mapping + user_mappings = {} + for edit in edited_media: + if edit.user and edit.user not in user_mappings: + player_user = PlayerUser.query.filter_by(user_code=edit.user).first() + if player_user and player_user.user_name: + user_mappings[edit.user] = player_user.user_name + else: + user_mappings[edit.user] = edit.user + + return render_template('players/edited_media_report.html', + player=player, + edited_media=edited_media, + user_mappings=user_mappings) + except Exception as e: + log_action('error', f'Error loading edited media report for player {player_id}: {str(e)}') + flash('Error loading edited media report.', 'danger') + return redirect(url_for('players.manage_player', player_id=player_id)) + + @players_bp.route('//fullscreen') def player_fullscreen(player_id: int): """Display player fullscreen view (no authentication required for players).""" @@ -598,6 +663,30 @@ def bulk_assign_playlist(): return jsonify({'success': False, 'error': str(e)}), 500 +@players_bp.route('/deployment-status') +@login_required +def deployment_status(): + """Return deployment status for all players (used by polling JS).""" + try: + players = Player.query.with_entities( + Player.id, Player.deployment_status, + Player.last_deployment_status, Player.last_deployment_at, + Player.last_deployment_message + ).all() + data = {} + for p in players: + data[p.id] = { + 'deployment_status': p.deployment_status, + 'last_deployment_status': p.last_deployment_status, + 'last_deployment_at': p.last_deployment_at.isoformat() if p.last_deployment_at else None, + 'last_deployment_message': p.last_deployment_message, + } + return jsonify(data) + except Exception as e: + log_action('error', f'Error fetching deployment status: {str(e)}') + return jsonify({'error': str(e)}), 500 + + @players_bp.route('//playlist/reorder', methods=['POST']) @login_required def reorder_playlist(player_id: int): diff --git a/app/templates/players/_deploy_badge.html b/app/templates/players/_deploy_badge.html new file mode 100644 index 0000000..0b3dc85 --- /dev/null +++ b/app/templates/players/_deploy_badge.html @@ -0,0 +1,25 @@ +{% if player.deployment_status == 'deploying' %} + + Deploying... + +{% elif player.deployment_status == 'deployed' %} + + ✅ Deployed + {% if player.last_deployment_at %} + {{ player.last_deployment_at | localtime }} + {% endif %} + +{% elif player.deployment_status == 'failed' %} + + ❌ Failed + {% if player.last_deployment_at %} + {{ player.last_deployment_at | localtime }} + {% endif %} + +{% elif player.deployment_status == 'pending' %} + + ⏳ Pending + +{% else %} + +{% endif %} diff --git a/app/templates/players/edited_media_report.html b/app/templates/players/edited_media_report.html new file mode 100644 index 0000000..eca432c --- /dev/null +++ b/app/templates/players/edited_media_report.html @@ -0,0 +1,342 @@ +{% extends "base.html" %} + +{% block title %}Edited Media Report - {{ player.name }}{% endblock %} + +{% block content %} + + +
+
+ + ← Back to Player + +

+ + Edited Media Report — {{ player.name }} +

+ +
+ + {% if edited_media %} + {% set total_edits = edited_media|length %} + {% set unique_files = edited_media|map(attribute='content_id')|unique|list|length %} + {% set unique_users = edited_media|selectattr('user')|map(attribute='user')|unique|list|length %} + +
+
+ {{ total_edits }} + Total Edits +
+
+ {{ unique_files }} + Files Edited +
+
+ {{ unique_users }} + Editors +
+
+ +
+ + + + + + + + + + + + {% for edit in edited_media %} + + + + + + + + {% endfor %} + +
UserEdited FileVersionDateLink
+ {% if edit.user %} + {% set display_name = user_mappings.get(edit.user, edit.user) %} + 👤 {{ display_name }} + {% else %} + + {% endif %} + + 📄 {{ edit.new_name }} + + v{{ edit.version }} + + {% if edit.time_of_modification %} + {{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') }} + {% elif edit.created_at %} + {{ edit.created_at | localtime('%Y-%m-%d %H:%M') }} + {% else %} + + {% endif %} +
+
+ {% else %} +
+
📋
+

No edited media found

+

This player has not submitted any edited media yet.

+
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/players/manage_player.html b/app/templates/players/manage_player.html index 9d683be..630bc6e 100644 --- a/app/templates/players/manage_player.html +++ b/app/templates/players/manage_player.html @@ -646,13 +646,22 @@ document.addEventListener('keydown', function(event) { Edited Media on the Player {% if edited_media %} - - 📋 View All Edited Media - + {% endif %}

Latest 3 edited files with their most recent versions

diff --git a/app/templates/players/players_list.html b/app/templates/players/players_list.html index 7d4e537..25bb897 100644 --- a/app/templates/players/players_list.html +++ b/app/templates/players/players_list.html @@ -125,6 +125,82 @@ body.dark-mode .info-box a { color: #90cdf4; } + + /* Deployment status */ + .deploy-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 3px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + } + .deploy-badge.pending { + background: #fff3cd; + color: #856404; + } + body.dark-mode .deploy-badge.pending { + background: #4a3800; + color: #fbbf24; + } + .deploy-badge.deployed { + background: #d4edda; + color: #155724; + } + body.dark-mode .deploy-badge.deployed { + background: #1a4d2e; + color: #86efac; + } + .deploy-badge.failed { + background: #f8d7da; + color: #721c24; + } + body.dark-mode .deploy-badge.failed { + background: #4a1a1a; + color: #fc8181; + } + .deploy-badge.deploying { + background: #cce5ff; + color: #004085; + animation: pulse-bg 1.5s ease-in-out infinite; + } + body.dark-mode .deploy-badge.deploying { + background: #1a365d; + color: #90cdf4; + } + .deploy-badge .spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid rgba(0,64,133,0.3); + border-radius: 50%; + border-top-color: #004085; + animation: deploy-spin 0.8s linear infinite; + } + body.dark-mode .deploy-badge .spinner { + border-color: rgba(144,205,244,0.3); + border-top-color: #90cdf4; + } + @keyframes deploy-spin { to { transform: rotate(360deg); } } + @keyframes pulse-bg { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } + } + .deploy-timestamp { + font-size: 11px; + color: #6c757d; + display: block; + margin-top: 2px; + } + body.dark-mode .deploy-timestamp { + color: #718096; + } + .deploy-tooltip { + cursor: help; + border-bottom: 1px dashed #aaa; + }
@@ -142,13 +218,14 @@ Location Orientation Status + Deployment Last Seen Actions {% for player in players %} - + {{ player.name }} @@ -168,6 +245,9 @@ Offline {% endif %} + + {% include "players/_deploy_badge.html" %} + {% if player.last_seen %} {{ player.last_seen | localtime }} @@ -192,4 +272,78 @@
{% endif %}
+ + + {% endblock %} diff --git a/app/utils/background_tasks.py b/app/utils/background_tasks.py index 30d8746..664a2d1 100644 --- a/app/utils/background_tasks.py +++ b/app/utils/background_tasks.py @@ -62,6 +62,17 @@ def background_player_deployment( from app.extensions import db from app.utils.logger import log_action + from datetime import datetime + + # Mark deployment as "in progress" immediately so the UI can show live status + player = Player.query.get(player_id) + if player: + player.deployment_status = 'deploying' + player.last_deployment_at = datetime.utcnow() + player.last_deployment_status = None + player.last_deployment_message = 'Deployment in progress...' + db.session.commit() + try: # Execute deployment result = deploy_player_to_host( @@ -79,10 +90,8 @@ def background_player_deployment( ) # Update player with deployment status - from datetime import datetime player = Player.query.get(player_id) if player: - player.last_deployment_at = datetime.utcnow() if result.get('success'): player.deployment_status = 'deployed' player.last_deployment_status = 'success' @@ -98,4 +107,10 @@ def background_player_deployment( except Exception as e: logger.error(f"Background deployment error for player '{player_name}': {str(e)}", exc_info=True) + player = Player.query.get(player_id) + if player: + player.deployment_status = 'failed' + player.last_deployment_status = 'failed' + player.last_deployment_message = f'Deployment crashed: {str(e)}' + db.session.commit() log_action('error', f'Background deployment error for player "{player_name}": {str(e)}')