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.

This commit is contained in:
SergeantPanda 2026-07-06 16:45:56 -05:00
parent 9c3ace6146
commit 96db4f92c7
6 changed files with 91 additions and 2 deletions

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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()

View file

@ -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

View file

@ -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),
]