diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml deleted file mode 100644 index 036d6ece..00000000 --- a/.github/workflows/backend-tests.yml +++ /dev/null @@ -1,161 +0,0 @@ -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 a97f5158..ea23e1dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,21 +7,6 @@ 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 b76918ac..ef78e576 100644 --- a/apps/channels/tests/test_ts_proxy_teardown.py +++ b/apps/channels/tests/test_ts_proxy_teardown.py @@ -923,62 +923,3 @@ 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 48d9fc9b..c7ba5628 100644 --- a/apps/proxy/config.py +++ b/apps/proxy/config.py @@ -42,8 +42,7 @@ class BaseConfig: "buffering_speed": 1.0, "redis_chunk_ttl": 60, "channel_shutdown_delay": 0, - "channel_init_grace_period": 60, - "channel_client_wait_period": 5, + "channel_init_grace_period": 5, "new_client_behind_seconds": 5, } @@ -136,13 +135,7 @@ 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", 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) + return settings.get("channel_init_grace_period", 5) # Dynamic property access for these settings @property @@ -161,6 +154,5 @@ 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 846b0153..d151a8a8 100644 --- a/apps/proxy/live_proxy/config_helper.py +++ b/apps/proxy/live_proxy/config_helper.py @@ -110,11 +110,6 @@ 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 97c5b68b..fd54fc24 100644 --- a/apps/proxy/live_proxy/server.py +++ b/apps/proxy/live_proxy/server.py @@ -910,26 +910,6 @@ 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 @@ -1819,27 +1799,31 @@ class ProxyServer: start_time = connection_attempt_time or init_time if start_time: - 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 + # 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: logger.warning( f"Channel {channel_id} in {channel_state} state with 0 clients for {time_since_ready:.1f}s " - f"(buffer ready, no client connected, client_wait_period: {timeout}s) - stopping channel" + f"(after reaching ready, shutdown_delay: {shutdown_delay}s) - stopping channel" ) - else: - time_since_start = time.time() - start_time + 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: logger.warning( f"Channel {channel_id} stuck in {channel_state} state for {time_since_start:.1f}s " - f"with no clients (timeout: {timeout}s) - stopping channel due to upstream issues" + f"with no clients (timeout: {connecting_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 deleted file mode 100644 index e821f1cb..00000000 --- a/apps/proxy/live_proxy/tests/test_proxy_settings.py +++ /dev/null @@ -1,143 +0,0 @@ -"""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 ee7d1db1..ec66d69a 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -225,8 +225,7 @@ class ProxySettingsViewSet(viewsets.ViewSet): "buffering_speed": 1.0, "redis_chunk_ttl": 60, "channel_shutdown_delay": 0, - "channel_init_grace_period": 60, - "channel_client_wait_period": 5, + "channel_init_grace_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 deleted file mode 100644 index 6119eb66..00000000 --- a/core/migrations/0026_add_channel_client_wait_period.py +++ /dev/null @@ -1,51 +0,0 @@ -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 ad9677c6..60be2f0e 100644 --- a/core/models.py +++ b/core/models.py @@ -394,8 +394,7 @@ class CoreSettings(models.Model): "buffering_speed": 1.0, "redis_chunk_ttl": 60, "channel_shutdown_delay": 0, - "channel_init_grace_period": 60, - "channel_client_wait_period": 5, + "channel_init_grace_period": 5, "new_client_behind_seconds": 5, }) diff --git a/core/serializers.py b/core/serializers.py index 1398b194..a0f27baf 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -96,8 +96,7 @@ 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=300) - channel_client_wait_period = serializers.IntegerField(min_value=0, max_value=300, required=False, default=5) + channel_init_grace_period = serializers.IntegerField(min_value=0, max_value=60) new_client_behind_seconds = serializers.IntegerField(min_value=0, max_value=120, required=False, default=5) def validate_buffering_timeout(self, value): @@ -121,16 +120,9 @@ class ProxySettingsSerializer(serializers.Serializer): return value def validate_channel_init_grace_period(self, value): - if value < 0 or value > 300: + if value < 0 or value > 60: raise serializers.ValidationError( - "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" + "Channel initialization timeout must be between 0 and 60 seconds" ) return value diff --git a/frontend/src/components/forms/settings/ProxySettingsForm.jsx b/frontend/src/components/forms/settings/ProxySettingsForm.jsx index 91b36bb7..52440769 100644 --- a/frontend/src/components/forms/settings/ProxySettingsForm.jsx +++ b/frontend/src/components/forms/settings/ProxySettingsForm.jsx @@ -5,119 +5,80 @@ 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 [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); - + 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; + }; return ( <> - {mainEntries.map(([key, config]) => - renderProxySettingField(key, config, proxySettingsForm) - )} - - {advancedEntries.length > 0 && ( - <> - - - - {advancedEntries.map(([key, config]) => - renderProxySettingField(key, config, proxySettingsForm) - )} - - - - )} + {Object.entries(PROXY_SETTINGS_OPTIONS).map(([key, config]) => { + if (isNumericField(key)) { + return ( + + ); + } else if (isFloatField(key)) { + return ( + + ); + } else { + return ( + + ); + } + })} ); }); diff --git a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx index 2b0dc55f..c874b3e6 100644 --- a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx @@ -14,21 +14,6 @@ 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', - }, }, })); @@ -86,8 +71,6 @@ vi.mock('@mantine/core', () => ({ ), Stack: ({ children }) =>
{children}
, - Collapse: ({ in: isOpen, children }) => - isOpen ?
{children}
: null, TextInput: ({ label, description, ...rest }) => (
@@ -370,38 +353,11 @@ describe('ProxySettingsForm', () => { // ── ProxySettingsOptions field routing ───────────────────────────────────── describe('ProxySettingsOptions field routing', () => { - it('binds main settings on initial render', () => { + it('calls getInputProps for each PROXY_SETTINGS_OPTIONS key', () => { 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 ea6dec13..b130553e 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -43,27 +43,19 @@ 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 the last client disconnects', + 'Delay in seconds before shutting down a channel after 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 ccbabdeb..eddbba91 100644 --- a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js +++ b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js @@ -13,8 +13,7 @@ export const getProxySettingDefaults = () => { buffering_speed: 1.0, redis_chunk_ttl: 60, channel_shutdown_delay: 0, - channel_init_grace_period: 60, - channel_client_wait_period: 5, + channel_init_grace_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 3c17c42e..21c48269 100644 --- a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js @@ -60,8 +60,7 @@ describe('ProxySettingsFormUtils', () => { buffering_speed: 1.0, redis_chunk_ttl: 60, channel_shutdown_delay: 0, - channel_init_grace_period: 60, - channel_client_wait_period: 5, + channel_init_grace_period: 5, new_client_behind_seconds: 5, }); }); @@ -82,7 +81,6 @@ 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 deleted file mode 100644 index e69356c4..00000000 --- a/scripts/ci_backend_test_labels.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/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 deleted file mode 100644 index d13e0218..00000000 --- a/scripts/ci_bootstrap_backend.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/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 25ccd35c..db62d71f 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.27.2' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.27.1' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process