Files
MUM/app/routes/api_v1/streams_api.py
Christopher 8cb49fed43 Integrate React SPA and API v1 routes into admin UI
Added blueprint to serve React SPA for /admin routes, refactored error handling and routing to support client-side navigation, and introduced new API v1 endpoints for frontend integration. Legacy admin blueprints for users, invites, and libraries are disabled in favor of the SPA. Dockerfile and extension updates enable WebSocket support and dynamic port configuration.
2025-10-24 05:44:45 -06:00

303 lines
10 KiB
Python

from uuid import uuid4
from datetime import datetime
from flask import jsonify, request
from flask_login import login_required, current_user
from sqlalchemy import desc, func
from app.routes.api_v1 import bp
from app.models_media_services import MediaStreamHistory, MediaServer, ServiceType
from app.models import User, EventType
from app.utils.helpers import permission_required, log_event
from app.services.media_service_factory import MediaServiceFactory
def _serialize_stream(stream: MediaStreamHistory):
return {
'id': stream.id,
'user_uuid': stream.user_uuid,
'media_title': stream.media_title,
'media_type': stream.media_type,
'server_id': stream.server_id,
'server_name': stream.server.server_nickname if stream.server else None,
'started_at': stream.started_at.isoformat() if stream.started_at else None,
'stopped_at': stream.stopped_at.isoformat() if stream.stopped_at else None,
'duration_seconds': stream.duration_seconds,
'platform': stream.platform,
'grandparent_title': stream.grandparent_title,
'parent_title': stream.parent_title,
'library_name': stream.library_name,
}
def _apply_filters(query, user_uuid=None, service_type=None, status=None, start_date=None, end_date=None):
if user_uuid:
query = query.filter(MediaStreamHistory.user_uuid == user_uuid)
if service_type:
try:
service_enum = ServiceType(service_type)
query = query.filter(MediaStreamHistory.server.has(service_type=service_enum))
except ValueError:
query = query.filter(False)
if status == 'active':
query = query.filter(MediaStreamHistory.stopped_at.is_(None))
elif status == 'completed':
query = query.filter(MediaStreamHistory.stopped_at.isnot(None))
if start_date:
query = query.filter(MediaStreamHistory.started_at >= start_date)
if end_date:
query = query.filter(MediaStreamHistory.started_at <= end_date)
return query
@bp.route('/streams', methods=['GET'])
@login_required
@permission_required('view_streaming')
def list_streams():
request_id = str(uuid4())
page = max(1, request.args.get('page', type=int) or 1)
page_size = max(1, min(request.args.get('page_size', type=int) or 25, 100))
user_uuid = request.args.get('user_uuid')
service_type = request.args.get('service_type')
status = request.args.get('status')
start_date_str = request.args.get('start')
end_date_str = request.args.get('end')
start_dt = None
end_dt = None
if start_date_str:
try:
start_dt = datetime.fromisoformat(start_date_str)
except ValueError:
pass
if end_date_str:
try:
from datetime import timedelta
end_dt = datetime.fromisoformat(end_date_str)
# Include entire day if date provided without time
if end_dt.time().isoformat() == '00:00:00':
end_dt = end_dt + timedelta(days=1)
except ValueError:
pass
query = MediaStreamHistory.query.order_by(desc(MediaStreamHistory.started_at))
query = _apply_filters(query, user_uuid, service_type, status, start_dt, end_dt)
pagination = query.paginate(page=page, per_page=page_size, error_out=False)
return jsonify({
'data': [_serialize_stream(stream) for stream in pagination.items],
'meta': {
'request_id': request_id,
'pagination': {
'page': pagination.page,
'page_size': pagination.per_page,
'total_items': pagination.total,
'total_pages': pagination.pages or 1
},
'filters': {
'user_uuid': user_uuid,
'service_type': service_type,
'status': status,
'start': start_date_str,
'end': end_date_str
}
}
})
@bp.route('/streams/<int:stream_id>', methods=['GET'])
@login_required
@permission_required('view_streaming')
def get_stream(stream_id):
request_id = str(uuid4())
stream = MediaStreamHistory.query.get_or_404(stream_id)
return jsonify({
'data': _serialize_stream(stream),
'meta': {
'request_id': request_id,
'deprecated': False
}
})
@bp.route('/streams/summary', methods=['GET'])
@login_required
@permission_required('view_streaming')
def streams_summary():
request_id = str(uuid4())
start_date_str = request.args.get('start')
end_date_str = request.args.get('end')
service_type = request.args.get('service_type')
user_uuid = request.args.get('user_uuid')
start_dt = None
end_dt = None
if start_date_str:
try:
start_dt = datetime.fromisoformat(start_date_str)
except ValueError:
start_dt = None
if end_date_str:
try:
from datetime import timedelta
end_dt = datetime.fromisoformat(end_date_str)
if end_dt.time().isoformat() == '00:00:00':
end_dt = end_dt + timedelta(days=1)
except ValueError:
end_dt = None
base_query = _apply_filters(MediaStreamHistory.query, user_uuid, service_type, None, start_dt, end_dt)
total_streams = base_query.count()
active_streams = _apply_filters(MediaStreamHistory.query, user_uuid, service_type, 'active', start_dt, end_dt).count()
completed_streams = _apply_filters(MediaStreamHistory.query, user_uuid, service_type, 'completed', start_dt, end_dt).count()
total_duration = base_query.with_entities(func.coalesce(func.sum(MediaStreamHistory.duration_seconds), 0)).scalar() or 0
average_duration = base_query.with_entities(func.coalesce(func.avg(MediaStreamHistory.duration_seconds), 0)).scalar() or 0
daily_counts = (
_apply_filters(MediaStreamHistory.query, user_uuid, service_type, None, start_dt, end_dt)
.with_entities(func.date(MediaStreamHistory.started_at).label('day'), func.count(MediaStreamHistory.id))
.group_by('day')
.order_by('day')
.all()
)
per_service = (
_apply_filters(
MediaStreamHistory.query.join(MediaServer),
user_uuid,
service_type,
None,
start_dt,
end_dt
)
.with_entities(MediaServer.service_type, func.count(MediaStreamHistory.id))
.group_by(MediaServer.service_type)
.order_by(MediaServer.service_type)
.all()
)
return jsonify({
'data': {
'counts': {
'total': total_streams,
'active': active_streams,
'completed': completed_streams
},
'duration': {
'total_seconds': int(total_duration),
'average_seconds': int(average_duration)
},
'daily': [
{'date': day.isoformat() if hasattr(day, 'isoformat') else str(day), 'count': count}
for day, count in daily_counts
],
'by_service': [
{'service_type': svc.value if isinstance(svc, ServiceType) else str(svc), 'count': count}
for svc, count in per_service
]
},
'meta': {
'request_id': request_id,
'filters': {
'start': start_date_str,
'end': end_date_str,
'service_type': service_type,
'user_uuid': user_uuid
},
'generated_at': datetime.utcnow().isoformat() + 'Z'
}
})
@bp.route('/streams/<int:stream_id>/terminate', methods=['POST'])
@login_required
@permission_required('kill_stream')
def terminate_stream(stream_id):
request_id = str(uuid4())
payload = request.get_json(silent=True) or {}
message = payload.get('message')
stream = MediaStreamHistory.query.get_or_404(stream_id)
if stream.stopped_at:
return jsonify({
'error': {
'code': 'STREAM_NOT_ACTIVE',
'message': 'The stream has already ended.'
},
'meta': {
'request_id': request_id
}
}), 400
session_key = stream.session_key or stream.external_session_id
if not session_key:
return jsonify({
'error': {
'code': 'SESSION_KEY_MISSING',
'message': 'No session key is stored for this stream; it cannot be terminated.'
},
'meta': {
'request_id': request_id
}
}), 400
server = stream.server
service = MediaServiceFactory.create_service_from_db(server)
if not service or not hasattr(service, 'terminate_session'):
return jsonify({
'error': {
'code': 'SERVICE_NOT_SUPPORTED',
'message': f"{server.service_type.value.capitalize()} does not support remote termination."
},
'meta': {
'request_id': request_id
}
}), 400
try:
success = service.terminate_session(session_key, message)
except Exception as exc:
return jsonify({
'error': {
'code': 'TERMINATION_FAILED',
'message': f'Failed to terminate session: {exc}'
},
'meta': {
'request_id': request_id
}
}), 500
if not success:
return jsonify({
'error': {
'code': 'TERMINATION_FAILED',
'message': 'The media server did not accept the termination command.'
},
'meta': {
'request_id': request_id
}
}), 502
log_event(
EventType.SETTING_CHANGE,
f"Terminated {server.service_type.value} session {session_key} on {server.server_nickname}",
admin_id=getattr(current_user, 'id', None),
server_id=server.id
)
return jsonify({
'data': {
'success': True,
'message': f'Termination command sent for session {session_key}.'
},
'meta': {
'request_id': request_id
}
}), 200