mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Merge pull request #1023 from CodeBormen/fix/940-Duplicate-Recordings-With-Corruption
fix(dvr): fix duplicate recording execution and add manual stop
This commit is contained in:
commit
37fa6aac20
38 changed files with 5872 additions and 856 deletions
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -11,6 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
- New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 2 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)".
|
||||
- Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012)
|
||||
- DVR enhancements — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- **Stop Recording**: A new Stop button (distinct from Cancel) cleanly ends an in-progress recording early and keeps the partial file available for playback. The API returns immediately; stream teardown and task revocation happen in a background thread to prevent 504 timeouts. When multiple recordings run simultaneously, stopping one only terminates that recording's proxy client by ID, leaving all others unaffected. (Closes #454)
|
||||
- **Extend Recording**: In-progress recordings can be extended by 15, 30, or 60 minutes without interrupting the stream.
|
||||
- **Inline metadata editing**: Title and description can now be edited directly in the recording details modal.
|
||||
- **Refresh artwork button**: Manually re-run poster resolution on demand from the recording card.
|
||||
- **Multi-source poster resolution**: Added pipeline querying EPG, VOD, TMDB, OMDb, TVMaze, and iTunes for richer recording artwork.
|
||||
- **Series rules for currently-airing episodes**: Series rules now capture currently-airing episodes in addition to future scheduled ones. (Closes #473)
|
||||
- **Search and filter controls**: Added search and filter controls to the recordings list.
|
||||
- **Stream generator throttling**: Cached the `ProxyServer` singleton reference per client and throttled Redis resource checks (1 s) and non-owner health checks (2 s), eliminating 3+ Redis round-trips per stream loop iteration.
|
||||
- **Automatic crash recovery on worker restart**: A `worker_ready` Celery signal now fires `recover_recordings_on_startup` automatically when the worker starts, so recordings stuck in "recording" status are recovered without manual intervention.
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
@ -36,6 +46,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **No recovery from expired ownership**: `get_channel_owner()` called `redis.get()` twice inside a lambda (TOCTOU race — key could expire between calls); `extend_ownership()` silently returned `False` on expiry with no re-acquisition; and the non-owner cleanup path unconditionally killed streams even when the worker held the `stream_manager`. Fixed with a single `GET` in `get_channel_owner()`, re-acquisition via atomic `SET NX EX` in `extend_ownership()`, and a re-acquisition attempt with client-aware cleanup deferral in the cleanup thread.
|
||||
- `get_instance()` deadlock: if `ProxyServer()` raised an exception during singleton construction, `_instance` was left permanently as the `_INITIALIZING` sentinel, causing all subsequent `get_instance()` callers to spin in an infinite `gevent.sleep()` loop. Construction is now wrapped in `try/except`; on failure `_instance` resets to `None` so the next call can retry.
|
||||
- Non-atomic ownership acquisition in `try_acquire_ownership()`: replaced the separate `setnx()` + `expire()` calls with a single atomic `SET NX EX`, eliminating the race window where a process crash between the two calls could leave an ownership key with no TTL (permanent ownership lock).
|
||||
- DVR bug fixes — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- **Duplicate recording execution**: `run_recording.apply_async(countdown=...)` exceeded Redis' default `visibility_timeout` (3600 s) for recordings scheduled more than one hour out, causing Redis to redeliver the task to multiple workers simultaneously and producing corrupted output files. Replaced `apply_async` with `ClockedSchedule` + `PeriodicTask` for database-backed one-shot scheduling that survives restarts and upgrades without the redelivery race. `run_recording` also now exits immediately if the recording is already in progress, completed, or stopped. `revoke_task()` cleans up both the `PeriodicTask` and its orphaned `ClockedSchedule` on execution. (Fixes #940, #641)
|
||||
- **Stream reconnection resilience**: Recordings now survive transient network drops with automatic reconnection retrying up to 5 times and appending to the existing file. DB operations use exponential-backoff retry for transient database errors throughout the recording lifecycle.
|
||||
- **Crash recovery pipeline**: On worker restart, recordings stuck in "recording" status have their segments concatenated and remuxed. Remux sanity checks reject MKV output that is less than 50% the size of a previous MKV (duplicate-task overwrite) or less than 10% of the source TS (corrupt first attempt); the source `.ts` is preserved for manual recovery on all failure paths. (Fixes #619, #624)
|
||||
- **Output file collision**: Fixed collision when multiple tasks targeted the same filename.
|
||||
- **WebSocket deadlock**: `send_websocket_update()` was deadlocking the gevent event loop, causing one recording's WebSocket events to block all other simultaneous recordings.
|
||||
- **DVR client isolation**: Stop and Cancel operations now identify the target client by recording ID (via `User-Agent: Dispatcharr-DVR/recording-{id}`), ensuring only the correct proxy client is torn down and never affecting other recordings on the same channel.
|
||||
- **Accidental stream termination on delete**: `destroy()` now only calls `_stop_dvr_clients()` for in-progress recordings, preventing stream termination when deleting a completed recording.
|
||||
- **Recording card logos**: Logos were not displaying due to a channel summary API shape mismatch.
|
||||
- **Logo fetch negative cache**: Added negative cache for failed remote logo fetches so dead CDNs no longer block Daphne workers on repeated requests.
|
||||
- **Artwork fuzzy-match sanitisation**: Poster artwork fuzzy-matching against external APIs (TMDB, OMDb, etc.) was producing incorrect results for channels with names like "USA A&E SD\*"; channel-name strings are now sanitised before querying external sources.
|
||||
- **Series modal "No upcoming episodes"**: Fixed due to a missing `_group_count` merge and an incorrect time filter.
|
||||
- **Series rule cleanup**: Deleting a series rule left orphaned recordings and stale Guide indicators; rule deletion now cleans up all associated recordings. Orphaned recordings with no parent rule are also cleaned up automatically. (Fixes #1041)
|
||||
- **Series rule timezone calculation**: Recurring rules silently dropped scheduled recordings for users in UTC-negative timezones after 4 pm local time. (Fixes #1042)
|
||||
- **Recording modal TDZ crash**: Modal crashed on load in production bundles due to a Temporal Dead Zone error — editing state was referenced before its declaration in the minified bundle.
|
||||
- **Description textarea focus loss**: The description textarea lost focus immediately when opened because the inline editing component was remounting on every render.
|
||||
- **WebSocket-driven refresh**: Replaced all manual `fetchRecordings()` polling calls with debounced WebSocket-driven refresh so the recordings list stays up to date without redundant API requests.
|
||||
- **comskip exit code handling**: comskip treated exit code 1 ("no commercials found") as a fatal error, causing post-processing to fail on clean recordings. Exit code 1 is now recognised as a successful no-op.
|
||||
- **Differentiated WebSocket notification events**: `recording_stopped`, `recording_cancelled` (in-progress cancel), and `recording_deleted` with a `was_in_progress` flag now allow the frontend to display distinct "Recording stopped", "Recording cancelled", and "Recording deleted" toasts.
|
||||
- **Duplicate series rule evaluation race**: Creating a series rule fired `evaluate_series_rules.delay()` in the API view while the frontend immediately called the synchronous evaluate endpoint, racing to create duplicate recordings for the same program. Removed the redundant async call from the API; the frontend's explicit evaluate call is now the sole evaluation path.
|
||||
- **Recording card S/E badge overlap**: Season/episode badges were overlapping and metadata was hidden on the recording card.
|
||||
- **Orphaned recording fallback in series modal**: When a series rule no longer exists, the recurring rule modal now shows a "Delete Recording" button for the orphaned recording instead of failing silently.
|
||||
|
||||
## [0.20.2] - 2026-03-03
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ from django.shortcuts import get_object_or_404, get_list_or_404
|
|||
from django.db import transaction
|
||||
from django.db.models import Count, F
|
||||
from django.db.models import Q
|
||||
import os, json, requests, logging, mimetypes
|
||||
import os, json, requests, logging, mimetypes, threading, time
|
||||
from datetime import timedelta
|
||||
from django.utils.http import http_date
|
||||
from urllib.parse import unquote
|
||||
from apps.accounts.permissions import (
|
||||
|
|
@ -48,7 +49,6 @@ from .serializers import (
|
|||
)
|
||||
from .tasks import (
|
||||
match_epg_channels,
|
||||
evaluate_series_rules,
|
||||
evaluate_series_rules_impl,
|
||||
match_single_channel_epg,
|
||||
match_selected_channels_epg,
|
||||
|
|
@ -72,6 +72,12 @@ from rest_framework.pagination import PageNumberPagination
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Negative cache for remote logo URLs that failed to fetch.
|
||||
# Prevents repeated blocking requests to unreachable hosts (e.g., dead CDNs)
|
||||
# from exhausting Daphne workers. Keyed by URL, value is expiry timestamp.
|
||||
_logo_fetch_failures = {}
|
||||
_LOGO_FAIL_TTL = 300 # seconds
|
||||
|
||||
|
||||
class OrInFilter(django_filters.Filter):
|
||||
"""
|
||||
|
|
@ -1956,6 +1962,12 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
return response
|
||||
|
||||
else: # Remote image
|
||||
# Skip URLs that recently failed to avoid blocking Daphne workers
|
||||
# on unreachable hosts (e.g., dead CDNs referenced by old recordings).
|
||||
fail_expiry = _logo_fetch_failures.get(logo_url)
|
||||
if fail_expiry and time.monotonic() < fail_expiry:
|
||||
raise Http404("Remote image temporarily unavailable")
|
||||
|
||||
try:
|
||||
# Get the default user agent
|
||||
try:
|
||||
|
|
@ -1974,6 +1986,9 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
headers={'User-Agent': user_agent}
|
||||
)
|
||||
if remote_response.status_code == 200:
|
||||
# Success — clear any previous failure entry
|
||||
_logo_fetch_failures.pop(logo_url, None)
|
||||
|
||||
# Try to get content type from response headers first
|
||||
content_type = remote_response.headers.get("Content-Type")
|
||||
|
||||
|
|
@ -1997,14 +2012,19 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
os.path.basename(logo_url)
|
||||
)
|
||||
return response
|
||||
# Non-200 response — cache the failure and evict stale entries
|
||||
now = time.monotonic()
|
||||
_logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL
|
||||
if len(_logo_fetch_failures) > 256:
|
||||
for k in [k for k, v in _logo_fetch_failures.items() if v <= now]:
|
||||
_logo_fetch_failures.pop(k, None)
|
||||
raise Http404("Remote image not found")
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"Timeout fetching logo from {logo_url}")
|
||||
raise Http404("Logo request timed out")
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.warning(f"Connection error fetching logo from {logo_url}")
|
||||
raise Http404("Unable to connect to logo server")
|
||||
except requests.RequestException as e:
|
||||
now = time.monotonic()
|
||||
_logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL
|
||||
if len(_logo_fetch_failures) > 256:
|
||||
for k in [k for k, v in _logo_fetch_failures.items() if v <= now]:
|
||||
_logo_fetch_failures.pop(k, None)
|
||||
logger.warning(f"Error fetching logo from {logo_url}: {e}")
|
||||
raise Http404("Error fetching remote image")
|
||||
|
||||
|
|
@ -2248,6 +2268,54 @@ class RecurringRecordingRuleViewSet(viewsets.ModelViewSet):
|
|||
logger.warning(f"Failed to purge recordings for rule {rule_id}: {err}")
|
||||
|
||||
|
||||
def _stop_dvr_clients(channel_uuid, recording_id=None):
|
||||
"""Stop DVR recording clients for a channel.
|
||||
|
||||
If recording_id is provided, only the client whose User-Agent contains that
|
||||
recording ID is stopped (safe for simultaneous recordings on the same channel).
|
||||
If recording_id is None, all Dispatcharr-DVR clients for the channel are stopped
|
||||
(used by destroy() when deleting a recording whose task_id is unknown).
|
||||
|
||||
Returns the number of DVR clients stopped.
|
||||
"""
|
||||
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 not r:
|
||||
return 0
|
||||
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 "")
|
||||
if not (ua_s and "Dispatcharr-DVR" in ua_s):
|
||||
continue
|
||||
# When a recording_id is specified, only stop the client for that recording.
|
||||
# Each run_recording task connects with User-Agent "Dispatcharr-DVR/recording-{id}",
|
||||
# so we can safely target just this recording without affecting others on the channel.
|
||||
if recording_id is not None and f"recording-{recording_id}" not in ua_s:
|
||||
continue
|
||||
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}")
|
||||
# Do not call ChannelService.stop_channel() here.
|
||||
# Stopping the channel proxy would terminate the source connection which may
|
||||
# be shared with other recordings on the same channel. The TS proxy server
|
||||
# already detects when client count reaches zero and tears down the channel
|
||||
# cleanly on its own (with the configured shutdown delay).
|
||||
return stopped
|
||||
|
||||
|
||||
class RecordingViewSet(viewsets.ModelViewSet):
|
||||
queryset = Recording.objects.all()
|
||||
serializer_class = RecordingSerializer
|
||||
|
|
@ -2342,72 +2410,290 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
|||
response["Content-Disposition"] = f"inline; filename=\"{file_name}\""
|
||||
return response
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="stop")
|
||||
def stop(self, request, pk=None):
|
||||
"""Stop a recording early while retaining the partial content for playback."""
|
||||
instance = self.get_object()
|
||||
|
||||
cp = instance.custom_properties or {}
|
||||
current_status = cp.get("status", "")
|
||||
|
||||
# Reject stop on recordings that are already in a terminal state.
|
||||
# Without this guard, stop() would overwrite "completed" or
|
||||
# "interrupted" with "stopped", losing the original outcome.
|
||||
terminal = {"completed", "interrupted", "failed"}
|
||||
if current_status in terminal:
|
||||
return Response(
|
||||
{"success": False, "error": f"Recording is already {current_status}"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
# Mark as stopped in the DB first so run_recording detects it.
|
||||
# This is the only operation that MUST be synchronous — run_recording reads
|
||||
# the status field to decide whether the stream disconnection was deliberate.
|
||||
cp["status"] = "stopped"
|
||||
cp["stopped_at"] = str(timezone.now())
|
||||
instance.custom_properties = cp
|
||||
instance.save(update_fields=["custom_properties"])
|
||||
|
||||
# Send the WebSocket notification before returning the response.
|
||||
# send_websocket_update is gevent-safe (offloads async_to_sync to a
|
||||
# real OS thread when monkey-patching is active).
|
||||
channel_uuid = str(instance.channel.uuid)
|
||||
recording_id = instance.id
|
||||
task_id = instance.task_id
|
||||
channel_name = instance.channel.name
|
||||
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {
|
||||
"success": True,
|
||||
"type": "recording_stopped",
|
||||
"channel": channel_name,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# DVR client teardown and task revocation are deferred to a daemon thread
|
||||
# because they have occasional slow paths (Redis timeouts, Celery control
|
||||
# broadcasts) that would otherwise add 5-15 s to the HTTP response time.
|
||||
def _background_stop():
|
||||
try:
|
||||
stopped = _stop_dvr_clients(channel_uuid, recording_id=recording_id)
|
||||
if stopped:
|
||||
logger.info(
|
||||
f"Stopped {stopped} DVR client(s) for channel {channel_uuid} (recording stopped early)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Unable to stop DVR clients for stopped recording: {e}")
|
||||
|
||||
try:
|
||||
from apps.channels.signals import revoke_task
|
||||
revoke_task(task_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"Unable to revoke task for stopped recording: {e}")
|
||||
|
||||
try:
|
||||
from django.db import connection as _conn
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_background_stop, daemon=True).start()
|
||||
|
||||
return Response({"success": True, "status": "stopped"})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="extend")
|
||||
def extend(self, request, pk=None):
|
||||
"""Extend an in-progress recording's end_time without interrupting the stream.
|
||||
|
||||
The running task re-reads end_time every ~2 s and adjusts its deadline
|
||||
dynamically. The pre_save signal skips task revocation while the
|
||||
recording status is 'recording'.
|
||||
"""
|
||||
instance = self.get_object()
|
||||
cp = instance.custom_properties or {}
|
||||
|
||||
if cp.get("status") in ("completed", "stopped", "interrupted"):
|
||||
return Response(
|
||||
{"success": False, "error": "Recording has already finished"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
extra_minutes = int(request.data.get("extra_minutes", 0))
|
||||
except (TypeError, ValueError):
|
||||
extra_minutes = 0
|
||||
|
||||
if extra_minutes <= 0:
|
||||
return Response(
|
||||
{"success": False, "error": "extra_minutes must be a positive integer"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
new_end_time = instance.end_time + timedelta(minutes=extra_minutes)
|
||||
# Use queryset .update() to bypass pre_save/post_save signals.
|
||||
# This avoids the pre_save signal revoking the scheduled/running
|
||||
# Celery task. The running task's 2-second polling loop re-reads
|
||||
# end_time from the DB and extends its deadline dynamically.
|
||||
# If the task hasn't started yet (still in Beat's queue), it will
|
||||
# read the updated end_time from the DB on its first poll cycle.
|
||||
Recording.objects.filter(pk=instance.pk).update(end_time=new_end_time)
|
||||
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {
|
||||
"success": True,
|
||||
"type": "recording_extended",
|
||||
"recording_id": instance.id,
|
||||
"new_end_time": new_end_time.isoformat(),
|
||||
"extra_minutes": extra_minutes,
|
||||
"channel": instance.channel.name,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response({"success": True, "new_end_time": new_end_time.isoformat()})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="refresh-artwork")
|
||||
def refresh_artwork(self, request, pk=None):
|
||||
"""Re-run the poster resolution pipeline for this recording.
|
||||
|
||||
Useful when a recording fell back to a channel logo or default logo
|
||||
because external sources were temporarily unavailable.
|
||||
"""
|
||||
instance = self.get_object()
|
||||
|
||||
def _background_refresh(rec_id):
|
||||
try:
|
||||
from .tasks import _resolve_poster_for_program
|
||||
from .models import Recording
|
||||
from core.utils import send_websocket_update
|
||||
from django.db import close_old_connections
|
||||
|
||||
rec = Recording.objects.select_related("channel").get(id=rec_id)
|
||||
cp = rec.custom_properties or {}
|
||||
program = cp.get("program") or {}
|
||||
|
||||
poster_logo_id, poster_url = _resolve_poster_for_program(
|
||||
rec.channel.name, program, channel_logo_id=rec.channel.logo_id,
|
||||
)
|
||||
|
||||
# Refresh and merge to avoid overwriting concurrent changes.
|
||||
# Only upgrade — never replace a real poster with a channel logo fallback.
|
||||
rec.refresh_from_db()
|
||||
fresh_cp = rec.custom_properties or {}
|
||||
updated = False
|
||||
is_channel_logo_fallback = (
|
||||
poster_logo_id == rec.channel.logo_id
|
||||
and not poster_url
|
||||
)
|
||||
if program and program.get("id"):
|
||||
fresh_cp["program"] = program
|
||||
updated = True
|
||||
if not is_channel_logo_fallback:
|
||||
if poster_logo_id and fresh_cp.get("poster_logo_id") != poster_logo_id:
|
||||
fresh_cp["poster_logo_id"] = poster_logo_id
|
||||
updated = True
|
||||
if poster_url and fresh_cp.get("poster_url") != poster_url:
|
||||
fresh_cp["poster_url"] = poster_url
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
rec.custom_properties = fresh_cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
|
||||
send_websocket_update('updates', 'update', {
|
||||
"success": True,
|
||||
"type": "recording_updated",
|
||||
"recording_id": rec_id,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"refresh-artwork background failed for {rec_id}: {e}")
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
t = threading.Thread(target=_background_refresh, args=(instance.id,), daemon=True)
|
||||
t.start()
|
||||
|
||||
return Response({"success": True, "message": "Artwork refresh started"})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="update-metadata")
|
||||
def update_metadata(self, request, pk=None):
|
||||
"""Update user-editable recording metadata (title, description).
|
||||
|
||||
Sets user_edited flag to prevent EPG auto-enrichment from overwriting
|
||||
the user's changes on subsequent task runs.
|
||||
"""
|
||||
instance = self.get_object()
|
||||
title = request.data.get("title")
|
||||
description = request.data.get("description")
|
||||
|
||||
if title is None and description is None:
|
||||
return Response(
|
||||
{"success": False, "error": "No fields to update"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Strip whitespace; treat blank strings as "no change"
|
||||
clean_title = str(title).strip() if title is not None else None
|
||||
clean_desc = str(description).strip() if description is not None else None
|
||||
|
||||
if not clean_title and not clean_desc:
|
||||
return Response(
|
||||
{"success": False, "error": "Title and description cannot be blank"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cp = instance.custom_properties or {}
|
||||
program = cp.get("program") or {}
|
||||
|
||||
if clean_title:
|
||||
program["title"] = clean_title
|
||||
if clean_desc:
|
||||
program["description"] = clean_desc
|
||||
program["user_edited"] = True
|
||||
|
||||
cp["program"] = program
|
||||
instance.custom_properties = cp
|
||||
instance.save(update_fields=["custom_properties"])
|
||||
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {
|
||||
"success": True,
|
||||
"type": "recording_updated",
|
||||
"recording_id": instance.id,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response({"success": True})
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Delete the Recording and ensure any active DVR client connection is closed.
|
||||
|
||||
Also removes the associated file(s) from disk if present.
|
||||
|
||||
Operation order matters for correctness:
|
||||
1. Delete the DB record first — run_recording's cancellation guard
|
||||
(Recording.objects.filter(id=...).exists()) will now return False,
|
||||
preventing it from saving 'interrupted' status or sending
|
||||
recording_ended after the stream is torn down.
|
||||
2. Send recording_cancelled WebSocket immediately so the frontend
|
||||
removes the card without waiting for the slow DVR client teardown.
|
||||
3. Spawn a background thread to stop the DVR client and delete files.
|
||||
This mirrors the stop() endpoint's approach and avoids the 5-15 s
|
||||
delay that _stop_dvr_clients() can introduce.
|
||||
"""
|
||||
instance = self.get_object()
|
||||
recording_id = instance.pk
|
||||
channel_name = instance.channel.name
|
||||
|
||||
# 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
|
||||
# Capture state before the DB row is deleted
|
||||
cp = instance.custom_properties or {}
|
||||
rec_status = cp.get("status", "")
|
||||
file_path = cp.get("file_path")
|
||||
temp_ts_path = cp.get("_temp_file_path")
|
||||
channel_uuid = str(instance.channel.uuid)
|
||||
|
||||
# Perform DB delete first, then try to remove files
|
||||
# 1. Delete the DB record (also fires post_delete → revoke_task_on_delete)
|
||||
response = super().destroy(request, *args, **kwargs)
|
||||
|
||||
# Notify frontends to refresh recordings
|
||||
# 2. Notify frontends immediately
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed"})
|
||||
send_websocket_update('updates', 'update', {
|
||||
"success": True,
|
||||
"type": "recording_cancelled",
|
||||
"recording_id": recording_id,
|
||||
"channel": channel_name,
|
||||
"was_in_progress": rec_status == "recording",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Defer slow teardown to a background thread
|
||||
library_dir = '/data'
|
||||
allowed_roots = ['/data/', library_dir.rstrip('/') + '/']
|
||||
|
||||
|
|
@ -2421,8 +2707,32 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
|||
except Exception as ex:
|
||||
logger.warning(f"Failed to delete recording artifact {path}: {ex}")
|
||||
|
||||
_safe_remove(file_path)
|
||||
_safe_remove(temp_ts_path)
|
||||
def _background_cancel():
|
||||
# Only stop the DVR client if the recording was actively streaming.
|
||||
# Stopping for completed/upcoming recordings would kill an unrelated
|
||||
# in-progress recording on the same channel.
|
||||
if rec_status == "recording":
|
||||
try:
|
||||
stopped = _stop_dvr_clients(channel_uuid, recording_id=recording_id)
|
||||
if stopped:
|
||||
logger.info(
|
||||
f"Stopped {stopped} DVR client(s) for channel {channel_uuid} due to recording cancellation"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Unable to stop DVR clients for cancelled recording: {e}")
|
||||
|
||||
# Best-effort file cleanup in case run_recording already exited
|
||||
# before the DB delete.
|
||||
_safe_remove(file_path)
|
||||
_safe_remove(temp_ts_path)
|
||||
|
||||
try:
|
||||
from django.db import connection as _conn
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_background_cancel, daemon=True).start()
|
||||
|
||||
return response
|
||||
|
||||
|
|
@ -2535,11 +2845,9 @@ class SeriesRulesAPIView(APIView):
|
|||
else:
|
||||
rules.append({"tvg_id": tvg_id, "mode": mode, "title": title})
|
||||
CoreSettings.set_dvr_series_rules(rules)
|
||||
# Evaluate immediately for this tvg_id (async)
|
||||
try:
|
||||
evaluate_series_rules.delay(tvg_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Note: frontend calls the evaluate endpoint explicitly after creating
|
||||
# the rule, so do NOT fire evaluate_series_rules.delay() here to
|
||||
# avoid a race that creates duplicate recordings.
|
||||
return Response({"success": True, "rules": rules})
|
||||
|
||||
|
||||
|
|
@ -2552,16 +2860,44 @@ class DeleteSeriesRuleAPIView(APIView):
|
|||
|
||||
@extend_schema(
|
||||
summary="Delete a series rule",
|
||||
description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.",
|
||||
description="Remove a series recording rule by TVG ID and clean up future scheduled recordings.",
|
||||
parameters=[
|
||||
OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'),
|
||||
],
|
||||
)
|
||||
def delete(self, request, tvg_id):
|
||||
tvg_id = unquote(str(tvg_id or ""))
|
||||
rules = [r for r in CoreSettings.get_dvr_series_rules() if str(r.get("tvg_id")) != tvg_id]
|
||||
CoreSettings.set_dvr_series_rules(rules)
|
||||
return Response({"success": True, "rules": rules})
|
||||
|
||||
# Find the rule before removing to retain the title for cleanup
|
||||
rules = CoreSettings.get_dvr_series_rules()
|
||||
deleted_rule = next((r for r in rules if str(r.get("tvg_id")) == tvg_id), None)
|
||||
remaining = [r for r in rules if str(r.get("tvg_id")) != tvg_id]
|
||||
CoreSettings.set_dvr_series_rules(remaining)
|
||||
|
||||
# Delete only FUTURE recordings — preserve previously recorded episodes
|
||||
removed = 0
|
||||
if deleted_rule:
|
||||
from .models import Recording
|
||||
qs = Recording.objects.filter(
|
||||
start_time__gte=timezone.now(),
|
||||
custom_properties__program__tvg_id=tvg_id,
|
||||
)
|
||||
title = deleted_rule.get("title")
|
||||
if title:
|
||||
qs = qs.filter(custom_properties__program__title=title)
|
||||
removed = qs.count()
|
||||
qs.delete()
|
||||
|
||||
# Notify frontend to refresh recordings list
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {
|
||||
"success": True, "type": "recordings_refreshed", "removed": removed,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response({"success": True, "rules": remaining, "removed": removed})
|
||||
|
||||
|
||||
class EvaluateSeriesRulesAPIView(APIView):
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@
|
|||
|
||||
from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django.utils.timezone import now, is_aware, make_aware
|
||||
from celery.result import AsyncResult
|
||||
from django_celery_beat.models import ClockedSchedule, PeriodicTask
|
||||
from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, Recording
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.epg.tasks import parse_programs_for_tvg_id
|
||||
import logging, requests, time
|
||||
import json
|
||||
import logging
|
||||
from .tasks import run_recording, prefetch_recording_artwork
|
||||
from django.utils.timezone import now, is_aware, make_aware
|
||||
from datetime import timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -85,29 +86,76 @@ def create_profile_memberships(sender, instance, created, **kwargs):
|
|||
for channel in channels
|
||||
])
|
||||
|
||||
def _dvr_task_name(recording_id):
|
||||
"""Predictable PeriodicTask name for a DVR recording."""
|
||||
return f"dvr-recording-{recording_id}"
|
||||
|
||||
|
||||
def schedule_recording_task(instance, eta=None):
|
||||
# Use the explicitly-passed (and timezone-aware) eta if provided;
|
||||
# fall back to instance.start_time only as a last resort.
|
||||
"""Schedule a recording task via ClockedSchedule + one-off PeriodicTask.
|
||||
|
||||
The task is stored in the database and dispatched by Celery Beat at the
|
||||
scheduled time with no countdown. This avoids the Redis visibility_timeout
|
||||
redelivery bug that caused duplicate recordings when using apply_async
|
||||
with long countdowns.
|
||||
"""
|
||||
if eta is None:
|
||||
eta = instance.start_time
|
||||
# Ensure eta is timezone-aware before comparing against now()
|
||||
if eta is not None and not is_aware(eta):
|
||||
eta = make_aware(eta)
|
||||
# countdown=0 fires immediately (in-progress programs whose start_time was
|
||||
# clamped to now by the serializer), countdown>0 delays until start_time
|
||||
# (future programs). Using an integer countdown avoids any timezone
|
||||
# serialization ambiguity that can occur with an absolute eta datetime.
|
||||
countdown = max(0, int((eta - now()).total_seconds()))
|
||||
# Pass recording_id first so task can persist metadata to the correct row
|
||||
task = run_recording.apply_async(
|
||||
args=[instance.id, instance.channel_id, str(instance.start_time), str(instance.end_time)],
|
||||
countdown=countdown,
|
||||
# Clamp to now so Beat dispatches immediately for past/current start times
|
||||
if eta <= now():
|
||||
eta = now()
|
||||
|
||||
task_args = [
|
||||
instance.id,
|
||||
instance.channel_id,
|
||||
str(instance.start_time),
|
||||
str(instance.end_time),
|
||||
]
|
||||
|
||||
clocked, _ = ClockedSchedule.objects.get_or_create(clocked_time=eta)
|
||||
task_name = _dvr_task_name(instance.id)
|
||||
PeriodicTask.objects.update_or_create(
|
||||
name=task_name,
|
||||
defaults={
|
||||
"task": "apps.channels.tasks.run_recording",
|
||||
"clocked": clocked,
|
||||
"args": json.dumps(task_args),
|
||||
"one_off": True,
|
||||
"enabled": True,
|
||||
"interval": None,
|
||||
"crontab": None,
|
||||
"solar": None,
|
||||
},
|
||||
)
|
||||
return task.id
|
||||
return task_name
|
||||
|
||||
|
||||
def revoke_task(task_id):
|
||||
if task_id:
|
||||
"""Cancel a pending recording task.
|
||||
|
||||
task_id is normally a PeriodicTask name (e.g. "dvr-recording-42").
|
||||
For backwards compatibility with legacy Celery async-result UUIDs,
|
||||
falls back to AsyncResult.revoke().
|
||||
"""
|
||||
if not task_id:
|
||||
return
|
||||
# Primary path: delete the PeriodicTask and clean up its ClockedSchedule
|
||||
try:
|
||||
pt = PeriodicTask.objects.get(name=task_id)
|
||||
old_clocked = pt.clocked
|
||||
pt.delete()
|
||||
if old_clocked and not PeriodicTask.objects.filter(clocked=old_clocked).exists():
|
||||
old_clocked.delete()
|
||||
return
|
||||
except PeriodicTask.DoesNotExist:
|
||||
pass
|
||||
# Fallback for legacy Celery task UUIDs
|
||||
try:
|
||||
AsyncResult(task_id).revoke()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@receiver(pre_save, sender=Recording)
|
||||
def revoke_old_task_on_update(sender, instance, **kwargs):
|
||||
|
|
@ -120,6 +168,12 @@ def revoke_old_task_on_update(sender, instance, **kwargs):
|
|||
old.end_time != instance.end_time or
|
||||
old.channel_id != instance.channel_id
|
||||
):
|
||||
# Do NOT revoke while the recording is actively streaming.
|
||||
# run_recording re-reads end_time from the DB every ~2 s and extends
|
||||
# its internal deadline dynamically — revoking here would kill the task.
|
||||
old_status = (old.custom_properties or {}).get("status", "")
|
||||
if old_status == "recording":
|
||||
return
|
||||
revoke_task(old.task_id)
|
||||
instance.task_id = None
|
||||
except Recording.DoesNotExist:
|
||||
|
|
@ -128,35 +182,50 @@ def revoke_old_task_on_update(sender, instance, **kwargs):
|
|||
@receiver(post_save, sender=Recording)
|
||||
def schedule_task_on_save(sender, instance, created, **kwargs):
|
||||
try:
|
||||
# Skip processing for internal field-only saves (metadata updates,
|
||||
# task_id assignment, end_time extensions) to prevent re-entrant
|
||||
# artwork dispatch and redundant recording_updated WS events.
|
||||
update_fields = kwargs.get('update_fields')
|
||||
if not created and update_fields is not None and set(update_fields) <= {'custom_properties', 'task_id', 'end_time'}:
|
||||
return
|
||||
|
||||
if not instance.task_id:
|
||||
start_time = instance.start_time
|
||||
end_time = instance.end_time
|
||||
|
||||
# Make both datetimes aware (in UTC)
|
||||
# Make datetimes aware (in UTC)
|
||||
if not is_aware(start_time):
|
||||
print("Start time was not aware, making aware")
|
||||
start_time = make_aware(start_time)
|
||||
if end_time and not is_aware(end_time):
|
||||
end_time = make_aware(end_time)
|
||||
|
||||
current_time = now()
|
||||
|
||||
# Debug log
|
||||
print(f"Start time: {start_time}, Now: {current_time}")
|
||||
|
||||
# Optionally allow slight fudge factor (1 second) to ensure scheduling happens
|
||||
if start_time > current_time - timedelta(seconds=1):
|
||||
print("Scheduling recording task!")
|
||||
# Pass the corrected, timezone-aware start_time explicitly so
|
||||
# schedule_recording_task uses it as the Celery ETA rather than
|
||||
# re-reading instance.start_time which may still be naive.
|
||||
# Future recording — schedule at start_time
|
||||
logger.info(f"Recording {instance.id}: scheduling task at {start_time}")
|
||||
task_id = schedule_recording_task(instance, eta=start_time)
|
||||
instance.task_id = task_id
|
||||
instance.save(update_fields=['task_id'])
|
||||
elif end_time and end_time > current_time:
|
||||
# Currently-playing — start immediately (e.g. series rule for in-progress program)
|
||||
logger.info(f"Recording {instance.id}: start_time in past but end_time still future, scheduling immediately")
|
||||
task_id = schedule_recording_task(instance, eta=current_time)
|
||||
instance.task_id = task_id
|
||||
instance.save(update_fields=['task_id'])
|
||||
else:
|
||||
print("Start time is in the past. Not scheduling.")
|
||||
# Kick off poster/artwork prefetch to enrich Upcoming cards
|
||||
try:
|
||||
prefetch_recording_artwork.apply_async(args=[instance.id], countdown=1)
|
||||
except Exception as e:
|
||||
print("Error scheduling artwork prefetch:", e)
|
||||
logger.info(f"Recording {instance.id}: start_time and end_time both in past, not scheduling")
|
||||
# Kick off poster/artwork prefetch to enrich Upcoming cards.
|
||||
# Skip when the recording is already active or finished — run_recording
|
||||
# handles its own poster resolution, and scheduling artwork prefetch
|
||||
# while the task is running causes a race that can overwrite status.
|
||||
cp = instance.custom_properties or {}
|
||||
rec_status = cp.get("status", "")
|
||||
if rec_status not in ("recording", "completed", "stopped", "interrupted"):
|
||||
try:
|
||||
prefetch_recording_artwork.apply_async(args=[instance.id], countdown=1)
|
||||
except Exception as e:
|
||||
print("Error scheduling artwork prefetch:", e)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print("Error in post_save signal:", e)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
278
apps/channels/tests/test_db_retry.py
Normal file
278
apps/channels/tests/test_db_retry.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"""Tests for DVR retry logic.
|
||||
|
||||
Covers:
|
||||
- _db_retry(): exponential backoff, max retries, connection reset
|
||||
- Final metadata save retry in run_recording post-processing
|
||||
- Initial TS proxy connection retry (per-base retry on retriable errors)
|
||||
- recover_recordings_on_startup DB retry wrappers
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
from django.db import OperationalError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.channels.models import Channel, Recording
|
||||
from apps.channels.tasks import _db_retry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _db_retry unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DbRetryTests(TestCase):
|
||||
"""Tests for the _db_retry() exponential backoff helper."""
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_succeeds_on_first_attempt(self, _close, _sleep):
|
||||
"""No retry needed when fn succeeds immediately."""
|
||||
result = _db_retry(lambda: "ok", max_retries=3)
|
||||
self.assertEqual(result, "ok")
|
||||
_sleep.assert_not_called()
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_retries_on_operational_error_then_succeeds(self, mock_close, mock_sleep):
|
||||
"""Retry succeeds on second attempt after OperationalError."""
|
||||
call_count = {"n": 0}
|
||||
|
||||
def flaky():
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
raise OperationalError("connection reset")
|
||||
return "recovered"
|
||||
|
||||
result = _db_retry(flaky, max_retries=3, base_interval=1)
|
||||
self.assertEqual(result, "recovered")
|
||||
self.assertEqual(call_count["n"], 2)
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_raises_after_max_retries_exhausted(self, mock_close, mock_sleep):
|
||||
"""Raises OperationalError after all retries fail."""
|
||||
def always_fail():
|
||||
raise OperationalError("db gone")
|
||||
|
||||
with self.assertRaises(OperationalError):
|
||||
_db_retry(always_fail, max_retries=3, base_interval=1)
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_exponential_backoff_timing(self, mock_close, mock_sleep):
|
||||
"""Sleep durations follow exponential backoff: 1s, 2s, 4s."""
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fail_twice():
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] <= 2:
|
||||
raise OperationalError("retry me")
|
||||
return "done"
|
||||
|
||||
_db_retry(fail_twice, max_retries=3, base_interval=1)
|
||||
mock_sleep.assert_has_calls([call(1), call(2)])
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_close_old_connections_called_between_retries(self, mock_close, mock_sleep):
|
||||
"""Stale DB connections are reset before each retry attempt."""
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fail_once():
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
raise OperationalError("stale conn")
|
||||
return "ok"
|
||||
|
||||
_db_retry(fail_once, max_retries=3)
|
||||
mock_close.assert_called_once()
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_non_operational_error_not_retried(self, mock_close, mock_sleep):
|
||||
"""Non-OperationalError exceptions propagate immediately."""
|
||||
def raise_value_error():
|
||||
raise ValueError("not a DB error")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
_db_retry(raise_value_error, max_retries=3)
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_returns_fn_return_value(self, mock_close, mock_sleep):
|
||||
"""Return value of fn() is passed through."""
|
||||
result = _db_retry(lambda: {"key": "value"}, max_retries=3)
|
||||
self.assertEqual(result, {"key": "value"})
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_single_retry_allowed(self, mock_close, mock_sleep):
|
||||
"""max_retries=1 means no retry — fail immediately."""
|
||||
with self.assertRaises(OperationalError):
|
||||
_db_retry(
|
||||
lambda: (_ for _ in ()).throw(OperationalError("fail")),
|
||||
max_retries=1,
|
||||
)
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Final metadata save retry integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FinalMetadataSaveRetryTests(TestCase):
|
||||
"""The final recording metadata save must retry on transient DB errors."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=95, name="Retry Test Channel"
|
||||
)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_metadata_save_uses_db_retry(self, _ws):
|
||||
"""Verify recording metadata is saved via _db_retry (retries on OperationalError)."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "recording"},
|
||||
)
|
||||
# Directly call _db_retry to save metadata as run_recording does
|
||||
cp = rec.custom_properties.copy()
|
||||
cp["status"] = "completed"
|
||||
cp["ended_at"] = str(now)
|
||||
cp["bytes_written"] = 1024
|
||||
|
||||
def _save():
|
||||
rec.custom_properties = cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
|
||||
_db_retry(_save, max_retries=3, base_interval=1, label="test save")
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties["status"], "completed")
|
||||
self.assertEqual(rec.custom_properties["bytes_written"], 1024)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_metadata_survives_transient_save_failure(self, _ws):
|
||||
"""Simulate OperationalError on first save, success on retry."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "recording"},
|
||||
)
|
||||
cp = {"status": "completed", "bytes_written": 2048}
|
||||
call_count = {"n": 0}
|
||||
_real_save = rec.save
|
||||
|
||||
def patched_save(**kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
raise OperationalError("connection reset by peer")
|
||||
return _real_save(**kwargs)
|
||||
|
||||
with patch.object(rec, "save", side_effect=patched_save):
|
||||
with patch("apps.channels.tasks.time.sleep"):
|
||||
with patch("apps.channels.tasks.close_old_connections"):
|
||||
def _save():
|
||||
rec.custom_properties = cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
_db_retry(_save, max_retries=3, base_interval=1, label="test")
|
||||
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties["status"], "completed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initial connection retry tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class InitialConnectionRetryTests(TestCase):
|
||||
"""Verify that the DVR task's reconnection logic retries the same
|
||||
base URL before falling back to the next candidate."""
|
||||
|
||||
def test_reconnect_max_constant_exists_in_run_recording(self):
|
||||
"""run_recording must define a max-reconnect limit to prevent
|
||||
infinite retries on the same broken base URL."""
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
source = inspect.getsource(run_recording)
|
||||
|
||||
# The reconnection counter pattern must be present
|
||||
self.assertIn("reconnect", source.lower(),
|
||||
"run_recording must contain reconnection logic")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# recover_recordings_on_startup retry tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RecoveryRetryTests(TestCase):
|
||||
"""DB operations in recover_recordings_on_startup must use _db_retry."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=97, name="Recovery Retry Channel"
|
||||
)
|
||||
|
||||
@patch("apps.channels.tasks.run_recording.apply_async")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_recovery_save_retries_on_operational_error(self, _ws, mock_async):
|
||||
"""Recovery status update uses _db_retry — survives one OperationalError."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(minutes=30),
|
||||
end_time=now + timedelta(minutes=30),
|
||||
custom_properties={},
|
||||
)
|
||||
# Simulate what recovery does: mark interrupted, then save with retry
|
||||
cp = rec.custom_properties or {}
|
||||
cp["status"] = "interrupted"
|
||||
cp["interrupted_reason"] = "server_restarted"
|
||||
rec.custom_properties = cp
|
||||
|
||||
call_count = {"n": 0}
|
||||
_real_save = Recording.save
|
||||
|
||||
def patched_save(self_rec, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
raise OperationalError("db temporarily unavailable")
|
||||
return _real_save(self_rec, **kwargs)
|
||||
|
||||
with patch.object(Recording, "save", patched_save):
|
||||
with patch("apps.channels.tasks.time.sleep"):
|
||||
with patch("apps.channels.tasks.close_old_connections"):
|
||||
_db_retry(
|
||||
lambda: rec.save(update_fields=["custom_properties"]),
|
||||
max_retries=3,
|
||||
label="test recovery",
|
||||
)
|
||||
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties.get("status"), "interrupted")
|
||||
self.assertEqual(rec.custom_properties.get("interrupted_reason"), "server_restarted")
|
||||
|
||||
def test_db_retry_fetches_recording_list(self):
|
||||
"""_db_retry correctly returns query results for recording list fetch."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(minutes=30),
|
||||
end_time=now + timedelta(minutes=30),
|
||||
custom_properties={},
|
||||
)
|
||||
result = _db_retry(
|
||||
lambda: list(Recording.objects.filter(
|
||||
start_time__lte=now, end_time__gt=now
|
||||
)),
|
||||
label="test query",
|
||||
)
|
||||
self.assertGreaterEqual(len(result), 1)
|
||||
ids = [r.id for r in result]
|
||||
self.assertIn(rec.id, ids)
|
||||
180
apps/channels/tests/test_epg_matching.py
Normal file
180
apps/channels/tests/test_epg_matching.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""Tests for the _match_epg_program_by_timeslot() helper in tasks.py.
|
||||
|
||||
Covers:
|
||||
- Exact time-slot match returns program dict
|
||||
- 80% overlap threshold: at boundary, above, and below
|
||||
- Multiple overlapping programs: dominant vs. evenly split
|
||||
- Edge cases: None inputs, zero-duration recording, no EPG data
|
||||
- Returned dict structure (id, title, sub_title, description)
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.channels.models import Channel
|
||||
from apps.epg.models import EPGSource, EPGData, ProgramData
|
||||
from apps.channels.tasks import _match_epg_program_by_timeslot
|
||||
|
||||
|
||||
class EpgMatchingSetupMixin:
|
||||
"""Shared setup for EPG matching tests."""
|
||||
|
||||
def setUp(self):
|
||||
self.source = EPGSource.objects.create(name="Test Source")
|
||||
self.epg = EPGData.objects.create(
|
||||
tvg_id="test.channel", name="Test Channel EPG", epg_source=self.source,
|
||||
)
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=50, name="EPG Match Channel", epg_data=self.epg,
|
||||
)
|
||||
self.base = timezone.now().replace(second=0, microsecond=0)
|
||||
|
||||
def _prog(self, offset_min, duration_min, title="Test Show", **kwargs):
|
||||
"""Create a ProgramData starting offset_min from self.base."""
|
||||
start = self.base + timedelta(minutes=offset_min)
|
||||
end = start + timedelta(minutes=duration_min)
|
||||
return ProgramData.objects.create(
|
||||
epg=self.epg, start_time=start, end_time=end, title=title, **kwargs,
|
||||
)
|
||||
|
||||
|
||||
class ExactMatchTests(EpgMatchingSetupMixin, TestCase):
|
||||
"""Recording window exactly matches an EPG program."""
|
||||
|
||||
def test_exact_match_returns_program_dict(self):
|
||||
prog = self._prog(0, 60, title="News at 9", sub_title="Top Stories",
|
||||
description="Evening news broadcast")
|
||||
result = _match_epg_program_by_timeslot(
|
||||
self.epg, prog.start_time, prog.end_time,
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], prog.id)
|
||||
self.assertEqual(result["title"], "News at 9")
|
||||
self.assertEqual(result["sub_title"], "Top Stories")
|
||||
self.assertEqual(result["description"], "Evening news broadcast")
|
||||
|
||||
def test_missing_optional_fields_returned_as_empty_strings(self):
|
||||
prog = self._prog(0, 30, title="Minimal Show")
|
||||
result = _match_epg_program_by_timeslot(
|
||||
self.epg, prog.start_time, prog.end_time,
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["sub_title"], "")
|
||||
self.assertEqual(result["description"], "")
|
||||
|
||||
|
||||
class OverlapThresholdTests(EpgMatchingSetupMixin, TestCase):
|
||||
"""80% overlap threshold boundary tests."""
|
||||
|
||||
def test_exactly_80_percent_overlap_returns_match(self):
|
||||
"""Program covers exactly 80% of the recording window."""
|
||||
# Program: 0-60min, Recording: 0-75min → overlap = 60/75 = 80%
|
||||
prog = self._prog(0, 60, title="Borderline Show")
|
||||
rec_start = self.base
|
||||
rec_end = self.base + timedelta(minutes=75)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Borderline Show")
|
||||
|
||||
def test_below_80_percent_returns_none(self):
|
||||
"""Program covers 79% of the recording — below threshold."""
|
||||
# Program: 0-60min, Recording: 0-76min → overlap = 60/76 ≈ 78.9%
|
||||
prog = self._prog(0, 60, title="Too Short")
|
||||
rec_start = self.base
|
||||
rec_end = self.base + timedelta(minutes=76)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_above_80_percent_returns_match(self):
|
||||
"""Program covers 90% of the recording."""
|
||||
# Program: 0-60min, Recording: 0-66min → overlap = 60/66 ≈ 90.9%
|
||||
prog = self._prog(0, 60, title="Good Match")
|
||||
rec_start = self.base
|
||||
rec_end = self.base + timedelta(minutes=66)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Good Match")
|
||||
|
||||
|
||||
class MultipleProgramTests(EpgMatchingSetupMixin, TestCase):
|
||||
"""Recording spans multiple EPG programs."""
|
||||
|
||||
def test_dominant_program_returned(self):
|
||||
"""Recording spans 2 programs; one covers 85%, the other 15%."""
|
||||
# Show A: 0-60min, Show B: 60-120min
|
||||
# Recording: 9-69min → A overlap=51/60=85%, B overlap=9/60=15%
|
||||
self._prog(0, 60, title="Show A")
|
||||
self._prog(60, 60, title="Show B")
|
||||
rec_start = self.base + timedelta(minutes=9)
|
||||
rec_end = self.base + timedelta(minutes=69)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Show A")
|
||||
|
||||
def test_evenly_split_returns_none(self):
|
||||
"""Recording spans 2 equal programs — neither reaches 80%."""
|
||||
# Show A: 0-60min, Show B: 60-120min
|
||||
# Recording: 30-90min → each covers 50%
|
||||
self._prog(0, 60, title="Show A")
|
||||
self._prog(60, 60, title="Show B")
|
||||
rec_start = self.base + timedelta(minutes=30)
|
||||
rec_end = self.base + timedelta(minutes=90)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_three_programs_one_dominant(self):
|
||||
"""Recording spans 3 programs; middle one is dominant."""
|
||||
# A: 0-30min, B: 30-90min, C: 90-120min
|
||||
# Recording: 25-95min (70min window) → B overlap=60/70≈85.7%
|
||||
self._prog(0, 30, title="Show A")
|
||||
self._prog(30, 60, title="Show B")
|
||||
self._prog(90, 30, title="Show C")
|
||||
rec_start = self.base + timedelta(minutes=25)
|
||||
rec_end = self.base + timedelta(minutes=95)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Show B")
|
||||
|
||||
|
||||
class EdgeCaseTests(EpgMatchingSetupMixin, TestCase):
|
||||
"""Edge cases and error handling."""
|
||||
|
||||
def test_none_epg_data_returns_none(self):
|
||||
result = _match_epg_program_by_timeslot(None, self.base, self.base + timedelta(hours=1))
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_none_start_time_returns_none(self):
|
||||
result = _match_epg_program_by_timeslot(self.epg, None, self.base + timedelta(hours=1))
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_none_end_time_returns_none(self):
|
||||
result = _match_epg_program_by_timeslot(self.epg, self.base, None)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_zero_duration_returns_none(self):
|
||||
"""Recording with start == end should return None."""
|
||||
result = _match_epg_program_by_timeslot(self.epg, self.base, self.base)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_negative_duration_returns_none(self):
|
||||
"""Recording with end before start should return None."""
|
||||
result = _match_epg_program_by_timeslot(
|
||||
self.epg, self.base + timedelta(hours=1), self.base,
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_no_overlapping_programs_returns_none(self):
|
||||
"""No EPG programs in the recording window."""
|
||||
self._prog(0, 60, title="Earlier Show")
|
||||
rec_start = self.base + timedelta(hours=5)
|
||||
rec_end = rec_start + timedelta(hours=1)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_empty_epg_no_programs_returns_none(self):
|
||||
"""EPGData exists but has no programs."""
|
||||
result = _match_epg_program_by_timeslot(
|
||||
self.epg, self.base, self.base + timedelta(hours=1),
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
235
apps/channels/tests/test_recording_extend.py
Normal file
235
apps/channels/tests/test_recording_extend.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""Tests for the Extend In-Progress Recording feature.
|
||||
|
||||
Covers:
|
||||
- extend() API endpoint (happy path and validation)
|
||||
- pre_save signal guard: end_time change must NOT revoke a live recording
|
||||
- pre_save signal guard: end_time change MUST still revoke an upcoming recording
|
||||
- TOCTOU edge cases (extend on a completed/stopped/nonexistent recording)
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from apps.channels.models import Channel, Recording
|
||||
from apps.channels.api_views import RecordingViewSet
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_admin():
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
u, _ = User.objects.get_or_create(
|
||||
username="extend_test_admin",
|
||||
defaults={"user_level": User.UserLevel.ADMIN},
|
||||
)
|
||||
u.set_password("pass")
|
||||
u.save()
|
||||
return u
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extend endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ExtendEndpointTests(TestCase):
|
||||
"""Tests for POST /api/channels/recordings/{id}/extend/"""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=88, name="Extend Test Channel"
|
||||
)
|
||||
self.user = _make_admin()
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def _extend(self, rec, extra_minutes):
|
||||
request = self.factory.post(
|
||||
f"/api/channels/recordings/{rec.id}/extend/",
|
||||
{"extra_minutes": extra_minutes},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "extend"})
|
||||
return view(request, pk=rec.id)
|
||||
|
||||
def _make_rec(self, status="recording"):
|
||||
now = timezone.now()
|
||||
return Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": status},
|
||||
)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_extend_updates_end_time_in_db(self, _ws):
|
||||
rec = self._make_rec()
|
||||
original_end = rec.end_time
|
||||
response = self._extend(rec, 30)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data.get("success"))
|
||||
rec.refresh_from_db()
|
||||
expected = original_end + timedelta(minutes=30)
|
||||
delta = abs((rec.end_time - expected).total_seconds())
|
||||
self.assertLess(delta, 1, "end_time was not extended by the correct amount")
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_extend_stacks_multiple_extensions(self, _ws):
|
||||
"""Calling extend() twice adds both increments."""
|
||||
rec = self._make_rec()
|
||||
original_end = rec.end_time
|
||||
self._extend(rec, 15)
|
||||
self._extend(rec, 30)
|
||||
rec.refresh_from_db()
|
||||
expected = original_end + timedelta(minutes=45)
|
||||
delta = abs((rec.end_time - expected).total_seconds())
|
||||
self.assertLess(delta, 1)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_extend_does_not_clear_task_id(self, _ws):
|
||||
"""The running Celery task must survive the DB save."""
|
||||
rec = self._make_rec()
|
||||
rec.task_id = "dvr-recording-999"
|
||||
rec.save(update_fields=["task_id"])
|
||||
self._extend(rec, 30)
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.task_id, "dvr-recording-999")
|
||||
|
||||
def test_extend_returns_400_if_finished(self):
|
||||
"""Cannot extend a completed, stopped, or interrupted recording."""
|
||||
for bad_status in ("completed", "stopped", "interrupted"):
|
||||
with self.subTest(status=bad_status):
|
||||
rec = self._make_rec(status=bad_status)
|
||||
response = self._extend(rec, 30)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(response.data.get("success"))
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_extend_succeeds_before_task_sets_status(self, _ws):
|
||||
"""Extend must work when status is empty (task hasn't started yet)."""
|
||||
rec = self._make_rec(status="")
|
||||
response = self._extend(rec, 15)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
expected = rec.end_time # already extended
|
||||
self.assertTrue(response.data.get("success"))
|
||||
|
||||
@patch("apps.channels.signals.revoke_task")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_extend_bypasses_signals_no_revoke(self, _ws, mock_revoke):
|
||||
"""Extend uses .update() to bypass pre_save — revoke_task must never fire."""
|
||||
rec = self._make_rec(status="")
|
||||
rec.task_id = "dvr-recording-500"
|
||||
rec.save(update_fields=["task_id"])
|
||||
self._extend(rec, 15)
|
||||
self._extend(rec, 30)
|
||||
mock_revoke.assert_not_called()
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.task_id, "dvr-recording-500")
|
||||
|
||||
def test_extend_returns_400_for_zero_minutes(self):
|
||||
response = self._extend(self._make_rec(), 0)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_extend_returns_400_for_negative_minutes(self):
|
||||
response = self._extend(self._make_rec(), -15)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_extend_returns_400_for_non_numeric_minutes(self):
|
||||
rec = self._make_rec()
|
||||
request = self.factory.post(
|
||||
f"/api/channels/recordings/{rec.id}/extend/",
|
||||
{"extra_minutes": "lots"},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "extend"})
|
||||
response = view(request, pk=rec.id)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_extend_returns_404_for_nonexistent_recording(self):
|
||||
request = self.factory.post(
|
||||
"/api/channels/recordings/999999/extend/",
|
||||
{"extra_minutes": 30},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "extend"})
|
||||
response = view(request, pk=999999)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pre_save signal guard tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PreSaveExtendGuardTests(TestCase):
|
||||
"""The pre_save signal must NOT revoke a live recording when end_time changes,
|
||||
but MUST still revoke a scheduled (upcoming) recording as before."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=77, name="Signal Guard Channel"
|
||||
)
|
||||
|
||||
def _make_rec(self, status="", task_id="dvr-recording-42"):
|
||||
now = timezone.now()
|
||||
return Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now + timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=2),
|
||||
task_id=task_id,
|
||||
custom_properties={"status": status} if status else {},
|
||||
)
|
||||
|
||||
@patch("apps.channels.signals.revoke_task")
|
||||
def test_end_time_change_does_not_revoke_live_recording(self, mock_revoke):
|
||||
"""When status='recording', extending end_time must not call revoke_task."""
|
||||
rec = self._make_rec(status="recording", task_id="dvr-recording-42")
|
||||
rec.end_time = rec.end_time + timedelta(minutes=30)
|
||||
rec.save(update_fields=["end_time"])
|
||||
mock_revoke.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.revoke_task")
|
||||
def test_task_id_preserved_after_extend_on_live_recording(self, mock_revoke):
|
||||
"""task_id must not be cleared for a live recording's end_time change."""
|
||||
rec = self._make_rec(status="recording", task_id="dvr-recording-42")
|
||||
original_task_id = rec.task_id
|
||||
rec.end_time = rec.end_time + timedelta(minutes=30)
|
||||
rec.save(update_fields=["end_time"])
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.task_id, original_task_id)
|
||||
|
||||
@patch("apps.channels.signals.revoke_task")
|
||||
def test_end_time_change_still_revokes_upcoming_recording(self, mock_revoke):
|
||||
"""The guard must NOT apply to upcoming recordings — existing behavior preserved."""
|
||||
rec = self._make_rec(status="", task_id="dvr-recording-77")
|
||||
rec.end_time = rec.end_time + timedelta(minutes=30)
|
||||
rec.save(update_fields=["end_time"])
|
||||
mock_revoke.assert_called_once_with("dvr-recording-77")
|
||||
|
||||
@patch("apps.channels.signals.revoke_task")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_pre_save_guard_reads_db_status_not_memory_status(self, _ws, mock_revoke):
|
||||
"""pre_save reads status from DB (old object), not from the instance being saved."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
task_id="dvr-recording-66",
|
||||
custom_properties={"status": "recording"},
|
||||
)
|
||||
# Simulate: DB status changes to 'completed' behind the instance's back
|
||||
Recording.objects.filter(pk=rec.pk).update(
|
||||
custom_properties={"status": "completed"}
|
||||
)
|
||||
rec.end_time = rec.end_time + timedelta(minutes=30)
|
||||
rec.save(update_fields=["end_time"])
|
||||
# revoke_task should be called because DB status is "completed", not "recording"
|
||||
mock_revoke.assert_called_once_with("dvr-recording-66")
|
||||
379
apps/channels/tests/test_recording_metadata.py
Normal file
379
apps/channels/tests/test_recording_metadata.py
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
"""Tests for recording metadata endpoints and logo proxy negative cache.
|
||||
|
||||
Covers:
|
||||
- update_metadata endpoint: title/description, user_edited flag, validation
|
||||
- refresh_artwork endpoint: returns immediately, background thread behavior
|
||||
- Logo proxy negative cache: cache hit/miss, expiry, eviction, success clears
|
||||
"""
|
||||
import time as time_mod
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from apps.channels.models import Channel, Recording, Logo
|
||||
from apps.channels.api_views import RecordingViewSet, LogoViewSet
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_admin():
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
u, _ = User.objects.get_or_create(
|
||||
username="metadata_test_admin",
|
||||
defaults={"user_level": User.UserLevel.ADMIN},
|
||||
)
|
||||
u.set_password("pass")
|
||||
u.save()
|
||||
return u
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_metadata endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class UpdateMetadataTests(TestCase):
|
||||
"""Tests for POST /api/channels/recordings/{id}/update-metadata/"""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=70, name="Meta Test Channel")
|
||||
self.user = _make_admin()
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def _update(self, rec, data):
|
||||
request = self.factory.post(
|
||||
f"/api/channels/recordings/{rec.id}/update-metadata/",
|
||||
data, format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "update_metadata"})
|
||||
return view(request, pk=rec.id)
|
||||
|
||||
def _make_rec(self, custom_properties=None):
|
||||
now = timezone.now()
|
||||
return Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties=custom_properties or {},
|
||||
)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_update_title_only(self, _ws):
|
||||
rec = self._make_rec()
|
||||
response = self._update(rec, {"title": "My Show"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
program = rec.custom_properties["program"]
|
||||
self.assertEqual(program["title"], "My Show")
|
||||
self.assertTrue(program["user_edited"])
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_update_description_only(self, _ws):
|
||||
rec = self._make_rec({"program": {"title": "Existing Title"}})
|
||||
response = self._update(rec, {"description": "A great episode"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
program = rec.custom_properties["program"]
|
||||
self.assertEqual(program["description"], "A great episode")
|
||||
self.assertEqual(program["title"], "Existing Title")
|
||||
self.assertTrue(program["user_edited"])
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_update_both_fields(self, _ws):
|
||||
rec = self._make_rec()
|
||||
response = self._update(rec, {"title": "New Title", "description": "New Desc"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
program = rec.custom_properties["program"]
|
||||
self.assertEqual(program["title"], "New Title")
|
||||
self.assertEqual(program["description"], "New Desc")
|
||||
self.assertTrue(program["user_edited"])
|
||||
|
||||
def test_no_fields_returns_400(self):
|
||||
rec = self._make_rec()
|
||||
response = self._update(rec, {})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(response.data.get("success"))
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_whitespace_trimmed(self, _ws):
|
||||
rec = self._make_rec()
|
||||
response = self._update(rec, {"title": " Padded Title "})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties["program"]["title"], "Padded Title")
|
||||
|
||||
def test_whitespace_only_title_returns_400(self):
|
||||
"""Whitespace-only title and description should be rejected."""
|
||||
rec = self._make_rec()
|
||||
response = self._update(rec, {"title": " ", "description": " "})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(response.data.get("success"))
|
||||
|
||||
def test_whitespace_only_title_with_valid_description(self):
|
||||
"""Whitespace-only title is ignored; valid description is accepted."""
|
||||
rec = self._make_rec({"program": {"title": "Original"}})
|
||||
with patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None):
|
||||
response = self._update(rec, {"title": " ", "description": "Valid desc"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
# Title should remain unchanged since the whitespace-only value is not applied
|
||||
self.assertEqual(rec.custom_properties["program"]["title"], "Original")
|
||||
self.assertEqual(rec.custom_properties["program"]["description"], "Valid desc")
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_creates_program_dict_when_absent(self, _ws):
|
||||
"""Recording with no program dict gets one created."""
|
||||
rec = self._make_rec({"status": "completed"})
|
||||
response = self._update(rec, {"title": "Brand New"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
self.assertIn("program", rec.custom_properties)
|
||||
self.assertEqual(rec.custom_properties["program"]["title"], "Brand New")
|
||||
|
||||
def test_returns_404_for_nonexistent(self):
|
||||
request = self.factory.post(
|
||||
"/api/channels/recordings/99999/update-metadata/",
|
||||
{"title": "Ghost"}, format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "update_metadata"})
|
||||
self.assertEqual(view(request, pk=99999).status_code, 404)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_sends_websocket_event(self, mock_ws):
|
||||
rec = self._make_rec()
|
||||
self._update(rec, {"title": "WS Test"})
|
||||
mock_ws.assert_called_once()
|
||||
payload = mock_ws.call_args[0][2]
|
||||
self.assertEqual(payload["type"], "recording_updated")
|
||||
self.assertEqual(payload["recording_id"], rec.id)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=Exception("WS down"))
|
||||
def test_ws_failure_does_not_fail_request(self, _ws):
|
||||
"""WebSocket errors are silenced — the save still succeeds."""
|
||||
rec = self._make_rec()
|
||||
response = self._update(rec, {"title": "Resilient"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties["program"]["title"], "Resilient")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# refresh_artwork endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RefreshArtworkTests(TestCase):
|
||||
"""Tests for POST /api/channels/recordings/{id}/refresh-artwork/"""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=71, name="Artwork Test Channel")
|
||||
self.user = _make_admin()
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def _refresh(self, rec):
|
||||
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
|
||||
return view(request, pk=rec.id)
|
||||
|
||||
def _make_rec(self, custom_properties=None):
|
||||
now = timezone.now()
|
||||
return Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties=custom_properties or {},
|
||||
)
|
||||
|
||||
@patch("threading.Thread")
|
||||
def test_returns_200_immediately(self, mock_thread):
|
||||
mock_thread.return_value.start = MagicMock()
|
||||
rec = self._make_rec()
|
||||
response = self._refresh(rec)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data.get("success"))
|
||||
|
||||
@patch("threading.Thread")
|
||||
def test_spawns_background_thread(self, mock_thread):
|
||||
mock_thread.return_value.start = MagicMock()
|
||||
rec = self._make_rec()
|
||||
self._refresh(rec)
|
||||
mock_thread.assert_called_once()
|
||||
self.assertTrue(mock_thread.call_args[1].get("daemon", False))
|
||||
mock_thread.return_value.start.assert_called_once()
|
||||
|
||||
def test_returns_404_for_nonexistent(self):
|
||||
request = self.factory.post("/api/channels/recordings/99999/refresh-artwork/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
|
||||
self.assertEqual(view(request, pk=99999).status_code, 404)
|
||||
|
||||
@patch("django.db.close_old_connections")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_no_downgrade_to_channel_logo(self, _ws, _close):
|
||||
"""When the pipeline returns the channel's own logo, existing poster is preserved."""
|
||||
logo = Logo.objects.create(name="Channel Logo", url="https://example.com/ch.png")
|
||||
self.channel.logo = logo
|
||||
self.channel.save()
|
||||
rec = self._make_rec({
|
||||
"poster_logo_id": 999, # existing real poster
|
||||
"poster_url": "https://tmdb.com/real-poster.jpg",
|
||||
})
|
||||
|
||||
with patch("apps.channels.tasks._resolve_poster_for_program",
|
||||
return_value=(logo.id, None)):
|
||||
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
# Run synchronously by intercepting the thread
|
||||
captured_fn = None
|
||||
def capture_thread(*args, **kwargs):
|
||||
nonlocal captured_fn
|
||||
captured_fn = kwargs.get("target") or args[0]
|
||||
mock = MagicMock()
|
||||
mock.start = lambda: captured_fn(*kwargs.get("args", ()))
|
||||
return mock
|
||||
|
||||
with patch("threading.Thread", side_effect=capture_thread):
|
||||
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
|
||||
view(request, pk=rec.id)
|
||||
|
||||
rec.refresh_from_db()
|
||||
# Existing poster should be preserved — not downgraded to channel logo
|
||||
self.assertEqual(rec.custom_properties.get("poster_logo_id"), 999)
|
||||
self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/real-poster.jpg")
|
||||
|
||||
@patch("django.db.close_old_connections")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_upgrade_from_no_poster(self, _ws, _close):
|
||||
"""When a recording has no poster and the pipeline finds one, it gets updated."""
|
||||
rec = self._make_rec({"program": {"title": "Some Show", "id": 42}})
|
||||
|
||||
with patch("apps.channels.tasks._resolve_poster_for_program",
|
||||
return_value=(555, "https://tmdb.com/new-poster.jpg")):
|
||||
captured_fn = None
|
||||
def capture_thread(*args, **kwargs):
|
||||
nonlocal captured_fn
|
||||
captured_fn = kwargs.get("target") or args[0]
|
||||
mock = MagicMock()
|
||||
mock.start = lambda: captured_fn(*kwargs.get("args", ()))
|
||||
return mock
|
||||
|
||||
with patch("threading.Thread", side_effect=capture_thread):
|
||||
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
|
||||
view(request, pk=rec.id)
|
||||
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties.get("poster_logo_id"), 555)
|
||||
self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/new-poster.jpg")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logo proxy negative cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LogoNegativeCacheTests(TestCase):
|
||||
"""Tests for the _logo_fetch_failures negative cache in LogoViewSet.cache()."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.channels import api_views
|
||||
self._failures = api_views._logo_fetch_failures
|
||||
self._failures.clear()
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = _make_admin()
|
||||
|
||||
def _fetch_logo(self, logo):
|
||||
request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = LogoViewSet.as_view({"get": "cache"})
|
||||
return view(request, pk=logo.id)
|
||||
|
||||
def test_failed_url_cached_on_non_200(self):
|
||||
"""Non-200 response adds URL to negative cache."""
|
||||
logo = Logo.objects.create(name="Dead Logo", url="https://dead-cdn.com/logo.png")
|
||||
mock_resp = MagicMock(status_code=404)
|
||||
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
|
||||
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
|
||||
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
|
||||
response = self._fetch_logo(logo)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn("https://dead-cdn.com/logo.png", self._failures)
|
||||
|
||||
def test_cached_failure_returns_404_immediately(self):
|
||||
"""Subsequent request for a cached-failed URL returns 404 without making a request."""
|
||||
logo = Logo.objects.create(name="Cached Fail", url="https://cached-fail.com/logo.png")
|
||||
self._failures["https://cached-fail.com/logo.png"] = time_mod.monotonic() + 300
|
||||
|
||||
with patch("apps.channels.api_views.requests.get") as mock_get:
|
||||
response = self._fetch_logo(logo)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_expired_cache_entry_allows_retry(self):
|
||||
"""After TTL expires, a new request is made."""
|
||||
logo = Logo.objects.create(name="Expired", url="https://expired.com/logo.png")
|
||||
self._failures["https://expired.com/logo.png"] = time_mod.monotonic() - 1 # already expired
|
||||
|
||||
mock_resp = MagicMock(status_code=200)
|
||||
mock_resp.headers = {"Content-Type": "image/png"}
|
||||
mock_resp.iter_content = MagicMock(return_value=[b"img"])
|
||||
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
|
||||
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
|
||||
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
|
||||
response = self._fetch_logo(logo)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_success_clears_previous_failure(self):
|
||||
"""A successful fetch removes the URL from the failure cache."""
|
||||
url = "https://recovered.com/logo.png"
|
||||
logo = Logo.objects.create(name="Recovered", url=url)
|
||||
self._failures[url] = time_mod.monotonic() - 1 # expired
|
||||
|
||||
mock_resp = MagicMock(status_code=200)
|
||||
mock_resp.headers = {"Content-Type": "image/png"}
|
||||
mock_resp.iter_content = MagicMock(return_value=[b"img"])
|
||||
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
|
||||
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
|
||||
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
|
||||
self._fetch_logo(logo)
|
||||
self.assertNotIn(url, self._failures)
|
||||
|
||||
def test_request_exception_cached(self):
|
||||
"""Network errors are cached the same as non-200 responses."""
|
||||
import requests
|
||||
logo = Logo.objects.create(name="Timeout", url="https://timeout.com/logo.png")
|
||||
with patch("apps.channels.api_views.requests.get", side_effect=requests.Timeout("timed out")), \
|
||||
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
|
||||
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
|
||||
response = self._fetch_logo(logo)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn("https://timeout.com/logo.png", self._failures)
|
||||
|
||||
def test_eviction_when_cache_exceeds_256(self):
|
||||
"""Stale entries are evicted when the cache grows past 256."""
|
||||
now = time_mod.monotonic()
|
||||
# Fill with 257 expired entries
|
||||
for i in range(257):
|
||||
self._failures[f"https://old-{i}.com/x.png"] = now - 1 # already expired
|
||||
|
||||
logo = Logo.objects.create(name="Trigger", url="https://trigger-evict.com/logo.png")
|
||||
import requests
|
||||
with patch("apps.channels.api_views.requests.get", side_effect=requests.ConnectionError("fail")), \
|
||||
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
|
||||
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
|
||||
self._fetch_logo(logo)
|
||||
|
||||
# Expired entries should be evicted
|
||||
old_entries = [k for k in self._failures if k.startswith("https://old-")]
|
||||
self.assertEqual(len(old_entries), 0)
|
||||
# New failure entry should exist
|
||||
self.assertIn("https://trigger-evict.com/logo.png", self._failures)
|
||||
524
apps/channels/tests/test_recording_pipeline.py
Normal file
524
apps/channels/tests/test_recording_pipeline.py
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
"""Tests for recent DVR fixes.
|
||||
|
||||
Covers:
|
||||
1. Collision avoidance: _build_output_paths checks both .mkv and .ts files
|
||||
2. Logo guard: _resolve_poster_for_program skips external APIs when title ≈ channel name
|
||||
3. Recording status lifecycle: status transitions visible via API
|
||||
4. Concat flags: error-tolerant ffmpeg flags used for segment concatenation
|
||||
5. Recovery skip-list: "recording" status NOT in terminal skip list
|
||||
"""
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from apps.channels.models import Channel, Recording
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_admin():
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
u, _ = User.objects.get_or_create(
|
||||
username="dvr_fixes_admin",
|
||||
defaults={"user_level": User.UserLevel.ADMIN},
|
||||
)
|
||||
u.set_password("pass")
|
||||
u.save()
|
||||
return u
|
||||
|
||||
|
||||
def _make_channel(name="Test Channel", number=100):
|
||||
return Channel.objects.create(channel_number=number, name=name)
|
||||
|
||||
|
||||
def _make_recording(channel, **overrides):
|
||||
now = timezone.now()
|
||||
defaults = {
|
||||
"channel": channel,
|
||||
"start_time": now - timedelta(hours=1),
|
||||
"end_time": now + timedelta(hours=1),
|
||||
"custom_properties": {},
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return Recording.objects.create(**defaults)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 1. Collision avoidance — _build_output_paths
|
||||
# =========================================================================
|
||||
|
||||
class CollisionAvoidanceTests(TestCase):
|
||||
"""_build_output_paths must increment the filename counter when
|
||||
EITHER the .mkv OR the .ts file already exists with size > 0."""
|
||||
|
||||
def _call(self, channel, program, start, end):
|
||||
from apps.channels.tasks import _build_output_paths
|
||||
return _build_output_paths(channel, program, start, end)
|
||||
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||
return_value="TV/{show}/{start}.mkv")
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
|
||||
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
|
||||
def test_no_collision_when_nothing_exists(self, _tv, _fb):
|
||||
"""Fresh path — no files exist, counter stays at 1."""
|
||||
ch = MagicMock(name="TestCh")
|
||||
ch.name = "TestCh"
|
||||
program = {"title": "My Show"}
|
||||
now = timezone.now()
|
||||
|
||||
def mock_stat(path):
|
||||
raise OSError("No such file")
|
||||
|
||||
with patch("os.stat", side_effect=mock_stat), \
|
||||
patch("os.makedirs"):
|
||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||
|
||||
# Should NOT have a _2 suffix
|
||||
self.assertNotIn("_2", final)
|
||||
self.assertTrue(final.endswith(".mkv"))
|
||||
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||
return_value="TV/{show}/{start}.mkv")
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
|
||||
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
|
||||
def test_collision_when_ts_exists_but_mkv_is_zero_bytes(self, _tv, _fb):
|
||||
"""Pre-restart scenario: MKV is 0-byte placeholder, TS has real data.
|
||||
The old code only checked MKV size, so it would reuse the path.
|
||||
The fix also checks TS, so it must increment."""
|
||||
ch = MagicMock(name="TestCh")
|
||||
ch.name = "TestCh"
|
||||
program = {"title": "My Show"}
|
||||
now = timezone.now()
|
||||
|
||||
def mock_stat(path):
|
||||
if "_2" in path:
|
||||
raise OSError("No such file")
|
||||
result = MagicMock()
|
||||
if path.endswith('.mkv'):
|
||||
result.st_size = 0 # MKV is 0-byte placeholder
|
||||
elif path.endswith('.ts'):
|
||||
result.st_size = 5000000 # TS has real data from pre-restart
|
||||
else:
|
||||
result.st_size = 0
|
||||
return result
|
||||
|
||||
with patch("os.stat", side_effect=mock_stat), \
|
||||
patch("os.makedirs"):
|
||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||
|
||||
# Must have incremented to _2
|
||||
self.assertIn("_2", final, "Should increment counter when TS file has data")
|
||||
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||
return_value="TV/{show}/{start}.mkv")
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
|
||||
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
|
||||
def test_collision_when_mkv_has_data(self, _tv, _fb):
|
||||
"""Standard collision: MKV file has data, should increment."""
|
||||
ch = MagicMock(name="TestCh")
|
||||
ch.name = "TestCh"
|
||||
program = {"title": "My Show"}
|
||||
now = timezone.now()
|
||||
|
||||
def mock_stat(path):
|
||||
if "_2" in path:
|
||||
raise OSError("No such file")
|
||||
result = MagicMock()
|
||||
if path.endswith('.mkv'):
|
||||
result.st_size = 1000000 # MKV has data
|
||||
else:
|
||||
result.st_size = 0
|
||||
return result
|
||||
|
||||
with patch("os.stat", side_effect=mock_stat), \
|
||||
patch("os.makedirs"):
|
||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||
|
||||
self.assertIn("_2", final, "Should increment counter when MKV file has data")
|
||||
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||
return_value="TV/{show}/{start}.mkv")
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
|
||||
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
|
||||
def test_no_collision_when_both_zero_bytes(self, _tv, _fb):
|
||||
"""Both MKV and TS exist but are 0 bytes — no collision."""
|
||||
ch = MagicMock(name="TestCh")
|
||||
ch.name = "TestCh"
|
||||
program = {"title": "My Show"}
|
||||
now = timezone.now()
|
||||
|
||||
def mock_stat(path):
|
||||
result = MagicMock()
|
||||
result.st_size = 0 # All files empty
|
||||
return result
|
||||
|
||||
with patch("os.stat", side_effect=mock_stat), \
|
||||
patch("os.makedirs"):
|
||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||
|
||||
self.assertNotIn("_2", final, "Should NOT increment when all files are empty")
|
||||
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||
return_value="TV/{show}/{start}.mkv")
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
|
||||
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
|
||||
def test_collision_increments_to_3_when_2_also_occupied(self, _tv, _fb):
|
||||
"""When both base and _2 are occupied, should go to _3."""
|
||||
ch = MagicMock(name="TestCh")
|
||||
ch.name = "TestCh"
|
||||
program = {"title": "My Show"}
|
||||
now = timezone.now()
|
||||
|
||||
def mock_stat(path):
|
||||
if "_3" in path:
|
||||
raise OSError("No such file")
|
||||
result = MagicMock()
|
||||
if path.endswith('.ts'):
|
||||
result.st_size = 5000000
|
||||
else:
|
||||
result.st_size = 0
|
||||
return result
|
||||
|
||||
with patch("os.stat", side_effect=mock_stat), \
|
||||
patch("os.makedirs"):
|
||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||
|
||||
self.assertIn("_3", final, "Should increment to _3 when base and _2 are occupied")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2. Logo guard — _resolve_poster_for_program
|
||||
# =========================================================================
|
||||
|
||||
class LogoGuardTests(TestCase):
|
||||
"""When the program title matches the channel name, external API
|
||||
searches (VOD, TMDB, OMDb, TVMaze, iTunes) must be skipped."""
|
||||
|
||||
def _call(self, channel_name, program, channel_logo_id=None):
|
||||
from apps.channels.tasks import _resolve_poster_for_program
|
||||
return _resolve_poster_for_program(channel_name, program, channel_logo_id)
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
def test_channel_name_as_title_skips_external_apis(self, mock_get):
|
||||
"""Title = 'USA A&E SD*', channel = 'USA A&E SD*' → no external calls."""
|
||||
program = {"title": "USA A&E SD*"}
|
||||
logo_id, url = self._call("USA A&E SD*", program, channel_logo_id=42)
|
||||
|
||||
# Should NOT have called any external APIs
|
||||
mock_get.assert_not_called()
|
||||
# Should fall back to channel logo
|
||||
self.assertEqual(logo_id, 42)
|
||||
self.assertIsNone(url)
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
def test_channel_name_normalized_match(self, mock_get):
|
||||
"""Title = 'fox news', channel = 'FOX-News*' → normalized match, skip APIs."""
|
||||
program = {"title": "fox news"}
|
||||
logo_id, url = self._call("FOX-News*", program, channel_logo_id=99)
|
||||
|
||||
mock_get.assert_not_called()
|
||||
self.assertEqual(logo_id, 99)
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
def test_real_title_still_searched(self, mock_get):
|
||||
"""Title = 'Breaking Bad' on channel 'AMC' → should try external APIs."""
|
||||
# Mock TVMaze returning a result
|
||||
mock_resp = MagicMock(ok=True, status_code=200)
|
||||
mock_resp.json.return_value = {
|
||||
"image": {"original": "https://tvmaze.com/breaking-bad.jpg"}
|
||||
}
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
program = {"title": "Breaking Bad"}
|
||||
logo_id, url = self._call("AMC", program)
|
||||
|
||||
# Should have made at least one external API call
|
||||
self.assertTrue(mock_get.called, "Should search external APIs for real titles")
|
||||
self.assertIsNotNone(url)
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
def test_no_title_skips_to_channel_logo(self, mock_get):
|
||||
"""No title at all → falls through to channel logo, no API calls."""
|
||||
program = {}
|
||||
logo_id, url = self._call("SomeChannel", program, channel_logo_id=55)
|
||||
|
||||
mock_get.assert_not_called()
|
||||
self.assertEqual(logo_id, 55)
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
def test_epg_image_still_used_even_when_title_is_channel_name(self, mock_get):
|
||||
"""Even when title = channel name, Stage 1 (EPG images) should still work."""
|
||||
from apps.epg.models import ProgramData, EPGSource, EPGData
|
||||
|
||||
# Create an EPG source + EPGData entry + program with an icon URL
|
||||
epg_source = EPGSource.objects.create(source_type="xmltv", name="Test EPG")
|
||||
epg_data = EPGData.objects.create(tvg_id="test.ch", epg_source=epg_source)
|
||||
prog = ProgramData.objects.create(
|
||||
epg=epg_data,
|
||||
title="Test Channel HD",
|
||||
start_time=timezone.now() - timedelta(hours=1),
|
||||
end_time=timezone.now() + timedelta(hours=1),
|
||||
custom_properties={"icon": "https://epg-cdn.com/test-icon.png"},
|
||||
)
|
||||
|
||||
program = {"title": "Test Channel HD", "id": prog.id}
|
||||
|
||||
# Mock _validate_url to return True for the icon URL
|
||||
with patch("apps.channels.tasks._validate_url", return_value=True):
|
||||
logo_id, url = self._call("Test Channel HD", program, channel_logo_id=10)
|
||||
|
||||
# EPG icon should still be used (Stage 1 doesn't depend on title guard)
|
||||
self.assertEqual(url, "https://epg-cdn.com/test-icon.png")
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. Recording status lifecycle via API
|
||||
# =========================================================================
|
||||
|
||||
class RecordingStatusLifecycleTests(TestCase):
|
||||
"""Verify recording status transitions and that terminal recordings
|
||||
are properly filterable (supports the red-dot fix in guideUtils)."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = _make_channel("Status Test Channel", 200)
|
||||
self.user = _make_admin()
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def _list_recordings(self):
|
||||
from apps.channels.api_views import RecordingViewSet
|
||||
request = self.factory.get("/api/channels/recordings/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"get": "list"})
|
||||
return view(request)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_stopped_recording_has_terminal_status(self, _ws):
|
||||
"""After stop, custom_properties.status = 'stopped'."""
|
||||
from apps.channels.api_views import RecordingViewSet
|
||||
|
||||
rec = _make_recording(self.channel, custom_properties={
|
||||
"status": "recording",
|
||||
"program": {"id": 1, "title": "Live Show"},
|
||||
})
|
||||
|
||||
request = self.factory.post(f"/api/channels/recordings/{rec.id}/stop/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "stop"})
|
||||
|
||||
with patch("apps.channels.signals.revoke_task"):
|
||||
response = view(request, pk=rec.id)
|
||||
|
||||
self.assertIn(response.status_code, [200, 204])
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties.get("status"), "stopped")
|
||||
|
||||
def test_listing_includes_status_in_custom_properties(self):
|
||||
"""API listing returns custom_properties with status field."""
|
||||
_make_recording(self.channel, custom_properties={
|
||||
"status": "recording",
|
||||
"program": {"id": 1, "title": "Recording Show"},
|
||||
})
|
||||
_make_recording(self.channel, custom_properties={
|
||||
"status": "stopped",
|
||||
"program": {"id": 2, "title": "Stopped Show"},
|
||||
})
|
||||
|
||||
response = self._list_recordings()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
statuses = [r["custom_properties"].get("status") for r in response.data]
|
||||
self.assertIn("recording", statuses)
|
||||
self.assertIn("stopped", statuses)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_delete_recording_removes_from_listing(self, _ws):
|
||||
"""Deleting a recording removes it from the listing entirely."""
|
||||
from apps.channels.api_views import RecordingViewSet
|
||||
|
||||
rec = _make_recording(self.channel, custom_properties={
|
||||
"status": "stopped",
|
||||
"program": {"id": 3, "title": "To Delete"},
|
||||
})
|
||||
rec_id = rec.id
|
||||
|
||||
request = self.factory.delete(f"/api/channels/recordings/{rec_id}/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"delete": "destroy"})
|
||||
|
||||
with patch("apps.channels.signals.revoke_task"):
|
||||
response = view(request, pk=rec_id)
|
||||
|
||||
self.assertIn(response.status_code, [200, 204])
|
||||
self.assertFalse(Recording.objects.filter(id=rec_id).exists())
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 4. Concat flags — error-tolerant ffmpeg
|
||||
# =========================================================================
|
||||
|
||||
class ConcatFlagsTests(TestCase):
|
||||
"""Verify that the finalize phase uses error-tolerant ffmpeg flags
|
||||
when concatenating pre-restart segments."""
|
||||
|
||||
def test_concat_command_includes_error_tolerant_flags(self):
|
||||
"""Inspect the source code to confirm error-tolerant flags are present.
|
||||
This is a static analysis test — no ffmpeg execution needed."""
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
source = inspect.getsource(run_recording)
|
||||
|
||||
# The concat subprocess.run call must include these flags
|
||||
self.assertIn("+genpts+igndts+discardcorrupt", source,
|
||||
"Concat must use +genpts+igndts+discardcorrupt fflags")
|
||||
self.assertIn("ignore_err", source,
|
||||
"Concat must use -err_detect ignore_err")
|
||||
self.assertIn("-f", source)
|
||||
self.assertIn("concat", source)
|
||||
|
||||
def test_concat_goes_directly_to_mkv(self):
|
||||
"""Concat must produce MKV directly (not intermediate .ts) to
|
||||
preserve timestamp boundaries and avoid playback freeze at splice."""
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
source = inspect.getsource(run_recording)
|
||||
|
||||
# Must contain reset_timestamps for proper segment boundary handling
|
||||
self.assertIn("reset_timestamps", source,
|
||||
"Concat must use -reset_timestamps 1 for seamless seeking")
|
||||
# Must write directly to final_path (MKV), not an intermediate .ts
|
||||
self.assertIn("_concat_did_remux", source,
|
||||
"Concat path must set flag to skip separate remux step")
|
||||
|
||||
def test_segment_time_metadata_present(self):
|
||||
"""Verify concat uses -segment_time_metadata for boundary awareness."""
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
source = inspect.getsource(run_recording)
|
||||
|
||||
self.assertIn("segment_time_metadata", source,
|
||||
"Concat must use -segment_time_metadata 1 for segment boundary handling")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 5. Recovery skip-list
|
||||
# =========================================================================
|
||||
|
||||
class RecoverySkipListTests(TestCase):
|
||||
"""Verify that the recovery function does NOT skip 'recording' status,
|
||||
since that's the exact status recordings have when the server crashes."""
|
||||
|
||||
def test_recording_status_not_in_skip_list(self):
|
||||
"""Inspect recover_recordings_on_startup to ensure 'recording' is
|
||||
NOT treated as a terminal/skip state."""
|
||||
import inspect
|
||||
from apps.channels.tasks import recover_recordings_on_startup
|
||||
source = inspect.getsource(recover_recordings_on_startup)
|
||||
|
||||
# Find the skip condition line
|
||||
# It should be: if current_status in ("completed", "stopped"):
|
||||
# NOT: if current_status in ("completed", "stopped", "recording"):
|
||||
lines = source.split('\n')
|
||||
skip_line = None
|
||||
for line in lines:
|
||||
if 'current_status in' in line and ('completed' in line or 'stopped' in line):
|
||||
skip_line = line.strip()
|
||||
break
|
||||
|
||||
self.assertIsNotNone(skip_line, "Should find the skip-list condition")
|
||||
self.assertNotIn('"recording"', skip_line,
|
||||
"Skip list must NOT contain 'recording' — "
|
||||
"that's the status of crashed mid-stream recordings that need recovery")
|
||||
|
||||
@patch("core.utils.RedisClient")
|
||||
@patch("apps.channels.tasks.run_recording")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_recovery_processes_recording_status(self, _ws, mock_run, mock_redis_cls):
|
||||
"""A recording with status='recording' should be recovered, not skipped."""
|
||||
mock_redis_conn = MagicMock()
|
||||
mock_redis_conn.set.return_value = True # Acquire lock
|
||||
mock_redis_cls.get_client.return_value = mock_redis_conn
|
||||
|
||||
channel = _make_channel("Recovery Test", 300)
|
||||
now = timezone.now()
|
||||
rec = _make_recording(channel, custom_properties={
|
||||
"status": "recording",
|
||||
"program": {"title": "Crashed Show"},
|
||||
}, end_time=now + timedelta(hours=2))
|
||||
|
||||
from apps.channels.tasks import recover_recordings_on_startup
|
||||
|
||||
with patch("apps.channels.signals.revoke_task"):
|
||||
result = recover_recordings_on_startup()
|
||||
|
||||
# The recording should have been dispatched for recovery
|
||||
self.assertTrue(mock_run.apply_async.called,
|
||||
"Recording with status='recording' should be dispatched for recovery")
|
||||
|
||||
@patch("core.utils.RedisClient")
|
||||
@patch("apps.channels.tasks.run_recording")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_recovery_skips_stopped_recordings(self, _ws, mock_run, mock_redis_cls):
|
||||
"""A recording with status='stopped' should be skipped by recovery."""
|
||||
mock_redis_conn = MagicMock()
|
||||
mock_redis_conn.set.return_value = True
|
||||
mock_redis_cls.get_client.return_value = mock_redis_conn
|
||||
|
||||
channel = _make_channel("Recovery Skip Test", 301)
|
||||
now = timezone.now()
|
||||
rec = _make_recording(channel, custom_properties={
|
||||
"status": "stopped",
|
||||
"program": {"title": "Finished Show"},
|
||||
}, end_time=now + timedelta(hours=2))
|
||||
|
||||
from apps.channels.tasks import recover_recordings_on_startup
|
||||
with patch("apps.channels.signals.revoke_task"):
|
||||
recover_recordings_on_startup()
|
||||
|
||||
# Should NOT have dispatched a recovery task
|
||||
mock_run.apply_async.assert_not_called()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 6. Frontend red-dot filter (guideUtils.mapRecordingsByProgramId)
|
||||
# =========================================================================
|
||||
|
||||
class MapRecordingsByProgramIdTests(TestCase):
|
||||
"""These test the BACKEND side — confirming that recording status
|
||||
is preserved in the API response so the frontend can filter on it.
|
||||
|
||||
The actual frontend filtering is covered by frontend/src/pages/__tests__/DVR.test.jsx
|
||||
and the guideUtils code, but we verify the data contract here."""
|
||||
|
||||
def test_recording_custom_properties_status_persisted(self):
|
||||
"""Recording status in custom_properties survives save/load cycle."""
|
||||
channel = _make_channel("Red Dot Test", 400)
|
||||
rec = _make_recording(channel, custom_properties={
|
||||
"status": "stopped",
|
||||
"program": {"id": 42, "title": "A Show"},
|
||||
})
|
||||
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties["status"], "stopped")
|
||||
|
||||
def test_terminal_statuses_are_well_defined(self):
|
||||
"""Verify the terminal status set matches what the frontend uses."""
|
||||
# These are the statuses that should NOT show a red dot in the Guide
|
||||
terminal = {"stopped", "completed", "interrupted", "failed"}
|
||||
channel = _make_channel("Terminal Status Test", 410)
|
||||
|
||||
# Verify each status is a valid recording status
|
||||
for status in terminal:
|
||||
rec = _make_recording(channel, custom_properties={
|
||||
"status": status,
|
||||
"program": {"id": 100, "title": "Test"},
|
||||
})
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties["status"], status)
|
||||
585
apps/channels/tests/test_recording_scheduling.py
Normal file
585
apps/channels/tests/test_recording_scheduling.py
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
"""Tests for DVR recording scheduling with ClockedSchedule.
|
||||
|
||||
Uses ClockedSchedule instead of apply_async with countdown because Redis
|
||||
visibility_timeout (default 3600s) causes task redelivery for long countdowns,
|
||||
leading to duplicate recordings.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from django_celery_beat.models import ClockedSchedule, PeriodicTask
|
||||
|
||||
from apps.channels.models import Channel, Recording
|
||||
from apps.channels.signals import (
|
||||
schedule_recording_task,
|
||||
revoke_task,
|
||||
_dvr_task_name,
|
||||
)
|
||||
|
||||
|
||||
class ScheduleRecordingTaskTests(TestCase):
|
||||
"""Tests for schedule_recording_task()."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
|
||||
def tearDown(self):
|
||||
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
|
||||
ClockedSchedule.objects.all().delete()
|
||||
|
||||
@patch("apps.channels.signals.run_recording")
|
||||
def test_future_recording_creates_periodic_task(self, mock_run_recording):
|
||||
"""Recordings in the future create a ClockedSchedule + PeriodicTask."""
|
||||
future_time = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future_time,
|
||||
end_time=future_time + timedelta(hours=1),
|
||||
)
|
||||
|
||||
task_id = schedule_recording_task(rec, eta=future_time)
|
||||
|
||||
expected_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(task_id, expected_name)
|
||||
|
||||
pt = PeriodicTask.objects.get(name=expected_name)
|
||||
self.assertTrue(pt.one_off)
|
||||
self.assertTrue(pt.enabled)
|
||||
self.assertEqual(pt.task, "apps.channels.tasks.run_recording")
|
||||
self.assertIsNotNone(pt.clocked)
|
||||
|
||||
# apply_async should not have been called
|
||||
mock_run_recording.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.run_recording")
|
||||
def test_immediate_recording_creates_periodic_task(self, mock_run_recording):
|
||||
"""Recordings starting now also use ClockedSchedule for consistency."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
)
|
||||
|
||||
task_id = schedule_recording_task(rec, eta=now)
|
||||
|
||||
expected_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(task_id, expected_name)
|
||||
self.assertTrue(PeriodicTask.objects.filter(name=expected_name).exists())
|
||||
|
||||
@patch("apps.channels.signals.run_recording")
|
||||
def test_past_start_time_clamps_to_now(self, mock_run_recording):
|
||||
"""Recordings with past start_time get clamped to now."""
|
||||
past_time = timezone.now() - timedelta(minutes=5)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=past_time,
|
||||
end_time=timezone.now() + timedelta(hours=1),
|
||||
)
|
||||
|
||||
task_id = schedule_recording_task(rec, eta=past_time)
|
||||
|
||||
expected_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(task_id, expected_name)
|
||||
pt = PeriodicTask.objects.get(name=expected_name)
|
||||
# Clocked time should be >= now
|
||||
self.assertGreaterEqual(pt.clocked.clocked_time, past_time)
|
||||
|
||||
@patch("apps.channels.signals.run_recording")
|
||||
def test_reschedule_updates_existing_periodic_task(self, mock_run_recording):
|
||||
"""Calling schedule_recording_task twice updates the existing PeriodicTask."""
|
||||
future_time = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future_time,
|
||||
end_time=future_time + timedelta(hours=1),
|
||||
)
|
||||
|
||||
schedule_recording_task(rec, eta=future_time)
|
||||
|
||||
# Reschedule with a different time
|
||||
new_eta = future_time + timedelta(hours=1)
|
||||
schedule_recording_task(rec, eta=new_eta)
|
||||
|
||||
# Should still be exactly one PeriodicTask
|
||||
task_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(PeriodicTask.objects.filter(name=task_name).count(), 1)
|
||||
|
||||
@patch("apps.channels.signals.run_recording")
|
||||
def test_naive_eta_is_made_aware(self, mock_run_recording):
|
||||
"""A naive (timezone-unaware) eta is made timezone-aware."""
|
||||
from datetime import datetime
|
||||
naive_eta = datetime(2030, 6, 15, 14, 0, 0)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=timezone.now() + timedelta(hours=1),
|
||||
end_time=timezone.now() + timedelta(hours=2),
|
||||
)
|
||||
|
||||
task_id = schedule_recording_task(rec, eta=naive_eta)
|
||||
|
||||
expected_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(task_id, expected_name)
|
||||
pt = PeriodicTask.objects.get(name=expected_name)
|
||||
self.assertTrue(timezone.is_aware(pt.clocked.clocked_time))
|
||||
|
||||
|
||||
class RevokeTaskTests(TestCase):
|
||||
"""Tests for revoke_task()."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
|
||||
def tearDown(self):
|
||||
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
|
||||
ClockedSchedule.objects.all().delete()
|
||||
|
||||
def test_revoke_deletes_periodic_task_and_clocked_schedule(self):
|
||||
"""revoke_task deletes the PeriodicTask and orphaned ClockedSchedule."""
|
||||
eta = timezone.now() + timedelta(hours=5)
|
||||
clocked = ClockedSchedule.objects.create(clocked_time=eta)
|
||||
PeriodicTask.objects.create(
|
||||
name="dvr-recording-10",
|
||||
task="apps.channels.tasks.run_recording",
|
||||
clocked=clocked,
|
||||
one_off=True,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
revoke_task("dvr-recording-10")
|
||||
|
||||
self.assertFalse(PeriodicTask.objects.filter(name="dvr-recording-10").exists())
|
||||
self.assertFalse(ClockedSchedule.objects.filter(id=clocked.id).exists())
|
||||
|
||||
def test_revoke_keeps_shared_clocked_schedule(self):
|
||||
"""ClockedSchedule is kept if another PeriodicTask still references it."""
|
||||
eta = timezone.now() + timedelta(hours=5)
|
||||
clocked = ClockedSchedule.objects.create(clocked_time=eta)
|
||||
PeriodicTask.objects.create(
|
||||
name="dvr-recording-10",
|
||||
task="apps.channels.tasks.run_recording",
|
||||
clocked=clocked,
|
||||
one_off=True,
|
||||
)
|
||||
PeriodicTask.objects.create(
|
||||
name="dvr-recording-11",
|
||||
task="apps.channels.tasks.run_recording",
|
||||
clocked=clocked,
|
||||
one_off=True,
|
||||
)
|
||||
|
||||
revoke_task("dvr-recording-10")
|
||||
|
||||
self.assertFalse(PeriodicTask.objects.filter(name="dvr-recording-10").exists())
|
||||
self.assertTrue(ClockedSchedule.objects.filter(id=clocked.id).exists())
|
||||
|
||||
@patch("apps.channels.signals.AsyncResult")
|
||||
def test_revoke_falls_back_to_async_result_for_legacy_ids(self, mock_async_result):
|
||||
"""revoke_task falls back to AsyncResult.revoke() for old-style UUIDs."""
|
||||
revoke_task("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
mock_async_result.assert_called_once_with("550e8400-e29b-41d4-a716-446655440000")
|
||||
mock_async_result.return_value.revoke.assert_called_once()
|
||||
|
||||
def test_revoke_none_is_noop(self):
|
||||
"""revoke_task(None) does nothing."""
|
||||
revoke_task(None) # Should not raise
|
||||
|
||||
def test_revoke_empty_string_is_noop(self):
|
||||
"""revoke_task('') does nothing."""
|
||||
revoke_task("") # Should not raise
|
||||
|
||||
|
||||
class DvrTaskNameTests(TestCase):
|
||||
"""Tests for the naming convention helper."""
|
||||
|
||||
def test_task_name_format(self):
|
||||
self.assertEqual(_dvr_task_name(42), "dvr-recording-42")
|
||||
|
||||
def test_task_name_fits_in_charfield(self):
|
||||
name = _dvr_task_name(999999999)
|
||||
self.assertLessEqual(len(name), 255)
|
||||
|
||||
|
||||
class SignalIntegrationTests(TestCase):
|
||||
"""Integration tests for the post_save / post_delete signal handlers."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
|
||||
def tearDown(self):
|
||||
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
|
||||
ClockedSchedule.objects.all().delete()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_post_save_creates_periodic_task_for_future_recording(self, mock_artwork):
|
||||
"""Saving a future Recording creates a PeriodicTask via post_save signal."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
)
|
||||
|
||||
rec.refresh_from_db()
|
||||
task_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(rec.task_id, task_name)
|
||||
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_post_delete_removes_periodic_task(self, mock_artwork):
|
||||
"""Deleting a Recording removes its PeriodicTask."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
)
|
||||
|
||||
rec.refresh_from_db()
|
||||
task_name = rec.task_id
|
||||
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
|
||||
|
||||
rec.delete()
|
||||
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_bulk_delete_cleans_up_all_periodic_tasks(self, mock_artwork):
|
||||
"""Bulk deleting recordings cleans up all their PeriodicTasks."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec_ids = []
|
||||
for i in range(5):
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future + timedelta(hours=i),
|
||||
end_time=future + timedelta(hours=i + 1),
|
||||
)
|
||||
rec_ids.append(rec.id)
|
||||
|
||||
for rid in rec_ids:
|
||||
self.assertTrue(
|
||||
PeriodicTask.objects.filter(name=f"dvr-recording-{rid}").exists()
|
||||
)
|
||||
|
||||
Recording.objects.filter(channel=self.channel).delete()
|
||||
|
||||
self.assertEqual(
|
||||
PeriodicTask.objects.filter(name__startswith="dvr-recording-").count(), 0
|
||||
)
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_post_save_schedules_currently_playing_recording(self, mock_artwork):
|
||||
"""A recording with past start_time but future end_time schedules immediately."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
past_start = timezone.now() - timedelta(minutes=30)
|
||||
future_end = timezone.now() + timedelta(minutes=30)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=past_start,
|
||||
end_time=future_end,
|
||||
)
|
||||
|
||||
rec.refresh_from_db()
|
||||
task_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(rec.task_id, task_name)
|
||||
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_post_save_skips_fully_past_recording(self, mock_artwork):
|
||||
"""A recording with both start_time and end_time in the past is not scheduled."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
past_start = timezone.now() - timedelta(hours=2)
|
||||
past_end = timezone.now() - timedelta(hours=1)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=past_start,
|
||||
end_time=past_end,
|
||||
)
|
||||
|
||||
rec.refresh_from_db()
|
||||
self.assertIsNone(rec.task_id)
|
||||
self.assertFalse(
|
||||
PeriodicTask.objects.filter(name=f"dvr-recording-{rec.id}").exists()
|
||||
)
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_pre_save_revokes_on_time_change(self, mock_artwork):
|
||||
"""Changing a recording's start_time revokes the old task and creates a new one."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
)
|
||||
|
||||
rec.refresh_from_db()
|
||||
old_task_name = rec.task_id
|
||||
self.assertTrue(PeriodicTask.objects.filter(name=old_task_name).exists())
|
||||
|
||||
# Change the start time — pre_save clears task_id, post_save reschedules
|
||||
new_future = future + timedelta(hours=3)
|
||||
rec.start_time = new_future
|
||||
rec.end_time = new_future + timedelta(hours=1)
|
||||
rec.save()
|
||||
|
||||
rec.refresh_from_db()
|
||||
# Old PeriodicTask should be deleted; new one should exist
|
||||
self.assertIsNotNone(rec.task_id)
|
||||
self.assertTrue(
|
||||
PeriodicTask.objects.filter(name=f"dvr-recording-{rec.id}").exists()
|
||||
)
|
||||
|
||||
|
||||
class IdempotencyGuardTests(TestCase):
|
||||
"""Tests for the idempotency guard in run_recording()."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
|
||||
@patch("apps.channels.tasks.get_channel_layer")
|
||||
def test_skips_if_already_recording(self, mock_layer):
|
||||
"""run_recording returns early if status is already 'recording'."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "recording", "started_at": str(now)},
|
||||
)
|
||||
|
||||
from apps.channels.tasks import run_recording as run_rec_task
|
||||
result = run_rec_task(rec.id, self.channel.id, str(now), str(now + timedelta(hours=1)))
|
||||
|
||||
self.assertIsNone(result)
|
||||
# get_channel_layer should not have been called (returned before)
|
||||
mock_layer.assert_not_called()
|
||||
|
||||
@patch("apps.channels.tasks.get_channel_layer")
|
||||
def test_skips_if_already_completed(self, mock_layer):
|
||||
"""run_recording returns early if status is already 'completed'."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=2),
|
||||
end_time=now - timedelta(hours=1),
|
||||
custom_properties={"status": "completed"},
|
||||
)
|
||||
|
||||
from apps.channels.tasks import run_recording as run_rec_task
|
||||
result = run_rec_task(rec.id, self.channel.id, str(rec.start_time), str(rec.end_time))
|
||||
|
||||
self.assertIsNone(result)
|
||||
mock_layer.assert_not_called()
|
||||
|
||||
@patch("apps.channels.tasks.get_channel_layer")
|
||||
def test_skips_if_already_stopped(self, mock_layer):
|
||||
"""run_recording returns early if status is already 'stopped' (user stopped it early)."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "stopped", "stopped_at": str(now)},
|
||||
)
|
||||
|
||||
from apps.channels.tasks import run_recording as run_rec_task
|
||||
result = run_rec_task(rec.id, self.channel.id, str(rec.start_time), str(rec.end_time))
|
||||
|
||||
self.assertIsNone(result)
|
||||
mock_layer.assert_not_called()
|
||||
|
||||
|
||||
class ArtworkPrefetchSignalGuardTests(TestCase):
|
||||
"""Tests that the post_save signal does not schedule artwork prefetch when
|
||||
the recording is in an active or terminal state."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
|
||||
def tearDown(self):
|
||||
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
|
||||
ClockedSchedule.objects.all().delete()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_artwork_prefetch_not_scheduled_when_status_recording(self, mock_artwork):
|
||||
"""post_save must NOT schedule artwork prefetch when status='recording'
|
||||
to prevent a race that overwrites the running task's status updates."""
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
custom_properties={"status": "recording"},
|
||||
)
|
||||
|
||||
# Simulate a save that run_recording itself might do mid-recording
|
||||
rec.custom_properties = {"status": "recording", "file_path": "/data/recordings/test.mkv"}
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
|
||||
# apply_async was not called for the "recording" save
|
||||
mock_artwork.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_artwork_prefetch_not_scheduled_when_status_completed(self, mock_artwork):
|
||||
"""post_save must NOT schedule artwork prefetch when status='completed'."""
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
custom_properties={"status": "completed"},
|
||||
)
|
||||
|
||||
rec.custom_properties = {"status": "completed"}
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
|
||||
mock_artwork.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_artwork_prefetch_not_scheduled_when_status_stopped(self, mock_artwork):
|
||||
"""post_save must NOT schedule artwork prefetch when status='stopped'."""
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
custom_properties={"status": "stopped"},
|
||||
)
|
||||
|
||||
rec.custom_properties = {"status": "stopped"}
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
|
||||
mock_artwork.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_artwork_prefetch_scheduled_for_new_upcoming_recording(self, mock_artwork):
|
||||
"""post_save SHOULD schedule artwork prefetch for a newly created upcoming recording."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
custom_properties={}, # no status yet — should trigger prefetch
|
||||
)
|
||||
|
||||
self.assertTrue(mock_artwork.apply_async.called)
|
||||
|
||||
|
||||
class DestroyDvrClientIsolationTests(TestCase):
|
||||
"""Tests that deleting a recording only stops DVR clients when the
|
||||
recording is actively streaming — never for completed/upcoming recordings
|
||||
that could share a channel with an unrelated in-progress recording."""
|
||||
|
||||
def setUp(self):
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="dvr_test_admin", password="pass",
|
||||
user_level=User.UserLevel.ADMIN,
|
||||
)
|
||||
self.factory = APIRequestFactory()
|
||||
self.force_authenticate = force_authenticate
|
||||
|
||||
def _delete_recording(self, rec):
|
||||
from apps.channels.api_views import RecordingViewSet
|
||||
request = self.factory.delete(f"/api/channels/recordings/{rec.id}/")
|
||||
self.force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"delete": "destroy"})
|
||||
return view(request, pk=rec.id)
|
||||
|
||||
@patch("apps.channels.api_views._stop_dvr_clients")
|
||||
def test_destroy_completed_recording_does_not_stop_dvr_clients(self, mock_stop):
|
||||
"""Deleting a completed recording must NOT call _stop_dvr_clients."""
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=timezone.now() - timedelta(hours=2),
|
||||
end_time=timezone.now() - timedelta(hours=1),
|
||||
custom_properties={"status": "completed", "file_path": "/data/recordings/test.mkv"},
|
||||
)
|
||||
self._delete_recording(rec)
|
||||
mock_stop.assert_not_called()
|
||||
|
||||
@patch("apps.channels.api_views._stop_dvr_clients")
|
||||
def test_destroy_upcoming_recording_does_not_stop_dvr_clients(self, mock_stop):
|
||||
"""Deleting an upcoming (scheduled) recording must NOT call _stop_dvr_clients."""
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
custom_properties={},
|
||||
)
|
||||
self._delete_recording(rec)
|
||||
mock_stop.assert_not_called()
|
||||
|
||||
@patch("apps.channels.api_views._stop_dvr_clients")
|
||||
def test_destroy_active_recording_does_stop_dvr_clients(self, mock_stop):
|
||||
"""Deleting an in-progress recording MUST call _stop_dvr_clients."""
|
||||
mock_stop.return_value = 1
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(minutes=5),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "recording"},
|
||||
)
|
||||
self._delete_recording(rec)
|
||||
mock_stop.assert_called_once_with(str(self.channel.uuid), recording_id=rec.id)
|
||||
|
||||
|
||||
class PeriodicTaskCleanupOnExecutionTests(TestCase):
|
||||
"""Tests for PeriodicTask cleanup when run_recording starts."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
|
||||
def tearDown(self):
|
||||
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
|
||||
ClockedSchedule.objects.all().delete()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
@patch("apps.channels.tasks.get_channel_layer")
|
||||
def test_periodic_task_cleaned_up_on_execution(self, mock_layer, mock_artwork):
|
||||
"""When run_recording executes, it deletes its own PeriodicTask."""
|
||||
mock_layer.return_value = MagicMock()
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
custom_properties={},
|
||||
)
|
||||
|
||||
# post_save signal should have created the PeriodicTask
|
||||
task_name = f"dvr-recording-{rec.id}"
|
||||
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
|
||||
pt = PeriodicTask.objects.get(name=task_name)
|
||||
clocked_id = pt.clocked_id
|
||||
|
||||
from apps.channels.tasks import run_recording as run_rec_task
|
||||
# This will proceed past guards, clean up the PeriodicTask, then
|
||||
# eventually fail on the actual stream connection (expected)
|
||||
try:
|
||||
run_rec_task(rec.id, self.channel.id, str(future), str(future + timedelta(hours=1)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
||||
self.assertFalse(ClockedSchedule.objects.filter(id=clocked_id).exists())
|
||||
356
apps/channels/tests/test_recording_stop_cancel.py
Normal file
356
apps/channels/tests/test_recording_stop_cancel.py
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
"""Tests for the DVR Stop/Cancel feature set.
|
||||
|
||||
Covers:
|
||||
- stop() endpoint
|
||||
- destroy() was_in_progress field in recording_cancelled WebSocket event
|
||||
- signals.py update_fields re-entrancy guard
|
||||
- run_recording race guard before status write
|
||||
- _stop_dvr_clients() DVR client isolation
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from apps.channels.models import Channel, Recording
|
||||
from apps.channels.api_views import RecordingViewSet, _stop_dvr_clients
|
||||
|
||||
|
||||
def _make_admin():
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
u, _ = User.objects.get_or_create(
|
||||
username="stop_test_admin",
|
||||
defaults={"user_level": User.UserLevel.ADMIN},
|
||||
)
|
||||
u.set_password("pass")
|
||||
u.save()
|
||||
return u
|
||||
|
||||
|
||||
def _async_channel_layer_mock():
|
||||
layer = MagicMock()
|
||||
layer.group_send = AsyncMock()
|
||||
return layer
|
||||
|
||||
|
||||
class StopEndpointTests(TestCase):
|
||||
"""Tests for POST /api/channels/recordings/{id}/stop/"""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=99, name="Stop Test Channel")
|
||||
self.user = _make_admin()
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def _stop(self, rec):
|
||||
request = self.factory.post(f"/api/channels/recordings/{rec.id}/stop/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "stop"})
|
||||
return view(request, pk=rec.id)
|
||||
|
||||
def _make_rec(self, status="recording"):
|
||||
now = timezone.now()
|
||||
return Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": status},
|
||||
)
|
||||
|
||||
@patch("core.utils.send_websocket_update")
|
||||
@patch("threading.Thread")
|
||||
def test_stop_writes_status_to_db_before_returning(self, mock_thread, mock_ws):
|
||||
"""DB write is synchronous — run_recording polls for this."""
|
||||
mock_thread.return_value.start = MagicMock()
|
||||
rec = self._make_rec()
|
||||
response = self._stop(rec)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data.get("success"))
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties.get("status"), "stopped")
|
||||
|
||||
@patch("core.utils.send_websocket_update")
|
||||
@patch("threading.Thread")
|
||||
def test_stop_writes_stopped_at_timestamp(self, mock_thread, mock_ws):
|
||||
mock_thread.return_value.start = MagicMock()
|
||||
rec = self._make_rec()
|
||||
self._stop(rec)
|
||||
rec.refresh_from_db()
|
||||
self.assertIn("stopped_at", rec.custom_properties)
|
||||
|
||||
def test_stop_calls_stop_dvr_clients_in_background(self):
|
||||
"""stop() spawns a background thread whose target calls _stop_dvr_clients."""
|
||||
rec = self._make_rec()
|
||||
|
||||
with patch("apps.channels.api_views._stop_dvr_clients", return_value=1) as mock_stop, \
|
||||
patch("core.utils.send_websocket_update"), \
|
||||
patch("threading.Thread") as mock_thread:
|
||||
mock_thread.return_value.start = MagicMock()
|
||||
self._stop(rec)
|
||||
|
||||
# Verify a daemon thread was spawned
|
||||
mock_thread.assert_called_once()
|
||||
thread_kwargs = mock_thread.call_args[1]
|
||||
self.assertTrue(thread_kwargs.get("daemon"), "Thread must be daemon")
|
||||
|
||||
# Execute the captured target with DB connection close patched out
|
||||
target = thread_kwargs["target"]
|
||||
with patch("apps.channels.api_views._stop_dvr_clients", return_value=1) as mock_stop2, \
|
||||
patch("apps.channels.signals.revoke_task", side_effect=Exception("skip")), \
|
||||
patch("django.db.connection") as mock_conn:
|
||||
target()
|
||||
|
||||
self.assertTrue(mock_stop2.called)
|
||||
args, kwargs = mock_stop2.call_args
|
||||
actual_rec_id = kwargs.get("recording_id") or (args[1] if len(args) > 1 else None)
|
||||
self.assertEqual(actual_rec_id, rec.id)
|
||||
|
||||
def test_stop_returns_404_for_nonexistent(self):
|
||||
request = self.factory.post("/api/channels/recordings/99999/stop/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "stop"})
|
||||
self.assertEqual(view(request, pk=99999).status_code, 404)
|
||||
|
||||
@patch("core.utils.send_websocket_update")
|
||||
@patch("threading.Thread")
|
||||
def test_stop_idempotent_on_already_stopped(self, mock_thread, mock_ws):
|
||||
mock_thread.return_value.start = MagicMock()
|
||||
rec = self._make_rec(status="stopped")
|
||||
self.assertEqual(self._stop(rec).status_code, 200)
|
||||
|
||||
|
||||
class CancelDestroyWasInProgressTests(TestCase):
|
||||
"""was_in_progress field in the recording_cancelled WebSocket event."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=98, name="Cancel Test Channel")
|
||||
self.user = _make_admin()
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def _delete(self, rec):
|
||||
request = self.factory.delete(f"/api/channels/recordings/{rec.id}/")
|
||||
force_authenticate(request, user=self.user)
|
||||
return RecordingViewSet.as_view({"delete": "destroy"})(request, pk=rec.id)
|
||||
|
||||
@patch("apps.channels.api_views._stop_dvr_clients", return_value=1)
|
||||
@patch("core.utils.send_websocket_update")
|
||||
def test_in_progress_sends_was_in_progress_true(self, mock_ws, _):
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(minutes=10),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "recording"},
|
||||
)
|
||||
self._delete(rec)
|
||||
payload = mock_ws.call_args[0][2]
|
||||
self.assertEqual(payload["type"], "recording_cancelled")
|
||||
self.assertTrue(payload["was_in_progress"])
|
||||
|
||||
@patch("core.utils.send_websocket_update")
|
||||
def test_completed_sends_was_in_progress_false(self, mock_ws):
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=timezone.now() - timedelta(hours=2),
|
||||
end_time=timezone.now() - timedelta(hours=1),
|
||||
custom_properties={"status": "completed"},
|
||||
)
|
||||
self._delete(rec)
|
||||
self.assertFalse(mock_ws.call_args[0][2]["was_in_progress"])
|
||||
|
||||
|
||||
class SignalUpdateFieldsReentrancyGuardTests(TestCase):
|
||||
"""update_fields guard in schedule_task_on_save prevents redundant WS events."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=97, name="Signal Guard Channel")
|
||||
|
||||
def _create_upcoming(self):
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
return Recording.objects.create(
|
||||
channel=self.channel, start_time=future,
|
||||
end_time=future + timedelta(hours=1), custom_properties={},
|
||||
)
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_custom_properties_save_skips_artwork(self, mock_artwork):
|
||||
rec = self._create_upcoming()
|
||||
mock_artwork.reset_mock()
|
||||
rec.custom_properties = {"poster_url": "https://example.com/p.jpg"}
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
mock_artwork.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_task_id_save_skips_artwork(self, mock_artwork):
|
||||
rec = self._create_upcoming()
|
||||
mock_artwork.reset_mock()
|
||||
rec.task_id = "dvr-recording-999"
|
||||
rec.save(update_fields=["task_id"])
|
||||
mock_artwork.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_combined_metadata_save_skips_artwork(self, mock_artwork):
|
||||
rec = self._create_upcoming()
|
||||
mock_artwork.reset_mock()
|
||||
rec.task_id = "dvr-recording-1000"
|
||||
rec.custom_properties = {"poster_url": "x"}
|
||||
rec.save(update_fields=["custom_properties", "task_id"])
|
||||
mock_artwork.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_creation_dispatches_artwork(self, mock_artwork):
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
self._create_upcoming()
|
||||
self.assertTrue(mock_artwork.apply_async.called)
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_scheduling_field_update_dispatches_artwork(self, mock_artwork):
|
||||
"""save(update_fields=['start_time']) is not a metadata save — dispatch runs."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
rec = self._create_upcoming()
|
||||
mock_artwork.reset_mock()
|
||||
future = timezone.now() + timedelta(hours=3)
|
||||
rec.start_time = future
|
||||
rec.end_time = future + timedelta(hours=1)
|
||||
rec.save(update_fields=["start_time", "end_time"])
|
||||
mock_artwork.apply_async.assert_called()
|
||||
|
||||
|
||||
class RunRecordingRaceGuardTests(TestCase):
|
||||
"""Race guard: stop() fires between idempotency check and status write."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=96, name="Race Guard Channel")
|
||||
|
||||
def test_race_guard_exits_when_stopped_at_db_read(self):
|
||||
"""If Recording.objects.get() shows 'stopped', the task must exit
|
||||
without writing 'recording' to the DB."""
|
||||
from apps.channels.tasks import run_recording as run_rec
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(minutes=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={},
|
||||
)
|
||||
mock_layer = _async_channel_layer_mock()
|
||||
original_get = Recording.objects.get
|
||||
|
||||
def patched_get(*args, **kwargs):
|
||||
obj = original_get(*args, **kwargs)
|
||||
if kwargs.get("id") == rec.id or (args and args[0] == rec.id):
|
||||
obj.custom_properties = {"status": "stopped"}
|
||||
return obj
|
||||
|
||||
with patch("apps.channels.tasks.get_channel_layer", return_value=mock_layer), \
|
||||
patch("core.utils.log_system_event", side_effect=Exception("skip")), \
|
||||
patch.object(Recording.objects, "get", side_effect=patched_get):
|
||||
result = run_rec(
|
||||
rec.id, self.channel.id, str(rec.start_time), str(rec.end_time),
|
||||
)
|
||||
|
||||
self.assertIsNone(result)
|
||||
rec.refresh_from_db()
|
||||
self.assertNotEqual(
|
||||
rec.custom_properties.get("status"), "recording",
|
||||
"Race guard failed: task overwrote 'stopped' with 'recording'",
|
||||
)
|
||||
|
||||
def test_idempotency_guard_catches_stopped_before_channel_layer(self):
|
||||
"""When status='stopped' at the idempotency check, get_channel_layer is never called."""
|
||||
from apps.channels.tasks import run_recording as run_rec
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(minutes=5),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "stopped"},
|
||||
)
|
||||
with patch("apps.channels.tasks.get_channel_layer") as mock_get_layer:
|
||||
result = run_rec(
|
||||
rec.id, self.channel.id, str(rec.start_time), str(rec.end_time),
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
mock_get_layer.assert_not_called()
|
||||
|
||||
|
||||
class StopDvrClientsTests(TestCase):
|
||||
"""_stop_dvr_clients() DVR client isolation."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=95, name="DVR Clients Channel")
|
||||
self._redis = "core.utils.RedisClient"
|
||||
self._sc = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_client"
|
||||
self._sch = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_channel"
|
||||
|
||||
def _mock_redis(self, client_ids, ua_map):
|
||||
r = MagicMock()
|
||||
r.smembers.return_value = {c.encode() for c in client_ids}
|
||||
def hget_side(key, field):
|
||||
ks = key if isinstance(key, str) else key.decode("utf-8", errors="replace")
|
||||
for cid, ua in ua_map.items():
|
||||
if cid in ks:
|
||||
return ua.encode() if isinstance(ua, str) else ua
|
||||
return b""
|
||||
r.hget.side_effect = hget_side
|
||||
return r
|
||||
|
||||
def test_returns_zero_when_redis_none(self):
|
||||
with patch(self._redis) as rc:
|
||||
rc.get_client.return_value = None
|
||||
self.assertEqual(_stop_dvr_clients(str(self.channel.uuid)), 0)
|
||||
|
||||
def test_stops_only_matching_client_when_recording_id_given(self):
|
||||
r = self._mock_redis(
|
||||
["client-a", "client-b"],
|
||||
{"client-a": "Dispatcharr-DVR/recording-42",
|
||||
"client-b": "Dispatcharr-DVR/recording-99"},
|
||||
)
|
||||
with patch(self._redis) as rc, patch(self._sc) as sc:
|
||||
rc.get_client.return_value = r
|
||||
result = _stop_dvr_clients(str(self.channel.uuid), recording_id=42)
|
||||
self.assertEqual(result, 1)
|
||||
stopped = [c[0][1] for c in sc.call_args_list]
|
||||
self.assertIn("client-a", stopped)
|
||||
self.assertNotIn("client-b", stopped)
|
||||
|
||||
def test_stops_all_dvr_clients_without_recording_id(self):
|
||||
r = self._mock_redis(
|
||||
["client-a", "client-b"],
|
||||
{"client-a": "Dispatcharr-DVR/recording-42",
|
||||
"client-b": "Dispatcharr-DVR/recording-99"},
|
||||
)
|
||||
with patch(self._redis) as rc, patch(self._sc) as sc:
|
||||
rc.get_client.return_value = r
|
||||
result = _stop_dvr_clients(str(self.channel.uuid))
|
||||
self.assertEqual(result, 2)
|
||||
|
||||
def test_skips_non_dvr_clients(self):
|
||||
r = self._mock_redis(
|
||||
["viewer", "dvr-client"],
|
||||
{"viewer": "Mozilla/5.0", "dvr-client": "Dispatcharr-DVR/recording-1"},
|
||||
)
|
||||
with patch(self._redis) as rc, patch(self._sc) as sc:
|
||||
rc.get_client.return_value = r
|
||||
result = _stop_dvr_clients(str(self.channel.uuid))
|
||||
self.assertEqual(result, 1)
|
||||
stopped = [c[0][1] for c in sc.call_args_list]
|
||||
self.assertNotIn("viewer", stopped)
|
||||
|
||||
def test_returns_zero_for_empty_channel(self):
|
||||
r = MagicMock()
|
||||
r.smembers.return_value = set()
|
||||
with patch(self._redis) as rc, patch(self._sc) as sc:
|
||||
rc.get_client.return_value = r
|
||||
self.assertEqual(_stop_dvr_clients(str(self.channel.uuid)), 0)
|
||||
sc.assert_not_called()
|
||||
|
||||
def test_never_calls_stop_channel(self):
|
||||
"""Must not stop the whole channel proxy — only individual clients."""
|
||||
r = self._mock_redis(["dvr-1"], {"dvr-1": "Dispatcharr-DVR/recording-1"})
|
||||
with patch(self._redis) as rc, patch(self._sc), patch(self._sch) as sch:
|
||||
rc.get_client.return_value = r
|
||||
_stop_dvr_clients(str(self.channel.uuid))
|
||||
sch.assert_not_called()
|
||||
324
apps/channels/tests/test_ts_proxy_keepalive.py
Normal file
324
apps/channels/tests/test_ts_proxy_keepalive.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"""Tests for ts_proxy keepalive and stats-update behavior.
|
||||
|
||||
Covers:
|
||||
- stream_generator._should_send_keepalive() owner vs non-owner worker paths
|
||||
- stream_generator._should_send_keepalive() Redis last_data health check
|
||||
- client_manager._do_stats_update() error handling and WebSocket dispatch
|
||||
- client_manager.remove_client() non-blocking stats update
|
||||
- Keepalive/DVR-timeout timing invariants
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _should_send_keepalive: owner worker path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OwnerWorkerKeepaliveTests(TestCase):
|
||||
"""Owner worker has a stream_manager; keepalive logic uses it directly."""
|
||||
|
||||
def _make_generator(self, healthy, at_buffer_head, consecutive_empty):
|
||||
from apps.proxy.ts_proxy.stream_generator import StreamGenerator
|
||||
gen = StreamGenerator.__new__(StreamGenerator)
|
||||
gen.channel_id = "00000000-0000-0000-0000-000000000001"
|
||||
gen.client_id = "test-client"
|
||||
|
||||
buffer = MagicMock()
|
||||
buffer.index = 10 if at_buffer_head else 100
|
||||
gen.local_index = 10
|
||||
gen.buffer = buffer
|
||||
|
||||
stream_manager = MagicMock()
|
||||
stream_manager.healthy = healthy
|
||||
gen.stream_manager = stream_manager
|
||||
|
||||
gen.consecutive_empty = consecutive_empty
|
||||
return gen
|
||||
|
||||
def test_owner_healthy_returns_false(self):
|
||||
"""Owner worker, healthy stream -> no keepalive."""
|
||||
gen = self._make_generator(healthy=True, at_buffer_head=True, consecutive_empty=10)
|
||||
self.assertFalse(gen._should_send_keepalive(gen.local_index))
|
||||
|
||||
def test_owner_unhealthy_at_head_returns_true(self):
|
||||
"""Owner worker, unhealthy stream, at buffer head -> send keepalive."""
|
||||
gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=10)
|
||||
self.assertTrue(gen._should_send_keepalive(gen.local_index))
|
||||
|
||||
def test_owner_unhealthy_not_at_head_returns_false(self):
|
||||
"""Owner worker, unhealthy stream, but NOT at buffer head -> no keepalive."""
|
||||
gen = self._make_generator(healthy=False, at_buffer_head=False, consecutive_empty=10)
|
||||
self.assertFalse(gen._should_send_keepalive(gen.local_index))
|
||||
|
||||
def test_owner_insufficient_consecutive_empty_returns_false(self):
|
||||
"""Owner worker, unhealthy, at head but consecutive_empty < 5 -> no keepalive."""
|
||||
gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=3)
|
||||
self.assertFalse(gen._should_send_keepalive(gen.local_index))
|
||||
|
||||
def test_owner_exactly_5_consecutive_empty_returns_true(self):
|
||||
"""consecutive_empty == 5 is the minimum threshold."""
|
||||
gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=5)
|
||||
self.assertTrue(gen._should_send_keepalive(gen.local_index))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _should_send_keepalive: non-owner worker path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class NonOwnerWorkerKeepaliveTests(TestCase):
|
||||
"""Non-owner worker has stream_manager=None; health determined from Redis."""
|
||||
|
||||
def _make_generator(self, consecutive_empty=10):
|
||||
from apps.proxy.ts_proxy.stream_generator import StreamGenerator
|
||||
gen = StreamGenerator.__new__(StreamGenerator)
|
||||
gen.channel_id = "00000000-0000-0000-0000-000000000002"
|
||||
gen.client_id = "test-client-nonowner"
|
||||
|
||||
buffer = MagicMock()
|
||||
buffer.index = 10
|
||||
gen.local_index = 10
|
||||
gen.buffer = buffer
|
||||
|
||||
gen.stream_manager = None # non-owner worker
|
||||
gen.consecutive_empty = consecutive_empty
|
||||
return gen
|
||||
|
||||
def _mock_proxy_server(self, last_data_value):
|
||||
"""Return a mock ProxyServer with a redis_client pre-configured."""
|
||||
server = MagicMock()
|
||||
redis_client = MagicMock()
|
||||
server.redis_client = redis_client
|
||||
redis_client.get.return_value = last_data_value
|
||||
return server
|
||||
|
||||
def test_non_owner_fresh_data_returns_false(self):
|
||||
"""Non-owner, last_data < 10s ago -> stream healthy -> no keepalive."""
|
||||
gen = self._make_generator()
|
||||
fresh_ts = str(time.time() - 2.0).encode()
|
||||
server = self._mock_proxy_server(fresh_ts)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertFalse(result, "Fresh data should NOT trigger keepalive")
|
||||
|
||||
def test_non_owner_stale_data_returns_true(self):
|
||||
"""Non-owner, last_data >= 10s ago -> stream unhealthy -> send keepalive."""
|
||||
gen = self._make_generator()
|
||||
stale_ts = str(time.time() - 12.0).encode()
|
||||
server = self._mock_proxy_server(stale_ts)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertTrue(result, "Stale data (12s) should trigger keepalive")
|
||||
|
||||
def test_non_owner_exactly_at_timeout_returns_true(self):
|
||||
"""Data age exactly equal to CONNECTION_TIMEOUT (10s) -> send keepalive."""
|
||||
gen = self._make_generator()
|
||||
ts = str(time.time() - 10.0).encode()
|
||||
server = self._mock_proxy_server(ts)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertTrue(result, "Data at exactly timeout threshold should trigger keepalive")
|
||||
|
||||
def test_non_owner_no_redis_key_returns_true(self):
|
||||
"""Non-owner, last_data key missing from Redis -> assume unhealthy."""
|
||||
gen = self._make_generator()
|
||||
server = self._mock_proxy_server(None)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertTrue(result, "Missing last_data key should trigger keepalive")
|
||||
|
||||
def test_non_owner_redis_client_none_returns_false(self):
|
||||
"""Non-owner, redis_client is None (disconnected) -> conservative, no keepalive."""
|
||||
gen = self._make_generator()
|
||||
server = MagicMock()
|
||||
server.redis_client = None
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertFalse(result, "No redis_client -> conservative, no keepalive")
|
||||
|
||||
def test_non_owner_redis_exception_returns_false(self):
|
||||
"""Non-owner, Redis raises an exception -> conservative, no keepalive."""
|
||||
gen = self._make_generator()
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.side_effect = Exception("Redis error")
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertFalse(result, "Redis error -> conservative, no keepalive")
|
||||
|
||||
def test_non_owner_not_at_buffer_head_returns_false(self):
|
||||
"""Non-owner, NOT at buffer head -> no keepalive regardless of Redis."""
|
||||
gen = self._make_generator()
|
||||
gen.buffer.index = 100 # far ahead of local_index=10
|
||||
server = self._mock_proxy_server(None)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_non_owner_insufficient_consecutive_empty_returns_false(self):
|
||||
"""Non-owner, at head, but consecutive_empty < 5 -> no keepalive."""
|
||||
gen = self._make_generator(consecutive_empty=2)
|
||||
stale_ts = str(time.time() - 30.0).encode()
|
||||
server = self._mock_proxy_server(stale_ts)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _do_stats_update: error handling and WebSocket dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DoStatsUpdateTests(TestCase):
|
||||
"""_do_stats_update runs the actual Redis scan + WebSocket call."""
|
||||
|
||||
def _make_client_manager(self):
|
||||
from apps.proxy.ts_proxy.client_manager import ClientManager
|
||||
cm = ClientManager.__new__(ClientManager)
|
||||
cm.channel_id = "00000000-0000-0000-0000-000000000004"
|
||||
cm._heartbeat_running = False
|
||||
return cm
|
||||
|
||||
def test_do_stats_update_calls_send_websocket_update(self):
|
||||
"""_do_stats_update must call send_websocket_update with channel_stats."""
|
||||
cm = self._make_client_manager()
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.scan.return_value = (0, [])
|
||||
|
||||
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update") as mock_ws, \
|
||||
patch("redis.Redis.from_url", return_value=mock_redis):
|
||||
cm._do_stats_update()
|
||||
|
||||
mock_ws.assert_called_once()
|
||||
event_type = mock_ws.call_args[0][1]
|
||||
self.assertEqual(event_type, "update")
|
||||
payload = mock_ws.call_args[0][2]
|
||||
self.assertEqual(payload["type"], "channel_stats")
|
||||
|
||||
def test_do_stats_update_does_not_raise_on_redis_error(self):
|
||||
"""Redis failure must be swallowed (logged), not propagated."""
|
||||
cm = self._make_client_manager()
|
||||
|
||||
with patch("redis.Redis.from_url", side_effect=Exception("Redis down")):
|
||||
try:
|
||||
cm._do_stats_update()
|
||||
except Exception as e:
|
||||
self.fail(f"_do_stats_update raised an exception: {e}")
|
||||
|
||||
def test_do_stats_update_scans_channel_client_keys(self):
|
||||
"""Must scan for ts_proxy:channel:*:clients pattern."""
|
||||
cm = self._make_client_manager()
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.scan.return_value = (0, [])
|
||||
|
||||
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update"), \
|
||||
patch("redis.Redis.from_url", return_value=mock_redis):
|
||||
cm._do_stats_update()
|
||||
|
||||
scan_call = mock_redis.scan.call_args
|
||||
self.assertIn("ts_proxy:channel:*:clients", str(scan_call))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: remove_client must not block on WebSocket
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ClientRemoveIntegrationTests(TestCase):
|
||||
"""When remove_client() fires, _trigger_stats_update must not block."""
|
||||
|
||||
def test_remove_client_does_not_block_on_websocket(self):
|
||||
"""remove_client() must return quickly even if WebSocket is slow."""
|
||||
from apps.proxy.ts_proxy.client_manager import ClientManager
|
||||
|
||||
cm = ClientManager.__new__(ClientManager)
|
||||
cm.channel_id = "00000000-0000-0000-0000-000000000005"
|
||||
cm._heartbeat_running = False
|
||||
cm.clients = {"test-client-1"}
|
||||
cm.last_heartbeat_time = {"test-client-1": time.time()}
|
||||
cm.last_active_time = time.time()
|
||||
cm.client_set_key = f"ts_proxy:channel:{cm.channel_id}:clients"
|
||||
cm.client_ttl = 60
|
||||
cm.worker_id = "worker-1"
|
||||
cm.proxy_server = MagicMock()
|
||||
cm.proxy_server.am_i_owner.return_value = False
|
||||
cm.lock = threading.Lock()
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.hgetall.return_value = {b"ip_address": b"127.0.0.1"}
|
||||
mock_redis.scard.return_value = 1
|
||||
cm.redis_client = mock_redis
|
||||
|
||||
slow_ws_called = threading.Event()
|
||||
|
||||
def slow_websocket(*args, **kwargs):
|
||||
time.sleep(2.0)
|
||||
slow_ws_called.set()
|
||||
|
||||
start = time.time()
|
||||
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update", side_effect=slow_websocket):
|
||||
cm.remove_client("test-client-1")
|
||||
elapsed = time.time() - start
|
||||
|
||||
self.assertLess(elapsed, 1.0,
|
||||
f"remove_client() blocked for {elapsed:.2f}s waiting for WebSocket "
|
||||
f"(should dispatch to background thread and return immediately)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DVR timeout threshold vs keepalive timing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class KeepaliveTimingTests(TestCase):
|
||||
"""Verify that keepalive threshold gives sufficient margin before DVR timeout."""
|
||||
|
||||
def test_keepalive_threshold_less_than_dvr_timeout(self):
|
||||
"""CONNECTION_TIMEOUT (keepalive trigger) must be < DVR read timeout (15s)."""
|
||||
from apps.proxy.config import TSConfig as Config
|
||||
connection_timeout = getattr(Config, "CONNECTION_TIMEOUT", 10)
|
||||
dvr_read_timeout = 15 # hard-coded in run_recording: timeout=(10, 15)
|
||||
self.assertLess(
|
||||
connection_timeout,
|
||||
dvr_read_timeout,
|
||||
f"CONNECTION_TIMEOUT ({connection_timeout}s) must be < DVR timeout ({dvr_read_timeout}s) "
|
||||
f"so keepalives fire before DVR times out",
|
||||
)
|
||||
|
||||
def test_keepalive_interval_is_short(self):
|
||||
"""KEEPALIVE_INTERVAL must be short enough to send multiple keepalives in the gap."""
|
||||
from apps.proxy.config import TSConfig as Config
|
||||
interval = getattr(Config, "KEEPALIVE_INTERVAL", 0.5)
|
||||
connection_timeout = getattr(Config, "CONNECTION_TIMEOUT", 10)
|
||||
remaining_window = 15 - connection_timeout
|
||||
self.assertGreater(
|
||||
remaining_window / interval,
|
||||
3,
|
||||
f"KEEPALIVE_INTERVAL ({interval}s) is too long: only "
|
||||
f"{remaining_window/interval:.1f} keepalives would fit in the "
|
||||
f"{remaining_window}s window before DVR timeout",
|
||||
)
|
||||
169
apps/channels/tests/test_validate_url.py
Normal file
169
apps/channels/tests/test_validate_url.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""Tests for the _validate_url() helper in tasks.py.
|
||||
|
||||
Covers:
|
||||
- Rejection of None, empty, and non-string inputs
|
||||
- Non-HTTP URLs pass through without network requests
|
||||
- HTTP(S) URLs validated via HEAD request (2xx/3xx pass, 4xx/5xx fail)
|
||||
- Network errors (timeout, connection) treated as failures
|
||||
- Per-worker result cache: hits, expiry, eviction
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.channels.tasks import _validate_url, _url_validation_cache, _URL_CACHE_TTL
|
||||
|
||||
|
||||
class ValidateUrlInputTests(TestCase):
|
||||
"""Input validation — no network requests should be made."""
|
||||
|
||||
def setUp(self):
|
||||
_url_validation_cache.clear()
|
||||
|
||||
def test_none_returns_false(self):
|
||||
self.assertFalse(_validate_url(None))
|
||||
|
||||
def test_empty_string_returns_false(self):
|
||||
self.assertFalse(_validate_url(""))
|
||||
|
||||
def test_non_string_returns_false(self):
|
||||
self.assertFalse(_validate_url(123))
|
||||
self.assertFalse(_validate_url(["http://example.com"]))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_non_http_url_returns_true_without_request(self, mock_head):
|
||||
"""file:// and other non-HTTP schemes skip validation."""
|
||||
self.assertTrue(_validate_url("file:///local/path.jpg"))
|
||||
self.assertTrue(_validate_url("/data/images/poster.jpg"))
|
||||
mock_head.assert_not_called()
|
||||
|
||||
|
||||
class ValidateUrlNetworkTests(TestCase):
|
||||
"""HTTP(S) URL validation via HEAD request."""
|
||||
|
||||
def setUp(self):
|
||||
_url_validation_cache.clear()
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_200_returns_true(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=200)
|
||||
self.assertTrue(_validate_url("https://example.com/poster.jpg"))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_302_redirect_returns_true(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=302)
|
||||
self.assertTrue(_validate_url("https://example.com/redirect"))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_404_returns_false(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=404)
|
||||
self.assertFalse(_validate_url("https://dead-cdn.com/missing.jpg"))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_500_returns_false(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=500)
|
||||
self.assertFalse(_validate_url("https://broken.com/error"))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_timeout_returns_false(self, mock_head):
|
||||
import requests
|
||||
mock_head.side_effect = requests.Timeout("timed out")
|
||||
self.assertFalse(_validate_url("https://slow-cdn.com/poster.jpg"))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_connection_error_returns_false(self, mock_head):
|
||||
import requests
|
||||
mock_head.side_effect = requests.ConnectionError("refused")
|
||||
self.assertFalse(_validate_url("https://unreachable.com/poster.jpg"))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_custom_timeout_passed_to_head(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=200)
|
||||
_validate_url("https://example.com/img.jpg", timeout=10)
|
||||
mock_head.assert_called_once_with(
|
||||
"https://example.com/img.jpg", timeout=10, allow_redirects=True
|
||||
)
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_405_falls_back_to_get(self, mock_head, mock_get):
|
||||
"""When HEAD returns 405, fall back to a ranged GET request."""
|
||||
mock_head.return_value = MagicMock(status_code=405)
|
||||
mock_resp = MagicMock(status_code=200)
|
||||
mock_get.return_value = mock_resp
|
||||
self.assertTrue(_validate_url("https://no-head.com/poster.jpg"))
|
||||
mock_get.assert_called_once()
|
||||
mock_resp.close.assert_called_once()
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_405_fallback_get_also_fails(self, mock_head, mock_get):
|
||||
"""When HEAD returns 405 and GET also fails, return False."""
|
||||
mock_head.return_value = MagicMock(status_code=405)
|
||||
mock_get.return_value = MagicMock(status_code=403)
|
||||
self.assertFalse(_validate_url("https://blocked.com/poster.jpg"))
|
||||
|
||||
|
||||
class ValidateUrlCacheTests(TestCase):
|
||||
"""Per-worker result caching."""
|
||||
|
||||
def setUp(self):
|
||||
_url_validation_cache.clear()
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_cache_hit_avoids_second_request(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=200)
|
||||
url = "https://cached.com/poster.jpg"
|
||||
self.assertTrue(_validate_url(url))
|
||||
self.assertTrue(_validate_url(url))
|
||||
mock_head.assert_called_once()
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_cache_hit_returns_false_for_failed_url(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=404)
|
||||
url = "https://dead.com/missing.jpg"
|
||||
self.assertFalse(_validate_url(url))
|
||||
self.assertFalse(_validate_url(url))
|
||||
mock_head.assert_called_once()
|
||||
|
||||
@patch("apps.channels.tasks.time.monotonic")
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_cache_expiry_triggers_new_request(self, mock_head, mock_time):
|
||||
"""After TTL expires, a new HEAD request is made."""
|
||||
mock_head.return_value = MagicMock(status_code=200)
|
||||
url = "https://expiring.com/poster.jpg"
|
||||
|
||||
mock_time.return_value = 1000.0
|
||||
self.assertTrue(_validate_url(url))
|
||||
self.assertEqual(mock_head.call_count, 1)
|
||||
|
||||
# Within TTL — cache hit
|
||||
mock_time.return_value = 1000.0 + _URL_CACHE_TTL - 1
|
||||
self.assertTrue(_validate_url(url))
|
||||
self.assertEqual(mock_head.call_count, 1)
|
||||
|
||||
# Past TTL — new request
|
||||
mock_time.return_value = 1000.0 + _URL_CACHE_TTL + 1
|
||||
self.assertTrue(_validate_url(url))
|
||||
self.assertEqual(mock_head.call_count, 2)
|
||||
|
||||
@patch("apps.channels.tasks.time.monotonic")
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_eviction_when_cache_exceeds_limit(self, mock_head, mock_time):
|
||||
"""Expired entries are evicted when cache grows past 512 entries."""
|
||||
mock_head.return_value = MagicMock(status_code=200)
|
||||
|
||||
# Fill cache with 513 entries at time 0
|
||||
mock_time.return_value = 0.0
|
||||
for i in range(513):
|
||||
_url_validation_cache[f"https://fill-{i}.com/img.jpg"] = (True, 0.0)
|
||||
|
||||
# Advance past TTL and add one more — triggers eviction
|
||||
mock_time.return_value = _URL_CACHE_TTL + 1
|
||||
_validate_url("https://trigger-eviction.com/img.jpg")
|
||||
|
||||
# All 513 old entries expired and should be evicted
|
||||
remaining = [k for k in _url_validation_cache if k.startswith("https://fill-")]
|
||||
self.assertEqual(len(remaining), 0)
|
||||
# The new entry should remain
|
||||
self.assertIn("https://trigger-eviction.com/img.jpg", _url_validation_cache)
|
||||
|
|
@ -23,7 +23,7 @@ class ClientManager:
|
|||
self.channel_id = channel_id
|
||||
self.redis_client = redis_client
|
||||
self.clients = set()
|
||||
self.lock = threading.Lock()
|
||||
self.lock = threading.RLock()
|
||||
self.last_active_time = time.time()
|
||||
self.worker_id = worker_id # Store worker ID as instance variable
|
||||
self._heartbeat_running = True # Flag to control heartbeat thread
|
||||
|
|
@ -43,14 +43,20 @@ class ClientManager:
|
|||
self._registered_clients = set() # Track already registered client IDs
|
||||
|
||||
def _trigger_stats_update(self):
|
||||
"""Trigger a channel stats update via WebSocket"""
|
||||
"""Trigger a channel stats update via WebSocket in a background thread.
|
||||
|
||||
Offloaded so the caller is not blocked. send_websocket_update is
|
||||
gevent-safe (offloads async_to_sync to a native OS thread).
|
||||
"""
|
||||
threading.Thread(target=self._do_stats_update, daemon=True).start()
|
||||
|
||||
def _do_stats_update(self):
|
||||
"""Perform the stats update in the background."""
|
||||
try:
|
||||
# Import here to avoid potential import issues
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
import redis
|
||||
from django.conf import settings
|
||||
|
||||
# Get all channels from Redis using settings
|
||||
redis_url = getattr(settings, 'REDIS_URL', 'redis://localhost:6379/0')
|
||||
redis_client = redis.Redis.from_url(redis_url, decode_responses=True)
|
||||
all_channels = []
|
||||
|
|
@ -59,7 +65,6 @@ class ClientManager:
|
|||
while True:
|
||||
cursor, keys = redis_client.scan(cursor, match="ts_proxy:channel:*:clients", count=100)
|
||||
for key in keys:
|
||||
# Extract channel ID from key
|
||||
parts = key.split(':')
|
||||
if len(parts) >= 4:
|
||||
ch_id = parts[2]
|
||||
|
|
@ -70,7 +75,6 @@ class ClientManager:
|
|||
if cursor == 0:
|
||||
break
|
||||
|
||||
# Send WebSocket update using existing infrastructure
|
||||
send_websocket_update(
|
||||
"updates",
|
||||
"update",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,19 @@ class StreamGenerator:
|
|||
self.last_ttl_refresh = time.time()
|
||||
self.ttl_refresh_interval = 3 # Refresh TTL every 3 seconds of active streaming
|
||||
|
||||
# Cached proxy server reference
|
||||
self.proxy_server = None
|
||||
|
||||
# Non-owner health check throttle: avoid Redis GET on every loop iteration
|
||||
self._last_health_check_time = 0.0
|
||||
self._last_health_check_result = False
|
||||
self._health_check_interval = 2.0 # seconds
|
||||
|
||||
# Resource check throttle: Redis stop/state checks are expensive; throttle
|
||||
# them while allowing cheap in-memory checks to run every iteration.
|
||||
self._last_resource_check_time = 0.0
|
||||
self._resource_check_interval = 1.0 # seconds
|
||||
|
||||
def generate(self):
|
||||
"""
|
||||
Generator function that produces the stream content for the client.
|
||||
|
|
@ -229,6 +242,7 @@ class StreamGenerator:
|
|||
)
|
||||
|
||||
# Store important objects as instance variables
|
||||
self.proxy_server = proxy_server
|
||||
self.buffer = buffer
|
||||
self.stream_manager = stream_manager
|
||||
self.last_yield_time = time.time()
|
||||
|
|
@ -320,9 +334,7 @@ class StreamGenerator:
|
|||
|
||||
def _check_resources(self):
|
||||
"""Check if required resources still exist."""
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
|
||||
# Enhanced resource checks
|
||||
proxy_server = self.proxy_server or ProxyServer.get_instance()
|
||||
if self.channel_id not in proxy_server.stream_buffers:
|
||||
logger.info(f"[{self.client_id}] Channel buffer no longer exists, terminating stream")
|
||||
return False
|
||||
|
|
@ -331,35 +343,43 @@ class StreamGenerator:
|
|||
logger.info(f"[{self.client_id}] Client manager no longer exists, terminating stream")
|
||||
return False
|
||||
|
||||
# Check if this specific client has been stopped (Redis keys, etc.)
|
||||
if proxy_server.redis_client:
|
||||
# Channel stop check - with extended key set
|
||||
stop_key = RedisKeys.channel_stopping(self.channel_id)
|
||||
if proxy_server.redis_client.exists(stop_key):
|
||||
logger.info(f"[{self.client_id}] Detected channel stop signal, terminating stream")
|
||||
client_manager = proxy_server.client_managers[self.channel_id]
|
||||
if self.client_id not in client_manager.clients:
|
||||
logger.info(f"[{self.client_id}] Client no longer in client manager, terminating stream")
|
||||
return False
|
||||
|
||||
# --- Redis checks: throttled to _resource_check_interval (default 1s) ---
|
||||
# 3 Redis round-trips on every iteration is expensive at stream rates;
|
||||
# stop/state signals change infrequently so a 1-second poll is sufficient.
|
||||
if not proxy_server.redis_client:
|
||||
return True
|
||||
|
||||
now = time.time()
|
||||
if now - self._last_resource_check_time < self._resource_check_interval:
|
||||
return True
|
||||
|
||||
self._last_resource_check_time = now
|
||||
|
||||
# Channel stop check
|
||||
stop_key = RedisKeys.channel_stopping(self.channel_id)
|
||||
if proxy_server.redis_client.exists(stop_key):
|
||||
logger.info(f"[{self.client_id}] Detected channel stop signal, terminating stream")
|
||||
return False
|
||||
|
||||
# Channel state in metadata
|
||||
metadata_key = RedisKeys.channel_metadata(self.channel_id)
|
||||
metadata = proxy_server.redis_client.hgetall(metadata_key)
|
||||
if metadata and b'state' in metadata:
|
||||
state = metadata[b'state'].decode('utf-8')
|
||||
if state in ['error', 'stopped', 'stopping']:
|
||||
logger.info(f"[{self.client_id}] Channel in {state} state, terminating stream")
|
||||
return False
|
||||
|
||||
# Also check channel state in metadata
|
||||
metadata_key = RedisKeys.channel_metadata(self.channel_id)
|
||||
metadata = proxy_server.redis_client.hgetall(metadata_key)
|
||||
if metadata and b'state' in metadata:
|
||||
state = metadata[b'state'].decode('utf-8')
|
||||
if state in ['error', 'stopped', 'stopping']:
|
||||
logger.info(f"[{self.client_id}] Channel in {state} state, terminating stream")
|
||||
return False
|
||||
|
||||
# Client stop check
|
||||
client_stop_key = RedisKeys.client_stop(self.channel_id, self.client_id)
|
||||
if proxy_server.redis_client.exists(client_stop_key):
|
||||
logger.info(f"[{self.client_id}] Detected client stop signal, terminating stream")
|
||||
return False
|
||||
|
||||
# Also check if client has been removed from client_manager
|
||||
if self.channel_id in proxy_server.client_managers:
|
||||
client_manager = proxy_server.client_managers[self.channel_id]
|
||||
if self.client_id not in client_manager.clients:
|
||||
logger.info(f"[{self.client_id}] Client no longer in client manager, terminating stream")
|
||||
return False
|
||||
# Client stop check
|
||||
client_stop_key = RedisKeys.client_stop(self.channel_id, self.client_id)
|
||||
if proxy_server.redis_client.exists(client_stop_key):
|
||||
logger.info(f"[{self.client_id}] Detected client stop signal, terminating stream")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
|
@ -368,7 +388,7 @@ class StreamGenerator:
|
|||
# Process and send chunks
|
||||
total_size = sum(len(c) for c in chunks)
|
||||
logger.debug(f"[{self.client_id}] Retrieved {len(chunks)} chunks ({total_size} bytes) from index {self.local_index+1} to {next_index}")
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
proxy_server = self.proxy_server or ProxyServer.get_instance()
|
||||
|
||||
# Send the chunks to the client
|
||||
for chunk in chunks:
|
||||
|
|
@ -436,10 +456,38 @@ class StreamGenerator:
|
|||
"""Determine if a keepalive packet should be sent."""
|
||||
# Check if we're caught up to buffer head
|
||||
at_buffer_head = local_index >= self.buffer.index
|
||||
if not at_buffer_head or self.consecutive_empty < 5:
|
||||
return False
|
||||
|
||||
# If we're at buffer head and no data is coming, send keepalive
|
||||
stream_healthy = self.stream_manager.healthy if self.stream_manager else True
|
||||
return at_buffer_head and not stream_healthy and self.consecutive_empty >= 5
|
||||
if self.stream_manager is not None:
|
||||
# Owner worker: use the in-memory health flag directly.
|
||||
return not self.stream_manager.healthy
|
||||
else:
|
||||
# Non-owner worker: stream_manager only exists in the owner process.
|
||||
# Approximate health from the Redis last_data timestamp; if stale
|
||||
# beyond CONNECTION_TIMEOUT, send keepalives to prevent DVR timeout.
|
||||
# Throttled: only re-query Redis every _health_check_interval seconds
|
||||
# to avoid a Redis GET on every loop iteration during sustained waits.
|
||||
now = time.time()
|
||||
if now - self._last_health_check_time < self._health_check_interval:
|
||||
return self._last_health_check_result
|
||||
try:
|
||||
proxy_server = self.proxy_server or ProxyServer.get_instance()
|
||||
if proxy_server.redis_client:
|
||||
raw = proxy_server.redis_client.get(RedisKeys.last_data(self.channel_id))
|
||||
if raw:
|
||||
age = now - float(raw)
|
||||
timeout_threshold = getattr(Config, 'CONNECTION_TIMEOUT', 10)
|
||||
result = age >= timeout_threshold
|
||||
else:
|
||||
# No timestamp in Redis → key missing or expired → unhealthy
|
||||
result = True
|
||||
self._last_health_check_time = now
|
||||
self._last_health_check_result = result
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _is_ghost_client(self, local_index):
|
||||
"""Check if this appears to be a ghost client (stuck but buffer advancing)."""
|
||||
|
|
|
|||
|
|
@ -294,30 +294,34 @@ def send_websocket_update(group_name, event_type, data, collect_garbage=False):
|
|||
"""
|
||||
Standardized function to send WebSocket updates with proper memory management.
|
||||
|
||||
Args:
|
||||
group_name: The WebSocket group to send to (e.g. 'updates')
|
||||
event_type: The type of message (e.g. 'update')
|
||||
data: The data to send
|
||||
collect_garbage: Whether to force garbage collection after sending
|
||||
In uWSGI + gevent deployments, async_to_sync creates an asyncio event loop
|
||||
whose native EpollSelector blocks the entire OS thread, freezing all gevent
|
||||
greenlets in the worker. To avoid this, the actual send is offloaded to a
|
||||
real OS thread from gevent's native threadpool when monkey-patching is active.
|
||||
"""
|
||||
channel_layer = get_channel_layer()
|
||||
try:
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
group_name,
|
||||
{
|
||||
'type': event_type,
|
||||
'data': data
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send WebSocket update: {e}")
|
||||
finally:
|
||||
# Explicitly release references to help garbage collection
|
||||
channel_layer = None
|
||||
message = {'type': event_type, 'data': data}
|
||||
|
||||
# Force garbage collection if requested
|
||||
if collect_garbage:
|
||||
gc.collect()
|
||||
def _do_send():
|
||||
try:
|
||||
async_to_sync(channel_layer.group_send)(group_name, message)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send WebSocket update: {e}")
|
||||
|
||||
try:
|
||||
import gevent.monkey
|
||||
if gevent.monkey.is_module_patched('threading'):
|
||||
from gevent import get_hub
|
||||
get_hub().threadpool.spawn(_do_send)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Not in a gevent-patched environment — call directly
|
||||
_do_send()
|
||||
|
||||
if collect_garbage:
|
||||
gc.collect()
|
||||
|
||||
def send_websocket_event(event, success, data):
|
||||
"""Acquire a lock to prevent concurrent task execution."""
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import os
|
||||
from celery import Celery
|
||||
import logging
|
||||
from celery.signals import task_postrun # Add import for signals
|
||||
from celery.signals import task_postrun, worker_ready
|
||||
|
||||
# Initialize with defaults before Django settings are loaded
|
||||
DEFAULT_LOG_LEVEL = 'DEBUG'
|
||||
|
|
@ -149,3 +149,10 @@ def setup_celery_logging(**kwargs):
|
|||
except (AttributeError, TypeError):
|
||||
# If the log level string is invalid, default to DEBUG
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def on_worker_ready(**kwargs):
|
||||
"""Resume or finalize interrupted DVR recordings after a worker restart."""
|
||||
from apps.channels.tasks import recover_recordings_on_startup
|
||||
recover_recordings_on_startup.delay()
|
||||
|
|
|
|||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
|
|
@ -5794,24 +5794,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,21 @@ import useAuthStore from './store/auth';
|
|||
|
||||
export const WebsocketContext = createContext([false, () => {}, null]);
|
||||
|
||||
// Debounce: coalesces rapid recording WS events into a single fetchRecordings()
|
||||
// call (400 ms window) to prevent redundant re-renders in the TV Guide.
|
||||
let _recordingFetchTimer = null;
|
||||
function scheduleRecordingFetch() {
|
||||
if (_recordingFetchTimer) clearTimeout(_recordingFetchTimer);
|
||||
_recordingFetchTimer = setTimeout(async () => {
|
||||
_recordingFetchTimer = null;
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (e) {
|
||||
console.warn('Failed to refresh recordings:', e);
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
export const WebsocketProvider = ({ children }) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [val, setVal] = useState(null);
|
||||
|
|
@ -193,21 +208,21 @@ export const WebsocketProvider = ({ children }) => {
|
|||
loading: false,
|
||||
autoClose: 4000,
|
||||
});
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch {}
|
||||
scheduleRecordingFetch();
|
||||
} else if (status === 'skipped') {
|
||||
const reasonMap = {
|
||||
no_commercials_detected: 'No commercials were detected in this recording',
|
||||
no_commercials: 'No commercials were detected in this recording',
|
||||
};
|
||||
notifications.update({
|
||||
id,
|
||||
title: 'No commercials to remove',
|
||||
message: parsedEvent.data.reason || '',
|
||||
message: reasonMap[parsedEvent.data.reason] || parsedEvent.data.reason || '',
|
||||
color: 'teal',
|
||||
loading: false,
|
||||
autoClose: 3000,
|
||||
});
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch {}
|
||||
scheduleRecordingFetch();
|
||||
} else if (status === 'error') {
|
||||
notifications.update({
|
||||
id,
|
||||
|
|
@ -217,9 +232,7 @@ export const WebsocketProvider = ({ children }) => {
|
|||
loading: false,
|
||||
autoClose: 6000,
|
||||
});
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch {}
|
||||
scheduleRecordingFetch();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -508,19 +521,11 @@ export const WebsocketProvider = ({ children }) => {
|
|||
break;
|
||||
|
||||
case 'recording_updated':
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (e) {
|
||||
console.warn('Failed to refresh recordings on update:', e);
|
||||
}
|
||||
scheduleRecordingFetch();
|
||||
break;
|
||||
|
||||
case 'recordings_refreshed':
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (e) {
|
||||
console.warn('Failed to refresh recordings on refreshed:', e);
|
||||
}
|
||||
scheduleRecordingFetch();
|
||||
break;
|
||||
|
||||
case 'recording_started':
|
||||
|
|
@ -528,11 +533,7 @@ export const WebsocketProvider = ({ children }) => {
|
|||
title: 'Recording started!',
|
||||
message: `Started recording channel ${parsedEvent.data.channel}`,
|
||||
});
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (e) {
|
||||
console.warn('Failed to refresh recordings on start:', e);
|
||||
}
|
||||
scheduleRecordingFetch();
|
||||
break;
|
||||
|
||||
case 'recording_ended':
|
||||
|
|
@ -540,10 +541,36 @@ export const WebsocketProvider = ({ children }) => {
|
|||
title: 'Recording finished!',
|
||||
message: `Stopped recording channel ${parsedEvent.data.channel}`,
|
||||
});
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (e) {
|
||||
console.warn('Failed to refresh recordings on end:', e);
|
||||
scheduleRecordingFetch();
|
||||
break;
|
||||
|
||||
case 'recording_stopped':
|
||||
notifications.show({
|
||||
title: 'Recording stopped',
|
||||
message: `Recording stopped early for ${parsedEvent.data.channel || 'channel'}. Partial content has been saved.`,
|
||||
color: 'yellow',
|
||||
});
|
||||
scheduleRecordingFetch();
|
||||
break;
|
||||
|
||||
case 'recording_extended':
|
||||
scheduleRecordingFetch();
|
||||
break;
|
||||
|
||||
case 'recording_cancelled':
|
||||
notifications.show({
|
||||
title: parsedEvent.data.was_in_progress ? 'Recording cancelled' : 'Recording deleted',
|
||||
message: parsedEvent.data.was_in_progress
|
||||
? 'Recording cancelled and content removed.'
|
||||
: 'Recording deleted.',
|
||||
color: 'red',
|
||||
});
|
||||
// Surgical removal by ID avoids a full fetchRecordings() re-render.
|
||||
// Fall back to a full refresh if the ID is missing (e.g. older server).
|
||||
if (parsedEvent.data.recording_id != null) {
|
||||
useChannelsStore.getState().removeRecording(parsedEvent.data.recording_id);
|
||||
} else {
|
||||
scheduleRecordingFetch();
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
|
|||
|
|
@ -2662,6 +2662,55 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async stopRecording(id) {
|
||||
try {
|
||||
await request(`${host}/api/channels/recordings/${id}/stop/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (e) {
|
||||
errorNotification(`Failed to stop recording ${id}`, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async extendRecording(id, extraMinutes) {
|
||||
try {
|
||||
const resp = await request(`${host}/api/channels/recordings/${id}/extend/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ extra_minutes: extraMinutes }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return resp;
|
||||
} catch (e) {
|
||||
errorNotification(`Failed to extend recording ${id}`, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async refreshArtwork(id) {
|
||||
try {
|
||||
await request(`${host}/api/channels/recordings/${id}/refresh-artwork/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (e) {
|
||||
errorNotification(`Failed to refresh artwork for recording ${id}`, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async updateRecordingMetadata(id, { title, description }) {
|
||||
try {
|
||||
await request(`${host}/api/channels/recordings/${id}/update-metadata/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, description }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (e) {
|
||||
errorNotification(`Failed to update recording metadata`, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async runComskip(recordingId) {
|
||||
try {
|
||||
const resp = await request(
|
||||
|
|
|
|||
|
|
@ -4,12 +4,16 @@ class ErrorBoundary extends React.Component {
|
|||
state = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <div>Something went wrong</div>;
|
||||
return <div>Something went wrong: {this.state.error?.message}</div>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,20 +13,23 @@ import {
|
|||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Menu,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { AlertTriangle, SquareX } from 'lucide-react';
|
||||
import { AlertTriangle, Plus, Square, SquareX } from 'lucide-react';
|
||||
import defaultLogo from '../../images/logo.png';
|
||||
import RecordingSynopsis from '../RecordingSynopsis';
|
||||
import {
|
||||
deleteRecordingById,
|
||||
deleteSeriesAndRule,
|
||||
extendRecordingById,
|
||||
getChannelLogoUrl,
|
||||
getPosterUrl,
|
||||
getRecordingUrl,
|
||||
getSeasonLabel,
|
||||
|
|
@ -34,15 +37,39 @@ import {
|
|||
getShowVideoUrl,
|
||||
removeRecording,
|
||||
runComSkip,
|
||||
stopRecordingById,
|
||||
} from './../../utils/cards/RecordingCardUtils.js';
|
||||
|
||||
const areRecordingPropsEqual = (prev, next) => {
|
||||
const pr = prev.recording;
|
||||
const nr = next.recording;
|
||||
if (!pr || !nr) return pr === nr;
|
||||
|
||||
const pcp = pr.custom_properties || {};
|
||||
const ncp = nr.custom_properties || {};
|
||||
|
||||
return (
|
||||
pr.id === nr.id &&
|
||||
pr.start_time === nr.start_time &&
|
||||
pr.end_time === nr.end_time &&
|
||||
pr._group_count === nr._group_count &&
|
||||
pcp.status === ncp.status &&
|
||||
pcp.poster_logo_id === ncp.poster_logo_id &&
|
||||
pcp.poster_url === ncp.poster_url &&
|
||||
pcp.file_url === ncp.file_url &&
|
||||
pcp.output_file_url === ncp.output_file_url &&
|
||||
pcp.comskip?.status === ncp.comskip?.status &&
|
||||
pcp.program?.title === ncp.program?.title &&
|
||||
prev.channel?.id === next.channel?.id
|
||||
);
|
||||
};
|
||||
|
||||
const RecordingCard = ({
|
||||
recording,
|
||||
onOpenDetails,
|
||||
onOpenRecurring,
|
||||
channel: channelProp = null,
|
||||
}) => {
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
|
||||
|
|
@ -59,12 +86,11 @@ const RecordingCard = ({
|
|||
const description = program.description || customProps.description || '';
|
||||
const isRecurringRule = customProps?.rule?.type === 'recurring';
|
||||
|
||||
// Poster or channel logo
|
||||
// Poster or channel logo (getPosterUrl falls back to Dispatcharr default logo)
|
||||
const posterUrl = getPosterUrl(
|
||||
customProps.poster_logo_id,
|
||||
customProps,
|
||||
channel?.logo?.cache_url,
|
||||
env_mode
|
||||
getChannelLogoUrl(channel)
|
||||
);
|
||||
|
||||
const start = toUserTime(recording.start_time);
|
||||
|
|
@ -73,7 +99,7 @@ const RecordingCard = ({
|
|||
const status = customProps.status;
|
||||
const isTimeActive = now.isAfter(start) && now.isBefore(end);
|
||||
const isInterrupted = status === 'interrupted';
|
||||
const isInProgress = isTimeActive; // Show as recording by time, regardless of status glitches
|
||||
const isInProgress = isTimeActive && !isInterrupted && status !== 'completed' && status !== 'stopped';
|
||||
const isUpcoming = now.isBefore(start);
|
||||
const isSeriesGroup = Boolean(
|
||||
recording._group_count && recording._group_count > 1
|
||||
|
|
@ -117,10 +143,39 @@ const RecordingCard = ({
|
|||
}
|
||||
};
|
||||
|
||||
// Cancel handling for series groups
|
||||
const handleExtend = async (minutes, e) => {
|
||||
e?.stopPropagation?.();
|
||||
try {
|
||||
await extendRecordingById(recording.id, minutes);
|
||||
notifications.show({
|
||||
title: 'Recording extended',
|
||||
message: `Added ${minutes} minutes to this recording`,
|
||||
color: 'teal',
|
||||
autoClose: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to extend recording', error);
|
||||
notifications.show({
|
||||
title: 'Extension failed',
|
||||
message: 'Could not extend the recording',
|
||||
color: 'red',
|
||||
autoClose: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Stop / Cancel / Delete state and handlers
|
||||
const [cancelOpen, setCancelOpen] = React.useState(false);
|
||||
const [stopConfirmOpen, setStopConfirmOpen] = React.useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const handleCancelClick = (e) => {
|
||||
|
||||
const handleStopClick = (e) => {
|
||||
e.stopPropagation();
|
||||
setStopConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (isRecurringRule) {
|
||||
onOpenRecurring?.(recording, true);
|
||||
|
|
@ -129,7 +184,30 @@ const RecordingCard = ({
|
|||
if (isSeriesGroup) {
|
||||
setCancelOpen(true);
|
||||
} else {
|
||||
setDeleteConfirmOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmStop = async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
await stopRecordingById(recording.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to stop recording', error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setStopConfirmOpen(false);
|
||||
try { await fetchRecordings(); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
removeRecording(recording.id);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setDeleteConfirmOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -229,6 +307,8 @@ const RecordingCard = ({
|
|||
backgroundColor: isInterrupted ? '#2b1f20' : '#27272A',
|
||||
borderColor: isInterrupted ? '#a33' : undefined,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={handleOnMainCardClick}
|
||||
|
|
@ -270,30 +350,61 @@ const RecordingCard = ({
|
|||
Recurring
|
||||
</Badge>
|
||||
)}
|
||||
{seLabel && !isSeriesGroup && (
|
||||
<Badge color="gray" variant="light">
|
||||
{seLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Center>
|
||||
<Tooltip label={isUpcoming || isInProgress ? 'Cancel' : 'Delete'}>
|
||||
<Group gap={4}>
|
||||
{isInProgress && (
|
||||
<Tooltip label="Extend recording">
|
||||
<Box display="inline-flex">
|
||||
<Menu withinPortal position="bottom-end" shadow="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="teal.5"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus size={20} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||
<Menu.Label>Extend recording by</Menu.Label>
|
||||
<Menu.Item onClick={(e) => handleExtend(15, e)}>+15 minutes</Menu.Item>
|
||||
<Menu.Item onClick={(e) => handleExtend(30, e)}>+30 minutes</Menu.Item>
|
||||
<Menu.Item onClick={(e) => handleExtend(60, e)}>+1 hour</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isInProgress && (
|
||||
<Tooltip label="Stop recording (keep partial content)">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="yellow.6"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={handleStopClick}
|
||||
>
|
||||
<Square size="20" fill="currentColor" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={isInProgress ? 'Cancel & delete' : isUpcoming ? 'Cancel' : 'Delete'}>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="red.9"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={handleCancelClick}
|
||||
onClick={handleDeleteClick}
|
||||
>
|
||||
<SquareX size="20" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Center>
|
||||
</Group>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="sm" align="center">
|
||||
<Flex gap="sm" align="flex-start" style={{ flex: 1 }}>
|
||||
<Image
|
||||
src={posterUrl}
|
||||
w={64}
|
||||
|
|
@ -301,10 +412,10 @@ const RecordingCard = ({
|
|||
fit="contain"
|
||||
radius="sm"
|
||||
alt={recordingName}
|
||||
fallbackSrc="/logo.png"
|
||||
fallbackSrc={getChannelLogoUrl(channel) || defaultLogo}
|
||||
/>
|
||||
<Stack gap={6} flex={1}>
|
||||
{!isSeriesGroup && subTitle && (
|
||||
<Stack gap={6} flex={1} style={{ alignSelf: 'stretch' }}>
|
||||
{subTitle && (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Episode
|
||||
|
|
@ -314,6 +425,16 @@ const RecordingCard = ({
|
|||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{seLabel && (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Season/Episode
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{seLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Channel
|
||||
|
|
@ -346,12 +467,12 @@ const RecordingCard = ({
|
|||
</Text>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="xs" pt={4}>
|
||||
<Group justify="flex-end" gap="xs" pt={4} style={{ marginTop: 'auto' }}>
|
||||
{isInProgress && <WatchLive />}
|
||||
|
||||
{!isUpcoming && <WatchRecording />}
|
||||
{!isUpcoming &&
|
||||
customProps?.status === 'completed' &&
|
||||
(customProps?.status === 'completed' || customProps?.status === 'stopped' || customProps?.status === 'interrupted') &&
|
||||
(!customProps?.comskip ||
|
||||
customProps?.comskip?.status !== 'completed') && (
|
||||
<Button
|
||||
|
|
@ -378,11 +499,74 @@ const RecordingCard = ({
|
|||
)}
|
||||
</Card>
|
||||
);
|
||||
if (!isSeriesGroup) return MainCard;
|
||||
|
||||
// Confirmation modals for stop and cancel/delete
|
||||
const ConfirmModals = (
|
||||
<>
|
||||
<Modal
|
||||
opened={stopConfirmOpen}
|
||||
onClose={() => setStopConfirmOpen(false)}
|
||||
title="Stop Recording"
|
||||
centered
|
||||
size="md"
|
||||
zIndex={9999}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text>
|
||||
The recording will be stopped early. The portion already recorded
|
||||
will be saved and available for playback.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setStopConfirmOpen(false)} disabled={busy}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button color="yellow" loading={busy} onClick={confirmStop}>
|
||||
Stop Recording
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={deleteConfirmOpen}
|
||||
onClose={() => setDeleteConfirmOpen(false)}
|
||||
title={isInProgress || isUpcoming ? 'Cancel Recording' : 'Delete Recording'}
|
||||
centered
|
||||
size="md"
|
||||
zIndex={9999}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text>
|
||||
{isInProgress
|
||||
? 'The recording will be cancelled and all recorded content will be permanently deleted.'
|
||||
: isUpcoming
|
||||
? 'This scheduled recording will be cancelled.'
|
||||
: 'This recording and all associated files will be permanently deleted.'}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setDeleteConfirmOpen(false)} disabled={busy}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button color="red" loading={busy} onClick={confirmDelete}>
|
||||
{isInProgress ? 'Cancel & Delete' : isUpcoming ? 'Cancel' : 'Delete'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!isSeriesGroup) return (
|
||||
<>
|
||||
{ConfirmModals}
|
||||
{MainCard}
|
||||
</>
|
||||
);
|
||||
|
||||
// Stacked look for series groups: render two shadow layers behind the main card
|
||||
return (
|
||||
<Box style={{ position: 'relative' }}>
|
||||
{ConfirmModals}
|
||||
<Modal
|
||||
opened={cancelOpen}
|
||||
onClose={() => setCancelOpen(false)}
|
||||
|
|
@ -438,4 +622,4 @@ const RecordingCard = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default RecordingCard;
|
||||
export default React.memo(RecordingCard, areRecordingPropsEqual);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
import { Modal, Flex, Button } from '@mantine/core';
|
||||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
|
||||
import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js';
|
||||
import { deleteSeriesRuleByTvgId } from '../../pages/guideUtils.js';
|
||||
|
|
@ -22,11 +21,7 @@ export default function ProgramRecordingModal({
|
|||
} catch (error) {
|
||||
console.warn('Failed to delete recording', error);
|
||||
}
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh recordings after delete', error);
|
||||
}
|
||||
// recording_cancelled WS event triggers the debounced fetchRecordings()
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -35,11 +30,7 @@ export default function ProgramRecordingModal({
|
|||
tvg_id: program.tvg_id,
|
||||
title: program.title,
|
||||
});
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh recordings after series delete', error);
|
||||
}
|
||||
// recordings_refreshed WS event triggers the debounced fetchRecordings()
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import {
|
|||
useTimeHelpers,
|
||||
} from '../../utils/dateTimeUtils.js';
|
||||
import React from 'react';
|
||||
import { Pencil, RefreshCcw, Check, X } from 'lucide-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
|
|
@ -15,11 +17,15 @@ import {
|
|||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import useVideoStore from '../../store/useVideoStore.jsx';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import defaultLogo from '../../images/logo.png';
|
||||
import {
|
||||
deleteRecordingById,
|
||||
getChannelLogoUrl,
|
||||
getPosterUrl,
|
||||
getRecordingUrl,
|
||||
getSeasonLabel,
|
||||
|
|
@ -52,11 +58,50 @@ const RecordingDetailsModal = ({
|
|||
const { timeFormat: timeformat, dateFormat: dateformat } =
|
||||
useDateTimeFormat();
|
||||
|
||||
const safeRecording = recording || {};
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
|
||||
// Prefer the store version of the recording for live updates
|
||||
// (e.g., after artwork refresh or metadata edit via WebSocket).
|
||||
// Preserve _group_count from the categorized prop — the store version
|
||||
// doesn't carry this client-side field, so without merging it back
|
||||
// isSeriesGroup would always be false and the episode list hidden.
|
||||
const safeRecording = React.useMemo(() => {
|
||||
if (recording?.id && Array.isArray(allRecordings)) {
|
||||
const found = allRecordings.find((r) => r.id === recording.id);
|
||||
if (found) {
|
||||
if (recording._group_count != null) {
|
||||
return { ...found, _group_count: recording._group_count };
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return recording || {};
|
||||
}, [allRecordings, recording]);
|
||||
const customProps = safeRecording.custom_properties || {};
|
||||
const program = customProps.program || {};
|
||||
const recordingName = program.title || 'Custom Recording';
|
||||
const description = program.description || customProps.description || '';
|
||||
|
||||
// Derive poster URL from live store data instead of the stale prop snapshot.
|
||||
const livePosterUrl = React.useMemo(
|
||||
() => getPosterUrl(customProps.poster_logo_id, customProps, getChannelLogoUrl(channel)),
|
||||
[customProps.poster_logo_id, customProps, channel]
|
||||
);
|
||||
|
||||
// Optimistic overrides — show saved values immediately without waiting
|
||||
// for the WebSocket round-trip to refresh the store.
|
||||
const [savedTitle, setSavedTitle] = React.useState(null);
|
||||
const [savedDescription, setSavedDescription] = React.useState(null);
|
||||
const recordingName = savedTitle ?? (program.title || 'Custom Recording');
|
||||
const description = savedDescription ?? (program.description || customProps.description || '');
|
||||
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
const [editDescription, setEditDescription] = React.useState('');
|
||||
|
||||
// Reset optimistic state when the recording changes
|
||||
React.useEffect(() => {
|
||||
setSavedTitle(null);
|
||||
setSavedDescription(null);
|
||||
setEditing(false);
|
||||
}, [recording?.id]);
|
||||
const start = toUserTime(safeRecording.start_time);
|
||||
const end = toUserTime(safeRecording.end_time);
|
||||
const stats = customProps.stream_info || {};
|
||||
|
|
@ -70,6 +115,7 @@ const RecordingDetailsModal = ({
|
|||
const fileUrl = customProps.file_url || customProps.output_file_url;
|
||||
const canWatchRecording =
|
||||
(customProps.status === 'completed' ||
|
||||
customProps.status === 'stopped' ||
|
||||
customProps.status === 'interrupted') &&
|
||||
Boolean(fileUrl);
|
||||
|
||||
|
|
@ -157,12 +203,55 @@ const RecordingDetailsModal = ({
|
|||
url: getPosterUrl(
|
||||
childRec.custom_properties?.poster_logo_id,
|
||||
undefined,
|
||||
ch?.logo?.cache_url
|
||||
getChannelLogoUrl(ch)
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const startEditing = () => {
|
||||
setEditTitle(recordingName === 'Custom Recording' ? '' : recordingName);
|
||||
setEditDescription(description);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEditing = () => setEditing(false);
|
||||
|
||||
const saveMetadata = async () => {
|
||||
try {
|
||||
await API.updateRecordingMetadata(recording.id, {
|
||||
title: editTitle || 'Custom Recording',
|
||||
description: editDescription,
|
||||
});
|
||||
setSavedTitle(editTitle || 'Custom Recording');
|
||||
setSavedDescription(editDescription);
|
||||
setEditing(false);
|
||||
notifications.show({
|
||||
title: 'Saved',
|
||||
message: 'Recording metadata updated',
|
||||
color: 'green',
|
||||
autoClose: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save metadata', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshArtwork = async (e) => {
|
||||
e.stopPropagation?.();
|
||||
try {
|
||||
await API.refreshArtwork(recording.id);
|
||||
notifications.show({
|
||||
title: 'Refreshing artwork',
|
||||
message: 'Poster resolution started',
|
||||
color: 'blue.5',
|
||||
autoClose: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh artwork', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunComskip = async (e) => {
|
||||
e.stopPropagation?.();
|
||||
try {
|
||||
|
|
@ -191,7 +280,8 @@ const RecordingDetailsModal = ({
|
|||
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
|
||||
const se = getSeasonLabel(season, episode, onscreen);
|
||||
const posterLogoId = cp.poster_logo_id;
|
||||
const purl = getPosterUrl(posterLogoId, cp, posterUrl);
|
||||
const purl = getPosterUrl(posterLogoId, cp, livePosterUrl);
|
||||
const epChannel = channelsById[rec.channel] || (rec.channel === recording?.channel ? channel : null);
|
||||
|
||||
const onRemove = async (e) => {
|
||||
e?.stopPropagation?.();
|
||||
|
|
@ -200,11 +290,7 @@ const RecordingDetailsModal = ({
|
|||
} catch (error) {
|
||||
console.error('Failed to delete upcoming recording', error);
|
||||
}
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh recordings after delete', error);
|
||||
}
|
||||
// recording_cancelled WS event triggers the debounced fetchRecordings()
|
||||
};
|
||||
|
||||
const handleOnMainCardClick = () => {
|
||||
|
|
@ -228,7 +314,7 @@ const RecordingDetailsModal = ({
|
|||
fit="contain"
|
||||
radius="sm"
|
||||
alt={pr.title || recordingName}
|
||||
fallbackSrc="/logo.png"
|
||||
fallbackSrc={getChannelLogoUrl(epChannel) || defaultLogo}
|
||||
/>
|
||||
<Stack gap={4} flex={1}>
|
||||
<Group justify="space-between">
|
||||
|
|
@ -328,7 +414,7 @@ const RecordingDetailsModal = ({
|
|||
posterUrl={getPosterUrl(
|
||||
childRec.custom_properties?.poster_logo_id,
|
||||
childRec.custom_properties,
|
||||
channelsById[childRec.channel]?.logo?.cache_url
|
||||
getChannelLogoUrl(channelsById[childRec.channel])
|
||||
)}
|
||||
env_mode={env_mode}
|
||||
onWatchLive={handleOnWatchLive}
|
||||
|
|
@ -342,15 +428,27 @@ const RecordingDetailsModal = ({
|
|||
const Movie = () => {
|
||||
return (
|
||||
<Flex gap="lg" align="flex-start">
|
||||
<Image
|
||||
src={posterUrl}
|
||||
w={180}
|
||||
h={240}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
alt={recordingName}
|
||||
fallbackSrc="/logo.png"
|
||||
/>
|
||||
<Stack gap={4} align="center">
|
||||
<Image
|
||||
src={livePosterUrl}
|
||||
w={180}
|
||||
h={240}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
alt={recordingName}
|
||||
fallbackSrc={getChannelLogoUrl(channel) || defaultLogo}
|
||||
/>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="dimmed"
|
||||
leftSection={<RefreshCcw size={12} />}
|
||||
onClick={handleRefreshArtwork}
|
||||
styles={{ root: { fontWeight: 400 } }}
|
||||
>
|
||||
Refresh artwork
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack gap={8} style={{ flex: 1 }}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text c="dimmed" size="sm">
|
||||
|
|
@ -360,7 +458,9 @@ const RecordingDetailsModal = ({
|
|||
{onWatchLive && <WatchLive />}
|
||||
{onWatchRecording && <WatchRecording />}
|
||||
{onEdit && start.isAfter(userNow()) && <Edit />}
|
||||
{customProps.status === 'completed' &&
|
||||
{(customProps.status === 'completed' ||
|
||||
customProps.status === 'stopped' ||
|
||||
customProps.status === 'interrupted') &&
|
||||
(!customProps?.comskip ||
|
||||
customProps?.comskip?.status !== 'completed') && (
|
||||
<Button
|
||||
|
|
@ -385,11 +485,20 @@ const RecordingDetailsModal = ({
|
|||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
{description && (
|
||||
{editing ? (
|
||||
<Textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.currentTarget.value)}
|
||||
placeholder="Description (optional)"
|
||||
size="sm"
|
||||
minRows={2}
|
||||
autosize
|
||||
/>
|
||||
) : description ? (
|
||||
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
{statRows.length > 0 && (
|
||||
<Stack gap={4} pt={6}>
|
||||
<Text fw={600} size="sm">
|
||||
|
|
@ -415,9 +524,40 @@ const RecordingDetailsModal = ({
|
|||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
isSeriesGroup
|
||||
? `Series: ${recordingName}`
|
||||
: `${recordingName}${program.sub_title ? ` - ${program.sub_title}` : ''}`
|
||||
editing ? (
|
||||
<Group gap={8} align="center" wrap="nowrap" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.currentTarget.value)}
|
||||
placeholder="Recording title"
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') saveMetadata(); if (e.key === 'Escape') cancelEditing(); }}
|
||||
/>
|
||||
<ActionIcon size="sm" variant="subtle" color="green" onClick={saveMetadata}>
|
||||
<Check size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon size="sm" variant="subtle" color="gray" onClick={cancelEditing}>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
) : (
|
||||
<Group gap={8} align="center">
|
||||
<span>
|
||||
{isSeriesGroup
|
||||
? `Series: ${recordingName}`
|
||||
: savedTitle
|
||||
? recordingName
|
||||
: `${recordingName}${program.sub_title ? ` - ${program.sub_title}` : ''}`}
|
||||
</span>
|
||||
{!isSeriesGroup && (
|
||||
<ActionIcon size="sm" variant="subtle" color="dimmed" onClick={startEditing}>
|
||||
<Pencil size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
size="lg"
|
||||
centered
|
||||
|
|
@ -430,7 +570,7 @@ const RecordingDetailsModal = ({
|
|||
title: { color: 'white' },
|
||||
}}
|
||||
>
|
||||
{isSeriesGroup ? <Series /> : <Movie />}
|
||||
{isSeriesGroup ? Series() : Movie()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,11 +33,10 @@ import {
|
|||
updateRecurringRuleEnabled,
|
||||
} from '../../utils/forms/RecurringRuleModalUtils.js';
|
||||
|
||||
const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
|
||||
const RecurringRuleModal = ({ opened, onClose, ruleId, recording: sourceRecording, onEditOccurrence }) => {
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
const recurringRules = useChannelsStore((s) => s.recurringRules);
|
||||
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
|
||||
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
|
||||
const recordings = useChannelsStore((s) => s.recordings);
|
||||
const { toUserTime, userNow } = useTimeHelpers();
|
||||
const { timeFormat: timeformat, dateFormat: dateformat } =
|
||||
|
|
@ -125,7 +124,7 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
|
|||
setSaving(true);
|
||||
try {
|
||||
await updateRecurringRule(ruleId, values);
|
||||
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
|
||||
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
|
||||
notifications.show({
|
||||
title: 'Recurring rule updated',
|
||||
message: 'Schedule adjustments saved',
|
||||
|
|
@ -145,7 +144,7 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
|
|||
setDeleting(true);
|
||||
try {
|
||||
await deleteRecurringRuleById(ruleId);
|
||||
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
|
||||
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
|
||||
notifications.show({
|
||||
title: 'Recurring rule removed',
|
||||
message: 'All future occurrences were cancelled',
|
||||
|
|
@ -165,7 +164,7 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
|
|||
setSaving(true);
|
||||
try {
|
||||
await updateRecurringRuleEnabled(ruleId, checked);
|
||||
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
|
||||
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
|
||||
notifications.show({
|
||||
title: checked ? 'Recurring rule enabled' : 'Recurring rule paused',
|
||||
message: checked
|
||||
|
|
@ -186,7 +185,7 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
|
|||
setBusyOccurrence(occurrence.id);
|
||||
try {
|
||||
await deleteRecordingById(occurrence.id);
|
||||
await fetchRecordings();
|
||||
// recording_cancelled WS event handles recording list update
|
||||
notifications.show({
|
||||
title: 'Occurrence cancelled',
|
||||
message: 'The selected airing was removed',
|
||||
|
|
@ -203,7 +202,45 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
|
|||
if (!rule) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Recurring Rule" centered>
|
||||
<Text size="sm">Recurring rule not found.</Text>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
The recurring rule for this recording no longer exists.
|
||||
</Text>
|
||||
{sourceRecording && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
Would you like to delete this recording?
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={deleting}
|
||||
onClick={async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteRecordingById(sourceRecording.id);
|
||||
notifications.show({
|
||||
title: 'Recording deleted',
|
||||
color: 'green',
|
||||
autoClose: 2500,
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete orphaned recording', e);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete Recording
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
import React, { useMemo, useState, useEffect, lazy, Suspense } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
Flex,
|
||||
Group,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { SquarePlus } from 'lucide-react';
|
||||
import { Search, SquarePlus, X } from 'lucide-react';
|
||||
import useChannelsStore from '../store/channels';
|
||||
import API from '../api';
|
||||
import useSettingsStore from '../store/settings';
|
||||
|
|
@ -22,14 +26,22 @@ const RecordingDetailsModal = lazy(
|
|||
);
|
||||
import RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx';
|
||||
import RecordingCard from '../components/cards/RecordingCard.jsx';
|
||||
import { categorizeRecordings } from '../utils/pages/DVRUtils.js';
|
||||
import { categorizeRecordings, filterRecordings, buildChannelOptions } from '../utils/pages/DVRUtils.js';
|
||||
import {
|
||||
getChannelLogoUrl,
|
||||
getPosterUrl,
|
||||
getRecordingUrl,
|
||||
getShowVideoUrl,
|
||||
} from '../utils/cards/RecordingCardUtils.js';
|
||||
import ErrorBoundary from '../components/ErrorBoundary.jsx';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'recording', label: 'Recording' },
|
||||
{ value: 'scheduled', label: 'Scheduled' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'interrupted', label: 'Interrupted' },
|
||||
];
|
||||
|
||||
const RecordingList = ({
|
||||
list,
|
||||
onOpenDetails,
|
||||
|
|
@ -61,6 +73,11 @@ const DVRPage = () => {
|
|||
const [ruleModal, setRuleModal] = useState({ open: false, ruleId: null });
|
||||
const [editRecording, setEditRecording] = useState(null);
|
||||
|
||||
// Filter state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedChannelId, setSelectedChannelId] = useState(null);
|
||||
const [selectedStatus, setSelectedStatus] = useState(null);
|
||||
|
||||
const openRecordingModal = () => {
|
||||
setRecordingModalOpen(true);
|
||||
};
|
||||
|
|
@ -75,7 +92,7 @@ const DVRPage = () => {
|
|||
};
|
||||
const closeDetails = () => setDetailsOpen(false);
|
||||
|
||||
const openRuleModal = (recording) => {
|
||||
const openRuleModal = (recording, isDelete) => {
|
||||
const ruleId = recording?.custom_properties?.rule?.id;
|
||||
if (!ruleId) {
|
||||
openDetails(recording);
|
||||
|
|
@ -84,7 +101,7 @@ const DVRPage = () => {
|
|||
setDetailsOpen(false);
|
||||
setDetailsRecording(null);
|
||||
setEditRecording(null);
|
||||
setRuleModal({ open: true, ruleId });
|
||||
setRuleModal({ open: true, ruleId, recording, isDelete: isDelete || false });
|
||||
};
|
||||
|
||||
const closeRuleModal = () => setRuleModal({ open: false, ruleId: null });
|
||||
|
|
@ -129,6 +146,43 @@ const DVRPage = () => {
|
|||
return categorizeRecordings(recordings, toUserTime, now);
|
||||
}, [recordings, now, toUserTime]);
|
||||
|
||||
// Channel options for filter dropdown (from raw unfiltered data)
|
||||
const channelOptions = useMemo(() => {
|
||||
return buildChannelOptions(channelsById, inProgress, upcoming, completed);
|
||||
}, [channelsById, inProgress, upcoming, completed]);
|
||||
|
||||
// Filtered buckets
|
||||
const hasActiveFilters = searchQuery !== '' || selectedChannelId !== null || selectedStatus !== null;
|
||||
|
||||
const filteredInProgress = useMemo(() => {
|
||||
if (selectedStatus && selectedStatus !== 'recording') return [];
|
||||
return filterRecordings(inProgress, searchQuery, selectedChannelId);
|
||||
}, [inProgress, searchQuery, selectedChannelId, selectedStatus]);
|
||||
|
||||
const filteredUpcoming = useMemo(() => {
|
||||
if (selectedStatus && selectedStatus !== 'scheduled') return [];
|
||||
return filterRecordings(upcoming, searchQuery, selectedChannelId);
|
||||
}, [upcoming, searchQuery, selectedChannelId, selectedStatus]);
|
||||
|
||||
const filteredCompleted = useMemo(() => {
|
||||
if (selectedStatus && !['completed', 'interrupted'].includes(selectedStatus)) return [];
|
||||
let filtered = filterRecordings(completed, searchQuery, selectedChannelId);
|
||||
if (selectedStatus === 'interrupted') {
|
||||
filtered = filtered.filter((rec) => rec.custom_properties?.status === 'interrupted');
|
||||
} else if (selectedStatus === 'completed') {
|
||||
// "Completed" includes both completed and stopped recordings
|
||||
filtered = filtered.filter((rec) => rec.custom_properties?.status !== 'interrupted');
|
||||
}
|
||||
return filtered;
|
||||
}, [completed, searchQuery, selectedChannelId, selectedStatus]);
|
||||
|
||||
// Filter handlers
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedChannelId(null);
|
||||
setSelectedStatus(null);
|
||||
};
|
||||
|
||||
const handleOnWatchLive = () => {
|
||||
const rec = detailsRecording;
|
||||
const now = userNow();
|
||||
|
|
@ -158,33 +212,84 @@ const DVRPage = () => {
|
|||
url: getPosterUrl(
|
||||
detailsRecording.custom_properties?.poster_logo_id,
|
||||
undefined,
|
||||
channelsById[detailsRecording.channel]?.logo?.cache_url
|
||||
getChannelLogoUrl(channelsById[detailsRecording.channel])
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Box p={10}>
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={openRecordingModal}
|
||||
p={5}
|
||||
color={theme.tailwind.green[5]}
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
New Recording
|
||||
</Button>
|
||||
<Stack gap="lg" pt={12}>
|
||||
<Flex gap="md" align="center" wrap="wrap" mb={12}>
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={openRecordingModal}
|
||||
p={5}
|
||||
color={theme.tailwind.green[5]}
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
New Recording
|
||||
</Button>
|
||||
|
||||
<TextInput
|
||||
placeholder="Search recordings..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
w={'250px'}
|
||||
leftSection={<Search size={16} />}
|
||||
rightSection={
|
||||
searchQuery ? (
|
||||
<ActionIcon
|
||||
onClick={() => setSearchQuery('')}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder="Filter by channel"
|
||||
data={channelOptions}
|
||||
value={selectedChannelId}
|
||||
onChange={setSelectedChannelId}
|
||||
w={'220px'}
|
||||
clearable
|
||||
searchable
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder="Filter by status"
|
||||
data={STATUS_OPTIONS}
|
||||
value={selectedStatus}
|
||||
onChange={setSelectedStatus}
|
||||
w={'180px'}
|
||||
clearable
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="subtle" onClick={clearFilters} size="sm">
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Group justify="space-between" mb={8}>
|
||||
<Title order={4}>Currently Recording</Title>
|
||||
<Badge color="red.6">{inProgress.length}</Badge>
|
||||
<Badge color="red.6">
|
||||
{hasActiveFilters
|
||||
? `${filteredInProgress.length} / ${inProgress.length}`
|
||||
: inProgress.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
|
|
@ -196,15 +301,17 @@ const DVRPage = () => {
|
|||
>
|
||||
{
|
||||
<RecordingList
|
||||
list={inProgress}
|
||||
list={filteredInProgress}
|
||||
onOpenDetails={openDetails}
|
||||
onOpenRecurring={openRuleModal}
|
||||
channelsById={channelsById}
|
||||
/>
|
||||
}
|
||||
{inProgress.length === 0 && (
|
||||
{filteredInProgress.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing recording right now.
|
||||
{hasActiveFilters
|
||||
? 'No recordings match your filters.'
|
||||
: 'Nothing recording right now.'}
|
||||
</Text>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
|
@ -213,7 +320,11 @@ const DVRPage = () => {
|
|||
<div>
|
||||
<Group justify="space-between" mb={8}>
|
||||
<Title order={4}>Upcoming Recordings</Title>
|
||||
<Badge color="yellow.6">{upcoming.length}</Badge>
|
||||
<Badge color="yellow.6">
|
||||
{hasActiveFilters
|
||||
? `${filteredUpcoming.length} / ${upcoming.length}`
|
||||
: upcoming.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
|
|
@ -225,15 +336,17 @@ const DVRPage = () => {
|
|||
>
|
||||
{
|
||||
<RecordingList
|
||||
list={upcoming}
|
||||
list={filteredUpcoming}
|
||||
onOpenDetails={openDetails}
|
||||
onOpenRecurring={openRuleModal}
|
||||
channelsById={channelsById}
|
||||
/>
|
||||
}
|
||||
{upcoming.length === 0 && (
|
||||
{filteredUpcoming.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No upcoming recordings.
|
||||
{hasActiveFilters
|
||||
? 'No recordings match your filters.'
|
||||
: 'No upcoming recordings.'}
|
||||
</Text>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
|
@ -242,7 +355,11 @@ const DVRPage = () => {
|
|||
<div>
|
||||
<Group justify="space-between" mb={8}>
|
||||
<Title order={4}>Previously Recorded</Title>
|
||||
<Badge color="gray.6">{completed.length}</Badge>
|
||||
<Badge color="gray.6">
|
||||
{hasActiveFilters
|
||||
? `${filteredCompleted.length} / ${completed.length}`
|
||||
: completed.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
|
|
@ -254,15 +371,17 @@ const DVRPage = () => {
|
|||
>
|
||||
{
|
||||
<RecordingList
|
||||
list={completed}
|
||||
list={filteredCompleted}
|
||||
onOpenDetails={openDetails}
|
||||
onOpenRecurring={openRuleModal}
|
||||
channelsById={channelsById}
|
||||
/>
|
||||
}
|
||||
{completed.length === 0 && (
|
||||
{filteredCompleted.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No completed recordings yet.
|
||||
{hasActiveFilters
|
||||
? 'No recordings match your filters.'
|
||||
: 'No completed recordings yet.'}
|
||||
</Text>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
|
@ -284,6 +403,7 @@ const DVRPage = () => {
|
|||
opened={ruleModal.open}
|
||||
onClose={closeRuleModal}
|
||||
ruleId={ruleModal.ruleId}
|
||||
recording={ruleModal.recording}
|
||||
onEditOccurrence={(occ) => {
|
||||
setRuleModal({ open: false, ruleId: null });
|
||||
setEditRecording(occ);
|
||||
|
|
@ -302,7 +422,7 @@ const DVRPage = () => {
|
|||
posterUrl={getPosterUrl(
|
||||
detailsRecording.custom_properties?.poster_logo_id,
|
||||
detailsRecording.custom_properties,
|
||||
channelsById[detailsRecording.channel]?.logo?.cache_url
|
||||
getChannelLogoUrl(channelsById[detailsRecording.channel])
|
||||
)}
|
||||
env_mode={useSettingsStore.getState().environment.env_mode}
|
||||
onWatchLive={handleOnWatchLive}
|
||||
|
|
|
|||
|
|
@ -93,7 +93,6 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
const recordings = useChannelsStore((s) => s.recordings);
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
const isLoading = useChannelsStore((s) => s.isLoading);
|
||||
const [isProgramsLoading, setIsProgramsLoading] = useState(true);
|
||||
const logos = useLogosStore((s) => s.logos);
|
||||
|
||||
|
|
@ -699,14 +698,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
const saveSeriesRule = useCallback(async (program, mode) => {
|
||||
await createSeriesRule(program, mode);
|
||||
await evaluateSeriesRule(program);
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Failed to refresh recordings after saving series rule',
|
||||
error
|
||||
);
|
||||
}
|
||||
// recordings_refreshed WS event triggers the debounced fetchRecordings()
|
||||
showNotification({
|
||||
title: mode === 'new' ? 'Record new episodes' : 'Record all episodes',
|
||||
});
|
||||
|
|
@ -1351,7 +1343,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
pos="relative"
|
||||
>
|
||||
<LoadingOverlay
|
||||
visible={isLoading || isProgramsLoading || isChannelsLoading}
|
||||
visible={isProgramsLoading || isChannelsLoading}
|
||||
/>
|
||||
{nowPosition >= 0 && (
|
||||
<Box
|
||||
|
|
|
|||
|
|
@ -33,10 +33,35 @@ vi.mock('../../api');
|
|||
|
||||
// Mock Mantine components
|
||||
vi.mock('@mantine/core', () => ({
|
||||
ActionIcon: ({ children, onClick }) => (
|
||||
<button data-testid="action-icon" onClick={onClick}>{children}</button>
|
||||
),
|
||||
Box: ({ children }) => <div data-testid="box">{children}</div>,
|
||||
Container: ({ children }) => <div data-testid="container">{children}</div>,
|
||||
Flex: ({ children }) => <div data-testid="flex">{children}</div>,
|
||||
Title: ({ children, order }) => <h1 data-order={order}>{children}</h1>,
|
||||
Text: ({ children }) => <p>{children}</p>,
|
||||
TextInput: ({ placeholder, value, onChange, ...props }) => (
|
||||
<input
|
||||
data-testid="text-input"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
Select: ({ placeholder, data, value, onChange, ...props }) => (
|
||||
<select
|
||||
data-testid="select"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
aria-label={placeholder}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{(data || []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
Button: ({ children, onClick, leftSection, loading, ...props }) => (
|
||||
<button onClick={onClick} disabled={loading} {...props}>
|
||||
{leftSection}
|
||||
|
|
@ -128,11 +153,15 @@ vi.mock('../../utils/dateTimeUtils.js', async (importActual) => {
|
|||
useTimeHelpers: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../../utils/cards/RecordingCardUtils.js', () => ({
|
||||
getPosterUrl: vi.fn(),
|
||||
getRecordingUrl: vi.fn(),
|
||||
getShowVideoUrl: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../utils/cards/RecordingCardUtils.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
getPosterUrl: vi.fn(),
|
||||
getRecordingUrl: vi.fn(),
|
||||
getShowVideoUrl: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../../utils/pages/DVRUtils.js', async (importActual) => {
|
||||
const actual = await importActual();
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ describe('UsersPage', () => {
|
|||
|
||||
const { container } = render(<UsersPage />);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('users-table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ describe('UsersPage', () => {
|
|||
const { container } = render(<UsersPage />);
|
||||
|
||||
// id: 0 is falsy, so should render empty
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches from unauthenticated to authenticated state', () => {
|
||||
|
|
@ -47,7 +47,7 @@ describe('UsersPage', () => {
|
|||
|
||||
render(<UsersPage />);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
|
||||
|
||||
useAuthStore.mockReturnValue({ id: 1 });
|
||||
|
||||
|
|
|
|||
|
|
@ -705,6 +705,23 @@ describe('guideUtils', () => {
|
|||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should exclude terminal status recordings', () => {
|
||||
const recordings = [
|
||||
{ id: 1, custom_properties: { program: { id: 'p1' }, status: 'completed' } },
|
||||
{ id: 2, custom_properties: { program: { id: 'p2' }, status: 'stopped' } },
|
||||
{ id: 3, custom_properties: { program: { id: 'p3' }, status: 'interrupted' } },
|
||||
{ id: 4, custom_properties: { program: { id: 'p4' }, status: 'failed' } },
|
||||
{ id: 5, custom_properties: { program: { id: 'p5' }, status: 'recording' } },
|
||||
{ id: 6, custom_properties: { program: { id: 'p6' } } },
|
||||
];
|
||||
|
||||
const result = guideUtils.mapRecordingsByProgramId(recordings);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get('p5').id).toBe(5);
|
||||
expect(result.get('p6').id).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTime', () => {
|
||||
|
|
|
|||
|
|
@ -214,11 +214,15 @@ export const mapChannelsById = (guideChannels) => {
|
|||
return map;
|
||||
};
|
||||
|
||||
const _terminalStatuses = new Set(['stopped', 'completed', 'interrupted', 'failed']);
|
||||
|
||||
export const mapRecordingsByProgramId = (recordings) => {
|
||||
const map = new Map();
|
||||
(recordings || []).forEach((recording) => {
|
||||
const programId = recording?.custom_properties?.program?.id;
|
||||
if (programId != null) {
|
||||
const status = recording?.custom_properties?.status;
|
||||
// Only show indicator for pending/active recordings, not terminal ones
|
||||
if (programId != null && !_terminalStatuses.has(status)) {
|
||||
map.set(programId, recording);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -459,15 +459,10 @@ const useChannelsStore = create((set, get) => ({
|
|||
},
|
||||
|
||||
fetchRecordings: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
set({
|
||||
recordings: await api.getRecordings(),
|
||||
isLoading: false,
|
||||
});
|
||||
set({ recordings: await api.getRecordings() });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recordings:', error);
|
||||
set({ error: 'Failed to load recordings.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -494,11 +489,16 @@ const useChannelsStore = create((set, get) => ({
|
|||
const target = String(id);
|
||||
const current = state.recordings;
|
||||
if (Array.isArray(current)) {
|
||||
// Early return if item doesn't exist — avoids a new array reference
|
||||
// (and thus a needless re-render) when called redundantly, e.g. both
|
||||
// the optimistic API delete and the WS recording_cancelled handler.
|
||||
if (!current.some((r) => String(r?.id) === target)) return {};
|
||||
return {
|
||||
recordings: current.filter((r) => String(r?.id) !== target),
|
||||
};
|
||||
}
|
||||
if (current && typeof current === 'object') {
|
||||
if (!Object.values(current).some((r) => String(r?.id) === target)) return {};
|
||||
const next = { ...current };
|
||||
for (const k of Object.keys(next)) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import API from '../../api.js';
|
||||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import defaultLogo from '../../images/logo.png';
|
||||
|
||||
export const removeRecording = (id) => {
|
||||
// Optimistically remove immediately from UI
|
||||
|
|
@ -19,20 +20,30 @@ export const removeRecording = (id) => {
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the channel logo cache URL from either a full channel object
|
||||
* (has logo.cache_url) or a summary object (has logo_id integer).
|
||||
*/
|
||||
export const getChannelLogoUrl = (channel) => {
|
||||
if (!channel) return null;
|
||||
if (channel.logo_id) return `/api/channels/logos/${channel.logo_id}/cache/`;
|
||||
return channel.logo?.cache_url || null;
|
||||
};
|
||||
|
||||
export const getPosterUrl = (posterLogoId, customProperties, posterUrl) => {
|
||||
let purl = posterLogoId
|
||||
? `/api/channels/logos/${posterLogoId}/cache/`
|
||||
: customProperties?.poster_url || posterUrl || '/logo.png';
|
||||
: customProperties?.poster_url || posterUrl || null;
|
||||
if (
|
||||
purl &&
|
||||
typeof import.meta !== 'undefined' &&
|
||||
import.meta.env &&
|
||||
import.meta.env.DEV &&
|
||||
purl &&
|
||||
purl.startsWith('/')
|
||||
) {
|
||||
purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`;
|
||||
}
|
||||
return purl;
|
||||
return purl || defaultLogo;
|
||||
};
|
||||
|
||||
export const getShowVideoUrl = (channel, env_mode) => {
|
||||
|
|
@ -51,6 +62,14 @@ export const deleteRecordingById = async (recordingId) => {
|
|||
await API.deleteRecording(recordingId);
|
||||
};
|
||||
|
||||
export const stopRecordingById = async (recordingId) => {
|
||||
await API.stopRecording(recordingId);
|
||||
};
|
||||
|
||||
export const extendRecordingById = async (recordingId, extraMinutes) => {
|
||||
await API.extendRecording(recordingId, extraMinutes);
|
||||
};
|
||||
|
||||
export const deleteSeriesAndRule = async (seriesInfo) => {
|
||||
const { tvg_id, title } = seriesInfo;
|
||||
if (tvg_id) {
|
||||
|
|
|
|||
|
|
@ -126,7 +126,9 @@ describe('RecordingCardUtils', () => {
|
|||
vi.stubEnv('DEV', false);
|
||||
const result = getPosterUrl(null, {}, '');
|
||||
|
||||
expect(result).toBe('/logo.png');
|
||||
// Falls back to the imported default Dispatcharr logo asset
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toContain('logo');
|
||||
});
|
||||
|
||||
it('prepends dev server URL in dev mode for relative paths', () => {
|
||||
|
|
|
|||
|
|
@ -30,8 +30,9 @@ const filterByUpcoming = (arr, tvid, titleKey, toUserTime, userNow) => {
|
|||
|
||||
if ((pr.tvg_id || '') !== tvid) return false;
|
||||
if ((pr.title || '').toLowerCase() !== titleKey) return false;
|
||||
const st = toUserTime(r.start_time);
|
||||
return st.isAfter(userNow());
|
||||
// Include episodes that haven't ended yet (currently-airing + future)
|
||||
const et = toUserTime(r.end_time);
|
||||
return et.isAfter(userNow());
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -282,6 +282,7 @@ describe('RecordingDetailsModalUtils', () => {
|
|||
const recordings = [
|
||||
{
|
||||
start_time: '2024-01-02T12:00:00',
|
||||
end_time: '2024-01-02T13:00:00',
|
||||
channel: 'ch1',
|
||||
custom_properties: {
|
||||
program: { tvg_id: 'show1', title: 'Test Show' },
|
||||
|
|
@ -289,6 +290,7 @@ describe('RecordingDetailsModalUtils', () => {
|
|||
},
|
||||
{
|
||||
start_time: '2024-01-02T13:00:00',
|
||||
end_time: '2024-01-02T14:00:00',
|
||||
channel: 'ch1',
|
||||
custom_properties: {
|
||||
program: { tvg_id: 'show2', title: 'Other Show' },
|
||||
|
|
@ -313,6 +315,7 @@ describe('RecordingDetailsModalUtils', () => {
|
|||
const recordings = [
|
||||
{
|
||||
start_time: '2023-12-31T12:00:00',
|
||||
end_time: '2023-12-31T13:00:00',
|
||||
channel: 'ch1',
|
||||
custom_properties: {
|
||||
program: { tvg_id: 'show1', title: 'Test Show' },
|
||||
|
|
@ -320,6 +323,7 @@ describe('RecordingDetailsModalUtils', () => {
|
|||
},
|
||||
{
|
||||
start_time: '2024-01-02T12:00:00',
|
||||
end_time: '2024-01-02T13:00:00',
|
||||
channel: 'ch1',
|
||||
custom_properties: {
|
||||
program: { tvg_id: 'show1', title: 'Test Show' },
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const dedupeById = (list, toUserTime, completed, now, inProgress, upcoming) => {
|
|||
const e = toUserTime(rec.end_time);
|
||||
const status = rec.custom_properties?.status;
|
||||
|
||||
if (status === 'interrupted' || status === 'completed') {
|
||||
if (status === 'interrupted' || status === 'completed' || status === 'stopped') {
|
||||
completed.push(rec);
|
||||
} else {
|
||||
if (now.isAfter(s) && now.isBefore(e)) inProgress.push(rec);
|
||||
|
|
@ -88,3 +88,55 @@ export const categorizeRecordings = (recordings, toUserTime, now) => {
|
|||
completed,
|
||||
};
|
||||
};
|
||||
|
||||
export const filterRecordings = (recordings, searchQuery, selectedChannelId) => {
|
||||
return recordings.filter((rec) => {
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const cp = rec.custom_properties || {};
|
||||
const program = cp.program || {};
|
||||
const title = (program.title || '').toLowerCase();
|
||||
const subTitle = (program.sub_title || '').toLowerCase();
|
||||
const description = (program.description || cp.description || '').toLowerCase();
|
||||
|
||||
if (!title.includes(q) && !subTitle.includes(q) && !description.includes(q)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedChannelId) {
|
||||
if (String(rec.channel) !== String(selectedChannelId)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const buildChannelOptions = (channelsById, ...buckets) => {
|
||||
const channelIds = new Set();
|
||||
for (const bucket of buckets) {
|
||||
for (const rec of bucket) {
|
||||
if (rec.channel != null) channelIds.add(rec.channel);
|
||||
}
|
||||
}
|
||||
|
||||
const options = [];
|
||||
for (const id of channelIds) {
|
||||
const ch = channelsById[id];
|
||||
if (ch) {
|
||||
options.push({
|
||||
value: String(ch.id),
|
||||
label: ch.channel_number ? `${ch.channel_number} - ${ch.name}` : ch.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
options.sort((a, b) => {
|
||||
const aNum = parseInt(a.label, 10);
|
||||
const bNum = parseInt(b.label, 10);
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) return aNum - bNum;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return options;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -541,4 +541,162 @@ describe('DVRUtils', () => {
|
|||
expect(result.upcoming[0]._group_count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRecordings', () => {
|
||||
const makeRec = (id, title, subTitle, description, channel) => ({
|
||||
id,
|
||||
channel,
|
||||
custom_properties: {
|
||||
program: { title, sub_title: subTitle, description },
|
||||
},
|
||||
});
|
||||
|
||||
it('returns all recordings when no filters active', () => {
|
||||
const recs = [makeRec(1, 'News', 'Episode 1', 'Daily news', 5)];
|
||||
expect(DVRUtils.filterRecordings(recs, '', null)).toEqual(recs);
|
||||
});
|
||||
|
||||
it('filters by title case-insensitively', () => {
|
||||
const recs = [
|
||||
makeRec(1, 'Morning News', '', '', 1),
|
||||
makeRec(2, 'Sports Center', '', '', 1),
|
||||
];
|
||||
const result = DVRUtils.filterRecordings(recs, 'news', null);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('filters by sub_title', () => {
|
||||
const recs = [
|
||||
makeRec(1, 'Show A', 'Pilot Episode', '', 1),
|
||||
makeRec(2, 'Show B', 'Finale', '', 1),
|
||||
];
|
||||
const result = DVRUtils.filterRecordings(recs, 'pilot', null);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('filters by description', () => {
|
||||
const recs = [
|
||||
makeRec(1, 'Show A', '', 'A thriller about detectives', 1),
|
||||
makeRec(2, 'Show B', '', 'A comedy special', 1),
|
||||
];
|
||||
const result = DVRUtils.filterRecordings(recs, 'detective', null);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('filters by channel id', () => {
|
||||
const recs = [
|
||||
makeRec(1, 'Show A', '', '', 5),
|
||||
makeRec(2, 'Show B', '', '', 10),
|
||||
];
|
||||
const result = DVRUtils.filterRecordings(recs, '', '5');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('combines search and channel filter with AND logic', () => {
|
||||
const recs = [
|
||||
makeRec(1, 'News', '', '', 5),
|
||||
makeRec(2, 'News', '', '', 10),
|
||||
makeRec(3, 'Sports', '', '', 5),
|
||||
];
|
||||
const result = DVRUtils.filterRecordings(recs, 'news', '5');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('handles recordings without custom_properties', () => {
|
||||
const recs = [{ id: 1, channel: 5 }];
|
||||
const result = DVRUtils.filterRecordings(recs, 'anything', null);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles recordings without custom_properties and no search', () => {
|
||||
const recs = [{ id: 1, channel: 5 }];
|
||||
const result = DVRUtils.filterRecordings(recs, '', null);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
expect(DVRUtils.filterRecordings([], 'test', '5')).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to custom_properties.description', () => {
|
||||
const recs = [{
|
||||
id: 1,
|
||||
channel: 1,
|
||||
custom_properties: {
|
||||
description: 'A fallback description',
|
||||
program: { title: 'Show' },
|
||||
},
|
||||
}];
|
||||
const result = DVRUtils.filterRecordings(recs, 'fallback', null);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildChannelOptions', () => {
|
||||
const channelsById = {
|
||||
5: { id: 5, name: 'CNN', channel_number: '5' },
|
||||
10: { id: 10, name: 'ESPN', channel_number: '10' },
|
||||
3: { id: 3, name: 'NBC', channel_number: '3' },
|
||||
20: { id: 20, name: 'Local Access' },
|
||||
};
|
||||
|
||||
it('returns only channels present in recordings', () => {
|
||||
const bucket = [{ channel: 5 }, { channel: 10 }];
|
||||
const result = DVRUtils.buildChannelOptions(channelsById, bucket);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((o) => o.value)).toEqual(['5', '10']);
|
||||
});
|
||||
|
||||
it('sorts by channel number numerically', () => {
|
||||
const bucket = [{ channel: 10 }, { channel: 3 }, { channel: 5 }];
|
||||
const result = DVRUtils.buildChannelOptions(channelsById, bucket);
|
||||
expect(result[0].label).toBe('3 - NBC');
|
||||
expect(result[1].label).toBe('5 - CNN');
|
||||
expect(result[2].label).toBe('10 - ESPN');
|
||||
});
|
||||
|
||||
it('handles multiple buckets', () => {
|
||||
const b1 = [{ channel: 5 }];
|
||||
const b2 = [{ channel: 10 }];
|
||||
const b3 = [{ channel: 3 }];
|
||||
const result = DVRUtils.buildChannelOptions(channelsById, b1, b2, b3);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('deduplicates channels across buckets', () => {
|
||||
const b1 = [{ channel: 5 }, { channel: 10 }];
|
||||
const b2 = [{ channel: 5 }, { channel: 3 }];
|
||||
const result = DVRUtils.buildChannelOptions(channelsById, b1, b2);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('skips channels not in channelsById', () => {
|
||||
const bucket = [{ channel: 5 }, { channel: 999 }];
|
||||
const result = DVRUtils.buildChannelOptions(channelsById, bucket);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value).toBe('5');
|
||||
});
|
||||
|
||||
it('formats label with channel number when available', () => {
|
||||
const bucket = [{ channel: 5 }];
|
||||
const result = DVRUtils.buildChannelOptions(channelsById, bucket);
|
||||
expect(result[0].label).toBe('5 - CNN');
|
||||
});
|
||||
|
||||
it('formats label without channel number when missing', () => {
|
||||
const bucket = [{ channel: 20 }];
|
||||
const result = DVRUtils.buildChannelOptions(channelsById, bucket);
|
||||
expect(result[0].label).toBe('Local Access');
|
||||
});
|
||||
|
||||
it('returns empty array for empty buckets', () => {
|
||||
const result = DVRUtils.buildChannelOptions(channelsById, [], []);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue