From 391c3412d2a750bf70bf668dec5e2f01f451c789 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 28 Jun 2026 11:31:21 -0500 Subject: [PATCH 1/5] refactor(proxy): Add new client connect grace period setting and update channel initialization grace period defaults. The `channel_client_wait_period` is introduced to manage channel lifetimes before client connections, while the `channel_init_grace_period` default is increased to improve failover handling. --- CHANGELOG.md | 12 ++ apps/channels/tests/test_ts_proxy_teardown.py | 59 ++++++++ apps/proxy/config.py | 16 +- apps/proxy/live_proxy/config_helper.py | 5 + apps/proxy/live_proxy/server.py | 54 ++++--- .../live_proxy/tests/test_proxy_settings.py | 143 ++++++++++++++++++ core/api_views.py | 3 +- .../0026_add_channel_client_wait_period.py | 51 +++++++ core/models.py | 3 +- core/serializers.py | 14 +- .../forms/settings/ProxySettingsForm.jsx | 9 +- frontend/src/constants.js | 7 +- .../forms/settings/ProxySettingsFormUtils.js | 3 +- .../__tests__/ProxySettingsFormUtils.test.js | 4 +- 14 files changed, 349 insertions(+), 34 deletions(-) create mode 100644 apps/proxy/live_proxy/tests/test_proxy_settings.py create mode 100644 core/migrations/0026_add_channel_client_wait_period.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ea23e1dd..03a9bcaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **New proxy setting: Client Connect Grace Period (`channel_client_wait_period`, default 5s).** Restores the grace-period behaviour that was unintentionally dropped in 0.27.1. When a channel's buffer becomes ready but no client has connected yet (e.g. after an API warmup), this controls how long to keep the channel alive before tearing down. + +### Changed + +- **Proxy grace-period settings are now split into three distinct timeouts.** In 0.27.1, `channel_init_grace_period` was repurposed as the channel startup/failover timeout (replacing a hardcoded 10s). That left API-warmup "wait for the first client" behaviour tied to `channel_shutdown_delay` (default 0s), so warmed-up channels stopped almost immediately. Behaviour is now: + - **`channel_init_grace_period` (default 60s, max 300s):** how long the proxy may spend connecting and cycling failover streams before giving up on startup. + - **`channel_client_wait_period` (default 5s):** how long a ready channel with no viewers stays up waiting for the first client (the original grace-period use case). + - **`channel_shutdown_delay` (default 0s):** delay after the last client disconnects only; no longer applies to warmup. +- **Migration 0026 bumps `channel_init_grace_period` to 60s when the stored value is below 60.** Existing installs on the old 5s default (or any custom value under 60) are raised automatically. Values already at 60s or higher are unchanged. If you previously raised `channel_shutdown_delay` to keep warmed-up channels alive, set `channel_client_wait_period` instead. + ## [0.27.1] - 2026-06-25 ### Security diff --git a/apps/channels/tests/test_ts_proxy_teardown.py b/apps/channels/tests/test_ts_proxy_teardown.py index ef78e576..b76918ac 100644 --- a/apps/channels/tests/test_ts_proxy_teardown.py +++ b/apps/channels/tests/test_ts_proxy_teardown.py @@ -923,3 +923,62 @@ class StreamManagerStillOwnerTests(TestCase): return_value=5, ): self.assertTrue(manager._still_owner()) + + +class PreActiveNoClientsTimeoutTests(TestCase): + @patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5) + def test_buffer_ready_uses_client_wait_period(self, _mock_client_wait): + should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop( + connection_ready_time=1000.0, + start_time=900.0, + now=1006.0, + ) + self.assertTrue(should_stop) + self.assertEqual(timeout, 5) + self.assertEqual(reason, "client_wait") + + @patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5) + def test_buffer_ready_within_client_wait_period(self, _mock_client_wait): + should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop( + connection_ready_time=1000.0, + start_time=900.0, + now=1003.0, + ) + self.assertFalse(should_stop) + self.assertEqual(timeout, 5) + self.assertEqual(reason, "client_wait") + + @patch("apps.proxy.live_proxy.server.ConfigHelper.channel_init_grace_period", return_value=60) + def test_startup_uses_init_grace_period(self, _mock_init_grace): + should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop( + connection_ready_time=None, + start_time=1000.0, + now=1070.0, + ) + self.assertTrue(should_stop) + self.assertEqual(timeout, 60) + self.assertEqual(reason, "startup") + + @patch("apps.proxy.live_proxy.server.ConfigHelper.channel_init_grace_period", return_value=60) + def test_startup_within_init_grace_period(self, _mock_init_grace): + should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop( + connection_ready_time=None, + start_time=1000.0, + now=1030.0, + ) + self.assertFalse(should_stop) + self.assertEqual(timeout, 60) + self.assertEqual(reason, "startup") + + @patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30) + @patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5) + def test_buffer_ready_does_not_use_shutdown_delay(self, mock_client_wait, mock_shutdown_delay): + should_stop, _, reason = ProxyServer._pre_active_no_clients_should_stop( + connection_ready_time=1000.0, + start_time=900.0, + now=1006.0, + ) + self.assertTrue(should_stop) + self.assertEqual(reason, "client_wait") + mock_client_wait.assert_called_once() + mock_shutdown_delay.assert_not_called() diff --git a/apps/proxy/config.py b/apps/proxy/config.py index c7ba5628..48d9fc9b 100644 --- a/apps/proxy/config.py +++ b/apps/proxy/config.py @@ -42,7 +42,8 @@ class BaseConfig: "buffering_speed": 1.0, "redis_chunk_ttl": 60, "channel_shutdown_delay": 0, - "channel_init_grace_period": 5, + "channel_init_grace_period": 60, + "channel_client_wait_period": 5, "new_client_behind_seconds": 5, } @@ -135,7 +136,13 @@ class TSConfig(BaseConfig): def get_channel_init_grace_period(cls): """Max seconds to wait for initial buffer fill during channel startup.""" settings = cls.get_proxy_settings() - return settings.get("channel_init_grace_period", 5) + return settings.get("channel_init_grace_period", 60) + + @classmethod + def get_channel_client_wait_period(cls): + """Seconds to keep a ready channel alive waiting for the first client to connect.""" + settings = cls.get_proxy_settings() + return settings.get("channel_client_wait_period", 5) # Dynamic property access for these settings @property @@ -154,5 +161,6 @@ class TSConfig(BaseConfig): def CHANNEL_INIT_GRACE_PERIOD(self): return self.get_channel_init_grace_period() - - + @property + def CHANNEL_CLIENT_WAIT_PERIOD(self): + return self.get_channel_client_wait_period() diff --git a/apps/proxy/live_proxy/config_helper.py b/apps/proxy/live_proxy/config_helper.py index d151a8a8..846b0153 100644 --- a/apps/proxy/live_proxy/config_helper.py +++ b/apps/proxy/live_proxy/config_helper.py @@ -110,6 +110,11 @@ class ConfigHelper: """Max seconds to wait for initial buffer fill during channel startup.""" return Config.get_channel_init_grace_period() + @staticmethod + def channel_client_wait_period(): + """Seconds to keep a ready channel alive waiting for the first client to connect.""" + return Config.get_channel_client_wait_period() + @staticmethod def chunk_timeout(): """ diff --git a/apps/proxy/live_proxy/server.py b/apps/proxy/live_proxy/server.py index fd54fc24..97c5b68b 100644 --- a/apps/proxy/live_proxy/server.py +++ b/apps/proxy/live_proxy/server.py @@ -910,6 +910,26 @@ class ProxyServer: delay = ConfigHelper.channel_shutdown_delay() return max(int(delay * 2), 60) + @staticmethod + def _pre_active_no_clients_should_stop(connection_ready_time, start_time, now=None): + """ + Decide whether a pre-active channel with zero clients should be stopped. + + Returns (should_stop, timeout_seconds, reason) where reason is + 'client_wait' (buffer ready, waiting for first viewer) or 'startup' + (still connecting / filling buffer). + """ + now = now if now is not None else time.time() + if connection_ready_time: + elapsed = now - connection_ready_time + timeout = ConfigHelper.channel_client_wait_period() + return elapsed > timeout, timeout, "client_wait" + if start_time: + elapsed = now - start_time + timeout = ConfigHelper.channel_init_grace_period() + return elapsed > timeout, timeout, "startup" + return False, None, None + def _wait_for_shutdown_delay(self, channel_id): """ Wait until shutdown_delay has elapsed since the Redis disconnect @@ -1799,31 +1819,27 @@ class ProxyServer: start_time = connection_attempt_time or init_time if start_time: - # Check which timeout to apply based on channel lifecycle - if connection_ready_time: - # Already reached ready - use shutdown_delay - time_since_ready = time.time() - connection_ready_time - shutdown_delay = ConfigHelper.channel_shutdown_delay() - - if time_since_ready > shutdown_delay: + should_stop, timeout, reason = ( + self._pre_active_no_clients_should_stop( + connection_ready_time, + start_time, + ) + ) + if should_stop: + if reason == "client_wait": + time_since_ready = time.time() - connection_ready_time logger.warning( f"Channel {channel_id} in {channel_state} state with 0 clients for {time_since_ready:.1f}s " - f"(after reaching ready, shutdown_delay: {shutdown_delay}s) - stopping channel" + f"(buffer ready, no client connected, client_wait_period: {timeout}s) - stopping channel" ) - self._coordinated_stop_channel(channel_id) - continue - else: - # Never reached ready - use grace_period timeout - time_since_start = time.time() - start_time - connecting_timeout = ConfigHelper.channel_init_grace_period() - - if time_since_start > connecting_timeout: + else: + time_since_start = time.time() - start_time logger.warning( f"Channel {channel_id} stuck in {channel_state} state for {time_since_start:.1f}s " - f"with no clients (timeout: {connecting_timeout}s) - stopping channel due to upstream issues" + f"with no clients (timeout: {timeout}s) - stopping channel due to upstream issues" ) - self._coordinated_stop_channel(channel_id) - continue + self._coordinated_stop_channel(channel_id) + continue elif ( channel_state == ChannelState.WAITING_FOR_CLIENTS and total_clients > 0 diff --git a/apps/proxy/live_proxy/tests/test_proxy_settings.py b/apps/proxy/live_proxy/tests/test_proxy_settings.py new file mode 100644 index 00000000..e821f1cb --- /dev/null +++ b/apps/proxy/live_proxy/tests/test_proxy_settings.py @@ -0,0 +1,143 @@ +"""Tests for proxy settings defaults, serializer validation, and migration 0026.""" + +from importlib import import_module +from unittest.mock import patch + +from django.apps import apps +from django.test import SimpleTestCase, TestCase + +from apps.proxy.config import TSConfig +from core.models import CoreSettings +from core.serializers import ProxySettingsSerializer + +MIGRATION_0026 = import_module("core.migrations.0026_add_channel_client_wait_period") + + +class TSConfigProxySettingsDefaultsTests(SimpleTestCase): + @patch.object(TSConfig, "get_proxy_settings", return_value={}) + def test_channel_init_grace_period_default(self, _mock_settings): + self.assertEqual(TSConfig.get_channel_init_grace_period(), 60) + + @patch.object(TSConfig, "get_proxy_settings", return_value={}) + def test_channel_client_wait_period_default(self, _mock_settings): + self.assertEqual(TSConfig.get_channel_client_wait_period(), 5) + + @patch.object( + TSConfig, + "get_proxy_settings", + return_value={ + "channel_init_grace_period": 120, + "channel_client_wait_period": 15, + }, + ) + def test_settings_override_db_values(self, _mock_settings): + self.assertEqual(TSConfig.get_channel_init_grace_period(), 120) + self.assertEqual(TSConfig.get_channel_client_wait_period(), 15) + + +class ProxySettingsSerializerTests(SimpleTestCase): + def _valid_payload(self, **overrides): + payload = { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 60, + "channel_client_wait_period": 5, + "new_client_behind_seconds": 5, + } + payload.update(overrides) + return payload + + def test_accepts_new_client_wait_period(self): + serializer = ProxySettingsSerializer(data=self._valid_payload()) + self.assertTrue(serializer.is_valid(), serializer.errors) + self.assertEqual(serializer.validated_data["channel_client_wait_period"], 5) + + def test_init_grace_period_allows_up_to_300(self): + serializer = ProxySettingsSerializer( + data=self._valid_payload(channel_init_grace_period=300) + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_init_grace_period_rejects_above_300(self): + serializer = ProxySettingsSerializer( + data=self._valid_payload(channel_init_grace_period=301) + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("channel_init_grace_period", serializer.errors) + + +class CoreSettingsProxyDefaultsTests(TestCase): + def test_get_proxy_settings_defaults_when_missing(self): + CoreSettings.objects.filter(key="proxy_settings").delete() + defaults = CoreSettings.get_proxy_settings() + self.assertEqual(defaults["channel_init_grace_period"], 60) + self.assertEqual(defaults["channel_client_wait_period"], 5) + + +class Migration0026ProxySettingsTests(TestCase): + def _run_migration_forward(self): + MIGRATION_0026.add_channel_client_wait_period(apps, None) + + def _set_proxy_settings(self, value): + settings_obj, _ = CoreSettings.objects.get_or_create( + key="proxy_settings", + defaults={"name": "Proxy Settings", "value": value}, + ) + settings_obj.value = value + settings_obj.save(update_fields=["value"]) + return settings_obj + + def test_bumps_legacy_init_grace_and_adds_client_wait(self): + settings_obj = self._set_proxy_settings( + { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 5, + "new_client_behind_seconds": 5, + } + ) + + self._run_migration_forward() + settings_obj.refresh_from_db() + + self.assertEqual(settings_obj.value["channel_init_grace_period"], 60) + self.assertEqual(settings_obj.value["channel_client_wait_period"], 5) + + def test_bumps_init_grace_below_new_default(self): + settings_obj = self._set_proxy_settings( + { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 45, + "new_client_behind_seconds": 5, + } + ) + + self._run_migration_forward() + settings_obj.refresh_from_db() + + self.assertEqual(settings_obj.value["channel_init_grace_period"], 60) + + def test_preserves_init_grace_at_or_above_new_default(self): + settings_obj = self._set_proxy_settings( + { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 90, + "new_client_behind_seconds": 5, + } + ) + + self._run_migration_forward() + settings_obj.refresh_from_db() + + self.assertEqual(settings_obj.value["channel_init_grace_period"], 90) + self.assertEqual(settings_obj.value["channel_client_wait_period"], 5) diff --git a/core/api_views.py b/core/api_views.py index ec66d69a..ee7d1db1 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -225,7 +225,8 @@ class ProxySettingsViewSet(viewsets.ViewSet): "buffering_speed": 1.0, "redis_chunk_ttl": 60, "channel_shutdown_delay": 0, - "channel_init_grace_period": 5, + "channel_init_grace_period": 60, + "channel_client_wait_period": 5, "new_client_behind_seconds": 5, } settings_obj, created = CoreSettings.objects.get_or_create( diff --git a/core/migrations/0026_add_channel_client_wait_period.py b/core/migrations/0026_add_channel_client_wait_period.py new file mode 100644 index 00000000..6119eb66 --- /dev/null +++ b/core/migrations/0026_add_channel_client_wait_period.py @@ -0,0 +1,51 @@ +from django.db import migrations + +PROXY_SETTINGS_KEY = "proxy_settings" + + +def add_channel_client_wait_period(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + + try: + obj = CoreSettings.objects.get(key=PROXY_SETTINGS_KEY) + except CoreSettings.DoesNotExist: + return + + value = obj.value if isinstance(obj.value, dict) else {} + + # Add the new client-connect grace period default. + value.setdefault("channel_client_wait_period", 5) + + # channel_init_grace_period was repurposed in 0.27.1 as the channel startup + # timeout (replacing a hardcoded 10s). Values below the new 60s default are + # too short when a channel has many failover streams to cycle through. + current_init = value.get("channel_init_grace_period", 5) + if current_init < 60: + value["channel_init_grace_period"] = 60 + + obj.value = value + obj.save() + + +def remove_channel_client_wait_period(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + + try: + obj = CoreSettings.objects.get(key=PROXY_SETTINGS_KEY) + except CoreSettings.DoesNotExist: + return + + value = obj.value if isinstance(obj.value, dict) else {} + value.pop("channel_client_wait_period", None) + obj.value = value + obj.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0025_move_preferred_region_and_auto_import_to_system_settings"), + ] + + operations = [ + migrations.RunPython(add_channel_client_wait_period, remove_channel_client_wait_period), + ] diff --git a/core/models.py b/core/models.py index 60be2f0e..ad9677c6 100644 --- a/core/models.py +++ b/core/models.py @@ -394,7 +394,8 @@ class CoreSettings(models.Model): "buffering_speed": 1.0, "redis_chunk_ttl": 60, "channel_shutdown_delay": 0, - "channel_init_grace_period": 5, + "channel_init_grace_period": 60, + "channel_client_wait_period": 5, "new_client_behind_seconds": 5, }) diff --git a/core/serializers.py b/core/serializers.py index a0f27baf..1398b194 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -96,7 +96,8 @@ class ProxySettingsSerializer(serializers.Serializer): buffering_speed = serializers.FloatField(min_value=0.1, max_value=10.0) redis_chunk_ttl = serializers.IntegerField(min_value=10, max_value=3600) channel_shutdown_delay = serializers.IntegerField(min_value=0, max_value=300) - channel_init_grace_period = serializers.IntegerField(min_value=0, max_value=60) + channel_init_grace_period = serializers.IntegerField(min_value=0, max_value=300) + channel_client_wait_period = serializers.IntegerField(min_value=0, max_value=300, required=False, default=5) new_client_behind_seconds = serializers.IntegerField(min_value=0, max_value=120, required=False, default=5) def validate_buffering_timeout(self, value): @@ -120,9 +121,16 @@ class ProxySettingsSerializer(serializers.Serializer): return value def validate_channel_init_grace_period(self, value): - if value < 0 or value > 60: + if value < 0 or value > 300: raise serializers.ValidationError( - "Channel initialization timeout must be between 0 and 60 seconds" + "Channel initialization timeout must be between 0 and 300 seconds" + ) + return value + + def validate_channel_client_wait_period(self, value): + if value < 0 or value > 300: + raise serializers.ValidationError( + "Client connect grace period must be between 0 and 300 seconds" ) return value diff --git a/frontend/src/components/forms/settings/ProxySettingsForm.jsx b/frontend/src/components/forms/settings/ProxySettingsForm.jsx index 52440769..a3a9600a 100644 --- a/frontend/src/components/forms/settings/ProxySettingsForm.jsx +++ b/frontend/src/components/forms/settings/ProxySettingsForm.jsx @@ -24,6 +24,7 @@ const ProxySettingsOptions = React.memo(({ proxySettingsForm }) => { 'redis_chunk_ttl', 'channel_shutdown_delay', 'channel_init_grace_period', + 'channel_client_wait_period', 'new_client_behind_seconds', ].includes(key); }; @@ -37,9 +38,11 @@ const ProxySettingsOptions = React.memo(({ proxySettingsForm }) => { ? 3600 : key === 'channel_shutdown_delay' ? 300 - : key === 'new_client_behind_seconds' - ? 120 - : 60; + : key === 'channel_client_wait_period' + ? 300 + : key === 'new_client_behind_seconds' + ? 120 + : 300; }; return ( <> diff --git a/frontend/src/constants.js b/frontend/src/constants.js index b130553e..fba0d7c6 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -49,13 +49,18 @@ export const PROXY_SETTINGS_OPTIONS = { channel_shutdown_delay: { label: 'Channel Shutdown Delay', description: - 'Delay in seconds before shutting down a channel after last client disconnects', + 'Delay in seconds before shutting down a channel after the last client disconnects', }, channel_init_grace_period: { label: 'Channel Initialization Timeout', description: 'Maximum seconds to wait for the initial buffer to fill while a channel is connecting. Channels that never receive enough buffered data are stopped after this limit.', }, + channel_client_wait_period: { + label: 'Client Connect Grace Period', + description: + 'Seconds to keep a ready channel alive when no client has connected yet (e.g. after an API warmup). Once a client connects the channel becomes active; if none connect within this window the channel stops.', + }, new_client_behind_seconds: { label: 'New Client Buffer (seconds)', description: diff --git a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js index eddbba91..ccbabdeb 100644 --- a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js +++ b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js @@ -13,7 +13,8 @@ export const getProxySettingDefaults = () => { buffering_speed: 1.0, redis_chunk_ttl: 60, channel_shutdown_delay: 0, - channel_init_grace_period: 5, + channel_init_grace_period: 60, + channel_client_wait_period: 5, new_client_behind_seconds: 5, }; }; diff --git a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js index 21c48269..3c17c42e 100644 --- a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js @@ -60,7 +60,8 @@ describe('ProxySettingsFormUtils', () => { buffering_speed: 1.0, redis_chunk_ttl: 60, channel_shutdown_delay: 0, - channel_init_grace_period: 5, + channel_init_grace_period: 60, + channel_client_wait_period: 5, new_client_behind_seconds: 5, }); }); @@ -81,6 +82,7 @@ describe('ProxySettingsFormUtils', () => { expect(typeof result.redis_chunk_ttl).toBe('number'); expect(typeof result.channel_shutdown_delay).toBe('number'); expect(typeof result.channel_init_grace_period).toBe('number'); + expect(typeof result.channel_client_wait_period).toBe('number'); expect(typeof result.new_client_behind_seconds).toBe('number'); }); }); From ac6d43af8ccf2b145fe814697d82966b14e55106 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 30 Jun 2026 10:00:22 -0500 Subject: [PATCH 2/5] refactor(proxysettings): Enhance proxy settings UI by categorizing advanced options and improving documentation. The `channel_client_wait_period` and `channel_init_grace_period` are now marked as advanced settings, and the UI has been updated to allow users to expand and view these options. Additionally, the changelog has been updated to reflect these changes and clarify the behavior of grace periods. --- CHANGELOG.md | 9 +- .../forms/settings/ProxySettingsForm.jsx | 162 +++++++++++------- .../__tests__/ProxySettingsForm.test.jsx | 60 ++++++- frontend/src/constants.js | 5 +- 4 files changed, 167 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a9bcaf..309ed5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **New proxy setting: Client Connect Grace Period (`channel_client_wait_period`, default 5s).** Restores the grace-period behaviour that was unintentionally dropped in 0.27.1. When a channel's buffer becomes ready but no client has connected yet (e.g. after an API warmup), this controls how long to keep the channel alive before tearing down. +- **New proxy setting: Client Connect Grace Period (`channel_client_wait_period`, default 5s).** Adds a dedicated timeout for channels that have filled their buffer but still have no viewers (`waiting_for_clients`). Previously that window reused `channel_shutdown_delay` (default 0s), so those channels were torn down almost immediately. ### Changed -- **Proxy grace-period settings are now split into three distinct timeouts.** In 0.27.1, `channel_init_grace_period` was repurposed as the channel startup/failover timeout (replacing a hardcoded 10s). That left API-warmup "wait for the first client" behaviour tied to `channel_shutdown_delay` (default 0s), so warmed-up channels stopped almost immediately. Behaviour is now: +- **Proxy grace-period settings are now split into three distinct timeouts.** The cleanup watchdog already applied `channel_init_grace_period` while a channel was still connecting (buffer not ready) and reused `channel_shutdown_delay` once `connection_ready_time` was set, including for `waiting_for_clients` with zero viewers. With the default `channel_shutdown_delay` of 0s, a buffered channel waiting for its first viewer was stopped almost immediately; raising shutdown delay was the only workaround, but that also delayed teardown after real disconnects. Behaviour is now: - **`channel_init_grace_period` (default 60s, max 300s):** how long the proxy may spend connecting and cycling failover streams before giving up on startup. - **`channel_client_wait_period` (default 5s):** how long a ready channel with no viewers stays up waiting for the first client (the original grace-period use case). - - **`channel_shutdown_delay` (default 0s):** delay after the last client disconnects only; no longer applies to warmup. -- **Migration 0026 bumps `channel_init_grace_period` to 60s when the stored value is below 60.** Existing installs on the old 5s default (or any custom value under 60) are raised automatically. Values already at 60s or higher are unchanged. If you previously raised `channel_shutdown_delay` to keep warmed-up channels alive, set `channel_client_wait_period` instead. + - **`channel_shutdown_delay` (default 0s):** delay after the last client disconnects only; no longer applies when the buffer is ready but no viewer has connected yet. +- **Migration 0026 bumps `channel_init_grace_period` to 60s when the stored value is below 60.** Existing installs on the old 5s default (or any custom value under 60) are raised automatically. Values already at 60s or higher are unchanged. If you previously raised `channel_shutdown_delay` to keep buffered channels alive with no viewers, set `channel_client_wait_period` instead (Settings → Proxy → Advanced). +- **Proxy settings UI: less-used options moved under Advanced.** Settings → Proxy now shows the day-to-day tuning fields by default (`buffering_timeout`, `buffering_speed`, `channel_shutdown_delay`, `new_client_behind_seconds`). **Buffer Chunk TTL**, **Channel Initialization Timeout**, and **Client Connect Grace Period** are tucked under **Show Advanced Settings**. ## [0.27.1] - 2026-06-25 diff --git a/frontend/src/components/forms/settings/ProxySettingsForm.jsx b/frontend/src/components/forms/settings/ProxySettingsForm.jsx index a3a9600a..91b36bb7 100644 --- a/frontend/src/components/forms/settings/ProxySettingsForm.jsx +++ b/frontend/src/components/forms/settings/ProxySettingsForm.jsx @@ -5,83 +5,119 @@ import { updateSetting } from '../../../utils/pages/SettingsUtils.js'; import { Alert, Button, + Collapse, Flex, NumberInput, Stack, TextInput, } from '@mantine/core'; +import { ChevronDown, ChevronRight } from 'lucide-react'; import { PROXY_SETTINGS_OPTIONS } from '../../../constants.js'; import { getProxySettingDefaults, getProxySettingsFormInitialValues, } from '../../../utils/forms/settings/ProxySettingsFormUtils.js'; +const isNumericField = (key) => { + return [ + 'buffering_timeout', + 'redis_chunk_ttl', + 'channel_shutdown_delay', + 'channel_init_grace_period', + 'channel_client_wait_period', + 'new_client_behind_seconds', + ].includes(key); +}; + +const isFloatField = (key) => key === 'buffering_speed'; + +const getNumericFieldMax = (key) => { + if (key === 'buffering_timeout') return 300; + if (key === 'redis_chunk_ttl') return 3600; + if (key === 'channel_shutdown_delay') return 300; + if (key === 'channel_client_wait_period') return 300; + if (key === 'new_client_behind_seconds') return 120; + return 300; +}; + +const renderProxySettingField = (key, config, proxySettingsForm) => { + if (isNumericField(key)) { + return ( + + ); + } + + if (isFloatField(key)) { + return ( + + ); + } + + return ( + + ); +}; + const ProxySettingsOptions = React.memo(({ proxySettingsForm }) => { - const isNumericField = (key) => { - // Determine if this field should be a NumberInput - return [ - 'buffering_timeout', - 'redis_chunk_ttl', - 'channel_shutdown_delay', - 'channel_init_grace_period', - 'channel_client_wait_period', - 'new_client_behind_seconds', - ].includes(key); - }; - const isFloatField = (key) => { - return key === 'buffering_speed'; - }; - const getNumericFieldMax = (key) => { - return key === 'buffering_timeout' - ? 300 - : key === 'redis_chunk_ttl' - ? 3600 - : key === 'channel_shutdown_delay' - ? 300 - : key === 'channel_client_wait_period' - ? 300 - : key === 'new_client_behind_seconds' - ? 120 - : 300; - }; + const [advancedOpen, setAdvancedOpen] = useState(false); + const entries = Object.entries(PROXY_SETTINGS_OPTIONS); + const mainEntries = entries.filter(([, config]) => !config.advanced); + const advancedEntries = entries.filter(([, config]) => config.advanced); + return ( <> - {Object.entries(PROXY_SETTINGS_OPTIONS).map(([key, config]) => { - if (isNumericField(key)) { - return ( - - ); - } else if (isFloatField(key)) { - return ( - - ); - } else { - return ( - - ); - } - })} + {mainEntries.map(([key, config]) => + renderProxySettingField(key, config, proxySettingsForm) + )} + + {advancedEntries.length > 0 && ( + <> + + + + {advancedEntries.map(([key, config]) => + renderProxySettingField(key, config, proxySettingsForm) + )} + + + + )} ); }); diff --git a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx index c874b3e6..b0fa3a56 100644 --- a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx @@ -14,6 +14,21 @@ vi.mock('../../../../constants.js', () => ({ description: 'Speed multiplier', }, redis_url: { label: 'Redis URL', description: 'Redis connection URL' }, + redis_chunk_ttl: { + label: 'Buffer Chunk TTL', + advanced: true, + description: 'Chunk TTL', + }, + channel_init_grace_period: { + label: 'Channel Initialization Timeout', + advanced: true, + description: 'Init timeout', + }, + channel_client_wait_period: { + label: 'Client Connect Grace Period', + advanced: true, + description: 'Advanced grace period', + }, }, })); @@ -71,6 +86,8 @@ vi.mock('@mantine/core', () => ({ ), Stack: ({ children }) =>
{children}
, + Collapse: ({ in: isOpen, children }) => + isOpen ?
{children}
: null, TextInput: ({ label, description, ...rest }) => (
@@ -353,11 +370,52 @@ describe('ProxySettingsForm', () => { // ── ProxySettingsOptions field routing ───────────────────────────────────── describe('ProxySettingsOptions field routing', () => { - it('calls getInputProps for each PROXY_SETTINGS_OPTIONS key', () => { + it('calls getInputProps for main settings on initial render', () => { render(); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_timeout'); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_speed'); expect(formMock.getInputProps).toHaveBeenCalledWith('redis_url'); + expect(formMock.getInputProps).not.toHaveBeenCalledWith( + 'channel_client_wait_period' + ); + expect(formMock.getInputProps).not.toHaveBeenCalledWith( + 'channel_init_grace_period' + ); + expect(formMock.getInputProps).not.toHaveBeenCalledWith('redis_chunk_ttl'); + }); + + it('hides advanced settings until expanded', () => { + render(); + expect( + screen.queryByTestId('number-input-Client Connect Grace Period') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('number-input-Channel Initialization Timeout') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('number-input-Buffer Chunk TTL') + ).not.toBeInTheDocument(); + expect(screen.getByText('Show Advanced Settings')).toBeInTheDocument(); + }); + + it('shows advanced settings when expanded', () => { + render(); + fireEvent.click(screen.getByText('Show Advanced Settings')); + expect(screen.getByTestId('collapse-open')).toBeInTheDocument(); + expect( + screen.getByTestId('number-input-Client Connect Grace Period') + ).toBeInTheDocument(); + expect( + screen.getByTestId('number-input-Channel Initialization Timeout') + ).toBeInTheDocument(); + expect(screen.getByTestId('number-input-Buffer Chunk TTL')).toBeInTheDocument(); + expect(formMock.getInputProps).toHaveBeenCalledWith( + 'channel_client_wait_period' + ); + expect(formMock.getInputProps).toHaveBeenCalledWith( + 'channel_init_grace_period' + ); + expect(formMock.getInputProps).toHaveBeenCalledWith('redis_chunk_ttl'); }); }); }); diff --git a/frontend/src/constants.js b/frontend/src/constants.js index fba0d7c6..ea6dec13 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -43,6 +43,7 @@ export const PROXY_SETTINGS_OPTIONS = { }, redis_chunk_ttl: { label: 'Buffer Chunk TTL', + advanced: true, description: 'Time-to-live for buffer chunks in seconds (how long stream data is cached)', }, @@ -53,13 +54,15 @@ export const PROXY_SETTINGS_OPTIONS = { }, channel_init_grace_period: { label: 'Channel Initialization Timeout', + advanced: true, description: 'Maximum seconds to wait for the initial buffer to fill while a channel is connecting. Channels that never receive enough buffered data are stopped after this limit.', }, channel_client_wait_period: { label: 'Client Connect Grace Period', + advanced: true, description: - 'Seconds to keep a ready channel alive when no client has connected yet (e.g. after an API warmup). Once a client connects the channel becomes active; if none connect within this window the channel stops.', + 'Seconds to keep a buffered channel alive when no viewer is connected yet. Rarely needed unless you start channels programmatically.', }, new_client_behind_seconds: { label: 'New Client Buffer (seconds)', From 97515c4d05acc8bc438f05b49057027d09e0ad1a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 30 Jun 2026 10:34:40 -0500 Subject: [PATCH 3/5] tests: fix proxy setting test --- .../__tests__/ProxySettingsForm.test.jsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx index b0fa3a56..2b0dc55f 100644 --- a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx @@ -370,18 +370,11 @@ describe('ProxySettingsForm', () => { // ── ProxySettingsOptions field routing ───────────────────────────────────── describe('ProxySettingsOptions field routing', () => { - it('calls getInputProps for main settings on initial render', () => { + it('binds main settings on initial render', () => { render(); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_timeout'); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_speed'); expect(formMock.getInputProps).toHaveBeenCalledWith('redis_url'); - expect(formMock.getInputProps).not.toHaveBeenCalledWith( - 'channel_client_wait_period' - ); - expect(formMock.getInputProps).not.toHaveBeenCalledWith( - 'channel_init_grace_period' - ); - expect(formMock.getInputProps).not.toHaveBeenCalledWith('redis_chunk_ttl'); }); it('hides advanced settings until expanded', () => { @@ -409,13 +402,6 @@ describe('ProxySettingsForm', () => { screen.getByTestId('number-input-Channel Initialization Timeout') ).toBeInTheDocument(); expect(screen.getByTestId('number-input-Buffer Chunk TTL')).toBeInTheDocument(); - expect(formMock.getInputProps).toHaveBeenCalledWith( - 'channel_client_wait_period' - ); - expect(formMock.getInputProps).toHaveBeenCalledWith( - 'channel_init_grace_period' - ); - expect(formMock.getInputProps).toHaveBeenCalledWith('redis_chunk_ttl'); }); }); }); From 22b957aee4934cc7546b57c836d96be1be3c10c4 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 30 Jun 2026 16:28:27 +0000 Subject: [PATCH 4/5] Release v0.27.2 --- CHANGELOG.md | 2 ++ version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 309ed5c1..a97f5158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.27.2] - 2026-06-30 + ### Added - **New proxy setting: Client Connect Grace Period (`channel_client_wait_period`, default 5s).** Adds a dedicated timeout for channels that have filled their buffer but still have no viewers (`waiting_for_clients`). Previously that window reused `channel_shutdown_delay` (default 0s), so those channels were torn down almost immediately. diff --git a/version.py b/version.py index db62d71f..25ccd35c 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.27.1' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.27.2' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From a347e50e4806105e65def14795d8f7108a88f076 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 7 Jul 2026 20:51:49 +0000 Subject: [PATCH 5/5] test: Implement github workflow for backend tests. --- .github/workflows/backend-tests.yml | 161 ++++++++++++++++++++++++++++ scripts/ci_backend_test_labels.py | 104 ++++++++++++++++++ scripts/ci_bootstrap_backend.sh | 77 +++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 .github/workflows/backend-tests.yml create mode 100644 scripts/ci_backend_test_labels.py create mode 100644 scripts/ci_bootstrap_backend.sh diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml new file mode 100644 index 00000000..036d6ece --- /dev/null +++ b/.github/workflows/backend-tests.yml @@ -0,0 +1,161 @@ +name: Backend Tests + +on: + push: + branches: [main, dev] + paths: + - 'apps/**' + - 'core/**' + - 'tests/**' + - 'dispatcharr/**' + - 'pyproject.toml' + - 'version.py' + - 'manage.py' + - 'scripts/ci_backend_test_labels.py' + - 'scripts/ci_bootstrap_backend.sh' + - '.github/workflows/backend-tests.yml' + pull_request: + branches: [main, dev] + paths: + - 'apps/**' + - 'core/**' + - 'tests/**' + - 'dispatcharr/**' + - 'pyproject.toml' + - 'version.py' + - 'manage.py' + - 'scripts/ci_backend_test_labels.py' + - 'scripts/ci_bootstrap_backend.sh' + - '.github/workflows/backend-tests.yml' + workflow_dispatch: + inputs: + full_suite: + description: Run the full backend test suite + type: boolean + default: true + +permissions: + contents: read + packages: read + +concurrency: + group: backend-tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + plan: + name: Plan test groups + runs-on: ubuntu-latest + outputs: + labels: ${{ steps.resolve.outputs.labels }} + has_tests: ${{ steps.resolve.outputs.has_tests }} + base_image: ${{ steps.base_image.outputs.image }} + sync_python_deps: ${{ steps.base_image.outputs.sync_python_deps }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Collect changed paths + id: changed + shell: bash + run: | + set -euo pipefail + : > /tmp/changed_paths.txt + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + if [ "${{ inputs.full_suite }}" = "true" ]; then + : > /tmp/changed_paths.txt + echo "mode=full" >> "$GITHUB_OUTPUT" + else + git diff --name-only HEAD~1 HEAD > /tmp/changed_paths.txt || true + echo "mode=diff" >> "$GITHUB_OUTPUT" + fi + elif [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch origin "${{ github.base_ref }}" + git diff --name-only "origin/${{ github.base_ref }}...HEAD" > /tmp/changed_paths.txt + echo "mode=pr" >> "$GITHUB_OUTPUT" + else + if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then + git ls-files > /tmp/changed_paths.txt + echo "mode=initial-push" >> "$GITHUB_OUTPUT" + else + git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" > /tmp/changed_paths.txt + echo "mode=push" >> "$GITHUB_OUTPUT" + fi + fi + + - name: Select base image + id: base_image + shell: bash + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "pull_request" ]; then + TARGET_BRANCH="${{ github.base_ref }}" + else + TARGET_BRANCH="${{ github.ref_name }}" + fi + if [ "$TARGET_BRANCH" = "main" ]; then + TAG="base" + else + TAG="base-dev" + fi + REPO_OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')" + REPO_NAME="$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')" + echo "image=ghcr.io/${REPO_OWNER}/${REPO_NAME}:${TAG}" >> "$GITHUB_OUTPUT" + if grep -qx 'pyproject.toml' /tmp/changed_paths.txt || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "sync_python_deps=true" >> "$GITHUB_OUTPUT" + else + echo "sync_python_deps=false" >> "$GITHUB_OUTPUT" + fi + echo "Using base image: ghcr.io/${REPO_OWNER}/${REPO_NAME}:${TAG}" + + - name: Resolve Django test labels + id: resolve + env: + FULL_SUITE: ${{ github.event_name == 'workflow_dispatch' && inputs.full_suite == true && 'true' || 'false' }} + run: | + set -euo pipefail + LABELS=$(python scripts/ci_backend_test_labels.py < /tmp/changed_paths.txt) + echo "labels=${LABELS}" >> "$GITHUB_OUTPUT" + if [ "${LABELS}" = "[]" ]; then + echo "has_tests=false" >> "$GITHUB_OUTPUT" + else + echo "has_tests=true" >> "$GITHUB_OUTPUT" + fi + echo "Selected labels: ${LABELS}" + + test: + name: ${{ matrix.label }} + needs: plan + if: needs.plan.outputs.has_tests == 'true' + runs-on: ubuntu-latest + container: + image: ${{ needs.plan.outputs.base_image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + options: --entrypoint "" + strategy: + max-parallel: 6 + fail-fast: false + matrix: + label: ${{ fromJSON(needs.plan.outputs.labels) }} + env: + DISPATCHARR_ENV: aio + DJANGO_SECRET_KEY: ci-test-secret-key + POSTGRES_DB: dispatcharr + POSTGRES_USER: dispatch + POSTGRES_PASSWORD: secret + DISPATCHARR_LOG_LEVEL: WARNING + SYNC_PYTHON_DEPS: ${{ needs.plan.outputs.sync_python_deps }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Run tests in base image + env: + GITHUB_WORKSPACE: ${{ github.workspace }} + run: bash scripts/ci_bootstrap_backend.sh "${{ matrix.label }}" -v2 diff --git a/scripts/ci_backend_test_labels.py b/scripts/ci_backend_test_labels.py new file mode 100644 index 00000000..e69356c4 --- /dev/null +++ b/scripts/ci_backend_test_labels.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Map changed repository paths to Django test package labels for CI.""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import PurePosixPath + +# Keep in sync with dispatcharr.test_runner package discovery. +ALL_LABELS: tuple[str, ...] = ( + "apps.accounts.tests", + "apps.backups.tests", + "apps.channels.tests", + "apps.connect.tests", + "apps.dashboard.tests", + "apps.epg.tests", + "apps.m3u.tests", + "apps.output.tests", + "apps.plugins.tests", + "apps.timeshift.tests", + "apps.proxy.live_proxy.tests", + "apps.proxy.vod_proxy.tests", + "core.tests", + "tests", +) + +# Longest-prefix wins; order matters for overlapping rules. +_PATH_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("apps/channels/", ("apps.channels.tests",)), + ("apps/epg/", ("apps.epg.tests",)), + ("apps/m3u/", ("apps.m3u.tests",)), + ("apps/proxy/", ("apps.proxy.live_proxy.tests", "apps.proxy.vod_proxy.tests")), + ("apps/connect/", ("apps.connect.tests",)), + ("apps/output/", ("apps.output.tests",)), + ("apps/accounts/", ("apps.accounts.tests",)), + ("apps/backups/", ("apps.backups.tests",)), + ("apps/dashboard/", ("apps.dashboard.tests",)), + ("apps/plugins/", ("apps.plugins.tests",)), + ("apps/timeshift/", ("apps.timeshift.tests",)), + ("apps/vod/", ("apps.output.tests",)), + ("apps/hdhr/", ("apps.output.tests", "apps.channels.tests")), + ("apps/api/", ALL_LABELS), + ("core/", ("core.tests", "tests")), + ("tests/", ("tests",)), +) + +_SHARED_PREFIXES: tuple[str, ...] = ( + "dispatcharr/", + "pyproject.toml", + "manage.py", + "version.py", + "scripts/ci_backend_test_labels.py", + "scripts/ci_bootstrap_backend.sh", + ".github/workflows/backend-tests.yml", +) + + +def _normalize(path: str) -> str: + return PurePosixPath(path.strip().replace("\\", "/")).as_posix() + + +def labels_for_paths(paths: list[str], *, full_suite: bool = False) -> list[str]: + if full_suite: + return list(ALL_LABELS) + + labels: set[str] = set() + for raw in paths: + path = _normalize(raw) + if not path: + continue + if any(path == prefix.rstrip("/") or path.startswith(prefix) for prefix in _SHARED_PREFIXES): + return list(ALL_LABELS) + for prefix, group_labels in _PATH_RULES: + if path.startswith(prefix): + labels.update(group_labels) + break + + return sorted(labels) + + +def _read_paths(argv: list[str]) -> list[str]: + if argv: + return argv + if not sys.stdin.isatty(): + return [line for line in sys.stdin.read().splitlines() if line.strip()] + return [] + + +def main(argv: list[str] | None = None) -> int: + argv = argv if argv is not None else sys.argv[1:] + full_suite = os.environ.get("FULL_SUITE", "").lower() in {"1", "true", "yes"} + if full_suite: + print(json.dumps(list(ALL_LABELS))) + return 0 + paths = _read_paths(argv) + labels = labels_for_paths(paths, full_suite=False) + print(json.dumps(labels)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci_bootstrap_backend.sh b/scripts/ci_bootstrap_backend.sh new file mode 100644 index 00000000..d13e0218 --- /dev/null +++ b/scripts/ci_bootstrap_backend.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Bootstrap internal Postgres/Redis (AIO-style) and run Django tests in the base image. +set -euo pipefail + +REPO_ROOT="${GITHUB_WORKSPACE:-$(pwd)}" +cd "${REPO_ROOT}" + +export DISPATCHARR_ENV="${DISPATCHARR_ENV:-aio}" +export PUID="${PUID:-1000}" +export PGID="${PGID:-1000}" +export POSTGRES_DB="${POSTGRES_DB:-dispatcharr}" +export POSTGRES_USER="${POSTGRES_USER:-dispatch}" +export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-secret}" +export POSTGRES_PORT="${POSTGRES_PORT:-5432}" +export POSTGRES_DIR="${POSTGRES_DIR:-/tmp/dispatcharr-ci/db}" +export REDIS_HOST="${REDIS_HOST:-localhost}" +export REDIS_PORT="${REDIS_PORT:-6379}" +export REDIS_DB="${REDIS_DB:-0}" +export DJANGO_SECRET_KEY="${DJANGO_SECRET_KEY:-ci-test-secret-key}" +export DISPATCHARR_LOG_LEVEL="${DISPATCHARR_LOG_LEVEL:-WARNING}" +export PATH="/dispatcharrpy/bin:${PATH}" +export PG_VERSION +PG_VERSION="$(ls /usr/lib/postgresql/ | sort -V | tail -n 1)" +export PG_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin" + +if [[ "$DISPATCHARR_ENV" == "aio" ]]; then + export POSTGRES_HOST="${POSTGRES_HOST:-/var/run/postgresql}" +else + export POSTGRES_HOST="${POSTGRES_HOST:-localhost}" +fi + +if [[ "${SYNC_PYTHON_DEPS:-}" == "true" ]]; then + echo "Syncing Python dependencies with uv..." + uv sync --python /dispatcharrpy/bin/python --no-install-project --no-dev +fi + +echo "Setting up CI user and PostgreSQL data directory..." +# shellcheck source=docker/init/01-user-setup.sh +. "${REPO_ROOT}/docker/init/01-user-setup.sh" +mkdir -p "${POSTGRES_DIR}" +chown "${PUID}:${PGID}" "${POSTGRES_DIR}" +chmod 700 "${POSTGRES_DIR}" +# shellcheck source=docker/init/02-postgres.sh +. "${REPO_ROOT}/docker/init/02-postgres.sh" + +echo "Starting internal PostgreSQL..." +prepare_pg_socket_dir +su - "$POSTGRES_USER" -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 120 -o '-c port=${POSTGRES_PORT}'" +until su - "$POSTGRES_USER" -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do + sleep 1 +done +set +e +promote_app_role +_promote_status=$? +ensure_app_database +_ensure_status=$? +set -e +if [ "$_promote_status" -ne 0 ] || [ "$_ensure_status" -ne 0 ]; then + echo "Failed to configure PostgreSQL role/database (promote=${_promote_status}, ensure=${_ensure_status})" + exit 1 +fi + +echo "Starting internal Redis..." +if redis-cli -p "${REDIS_PORT}" ping >/dev/null 2>&1; then + echo "Redis already listening on port ${REDIS_PORT}" +else + redis-server --daemonize yes --protected-mode no --bind 127.0.0.1 --port "${REDIS_PORT}" +fi +python "${REPO_ROOT}/scripts/wait_for_redis.py" + +cleanup() { + redis-cli -p "${REDIS_PORT}" shutdown nosave >/dev/null 2>&1 || true + su - "$POSTGRES_USER" -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} stop -m fast" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +exec python manage.py test --keepdb "$@"