Enhancement: update catch-up features and enhance stream ordering logic
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run

- Improved multi-provider failover by preferring streams with sufficient catch-up days.
- Added new helper functions for calculating programme age and ordering catch-up streams.
- Removed deprecated XMLTV settings from the frontend and backend.
- Updated tests to validate new stream ordering logic and catch-up functionality.
This commit is contained in:
SergeantPanda 2026-07-16 15:33:34 +00:00
parent 6bd42c83f4
commit 388c4bd28d
14 changed files with 169 additions and 573 deletions

View file

@ -17,16 +17,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Catch-up Stats UI.** Dedicated catch-up connection cards show channel logo, programme preview (watched/remaining timeline), playback position, bitrate, and a stop button. Stats refresh in real time via WebSocket (`timeshift_stats`) with desktop notifications when catch-up sessions start or end.
- **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; end-of-refresh SQL rollup updates channels linked to the refreshed account and self-heals stale flags on those channels only (e.g. after catch-up streams are removed or an account is deactivated).
- **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure.
- **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams.
- **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. Attempts prefer streams whose `catchup_days` cover the requested programme age, then fall back to shorter archives in channel order.
- **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it when the session ends. When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full.
- **Per-client session pool.** The first request without `session_id` and no fingerprint match receives a `301` redirect to the same URL with a stable `?session_id=` query parameter; that ID becomes the client identity for stats, stop keys, and pool coordination. Reconnects that omit `session_id` but match an existing pool entry (same user, IP, and user-agent) are served immediately without a redirect, matching IPTV client fast-forward behaviour. Each viewer gets their own Redis-backed pool entry even when watching the same programme; idle sessions can be reclaimed via fingerprint matching. Plain GET restarts stream from byte 0 with upstream `Content-Length` when known (provider-faithful). Real scrubs within a session stop the in-flight stream through the standard stop-key mechanism; parallel startup probes (full-file, open-ended, and EOF tail `Range` requests) are deferred with `503` instead of opening extra upstream connections. Parallel HTTP probes from the same `session_id` for the same programme do not count as extra streams toward the user limit; distinct sessions still each consume a slot.
- **`xmltv_prev_days_override` under `Settings → EPG`** (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level.
- **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter.
- Verbose timeshift logging follows the standard logger DEBUG level.
- **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via per-user `epg_prev_days` or `?prev_days=` URL parameter.
- **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer.
- **Catch-up indicators in Channels and Streams tables.** Channels and streams with catch-up enabled show a grey history icon beside the name (tooltip includes archive days when known). Expanded channel stream rows show a catch-up badge. Channel and stream API serializers expose `is_catchup` and `catchup_days` for the UI. The Channels and Streams table filter menus include **Only Catch-up** to narrow each list to catch-up entries.
- **Combined connection stats API.** `GET /proxy/stats/` (admin) returns live, VOD, and catch-up connection stats in one JSON response (`live`, `vod`, `catchup`, `timestamp`). The Stats page polls this endpoint instead of separate live and VOD stats requests.
- **Frontend unit tests extended to table components.** `ChannelsTable`, `StreamsTable`, `M3UsTable`, `EPGsTable`, `LogosTable`, `CustomTable`, and related header/editable subcomponents now have Vitest + Testing Library suites. Table business logic was extracted into `frontend/src/utils/tables/*` (with shared `M3uTableUtils` for M3U/EPG sort headers) and covered by dedicated util tests. `dateTimeUtils.formatDuration` gained a `human` precision mode for readable content lengths. — Thanks [@nick4810](https://github.com/nick4810)
- **Frontend unit tests extended to plugin, backup, and settings components.** `AboutModal`, `PluginDetailPanel`, `BackupManager`, `AvailablePluginCard`, `ConnectionSecurityPanel`, and `UserLimitsForm` were refactored with business logic moved into `frontend/src/utils/components/*` and `PluginsUtils`; `pluginUtils` (version comparison, compatibility labels, install/downgrade detection) now lives under `utils/components/`. Vitest + Testing Library suites were added for those components plus `PluginWarnings` and `EpgSettingsForm`. — Thanks [@nick4810](https://github.com/nick4810)
- **Frontend unit tests extended to plugin, backup, and settings components.** `AboutModal`, `PluginDetailPanel`, `BackupManager`, `AvailablePluginCard`, `ConnectionSecurityPanel`, and `UserLimitsForm` were refactored with business logic moved into `frontend/src/utils/components/*` and `PluginsUtils`; `pluginUtils` (version comparison, compatibility labels, install/downgrade detection) now lives under `utils/components/`. Vitest + Testing Library suites were added for those components plus `PluginWarnings`. — Thanks [@nick4810](https://github.com/nick4810)
### Changed

View file

@ -44,13 +44,6 @@ class ResolveXcEpgPrevDaysTests(TestCase):
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 3)
mock_compute.assert_not_called()
@patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14)
@patch("core.models.CoreSettings.get_xmltv_prev_days_override", return_value=7)
def test_epg_settings_override_prevents_auto_detect(self, _override, mock_compute):
request = self.factory.get("/xmltv.php")
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 7)
mock_compute.assert_not_called()
class CatchupRollupActiveAccountTests(TestCase):
"""Denormalized catch-up flags ignore disabled M3U accounts."""

View file

@ -67,8 +67,7 @@ def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True):
Resolution order:
1. URL ``?prev_days=`` (explicit; 0 means no past programmes)
2. ``user.custom_properties.epg_prev_days``
3. ``CoreSettings.epg_settings.xmltv_prev_days_override`` when > 0
4. Auto-detect (only when *auto_detect_fallback* is True)
3. Auto-detect (only when *auto_detect_fallback* is True)
"""
user_custom = (user.custom_properties or {}) if user else {}
url_prev = request.GET.get("prev_days")
@ -85,14 +84,6 @@ def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True):
except (ValueError, TypeError):
return 0
from core.models import CoreSettings
try:
override = int(CoreSettings.get_xmltv_prev_days_override() or 0)
except (TypeError, ValueError):
override = 0
if override > 0:
return max(0, min(override, MAX_AUTO_PREV_DAYS))
if auto_detect_fallback:
return compute_provider_archive_days_capped()
return 0

View file

@ -1,6 +1,7 @@
"""URL builders and timestamp helpers for XC catch-up."""
import logging
import math
import re
from collections import namedtuple
from datetime import datetime, timezone
@ -339,3 +340,51 @@ def format_timestamp_as_underscore(timestamp):
def format_timestamp_as_sql_datetime(timestamp):
"""Reshape to ``YYYY-MM-DD HH:MM:SS`` without timezone conversion."""
return _reshape_timestamp(timestamp, "%Y-%m-%d %H:%M:%S", "SQL")
def programme_age_days(timestamp_str, *, now=None):
"""Archive depth in whole days needed to cover a catch-up start timestamp.
Returns:
``None`` if the timestamp cannot be parsed, ``0`` if start is at/after
*now*, otherwise ``ceil(elapsed / 86400)`` (at least 1).
"""
dt = parse_catchup_timestamp(timestamp_str)
if dt is None:
return None
if now is None:
now = datetime.now(timezone.utc).replace(tzinfo=None)
elif getattr(now, "tzinfo", None) is not None:
now = now.astimezone(timezone.utc).replace(tzinfo=None)
elapsed = (now - dt).total_seconds()
if elapsed <= 0:
return 0
return max(1, math.ceil(elapsed / 86400.0))
def order_catchup_streams_for_timestamp(streams, timestamp_str, *, now=None):
"""Prefer streams whose ``catchup_days`` cover the programme age.
Relative channel order is preserved within the preferred and fallback
groups. Streams with unknown/zero ``catchup_days`` stay preferred so
incomplete provider metadata does not skip a possible server. Unparseable
timestamps leave the input order unchanged.
"""
age = programme_age_days(timestamp_str, now=now)
if age is None:
return list(streams)
preferred = []
fallback = []
for stream in streams:
raw = getattr(stream, "catchup_days", None)
try:
days = int(raw) if raw is not None else 0
except (TypeError, ValueError):
days = 0
if days <= 0 or days >= age:
preferred.append(stream)
else:
fallback.append(stream)
return preferred + fallback

View file

@ -15,7 +15,9 @@ from apps.timeshift.helpers import (
format_timestamp_as_sql_datetime,
format_timestamp_as_underscore,
normalize_catchup_timestamp_input,
order_catchup_streams_for_timestamp,
parse_catchup_timestamp,
programme_age_days,
)
@ -335,3 +337,78 @@ class GetProgrammeDurationTests(TestCase):
from unittest.mock import MagicMock
from apps.timeshift.helpers import get_programme_duration
self.assertEqual(get_programme_duration(MagicMock(), "garbage"), 120)
class ProgrammeAgeAndStreamOrderTests(TestCase):
"""Archive-age helpers used to prefer deep catch-up providers first."""
def test_programme_age_days_ceil(self):
now = datetime(2026, 7, 16, 12, 0, 0)
self.assertEqual(
programme_age_days("2026-07-12:12-00", now=now),
4,
)
self.assertEqual(
programme_age_days("2026-07-15:12-00", now=now),
1,
)
self.assertEqual(
programme_age_days("2026-07-16:12-00", now=now),
0,
)
def test_programme_age_days_unparseable(self):
self.assertIsNone(programme_age_days("garbage"))
def test_order_prefers_covering_streams_then_fallback(self):
from types import SimpleNamespace
now = datetime(2026, 7, 16, 12, 0, 0)
streams = [
SimpleNamespace(catchup_days=2, name="p1"),
SimpleNamespace(catchup_days=1, name="p2"),
SimpleNamespace(catchup_days=5, name="p3"),
]
ordered = order_catchup_streams_for_timestamp(
streams, "2026-07-12:12-00", now=now
)
self.assertEqual([s.name for s in ordered], ["p3", "p1", "p2"])
def test_order_keeps_channel_order_within_groups(self):
from types import SimpleNamespace
now = datetime(2026, 7, 16, 12, 0, 0)
streams = [
SimpleNamespace(catchup_days=7, name="a"),
SimpleNamespace(catchup_days=2, name="b"),
SimpleNamespace(catchup_days=14, name="c"),
SimpleNamespace(catchup_days=1, name="d"),
]
ordered = order_catchup_streams_for_timestamp(
streams, "2026-07-12:12-00", now=now
)
self.assertEqual([s.name for s in ordered], ["a", "c", "b", "d"])
def test_unknown_catchup_days_stay_preferred(self):
from types import SimpleNamespace
now = datetime(2026, 7, 16, 12, 0, 0)
streams = [
SimpleNamespace(catchup_days=2, name="short"),
SimpleNamespace(catchup_days=0, name="unknown"),
SimpleNamespace(catchup_days=5, name="deep"),
]
ordered = order_catchup_streams_for_timestamp(
streams, "2026-07-12:12-00", now=now
)
self.assertEqual([s.name for s in ordered], ["unknown", "deep", "short"])
def test_unparseable_timestamp_preserves_order(self):
from types import SimpleNamespace
streams = [
SimpleNamespace(catchup_days=2, name="a"),
SimpleNamespace(catchup_days=5, name="b"),
]
ordered = order_catchup_streams_for_timestamp(streams, "garbage")
self.assertEqual([s.name for s in ordered], ["a", "b"])

View file

@ -531,12 +531,12 @@ class RedactUrlTests(TestCase):
def _make_catchup_stream(provider_tz="Europe/Brussels", *, account_id=9,
stream_id="22372", account_type="XC", profile_id=31,
extra_profiles=()):
extra_profiles=(), catchup_days=0):
"""Build a mocked catch-up Stream with its own provider context.
The default (tz-bearing) profile leads the active-profile list the view
walks; ``extra_profiles`` appends alternate (non-default) profiles for
capacity-walk tests.
capacity-walk tests. ``catchup_days`` defaults to 0 (unknown archive depth).
"""
profile = MagicMock()
profile.id = profile_id
@ -550,6 +550,7 @@ def _make_catchup_stream(provider_tz="Europe/Brussels", *, account_id=9,
stream.m3u_account = m3u_account
stream.m3u_account_id = account_id
stream.custom_properties = {"stream_id": stream_id} if stream_id else {}
stream.catchup_days = catchup_days
return stream
@ -1022,6 +1023,38 @@ class TimeshiftProxyFailoverTests(TestCase):
self.assertIs(response, passthrough)
self.assertEqual(stream_mock.call_count, 1)
@patch("apps.timeshift.helpers.programme_age_days", return_value=4)
def test_prefers_stream_with_sufficient_catchup_days(self, _age):
"""Deep archive is tried first when earlier streams are too short."""
streams = [
_make_catchup_stream(account_id=1, stream_id="111", catchup_days=2),
_make_catchup_stream(account_id=2, stream_id="222", catchup_days=1),
_make_catchup_stream(account_id=3, stream_id="333", catchup_days=5),
]
ok = MagicMock(status_code=200)
response, stream_mock, build_mock, _ = self._call(streams, [ok])
self.assertIs(response, ok)
self.assertEqual(stream_mock.call_count, 1)
self.assertEqual(stream_mock.call_args.kwargs["account_id"], 3)
self.assertEqual(build_mock.call_args.args[1], "333")
@patch("apps.timeshift.helpers.programme_age_days", return_value=4)
def test_falls_back_to_shorter_archives_when_preferred_fail(self, _age):
streams = [
_make_catchup_stream(account_id=1, stream_id="111", catchup_days=2),
_make_catchup_stream(account_id=2, stream_id="222", catchup_days=1),
_make_catchup_stream(account_id=3, stream_id="333", catchup_days=5),
]
ok = MagicMock(status_code=200)
response, stream_mock, _, _ = self._call(
streams, [MagicMock(status_code=404), ok]
)
self.assertIs(response, ok)
self.assertEqual(
[c.kwargs["account_id"] for c in stream_mock.call_args_list],
[3, 1],
)
class _ProxyLoopTestMixin:
"""Shared driver for tests exercising the failover loop end to end -

