Fixed bug where connection wouldn't release
This commit is contained in:
Dispatcharr 2025-09-04 16:00:01 -05:00
parent 7401b4c8d3
commit 8c364d3eb8
4 changed files with 112 additions and 16 deletions

View file

@ -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

View file

@ -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);
}

View file

@ -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 }) => {
<ActionIcon
variant="transparent"
color="red.9"
onMouseDown={(e) => e.stopPropagation()}
onClick={handleCancelClick}
>
<SquareX size="20" />

View file

@ -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 =