Files
digiserver-v2/app/blueprints/api.py
DigiServer Developer 8d52c0338f 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
2025-12-06 00:06:11 +02:00

856 lines
30 KiB
Python

"""API blueprint for REST endpoints and player communication."""
from flask import Blueprint, request, jsonify, current_app
from functools import wraps
from datetime import datetime, timedelta
import secrets
import bcrypt
from typing import Optional, Dict, List
from app.extensions import db, cache
from app.models import Player, Group, Content, PlayerFeedback, ServerLog
from app.utils.logger import log_action
api_bp = Blueprint('api', __name__, url_prefix='/api')
# Simple rate limiting (use Redis-based solution in production)
rate_limit_storage = {}
def rate_limit(max_requests: int = 60, window: int = 60):
"""Rate limiting decorator.
Args:
max_requests: Maximum number of requests allowed
window: Time window in seconds
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# Get client identifier (IP address or API key)
client_id = request.remote_addr
auth_header = request.headers.get('Authorization')
if auth_header and auth_header.startswith('Bearer '):
client_id = auth_header[7:] # Use API key as identifier
now = datetime.now()
key = f"{client_id}:{f.__name__}"
# Clean old entries
if key in rate_limit_storage:
rate_limit_storage[key] = [
req_time for req_time in rate_limit_storage[key]
if now - req_time < timedelta(seconds=window)
]
else:
rate_limit_storage[key] = []
# Check rate limit
if len(rate_limit_storage[key]) >= max_requests:
return jsonify({
'error': 'Rate limit exceeded',
'retry_after': window
}), 429
# Add current request
rate_limit_storage[key].append(now)
return f(*args, **kwargs)
return decorated_function
return decorator
def verify_player_auth(f):
"""Decorator to verify player authentication via auth code."""
@wraps(f)
def decorated_function(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Missing or invalid authorization header'}), 401
auth_code = auth_header[7:] # Remove 'Bearer ' prefix
# Find player with this auth code
player = Player.query.filter_by(auth_code=auth_code).first()
if not player:
log_action('warning', f'Invalid auth code attempt: {auth_code}')
return jsonify({'error': 'Invalid authentication code'}), 403
# Store player in request context
request.player = player
return f(*args, **kwargs)
return decorated_function
@api_bp.route('/health', methods=['GET'])
def health_check():
"""API health check endpoint."""
return jsonify({
'status': 'healthy',
'timestamp': datetime.utcnow().isoformat(),
'version': '2.0.0'
})
@api_bp.route('/auth/player', methods=['POST'])
@rate_limit(max_requests=120, window=60)
def authenticate_player():
"""Authenticate a player and return auth code and configuration.
Request JSON:
hostname: Player hostname/identifier (required)
password: Player password (optional if using quickconnect)
quickconnect_code: Quick connect code (optional if using password)
Returns:
JSON with auth_code, player_id, group_id, and configuration
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
hostname = data.get('hostname')
password = data.get('password')
quickconnect_code = data.get('quickconnect_code')
if not hostname:
return jsonify({'error': 'Hostname is required'}), 400
if not password and not quickconnect_code:
return jsonify({'error': 'Password or quickconnect code required'}), 400
# Authenticate player
player = Player.authenticate(hostname, password, quickconnect_code)
if not player:
log_action('warning', f'Failed authentication attempt for hostname: {hostname}')
return jsonify({'error': 'Invalid credentials'}), 401
# Update player status
player.update_status('online')
db.session.commit()
log_action('info', f'Player authenticated: {player.name} ({player.hostname})')
# Return authentication response
response = {
'success': True,
'player_id': player.id,
'player_name': player.name,
'hostname': player.hostname,
'auth_code': player.auth_code,
'playlist_id': player.playlist_id,
'orientation': player.orientation,
'status': player.status
}
return jsonify(response), 200
@api_bp.route('/auth/verify', methods=['POST'])
@rate_limit(max_requests=300, window=60)
def verify_auth_code():
"""Verify an auth code and return player information.
Request JSON:
auth_code: Player authentication code
Returns:
JSON with player information if valid
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
auth_code = data.get('auth_code')
if not auth_code:
return jsonify({'error': 'Auth code is required'}), 400
# Find player with this auth code
player = Player.query.filter_by(auth_code=auth_code).first()
if not player:
return jsonify({'error': 'Invalid auth code'}), 401
# Update last seen
player.update_status(player.status)
db.session.commit()
response = {
'valid': True,
'player_id': player.id,
'player_name': player.name,
'hostname': player.hostname,
'playlist_id': player.playlist_id,
'orientation': player.orientation,
'status': player.status
}
return jsonify(response), 200
@api_bp.route('/playlists', methods=['GET'])
@rate_limit(max_requests=30, window=60)
def get_playlist_by_quickconnect():
"""Get playlist using hostname and quickconnect code (Kivy player compatible).
Query parameters:
hostname: Player hostname/identifier
quickconnect_code: Quick connect code for authentication
Returns:
JSON with playlist, playlist_version, and hashed_quickconnect
"""
try:
import bcrypt
hostname = request.args.get('hostname')
quickconnect_code = request.args.get('quickconnect_code')
if not hostname or not quickconnect_code:
return jsonify({
'error': 'hostname and quickconnect_code are required',
'playlist': [],
'playlist_version': 0
}), 400
# Find player by hostname and validate quickconnect
player = Player.query.filter_by(hostname=hostname).first()
if not player:
log_action('warning', f'Player not found with hostname: {hostname}')
return jsonify({
'error': 'Player not found',
'playlist': [],
'playlist_version': 0
}), 404
# Validate quickconnect code
if not player.quickconnect_code:
log_action('warning', f'Player {hostname} has no quickconnect code set')
return jsonify({
'error': 'Quickconnect not configured',
'playlist': [],
'playlist_version': 0
}), 403
# Check if quickconnect matches (using bcrypt verification)
if not player.check_quickconnect_code(quickconnect_code):
log_action('warning', f'Invalid quickconnect code for player: {hostname}')
return jsonify({
'error': 'Invalid quickconnect code',
'playlist': [],
'playlist_version': 0
}), 403
# Get playlist (with caching)
playlist = get_cached_playlist(player.id)
# Update player's last seen timestamp and status
player.last_seen = datetime.utcnow()
player.status = 'online'
db.session.commit()
# Get playlist version from the assigned playlist
playlist_version = 1
if player.playlist_id:
from app.models import Playlist
assigned_playlist = Playlist.query.get(player.playlist_id)
if assigned_playlist:
playlist_version = assigned_playlist.version
# Hash the quickconnect code for validation on client side
hashed_quickconnect = bcrypt.hashpw(
quickconnect_code.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
log_action('info', f'Playlist fetched for player: {player.name} ({hostname})')
return jsonify({
'player_id': player.id,
'player_name': player.name,
'playlist_id': player.playlist_id,
'playlist_version': playlist_version,
'playlist': playlist,
'hashed_quickconnect': hashed_quickconnect,
'count': len(playlist)
}), 200
except Exception as e:
log_action('error', f'Error getting playlist: {str(e)}')
return jsonify({
'error': 'Internal server error',
'playlist': [],
'playlist_version': 0
}), 500
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
@rate_limit(max_requests=300, window=60)
@verify_player_auth
def get_player_playlist(player_id: int):
"""Get playlist for a specific player.
Requires player authentication via Bearer token.
"""
try:
# Verify the authenticated player matches the requested player_id
if request.player.id != player_id:
return jsonify({'error': 'Unauthorized access to this player'}), 403
player = request.player
# Get playlist (with caching)
playlist = get_cached_playlist(player_id)
# Update player's last seen timestamp
player.last_seen = datetime.utcnow()
db.session.commit()
# Get playlist version from the assigned playlist
playlist_version = 1
if player.playlist_id:
from app.models import Playlist
assigned_playlist = Playlist.query.get(player.playlist_id)
if assigned_playlist:
playlist_version = assigned_playlist.version
return jsonify({
'player_id': player_id,
'player_name': player.name,
'playlist_id': player.playlist_id,
'playlist_version': playlist_version,
'playlist': playlist,
'count': len(playlist)
})
except Exception as e:
log_action('error', f'Error getting playlist for player {player_id}: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/playlist-version/<int:player_id>', methods=['GET'])
@verify_player_auth
@rate_limit(max_requests=60, window=60)
def get_playlist_version(player_id: int):
"""Get current playlist version for a player.
Lightweight endpoint for players to check if playlist needs updating.
Requires player authentication via Bearer token.
"""
try:
# Verify the authenticated player matches the requested player_id
if request.player.id != player_id:
return jsonify({'error': 'Unauthorized access to this player'}), 403
player = request.player
# Update last seen
player.last_seen = datetime.utcnow()
db.session.commit()
return jsonify({
'player_id': player_id,
'playlist_version': player.playlist_version,
'content_count': Content.query.filter_by(player_id=player_id).count()
})
except Exception as e:
log_action('error', f'Error getting playlist version for player {player_id}: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@cache.memoize(timeout=300)
def get_cached_playlist(player_id: int) -> List[Dict]:
"""Get cached playlist for a player based on assigned playlist."""
from flask import url_for
from app.models import Playlist
player = Player.query.get(player_id)
if not player or not player.playlist_id:
return []
# Get the playlist assigned to this player
playlist = Playlist.query.get(player.playlist_id)
if not playlist:
return []
# Get content from playlist (ordered)
content_list = playlist.get_content_ordered()
# Build playlist response
playlist_data = []
for idx, content in enumerate(content_list, start=1):
# Generate full URL for content
from flask import request as current_request
# Get server base URL
server_base = current_request.host_url.rstrip('/')
content_url = f"{server_base}/static/uploads/{content.filename}"
playlist_data.append({
'id': content.id,
'file_name': content.filename, # Player expects 'file_name' not 'filename'
'type': content.content_type,
'duration': content._playlist_duration or content.duration or 10,
'position': content._playlist_position or idx,
'url': content_url, # Full URL for downloads
'description': content.description,
'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=600, window=60)
def receive_player_feedback():
"""Receive feedback/status updates from players (Kivy player compatible).
Expected JSON payload:
{
"player_name": "Screen1",
"quickconnect_code": "ABC123",
"status": "playing|paused|error|restarting",
"message": "Status message",
"playlist_version": 1,
"error_details": "Optional error details",
"timestamp": "ISO timestamp"
}
"""
try:
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')
# Find player by hostname first (more reliable), then by name
player = None
if hostname:
player = Player.query.filter_by(hostname=hostname).first()
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 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
# Create feedback record
status = data.get('status', 'unknown')
message = data.get('message', '')
error_details = data.get('error_details')
feedback = PlayerFeedback(
player_id=player.id,
status=status,
message=message,
error=error_details
)
db.session.add(feedback)
# Update player's last seen and status
player.last_seen = datetime.utcnow()
player.status = status
db.session.commit()
log_action('info', f'Feedback received from {player.name} ({player.hostname}): {status} - {message}')
return jsonify({
'success': True,
'message': 'Feedback received',
'player_id': player.id
}), 200
except Exception as e:
db.session.rollback()
log_action('error', f'Error receiving player feedback: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/player-status/<int:player_id>', methods=['GET'])
@rate_limit(max_requests=60, window=60)
def get_player_status(player_id: int):
"""Get current status of a player (public endpoint for monitoring)."""
try:
player = Player.query.get_or_404(player_id)
# Get latest feedback
latest_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\
.order_by(PlayerFeedback.timestamp.desc())\
.first()
# Calculate if player is online (seen in last 5 minutes)
is_online = False
if player.last_seen:
is_online = (datetime.utcnow() - player.last_seen).total_seconds() < 300
return jsonify({
'player_id': player_id,
'name': player.name,
'location': player.location,
'group_id': player.group_id,
'status': player.status,
'is_online': is_online,
'last_seen': player.last_seen.isoformat() if player.last_seen else None,
'latest_feedback': {
'status': latest_feedback.status,
'message': latest_feedback.message,
'timestamp': latest_feedback.timestamp.isoformat()
} if latest_feedback else None
})
except Exception as e:
log_action('error', f'Error getting player status: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/upload-progress/<upload_id>', methods=['GET'])
@rate_limit(max_requests=120, window=60)
def get_upload_progress(upload_id: str):
"""Get progress of a file upload."""
from app.utils.uploads import get_upload_progress as get_progress
try:
progress = get_progress(upload_id)
return jsonify(progress)
except Exception as e:
log_action('error', f'Error getting upload progress: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/system-info', methods=['GET'])
@rate_limit(max_requests=30, window=60)
def system_info():
"""Get system information and statistics."""
try:
# Get counts
total_players = Player.query.count()
total_groups = Group.query.count()
total_content = Content.query.count()
# Count online players (seen in last 5 minutes)
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
online_players = Player.query.filter(Player.last_seen >= five_min_ago).count()
# Get recent logs count
recent_logs = ServerLog.query.filter(
ServerLog.timestamp >= datetime.utcnow() - timedelta(hours=24)
).count()
return jsonify({
'players': {
'total': total_players,
'online': online_players
},
'groups': total_groups,
'content': total_content,
'logs_24h': recent_logs,
'timestamp': datetime.utcnow().isoformat()
})
except Exception as e:
log_action('error', f'Error getting system info: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/groups', methods=['GET'])
@rate_limit(max_requests=60, window=60)
def list_groups():
"""List all groups with basic information."""
try:
groups = Group.query.order_by(Group.name).all()
groups_data = []
for group in groups:
groups_data.append({
'id': group.id,
'name': group.name,
'description': group.description,
'player_count': group.players.count(),
'content_count': group.contents.count()
})
return jsonify({
'groups': groups_data,
'count': len(groups_data)
})
except Exception as e:
log_action('error', f'Error listing groups: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/content', methods=['GET'])
@rate_limit(max_requests=60, window=60)
def list_content():
"""List all content with basic information."""
try:
contents = Content.query.order_by(Content.uploaded_at.desc()).all()
content_data = []
for content in contents:
content_data.append({
'id': content.id,
'filename': content.filename,
'type': content.content_type,
'duration': content.duration,
'size': content.file_size,
'uploaded_at': content.uploaded_at.isoformat(),
'group_count': content.groups.count()
})
return jsonify({
'content': content_data,
'count': len(content_data)
})
except Exception as e:
log_action('error', f'Error listing content: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/logs', methods=['GET'])
@rate_limit(max_requests=30, window=60)
def get_logs():
"""Get recent server logs.
Query parameters:
limit: Number of logs to return (default: 50, max: 200)
level: Filter by log level (info, warning, error)
since: ISO timestamp to get logs since that time
"""
try:
# Get query parameters
limit = min(request.args.get('limit', 50, type=int), 200)
level = request.args.get('level')
since_str = request.args.get('since')
# Build query
query = ServerLog.query
if level:
query = query.filter_by(level=level)
if since_str:
try:
since = datetime.fromisoformat(since_str)
query = query.filter(ServerLog.timestamp >= since)
except ValueError:
return jsonify({'error': 'Invalid since timestamp format'}), 400
# Get logs
logs = query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
logs_data = []
for log in logs:
logs_data.append({
'id': log.id,
'level': log.level,
'message': log.message,
'timestamp': log.timestamp.isoformat()
})
return jsonify({
'logs': logs_data,
'count': len(logs_data)
})
except Exception as e:
log_action('error', f'Error getting logs: {str(e)}')
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."""
return jsonify({'error': 'Endpoint not found'}), 404
@api_bp.errorhandler(405)
def method_not_allowed(error):
"""Handle 405 errors in API."""
return jsonify({'error': 'Method not allowed'}), 405
@api_bp.errorhandler(500)
def internal_error(error):
"""Handle 500 errors in API."""
return jsonify({'error': 'Internal server error'}), 500