Compare commits

...

6 commits

Author SHA1 Message Date
SergeantPanda
a347e50e48 test: Implement github workflow for backend tests.
Some checks failed
Backend Tests / Plan test groups (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Backend Tests / (push) Has been cancelled
2026-07-07 21:14:14 +00:00
GitHub Actions
22b957aee4 Release v0.27.2
Some checks failed
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
2026-06-30 16:28:27 +00:00
SergeantPanda
68ab03d95f
Merge pull request #1399 from Dispatcharr/refactor-channel-initialization
Some checks failed
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Has been cancelled
Refactor channel initialization
2026-06-30 10:47:15 -05:00
SergeantPanda
97515c4d05 tests: fix proxy setting test 2026-06-30 10:34:40 -05:00
SergeantPanda
ac6d43af8c 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. 2026-06-30 10:00:22 -05:00
SergeantPanda
391c3412d2 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. 2026-06-28 11:31:21 -05:00
19 changed files with 836 additions and 93 deletions

161
.github/workflows/backend-tests.yml vendored Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<NumberInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
min={0}
max={getNumericFieldMax(key)}
/>
);
}
if (isFloatField(key)) {
return (
<NumberInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
min={0.0}
max={10.0}
step={0.01}
precision={1}
/>
);
}
return (
<TextInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
/>
);
};
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 (
<NumberInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
min={0}
max={getNumericFieldMax(key)}
/>
);
} else if (isFloatField(key)) {
return (
<NumberInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
min={0.0}
max={10.0}
step={0.01}
precision={1}
/>
);
} else {
return (
<TextInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
/>
);
}
})}
{mainEntries.map(([key, config]) =>
renderProxySettingField(key, config, proxySettingsForm)
)}
{advancedEntries.length > 0 && (
<>
<Button
variant="subtle"
size="xs"
leftSection={
advancedOpen ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)
}
onClick={() => setAdvancedOpen((open) => !open)}
c="dimmed"
styles={{ root: { alignSelf: 'flex-start' } }}
>
{advancedOpen ? 'Hide' : 'Show'} Advanced Settings
</Button>
<Collapse in={advancedOpen}>
<Stack gap="sm">
{advancedEntries.map(([key, config]) =>
renderProxySettingField(key, config, proxySettingsForm)
)}
</Stack>
</Collapse>
</>
)}
</>
);
});

View file

@ -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', () => ({
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Collapse: ({ in: isOpen, children }) =>
isOpen ? <div data-testid="collapse-open">{children}</div> : null,
TextInput: ({ label, description, ...rest }) => (
<div>
<label>{label}</label>
@ -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(<ProxySettingsForm active={true} />);
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(<ProxySettingsForm active={true} />);
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(<ProxySettingsForm active={true} />);
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();
});
});
});

View file

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

View file

@ -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,
};
};

View file

@ -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');
});
});

View file

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

View file

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

View file

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