- 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
856 lines
30 KiB
Python
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
|