From 8c364d3eb8daf3c2e76592f6f8af30b5645bc34c Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Thu, 4 Sep 2025 16:00:01 -0500 Subject: [PATCH] Fix DVR Fixed bug where connection wouldn't release --- apps/channels/api_views.py | 82 +++++++++++++++++++++++++++++---- frontend/src/api.js | 8 ++-- frontend/src/pages/DVR.jsx | 16 +++++-- frontend/src/store/channels.jsx | 22 +++++++++ 4 files changed, 112 insertions(+), 16 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 1336a918..d94f30d5 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -1765,21 +1765,87 @@ class RecordingViewSet(viewsets.ModelViewSet): return response def destroy(self, request, *args, **kwargs): - """Delete the Recording and remove the associated file from disk if present.""" + """Delete the Recording and ensure any active DVR client connection is closed. + + Also removes the associated file(s) from disk if present. + """ instance = self.get_object() + + # Attempt to close the DVR client connection for this channel if active + try: + channel_uuid = str(instance.channel.uuid) + # Lazy imports to avoid module overhead if proxy isn't used + from core.utils import RedisClient + from apps.proxy.ts_proxy.redis_keys import RedisKeys + from apps.proxy.ts_proxy.services.channel_service import ChannelService + + r = RedisClient.get_client() + if r: + client_set_key = RedisKeys.clients(channel_uuid) + client_ids = r.smembers(client_set_key) or [] + stopped = 0 + for raw_id in client_ids: + try: + cid = raw_id.decode("utf-8") if isinstance(raw_id, (bytes, bytearray)) else str(raw_id) + meta_key = RedisKeys.client_metadata(channel_uuid, cid) + ua = r.hget(meta_key, "user_agent") + ua_s = ua.decode("utf-8") if isinstance(ua, (bytes, bytearray)) else (ua or "") + # Identify DVR recording client by its user agent + if ua_s and "Dispatcharr-DVR" in ua_s: + try: + ChannelService.stop_client(channel_uuid, cid) + stopped += 1 + except Exception as inner_e: + logger.debug(f"Failed to stop DVR client {cid} for channel {channel_uuid}: {inner_e}") + except Exception as inner: + logger.debug(f"Error while checking client metadata: {inner}") + if stopped: + logger.info(f"Stopped {stopped} DVR client(s) for channel {channel_uuid} due to recording cancellation") + # If no clients remain after stopping DVR clients, proactively stop the channel + try: + remaining = r.scard(client_set_key) or 0 + except Exception: + remaining = 0 + if remaining == 0: + try: + ChannelService.stop_channel(channel_uuid) + logger.info(f"Stopped channel {channel_uuid} (no clients remain)") + except Exception as sc_e: + logger.debug(f"Unable to stop channel {channel_uuid}: {sc_e}") + except Exception as e: + logger.debug(f"Unable to stop DVR clients for cancelled recording: {e}") + + # Capture paths before deletion cp = instance.custom_properties or {} file_path = cp.get("file_path") - # Perform DB delete first, then try to remove file + temp_ts_path = cp.get("_temp_file_path") + + # Perform DB delete first, then try to remove files response = super().destroy(request, *args, **kwargs) + + # Notify frontends to refresh recordings + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed"}) + except Exception: + pass + library_dir = '/app/data' allowed_roots = ['/data/', library_dir.rstrip('/') + '/'] - if file_path and isinstance(file_path, str) and any(file_path.startswith(root) for root in allowed_roots): + + def _safe_remove(path: str): + if not path or not isinstance(path, str): + return try: - if os.path.exists(file_path): - os.remove(file_path) - logger.info(f"Deleted recording file: {file_path}") - except Exception as e: - logger.warning(f"Failed to delete recording file {file_path}: {e}") + if any(path.startswith(root) for root in allowed_roots) and os.path.exists(path): + os.remove(path) + logger.info(f"Deleted recording artifact: {path}") + except Exception as ex: + logger.warning(f"Failed to delete recording artifact {path}: {ex}") + + _safe_remove(file_path) + _safe_remove(temp_ts_path) + return response diff --git a/frontend/src/api.js b/frontend/src/api.js index 348e4b01..71b2d692 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1658,11 +1658,9 @@ export default class API { static async deleteRecording(id) { try { - await request(`${host}/api/channels/recordings/${id}/`, { - method: 'DELETE', - }); - - useChannelsStore.getState().fetchRecordings(); + await request(`${host}/api/channels/recordings/${id}/`, { method: 'DELETE' }); + // Optimistically remove locally for instant UI update + try { useChannelsStore.getState().removeRecording(id); } catch {} } catch (e) { errorNotification(`Failed to delete recording ${id}`, e); } diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index 3dc7663f..6a9ac90e 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -298,7 +298,13 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => { const channel = channels?.[recording.channel]; const deleteRecording = (id) => { - API.deleteRecording(id); + // Optimistically remove immediately from UI + try { useChannelsStore.getState().removeRecording(id); } catch {} + // Fire-and-forget server delete; websocket will keep others in sync + API.deleteRecording(id).catch(() => { + // On failure, fallback to refetch to restore state + try { useChannelsStore.getState().fetchRecordings(); } catch {} + }); }; const customProps = recording.custom_properties || {}; @@ -364,8 +370,11 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => { const [busy, setBusy] = React.useState(false); const handleCancelClick = (e) => { e.stopPropagation(); - if (isSeriesGroup) setCancelOpen(true); - else deleteRecording(recording.id); + if (isSeriesGroup) { + setCancelOpen(true); + } else { + deleteRecording(recording.id); + } }; const seriesInfo = React.useMemo(() => { @@ -441,6 +450,7 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => { e.stopPropagation()} onClick={handleCancelClick} > diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 451e74a6..97e42f06 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -408,6 +408,28 @@ const useChannelsStore = create((set, get) => ({ } }, + // Optimistically remove a single recording from the local store + removeRecording: (id) => + set((state) => { + const target = String(id); + const current = state.recordings; + if (Array.isArray(current)) { + return { + recordings: current.filter((r) => String(r?.id) !== target), + }; + } + if (current && typeof current === 'object') { + const next = { ...current }; + for (const k of Object.keys(next)) { + try { + if (String(next[k]?.id) === target) delete next[k]; + } catch {} + } + return { recordings: next }; + } + return {}; + }), + // Add helper methods for validation canEditChannelGroup: (groupIdOrGroup) => { const groupId =