Compare commits

..

No commits in common. "main" and "v0.27.1" have entirely different histories.

19 changed files with 93 additions and 836 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<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 [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 && (
<>
<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>
</>
)}
{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}
/>
);
}
})}
</>
);
});

View file

@ -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', () => ({
</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>
@ -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(<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,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:

View file

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

View file

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

View file

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

View file

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

View file

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