View file

@ -59,6 +59,7 @@ from .helpers import (
build_timeshift_candidate_urls,
convert_timestamp_to_provider_tz,
get_programme_duration,
order_catchup_streams_for_timestamp,
parse_catchup_timestamp,
)
from .sessions import catchup_session_exists, delete_catchup_session, resolve_catchup_playback
@ -298,6 +299,8 @@ def _serve_catchup(request, user, channel, timestamp):
HttpResponseBadRequest("Timeshift not supported for this channel")
)
catchup_streams = order_catchup_streams_for_timestamp(catchup_streams, timestamp)
debug = logger.isEnabledFor(logging.DEBUG)
# EPG duration lookup stays in UTC; provider TZ conversion is per-attempt below.

View file

@ -280,18 +280,8 @@ class CoreSettings(models.Model):
"epg_match_ignore_prefixes": [],
"epg_match_ignore_suffixes": [],
"epg_match_ignore_custom": [],
# XC catch-up: forced XMLTV lookback (0 = auto-detect, capped at 30).
"xmltv_prev_days_override": 0,
})
@classmethod
def get_xmltv_prev_days_override(cls):
"""Global XC XMLTV prev_days default (0 = auto-detect from provider archives)."""
try:
return int(cls.get_epg_settings().get("xmltv_prev_days_override", 0) or 0)
except (TypeError, ValueError):
return 0
@classmethod
def _safe_string_list(cls, value):
"""Return a list of strings, filtering out non-list or non-string values."""

