Add player media editing feature with versioning

- Added PlayerEdit model to track edited media history
- Created /api/player-edit-media endpoint for receiving edited files from players
- Implemented versioned storage: edited_media/<content_id>/<filename_vN.ext>
- Automatic playlist update when edited media is received
- Updated content.filename to reference versioned file in playlist
- Added 'Edited Media on the Player' card to player management page
- UI shows version history grouped by original file
- Each edit preserves previous versions in archive folder
- Includes dark mode support for new UI elements
- Documentation: PLAYER_EDIT_MEDIA_API.md
This commit is contained in:
DigiServer Developer
2025-12-06 00:06:11 +02:00
parent 3921a09c4e
commit 8d52c0338f
6 changed files with 537 additions and 10 deletions

View File

@@ -96,7 +96,7 @@ def health_check():
@api_bp.route('/auth/player', methods=['POST'])
@rate_limit(max_requests=10, window=60)
@rate_limit(max_requests=120, window=60)
def authenticate_player():
"""Authenticate a player and return auth code and configuration.
@@ -152,7 +152,7 @@ def authenticate_player():
@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():
"""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'])
@rate_limit(max_requests=30, window=60)
@rate_limit(max_requests=300, window=60)
@verify_player_auth
def get_player_playlist(player_id: int):
"""Get playlist for a specific player.
@@ -402,14 +402,14 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
'position': content._playlist_position or idx,
'url': content_url, # Full URL for downloads
'description': content.description,
'edit_on_player_enabled': getattr(content, '_playlist_edit_on_player_enabled', False)
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
})
return playlist_data
@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():
"""Receive feedback/status updates from players (Kivy player compatible).
@@ -428,15 +428,13 @@ def receive_player_feedback():
data = request.json
if not data:
log_action('warning', 'Player feedback received with no data')
return jsonify({'error': 'No data provided'}), 400
player_name = data.get('player_name')
hostname = data.get('hostname') # Also accept hostname
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
player = None
if hostname:
@@ -444,12 +442,35 @@ def receive_player_feedback():
if not player and player_name:
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:
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
return jsonify({'error': 'Player not found'}), 404
# Validate quickconnect code (using bcrypt verification)
if not player.check_quickconnect_code(quickconnect_code):
# Validate quickconnect code if provided (using bcrypt verification)
if quickconnect_code and not player.check_quickconnect_code(quickconnect_code):
log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})')
return jsonify({'error': 'Invalid quickconnect code'}), 403
@@ -679,6 +700,143 @@ def get_logs():
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)
def api_not_found(error):
"""Handle 404 errors in API."""