Stop a recording early while retaining the partial content for playback.
(self, request, pk=None)
| 3508 | |
| 3509 | @action(detail=True, methods=["post"], url_path="stop") |
| 3510 | def stop(self, request, pk=None): |
| 3511 | """Stop a recording early while retaining the partial content for playback.""" |
| 3512 | instance = self.get_object() |
| 3513 | |
| 3514 | cp = instance.custom_properties or {} |
| 3515 | current_status = cp.get("status", "") |
| 3516 | |
| 3517 | # Reject stop on recordings that are already in a terminal state. |
| 3518 | # Without this guard, stop() would overwrite "completed" or |
| 3519 | # "interrupted" with "stopped", losing the original outcome. |
| 3520 | terminal = {"completed", "interrupted", "failed"} |
| 3521 | if current_status in terminal: |
| 3522 | return Response( |
| 3523 | {"success": False, "error": f"Recording is already {current_status}"}, |
| 3524 | status=status.HTTP_409_CONFLICT, |
| 3525 | ) |
| 3526 | |
| 3527 | # Mark as stopped in the DB first so run_recording detects it. |
| 3528 | # This is the only operation that MUST be synchronous — run_recording reads |
| 3529 | # the status field to decide whether the stream disconnection was deliberate. |
| 3530 | cp["status"] = "stopped" |
| 3531 | cp["stopped_at"] = str(timezone.now()) |
| 3532 | instance.custom_properties = cp |
| 3533 | instance.save(update_fields=["custom_properties"]) |
| 3534 | |
| 3535 | # send_websocket_update offloads async_to_sync to a real OS thread when gevent is active. |
| 3536 | channel_uuid = str(instance.channel.uuid) |
| 3537 | recording_id = instance.id |
| 3538 | task_id = instance.task_id |
| 3539 | channel_name = instance.channel.name |
| 3540 | |
| 3541 | try: |
| 3542 | from core.utils import send_websocket_update |
| 3543 | send_websocket_update('updates', 'update', { |
| 3544 | "success": True, |
| 3545 | "type": "recording_stopped", |
| 3546 | "channel": channel_name, |
| 3547 | }) |
| 3548 | except Exception: |
| 3549 | pass |
| 3550 | |
| 3551 | # DVR client teardown and task revocation are deferred to a daemon thread |
| 3552 | # because they have occasional slow paths (Redis timeouts, Celery control |
| 3553 | # broadcasts) that would otherwise add 5-15 s to the HTTP response time. |
| 3554 | def _background_stop(): |
| 3555 | try: |
| 3556 | stopped = _stop_dvr_clients(channel_uuid, recording_id=recording_id) |
| 3557 | if stopped: |
| 3558 | logger.info( |
| 3559 | f"Stopped {stopped} DVR client(s) for channel {channel_uuid} (recording stopped early)" |
| 3560 | ) |
| 3561 | except Exception as e: |
| 3562 | logger.debug(f"Unable to stop DVR clients for stopped recording: {e}") |
| 3563 | |
| 3564 | try: |
| 3565 | from apps.channels.signals import revoke_task |
| 3566 | revoke_task(task_id) |
| 3567 | except Exception as e: |
nothing calls this directly
no test coverage detected