View file

@ -1,74 +0,0 @@
import useSettingsStore from '../../../store/settings.jsx';
import React, { useEffect, useState } from 'react';
import { useForm } from '@mantine/form';
import { Alert, Button, Flex, NumberInput, Stack, Text } from '@mantine/core';
import { EPG_SETTINGS_OPTIONS } from '../../../constants.js';
import {
getChangedSettings,
parseSettings,
saveChangedSettings,
} from '../../../utils/pages/SettingsUtils.js';
import { getEpgSettingsFormInitialValues } from '../../../utils/forms/settings/EpgSettingsFormUtils.js';
const EpgSettingsForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
const [saved, setSaved] = useState(false);
const form = useForm({
mode: 'controlled',
initialValues: getEpgSettingsFormInitialValues(),
});
useEffect(() => {
if (!active) setSaved(false);
}, [active]);
useEffect(() => {
if (settings) {
const parsed = parseSettings(settings);
form.setFieldValue(
'xmltv_prev_days_override',
parsed.xmltv_prev_days_override ?? 0
);
}
}, [settings]);
const onSubmit = async () => {
setSaved(false);
const changedSettings = getChangedSettings(form.getValues(), settings);
try {
await saveChangedSettings(settings, changedSettings);
setSaved(true);
} catch (error) {
console.error('Error saving EPG settings:', error);
}
};
const prevDaysConfig = EPG_SETTINGS_OPTIONS.xmltv_prev_days_override;
return (
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap="md">
{saved && (
<Alert variant="light" color="green" title="Saved Successfully" />
)}
<NumberInput
label={prevDaysConfig.label}
description={prevDaysConfig.description}
min={0}
max={30}
{...form.getInputProps('xmltv_prev_days_override')}
/>
<Text size="xs" c="dimmed">
Per-user defaults and URL parameters still override this global value.
EPG channel matching options are configured from the Channels page.
</Text>
<Flex justify="flex-end">
<Button type="submit">Save</Button>
</Flex>
</Stack>
</form>
);
});
export default EpgSettingsForm;

View file

@ -1,417 +0,0 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import EpgSettingsForm from '../EpgSettingsForm';
// Store mock
vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() }));
// @mantine/form mock
vi.mock('@mantine/form', () => ({ useForm: vi.fn() }));
// @mantine/core mock
vi.mock('@mantine/core', () => ({
Alert: ({ title, color, variant }) => (
<div data-testid="alert" data-color={color} data-variant={variant}>
{title}
</div>
),
Button: ({ children, type, disabled }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Flex: ({ children, justify }) => <div data-justify={justify}>{children}</div>,
NumberInput: ({ label, description, min, max, value, onChange }) => (
<div>
{label && <label data-testid="number-input-label">{label}</label>}
{description && (
<span data-testid="number-input-description">{description}</span>
)}
<input
data-testid="number-input"
type="number"
min={min}
max={max}
value={value ?? 0}
onChange={(e) => onChange?.(Number(e.target.value))}
/>
</div>
),
Stack: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
Text: ({ children, size, c }) => (
<span data-testid="text" data-size={size} data-color={c}>
{children}
</span>
),
}));
// SettingsUtils mock
vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({
getChangedSettings: vi.fn(),
parseSettings: vi.fn(),
saveChangedSettings: vi.fn(),
}));
// EpgSettingsFormUtils mock
vi.mock('../../../../utils/forms/settings/EpgSettingsFormUtils.js', () => ({
getEpgSettingsFormInitialValues: vi.fn(() => ({
xmltv_prev_days_override: 0,
})),
}));
//
// Imports after mocks
//
import useSettingsStore from '../../../../store/settings.jsx';
import { useForm } from '@mantine/form';
import * as SettingsUtils from '../../../../utils/pages/SettingsUtils.js';
import { getEpgSettingsFormInitialValues } from '../../../../utils/forms/settings/EpgSettingsFormUtils.js';
import { EPG_SETTINGS_OPTIONS } from '../../../../constants.js';
// Form mock factory
let mockForm;
const createMockForm = (initialValues = { xmltv_prev_days_override: 0 }) => {
const state = { ...initialValues };
return {
values: state,
setFieldValue: vi.fn((field, value) => {
state[field] = value;
}),
getInputProps: vi.fn((field) => ({
value: state[field],
onChange: vi.fn((val) => {
state[field] = val;
}),
})),
getValues: vi.fn(() => ({ ...state })),
onSubmit: vi.fn((handler) => (e) => {
e?.preventDefault?.();
return handler();
}),
submitting: false,
};
};
// Settings factories
const makeSettings = (epgValue = {}) => ({
epg_settings: { id: 1, value: epgValue },
});
//
describe('EpgSettingsForm', () => {
beforeEach(() => {
vi.clearAllMocks();
mockForm = createMockForm();
vi.mocked(useForm).mockReturnValue(mockForm);
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: null })
);
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
xmltv_prev_days_override: 0,
});
vi.mocked(SettingsUtils.getChangedSettings).mockReturnValue({});
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
});
// Rendering
describe('rendering', () => {
it('renders without crashing', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toBeInTheDocument();
});
it('renders the NumberInput label from EPG_SETTINGS_OPTIONS', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.label)
).toBeInTheDocument();
});
it('renders the NumberInput description from EPG_SETTINGS_OPTIONS', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(
EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.description
)
).toBeInTheDocument();
});
it('renders the disclaimer text about per-user defaults', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(
/Per-user defaults and URL parameters still override this global value/i
)
).toBeInTheDocument();
});
it('renders the EPG channel matching hint', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(
/EPG channel matching options are configured from the Channels page/i
)
).toBeInTheDocument();
});
it('renders a Save button of type="submit"', () => {
render(<EpgSettingsForm active={true} />);
const btn = screen.getByText('Save');
expect(btn).toBeInTheDocument();
expect(btn).toHaveAttribute('type', 'submit');
});
it('does not show the "Saved Successfully" alert initially', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('NumberInput has min=0', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toHaveAttribute('min', '0');
});
it('NumberInput has max=30', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toHaveAttribute('max', '30');
});
it('NumberInput starts with value 0 from initial form values', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toHaveValue(0);
});
});
// Initialization
describe('initialization', () => {
it('calls getEpgSettingsFormInitialValues to seed useForm', () => {
render(<EpgSettingsForm active={true} />);
expect(getEpgSettingsFormInitialValues).toHaveBeenCalled();
});
it('passes initial values from getEpgSettingsFormInitialValues to useForm', () => {
vi.mocked(getEpgSettingsFormInitialValues).mockReturnValue({
xmltv_prev_days_override: 5,
});
render(<EpgSettingsForm active={true} />);
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
expect.objectContaining({
initialValues: { xmltv_prev_days_override: 5 },
})
);
});
it('calls useForm with mode="controlled"', () => {
render(<EpgSettingsForm active={true} />);
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'controlled' })
);
});
});
// Settings effect
describe('settings effect', () => {
it('calls parseSettings when settings are provided', () => {
const settings = makeSettings({ xmltv_prev_days_override: 7 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
xmltv_prev_days_override: 7,
});
render(<EpgSettingsForm active={true} />);
expect(SettingsUtils.parseSettings).toHaveBeenCalledWith(settings);
});
it('calls setFieldValue with parsed xmltv_prev_days_override', () => {
const settings = makeSettings({ xmltv_prev_days_override: 14 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
xmltv_prev_days_override: 14,
});
render(<EpgSettingsForm active={true} />);
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
'xmltv_prev_days_override',
14
);
});
it('calls setFieldValue with 0 when parsed value is undefined', () => {
const settings = makeSettings({});
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({});
render(<EpgSettingsForm active={true} />);
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
'xmltv_prev_days_override',
0
);
});
it('does not call parseSettings when settings is null', () => {
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: null })
);
render(<EpgSettingsForm active={true} />);
expect(SettingsUtils.parseSettings).not.toHaveBeenCalled();
});
});
// active prop effect
describe('active prop effect', () => {
it('resets the saved alert when active changes to false', async () => {
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
const { rerender } = render(<EpgSettingsForm active={true} />);
// Trigger a successful save to get saved=true
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
// Switching active to false should dismiss the alert
rerender(<EpgSettingsForm active={false} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('does not show alert when active starts as false', () => {
render(<EpgSettingsForm active={false} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
// Submit success
describe('submit — success', () => {
beforeEach(() => {
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
});
it('calls getChangedSettings with form values and settings on submit', async () => {
const settings = makeSettings({ xmltv_prev_days_override: 3 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
render(<EpgSettingsForm active={true} />);
// Set the value after render so the settings useEffect (which resets the
// field to the parseSettings result) has already run and won't overwrite it.
mockForm.values.xmltv_prev_days_override = 5;
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(SettingsUtils.getChangedSettings).toHaveBeenCalledWith(
expect.objectContaining({ xmltv_prev_days_override: 5 }),
settings
);
});
});
it('calls saveChangedSettings with settings and changedSettings on submit', async () => {
const settings = makeSettings({ xmltv_prev_days_override: 3 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
const changed = { xmltv_prev_days_override: 7 };
vi.mocked(SettingsUtils.getChangedSettings).mockReturnValue(changed);
render(<EpgSettingsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(SettingsUtils.saveChangedSettings).toHaveBeenCalledWith(
settings,
changed
);
});
});
it('shows "Saved Successfully" alert after a successful save', async () => {
render(<EpgSettingsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
expect(screen.getByText('Saved Successfully')).toBeInTheDocument();
});
});
it('clears saved state before re-submitting', async () => {
render(<EpgSettingsForm active={true} />);
// First save
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
// Second save saved resets to false then true again
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
});
});
// Submit error
describe('submit — error', () => {
it('does not show alert when saveChangedSettings throws', async () => {
vi.mocked(SettingsUtils.saveChangedSettings).mockRejectedValue(
new Error('network error')
);
render(<EpgSettingsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(SettingsUtils.saveChangedSettings).toHaveBeenCalled();
});
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('does not throw when saveChangedSettings rejects', async () => {
vi.mocked(SettingsUtils.saveChangedSettings).mockRejectedValue(
new Error('fail')
);
render(<EpgSettingsForm active={true} />);
await expect(
waitFor(() => fireEvent.click(screen.getByText('Save')))
).resolves.not.toThrow();
});
});
// getInputProps wiring
describe('getInputProps wiring', () => {
it('calls form.getInputProps with xmltv_prev_days_override', () => {
render(<EpgSettingsForm active={true} />);
expect(mockForm.getInputProps).toHaveBeenCalledWith(
'xmltv_prev_days_override'
);
});
});
});

View file

@ -71,14 +71,6 @@ export const PROXY_SETTINGS_OPTIONS = {
},
};
export const EPG_SETTINGS_OPTIONS = {
xmltv_prev_days_override: {
label: 'XMLTV prev_days Override (catch-up)',
description:
'Days of past programmes in the XC EPG output. 0 = auto-detect from the providers tv_archive_duration (capped at 30).',
},
};
export const USER_LIMITS_OPTIONS = {
terminate_on_limit_exceeded: {
label: 'Terminate on Limit Exceeded',

View file

@ -45,9 +45,6 @@ const DvrSettingsForm = React.lazy(
const SystemSettingsForm = React.lazy(
() => import('../components/forms/settings/SystemSettingsForm.jsx')
);
const EpgSettingsForm = React.lazy(
() => import('../components/forms/settings/EpgSettingsForm.jsx')
);
const NavOrderForm = React.lazy(
() => import('../components/forms/settings/NavOrderForm.jsx')
);
@ -125,19 +122,6 @@ const SettingsPage = () => {
</AccordionPanel>
</AccordionItem>
<AccordionItem value="epg-settings">
<AccordionControl>EPG</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<EpgSettingsForm
active={accordianValue === 'epg-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="system-settings">
<AccordionControl>System Settings</AccordionControl>
<AccordionPanel>

View file

@ -1,3 +0,0 @@
export const getEpgSettingsFormInitialValues = () => ({
xmltv_prev_days_override: 0,
});

View file

@ -39,7 +39,6 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'epg_match_ignore_prefixes',
'epg_match_ignore_suffixes',
'epg_match_ignore_custom',
'xmltv_prev_days_override',
];
const dvrFields = [
'tv_template',
@ -126,7 +125,6 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'retention_count',
'schedule_day_of_week',
'max_system_events',
'xmltv_prev_days_override',
];
if (numericFields.includes(formKey) && value != null) {
value = typeof value === 'number' ? value : parseInt(value, 10);
@ -224,20 +222,6 @@ export const getChangedSettings = (values, settings) => {
continue;
}
if (settingKey === 'xmltv_prev_days_override') {
const baseline = Number(
settings['epg_settings']?.value?.xmltv_prev_days_override ?? 0,
);
const nextVal =
typeof actualValue === 'number'
? actualValue
: parseInt(actualValue, 10) || 0;
if (nextVal !== baseline) {
changedSettings[settingKey] = nextVal;
}
continue;
}
// Convert array values (like m3u_hash_key) to comma-separated strings for comparison
if (Array.isArray(actualValue)) {
actualValue = actualValue.join(',');
@ -312,12 +296,6 @@ export const parseSettings = (settings) => {
epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom)
? epgSettings.epg_match_ignore_custom
: [];
parsed.xmltv_prev_days_override =
epgSettings && epgSettings.xmltv_prev_days_override != null
? typeof epgSettings.xmltv_prev_days_override === 'number'
? epgSettings.xmltv_prev_days_override
: parseInt(epgSettings.xmltv_prev_days_override, 10) || 0
: 0;
// DVR settings - direct mapping with underscore keys
const dvrSettings = settings['dvr_settings']?.value;