fix(dvr): harden recording pipeline — idempotency guard, remux sanity checks, timezone fix

- Idempotency guard: fail closed on DB errors instead of proceeding, preventing duplicate tasks from overwriting valid recordings (#641)
- Remux sanity checks: reject MKV output that is <50% of a previous MKV (duplicate-task overwrite) or <10% of the source TS (corrupt first attempt); preserve .ts for manual recovery on all failure paths (#619)
- Recurring rule timezone fix: use user's configured timezone for date window calculation instead of UTC, fixing silent recording loss for users in UTC-negative timezones after 4pm local time (#1042)
- Include .ts path in final remux failure log for easier recovery
This commit is contained in:
None 2026-03-02 22:00:49 -06:00
parent 759688390b
commit bd14e48f8b

View file

@ -1404,14 +1404,15 @@ def sync_recurring_rule_impl(rule_id: int, drop_existing: bool = True, horizon_d
except Exception:
logger.warning("Invalid or unsupported time zone '%s'; falling back to Server default", tz_name)
tz = timezone.get_current_timezone()
start_limit = rule.start_date or now.date()
local_today = now.astimezone(tz).date()
start_limit = rule.start_date or local_today
end_limit = rule.end_date
horizon = now + timedelta(days=horizon_days)
start_window = max(start_limit, now.date())
start_window = max(start_limit, local_today)
if drop_existing and end_limit:
end_window = end_limit
else:
end_window = horizon.date()
end_window = horizon.astimezone(tz).date()
if end_limit and end_limit < end_window:
end_window = end_limit
if end_window < start_window:
@ -1633,6 +1634,8 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
from .models import Recording, Logo
# --- Idempotency guard (prevents duplicate recordings from task redelivery) ---
# Fail closed: if the DB is unreachable, abort rather than risk a duplicate
# task overwriting a valid recording.
try:
rec_check = Recording.objects.filter(id=recording_id).only("custom_properties").first()
if not rec_check:
@ -1648,7 +1651,11 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
)
return
except Exception as e:
logger.debug(f"Idempotency guard check failed (proceeding anyway): {e}")
logger.error(
f"Idempotency guard DB check failed for recording {recording_id} "
f"({type(e).__name__}: {e}) — aborting to prevent potential duplicate."
)
return
# --- Clean up the one-off PeriodicTask that dispatched this task ---
try:
@ -2114,7 +2121,7 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
recording_cancelled = not Recording.objects.filter(id=recording_id).exists()
if recording_cancelled:
logger.info(f"Recording {recording_id} was cancelled — skipping remux and metadata.")
# Clean up any remaining temp or stub output files
# Clean up all artifacts for the cancelled recording
for _cleanup_path in [temp_ts_path, final_path]:
if not _cleanup_path:
continue
@ -2129,6 +2136,12 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
# Remux TS to MKV container with retry for transient I/O errors
remux_success = False
existing_mkv_size = 0
try:
if final_path and os.path.exists(final_path):
existing_mkv_size = os.path.getsize(final_path)
except OSError:
pass
for _remux_attempt in range(_dvr_remux_max_retries):
try:
if temp_ts_path and os.path.exists(temp_ts_path):
@ -2197,6 +2210,43 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
else:
logger.error(f"TS→MP4 conversion failed (return code: {result_mp4.returncode})")
# Sanity-check the remuxed file. Two checks:
# 1. If a pre-existing MKV was overwritten, reject a
# file that is drastically smaller (duplicate-task
# overwrite protection).
# 2. If the MKV is smaller than the .ts source, the
# remux likely produced a corrupt or truncated file.
if remux_success:
try:
new_size = os.path.getsize(final_path)
ts_size = os.path.getsize(temp_ts_path) if temp_ts_path and os.path.exists(temp_ts_path) else 0
reject = False
if existing_mkv_size > 0 and new_size < existing_mkv_size * 0.5:
logger.error(
f"DVR recording {recording_id}: new MKV "
f"({new_size:,} bytes) is less than 50%% of "
f"the previous MKV ({existing_mkv_size:,} bytes) "
f"— refusing to overwrite. Keeping .ts for "
f"manual recovery."
)
reject = True
elif ts_size > 0 and new_size < ts_size * 0.1:
logger.error(
f"DVR recording {recording_id}: remuxed MKV "
f"({new_size:,} bytes) is less than 10%% of "
f"the source TS ({ts_size:,} bytes) — likely "
f"corrupt. Keeping .ts for manual recovery."
)
reject = True
if reject:
remux_success = False
try:
os.remove(final_path)
except OSError:
pass
except OSError:
pass
# Clean up temp TS file only on successful remux
if remux_success:
try:
@ -2234,7 +2284,8 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
else:
logger.warning(
f"DVR recording {recording_id}: remux failed "
f"after {_dvr_remux_max_retries} attempts: {e}"
f"after {_dvr_remux_max_retries} attempts: {e}. "
f"Keeping .ts for manual recovery: {temp_ts_path}"
)
# Persist final metadata to Recording (status, ended_at, and stream stats if available)