|
|
|
@ -1,721 +1,92 @@ |
|
|
|
# import json |
|
|
|
# import hmac |
|
|
|
# import hashlib |
|
|
|
# import logging |
|
|
|
# from typing import Dict, Any |
|
|
|
|
|
|
|
# from django.conf import settings |
|
|
|
# from django.core.exceptions import ImproperlyConfigured |
|
|
|
# from django.utils import timezone |
|
|
|
# from django.utils.decorators import method_decorator |
|
|
|
# from django.views.decorators.csrf import csrf_exempt |
|
|
|
|
|
|
|
# from rest_framework import status |
|
|
|
# from rest_framework.views import APIView |
|
|
|
# from rest_framework.response import Response |
|
|
|
# from rest_framework.permissions import AllowAny |
|
|
|
# from drf_yasg.utils import swagger_auto_schema |
|
|
|
# from drf_yasg import openapi |
|
|
|
|
|
|
|
# from apps.course.models import CourseLiveSession, LiveSessionUser, Course, Participant |
|
|
|
# from apps.account.models import User |
|
|
|
# from utils.exceptions import AppAPIException |
|
|
|
|
|
|
|
# logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
|
|
|
# @method_decorator(csrf_exempt, name='dispatch') |
|
|
|
# class PlugNMeetWebhookAPIView(APIView): |
|
|
|
# """ |
|
|
|
# Webhook endpoint to receive events from PlugNMeet server. |
|
|
|
|
|
|
|
# Events handled: |
|
|
|
# - room_finished: Close the live session |
|
|
|
# - participant_joined: Create LiveSessionUser entry |
|
|
|
# - participant_left: Mark LiveSessionUser as offline/exited |
|
|
|
# - end_recording: (Future implementation) |
|
|
|
# """ |
|
|
|
# permission_classes = [AllowAny] |
|
|
|
|
|
|
|
# @swagger_auto_schema( |
|
|
|
# operation_description="Handle webhook events from PlugNMeet server for live sessions", |
|
|
|
# tags=["Imam-Javad - Course"], |
|
|
|
# responses={ |
|
|
|
# 200: openapi.Response(description="Webhook processed successfully"), |
|
|
|
# 400: openapi.Response(description="Invalid webhook signature or data"), |
|
|
|
# 500: openapi.Response(description="Internal server error") |
|
|
|
# } |
|
|
|
# ) |
|
|
|
# def post(self, request, *args, **kwargs): |
|
|
|
# logger.info(f"[PlugNMeet Webhook] Received webhook request") |
|
|
|
|
|
|
|
# # Verify webhook signature |
|
|
|
# if not self._verify_webhook_signature(request): |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] Invalid signature") |
|
|
|
# raise AppAPIException( |
|
|
|
# {'message': 'Invalid webhook signature'}, |
|
|
|
# status_code=status.HTTP_403_FORBIDDEN |
|
|
|
# ) |
|
|
|
|
|
|
|
# try: |
|
|
|
# payload = json.loads(request.body.decode('utf-8')) |
|
|
|
# except (json.JSONDecodeError, UnicodeDecodeError) as e: |
|
|
|
# logger.error(f"[PlugNMeet Webhook] Invalid JSON payload - error={str(e)}") |
|
|
|
# raise AppAPIException( |
|
|
|
# {'message': 'Invalid JSON payload'}, |
|
|
|
# status_code=status.HTTP_400_BAD_REQUEST |
|
|
|
# ) |
|
|
|
|
|
|
|
# event = payload.get('event') |
|
|
|
# if not event: |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] Missing event field") |
|
|
|
# raise AppAPIException( |
|
|
|
# {'message': 'Missing event field'}, |
|
|
|
# status_code=status.HTTP_400_BAD_REQUEST |
|
|
|
# ) |
|
|
|
|
|
|
|
# logger.info(f"[PlugNMeet Webhook] Processing event={event}") |
|
|
|
|
|
|
|
# # Route to appropriate handler |
|
|
|
# handler_map = { |
|
|
|
# 'room_finished': self._handle_room_finished, |
|
|
|
# 'participant_joined': self._handle_participant_joined, |
|
|
|
# 'participant_left': self._handle_participant_left, |
|
|
|
# 'end_recording': self._handle_end_recording, |
|
|
|
# } |
|
|
|
|
|
|
|
# handler = handler_map.get(event) |
|
|
|
# if not handler: |
|
|
|
# logger.info(f"[PlugNMeet Webhook] Unhandled event={event}, ignoring") |
|
|
|
# return Response({'status': 'ok', 'message': f'Event {event} ignored'}) |
|
|
|
|
|
|
|
# try: |
|
|
|
# result = handler(payload) |
|
|
|
# logger.info(f"[PlugNMeet Webhook] Event processed successfully - event={event}") |
|
|
|
# return Response({'status': 'ok', **result}) |
|
|
|
# except Exception as e: |
|
|
|
# logger.error(f"[PlugNMeet Webhook] Error processing event={event} - error={str(e)}", exc_info=True) |
|
|
|
# return Response( |
|
|
|
# {'status': 'error', 'message': str(e)}, |
|
|
|
# status=status.HTTP_500_INTERNAL_SERVER_ERROR |
|
|
|
# ) |
|
|
|
|
|
|
|
# # def _verify_webhook_signature(self, request) -> bool: |
|
|
|
# # """ |
|
|
|
# # Verify webhook signature using SHA256 HMAC. |
|
|
|
# # Expects Hash-Token header with SHA256 signature of request body. |
|
|
|
# # """ |
|
|
|
# # hash_token = request.headers.get('Hash-Token') |
|
|
|
# # if not hash_token: |
|
|
|
# # logger.warning(f"[PlugNMeet Webhook] Missing Hash-Token header") |
|
|
|
# # return False |
|
|
|
|
|
|
|
# # # Get API secret from settings |
|
|
|
# # api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None) |
|
|
|
# # if not api_secret: |
|
|
|
# # logger.error(f"[PlugNMeet Webhook] PLUGNMEET_API_SECRET not configured") |
|
|
|
# # raise ImproperlyConfigured("PLUGNMEET_API_SECRET is not configured") |
|
|
|
|
|
|
|
# # # Calculate expected signature |
|
|
|
# # body = request.body |
|
|
|
# # expected_signature = hmac.new( |
|
|
|
# # api_secret.encode('utf-8'), |
|
|
|
# # body, |
|
|
|
# # hashlib.sha256 |
|
|
|
# # ).hexdigest() |
|
|
|
|
|
|
|
# # # Compare signatures (constant time comparison) |
|
|
|
# # is_valid = hmac.compare_digest(hash_token, expected_signature) |
|
|
|
|
|
|
|
# # if not is_valid: |
|
|
|
# # logger.warning(f"[PlugNMeet Webhook] Signature mismatch - expected={expected_signature[:10]}... got={hash_token[:10]}...") |
|
|
|
|
|
|
|
# # return is_valid |
|
|
|
|
|
|
|
# def _verify_webhook_signature(self, request) -> bool: |
|
|
|
# """ |
|
|
|
# DEBUG VERSION: Prints details to find the mismatch. |
|
|
|
# """ |
|
|
|
# hash_token = request.headers.get('Hash-Token') |
|
|
|
# if not hash_token: |
|
|
|
# logger.error("[PlugNMeet Webhook] MISSING Hash-Token header") |
|
|
|
# return False |
|
|
|
|
|
|
|
# # Get API secret from settings |
|
|
|
# api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None) |
|
|
|
# if not api_secret: |
|
|
|
# logger.error("[PlugNMeet Webhook] PLUGNMEET_API_SECRET is missing in settings.py") |
|
|
|
# raise ImproperlyConfigured("PLUGNMEET_API_SECRET is not configured") |
|
|
|
|
|
|
|
# # DEBUG: Print the first/last few chars of the secret to ensure it's loaded correctly |
|
|
|
# # DO NOT log the whole secret in production! |
|
|
|
# logger.info(f"[DEBUG] Secret in Django: {api_secret[:4]}...{api_secret[-4:]}") |
|
|
|
|
|
|
|
# # Calculate expected signature |
|
|
|
# body = request.body |
|
|
|
|
|
|
|
# # DEBUG: Check if body is empty (common middleware issue) |
|
|
|
# if len(body) == 0: |
|
|
|
# logger.error("[DEBUG] Request Body is EMPTY! Middleware might have consumed it.") |
|
|
|
|
|
|
|
# expected_signature = hmac.new( |
|
|
|
# api_secret.encode('utf-8'), |
|
|
|
# body, |
|
|
|
# hashlib.sha256 |
|
|
|
# ).hexdigest() |
|
|
|
|
|
|
|
# # DEBUG: Compare them visually in logs |
|
|
|
# logger.info(f"[DEBUG] Received Token: {hash_token}") |
|
|
|
# logger.info(f"[DEBUG] Calculated: {expected_signature}") |
|
|
|
|
|
|
|
# is_valid = hmac.compare_digest(hash_token, expected_signature) |
|
|
|
|
|
|
|
# if not is_valid: |
|
|
|
# logger.error("[PlugNMeet Webhook] Signature MISMATCH") |
|
|
|
|
|
|
|
# return is_valid |
|
|
|
|
|
|
|
# def _handle_room_finished(self, payload: Dict[str, Any]) -> Dict[str, Any]: |
|
|
|
# """ |
|
|
|
# Handle room_finished event: Close the live session and all user sessions. |
|
|
|
|
|
|
|
# Payload structure: |
|
|
|
# { |
|
|
|
# "event": "room_finished", |
|
|
|
# "room": { |
|
|
|
# "sid": "room-123456", |
|
|
|
# "identity": "course-slug-20240101120000", |
|
|
|
# "name": "کلاس جبر", |
|
|
|
# "duration": 3600 |
|
|
|
# } |
|
|
|
# } |
|
|
|
# """ |
|
|
|
# room_data = payload.get('room', {}) |
|
|
|
# room_id = room_data.get('identity') |
|
|
|
|
|
|
|
# if not room_id: |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] room_finished: Missing room identity") |
|
|
|
# return {'message': 'Missing room identity'} |
|
|
|
|
|
|
|
# logger.info(f"[PlugNMeet Webhook] room_finished - room_id={room_id}") |
|
|
|
|
|
|
|
# try: |
|
|
|
# session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) |
|
|
|
# except CourseLiveSession.DoesNotExist: |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] room_finished: Session not found or already ended - room_id={room_id}") |
|
|
|
# return {'message': 'Session not found or already ended'} |
|
|
|
|
|
|
|
# # Close the session |
|
|
|
# now = timezone.now() |
|
|
|
# session.ended_at = now |
|
|
|
# session.save(update_fields=['ended_at', 'updated_at']) |
|
|
|
# logger.info(f"[PlugNMeet Webhook] Session closed - session_id={session.id} room_id={room_id}") |
|
|
|
|
|
|
|
# # Close all active user sessions |
|
|
|
# updated_count = LiveSessionUser.objects.filter( |
|
|
|
# session=session, |
|
|
|
# is_online=True, |
|
|
|
# exited_at__isnull=True |
|
|
|
# ).update( |
|
|
|
# is_online=False, |
|
|
|
# exited_at=now, |
|
|
|
# updated_at=now |
|
|
|
# ) |
|
|
|
|
|
|
|
# logger.info(f"[PlugNMeet Webhook] User sessions closed - session_id={session.id} count={updated_count}") |
|
|
|
|
|
|
|
# return { |
|
|
|
# 'message': 'Room finished', |
|
|
|
# 'session_id': session.id, |
|
|
|
# 'users_disconnected': updated_count |
|
|
|
# } |
|
|
|
|
|
|
|
# def _handle_participant_joined(self, payload: Dict[str, Any]) -> Dict[str, Any]: |
|
|
|
# """ |
|
|
|
# Handle participant_joined event: Create or update LiveSessionUser entry. |
|
|
|
|
|
|
|
# Payload structure: |
|
|
|
# { |
|
|
|
# "event": "participant_joined", |
|
|
|
# "room": { |
|
|
|
# "sid": "room-123456", |
|
|
|
# "identity": "course-slug-20240101120000" |
|
|
|
# }, |
|
|
|
# "participant": { |
|
|
|
# "sid": "participant-user-27", |
|
|
|
# "identity": "27", |
|
|
|
# "name": "دانشجو نمونه", |
|
|
|
# "metadata": "{\"is_admin\": false}", |
|
|
|
# "joinedAt": 1697497300 |
|
|
|
# } |
|
|
|
# } |
|
|
|
# """ |
|
|
|
# room_data = payload.get('room', {}) |
|
|
|
# participant_data = payload.get('participant', {}) |
|
|
|
|
|
|
|
# room_id = room_data.get('identity') |
|
|
|
# user_identity = participant_data.get('identity') |
|
|
|
# joined_at_timestamp = participant_data.get('joinedAt') |
|
|
|
|
|
|
|
# if not room_id or not user_identity: |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] participant_joined: Missing room_id or user_identity") |
|
|
|
# return {'message': 'Missing required fields'} |
|
|
|
|
|
|
|
# logger.info(f"[PlugNMeet Webhook] participant_joined - room_id={room_id} user_identity={user_identity}") |
|
|
|
|
|
|
|
# # Get session |
|
|
|
# try: |
|
|
|
# session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) |
|
|
|
# except CourseLiveSession.DoesNotExist: |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] participant_joined: Active session not found - room_id={room_id}") |
|
|
|
# return {'message': 'Active session not found'} |
|
|
|
|
|
|
|
# # Get user |
|
|
|
# try: |
|
|
|
# user = User.objects.get(id=int(user_identity)) |
|
|
|
# except (User.DoesNotExist, ValueError): |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] participant_joined: User not found - user_identity={user_identity}") |
|
|
|
# return {'message': 'User not found'} |
|
|
|
|
|
|
|
# # Determine user role |
|
|
|
# is_admin = user.can_manage_course(session.course) |
|
|
|
# role = 'moderator' if is_admin else 'participant' |
|
|
|
|
|
|
|
# # Parse joined_at timestamp |
|
|
|
# if joined_at_timestamp: |
|
|
|
# entered_at = timezone.datetime.fromtimestamp(joined_at_timestamp, tz=timezone.utc) |
|
|
|
# else: |
|
|
|
# entered_at = timezone.now() |
|
|
|
|
|
|
|
# # Check if user has an existing session entry that was marked as offline |
|
|
|
# # If so, reactivate it instead of creating a new one |
|
|
|
# existing_session = LiveSessionUser.objects.filter( |
|
|
|
# session=session, |
|
|
|
# user=user, |
|
|
|
# is_online=False |
|
|
|
# ).order_by('-entered_at').first() |
|
|
|
|
|
|
|
# if existing_session: |
|
|
|
# # Reactivate existing session |
|
|
|
# existing_session.is_online = True |
|
|
|
# existing_session.exited_at = None |
|
|
|
# existing_session.save(update_fields=['is_online', 'exited_at', 'updated_at']) |
|
|
|
# logger.info(f"[PlugNMeet Webhook] User rejoined (reactivated) - session_user_id={existing_session.id} user_id={user.id}") |
|
|
|
# return { |
|
|
|
# 'message': 'Participant rejoined', |
|
|
|
# 'session_user_id': existing_session.id, |
|
|
|
# 'created': False |
|
|
|
# } |
|
|
|
|
|
|
|
# # Create new LiveSessionUser entry |
|
|
|
# try: |
|
|
|
# session_user = LiveSessionUser.objects.create( |
|
|
|
# session=session, |
|
|
|
# user=user, |
|
|
|
# role=role, |
|
|
|
# entered_at=entered_at, |
|
|
|
# is_online=True, |
|
|
|
# ) |
|
|
|
# logger.info(f"[PlugNMeet Webhook] User joined - session_user_id={session_user.id} user_id={user.id} role={role}") |
|
|
|
# except Exception as e: |
|
|
|
# logger.error(f"[PlugNMeet Webhook] Failed to create session user - error={str(e)}") |
|
|
|
# return {'message': f'Failed to create session user: {str(e)}'} |
|
|
|
|
|
|
|
# return { |
|
|
|
# 'message': 'Participant joined', |
|
|
|
# 'session_user_id': session_user.id, |
|
|
|
# 'created': created |
|
|
|
# } |
|
|
|
|
|
|
|
# def _handle_participant_left(self, payload: Dict[str, Any]) -> Dict[str, Any]: |
|
|
|
# """ |
|
|
|
# Handle participant_left event: Mark LiveSessionUser as offline/exited. |
|
|
|
|
|
|
|
# Payload structure: |
|
|
|
# { |
|
|
|
# "event": "participant_left", |
|
|
|
# "room": { |
|
|
|
# "sid": "room-123456", |
|
|
|
# "identity": "course-slug-20240101120000" |
|
|
|
# }, |
|
|
|
# "participant": { |
|
|
|
# "sid": "participant-user-27", |
|
|
|
# "identity": "27", |
|
|
|
# "state": "DISCONNECTED", |
|
|
|
# "joinedAt": 1697497300, |
|
|
|
# "duration": 1800 |
|
|
|
# } |
|
|
|
# } |
|
|
|
# """ |
|
|
|
# room_data = payload.get('room', {}) |
|
|
|
# participant_data = payload.get('participant', {}) |
|
|
|
|
|
|
|
# room_id = room_data.get('identity') |
|
|
|
# user_identity = participant_data.get('identity') |
|
|
|
|
|
|
|
# if not room_id or not user_identity: |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] participant_left: Missing room_id or user_identity") |
|
|
|
# return {'message': 'Missing required fields'} |
|
|
|
|
|
|
|
# logger.info(f"[PlugNMeet Webhook] participant_left - room_id={room_id} user_identity={user_identity}") |
|
|
|
|
|
|
|
# # Get session |
|
|
|
# try: |
|
|
|
# session = CourseLiveSession.objects.get(room_id=room_id) |
|
|
|
# except CourseLiveSession.DoesNotExist: |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] participant_left: Session not found - room_id={room_id}") |
|
|
|
# return {'message': 'Session not found'} |
|
|
|
|
|
|
|
# # Get user |
|
|
|
# try: |
|
|
|
# user = User.objects.get(id=int(user_identity)) |
|
|
|
# except (User.DoesNotExist, ValueError): |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] participant_left: User not found - user_identity={user_identity}") |
|
|
|
# return {'message': 'User not found'} |
|
|
|
|
|
|
|
# # Update LiveSessionUser |
|
|
|
# now = timezone.now() |
|
|
|
# updated_count = LiveSessionUser.objects.filter( |
|
|
|
# session=session, |
|
|
|
# user=user, |
|
|
|
# is_online=True, |
|
|
|
# exited_at__isnull=True |
|
|
|
# ).update( |
|
|
|
# is_online=False, |
|
|
|
# exited_at=now, |
|
|
|
# updated_at=now |
|
|
|
# ) |
|
|
|
|
|
|
|
# if updated_count > 0: |
|
|
|
# logger.info(f"[PlugNMeet Webhook] User left - session_id={session.id} user_id={user.id}") |
|
|
|
# else: |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] participant_left: No active session found for user - session_id={session.id} user_id={user.id}") |
|
|
|
|
|
|
|
# return { |
|
|
|
# 'message': 'Participant left', |
|
|
|
# 'updated': updated_count > 0 |
|
|
|
# } |
|
|
|
|
|
|
|
# def _handle_end_recording(self, payload: Dict[str, Any]) -> Dict[str, Any]: |
|
|
|
# """ |
|
|
|
# Handle end_recording event: Download and save recording file. |
|
|
|
|
|
|
|
# Payload structure: |
|
|
|
# { |
|
|
|
# "event": "end_recording", |
|
|
|
# "room": { |
|
|
|
# "sid": "room-123456", |
|
|
|
# "identity": "course-slug-20240101120000" |
|
|
|
# }, |
|
|
|
# "recording_info": { |
|
|
|
# "recordingId": "rec-123456", |
|
|
|
# "roomId": "course-slug-20240101120000", |
|
|
|
# "recordingType": "COMPOSITE", |
|
|
|
# "fileName": "algebra-1402-20231016.mp4", |
|
|
|
# "duration": 3600, |
|
|
|
# "status": "FINISHED" |
|
|
|
# } |
|
|
|
# } |
|
|
|
# """ |
|
|
|
# from django.core.files.base import ContentFile |
|
|
|
# from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError |
|
|
|
# from apps.course.models import LiveSessionRecording |
|
|
|
# import os |
|
|
|
# import tempfile |
|
|
|
# import subprocess |
|
|
|
|
|
|
|
# room_data = payload.get('room', {}) |
|
|
|
# recording_info = payload.get('recording_info', {}) |
|
|
|
|
|
|
|
# room_id = room_data.get('identity') |
|
|
|
# recording_id = recording_info.get('recordingId') |
|
|
|
# file_name = recording_info.get('fileName', 'recording.mp4') |
|
|
|
# duration = recording_info.get('duration', 0) |
|
|
|
|
|
|
|
# if not room_id or not recording_id: |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] end_recording: Missing room_id or recording_id") |
|
|
|
# return {'message': 'Missing required fields'} |
|
|
|
|
|
|
|
# logger.info(f"[PlugNMeet Webhook] end_recording - room_id={room_id} recording_id={recording_id}") |
|
|
|
|
|
|
|
# # Get session |
|
|
|
# try: |
|
|
|
# session = CourseLiveSession.objects.get(room_id=room_id) |
|
|
|
# except CourseLiveSession.DoesNotExist: |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] end_recording: Session not found - room_id={room_id}") |
|
|
|
# return {'message': 'Session not found'} |
|
|
|
|
|
|
|
# try: |
|
|
|
# client = PlugNMeetClient() |
|
|
|
|
|
|
|
# # Step 1: Get recording info |
|
|
|
# logger.info(f"[PlugNMeet Webhook] Fetching recording info - recording_id={recording_id}") |
|
|
|
# recording_data = client.get_recording_info(recording_id) |
|
|
|
|
|
|
|
# if not recording_data.get('status'): |
|
|
|
# logger.error(f"[PlugNMeet Webhook] Failed to get recording info - recording_id={recording_id}") |
|
|
|
# return {'message': 'Failed to get recording info', 'error': recording_data.get('msg')} |
|
|
|
|
|
|
|
# # Step 2: Get download token |
|
|
|
# logger.info(f"[PlugNMeet Webhook] Getting download token - recording_id={recording_id}") |
|
|
|
# token_response = client.get_recording_download_token(recording_id) |
|
|
|
|
|
|
|
# if not token_response.get('status'): |
|
|
|
# logger.error(f"[PlugNMeet Webhook] Failed to get download token - recording_id={recording_id}") |
|
|
|
# return {'message': 'Failed to get download token', 'error': token_response.get('msg')} |
|
|
|
|
|
|
|
# download_token = token_response.get('token') |
|
|
|
# if not download_token: |
|
|
|
# logger.error(f"[PlugNMeet Webhook] No download token in response - recording_id={recording_id}") |
|
|
|
# return {'message': 'No download token received'} |
|
|
|
|
|
|
|
# # Step 3: Download file |
|
|
|
# download_path = f"/download/recording/{download_token}" |
|
|
|
|
|
|
|
# # Create temporary file |
|
|
|
# with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as tmp_file: |
|
|
|
# tmp_file_path = tmp_file.name |
|
|
|
|
|
|
|
# try: |
|
|
|
# logger.info(f"[PlugNMeet Webhook] Downloading recording file - recording_id={recording_id}") |
|
|
|
# client.download_file(download_path, tmp_file_path) |
|
|
|
|
|
|
|
# # Get file size |
|
|
|
# file_size = os.path.getsize(tmp_file_path) |
|
|
|
# logger.info(f"[PlugNMeet Webhook] File downloaded - size={file_size} bytes") |
|
|
|
|
|
|
|
# # Determine recording type (video or audio) |
|
|
|
# is_video = file_name.lower().endswith(('.mp4', '.webm', '.mkv')) |
|
|
|
# recording_type = 'video' if is_video else 'voice' |
|
|
|
|
|
|
|
# # Read file content |
|
|
|
# with open(tmp_file_path, 'rb') as f: |
|
|
|
# file_content = f.read() |
|
|
|
|
|
|
|
# # Create LiveSessionRecording entry |
|
|
|
# recording = LiveSessionRecording.objects.create( |
|
|
|
# session=session, |
|
|
|
# title=f"{session.subject} - Recording", |
|
|
|
# file_time=timezone.timedelta(seconds=duration) if duration > 0 else None, |
|
|
|
# recording_type=recording_type, |
|
|
|
# ) |
|
|
|
|
|
|
|
# # Save file to Django FileField |
|
|
|
# recording.file.save(file_name, ContentFile(file_content), save=True) |
|
|
|
# logger.info(f"[PlugNMeet Webhook] Recording saved - recording_id={recording.id} file={file_name}") |
|
|
|
|
|
|
|
# # Generate thumbnail for video recordings (if ffmpeg is available) |
|
|
|
# thumbnail_generated = False |
|
|
|
# if is_video and file_size > 0: |
|
|
|
# try: |
|
|
|
# thumbnail_generated = self._generate_video_thumbnail(tmp_file_path, recording) |
|
|
|
# if thumbnail_generated: |
|
|
|
# logger.info(f"[PlugNMeet Webhook] Thumbnail generated - recording_id={recording.id}") |
|
|
|
# except Exception as e: |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] Thumbnail generation skipped - error={str(e)}") |
|
|
|
|
|
|
|
# return { |
|
|
|
# 'message': 'Recording downloaded and saved successfully', |
|
|
|
# 'recording_id': recording.id, |
|
|
|
# 'file_name': file_name, |
|
|
|
# 'file_size': file_size, |
|
|
|
# 'thumbnail_generated': thumbnail_generated |
|
|
|
# } |
|
|
|
|
|
|
|
# finally: |
|
|
|
# # Clean up temporary file |
|
|
|
# if os.path.exists(tmp_file_path): |
|
|
|
# os.unlink(tmp_file_path) |
|
|
|
# logger.debug(f"[PlugNMeet Webhook] Temporary file deleted - path={tmp_file_path}") |
|
|
|
|
|
|
|
# except PlugNMeetError as e: |
|
|
|
# logger.error(f"[PlugNMeet Webhook] PlugNMeet API error - recording_id={recording_id} error={str(e)}") |
|
|
|
# return {'message': f'PlugNMeet API error: {str(e)}'} |
|
|
|
# except Exception as e: |
|
|
|
# logger.error(f"[PlugNMeet Webhook] Unexpected error - recording_id={recording_id} error={str(e)}", exc_info=True) |
|
|
|
# return {'message': f'Unexpected error: {str(e)}'} |
|
|
|
|
|
|
|
# def _generate_video_thumbnail(self, video_path: str, recording: 'LiveSessionRecording') -> bool: |
|
|
|
# """ |
|
|
|
# Generate thumbnail from video file using ffmpeg. |
|
|
|
|
|
|
|
# Args: |
|
|
|
# video_path: Path to the video file |
|
|
|
# recording: LiveSessionRecording instance |
|
|
|
|
|
|
|
# Returns: |
|
|
|
# True if thumbnail generated successfully, False otherwise |
|
|
|
# """ |
|
|
|
# from django.core.files.base import ContentFile |
|
|
|
# import subprocess |
|
|
|
# import tempfile |
|
|
|
# import os |
|
|
|
|
|
|
|
# try: |
|
|
|
# # Create temporary file for thumbnail |
|
|
|
# with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_thumb: |
|
|
|
# thumbnail_path = tmp_thumb.name |
|
|
|
|
|
|
|
# # Extract frame at 1 second using ffmpeg |
|
|
|
# # -ss 1: seek to 1 second |
|
|
|
# # -i: input file |
|
|
|
# # -frames:v 1: extract 1 frame |
|
|
|
# # -q:v 2: quality (2 is high quality) |
|
|
|
# cmd = [ |
|
|
|
# 'ffmpeg', |
|
|
|
# '-ss', '1', |
|
|
|
# '-i', video_path, |
|
|
|
# '-frames:v', '1', |
|
|
|
# '-q:v', '2', |
|
|
|
# '-vf', 'scale=640:-1', # scale to width 640, maintain aspect ratio |
|
|
|
# '-y', # overwrite output file |
|
|
|
# thumbnail_path |
|
|
|
# ] |
|
|
|
|
|
|
|
# result = subprocess.run( |
|
|
|
# cmd, |
|
|
|
# stdout=subprocess.PIPE, |
|
|
|
# stderr=subprocess.PIPE, |
|
|
|
# timeout=30 |
|
|
|
# ) |
|
|
|
|
|
|
|
# if result.returncode != 0: |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] ffmpeg failed - return_code={result.returncode}") |
|
|
|
# return False |
|
|
|
|
|
|
|
# # Check if thumbnail was created |
|
|
|
# if not os.path.exists(thumbnail_path) or os.path.getsize(thumbnail_path) == 0: |
|
|
|
# logger.warning(f"[PlugNMeet Webhook] Thumbnail file not created or empty") |
|
|
|
# return False |
|
|
|
|
|
|
|
# # Save thumbnail to recording |
|
|
|
# with open(thumbnail_path, 'rb') as f: |
|
|
|
# thumbnail_content = f.read() |
|
|
|
|
|
|
|
# thumbnail_filename = f"thumb_{recording.id}.jpg" |
|
|
|
# recording.thumbnail.save(thumbnail_filename, ContentFile(thumbnail_content), save=True) |
|
|
|
|
|
|
|
# # Clean up temporary file |
|
|
|
# os.unlink(thumbnail_path) |
|
|
|
|
|
|
|
# return True |
|
|
|
|
|
|
|
# except subprocess.TimeoutExpired: |
|
|
|
# logger.error(f"[PlugNMeet Webhook] ffmpeg timeout during thumbnail generation") |
|
|
|
# return False |
|
|
|
# except FileNotFoundError: |
|
|
|
# logger.error(f"[PlugNMeet Webhook] ffmpeg not found - please install ffmpeg") |
|
|
|
# return False |
|
|
|
# except Exception as e: |
|
|
|
# logger.error(f"[PlugNMeet Webhook] Thumbnail generation error - error={str(e)}", exc_info=True) |
|
|
|
# return False |
|
|
|
# finally: |
|
|
|
# # Clean up temporary file if it still exists |
|
|
|
# if 'thumbnail_path' in locals() and os.path.exists(thumbnail_path): |
|
|
|
# try: |
|
|
|
# os.unlink(thumbnail_path) |
|
|
|
# except: |
|
|
|
# pass |
|
|
|
|
|
|
|
from rest_framework.parsers import BaseParser |
|
|
|
import json |
|
|
|
import hmac |
|
|
|
import hashlib |
|
|
|
import logging |
|
|
|
import os |
|
|
|
import tempfile |
|
|
|
import subprocess |
|
|
|
from typing import Dict, Any |
|
|
|
|
|
|
|
from django.conf import settings |
|
|
|
from django.core.exceptions import ImproperlyConfigured |
|
|
|
from django.utils import timezone |
|
|
|
from django.utils.decorators import method_decorator |
|
|
|
from django.views.decorators.csrf import csrf_exempt |
|
|
|
from django.core.files.base import ContentFile |
|
|
|
|
|
|
|
from rest_framework import status |
|
|
|
from rest_framework.views import APIView |
|
|
|
from rest_framework.response import Response |
|
|
|
from rest_framework.permissions import AllowAny |
|
|
|
from rest_framework import status |
|
|
|
from rest_framework.parsers import BaseParser |
|
|
|
from drf_yasg.utils import swagger_auto_schema |
|
|
|
from drf_yasg import openapi |
|
|
|
|
|
|
|
from apps.course.models import CourseLiveSession, LiveSessionUser, Course, Participant, LiveSessionRecording |
|
|
|
from apps.account.models import User |
|
|
|
from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError |
|
|
|
from utils.exceptions import AppAPIException |
|
|
|
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
# 1. Custom Parser to preserve the raw body for HMAC verification |
|
|
|
class RawJSONParser(BaseParser): |
|
|
|
""" |
|
|
|
Parser that preserves the raw body bytes for HMAC signature verification. |
|
|
|
""" |
|
|
|
media_type = 'application/json' |
|
|
|
def parse(self, stream, media_type=None, parser_context=None): |
|
|
|
# We return the raw bytes so we can verify the signature first |
|
|
|
return stream.read() |
|
|
|
|
|
|
|
@method_decorator(csrf_exempt, name='dispatch') |
|
|
|
class PlugNMeetWebhookAPIView(APIView): |
|
|
|
# 2. CRITICAL: Disable Global Authentication logic for this specific endpoint |
|
|
|
""" |
|
|
|
Webhook endpoint to receive and process events from the PlugNMeet server. |
|
|
|
|
|
|
|
Handles: |
|
|
|
- room_finished: Closes the live session record. |
|
|
|
- participant_joined: Tracks student entry. |
|
|
|
- participant_left: Tracks student exit. |
|
|
|
- end_recording: Downloads and saves session recordings. |
|
|
|
""" |
|
|
|
authentication_classes = [] |
|
|
|
permission_classes = [AllowAny] |
|
|
|
|
|
|
|
# 3. Use the raw parser to prevent DRF from consuming the stream prematurely |
|
|
|
parser_classes = [RawJSONParser] |
|
|
|
|
|
|
|
@swagger_auto_schema( |
|
|
|
operation_description="Handle webhook events from PlugNMeet server for live sessions", |
|
|
|
tags=["Imam-Javad - Course"], |
|
|
|
responses={ |
|
|
|
200: openapi.Response(description="Webhook processed successfully"), |
|
|
|
403: openapi.Response(description="Invalid signature"), |
|
|
|
400: openapi.Response(description="Invalid payload") |
|
|
|
} |
|
|
|
) |
|
|
|
def post(self, request, *args, **kwargs): |
|
|
|
logger.info("⚡ [PlugNMeet Webhook] Request received") |
|
|
|
|
|
|
|
logger.info("⚡ [PlugNMeet Webhook] Request Hit the View!") |
|
|
|
|
|
|
|
# --- 1. ROBUST HEADER EXTRACTION --- |
|
|
|
# Try standard way |
|
|
|
hash_token = request.headers.get('Hash-Token') |
|
|
|
# 1. Extract Signature |
|
|
|
hash_token = request.headers.get('Hash-Token') or request.META.get('HTTP_HASH_TOKEN') |
|
|
|
|
|
|
|
# Try Django internal way (Fall back to META) |
|
|
|
# Django converts "Hash-Token" -> "HTTP_HASH_TOKEN" |
|
|
|
if not hash_token: |
|
|
|
hash_token = request.META.get('HTTP_HASH_TOKEN') |
|
|
|
|
|
|
|
# --- 2. DEBUG IF MISSING --- |
|
|
|
if not hash_token: |
|
|
|
logger.error("❌ [PlugNMeet Webhook] MISSING Hash-Token.") |
|
|
|
|
|
|
|
# Print ALL headers to see if it's there under a different name |
|
|
|
# This is critical for debugging Nginx stripping issues |
|
|
|
logger.info("--- DUMPING ALL HEADERS ---") |
|
|
|
for key, value in request.headers.items(): |
|
|
|
logger.info(f"Header: {key} = {value}") |
|
|
|
|
|
|
|
# Also dump META keys starting with HTTP |
|
|
|
meta_headers = {k: v for k, v in request.META.items() if k.startswith('HTTP_')} |
|
|
|
logger.info(f"META Headers: {meta_headers}") |
|
|
|
logger.info("-----------------------------") |
|
|
|
|
|
|
|
logger.error("❌ [PlugNMeet Webhook] Missing Hash-Token header") |
|
|
|
return Response({'message': 'Missing Hash-Token header'}, status=403) |
|
|
|
|
|
|
|
# # --- SIGNATURE VERIFICATION --- |
|
|
|
# hash_token = request.headers.get('Hash-Token') |
|
|
|
# if not hash_token: |
|
|
|
# logger.error("❌ [PlugNMeet Webhook] Missing Hash-Token") |
|
|
|
# return Response({'message': 'Missing Hash-Token'}, status=403) |
|
|
|
|
|
|
|
api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None) |
|
|
|
|
|
|
|
# We use request.data because our RawJSONParser returned the bytes into it |
|
|
|
body_bytes = request.data |
|
|
|
|
|
|
|
# Debug Logs (Remove in production) |
|
|
|
logger.info(f"🔍 [DEBUG] Secret: {api_secret[:5]}...") |
|
|
|
logger.info(f"🔍 [DEBUG] Body Bytes Len: {len(body_bytes)}") |
|
|
|
|
|
|
|
expected_signature = hmac.new( |
|
|
|
api_secret.encode('utf-8'), |
|
|
|
body_bytes, |
|
|
|
hashlib.sha256 |
|
|
|
).hexdigest() |
|
|
|
|
|
|
|
if not hmac.compare_digest(hash_token, expected_signature): |
|
|
|
logger.error(f"❌ [PlugNMeet Webhook] Mismatch! \nExpected: {expected_signature}\nReceived: {hash_token}") |
|
|
|
# 2. Verify Signature |
|
|
|
if not self._verify_webhook_signature(request, hash_token): |
|
|
|
return Response({'message': 'Invalid webhook signature'}, status=403) |
|
|
|
|
|
|
|
# --- PARSING --- |
|
|
|
# 3. Parse Payload |
|
|
|
try: |
|
|
|
# Now we manually parse the JSON since we used a RawParser |
|
|
|
body_bytes = request.data # RawJSONParser puts bytes here |
|
|
|
payload = json.loads(body_bytes.decode('utf-8')) |
|
|
|
except Exception as e: |
|
|
|
logger.error(f"❌ [PlugNMeet Webhook] JSON Error: {e}") |
|
|
|
logger.error(f"❌ [PlugNMeet Webhook] Parsing Error: {e}") |
|
|
|
return Response({'message': 'Invalid JSON'}, status=400) |
|
|
|
|
|
|
|
# --- EVENT ROUTING --- |
|
|
|
event = payload.get('event') |
|
|
|
logger.info(f"✅ [PlugNMeet Webhook] Processing Event: {event}") |
|
|
|
logger.info(f"✅ [PlugNMeet Webhook] Event: {event}") |
|
|
|
|
|
|
|
# 4. Route Event |
|
|
|
handler_map = { |
|
|
|
'room_finished': self._handle_room_finished, |
|
|
|
'participant_joined': self._handle_participant_joined, |
|
|
|
@ -725,18 +96,184 @@ class PlugNMeetWebhookAPIView(APIView): |
|
|
|
|
|
|
|
handler = handler_map.get(event) |
|
|
|
if not handler: |
|
|
|
logger.info(f"ℹ️ [PlugNMeet Webhook] Event {event} ignored") |
|
|
|
return Response({'status': 'ok', 'message': f'Event {event} ignored'}) |
|
|
|
|
|
|
|
try: |
|
|
|
result = handler(payload) |
|
|
|
return Response({'status': 'ok', **result}) |
|
|
|
except Exception as e: |
|
|
|
logger.error(f"❌ [PlugNMeet Webhook] Logic Error: {e}", exc_info=True) |
|
|
|
logger.error(f"❌ [PlugNMeet Webhook] Error in {event}: {e}", exc_info=True) |
|
|
|
return Response({'status': 'error', 'message': str(e)}, status=500) |
|
|
|
|
|
|
|
# ... (Keep your existing _handle methods exactly as they were) ... |
|
|
|
# Copy _handle_room_finished, _handle_participant_joined, etc. here |
|
|
|
|
|
|
|
def _handle_room_finished(self, payload): |
|
|
|
# ... your existing code ... |
|
|
|
return {'status': 'ok'} # Placeholder |
|
|
|
def _verify_webhook_signature(self, request, hash_token: str) -> bool: |
|
|
|
api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None) |
|
|
|
if not api_secret: |
|
|
|
logger.error("❌ [PlugNMeet Webhook] PLUGNMEET_API_SECRET not configured") |
|
|
|
return False |
|
|
|
|
|
|
|
body_bytes = request.data |
|
|
|
expected_signature = hmac.new( |
|
|
|
api_secret.encode('utf-8'), |
|
|
|
body_bytes, |
|
|
|
hashlib.sha256 |
|
|
|
).hexdigest() |
|
|
|
|
|
|
|
if not hmac.compare_digest(hash_token, expected_signature): |
|
|
|
logger.error(f"❌ [PlugNMeet Webhook] Signature mismatch! \nReceived: {hash_token[:10]}...\nExpected: {expected_signature[:10]}...") |
|
|
|
return False |
|
|
|
return True |
|
|
|
|
|
|
|
def _handle_room_finished(self, payload: Dict[str, Any]) -> Dict[str, Any]: |
|
|
|
room_data = payload.get('room', {}) |
|
|
|
room_id = room_data.get('identity') |
|
|
|
|
|
|
|
if not room_id: |
|
|
|
return {'message': 'Missing room identity'} |
|
|
|
|
|
|
|
try: |
|
|
|
session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) |
|
|
|
now = timezone.now() |
|
|
|
session.ended_at = now |
|
|
|
session.save(update_fields=['ended_at', 'updated_at']) |
|
|
|
|
|
|
|
# Close active user sessions |
|
|
|
updated_count = LiveSessionUser.objects.filter( |
|
|
|
session=session, |
|
|
|
is_online=True, |
|
|
|
exited_at__isnull=True |
|
|
|
).update(is_online=False, exited_at=now, updated_at=now) |
|
|
|
|
|
|
|
logger.info(f"🏁 [PlugNMeet Webhook] Session {session.id} ended. Users disconnected: {updated_count}") |
|
|
|
return {'session_id': session.id, 'closed_users': updated_count} |
|
|
|
except CourseLiveSession.DoesNotExist: |
|
|
|
return {'message': 'No active session found for this room'} |
|
|
|
|
|
|
|
def _handle_participant_joined(self, payload: Dict[str, Any]) -> Dict[str, Any]: |
|
|
|
room_data = payload.get('room', {}) |
|
|
|
participant_data = payload.get('participant', {}) |
|
|
|
room_id = room_data.get('identity') |
|
|
|
user_id = participant_data.get('identity') |
|
|
|
|
|
|
|
if not room_id or not user_id: |
|
|
|
return {'message': 'Missing required metadata'} |
|
|
|
|
|
|
|
try: |
|
|
|
session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) |
|
|
|
user = User.objects.get(id=int(user_id)) |
|
|
|
|
|
|
|
role = 'moderator' if user.can_manage_course(session.course) else 'participant' |
|
|
|
|
|
|
|
session_user, created = LiveSessionUser.objects.update_or_create( |
|
|
|
session=session, |
|
|
|
user=user, |
|
|
|
defaults={ |
|
|
|
'role': role, |
|
|
|
'is_online': True, |
|
|
|
'exited_at': None, |
|
|
|
'entered_at': timezone.now() |
|
|
|
} |
|
|
|
) |
|
|
|
logger.info(f"👤 [PlugNMeet Webhook] User {user.id} joined session {session.id}") |
|
|
|
return {'session_user_id': session_user.id, 'created': created} |
|
|
|
except Exception as e: |
|
|
|
return {'error': str(e)} |
|
|
|
|
|
|
|
def _handle_participant_left(self, payload: Dict[str, Any]) -> Dict[str, Any]: |
|
|
|
room_data = payload.get('room', {}) |
|
|
|
participant_data = payload.get('participant', {}) |
|
|
|
room_id = room_data.get('identity') |
|
|
|
user_id = participant_data.get('identity') |
|
|
|
|
|
|
|
try: |
|
|
|
session = CourseLiveSession.objects.get(room_id=room_id) |
|
|
|
user = User.objects.get(id=int(user_id)) |
|
|
|
|
|
|
|
updated = LiveSessionUser.objects.filter( |
|
|
|
session=session, user=user, is_online=True |
|
|
|
).update(is_online=False, exited_at=timezone.now(), updated_at=timezone.now()) |
|
|
|
|
|
|
|
logger.info(f"🚪 [PlugNMeet Webhook] User {user.id} left session {session.id}") |
|
|
|
return {'updated': bool(updated)} |
|
|
|
except Exception as e: |
|
|
|
return {'error': str(e)} |
|
|
|
|
|
|
|
def _handle_end_recording(self, payload: Dict[str, Any]) -> Dict[str, Any]: |
|
|
|
room_data = payload.get('room', {}) |
|
|
|
recording_info = payload.get('recording_info', {}) |
|
|
|
|
|
|
|
room_id = room_data.get('identity') |
|
|
|
recording_id = recording_info.get('recordingId') |
|
|
|
file_name = recording_info.get('fileName', 'recording.mp4') |
|
|
|
duration = recording_info.get('duration', 0) |
|
|
|
|
|
|
|
if not room_id or not recording_id: |
|
|
|
return {'message': 'Missing recording metadata'} |
|
|
|
|
|
|
|
try: |
|
|
|
session = CourseLiveSession.objects.get(room_id=room_id) |
|
|
|
client = PlugNMeetClient() |
|
|
|
|
|
|
|
# 1. Fetch download token |
|
|
|
token_response = client.get_recording_download_token(recording_id) |
|
|
|
if not token_response.get('status'): |
|
|
|
return {'error': 'Failed to get download token'} |
|
|
|
|
|
|
|
download_token = token_response.get('token') |
|
|
|
download_path = f"/download/recording/{download_token}" |
|
|
|
|
|
|
|
# 2. Download to temporary file |
|
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as tmp_file: |
|
|
|
tmp_file_path = tmp_file.name |
|
|
|
|
|
|
|
try: |
|
|
|
logger.info(f"📥 [PlugNMeet Webhook] Downloading recording {recording_id}...") |
|
|
|
client.download_file(download_path, tmp_file_path) |
|
|
|
|
|
|
|
# 3. Save to Database |
|
|
|
with open(tmp_file_path, 'rb') as f: |
|
|
|
content = f.read() |
|
|
|
|
|
|
|
recording = LiveSessionRecording.objects.create( |
|
|
|
session=session, |
|
|
|
title=f"{session.subject} - Recording", |
|
|
|
file_time=timezone.timedelta(seconds=duration) if duration > 0 else None, |
|
|
|
recording_type='video' if file_name.lower().endswith('.mp4') else 'voice' |
|
|
|
) |
|
|
|
recording.file.save(file_name, ContentFile(content), save=True) |
|
|
|
|
|
|
|
# 4. Generate thumbnail (Optional) |
|
|
|
self._generate_video_thumbnail(tmp_file_path, recording) |
|
|
|
|
|
|
|
logger.info(f"💾 [PlugNMeet Webhook] Recording saved successfully: {recording.id}") |
|
|
|
return {'recording_id': recording.id, 'file': file_name} |
|
|
|
|
|
|
|
finally: |
|
|
|
if os.path.exists(tmp_file_path): |
|
|
|
os.unlink(tmp_file_path) |
|
|
|
|
|
|
|
except Exception as e: |
|
|
|
logger.error(f"❌ [PlugNMeet Webhook] End Recording Error: {e}", exc_info=True) |
|
|
|
return {'error': str(e)} |
|
|
|
|
|
|
|
def _generate_video_thumbnail(self, video_path: str, recording: LiveSessionRecording) -> bool: |
|
|
|
try: |
|
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_thumb: |
|
|
|
thumbnail_path = tmp_thumb.name |
|
|
|
|
|
|
|
cmd = [ |
|
|
|
'ffmpeg', '-ss', '1', '-i', video_path, '-frames:v', '1', |
|
|
|
'-q:v', '2', '-vf', 'scale=640:-1', '-y', thumbnail_path |
|
|
|
] |
|
|
|
|
|
|
|
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30) |
|
|
|
|
|
|
|
if result.returncode == 0 and os.path.exists(thumbnail_path) and os.path.getsize(thumbnail_path) > 0: |
|
|
|
with open(thumbnail_path, 'rb') as f: |
|
|
|
recording.thumbnail.save(f"thumb_{recording.id}.jpg", ContentFile(f.read()), save=True) |
|
|
|
os.unlink(thumbnail_path) |
|
|
|
return True |
|
|
|
return False |
|
|
|
except Exception as e: |
|
|
|
logger.warning(f"⚠️ [PlugNMeet Webhook] Thumbnail failed: {e}") |
|
|
|
return False |