mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a347e50e48 | ||
|
|
22b957aee4 | ||
|
|
68ab03d95f | ||
|
|
97515c4d05 | ||
|
|
ac6d43af8c | ||
|
|
391c3412d2 |
19 changed files with 836 additions and 93 deletions
161
.github/workflows/backend-tests.yml
vendored
Normal file
161
.github/workflows/backend-tests.yml
vendored
Normal 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
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.27.2] - 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- **New proxy setting: Client Connect Grace Period (`channel_client_wait_period`, default 5s).** Adds a dedicated timeout for channels that have filled their buffer but still have no viewers (`waiting_for_clients`). Previously that window reused `channel_shutdown_delay` (default 0s), so those channels were torn down almost immediately.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Proxy grace-period settings are now split into three distinct timeouts.** The cleanup watchdog already applied `channel_init_grace_period` while a channel was still connecting (buffer not ready) and reused `channel_shutdown_delay` once `connection_ready_time` was set, including for `waiting_for_clients` with zero viewers. With the default `channel_shutdown_delay` of 0s, a buffered channel waiting for its first viewer was stopped almost immediately; raising shutdown delay was the only workaround, but that also delayed teardown after real disconnects. Behaviour is now:
|
||||
- **`channel_init_grace_period` (default 60s, max 300s):** how long the proxy may spend connecting and cycling failover streams before giving up on startup.
|
||||
- **`channel_client_wait_period` (default 5s):** how long a ready channel with no viewers stays up waiting for the first client (the original grace-period use case).
|
||||
- **`channel_shutdown_delay` (default 0s):** delay after the last client disconnects only; no longer applies when the buffer is ready but no viewer has connected yet.
|
||||
- **Migration 0026 bumps `channel_init_grace_period` to 60s when the stored value is below 60.** Existing installs on the old 5s default (or any custom value under 60) are raised automatically. Values already at 60s or higher are unchanged. If you previously raised `channel_shutdown_delay` to keep buffered channels alive with no viewers, set `channel_client_wait_period` instead (Settings → Proxy → Advanced).
|
||||
- **Proxy settings UI: less-used options moved under Advanced.** Settings → Proxy now shows the day-to-day tuning fields by default (`buffering_timeout`, `buffering_speed`, `channel_shutdown_delay`, `new_client_behind_seconds`). **Buffer Chunk TTL**, **Channel Initialization Timeout**, and **Client Connect Grace Period** are tucked under **Show Advanced Settings**.
|
||||
|
||||
## [0.27.1] - 2026-06-25
|
||||
|
||||
### Security
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
143
apps/proxy/live_proxy/tests/test_proxy_settings.py
Normal file
143
apps/proxy/live_proxy/tests/test_proxy_settings.py
Normal 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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
51
core/migrations/0026_add_channel_client_wait_period.py
Normal file
51
core/migrations/0026_add_channel_client_wait_period.py
Normal 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),
|
||||
]
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
104
scripts/ci_backend_test_labels.py
Normal file
104
scripts/ci_backend_test_labels.py
Normal 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())
|
||||
77
scripts/ci_bootstrap_backend.sh
Normal file
77
scripts/ci_bootstrap_backend.sh
Normal 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 "$@"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue