From 96db4f92c7557cc0b9aa883e88a9a257bfb6cfdb Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 6 Jul 2026 16:45:56 -0500 Subject: [PATCH] Enhancement: Implement live proxy failover for VLC stream profile, ensuring proper handling of upstream URL failures. Update changelog to reflect changes in stream management and VLC profile parameters. --- CHANGELOG.md | 1 + apps/proxy/live_proxy/input/manager.py | 8 +++- apps/proxy/live_proxy/services/log_parsers.py | 3 ++ .../live_proxy/tests/test_vlc_failover.py | 42 +++++++++++++++++++ .../migrations/0019_add_vlc_stream_profile.py | 2 +- core/migrations/0027_vlc_play_and_exit.py | 37 ++++++++++++++++ 6 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 apps/proxy/live_proxy/tests/test_vlc_failover.py create mode 100644 core/migrations/0027_vlc_play_and_exit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b8b737c1..6a3449a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`ChannelUtils.requeryChannels()` returns the API promise again** so bulk channel delete, drag reorder, and inline channel-number saves await the table refetch before clearing selection or continuing. — Thanks [@nick4810](https://github.com/nick4810) - **VOD connection card Content Duration badge** shows human-readable lengths (`45m`, `2h 0m`) again on the Stats page instead of bare minute integers. — Thanks [@nick4810](https://github.com/nick4810) - **Frontend unit tests pass on Node 25+ again.** Node 25+ exposes a native `localStorage` stub on `globalThis` that lacks Storage API methods (`getItem`, `clear`, etc.), which shadows jsdom's implementation and breaks tests that touch `localStorage` (e.g. `TypeError: localStorage.clear is not a function`). `setupTests.js` now installs an in-memory shim on `globalThis` and `window` when those methods are missing; the shim is skipped automatically once Node provides a working implementation. — Thanks [@nick4810](https://github.com/nick4810) +- **Live proxy failover works with the default VLC stream profile.** When `cvlc` could not open an upstream URL it often stayed running in dummy mode without writing TS data, so the stream manager never left its read loop and never exhausted retries to switch streams. The locked VLC profile now passes `--play-and-exit` (migration 0027 for existing installs), and `VLCLogParser` treats `unable to open the mrl` as a fatal input error so the connection is closed and the normal retry → failover cycle can proceed. ## [0.27.2] - 2026-06-30 diff --git a/apps/proxy/live_proxy/input/manager.py b/apps/proxy/live_proxy/input/manager.py index 8c76c1be..04e197fb 100644 --- a/apps/proxy/live_proxy/input/manager.py +++ b/apps/proxy/live_proxy/input/manager.py @@ -934,7 +934,13 @@ class StreamManager: parser = LogParserFactory._parsers.get(self.parser_type) if parser: stream_type = parser.can_parse(content) - if stream_type: + if stream_type == 'vlc_input_failed': + logger.warning( + f"VLC could not open input for channel {self.channel_id}: {content}" + ) + self.connected = False + self._close_socket() + elif stream_type: # Parser can handle this line, parse it directly parsed_data = LogParserFactory.parse(stream_type, content) if parsed_data: diff --git a/apps/proxy/live_proxy/services/log_parsers.py b/apps/proxy/live_proxy/services/log_parsers.py index 95ee7a06..38330c89 100644 --- a/apps/proxy/live_proxy/services/log_parsers.py +++ b/apps/proxy/live_proxy/services/log_parsers.py @@ -164,6 +164,9 @@ class VLCLogParser(BaseLogParser): """Check if this is a VLC line we can parse""" lower = line.lower() + if 'unable to open the mrl' in lower: + return 'vlc_input_failed' + # VLC TS demux codec detection if 'ts demux debug' in lower and 'type=' in lower: if 'video' in lower: diff --git a/apps/proxy/live_proxy/tests/test_vlc_failover.py b/apps/proxy/live_proxy/tests/test_vlc_failover.py new file mode 100644 index 00000000..3e234cb4 --- /dev/null +++ b/apps/proxy/live_proxy/tests/test_vlc_failover.py @@ -0,0 +1,42 @@ +"""Tests for VLC input-failure detection used during stream failover.""" +from unittest.mock import MagicMock, patch + +from django.test import TestCase + +from apps.proxy.live_proxy.input.manager import StreamManager +from apps.proxy.live_proxy.services.log_parsers import VLCLogParser + + +class VLCInputFailureParserTests(TestCase): + def test_detects_unable_to_open_mrl(self): + parser = VLCLogParser() + line = ( + "main input error: VLC is unable to open the MRL " + "'http://example.com/live/1/2/3.ts'. Check the log for details." + ) + self.assertEqual(parser.can_parse(line), "vlc_input_failed") + + def test_ignores_unrelated_vlc_lines(self): + parser = VLCLogParser() + line = "vlcpulse audio output error: PulseAudio server connection failure" + self.assertIsNone(parser.can_parse(line)) + + +class VLCInputFailureHandlingTests(TestCase): + def test_stderr_handler_closes_connection_on_input_failure(self): + sm = StreamManager.__new__(StreamManager) + sm.channel_id = "test-channel" + sm.parser_type = "vlc" + sm.ffmpeg_input_phase = True + sm.bytes_processed = 0 + sm.connected = True + sm.current_stream_id = 1 + + with patch.object(sm, "_close_socket") as close_socket: + sm._log_stderr_content( + "main input error: VLC is unable to open the MRL 'http://x'. " + "Check the log for details." + ) + + self.assertFalse(sm.connected) + close_socket.assert_called_once() diff --git a/core/migrations/0019_add_vlc_stream_profile.py b/core/migrations/0019_add_vlc_stream_profile.py index c3f72592..b8dc16ba 100644 --- a/core/migrations/0019_add_vlc_stream_profile.py +++ b/core/migrations/0019_add_vlc_stream_profile.py @@ -21,7 +21,7 @@ def add_vlc_profile(apps, schema_editor): StreamProfile.objects.create( name="VLC", command="cvlc", - parameters="-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}", + parameters="-vv -I dummy --no-video-title-show --play-and-exit --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}", is_active=True, user_agent=tivimate_ua, locked=True, # Make it read-only like ffmpeg/streamlink diff --git a/core/migrations/0027_vlc_play_and_exit.py b/core/migrations/0027_vlc_play_and_exit.py new file mode 100644 index 00000000..1c0fdac5 --- /dev/null +++ b/core/migrations/0027_vlc_play_and_exit.py @@ -0,0 +1,37 @@ +# Add --play-and-exit so cvlc exits when the input cannot be opened. + +from django.db import migrations + +_VLC_PARAMETERS = ( + "-vv -I dummy --no-video-title-show --play-and-exit " + "--http-user-agent {userAgent} {streamUrl} " + "--sout #standard{access=file,mux=ts,dst=-}" +) + + +def update_vlc_profile(apps, schema_editor): + StreamProfile = apps.get_model("core", "StreamProfile") + StreamProfile.objects.filter(name="VLC", locked=True).update( + parameters=_VLC_PARAMETERS + ) + + +def revert_vlc_profile(apps, schema_editor): + StreamProfile = apps.get_model("core", "StreamProfile") + StreamProfile.objects.filter(name="VLC", locked=True).update( + parameters=( + "-vv -I dummy --no-video-title-show --http-user-agent {userAgent} " + "{streamUrl} --sout #standard{access=file,mux=ts,dst=-}" + ) + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0026_add_channel_client_wait_period"), + ] + + operations = [ + migrations.RunPython(update_vlc_profile, revert_vlc_profile), + ]