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/CHANGELOG.md b/CHANGELOG.md index ea23e1dd..a97f5158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ 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. + +### Changed + +- **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 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 ### 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..91b36bb7 100644 --- a/frontend/src/components/forms/settings/ProxySettingsForm.jsx +++ b/frontend/src/components/forms/settings/ProxySettingsForm.jsx @@ -5,80 +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', - '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 === 'new_client_behind_seconds' - ? 120 - : 60; - }; + 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..2b0dc55f 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,38 @@ describe('ProxySettingsForm', () => { // ── ProxySettingsOptions field routing ───────────────────────────────────── describe('ProxySettingsOptions field routing', () => { - it('calls getInputProps for each PROXY_SETTINGS_OPTIONS key', () => { + 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'); }); + + 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(); + }); }); }); diff --git a/frontend/src/constants.js b/frontend/src/constants.js index b130553e..ea6dec13 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -43,19 +43,27 @@ 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)', }, 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', + 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 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)', 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'); }); }); 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 "$@" 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