Compare commits
5 Commits
d395240dce
...
digiserver
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
328edebe3c | ||
|
|
ff14e8defb | ||
|
|
8d52c0338f | ||
|
|
3921a09c4e | ||
|
|
8e43f2bd42 |
@@ -426,6 +426,16 @@ def delete_leftover_images():
|
|||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
|
|
||||||
|
# Delete edited media archive folder if it exists
|
||||||
|
import shutil
|
||||||
|
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
|
||||||
|
if os.path.exists(edited_media_dir):
|
||||||
|
shutil.rmtree(edited_media_dir)
|
||||||
|
|
||||||
|
# Delete associated player edit records first
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
PlayerEdit.query.filter_by(content_id=content.id).delete()
|
||||||
|
|
||||||
# Delete database record
|
# Delete database record
|
||||||
db.session.delete(content)
|
db.session.delete(content)
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
@@ -472,6 +482,16 @@ def delete_leftover_videos():
|
|||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
|
|
||||||
|
# Delete edited media archive folder if it exists
|
||||||
|
import shutil
|
||||||
|
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
|
||||||
|
if os.path.exists(edited_media_dir):
|
||||||
|
shutil.rmtree(edited_media_dir)
|
||||||
|
|
||||||
|
# Delete associated player edit records first
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
PlayerEdit.query.filter_by(content_id=content.id).delete()
|
||||||
|
|
||||||
# Delete database record
|
# Delete database record
|
||||||
db.session.delete(content)
|
db.session.delete(content)
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
@@ -505,6 +525,16 @@ def delete_single_leftover(content_id):
|
|||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
|
|
||||||
|
# Delete edited media archive folder if it exists
|
||||||
|
import shutil
|
||||||
|
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
|
||||||
|
if os.path.exists(edited_media_dir):
|
||||||
|
shutil.rmtree(edited_media_dir)
|
||||||
|
|
||||||
|
# Delete associated player edit records first
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
PlayerEdit.query.filter_by(content_id=content.id).delete()
|
||||||
|
|
||||||
# Delete database record
|
# Delete database record
|
||||||
db.session.delete(content)
|
db.session.delete(content)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ def health_check():
|
|||||||
|
|
||||||
|
|
||||||
@api_bp.route('/auth/player', methods=['POST'])
|
@api_bp.route('/auth/player', methods=['POST'])
|
||||||
@rate_limit(max_requests=10, window=60)
|
@rate_limit(max_requests=120, window=60)
|
||||||
def authenticate_player():
|
def authenticate_player():
|
||||||
"""Authenticate a player and return auth code and configuration.
|
"""Authenticate a player and return auth code and configuration.
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ def authenticate_player():
|
|||||||
|
|
||||||
|
|
||||||
@api_bp.route('/auth/verify', methods=['POST'])
|
@api_bp.route('/auth/verify', methods=['POST'])
|
||||||
@rate_limit(max_requests=30, window=60)
|
@rate_limit(max_requests=300, window=60)
|
||||||
def verify_auth_code():
|
def verify_auth_code():
|
||||||
"""Verify an auth code and return player information.
|
"""Verify an auth code and return player information.
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ def get_playlist_by_quickconnect():
|
|||||||
|
|
||||||
|
|
||||||
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
|
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
|
||||||
@rate_limit(max_requests=30, window=60)
|
@rate_limit(max_requests=300, window=60)
|
||||||
@verify_player_auth
|
@verify_player_auth
|
||||||
def get_player_playlist(player_id: int):
|
def get_player_playlist(player_id: int):
|
||||||
"""Get playlist for a specific player.
|
"""Get playlist for a specific player.
|
||||||
@@ -401,14 +401,15 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
|
|||||||
'duration': content._playlist_duration or content.duration or 10,
|
'duration': content._playlist_duration or content.duration or 10,
|
||||||
'position': content._playlist_position or idx,
|
'position': content._playlist_position or idx,
|
||||||
'url': content_url, # Full URL for downloads
|
'url': content_url, # Full URL for downloads
|
||||||
'description': content.description
|
'description': content.description,
|
||||||
|
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
|
||||||
})
|
})
|
||||||
|
|
||||||
return playlist_data
|
return playlist_data
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/player-feedback', methods=['POST'])
|
@api_bp.route('/player-feedback', methods=['POST'])
|
||||||
@rate_limit(max_requests=100, window=60)
|
@rate_limit(max_requests=600, window=60)
|
||||||
def receive_player_feedback():
|
def receive_player_feedback():
|
||||||
"""Receive feedback/status updates from players (Kivy player compatible).
|
"""Receive feedback/status updates from players (Kivy player compatible).
|
||||||
|
|
||||||
@@ -427,15 +428,13 @@ def receive_player_feedback():
|
|||||||
data = request.json
|
data = request.json
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
|
log_action('warning', 'Player feedback received with no data')
|
||||||
return jsonify({'error': 'No data provided'}), 400
|
return jsonify({'error': 'No data provided'}), 400
|
||||||
|
|
||||||
player_name = data.get('player_name')
|
player_name = data.get('player_name')
|
||||||
hostname = data.get('hostname') # Also accept hostname
|
hostname = data.get('hostname') # Also accept hostname
|
||||||
quickconnect_code = data.get('quickconnect_code')
|
quickconnect_code = data.get('quickconnect_code')
|
||||||
|
|
||||||
if (not player_name and not hostname) or not quickconnect_code:
|
|
||||||
return jsonify({'error': 'player_name/hostname and quickconnect_code required'}), 400
|
|
||||||
|
|
||||||
# Find player by hostname first (more reliable), then by name
|
# Find player by hostname first (more reliable), then by name
|
||||||
player = None
|
player = None
|
||||||
if hostname:
|
if hostname:
|
||||||
@@ -443,12 +442,35 @@ def receive_player_feedback():
|
|||||||
if not player and player_name:
|
if not player and player_name:
|
||||||
player = Player.query.filter_by(name=player_name).first()
|
player = Player.query.filter_by(name=player_name).first()
|
||||||
|
|
||||||
|
# If player not found and no credentials provided, try to infer from IP and recent auth
|
||||||
|
if not player and (not quickconnect_code or (not player_name and not hostname)):
|
||||||
|
# Try to find player by recent authentication from same IP
|
||||||
|
client_ip = request.remote_addr
|
||||||
|
# Look for players with matching IP in recent activity (last 5 minutes)
|
||||||
|
recent_time = datetime.utcnow() - timedelta(minutes=5)
|
||||||
|
possible_player = Player.query.filter(
|
||||||
|
Player.last_seen >= recent_time
|
||||||
|
).order_by(Player.last_seen.desc()).first()
|
||||||
|
|
||||||
|
if possible_player:
|
||||||
|
player = possible_player
|
||||||
|
log_action('info', f'Inferred player feedback from {player.name} ({player.hostname}) based on recent activity')
|
||||||
|
|
||||||
|
# Still require quickconnect validation if provided
|
||||||
|
if not player:
|
||||||
|
if not player_name and not hostname:
|
||||||
|
log_action('warning', f'Player feedback missing required fields. Data: {data}')
|
||||||
|
return jsonify({'error': 'player_name/hostname and quickconnect_code required'}), 400
|
||||||
|
else:
|
||||||
|
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
|
||||||
|
return jsonify({'error': 'Player not found'}), 404
|
||||||
|
|
||||||
if not player:
|
if not player:
|
||||||
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
|
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
|
||||||
return jsonify({'error': 'Player not found'}), 404
|
return jsonify({'error': 'Player not found'}), 404
|
||||||
|
|
||||||
# Validate quickconnect code (using bcrypt verification)
|
# Validate quickconnect code if provided (using bcrypt verification)
|
||||||
if not player.check_quickconnect_code(quickconnect_code):
|
if quickconnect_code and not player.check_quickconnect_code(quickconnect_code):
|
||||||
log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})')
|
log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})')
|
||||||
return jsonify({'error': 'Invalid quickconnect code'}), 403
|
return jsonify({'error': 'Invalid quickconnect code'}), 403
|
||||||
|
|
||||||
@@ -678,6 +700,143 @@ def get_logs():
|
|||||||
return jsonify({'error': 'Internal server error'}), 500
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/player-edit-media', methods=['POST'])
|
||||||
|
@rate_limit(max_requests=60, window=60)
|
||||||
|
@verify_player_auth
|
||||||
|
def receive_edited_media():
|
||||||
|
"""Receive edited media from player.
|
||||||
|
|
||||||
|
Expected multipart/form-data:
|
||||||
|
- image_file: The edited image file
|
||||||
|
- metadata: JSON string with metadata
|
||||||
|
|
||||||
|
Metadata JSON structure:
|
||||||
|
{
|
||||||
|
"time_of_modification": "ISO timestamp",
|
||||||
|
"original_name": "original_file.jpg",
|
||||||
|
"new_name": "original_file_v1.jpg",
|
||||||
|
"version": 1,
|
||||||
|
"user": "player_user"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
|
||||||
|
# Find the original content by filename
|
||||||
|
original_name = metadata['original_name']
|
||||||
|
content = Content.query.filter_by(filename=original_name).first()
|
||||||
|
|
||||||
|
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/<content_id>/
|
||||||
|
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']
|
||||||
|
edited_file_path = os.path.join(edited_media_dir, new_filename)
|
||||||
|
file.save(edited_file_path)
|
||||||
|
|
||||||
|
# Save metadata JSON file
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Copy the versioned image to the main uploads folder
|
||||||
|
import shutil
|
||||||
|
versioned_upload_path = os.path.join(base_upload_dir, new_filename)
|
||||||
|
shutil.copy2(edited_file_path, versioned_upload_path)
|
||||||
|
|
||||||
|
# Update the content record to reference the new versioned filename
|
||||||
|
old_filename = content.filename
|
||||||
|
content.filename = new_filename
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
edit_record = PlayerEdit(
|
||||||
|
player_id=player.id,
|
||||||
|
content_id=content.id,
|
||||||
|
original_name=original_name,
|
||||||
|
new_name=new_filename,
|
||||||
|
version=version,
|
||||||
|
user=metadata.get('user'),
|
||||||
|
time_of_modification=time_of_mod,
|
||||||
|
metadata_path=metadata_path,
|
||||||
|
edited_file_path=edited_file_path
|
||||||
|
)
|
||||||
|
db.session.add(edit_record)
|
||||||
|
|
||||||
|
# Update playlist version to force player refresh
|
||||||
|
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',
|
||||||
|
'edit_id': edit_record.id,
|
||||||
|
'version': version,
|
||||||
|
'old_filename': old_filename,
|
||||||
|
'new_filename': new_filename,
|
||||||
|
'new_playlist_version': playlist.version if player.playlist_id and playlist else None
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log_action('error', f'Error receiving edited media: {str(e)}')
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
@api_bp.errorhandler(404)
|
@api_bp.errorhandler(404)
|
||||||
def api_not_found(error):
|
def api_not_found(error):
|
||||||
"""Handle 404 errors in API."""
|
"""Handle 404 errors in API."""
|
||||||
|
|||||||
@@ -39,8 +39,14 @@ def content_list():
|
|||||||
@login_required
|
@login_required
|
||||||
def media_library():
|
def media_library():
|
||||||
"""View all media files in the library."""
|
"""View all media files in the library."""
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
|
||||||
media_files = Content.query.order_by(Content.uploaded_at.desc()).all()
|
media_files = Content.query.order_by(Content.uploaded_at.desc()).all()
|
||||||
|
|
||||||
|
# Add edit count to each media item
|
||||||
|
for media in media_files:
|
||||||
|
media.edit_count = PlayerEdit.query.filter_by(content_id=media.id).count()
|
||||||
|
|
||||||
# Group by content type
|
# Group by content type
|
||||||
images = [m for m in media_files if m.content_type == 'image']
|
images = [m for m in media_files if m.content_type == 'image']
|
||||||
videos = [m for m in media_files if m.content_type == 'video']
|
videos = [m for m in media_files if m.content_type == 'video']
|
||||||
@@ -74,6 +80,21 @@ def delete_media(media_id: int):
|
|||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
log_action('info', f'Deleted physical file: {filename}')
|
log_action('info', f'Deleted physical file: {filename}')
|
||||||
|
|
||||||
|
# Delete edited media archive folder if it exists
|
||||||
|
import shutil
|
||||||
|
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(media.id))
|
||||||
|
if os.path.exists(edited_media_dir):
|
||||||
|
shutil.rmtree(edited_media_dir)
|
||||||
|
log_action('info', f'Deleted edited media archive for content ID {media.id}')
|
||||||
|
|
||||||
|
# Delete associated player edit records first
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
edit_records = PlayerEdit.query.filter_by(content_id=media.id).all()
|
||||||
|
if edit_records:
|
||||||
|
for edit in edit_records:
|
||||||
|
db.session.delete(edit)
|
||||||
|
log_action('info', f'Deleted {len(edit_records)} edit record(s) for content: {filename}')
|
||||||
|
|
||||||
# Remove from all playlists (this will cascade properly)
|
# Remove from all playlists (this will cascade properly)
|
||||||
db.session.delete(media)
|
db.session.delete(media)
|
||||||
|
|
||||||
@@ -396,6 +417,47 @@ def update_playlist_content_muted(playlist_id: int, content_id: int):
|
|||||||
return jsonify({'success': False, 'message': str(e)}), 500
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@content_bp.route('/playlist/<int:playlist_id>/update-edit-enabled/<int:content_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def update_playlist_content_edit_enabled(playlist_id: int, content_id: int):
|
||||||
|
"""Update content edit_on_player_enabled setting in playlist."""
|
||||||
|
playlist = Playlist.query.get_or_404(playlist_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
edit_enabled = request.form.get('edit_enabled', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
from app.models.playlist import playlist_content
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
# Update edit_on_player_enabled in association table
|
||||||
|
stmt = update(playlist_content).where(
|
||||||
|
(playlist_content.c.playlist_id == playlist_id) &
|
||||||
|
(playlist_content.c.content_id == content_id)
|
||||||
|
).values(edit_on_player_enabled=edit_enabled)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
# Increment playlist version
|
||||||
|
playlist.increment_version()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
log_action('info', f'Updated edit_on_player_enabled={edit_enabled} for "{content.filename}" in playlist "{playlist.name}"')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Edit setting updated',
|
||||||
|
'edit_enabled': edit_enabled,
|
||||||
|
'version': playlist.version
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log_action('error', f'Error updating edit setting: {str(e)}')
|
||||||
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/upload-media-page')
|
@content_bp.route('/upload-media-page')
|
||||||
@login_required
|
@login_required
|
||||||
def upload_media_page():
|
def upload_media_page():
|
||||||
@@ -527,7 +589,7 @@ def process_pdf_file(filepath: str, filename: str) -> tuple[bool, str]:
|
|||||||
|
|
||||||
|
|
||||||
def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
|
def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
|
||||||
duration: int, playlist_id: Optional[int], task_id: str):
|
duration: int, playlist_id: Optional[int], task_id: str, edit_on_player_enabled: bool = False):
|
||||||
"""Process large files (PDF, PPTX, Video) in background thread."""
|
"""Process large files (PDF, PPTX, Video) in background thread."""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
try:
|
try:
|
||||||
@@ -573,7 +635,8 @@ def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
|
|||||||
playlist_id=playlist_id,
|
playlist_id=playlist_id,
|
||||||
content_id=page_content.id,
|
content_id=page_content.id,
|
||||||
position=max_position,
|
position=max_position,
|
||||||
duration=duration
|
duration=duration,
|
||||||
|
edit_on_player_enabled=edit_on_player_enabled
|
||||||
)
|
)
|
||||||
db.session.execute(stmt)
|
db.session.execute(stmt)
|
||||||
|
|
||||||
@@ -629,7 +692,8 @@ def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
|
|||||||
playlist_id=playlist_id,
|
playlist_id=playlist_id,
|
||||||
content_id=slide_content.id,
|
content_id=slide_content.id,
|
||||||
position=max_position,
|
position=max_position,
|
||||||
duration=duration
|
duration=duration,
|
||||||
|
edit_on_player_enabled=edit_on_player_enabled
|
||||||
)
|
)
|
||||||
db.session.execute(stmt)
|
db.session.execute(stmt)
|
||||||
|
|
||||||
@@ -677,7 +741,8 @@ def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
|
|||||||
playlist_id=playlist_id,
|
playlist_id=playlist_id,
|
||||||
content_id=content.id,
|
content_id=content.id,
|
||||||
position=max_position + 1,
|
position=max_position + 1,
|
||||||
duration=duration
|
duration=duration,
|
||||||
|
edit_on_player_enabled=edit_on_player_enabled
|
||||||
)
|
)
|
||||||
db.session.execute(stmt)
|
db.session.execute(stmt)
|
||||||
playlist.version += 1
|
playlist.version += 1
|
||||||
@@ -949,6 +1014,7 @@ def upload_media():
|
|||||||
content_type = request.form.get('content_type', 'image')
|
content_type = request.form.get('content_type', 'image')
|
||||||
duration = request.form.get('duration', type=int, default=10)
|
duration = request.form.get('duration', type=int, default=10)
|
||||||
playlist_id = request.form.get('playlist_id', type=int)
|
playlist_id = request.form.get('playlist_id', type=int)
|
||||||
|
edit_on_player_enabled = request.form.get('edit_on_player_enabled', '0') == '1'
|
||||||
|
|
||||||
if not files or files[0].filename == '':
|
if not files or files[0].filename == '':
|
||||||
flash('No files provided.', 'warning')
|
flash('No files provided.', 'warning')
|
||||||
@@ -991,7 +1057,7 @@ def upload_media():
|
|||||||
|
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=process_file_in_background,
|
target=process_file_in_background,
|
||||||
args=(current_app._get_current_object(), filepath, filename, file_ext, duration, playlist_id, task_id)
|
args=(current_app._get_current_object(), filepath, filename, file_ext, duration, playlist_id, task_id, edit_on_player_enabled)
|
||||||
)
|
)
|
||||||
thread.daemon = True
|
thread.daemon = True
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -1054,7 +1120,8 @@ def upload_media():
|
|||||||
playlist_id=playlist_id,
|
playlist_id=playlist_id,
|
||||||
content_id=page_content.id,
|
content_id=page_content.id,
|
||||||
position=max_position,
|
position=max_position,
|
||||||
duration=duration
|
duration=duration,
|
||||||
|
edit_on_player_enabled=edit_on_player_enabled
|
||||||
)
|
)
|
||||||
db.session.execute(stmt)
|
db.session.execute(stmt)
|
||||||
|
|
||||||
@@ -1113,7 +1180,8 @@ def upload_media():
|
|||||||
playlist_id=playlist_id,
|
playlist_id=playlist_id,
|
||||||
content_id=slide_content.id,
|
content_id=slide_content.id,
|
||||||
position=max_position,
|
position=max_position,
|
||||||
duration=duration
|
duration=duration,
|
||||||
|
edit_on_player_enabled=edit_on_player_enabled
|
||||||
)
|
)
|
||||||
db.session.execute(stmt)
|
db.session.execute(stmt)
|
||||||
|
|
||||||
@@ -1165,7 +1233,8 @@ def upload_media():
|
|||||||
playlist_id=playlist_id,
|
playlist_id=playlist_id,
|
||||||
content_id=content.id,
|
content_id=content.id,
|
||||||
position=max_position + 1,
|
position=max_position + 1,
|
||||||
duration=duration
|
duration=duration,
|
||||||
|
edit_on_player_enabled=edit_on_player_enabled
|
||||||
)
|
)
|
||||||
db.session.execute(stmt)
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
|||||||
@@ -315,6 +315,13 @@ def manage_player(player_id: int):
|
|||||||
.limit(20)\
|
.limit(20)\
|
||||||
.all()
|
.all()
|
||||||
|
|
||||||
|
# Get edited media history from player
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
|
||||||
|
.order_by(PlayerEdit.created_at.desc())\
|
||||||
|
.limit(20)\
|
||||||
|
.all()
|
||||||
|
|
||||||
# Get player status
|
# Get player status
|
||||||
status_info = get_player_status_info(player_id)
|
status_info = get_player_status_info(player_id)
|
||||||
|
|
||||||
@@ -323,9 +330,41 @@ def manage_player(player_id: int):
|
|||||||
playlists=playlists,
|
playlists=playlists,
|
||||||
current_playlist=current_playlist,
|
current_playlist=current_playlist,
|
||||||
recent_logs=recent_logs,
|
recent_logs=recent_logs,
|
||||||
|
edited_media=edited_media,
|
||||||
status_info=status_info)
|
status_info=status_info)
|
||||||
|
|
||||||
|
|
||||||
|
@players_bp.route('/<int:player_id>/edited-media')
|
||||||
|
@login_required
|
||||||
|
def edited_media(player_id: int):
|
||||||
|
"""Display all edited media files from this player."""
|
||||||
|
try:
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
|
# Get all edited media history from player
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
|
||||||
|
.order_by(PlayerEdit.created_at.desc())\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
# Get original content files for each edited media
|
||||||
|
content_files = {}
|
||||||
|
for edit in edited_media:
|
||||||
|
if edit.content_id not in content_files:
|
||||||
|
content = Content.query.get(edit.content_id)
|
||||||
|
if content:
|
||||||
|
content_files[edit.content_id] = content
|
||||||
|
|
||||||
|
return render_template('players/edited_media.html',
|
||||||
|
player=player,
|
||||||
|
edited_media=edited_media,
|
||||||
|
content_files=content_files)
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Error loading edited media for player {player_id}: {str(e)}')
|
||||||
|
flash('Error loading edited media.', 'danger')
|
||||||
|
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||||
|
|
||||||
|
|
||||||
@players_bp.route('/<int:player_id>/fullscreen')
|
@players_bp.route('/<int:player_id>/fullscreen')
|
||||||
def player_fullscreen(player_id: int):
|
def player_fullscreen(player_id: int):
|
||||||
"""Display player fullscreen view (no authentication required for players)."""
|
"""Display player fullscreen view (no authentication required for players)."""
|
||||||
|
|||||||
@@ -80,11 +80,8 @@ class ProductionConfig(Config):
|
|||||||
f'sqlite:///{os.path.join(_basedir, "instance", "dashboard.db")}'
|
f'sqlite:///{os.path.join(_basedir, "instance", "dashboard.db")}'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Redis Cache
|
# Cache - use simple cache instead of Redis
|
||||||
CACHE_TYPE = 'redis'
|
CACHE_TYPE = 'simple'
|
||||||
CACHE_REDIS_HOST = os.getenv('REDIS_HOST', 'redis')
|
|
||||||
CACHE_REDIS_PORT = int(os.getenv('REDIS_PORT', 6379))
|
|
||||||
CACHE_REDIS_DB = 0
|
|
||||||
CACHE_DEFAULT_TIMEOUT = 300
|
CACHE_DEFAULT_TIMEOUT = 300
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from app.models.playlist import Playlist, playlist_content
|
|||||||
from app.models.content import Content
|
from app.models.content import Content
|
||||||
from app.models.server_log import ServerLog
|
from app.models.server_log import ServerLog
|
||||||
from app.models.player_feedback import PlayerFeedback
|
from app.models.player_feedback import PlayerFeedback
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'User',
|
'User',
|
||||||
@@ -15,6 +16,7 @@ __all__ = [
|
|||||||
'Content',
|
'Content',
|
||||||
'ServerLog',
|
'ServerLog',
|
||||||
'PlayerFeedback',
|
'PlayerFeedback',
|
||||||
|
'PlayerEdit',
|
||||||
'group_content',
|
'group_content',
|
||||||
'playlist_content',
|
'playlist_content',
|
||||||
]
|
]
|
||||||
|
|||||||
60
app/models/player_edit.py
Normal file
60
app/models/player_edit.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Player edit model for tracking media edited on players."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerEdit(db.Model):
|
||||||
|
"""Player edit model for tracking media files edited on player devices.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
player_id: Foreign key to player
|
||||||
|
content_id: Foreign key to content that was edited
|
||||||
|
original_name: Original filename
|
||||||
|
new_name: New filename after editing
|
||||||
|
version: Edit version number (v1, v2, etc.)
|
||||||
|
user: User who made the edit (from player)
|
||||||
|
time_of_modification: When the edit was made
|
||||||
|
metadata_path: Path to the metadata JSON file
|
||||||
|
edited_file_path: Path to the edited file
|
||||||
|
created_at: Record creation timestamp
|
||||||
|
"""
|
||||||
|
__tablename__ = 'player_edit'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
player_id = db.Column(db.Integer, db.ForeignKey('player.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||||
|
content_id = db.Column(db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||||
|
original_name = db.Column(db.String(255), nullable=False)
|
||||||
|
new_name = db.Column(db.String(255), nullable=False)
|
||||||
|
version = db.Column(db.Integer, default=1, nullable=False)
|
||||||
|
user = db.Column(db.String(255), nullable=True)
|
||||||
|
time_of_modification = db.Column(db.DateTime, nullable=True)
|
||||||
|
metadata_path = db.Column(db.String(512), nullable=True)
|
||||||
|
edited_file_path = db.Column(db.String(512), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
player = db.relationship('Player', backref=db.backref('edits', lazy='dynamic', cascade='all, delete-orphan'))
|
||||||
|
content = db.relationship('Content', backref=db.backref('edits', lazy='dynamic', cascade='all, delete-orphan'))
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of PlayerEdit."""
|
||||||
|
return f'<PlayerEdit {self.original_name} v{self.version} by {self.user or "unknown"}>'
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for API responses."""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'player_id': self.player_id,
|
||||||
|
'player_name': self.player.name if self.player else None,
|
||||||
|
'content_id': self.content_id,
|
||||||
|
'original_name': self.original_name,
|
||||||
|
'new_name': self.new_name,
|
||||||
|
'version': self.version,
|
||||||
|
'user': self.user,
|
||||||
|
'time_of_modification': self.time_of_modification.isoformat() if self.time_of_modification else None,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'edited_file_path': self.edited_file_path
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ playlist_content = db.Table('playlist_content',
|
|||||||
db.Column('content_id', db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), primary_key=True),
|
db.Column('content_id', db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), primary_key=True),
|
||||||
db.Column('position', db.Integer, default=0),
|
db.Column('position', db.Integer, default=0),
|
||||||
db.Column('duration', db.Integer, default=10),
|
db.Column('duration', db.Integer, default=10),
|
||||||
db.Column('muted', db.Boolean, default=True)
|
db.Column('muted', db.Boolean, default=True),
|
||||||
|
db.Column('edit_on_player_enabled', db.Boolean, default=False)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -78,7 +79,8 @@ class Playlist(db.Model):
|
|||||||
stmt = select(playlist_content.c.content_id,
|
stmt = select(playlist_content.c.content_id,
|
||||||
playlist_content.c.position,
|
playlist_content.c.position,
|
||||||
playlist_content.c.duration,
|
playlist_content.c.duration,
|
||||||
playlist_content.c.muted).where(
|
playlist_content.c.muted,
|
||||||
|
playlist_content.c.edit_on_player_enabled).where(
|
||||||
playlist_content.c.playlist_id == self.id
|
playlist_content.c.playlist_id == self.id
|
||||||
).order_by(playlist_content.c.position)
|
).order_by(playlist_content.c.position)
|
||||||
|
|
||||||
@@ -91,6 +93,7 @@ class Playlist(db.Model):
|
|||||||
content._playlist_position = row.position
|
content._playlist_position = row.position
|
||||||
content._playlist_duration = row.duration
|
content._playlist_duration = row.duration
|
||||||
content._playlist_muted = row.muted if len(row) > 3 else True
|
content._playlist_muted = row.muted if len(row) > 3 else True
|
||||||
|
content._playlist_edit_on_player_enabled = row.edit_on_player_enabled if len(row) > 4 else False
|
||||||
ordered_content.append(content)
|
ordered_content.append(content)
|
||||||
|
|
||||||
return ordered_content
|
return ordered_content
|
||||||
|
|||||||
@@ -211,6 +211,7 @@
|
|||||||
<th style="width: 100px;">Type</th>
|
<th style="width: 100px;">Type</th>
|
||||||
<th style="width: 100px;">Duration</th>
|
<th style="width: 100px;">Duration</th>
|
||||||
<th style="width: 80px;">Audio</th>
|
<th style="width: 80px;">Audio</th>
|
||||||
|
<th style="width: 80px;">Edit</th>
|
||||||
<th style="width: 100px;">Actions</th>
|
<th style="width: 100px;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -247,6 +248,23 @@
|
|||||||
<span style="color: #999;">—</span>
|
<span style="color: #999;">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if content.content_type in ['image', 'pdf'] %}
|
||||||
|
<label class="audio-toggle">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="edit-checkbox"
|
||||||
|
data-content-id="{{ content.id }}"
|
||||||
|
{{ 'checked' if content._playlist_edit_on_player_enabled else '' }}
|
||||||
|
onchange="toggleEdit({{ content.id }}, this.checked)">
|
||||||
|
<span class="audio-label">
|
||||||
|
<span class="audio-on">✏️</span>
|
||||||
|
<span class="audio-off">🔒</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #999;">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST"
|
<form method="POST"
|
||||||
action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}"
|
action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}"
|
||||||
@@ -427,6 +445,37 @@ function toggleAudio(contentId, enabled) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleEdit(contentId, enabled) {
|
||||||
|
const playlistId = {{ playlist.id }};
|
||||||
|
const url = `/content/playlist/${playlistId}/update-edit-enabled/${contentId}`;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('edit_enabled', enabled ? 'true' : 'false');
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
console.log('Edit setting updated:', enabled ? 'Enabled' : 'Disabled');
|
||||||
|
} else {
|
||||||
|
alert('Error updating edit setting: ' + data.message);
|
||||||
|
// Revert checkbox on error
|
||||||
|
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
|
||||||
|
if (checkbox) checkbox.checked = !enabled;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Error updating edit setting');
|
||||||
|
// Revert checkbox on error
|
||||||
|
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
|
||||||
|
if (checkbox) checkbox.checked = !enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSelectAll(checkbox) {
|
function toggleSelectAll(checkbox) {
|
||||||
const checkboxes = document.querySelectorAll('.content-checkbox');
|
const checkboxes = document.querySelectorAll('.content-checkbox');
|
||||||
checkboxes.forEach(cb => {
|
checkboxes.forEach(cb => {
|
||||||
|
|||||||
@@ -273,7 +273,7 @@
|
|||||||
<div class="media-grid">
|
<div class="media-grid">
|
||||||
{% for media in images %}
|
{% for media in images %}
|
||||||
<div class="media-card">
|
<div class="media-card">
|
||||||
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }})" title="Delete">🗑️</button>
|
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||||
<div class="media-thumbnail">
|
<div class="media-thumbnail">
|
||||||
<img src="{{ url_for('static', filename='uploads/' + media.filename) }}"
|
<img src="{{ url_for('static', filename='uploads/' + media.filename) }}"
|
||||||
alt="{{ media.filename }}"
|
alt="{{ media.filename }}"
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
<div class="media-grid">
|
<div class="media-grid">
|
||||||
{% for media in videos %}
|
{% for media in videos %}
|
||||||
<div class="media-card">
|
<div class="media-card">
|
||||||
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }})" title="Delete">🗑️</button>
|
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||||
<div class="media-thumbnail">
|
<div class="media-thumbnail">
|
||||||
<span class="media-icon">🎥</span>
|
<span class="media-icon">🎥</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,7 +333,7 @@
|
|||||||
<div class="media-grid">
|
<div class="media-grid">
|
||||||
{% for media in pdfs %}
|
{% for media in pdfs %}
|
||||||
<div class="media-card">
|
<div class="media-card">
|
||||||
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }})" title="Delete">🗑️</button>
|
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||||
<div class="media-thumbnail">
|
<div class="media-thumbnail">
|
||||||
<span class="media-icon">📄</span>
|
<span class="media-icon">📄</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,7 +362,7 @@
|
|||||||
<div class="media-grid">
|
<div class="media-grid">
|
||||||
{% for media in presentations %}
|
{% for media in presentations %}
|
||||||
<div class="media-card">
|
<div class="media-card">
|
||||||
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }})" title="Delete">🗑️</button>
|
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||||
<div class="media-thumbnail">
|
<div class="media-thumbnail">
|
||||||
<span class="media-icon">📊</span>
|
<span class="media-icon">📊</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,7 +391,7 @@
|
|||||||
<div class="media-grid">
|
<div class="media-grid">
|
||||||
{% for media in others %}
|
{% for media in others %}
|
||||||
<div class="media-card">
|
<div class="media-card">
|
||||||
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }})" title="Delete">🗑️</button>
|
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||||
<div class="media-thumbnail">
|
<div class="media-thumbnail">
|
||||||
<span class="media-icon">📁</span>
|
<span class="media-icon">📁</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -424,28 +424,45 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
<!-- Delete Confirmation Modal -->
|
||||||
<div id="deleteModal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.5);">
|
<div id="deleteModal" class="delete-modal">
|
||||||
<div style="background-color: #fefefe; margin: 10% auto; padding: 30px; border-radius: 12px; width: 90%; max-width: 500px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
|
<div class="delete-modal-content">
|
||||||
<h2 style="margin-top: 0; color: #dc3545; display: flex; align-items: center; gap: 0.5rem;">
|
<div class="delete-modal-header">
|
||||||
<span style="font-size: 2rem;">⚠️</span>
|
<span class="delete-icon">⚠️</span>
|
||||||
Confirm Delete
|
<h2>Confirm Delete</h2>
|
||||||
</h2>
|
|
||||||
<p style="font-size: 1.1rem; margin: 1.5rem 0;">
|
|
||||||
Are you sure you want to delete <strong id="deleteFilename"></strong>?
|
|
||||||
</p>
|
|
||||||
<div id="playlistWarning" style="display: none; background: #fff3cd; border-left: 4px solid #ffc107; padding: 12px; margin: 1rem 0; border-radius: 4px;">
|
|
||||||
<strong style="color: #856404;">⚠️ Warning:</strong>
|
|
||||||
<p style="margin: 0.5rem 0 0 0; color: #856404;">This file is used in <strong id="playlistCount"></strong> playlist(s). Deleting it will remove it from all playlists and increment their version numbers.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p style="color: #dc3545; margin: 1rem 0;">
|
|
||||||
<strong>This action cannot be undone!</strong>
|
<div class="delete-modal-body">
|
||||||
</p>
|
<p class="delete-question">
|
||||||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 2rem;">
|
Are you sure you want to delete <strong id="deleteFilename" class="delete-filename"></strong>?
|
||||||
<button onclick="closeDeleteModal()" class="btn" style="background: #6c757d;">
|
</p>
|
||||||
|
|
||||||
|
<div id="playlistWarning" class="warning-box warning-playlist">
|
||||||
|
<div class="warning-header">
|
||||||
|
<span>⚠️</span>
|
||||||
|
<strong>Playlist Warning</strong>
|
||||||
|
</div>
|
||||||
|
<p>This file is used in <strong id="playlistCount"></strong> playlist(s). Deleting it will remove it from all playlists and increment their version numbers.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="editWarning" class="warning-box warning-edit">
|
||||||
|
<div class="warning-header">
|
||||||
|
<span>✏️</span>
|
||||||
|
<strong>Edited Versions</strong>
|
||||||
|
</div>
|
||||||
|
<p>This file has <strong id="editCount"></strong> edited version(s) from player devices. All edited versions and their metadata will also be permanently deleted.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delete-final-warning">
|
||||||
|
<strong>⚠️ This action cannot be undone!</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delete-modal-footer">
|
||||||
|
<button onclick="closeDeleteModal()" class="btn btn-cancel">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<form id="deleteForm" method="POST" style="margin: 0;">
|
<form id="deleteForm" method="POST">
|
||||||
<button type="submit" class="btn" style="background: #dc3545;">
|
<button type="submit" class="btn btn-delete">
|
||||||
Yes, Delete File
|
Yes, Delete File
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -454,52 +471,294 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
body.dark-mode #deleteModal {
|
/* Delete Modal - Light Mode Styles */
|
||||||
|
.delete-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-content {
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||||
|
margin: 8% auto;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 550px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: slideDown 0.3s ease-out;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
transform: translateY(-50px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-header {
|
||||||
|
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 24px 30px;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-body {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-question {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #2d3748;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-filename {
|
||||||
|
color: #dc3545;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-box {
|
||||||
|
display: none;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 4px solid;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-box:hover {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-header span {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-header strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-box p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-playlist {
|
||||||
|
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
|
||||||
|
border-color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-playlist .warning-header,
|
||||||
|
.warning-playlist p {
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-edit {
|
||||||
|
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
|
||||||
|
border-color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-edit .warning-header,
|
||||||
|
.warning-edit p {
|
||||||
|
color: #5b21b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-final-warning {
|
||||||
|
background: linear-gradient(135deg, #fee 0%, #fdd 100%);
|
||||||
|
border: 2px solid #dc3545;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 20px 0 0 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-final-warning strong {
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-footer {
|
||||||
|
padding: 20px 30px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 0 0 16px 16px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-footer form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: linear-gradient(135deg, #5a6268 0%, #4e555b 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 6px rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: linear-gradient(135deg, #c82333 0%, #bd2130 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.5);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete Modal - Dark Mode Styles */
|
||||||
|
body.dark-mode .delete-modal {
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode #deleteModal > div {
|
body.dark-mode .delete-modal-content {
|
||||||
background-color: #1a202c;
|
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
|
||||||
border: 1px solid #4a5568;
|
border: 1px solid #4a5568;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .delete-modal-header {
|
||||||
|
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .delete-question {
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode #deleteModal h2 {
|
body.dark-mode .delete-filename {
|
||||||
color: #f87171;
|
color: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode #deleteModal p {
|
body.dark-mode .warning-playlist {
|
||||||
color: #e2e8f0;
|
background: linear-gradient(135deg, #422006 0%, #713f12 100%);
|
||||||
|
border-color: #fbbf24;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode #deleteModal strong {
|
body.dark-mode .warning-playlist .warning-header,
|
||||||
color: #fbbf24;
|
body.dark-mode .warning-playlist p {
|
||||||
|
color: #fde68a;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode #playlistWarning {
|
body.dark-mode .warning-edit {
|
||||||
background: #4a3800;
|
background: linear-gradient(135deg, #3b0764 0%, #581c87 100%);
|
||||||
border-left-color: #ffc107;
|
border-color: #a78bfa;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode #playlistWarning strong,
|
body.dark-mode .warning-edit .warning-header,
|
||||||
body.dark-mode #playlistWarning p {
|
body.dark-mode .warning-edit p {
|
||||||
color: #fbbf24;
|
color: #ddd6fe;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode #deleteForm button {
|
body.dark-mode .delete-final-warning {
|
||||||
background: #dc3545 !important;
|
background: linear-gradient(135deg, #450a0a 0%, #7f1d1d 100%);
|
||||||
color: white !important;
|
border-color: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode #deleteForm button:hover {
|
body.dark-mode .delete-final-warning strong {
|
||||||
background: #a02834 !important;
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .delete-modal-footer {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-top: 1px solid #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .btn-cancel {
|
||||||
|
background: linear-gradient(135deg, #374151 0%, #1f2937 100%);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .btn-cancel:hover {
|
||||||
|
background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .btn-delete {
|
||||||
|
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
|
||||||
|
box-shadow: 0 2px 6px rgba(185, 28, 28, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .btn-delete:hover {
|
||||||
|
background: linear-gradient(135deg, #991b1b 0%, #7f1d1d 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.6);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let deleteMediaId = null;
|
let deleteMediaId = null;
|
||||||
|
|
||||||
function confirmDelete(mediaId, filename, playlistCount) {
|
function confirmDelete(mediaId, filename, playlistCount, editCount) {
|
||||||
deleteMediaId = mediaId;
|
deleteMediaId = mediaId;
|
||||||
document.getElementById('deleteFilename').textContent = filename;
|
document.getElementById('deleteFilename').textContent = filename;
|
||||||
document.getElementById('deleteForm').action = "{{ url_for('content.delete_media', media_id=0) }}".replace('/0', '/' + mediaId);
|
document.getElementById('deleteForm').action = "{{ url_for('content.delete_media', media_id=0) }}".replace('/0', '/' + mediaId);
|
||||||
@@ -512,6 +771,14 @@ function confirmDelete(mediaId, filename, playlistCount) {
|
|||||||
document.getElementById('playlistWarning').style.display = 'none';
|
document.getElementById('playlistWarning').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show edit versions warning if file has been edited
|
||||||
|
if (editCount > 0) {
|
||||||
|
document.getElementById('editWarning').style.display = 'block';
|
||||||
|
document.getElementById('editCount').textContent = editCount;
|
||||||
|
} else {
|
||||||
|
document.getElementById('editWarning').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('deleteModal').style.display = 'block';
|
document.getElementById('deleteModal').style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -296,6 +296,17 @@
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; cursor: pointer; user-select: none;">
|
||||||
|
<input type="checkbox" name="edit_on_player_enabled" id="edit_on_player_enabled"
|
||||||
|
value="1" style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
|
||||||
|
<span>Allow editing on player (PDF, Images, PPTX)</span>
|
||||||
|
</label>
|
||||||
|
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
|
||||||
|
✏️ Enable local editing of this media on the player device
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Upload Button -->
|
<!-- Upload Button -->
|
||||||
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;">
|
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;">
|
||||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||||
|
|||||||
525
app/templates/players/edited_media.html
Normal file
525
app/templates/players/edited_media.html
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Edited Media - {{ player.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.expandable-card {
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||||
|
border: 2px solid #cbd5e1;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandable-card:hover {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandable-card.expanded {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandable-card:not(.expanded) .card-header:hover {
|
||||||
|
background: rgba(124, 58, 237, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandable-card.expanded .card-header-icon {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandable-card.expanded .card-content {
|
||||||
|
max-height: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 350px 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info-item {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info-value {
|
||||||
|
color: #1a202c;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #7c3aed;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item:hover {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item.active {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
background: #000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-label {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a202c;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item.active .version-label {
|
||||||
|
background: #7c3aed;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-latest {
|
||||||
|
background: #7c3aed;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-original {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(124, 58, 237, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles */
|
||||||
|
body.dark-mode .expandable-card {
|
||||||
|
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .expandable-card:hover,
|
||||||
|
body.dark-mode .expandable-card.expanded {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card-header-title {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .expandable-card:not(.expanded) .card-header:hover {
|
||||||
|
background: rgba(124, 58, 237, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .preview-info {
|
||||||
|
background: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .preview-info-item {
|
||||||
|
border-bottom-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .preview-info-label {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .preview-info-value {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .versions-title {
|
||||||
|
color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .version-item {
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .version-item:hover,
|
||||||
|
body.dark-mode .version-item.active {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .version-label {
|
||||||
|
background: #1a202c;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .version-item.active .version-label {
|
||||||
|
background: #7c3aed;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
|
||||||
|
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||||
|
class="btn"
|
||||||
|
style="background: #6c757d; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||||
|
← Back to Player
|
||||||
|
</a>
|
||||||
|
<h1 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||||
|
Edited Media - {{ player.name }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p style="color: #6c757d; font-size: 1rem;">Complete history of media files edited on this player</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if edited_media %}
|
||||||
|
{% set edited_by_content = {} %}
|
||||||
|
{% for edit in edited_media %}
|
||||||
|
{% if edit.content_id not in edited_by_content %}
|
||||||
|
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'versions': []}}) %}
|
||||||
|
{% endif %}
|
||||||
|
{% set _ = edited_by_content[edit.content_id]['versions'].append(edit) %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Expandable Cards -->
|
||||||
|
{% for content_id, data in edited_by_content.items() %}
|
||||||
|
{% set original_content = content_files.get(content_id) %}
|
||||||
|
<div class="expandable-card" id="card-{{ content_id }}" onclick="toggleCard({{ content_id }})">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-header-title">
|
||||||
|
<span class="card-header-icon">▶</span>
|
||||||
|
<span>📄 {{ original_content.filename if original_content else data.original_name }}</span>
|
||||||
|
<span style="font-size: 0.9rem; color: #64748b; font-weight: normal;">
|
||||||
|
({{ data.versions|length + 1 }} version{{ 's' if (data.versions|length + 1) > 1 else '' }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Preview Area (Left Column) -->
|
||||||
|
<div class="preview-area">
|
||||||
|
<div class="preview-container" id="preview-{{ content_id }}">
|
||||||
|
{% set latest = data.versions|sort(attribute='version', reverse=True)|first %}
|
||||||
|
{% if latest.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||||
|
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
|
||||||
|
alt="{{ latest.new_name }}"
|
||||||
|
id="preview-img-{{ content_id }}">
|
||||||
|
{% else %}
|
||||||
|
<div style="color: white; text-align: center;">
|
||||||
|
<div style="font-size: 3rem; margin-bottom: 0.5rem;">📄</div>
|
||||||
|
<div>No preview available</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
|
||||||
|
download
|
||||||
|
class="download-btn"
|
||||||
|
id="download-btn-{{ content_id }}">
|
||||||
|
💾 Download File
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="preview-info" id="info-{{ content_id }}">
|
||||||
|
<div class="preview-info-item">
|
||||||
|
<span class="preview-info-label">📄 Filename:</span>
|
||||||
|
<span class="preview-info-value" id="info-filename-{{ content_id }}">{{ latest.new_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-info-item">
|
||||||
|
<span class="preview-info-label">📦 Version:</span>
|
||||||
|
<span class="preview-info-value" id="info-version-{{ content_id }}">v{{ latest.version }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-info-item">
|
||||||
|
<span class="preview-info-label">👤 Edited by:</span>
|
||||||
|
<span class="preview-info-value" id="info-user-{{ content_id }}">{{ latest.user or 'Unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-info-item">
|
||||||
|
<span class="preview-info-label">🕒 Modified:</span>
|
||||||
|
<span class="preview-info-value" id="info-date-{{ content_id }}">{{ latest.time_of_modification | localtime('%Y-%m-%d %H:%M') if latest.time_of_modification else 'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-info-item">
|
||||||
|
<span class="preview-info-label">📅 Uploaded:</span>
|
||||||
|
<span class="preview-info-value" id="info-created-{{ content_id }}">{{ latest.created_at | localtime('%Y-%m-%d %H:%M') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Versions Area (Right Column) -->
|
||||||
|
<div class="versions-area">
|
||||||
|
<div class="versions-title">
|
||||||
|
📚 All Versions
|
||||||
|
</div>
|
||||||
|
<div class="versions-grid">
|
||||||
|
{% for edit in data.versions|sort(attribute='version', reverse=True) %}
|
||||||
|
<div class="version-item {% if loop.first %}active{% endif %}"
|
||||||
|
id="version-{{ content_id }}-{{ edit.version }}"
|
||||||
|
onclick="event.stopPropagation(); selectVersion({{ content_id }}, {{ edit.version }}, '{{ edit.new_name }}', '{{ edit.user or 'Unknown' }}', '{{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') if edit.time_of_modification else 'N/A' }}', '{{ edit.created_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}')">
|
||||||
|
<div class="version-thumbnail">
|
||||||
|
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||||
|
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||||
|
alt="Version {{ edit.version }}">
|
||||||
|
{% else %}
|
||||||
|
<div style="color: white; font-size: 2rem;">📄</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="version-label">
|
||||||
|
v{{ edit.version }}
|
||||||
|
{% if loop.first %}
|
||||||
|
<span class="version-badge badge-latest">Latest</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Original File -->
|
||||||
|
{% if original_content %}
|
||||||
|
<div class="version-item"
|
||||||
|
id="version-{{ content_id }}-original"
|
||||||
|
onclick="event.stopPropagation(); selectVersion({{ content_id }}, 'original', '{{ original_content.filename }}', 'System', 'N/A', '{{ original_content.uploaded_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/' ~ original_content.filename) }}')">
|
||||||
|
<div class="version-thumbnail">
|
||||||
|
{% if original_content.filename.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||||
|
<img src="{{ url_for('static', filename='uploads/' ~ original_content.filename) }}"
|
||||||
|
alt="Original">
|
||||||
|
{% else %}
|
||||||
|
<div style="color: white; font-size: 2rem;">📄</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="version-label">
|
||||||
|
Original
|
||||||
|
<span class="version-badge badge-original">Source</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Summary Statistics -->
|
||||||
|
<div class="card" style="margin-top: 2rem; background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%); color: white; border-radius: 12px; overflow: hidden;">
|
||||||
|
<div style="padding: 1.5rem; display: flex; justify-content: space-around; align-items: center; flex-wrap: wrap; gap: 2rem;">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_by_content|length }}</div>
|
||||||
|
<div style="font-size: 0.9rem; opacity: 0.9;">Total Files Edited</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_media|length }}</div>
|
||||||
|
<div style="font-size: 0.9rem; opacity: 0.9;">Total Versions</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ ((edited_media|length / edited_by_content|length) | round(1)) if edited_by_content else 0 }}</div>
|
||||||
|
<div style="font-size: 0.9rem; opacity: 0.9;">Avg Versions per File</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="card" style="text-align: center; padding: 4rem 2rem;">
|
||||||
|
<div style="font-size: 4rem; margin-bottom: 1rem; opacity: 0.5;">📭</div>
|
||||||
|
<h2 style="color: #6c757d; margin-bottom: 1rem;">No Edited Media Yet</h2>
|
||||||
|
<p style="color: #6c757d; font-size: 1.1rem;">
|
||||||
|
This player hasn't edited any media files yet. When the player edits content,<br>
|
||||||
|
all versions will be tracked and displayed here.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||||
|
class="btn"
|
||||||
|
style="margin-top: 2rem; display: inline-block; background: #7c3aed; color: white; padding: 0.75rem 1.5rem; text-decoration: none; border-radius: 6px; font-size: 1rem;">
|
||||||
|
← Back to Player Management
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleCard(contentId) {
|
||||||
|
const card = document.getElementById('card-' + contentId);
|
||||||
|
const wasExpanded = card.classList.contains('expanded');
|
||||||
|
|
||||||
|
// Close all cards
|
||||||
|
document.querySelectorAll('.expandable-card').forEach(c => {
|
||||||
|
c.classList.remove('expanded');
|
||||||
|
});
|
||||||
|
|
||||||
|
// If this card wasn't expanded, expand it
|
||||||
|
if (!wasExpanded) {
|
||||||
|
card.classList.add('expanded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectVersion(contentId, version, filename, user, modifiedDate, createdDate, fileUrl) {
|
||||||
|
// Update preview image
|
||||||
|
const previewImg = document.getElementById('preview-img-' + contentId);
|
||||||
|
if (previewImg) {
|
||||||
|
previewImg.src = fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update download button
|
||||||
|
const downloadBtn = document.getElementById('download-btn-' + contentId);
|
||||||
|
if (downloadBtn) {
|
||||||
|
downloadBtn.href = fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
document.getElementById('info-filename-' + contentId).textContent = filename;
|
||||||
|
document.getElementById('info-version-' + contentId).textContent = 'v' + version;
|
||||||
|
document.getElementById('info-user-' + contentId).textContent = user;
|
||||||
|
document.getElementById('info-date-' + contentId).textContent = modifiedDate;
|
||||||
|
document.getElementById('info-created-' + contentId).textContent = createdDate;
|
||||||
|
|
||||||
|
// Update active state on version items
|
||||||
|
document.querySelectorAll('.version-item').forEach(item => {
|
||||||
|
if (item.id.startsWith('version-' + contentId + '-')) {
|
||||||
|
item.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('version-' + contentId + '-' + version).classList.add('active');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -247,6 +247,33 @@
|
|||||||
color: #a0aec0 !important;
|
color: #a0aec0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Edited Media Cards Dark Mode */
|
||||||
|
body.dark-mode .card[style*="border: 2px solid #7c3aed"] {
|
||||||
|
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%) !important;
|
||||||
|
border-color: #8b5cf6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card[style*="border: 2px solid #7c3aed"] h3 {
|
||||||
|
color: #a78bfa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card[style*="border: 2px solid #7c3aed"] div[style*="background: white"] {
|
||||||
|
background: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card[style*="border: 2px solid #7c3aed"] strong {
|
||||||
|
color: #a78bfa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #475569"],
|
||||||
|
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #64748b"] {
|
||||||
|
color: #cbd5e1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode div[style*="background: #f8f9fa; border-radius: 8px"] {
|
||||||
|
background: #2d3748 !important;
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode .card > div[style*="text-align: center"] p {
|
body.dark-mode .card > div[style*="text-align: center"] p {
|
||||||
color: #a0aec0 !important;
|
color: #a0aec0 !important;
|
||||||
}
|
}
|
||||||
@@ -611,6 +638,111 @@ document.addEventListener('keydown', function(event) {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edited Media Section - Full Width -->
|
||||||
|
<div class="card" style="margin-top: 2rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
|
||||||
|
<h2 style="display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||||
|
Edited Media on the Player
|
||||||
|
</h2>
|
||||||
|
{% if edited_media %}
|
||||||
|
<a href="{{ url_for('players.edited_media', player_id=player.id) }}"
|
||||||
|
class="btn"
|
||||||
|
style="background: #7c3aed; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; transition: background 0.2s;"
|
||||||
|
onmouseover="this.style.background='#6d28d9'"
|
||||||
|
onmouseout="this.style.background='#7c3aed'">
|
||||||
|
📋 View All Edited Media
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.5rem;">Latest 3 edited files with their most recent versions</p>
|
||||||
|
|
||||||
|
{% if edited_media %}
|
||||||
|
{% set edited_by_content = {} %}
|
||||||
|
{% for edit in edited_media %}
|
||||||
|
{% if edit.content_id not in edited_by_content %}
|
||||||
|
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'latest_version': edit}}) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;">
|
||||||
|
{% for content_id, data in edited_by_content.items() %}
|
||||||
|
{% if loop.index <= 3 %}
|
||||||
|
<div class="card" style="background: linear-gradient(135deg, #f8f9fa 0%, #fff 100%); border: 2px solid #7c3aed; box-shadow: 0 2px 8px rgba(124, 58, 237, 0.1);">
|
||||||
|
{% set edit = data.latest_version %}
|
||||||
|
|
||||||
|
<!-- Image Preview if it's an image -->
|
||||||
|
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||||
|
<div style="width: 100%; height: 200px; overflow: hidden; border-radius: 8px 8px 0 0; background: #000;">
|
||||||
|
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||||
|
alt="{{ edit.new_name }}"
|
||||||
|
style="width: 100%; height: 100%; object-fit: contain;">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="padding: 1rem;">
|
||||||
|
<h3 style="margin: 0 0 0.75rem 0; color: #7c3aed; font-size: 1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
✏️ {{ data.original_name }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style="padding: 0.75rem; background: white; border-radius: 6px; border-left: 4px solid #7c3aed; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
|
||||||
|
<strong style="color: #7c3aed; font-size: 0.95rem;">
|
||||||
|
Version {{ edit.version }}
|
||||||
|
<span style="background: #7c3aed; color: white; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; margin-left: 0.5rem;">Latest</span>
|
||||||
|
</strong>
|
||||||
|
<small style="color: #6c757d; white-space: nowrap;">
|
||||||
|
{{ edit.created_at | localtime('%m/%d %H:%M') }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin: 0.25rem 0; font-size: 0.85rem; color: #475569;">
|
||||||
|
📄 {{ edit.new_name }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if edit.user %}
|
||||||
|
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
|
||||||
|
👤 {{ edit.user }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if edit.time_of_modification %}
|
||||||
|
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
|
||||||
|
🕒 {{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
|
||||||
|
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||||
|
target="_blank"
|
||||||
|
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #7c3aed; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
|
||||||
|
onmouseover="this.style.background='#6d28d9'"
|
||||||
|
onmouseout="this.style.background='#7c3aed'">
|
||||||
|
📥 View File
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||||
|
download
|
||||||
|
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #64748b; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
|
||||||
|
onmouseover="this.style.background='#475569'"
|
||||||
|
onmouseout="this.style.background='#64748b'">
|
||||||
|
💾 Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; padding: 3rem; color: #6c757d; background: #f8f9fa; border-radius: 8px; margin-top: 1.5rem;">
|
||||||
|
<p style="font-size: 2rem; margin: 0;">📝</p>
|
||||||
|
<p style="margin: 0.5rem 0 0 0; font-size: 1.1rem; font-weight: 500;">No edited media yet</p>
|
||||||
|
<p style="font-size: 0.9rem; margin: 0.5rem 0 0 0;">Media edits will appear here once the player sends edited files</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Additional Info Section -->
|
<!-- Additional Info Section -->
|
||||||
<div class="card" style="margin-top: 2rem;">
|
<div class="card" style="margin-top: 2rem;">
|
||||||
<h2>ℹ️ Player Information</h2>
|
<h2>ℹ️ Player Information</h2>
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ mkdir -p /app/instance
|
|||||||
mkdir -p /app/app/static/uploads
|
mkdir -p /app/app/static/uploads
|
||||||
|
|
||||||
# Initialize database if it doesn't exist
|
# Initialize database if it doesn't exist
|
||||||
if [ ! -f /app/instance/digiserver.db ]; then
|
if [ ! -f /app/instance/dashboard.db ]; then
|
||||||
echo "Initializing database..."
|
echo "Initializing database..."
|
||||||
python -c "
|
python -c "
|
||||||
from app.app import create_app
|
from app.app import create_app
|
||||||
from app.extensions import db, bcrypt
|
from app.extensions import db, bcrypt
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
|
||||||
app = create_app()
|
app = create_app('production')
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
@@ -49,4 +49,4 @@ exec gunicorn \
|
|||||||
--timeout 120 \
|
--timeout 120 \
|
||||||
--access-logfile - \
|
--access-logfile - \
|
||||||
--error-logfile - \
|
--error-logfile - \
|
||||||
"app.app:create_app()"
|
"app.app:create_app('production')"
|
||||||
|
|||||||
47
migrate_add_edit_enabled.py
Normal file
47
migrate_add_edit_enabled.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Migration: Add edit_on_player_enabled column to playlist_content table."""
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = 'instance/dashboard.db'
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
"""Add edit_on_player_enabled column to playlist_content."""
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if column already exists
|
||||||
|
cursor.execute("PRAGMA table_info(playlist_content)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'edit_on_player_enabled' in columns:
|
||||||
|
print("Column 'edit_on_player_enabled' already exists!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Add the new column with default value False
|
||||||
|
print("Adding 'edit_on_player_enabled' column to playlist_content table...")
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE playlist_content
|
||||||
|
ADD COLUMN edit_on_player_enabled BOOLEAN DEFAULT 0
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("✅ Migration completed successfully!")
|
||||||
|
print("Column 'edit_on_player_enabled' added with default value False (0)")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"❌ Migration failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
migrate()
|
||||||
181
old_code_documentation/PLAYER_EDIT_MEDIA_API.md
Normal file
181
old_code_documentation/PLAYER_EDIT_MEDIA_API.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# Player Edit Media API
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This API allows players to upload edited media files back to the server, maintaining version history and automatically updating playlists.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
### POST `/api/player-edit-media`
|
||||||
|
|
||||||
|
Upload an edited media file from a player device.
|
||||||
|
|
||||||
|
**Authentication Required:** Yes (Bearer token)
|
||||||
|
|
||||||
|
**Rate Limit:** 60 requests per 60 seconds
|
||||||
|
|
||||||
|
**Content-Type:** `multipart/form-data`
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
### Form Data
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `image_file` | File | Yes | The edited image file |
|
||||||
|
| `metadata` | JSON String | Yes | Metadata about the edit (see below) |
|
||||||
|
|
||||||
|
### Metadata JSON Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"time_of_modification": "2025-12-05T20:30:00Z",
|
||||||
|
"original_name": "image.jpg",
|
||||||
|
"new_name": "image_v1.jpg",
|
||||||
|
"version": 1,
|
||||||
|
"user": "player_user_name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `time_of_modification` | ISO 8601 DateTime | Yes | When the edit was made |
|
||||||
|
| `original_name` | String | Yes | Original filename (must exist in content) |
|
||||||
|
| `new_name` | String | Yes | New filename with version suffix |
|
||||||
|
| `version` | Integer | Yes | Version number (1, 2, 3, etc.) |
|
||||||
|
| `user` | String | No | User who made the edit |
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
### Success (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Edited media received and processed",
|
||||||
|
"edit_id": 123,
|
||||||
|
"version": 1,
|
||||||
|
"new_playlist_version": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
#### 400 Bad Request
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "No image file provided"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 404 Not Found
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Original content not found: image.jpg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 500 Internal Server Error
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Internal server error"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Player edits media** - User edits an image/PDF/PPTX on the player device
|
||||||
|
2. **Player uploads** - Player sends edited file + metadata to this endpoint
|
||||||
|
3. **Server processes**:
|
||||||
|
- Saves edited file to `/static/uploads/edited_media/<content_id>/<new_name>`
|
||||||
|
- Saves metadata JSON to `/static/uploads/edited_media/<content_id>/<new_name>_metadata.json`
|
||||||
|
- Replaces original file in `/static/uploads/` with edited version
|
||||||
|
- Creates database record in `player_edit` table
|
||||||
|
- Increments playlist version to trigger player refresh
|
||||||
|
- Clears playlist cache
|
||||||
|
4. **Player refreshes** - Next playlist check shows updated media
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
Each edit is saved with a version number:
|
||||||
|
- `image.jpg` → `image_v1.jpg` (first edit)
|
||||||
|
- `image.jpg` → `image_v2.jpg` (second edit)
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
All versions are preserved in the `edited_media/<content_id>/` folder.
|
||||||
|
|
||||||
|
## Example cURL Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First, authenticate to get token
|
||||||
|
TOKEN=$(curl -X POST http://server/api/auth/authenticate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"hostname": "player-1", "password": "password123"}' \
|
||||||
|
| jq -r '.token')
|
||||||
|
|
||||||
|
# Upload edited media
|
||||||
|
curl -X POST http://server/api/player-edit-media \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-F "image_file=@edited_image_v1.jpg" \
|
||||||
|
-F 'metadata={"time_of_modification":"2025-12-05T20:30:00Z","original_name":"image.jpg","new_name":"image_v1.jpg","version":1,"user":"john"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Authenticate
|
||||||
|
auth_response = requests.post(
|
||||||
|
'http://server/api/auth/authenticate',
|
||||||
|
json={'hostname': 'player-1', 'password': 'password123'}
|
||||||
|
)
|
||||||
|
token = auth_response.json()['token']
|
||||||
|
|
||||||
|
# Prepare metadata
|
||||||
|
metadata = {
|
||||||
|
'time_of_modification': '2025-12-05T20:30:00Z',
|
||||||
|
'original_name': 'image.jpg',
|
||||||
|
'new_name': 'image_v1.jpg',
|
||||||
|
'version': 1,
|
||||||
|
'user': 'john'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upload edited file
|
||||||
|
with open('edited_image_v1.jpg', 'rb') as f:
|
||||||
|
response = requests.post(
|
||||||
|
'http://server/api/player-edit-media',
|
||||||
|
headers={'Authorization': f'Bearer {token}'},
|
||||||
|
files={'image_file': f},
|
||||||
|
data={'metadata': json.dumps(metadata)}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### player_edit Table
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | INTEGER | Primary key |
|
||||||
|
| player_id | INTEGER | Foreign key to player |
|
||||||
|
| content_id | INTEGER | Foreign key to content |
|
||||||
|
| original_name | VARCHAR(255) | Original filename |
|
||||||
|
| new_name | VARCHAR(255) | New filename with version |
|
||||||
|
| version | INTEGER | Version number |
|
||||||
|
| user | VARCHAR(255) | User who made the edit |
|
||||||
|
| time_of_modification | DATETIME | When edit was made |
|
||||||
|
| metadata_path | VARCHAR(512) | Path to metadata JSON |
|
||||||
|
| edited_file_path | VARCHAR(512) | Path to edited file |
|
||||||
|
| created_at | DATETIME | Record creation time |
|
||||||
|
|
||||||
|
## UI Display
|
||||||
|
|
||||||
|
Edited media history is displayed on the player management page under the "Edited Media on the Player" card, showing:
|
||||||
|
- Original filename
|
||||||
|
- Version number
|
||||||
|
- Editor name
|
||||||
|
- Modification time
|
||||||
|
- Link to view edited file
|
||||||
Reference in New Issue
Block a user