mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
fix(dvr): add DB retry resilience, fix recording card logos, prevent incorrect artwork
Add exponential backoff retry (_db_retry helper) for transient DB errors during final metadata save, startup recovery, and initial TS proxy connection. Add FFmpeg remux retry for transient I/O errors. Retry same proxy base before falling back to next candidate on initial connection failure. Fix DVR card logos not displaying: channel summary API returns logo_id (integer) but frontend expected a nested logo object with cache_url. Add getChannelLogoUrl() helper that handles both data shapes. Backend artwork prefetch and run_recording now fall back to the channel's own logo when no show-specific artwork is found, ensuring poster_logo_id is always populated. Prevent incorrect artwork by skipping external API searches (TVMaze, iTunes) when no program title is available - channel names like "USA A&E SD*" returned unrelated results from fuzzy matching. Add 13 tests for retry logic (test_dvr_retry.py)
This commit is contained in:
parent
e5206545dd
commit
cf3165ea88
6 changed files with 512 additions and 109 deletions
|
|
@ -30,6 +30,29 @@ from urllib.parse import quote
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _db_retry(fn, max_retries=3, base_interval=1, label="DB operation"):
|
||||
"""Execute fn() with exponential backoff retry on transient DB errors.
|
||||
|
||||
Follows the same backoff pattern as RedisClient.get_client().
|
||||
Resets stale connections between attempts so the ORM reconnects.
|
||||
"""
|
||||
from django.db import OperationalError, close_old_connections
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return fn()
|
||||
except OperationalError:
|
||||
if attempt + 1 >= max_retries:
|
||||
raise
|
||||
wait = base_interval * (2 ** attempt)
|
||||
logger.warning(
|
||||
f"{label}: failed, retrying in {wait}s "
|
||||
f"({attempt + 1}/{max_retries})..."
|
||||
)
|
||||
close_old_connections()
|
||||
time.sleep(wait)
|
||||
|
||||
|
||||
# PostgreSQL btree index has a limit of ~2704 bytes (1/3 of 8KB page size)
|
||||
# We use 2000 as a safe maximum to account for multibyte characters
|
||||
def validate_logo_url(logo_url, max_length=2000):
|
||||
|
|
@ -1743,10 +1766,12 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
|
|||
except Exception as e:
|
||||
logger.debug(f"External poster lookup failed: {e}")
|
||||
|
||||
# Keyless fallback providers (no API keys required)
|
||||
if not poster_url and not poster_logo_id:
|
||||
# Keyless fallback providers (no API keys required) — only search when
|
||||
# real program title is present; channel names return unrelated results.
|
||||
_program_title = (program.get('title') or '').strip()
|
||||
if not poster_url and not poster_logo_id and _program_title:
|
||||
try:
|
||||
title = (program.get('title') or channel.name or '').strip()
|
||||
title = _program_title
|
||||
if title:
|
||||
# 1) TVMaze (TV shows) - singlesearch by title
|
||||
try:
|
||||
|
|
@ -1802,6 +1827,10 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
|
|||
except Exception as e:
|
||||
logger.debug(f"Unable to persist poster to Logo: {e}")
|
||||
|
||||
# Fall back to the channel's own logo so the card always has an image
|
||||
if not poster_logo_id and not poster_url and channel.logo_id:
|
||||
poster_logo_id = channel.logo_id
|
||||
|
||||
if poster_logo_id:
|
||||
cp["poster_logo_id"] = poster_logo_id
|
||||
if poster_url and "poster_url" not in cp:
|
||||
|
|
@ -1894,14 +1923,16 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
|
|||
pass
|
||||
return False, False, None
|
||||
|
||||
# --- Stream with reconnection ---
|
||||
# Iterate candidate base URLs until one delivers data. Once streaming,
|
||||
# transient connectivity losses (upstream provider drops, proxy
|
||||
# reconnects) trigger automatic re-connection to the same base rather
|
||||
# than aborting the recording. TS container format tolerates the brief
|
||||
# discontinuity; the MKV remux normalises timestamps afterwards.
|
||||
# --- Retry / reconnection constants ---
|
||||
# Stream reconnection: retry the same TS proxy base on transient
|
||||
# connectivity loss. Counter resets when data resumes.
|
||||
_dvr_max_reconnects = 5
|
||||
_dvr_reconnect_delay = 2.0 # seconds
|
||||
# DB save retry: exponential backoff (1s, 2s, 4s) for transient errors.
|
||||
_dvr_db_max_retries = 3
|
||||
_dvr_db_retry_interval = 1 # seconds (base for exponential backoff)
|
||||
# FFmpeg remux retry: covers transient I/O errors.
|
||||
_dvr_remux_max_retries = 2
|
||||
|
||||
for base in candidates:
|
||||
test_url = f"{base.rstrip('/')}/proxy/ts/stream/{channel.uuid}"
|
||||
|
|
@ -2060,14 +2091,28 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
|
|||
_done = True
|
||||
break
|
||||
|
||||
# No data received yet — try next candidate base
|
||||
last_error = str(e)
|
||||
logger.warning(f"DVR recording {recording_id}: base {base} failed: {e}")
|
||||
# No data received yet — retry same base before moving on
|
||||
should_exit, is_int, reason = _check_recording_cancelled(recording_id)
|
||||
if should_exit:
|
||||
interrupted = is_int
|
||||
interrupted_reason = reason
|
||||
_done = True
|
||||
break
|
||||
_reconnects += 1
|
||||
if _reconnects <= _dvr_max_reconnects:
|
||||
logger.warning(
|
||||
f"DVR recording {recording_id}: initial connection "
|
||||
f"to {base} failed ({type(e).__name__}), retrying "
|
||||
f"({_reconnects}/{_dvr_max_reconnects}) "
|
||||
f"in {_dvr_reconnect_delay}s..."
|
||||
)
|
||||
time.sleep(_dvr_reconnect_delay)
|
||||
continue
|
||||
last_error = str(e)
|
||||
logger.warning(
|
||||
f"DVR recording {recording_id}: base {base} exhausted "
|
||||
f"retries ({_dvr_max_reconnects}): {e}"
|
||||
)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -2174,101 +2219,115 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
|
|||
pass
|
||||
return
|
||||
|
||||
# Remux TS to MKV container
|
||||
# Remux TS to MKV container with retry for transient I/O errors
|
||||
remux_success = False
|
||||
try:
|
||||
if temp_ts_path and os.path.exists(temp_ts_path):
|
||||
# First attempt: Direct TS to MKV remux
|
||||
result = subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-fflags", "+genpts+igndts+discardcorrupt", # Regenerate timestamps, ignore DTS
|
||||
"-err_detect", "ignore_err", # Ignore minor stream errors
|
||||
"-i", temp_ts_path,
|
||||
"-map", "0", # Map all streams
|
||||
"-c", "copy",
|
||||
final_path
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
|
||||
# Check if FFmpeg succeeded (return code 0) and output file is valid
|
||||
if result.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0:
|
||||
remux_success = True
|
||||
logger.info(f"Direct TS→MKV remux succeeded for {os.path.basename(final_path)}")
|
||||
else:
|
||||
# Direct remux failed - try fallback: TS → MP4 → MKV to fix timestamps
|
||||
logger.warning(f"Direct TS→MKV remux failed (return code: {result.returncode}), trying fallback TS→MP4→MKV")
|
||||
|
||||
# Clean up partial/failed MKV
|
||||
try:
|
||||
if os.path.exists(final_path):
|
||||
os.remove(final_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 1: TS → MP4 (MP4 container handles broken timestamps better)
|
||||
temp_mp4_path = os.path.splitext(temp_ts_path)[0] + ".mp4"
|
||||
result_mp4 = subprocess.run([
|
||||
for _remux_attempt in range(_dvr_remux_max_retries):
|
||||
try:
|
||||
if temp_ts_path and os.path.exists(temp_ts_path):
|
||||
# First attempt: Direct TS to MKV remux
|
||||
result = subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-fflags", "+genpts+igndts+discardcorrupt",
|
||||
"-err_detect", "ignore_err",
|
||||
"-fflags", "+genpts+igndts+discardcorrupt", # Regenerate timestamps, ignore DTS
|
||||
"-err_detect", "ignore_err", # Ignore minor stream errors
|
||||
"-i", temp_ts_path,
|
||||
"-map", "0",
|
||||
"-map", "0", # Map all streams
|
||||
"-c", "copy",
|
||||
temp_mp4_path
|
||||
final_path
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
|
||||
if result_mp4.returncode == 0 and os.path.exists(temp_mp4_path) and os.path.getsize(temp_mp4_path) > 0:
|
||||
logger.info(f"TS→MP4 conversion succeeded, now converting MP4→MKV")
|
||||
# Check if FFmpeg succeeded (return code 0) and output file is valid
|
||||
if result.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0:
|
||||
remux_success = True
|
||||
logger.info(f"Direct TS→MKV remux succeeded for {os.path.basename(final_path)}")
|
||||
else:
|
||||
# Direct remux failed - try fallback: TS → MP4 → MKV to fix timestamps
|
||||
logger.warning(f"Direct TS→MKV remux failed (return code: {result.returncode}), trying fallback TS→MP4→MKV")
|
||||
|
||||
# Step 2: MP4 → MKV (clean timestamps from MP4)
|
||||
result_mkv = subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-i", temp_mp4_path,
|
||||
"-map", "0",
|
||||
"-c", "copy",
|
||||
final_path
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
|
||||
if result_mkv.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0:
|
||||
remux_success = True
|
||||
logger.info(f"Fallback TS→MP4→MKV remux succeeded for {os.path.basename(final_path)}")
|
||||
else:
|
||||
logger.error(f"MP4→MKV conversion failed (return code: {result_mkv.returncode})")
|
||||
|
||||
# Clean up temp MP4
|
||||
# Clean up partial/failed MKV
|
||||
try:
|
||||
if os.path.exists(temp_mp4_path):
|
||||
os.remove(temp_mp4_path)
|
||||
if os.path.exists(final_path):
|
||||
os.remove(final_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 1: TS → MP4 (MP4 container handles broken timestamps better)
|
||||
temp_mp4_path = os.path.splitext(temp_ts_path)[0] + ".mp4"
|
||||
result_mp4 = subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-fflags", "+genpts+igndts+discardcorrupt",
|
||||
"-err_detect", "ignore_err",
|
||||
"-i", temp_ts_path,
|
||||
"-map", "0",
|
||||
"-c", "copy",
|
||||
temp_mp4_path
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
|
||||
if result_mp4.returncode == 0 and os.path.exists(temp_mp4_path) and os.path.getsize(temp_mp4_path) > 0:
|
||||
logger.info(f"TS→MP4 conversion succeeded, now converting MP4→MKV")
|
||||
|
||||
# Step 2: MP4 → MKV (clean timestamps from MP4)
|
||||
result_mkv = subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-i", temp_mp4_path,
|
||||
"-map", "0",
|
||||
"-c", "copy",
|
||||
final_path
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
|
||||
if result_mkv.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0:
|
||||
remux_success = True
|
||||
logger.info(f"Fallback TS→MP4→MKV remux succeeded for {os.path.basename(final_path)}")
|
||||
else:
|
||||
logger.error(f"MP4→MKV conversion failed (return code: {result_mkv.returncode})")
|
||||
|
||||
# Clean up temp MP4
|
||||
try:
|
||||
if os.path.exists(temp_mp4_path):
|
||||
os.remove(temp_mp4_path)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
logger.error(f"TS→MP4 conversion failed (return code: {result_mp4.returncode})")
|
||||
|
||||
# Clean up temp TS file only on successful remux
|
||||
if remux_success:
|
||||
try:
|
||||
os.remove(temp_ts_path)
|
||||
logger.debug(f"Cleaned up temp TS file: {temp_ts_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove temp TS file: {e}")
|
||||
else:
|
||||
logger.error(f"TS→MP4 conversion failed (return code: {result_mp4.returncode})")
|
||||
# Keep TS file for debugging/manual recovery if remux failed
|
||||
logger.warning(f"Remux failed - keeping temp TS file for recovery: {temp_ts_path}")
|
||||
# Clean up any partial MKV
|
||||
try:
|
||||
if os.path.exists(final_path):
|
||||
os.remove(final_path)
|
||||
logger.debug(f"Cleaned up partial MKV file: {final_path}")
|
||||
except Exception:
|
||||
pass
|
||||
break # Completed (success or deterministic failure)
|
||||
|
||||
# Clean up temp TS file only on successful remux
|
||||
if remux_success:
|
||||
try:
|
||||
os.remove(temp_ts_path)
|
||||
logger.debug(f"Cleaned up temp TS file: {temp_ts_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove temp TS file: {e}")
|
||||
except Exception as e:
|
||||
# Clean up partial output before potential retry
|
||||
try:
|
||||
if os.path.exists(final_path):
|
||||
os.remove(final_path)
|
||||
except Exception:
|
||||
pass
|
||||
if _remux_attempt + 1 < _dvr_remux_max_retries:
|
||||
_wait = _dvr_db_retry_interval * (2 ** _remux_attempt)
|
||||
logger.warning(
|
||||
f"DVR recording {recording_id}: remux failed "
|
||||
f"({type(e).__name__}), retrying in {_wait}s "
|
||||
f"({_remux_attempt + 1}/{_dvr_remux_max_retries})..."
|
||||
)
|
||||
time.sleep(_wait)
|
||||
else:
|
||||
# Keep TS file for debugging/manual recovery if remux failed
|
||||
logger.warning(f"Remux failed - keeping temp TS file for recovery: {temp_ts_path}")
|
||||
# Clean up any partial MKV
|
||||
try:
|
||||
if os.path.exists(final_path):
|
||||
os.remove(final_path)
|
||||
logger.debug(f"Cleaned up partial MKV file: {final_path}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"MKV remux failed with exception: {e}")
|
||||
# Clean up any partial files on exception
|
||||
try:
|
||||
if os.path.exists(final_path):
|
||||
os.remove(final_path)
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(
|
||||
f"DVR recording {recording_id}: remux failed "
|
||||
f"after {_dvr_remux_max_retries} attempts: {e}"
|
||||
)
|
||||
|
||||
# Persist final metadata to Recording (status, ended_at, and stream stats if available)
|
||||
try:
|
||||
|
|
@ -2364,7 +2423,17 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
|
|||
return
|
||||
|
||||
recording_obj.custom_properties = cp
|
||||
recording_obj.save(update_fields=["custom_properties"])
|
||||
|
||||
def _save_final_metadata():
|
||||
recording_obj.custom_properties = cp
|
||||
recording_obj.save(update_fields=["custom_properties"])
|
||||
|
||||
_db_retry(
|
||||
_save_final_metadata,
|
||||
max_retries=_dvr_db_max_retries,
|
||||
base_interval=_dvr_db_retry_interval,
|
||||
label=f"DVR recording {recording_id}: metadata save",
|
||||
)
|
||||
|
||||
# Notify frontends so the UI refreshes immediately (e.g. "Stopped" → "Completed")
|
||||
try:
|
||||
|
|
@ -2412,8 +2481,14 @@ def recover_recordings_on_startup():
|
|||
|
||||
now = timezone.now()
|
||||
|
||||
# Resume in-window recordings
|
||||
active = Recording.objects.filter(start_time__lte=now, end_time__gt=now)
|
||||
# Resume in-window recordings. DB queries and saves use _db_retry
|
||||
# to tolerate transient connection errors common during startup.
|
||||
active = _db_retry(
|
||||
lambda: list(Recording.objects.filter(
|
||||
start_time__lte=now, end_time__gt=now
|
||||
)),
|
||||
label="DVR recovery: fetching active recordings",
|
||||
)
|
||||
for rec in active:
|
||||
try:
|
||||
cp = rec.custom_properties or {}
|
||||
|
|
@ -2435,7 +2510,10 @@ def recover_recordings_on_startup():
|
|||
cp["status"] = "interrupted"
|
||||
cp["interrupted_reason"] = "server_restarted"
|
||||
rec.custom_properties = cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
_db_retry(
|
||||
lambda r=rec: r.save(update_fields=["custom_properties"]),
|
||||
label=f"DVR recovery: recording {rec.id} status update",
|
||||
)
|
||||
|
||||
# Start recording for remaining window
|
||||
run_recording.apply_async(
|
||||
|
|
@ -2447,7 +2525,12 @@ def recover_recordings_on_startup():
|
|||
# Ensure future recordings are scheduled.
|
||||
# With ClockedSchedule, PeriodicTasks survive restarts in the DB.
|
||||
# Only recreate if the PeriodicTask is missing (safety net).
|
||||
upcoming = Recording.objects.filter(start_time__gt=now, end_time__gt=now)
|
||||
upcoming = _db_retry(
|
||||
lambda: list(Recording.objects.filter(
|
||||
start_time__gt=now, end_time__gt=now
|
||||
)),
|
||||
label="DVR recovery: fetching upcoming recordings",
|
||||
)
|
||||
for rec in upcoming:
|
||||
try:
|
||||
from django_celery_beat.models import PeriodicTask as _PT
|
||||
|
|
@ -2456,13 +2539,19 @@ def recover_recordings_on_startup():
|
|||
if _PT.objects.filter(name=task_name).exists():
|
||||
if rec.task_id != task_name:
|
||||
rec.task_id = task_name
|
||||
rec.save(update_fields=["task_id"])
|
||||
_db_retry(
|
||||
lambda r=rec: r.save(update_fields=["task_id"]),
|
||||
label=f"DVR recovery: recording {rec.id} task_id update",
|
||||
)
|
||||
continue
|
||||
# PeriodicTask missing - recreate it
|
||||
task_id = schedule_recording_task(rec)
|
||||
if task_id:
|
||||
rec.task_id = task_id
|
||||
rec.save(update_fields=["task_id"])
|
||||
_db_retry(
|
||||
lambda r=rec: r.save(update_fields=["task_id"]),
|
||||
label=f"DVR recovery: recording {rec.id} task_id update",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to schedule recording {rec.id}: {e}")
|
||||
|
||||
|
|
@ -2782,10 +2871,13 @@ def _resolve_poster_for_program(channel_name, program):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Keyless providers (TVMaze & iTunes)
|
||||
if not poster_url and not poster_logo_id:
|
||||
# Keyless providers (TVMaze & iTunes) — only when real program title is
|
||||
# present. Searching by channel name returns unrelated art work from
|
||||
# fuzzy API matching.
|
||||
program_title = program.get('title') if isinstance(program, dict) else None
|
||||
if not poster_url and not poster_logo_id and program_title:
|
||||
try:
|
||||
title = (program.get('title') if isinstance(program, dict) else None) or channel_name
|
||||
title = program_title
|
||||
if title:
|
||||
# TVMaze
|
||||
try:
|
||||
|
|
@ -2858,6 +2950,10 @@ def prefetch_recording_artwork(recording_id):
|
|||
|
||||
program = cp.get("program") or {}
|
||||
poster_logo_id, poster_url = _resolve_poster_for_program(rec.channel.name, program)
|
||||
# Fall back to the channel's own logo so the card always has an image
|
||||
# rather than relying on the frontend's async channelsById lookup.
|
||||
if not poster_logo_id and not poster_url and rec.channel.logo_id:
|
||||
poster_logo_id = rec.channel.logo_id
|
||||
updated = False
|
||||
if poster_logo_id and cp.get("poster_logo_id") != poster_logo_id:
|
||||
cp["poster_logo_id"] = poster_logo_id
|
||||
|
|
|
|||
294
apps/channels/tests/test_dvr_retry.py
Normal file
294
apps/channels/tests/test_dvr_retry.py
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
"""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", create=True)
|
||||
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("django.db.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("django.db.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("django.db.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("django.db.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("django.db.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("django.db.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("django.db.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("django.db.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):
|
||||
"""When a candidate base URL fails with a retriable error before data flows,
|
||||
the DVR task must retry the same base before moving to the next candidate."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=96, name="Connection Retry Channel"
|
||||
)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_retriable_error_increments_reconnect_counter(self, _ws):
|
||||
"""Verify the reconnection counter logic: same base retried before fallback."""
|
||||
# This tests the logic structure directly rather than running the full task.
|
||||
# The reconnection counter starts at 0, increments on each retriable error,
|
||||
# and the loop 'continue's (retries same base) until max is exceeded.
|
||||
max_reconnects = 5
|
||||
reconnects = 0
|
||||
attempts_on_base = 0
|
||||
|
||||
# Simulate the inner reconnection loop logic
|
||||
while True:
|
||||
attempts_on_base += 1
|
||||
# Simulate retriable error with no data
|
||||
reconnects += 1
|
||||
if reconnects <= max_reconnects:
|
||||
continue # Would retry same base
|
||||
break # Would move to next base
|
||||
|
||||
self.assertEqual(attempts_on_base, max_reconnects + 1)
|
||||
self.assertEqual(reconnects, max_reconnects + 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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("django.db.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)
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
deleteRecordingById,
|
||||
deleteSeriesAndRule,
|
||||
extendRecordingById,
|
||||
getChannelLogoUrl,
|
||||
getPosterUrl,
|
||||
getRecordingUrl,
|
||||
getSeasonLabel,
|
||||
|
|
@ -64,7 +65,7 @@ const RecordingCard = ({
|
|||
const posterUrl = getPosterUrl(
|
||||
customProps.poster_logo_id,
|
||||
customProps,
|
||||
channel?.logo?.cache_url,
|
||||
getChannelLogoUrl(channel),
|
||||
env_mode
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import useVideoStore from '../../store/useVideoStore.jsx';
|
|||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
deleteRecordingById,
|
||||
getChannelLogoUrl,
|
||||
getPosterUrl,
|
||||
getRecordingUrl,
|
||||
getSeasonLabel,
|
||||
|
|
@ -158,7 +159,7 @@ const RecordingDetailsModal = ({
|
|||
url: getPosterUrl(
|
||||
childRec.custom_properties?.poster_logo_id,
|
||||
undefined,
|
||||
ch?.logo?.cache_url
|
||||
getChannelLogoUrl(ch)
|
||||
),
|
||||
},
|
||||
});
|
||||
|
|
@ -325,7 +326,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}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx';
|
|||
import RecordingCard from '../components/cards/RecordingCard.jsx';
|
||||
import { categorizeRecordings } from '../utils/pages/DVRUtils.js';
|
||||
import {
|
||||
getChannelLogoUrl,
|
||||
getPosterUrl,
|
||||
getRecordingUrl,
|
||||
getShowVideoUrl,
|
||||
|
|
@ -158,7 +159,7 @@ const DVRPage = () => {
|
|||
url: getPosterUrl(
|
||||
detailsRecording.custom_properties?.poster_logo_id,
|
||||
undefined,
|
||||
channelsById[detailsRecording.channel]?.logo?.cache_url
|
||||
getChannelLogoUrl(channelsById[detailsRecording.channel])
|
||||
),
|
||||
},
|
||||
});
|
||||
|
|
@ -302,7 +303,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}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,16 @@ 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/`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue