From 0b83137ac6d22a20c6074ae1d1954317dad2383b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 19 Jan 2026 18:56:23 -0600 Subject: [PATCH] =?UTF-8?q?Enhancement:=20DVR=20recording=20remux=20fallba?= =?UTF-8?q?ck=20strategy:=20Implemented=20two-stage=20TS=E2=86=92MP4?= =?UTF-8?q?=E2=86=92MKV=20fallback=20when=20direct=20TS=E2=86=92MKV=20conv?= =?UTF-8?q?ersion=20fails=20due=20to=20timestamp=20issues.=20On=20remux=20?= =?UTF-8?q?failure,=20system=20now=20attempts=20TS=E2=86=92MP4=20conversio?= =?UTF-8?q?n=20(MP4=20container=20handles=20broken=20timestamps=20better)?= =?UTF-8?q?=20followed=20by=20MP4=E2=86=92MKV=20conversion,=20automaticall?= =?UTF-8?q?y=20recovering=20from=20provider=20timestamp=20corruption.=20Fa?= =?UTF-8?q?iled=20conversions=20now=20properly=20clean=20up=20partial=20fi?= =?UTF-8?q?les=20and=20preserve=20source=20TS=20for=20manual=20recovery.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + apps/channels/tasks.py | 91 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a868a2b..6f53a70c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Unassociated streams filter: Added "Only Unassociated" filter option to streams table for quickly finding streams not assigned to any channels - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) (Closes #667) - Client-side logo caching: Added `Cache-Control` and `Last-Modified` headers to logo responses, enabling browsers to cache logos locally for 4 hours (local files) and respecting upstream cache headers (remote logos). This reduces network traffic and nginx load while providing faster page loads through browser-level caching that complements the existing nginx server-side cache - Thanks [@DawtCom](https://github.com/DawtCom) +- DVR recording remux fallback strategy: Implemented two-stage TS→MP4→MKV fallback when direct TS→MKV conversion fails due to timestamp issues. On remux failure, system now attempts TS→MP4 conversion (MP4 container handles broken timestamps better) followed by MP4→MKV conversion, automatically recovering from provider timestamp corruption. Failed conversions now properly clean up partial files and preserve source TS for manual recovery. - Mature content filtering support: - Added `is_adult` boolean field to both Stream and Channel models with database indexing for efficient filtering and sorting - Automatically populated during M3U/XC refresh operations by extracting `is_adult` value from provider data diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index f37838fd..b9983b87 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -1874,18 +1874,97 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): remux_success = False try: if temp_ts_path and os.path.exists(temp_ts_path): - subprocess.run([ - "ffmpeg", "-y", "-i", temp_ts_path, "-c", "copy", final_path - ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - remux_success = os.path.exists(final_path) - # Clean up temp file on success + # 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([ + "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: + # 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: {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 # Persist final metadata to Recording (status, ended_at, and stream stats if available) try: