diff --git a/CHANGELOG.md b/CHANGELOG.md
index 03a9bcaf..309ed5c1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,15 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-- **New proxy setting: Client Connect Grace Period (`channel_client_wait_period`, default 5s).** Restores the grace-period behaviour that was unintentionally dropped in 0.27.1. When a channel's buffer becomes ready but no client has connected yet (e.g. after an API warmup), this controls how long to keep the channel alive before tearing down.
+- **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.** In 0.27.1, `channel_init_grace_period` was repurposed as the channel startup/failover timeout (replacing a hardcoded 10s). That left API-warmup "wait for the first client" behaviour tied to `channel_shutdown_delay` (default 0s), so warmed-up channels stopped almost immediately. Behaviour is now:
+- **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 to warmup.
-- **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 warmed-up channels alive, set `channel_client_wait_period` instead.
+ - **`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
diff --git a/frontend/src/components/forms/settings/ProxySettingsForm.jsx b/frontend/src/components/forms/settings/ProxySettingsForm.jsx
index a3a9600a..91b36bb7 100644
--- a/frontend/src/components/forms/settings/ProxySettingsForm.jsx
+++ b/frontend/src/components/forms/settings/ProxySettingsForm.jsx
@@ -5,83 +5,119 @@ import { updateSetting } from '../../../utils/pages/SettingsUtils.js';
import {
Alert,
Button,
+ Collapse,
Flex,
NumberInput,
Stack,
TextInput,
} from '@mantine/core';
+import { ChevronDown, ChevronRight } from 'lucide-react';
import { PROXY_SETTINGS_OPTIONS } from '../../../constants.js';
import {
getProxySettingDefaults,
getProxySettingsFormInitialValues,
} from '../../../utils/forms/settings/ProxySettingsFormUtils.js';
+const isNumericField = (key) => {
+ return [
+ 'buffering_timeout',
+ 'redis_chunk_ttl',
+ 'channel_shutdown_delay',
+ 'channel_init_grace_period',
+ 'channel_client_wait_period',
+ 'new_client_behind_seconds',
+ ].includes(key);
+};
+
+const isFloatField = (key) => key === 'buffering_speed';
+
+const getNumericFieldMax = (key) => {
+ if (key === 'buffering_timeout') return 300;
+ if (key === 'redis_chunk_ttl') return 3600;
+ if (key === 'channel_shutdown_delay') return 300;
+ if (key === 'channel_client_wait_period') return 300;
+ if (key === 'new_client_behind_seconds') return 120;
+ return 300;
+};
+
+const renderProxySettingField = (key, config, proxySettingsForm) => {
+ if (isNumericField(key)) {
+ return (
+
+ );
+ }
+
+ if (isFloatField(key)) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
const ProxySettingsOptions = React.memo(({ proxySettingsForm }) => {
- const isNumericField = (key) => {
- // Determine if this field should be a NumberInput
- return [
- 'buffering_timeout',
- 'redis_chunk_ttl',
- 'channel_shutdown_delay',
- 'channel_init_grace_period',
- 'channel_client_wait_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 === 'channel_client_wait_period'
- ? 300
- : key === 'new_client_behind_seconds'
- ? 120
- : 300;
- };
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+ const entries = Object.entries(PROXY_SETTINGS_OPTIONS);
+ const mainEntries = entries.filter(([, config]) => !config.advanced);
+ const advancedEntries = entries.filter(([, config]) => config.advanced);
+
return (
<>
- {Object.entries(PROXY_SETTINGS_OPTIONS).map(([key, config]) => {
- if (isNumericField(key)) {
- return (
-
- );
- } else if (isFloatField(key)) {
- return (
-
- );
- } else {
- return (
-
- );
- }
- })}
+ {mainEntries.map(([key, config]) =>
+ renderProxySettingField(key, config, proxySettingsForm)
+ )}
+
+ {advancedEntries.length > 0 && (
+ <>
+
+ ) : (
+
+ )
+ }
+ onClick={() => setAdvancedOpen((open) => !open)}
+ c="dimmed"
+ styles={{ root: { alignSelf: 'flex-start' } }}
+ >
+ {advancedOpen ? 'Hide' : 'Show'} Advanced Settings
+
+
+
+ {advancedEntries.map(([key, config]) =>
+ renderProxySettingField(key, config, proxySettingsForm)
+ )}
+
+
+ >
+ )}
>
);
});
diff --git a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx
index c874b3e6..b0fa3a56 100644
--- a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx
+++ b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx
@@ -14,6 +14,21 @@ vi.mock('../../../../constants.js', () => ({
description: 'Speed multiplier',
},
redis_url: { label: 'Redis URL', description: 'Redis connection URL' },
+ redis_chunk_ttl: {
+ label: 'Buffer Chunk TTL',
+ advanced: true,
+ description: 'Chunk TTL',
+ },
+ channel_init_grace_period: {
+ label: 'Channel Initialization Timeout',
+ advanced: true,
+ description: 'Init timeout',
+ },
+ channel_client_wait_period: {
+ label: 'Client Connect Grace Period',
+ advanced: true,
+ description: 'Advanced grace period',
+ },
},
}));
@@ -71,6 +86,8 @@ vi.mock('@mantine/core', () => ({
),
Stack: ({ children }) =>
{children}
,
+ Collapse: ({ in: isOpen, children }) =>
+ isOpen ? {children}
: null,
TextInput: ({ label, description, ...rest }) => (
@@ -353,11 +370,52 @@ describe('ProxySettingsForm', () => {
// ── ProxySettingsOptions field routing ─────────────────────────────────────
describe('ProxySettingsOptions field routing', () => {
- it('calls getInputProps for each PROXY_SETTINGS_OPTIONS key', () => {
+ it('calls getInputProps for main settings on initial render', () => {
render(
);
expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_timeout');
expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_speed');
expect(formMock.getInputProps).toHaveBeenCalledWith('redis_url');
+ expect(formMock.getInputProps).not.toHaveBeenCalledWith(
+ 'channel_client_wait_period'
+ );
+ expect(formMock.getInputProps).not.toHaveBeenCalledWith(
+ 'channel_init_grace_period'
+ );
+ expect(formMock.getInputProps).not.toHaveBeenCalledWith('redis_chunk_ttl');
+ });
+
+ it('hides advanced settings until expanded', () => {
+ render(
);
+ expect(
+ screen.queryByTestId('number-input-Client Connect Grace Period')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('number-input-Channel Initialization Timeout')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('number-input-Buffer Chunk TTL')
+ ).not.toBeInTheDocument();
+ expect(screen.getByText('Show Advanced Settings')).toBeInTheDocument();
+ });
+
+ it('shows advanced settings when expanded', () => {
+ render(
);
+ fireEvent.click(screen.getByText('Show Advanced Settings'));
+ expect(screen.getByTestId('collapse-open')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('number-input-Client Connect Grace Period')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId('number-input-Channel Initialization Timeout')
+ ).toBeInTheDocument();
+ expect(screen.getByTestId('number-input-Buffer Chunk TTL')).toBeInTheDocument();
+ expect(formMock.getInputProps).toHaveBeenCalledWith(
+ 'channel_client_wait_period'
+ );
+ expect(formMock.getInputProps).toHaveBeenCalledWith(
+ 'channel_init_grace_period'
+ );
+ expect(formMock.getInputProps).toHaveBeenCalledWith('redis_chunk_ttl');
});
});
});
diff --git a/frontend/src/constants.js b/frontend/src/constants.js
index fba0d7c6..ea6dec13 100644
--- a/frontend/src/constants.js
+++ b/frontend/src/constants.js
@@ -43,6 +43,7 @@ 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)',
},
@@ -53,13 +54,15 @@ export const PROXY_SETTINGS_OPTIONS = {
},
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 ready channel alive when no client has connected yet (e.g. after an API warmup). Once a client connects the channel becomes active; if none connect within this window the channel stops.',
+ '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)',