Splitting up LiveGroupFilter component

Added AutoSyncBasic, AutoSyncAdvanced, AutoSyncOrphanCleanup.
This commit is contained in:
Nick Sandstrom 2026-05-11 20:40:55 -07:00
parent d824cd1646
commit 64f19237f9
14 changed files with 4333 additions and 1779 deletions

View file

@ -0,0 +1,756 @@
// Shared preview box for include and exclude filters. The marker and
// color reflect whether matched names are kept (teal check) or dropped
// (red x); empty/loading/error states mirror the find preview.
import {
Box,
Button,
Center,
Checkbox,
Divider,
Flex,
Group,
MultiSelect,
Popover,
PopoverDropdown,
PopoverTarget,
ScrollArea,
Select,
Stack,
Switch,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import { getRegexOptions } from '../../utils/forms/LiveGroupFilterUtils.js';
import React from 'react';
import useChannelsStore from '../../store/channels.jsx';
import { FixedSizeList as List } from 'react-window';
import logo from '../../images/logo.png';
import LazyLogo from '../LazyLogo.jsx';
import { RefreshCw } from 'lucide-react';
import { showNotification } from '../../utils/notificationUtils.js';
import {
formatPreviewSummary,
getEpgSourceData,
getEpgSourceValue,
repackGroupChannels,
} from '../../utils/forms/AutoSyncAdvancedUtils.js';
const RegexPreviewBox = ({ group, kind, regexPreviewState }) => {
const pattern =
kind === 'exclude'
? group.custom_properties?.name_match_exclude_regex || ''
: kind === 'include'
? group.custom_properties?.name_match_regex || ''
: group.custom_properties?.name_regex_pattern || '';
if (!pattern) return null;
const state = regexPreviewState[group.channel_group] || {};
const result =
kind === 'exclude'
? state.excludeResult
: kind === 'include'
? state.filterResult
: state.findResult;
const loading = state.loading;
const summaryLabel =
kind === 'exclude' ? 'exclude' : kind === 'include' ? 'filter' : 'rename';
const placeholderLabel =
kind === 'exclude'
? 'Exclude preview'
: kind === 'include'
? 'Filter preview'
: 'Preview';
const markerChar = kind === 'exclude' ? '✗' : '✓';
const markerColor = kind === 'exclude' ? 'red.4' : 'teal.4';
const emptyText =
kind === 'exclude'
? 'No streams matched this pattern (nothing would be excluded).'
: 'No streams matched this pattern.';
return (
<Box
style={{
border: '1px solid #3F3F46',
borderRadius: 6,
padding: 8,
backgroundColor: '#1E1E22',
}}
>
<Text size="xs" fw={600} mb={4}>
{result ? formatPreviewSummary(summaryLabel, result) : placeholderLabel}
</Text>
{result?.error && (
<Text size="xs" c="red.5">
Invalid regex: {result.error}
</Text>
)}
{loading && !result && (
<Text size="xs" c="dimmed">
Scanning streams...
</Text>
)}
{result && !result.error && result.matches?.length === 0 && (
<Text size="xs" c="dimmed">
{result.total_in_group === 0
? 'No streams in this group yet. Run an M3U refresh first to populate streams.'
: emptyText}
</Text>
)}
{result?.matches?.map((row, idx) =>
kind === 'find' ? (
<Flex
key={`${row.before}-${idx}`}
gap="xs"
align="center"
style={{ fontFamily: 'monospace' }}
>
<Text size="xs" c="dimmed" style={{ flex: 1 }} truncate>
{row.before}
</Text>
<Text size="xs" c="gray.5">
{' -> '}
</Text>
<Text size="xs" c="teal.4" style={{ flex: 1 }} truncate>
{row.after}
</Text>
</Flex>
) : (
<Flex
key={`${row.name}-${idx}`}
gap="xs"
align="center"
style={{ fontFamily: 'monospace' }}
>
<Text size="xs" c={markerColor} style={{ width: 18 }}>
{markerChar}
</Text>
<Text size="xs" c="gray.2" style={{ flex: 1 }} truncate>
{row.name}
</Text>
</Flex>
)
)}
</Box>
);
};
// Advanced Options form rendered inside the gear modal. A field's
// presence in custom_properties activates it; blanking returns the
// group to default behavior.
const AutoSyncAdvanced = ({
group,
epgSources,
channelGroups,
streamProfiles,
regexPreviewState,
onApplyGroupChange,
onScheduleRegexPreview,
onOpenLogoUpload,
channelLogos,
playlist,
logosLoading,
ensureLogosLoaded,
}) => {
const cp = group.custom_properties || {};
const profiles = useChannelsStore((s) => s.profiles);
const setCp = (patch, clears = []) => {
const next = { ...cp, ...patch };
clears.forEach((k) => delete next[k]);
onApplyGroupChange({ ...group, custom_properties: next });
};
// --- Name Transforms ---
const findValue = cp.name_regex_pattern ?? '';
const replaceValue = cp.name_replace_pattern ?? '';
const filterValue = cp.name_match_regex ?? '';
const excludeValue = cp.name_match_exclude_regex ?? '';
const updateFind = (val) => {
if (!val && !replaceValue) {
setCp({}, ['name_regex_pattern', 'name_replace_pattern']);
} else {
setCp({
name_regex_pattern: val,
name_replace_pattern: replaceValue,
});
}
onScheduleRegexPreview(
group,
getRegexOptions(val, replaceValue, filterValue, excludeValue)
);
};
const updateReplace = (val) => {
if (!val && !findValue) {
setCp({}, ['name_regex_pattern', 'name_replace_pattern']);
} else {
setCp({
name_regex_pattern: findValue,
name_replace_pattern: val,
});
}
onScheduleRegexPreview(
group,
getRegexOptions(findValue, val, filterValue, excludeValue)
);
};
const updateFilter = (val) => {
if (!val) setCp({}, ['name_match_regex']);
else setCp({ name_match_regex: val });
onScheduleRegexPreview(
group,
getRegexOptions(findValue, replaceValue, val, excludeValue)
);
};
const updateExclude = (val) => {
if (!val) setCp({}, ['name_match_exclude_regex']);
else setCp({ name_match_exclude_regex: val });
onScheduleRegexPreview(
group,
getRegexOptions(findValue, replaceValue, filterValue, val)
);
};
// --- EPG ---
const epgValue = getEpgSourceValue(cp);
const updateEpg = (value) => {
const next = { ...cp };
delete next.custom_epg_id;
delete next.force_dummy_epg;
delete next.force_epg_selected;
if (value === '0') {
next.force_dummy_epg = true;
} else if (value) {
next.custom_epg_id = parseInt(value);
}
onApplyGroupChange({ ...group, custom_properties: next });
};
// --- Channel Assignment ---
const groupOverrideValue = cp.group_override
? cp.group_override.toString()
: '';
const updateGroupOverride = (value) => {
if (!value) setCp({}, ['group_override']);
else setCp({ group_override: parseInt(value) });
};
const profileValue = cp.channel_profile_ids ?? [];
const updateProfiles = (value) => {
if (!value || value.length === 0) {
setCp({}, ['channel_profile_ids']);
} else {
setCp({ channel_profile_ids: value });
}
};
const streamProfileValue = cp.stream_profile_id
? cp.stream_profile_id.toString()
: '';
const updateStreamProfile = (value) => {
if (!value) setCp({}, ['stream_profile_id']);
else setCp({ stream_profile_id: parseInt(value) });
};
const sortOrderValue = cp.channel_sort_order ?? '__default__';
const sortReverseEnabled = cp.channel_sort_order !== undefined;
const updateSortOrder = (value) => {
if (!value || value === '__default__') {
setCp({}, ['channel_sort_order', 'channel_sort_reverse']);
} else {
setCp({
channel_sort_order: value,
channel_sort_reverse: cp.channel_sort_reverse ?? false,
});
}
};
const updateSortReverse = (checked) => {
setCp({ channel_sort_reverse: checked });
};
// --- Custom Logo ---
const logoValue = cp.custom_logo_id;
return (
<Stack gap="lg">
<Stack gap="sm">
<Divider
label={
<Text size="sm" fw={600} c="gray.3">
Name Transforms
</Text>
}
labelPosition="left"
size="sm"
color="gray.6"
/>
<Tooltip
label="Apply a regex find/replace to channel names during sync. Leave both empty to skip."
withArrow
multiline
w={280}
openDelay={500}
>
<Box>
<Flex gap="xs">
<TextInput
label="Find (Regex)"
placeholder="e.g. ^.*? - PPV\\d+ - (.+)$"
value={findValue}
onChange={(e) => updateFind(e.currentTarget.value)}
size="xs"
style={{ flex: 1 }}
/>
<TextInput
label="Replace"
placeholder="e.g. $1"
value={replaceValue}
onChange={(e) => updateReplace(e.currentTarget.value)}
size="xs"
style={{ flex: 1 }}
/>
</Flex>
{findValue && (
<RegexPreviewBox
group={group}
kind="find"
regexPreviewState={regexPreviewState}
/>
)}
</Box>
</Tooltip>
<Flex gap="xs" align="flex-start">
<Tooltip
label="Only include channels whose names match the pattern. Leave empty to include all."
withArrow
multiline
w={280}
openDelay={500}
>
<Box style={{ flex: 1 }}>
<TextInput
label="Include if name matches (Regex)"
placeholder="e.g. ^Sports.*"
value={filterValue}
onChange={(e) => updateFilter(e.currentTarget.value)}
size="xs"
/>
{filterValue && (
<RegexPreviewBox
group={group}
kind="include"
regexPreviewState={regexPreviewState}
/>
)}
</Box>
</Tooltip>
<Tooltip
label="Drop channels whose names match the pattern. Applied after the include filter; useful for removing specific bad streams without rewriting the include pattern."
withArrow
multiline
w={280}
openDelay={500}
>
<Box style={{ flex: 1 }}>
<TextInput
label="Exclude if name matches (Regex)"
placeholder="e.g. TEST|BACKUP"
value={excludeValue}
onChange={(e) => updateExclude(e.currentTarget.value)}
size="xs"
/>
{excludeValue && (
<RegexPreviewBox
group={group}
kind="exclude"
regexPreviewState={regexPreviewState}
/>
)}
</Box>
</Tooltip>
</Flex>
</Stack>
<Stack gap="sm">
<Divider
label={
<Text size="sm" fw={600} c="gray.3">
EPG & Logo
</Text>
}
labelPosition="left"
size="sm"
color="gray.6"
/>
<Flex gap="xs" align="flex-start">
<Tooltip
label="Force a specific EPG source. Defaults to auto-matching by tvg_id when blank."
withArrow
multiline
w={280}
openDelay={500}
>
<Select
label="EPG Source"
placeholder="Auto-match (default)"
value={epgValue || null}
onChange={updateEpg}
data={getEpgSourceData(epgSources)}
clearable
searchable
size="xs"
style={{ flex: 1 }}
/>
</Tooltip>
<Box style={{ flex: 1 }}>
<Group justify="space-between">
<Popover
opened={group.logoPopoverOpened || false}
onChange={(opened) => {
onApplyGroupChange({ ...group, logoPopoverOpened: opened });
if (opened) ensureLogosLoaded();
}}
withArrow
>
<PopoverTarget>
<TextInput
label="Custom Logo"
placeholder="Stream logo (default)"
readOnly
value={logoValue ? channelLogos[logoValue]?.name || '' : ''}
onClick={() =>
onApplyGroupChange({
...group,
logoPopoverOpened: true,
})
}
size="xs"
style={{ flex: 1 }}
/>
</PopoverTarget>
<PopoverDropdown onMouseDown={(e) => e.stopPropagation()}>
<Group>
<TextInput
placeholder="Filter logos..."
size="xs"
value={group.logoFilter || ''}
onChange={(e) =>
onApplyGroupChange({
...group,
logoFilter: e.currentTarget.value,
})
}
/>
{logosLoading && (
<Text size="xs" c="dimmed">
Loading...
</Text>
)}
</Group>
<ScrollArea style={{ height: 200 }}>
{(() => {
const logoOptions = [
{ id: '0', name: 'Default' },
...Object.values(channelLogos),
];
const filteredLogos = logoOptions.filter((logoItem) =>
logoItem.name
.toLowerCase()
.includes((group.logoFilter || '').toLowerCase())
);
if (filteredLogos.length === 0) {
return (
<Center style={{ height: 200 }}>
<Text size="sm" c="dimmed">
{group.logoFilter
? 'No logos match your filter'
: 'No logos available'}
</Text>
</Center>
);
}
return (
<List
height={200}
itemCount={filteredLogos.length}
itemSize={55}
style={{ width: '100%' }}
>
{({ index, style }) => {
const logoItem = filteredLogos[index];
return (
<div
style={{
...style,
cursor: 'pointer',
padding: '5px',
borderRadius: '4px',
}}
onClick={() => {
const next = { ...cp };
if (logoItem.id === '0' || !logoItem.id) {
delete next.custom_logo_id;
} else {
next.custom_logo_id = logoItem.id;
}
onApplyGroupChange({
...group,
custom_properties: next,
logoPopoverOpened: false,
});
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
'rgb(68, 68, 68)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor =
'transparent';
}}
>
<Center
style={{
flexDirection: 'column',
gap: '2px',
}}
>
<img
src={logoItem.cache_url || logo}
height="30"
style={{
maxWidth: 80,
objectFit: 'contain',
}}
alt={logoItem.name || 'Logo'}
onError={(e) => {
if (e.target.src !== logo) {
e.target.src = logo;
}
}}
/>
<Text
size="xs"
c="dimmed"
ta="center"
style={{
maxWidth: 80,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{logoItem.name || 'Default'}
</Text>
</Center>
</div>
);
}}
</List>
);
})()}
</ScrollArea>
</PopoverDropdown>
</Popover>
{logoValue && (
<Stack gap="xs" align="center">
<LazyLogo
logoId={logoValue}
alt="custom logo"
style={{ height: 40 }}
/>
</Stack>
)}
</Group>
<Button
onClick={() => onOpenLogoUpload(group.channel_group)}
variant="subtle"
size="compact-xs"
mt={4}
>
+ Upload new logo
</Button>
</Box>
</Flex>
</Stack>
<Stack gap="sm">
<Divider
label={
<Text size="sm" fw={600} c="gray.3">
Channel Assignment
</Text>
}
labelPosition="left"
size="sm"
color="gray.6"
/>
<Flex gap="xs">
<Tooltip
label="Send auto-created channels into a different group than their source."
withArrow
multiline
w={280}
openDelay={500}
>
<Select
label="Override Channel Group"
placeholder="Source group (default)"
value={groupOverrideValue || null}
onChange={updateGroupOverride}
data={Object.values(channelGroups).map((g) => ({
value: g.id.toString(),
label: g.name,
}))}
clearable
searchable
size="xs"
style={{ flex: 1 }}
/>
</Tooltip>
<Tooltip
label="Limit auto-created channels to specific channel profiles. Defaults to all profiles when blank."
withArrow
multiline
w={280}
openDelay={500}
>
<MultiSelect
label="Channel Profiles"
placeholder="All profiles (default)"
value={profileValue}
onChange={updateProfiles}
data={Object.values(profiles).map((profile) => ({
value: profile.id.toString(),
label: profile.name,
}))}
clearable
searchable
size="xs"
style={{ flex: 1 }}
/>
</Tooltip>
</Flex>
<Flex gap="xs" align="flex-start">
<Tooltip
label="Apply a specific stream profile to channels created by this group."
withArrow
multiline
w={280}
openDelay={500}
>
<Select
label="Stream Profile"
placeholder="Account default"
value={streamProfileValue || null}
onChange={updateStreamProfile}
data={streamProfiles.map((profile) => ({
value: profile.id.toString(),
label: profile.name,
}))}
clearable
searchable
size="xs"
style={{ flex: 1 }}
/>
</Tooltip>
<Tooltip
label="Order channels within the group before assigning numbers."
withArrow
multiline
w={280}
openDelay={500}
>
<Box style={{ flex: 1 }}>
<Select
label="Channel Sort Order"
value={sortOrderValue}
onChange={updateSortOrder}
data={[
{ value: '__default__', label: 'Provider Order (Default)' },
{ value: 'name', label: 'Name' },
{ value: 'tvg_id', label: 'TVG ID' },
{ value: 'updated_at', label: 'Updated At' },
]}
searchable
size="xs"
/>
{sortReverseEnabled && (
<Checkbox
label="Reverse sort order"
checked={cp.channel_sort_reverse || false}
onChange={(event) =>
updateSortReverse(event.currentTarget.checked)
}
size="xs"
mt="xs"
/>
)}
</Box>
</Tooltip>
</Flex>
<Flex align="center" justify="space-between" gap="md" mt="xs">
<Tooltip
label="Visible channels get sequential numbers; hidden channels release theirs. Set a channel number override to preserve channel numbers over time."
withArrow
multiline
w={320}
openDelay={500}
>
<Box style={{ flex: 1, minWidth: 0 }}>
<Switch
label="Compact numbering"
description="Numbers shift on hide/unhide. Pin a number with a channel number override."
checked={!!cp.compact_numbering}
onChange={(event) => {
if (event.currentTarget.checked) {
setCp({ compact_numbering: true });
} else {
setCp({}, ['compact_numbering']);
}
}}
size="xs"
/>
</Box>
</Tooltip>
<Tooltip
label="Re-assign visible channels into the group's current range. Overrides are kept as reservations and not modified."
withArrow
multiline
w={280}
openDelay={500}
>
<Button
variant="subtle"
size="compact-xs"
color="gray"
leftSection={<RefreshCw size={12} />}
onClick={async () => {
const result = await repackGroupChannels(playlist, group);
if (result) {
showNotification({
title: 'Channels renumbered',
message: `Assigned ${result.assigned}, released ${result.released}${
result.failed
? `, ${result.failed} could not fit in the configured range`
: ''
}.`,
color: result.failed ? 'yellow' : 'green',
autoClose: 5000,
});
}
}}
style={{ flexShrink: 0 }}
>
Renumber now
</Button>
</Tooltip>
</Flex>
</Stack>
</Stack>
);
};
export default AutoSyncAdvanced;

View file

@ -0,0 +1,181 @@
// Inline Start/End range inputs for Fixed and Provider modes. Start
// writes to auto_sync_channel_start (Fixed) or
// custom_properties.channel_numbering_fallback (Provider); End always
// writes to auto_sync_channel_end. Next Available shows a one-line
// explanation because a range contradicts pack-anywhere semantics.
import { Flex, NumberInput, Stack, Text, Tooltip } from '@mantine/core';
import { AlertTriangle } from 'lucide-react';
import React from 'react';
import {
clampChannelNumber,
computeRangeOverlapsFor,
} from '../../utils/forms/AutoSyncBasicUtils.js';
const AutoSyncBasic = ({
group,
groupStates,
groupConflicts,
onApplyGroupChange,
}) => {
const mode = group.custom_properties?.channel_numbering_mode || 'fixed';
if (mode === 'next_available') {
return (
<Text size="xs" c="dimmed">
Channels receive the lowest available numbers starting at 1.
</Text>
);
}
const startValue =
mode === 'provider'
? (group.custom_properties?.channel_numbering_fallback ?? 1)
: (group.auto_sync_channel_start ?? 1);
const endValue = group.auto_sync_channel_end;
// Caps pathological pasted values like "1e308" at the input layer.
const updateStart = (value) => {
const normalized =
value === '' || value === null || value === undefined
? 1
: clampChannelNumber(value);
if (mode === 'provider') {
onApplyGroupChange({
...group,
custom_properties: {
...(group.custom_properties || {}),
channel_numbering_fallback: normalized,
},
});
} else {
// If End is set and the new Start exceeds it, drop End so the user
// is not left holding an invalid range silently.
const next = { ...group, auto_sync_channel_start: normalized };
if (
endValue !== null &&
endValue !== undefined &&
normalized > endValue
) {
next.auto_sync_channel_end = null;
}
onApplyGroupChange(next);
}
// Sweep effect picks up the state change and dispatches the scan.
};
const updateEnd = (value) => {
const normalized =
value === '' || value === null || value === undefined
? null
: clampChannelNumber(value);
onApplyGroupChange({
...group,
auto_sync_channel_end: normalized,
});
};
const streamCount =
typeof group.stream_count === 'number' ? group.stream_count : null;
const metaParts = [];
if (endValue) {
metaParts.push(`Range: ${Math.max(endValue - startValue + 1, 0)}`);
}
if (streamCount !== null) {
metaParts.push(`Streams: ${streamCount}`);
}
const metaText = metaParts.join(' · ');
const conflict = groupConflicts[group.channel_group];
const hasChannelConflict = !!conflict?.hasChannelConflict;
const overlaps = computeRangeOverlapsFor(group, groupStates);
// Channel-level conflicts get a generic Channels-page pointer (count
// can be large); range-level overlaps stay specific to the modal.
const tooltipSections = [];
if (hasChannelConflict) {
tooltipSections.push(
'Range conflicts with configured channels.\nView the Channels page to inspect.'
);
}
if (overlaps.length > 0) {
const overlapLines = overlaps
.map((o) => `${o.name} (${o.start}-${o.end})`)
.join('\n ');
tooltipSections.push(
overlaps.length === 1
? `Range overlaps with: ${overlaps[0].name} (${overlaps[0].start}-${overlaps[0].end})`
: `Range overlaps with:\n ${overlapLines}`
);
}
const showWarning = tooltipSections.length > 0;
const tooltipBody = tooltipSections.join('\n\n');
return (
<Stack gap={4}>
<Flex gap="xs" align="flex-start">
<Tooltip
label={
mode === 'provider'
? 'Fallback channel number used when a stream has no provider-supplied number.'
: 'First channel number assigned to this group.'
}
withArrow
multiline
w={260}
>
<NumberInput
label="Start #"
value={startValue}
onChange={updateStart}
min={1}
step={1}
size="xs"
precision={0}
style={{ flex: 1 }}
/>
</Tooltip>
<Tooltip
label="Optional upper bound. Streams exceeding the range are skipped and reported after sync."
withArrow
multiline
w={260}
>
<NumberInput
label="End # (optional)"
placeholder="Unlimited"
value={endValue ?? ''}
onChange={updateEnd}
min={startValue || 1}
step={1}
size="xs"
precision={0}
style={{ flex: 1 }}
/>
</Tooltip>
</Flex>
<Flex
gap="xs"
align="center"
justify="space-between"
style={{ minHeight: 18 }}
>
<Text size="xs" c="dimmed">
{metaText}
</Text>
{showWarning && (
<Tooltip
label={tooltipBody}
withArrow
multiline
w={280}
styles={{ tooltip: { whiteSpace: 'pre-line' } }}
>
<AlertTriangle
size={14}
color="var(--mantine-color-yellow-6)"
aria-label="Range conflict warning"
/>
</Tooltip>
)}
</Flex>
</Stack>
);
};
export default AutoSyncBasic;

View file

@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react';
import { updatePlaylist } from '../../utils/forms/M3uUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
import { Box, Group, SegmentedControl, Text, Tooltip } from '@mantine/core';
const OrphanCleanupControl = ({ playlist }) => {
// Local state mirrors the persisted mode so the SegmentedControl
// reflects clicks immediately even when the parent's playlist prop is
// a stale snapshot from before the PATCH lands.
const [orphanCleanupMode, setOrphanCleanupMode] = useState(
(playlist?.custom_properties || {}).orphan_channel_cleanup || 'always'
);
useEffect(() => {
setOrphanCleanupMode(
(playlist?.custom_properties || {}).orphan_channel_cleanup || 'always'
);
}, [playlist?.id, playlist?.custom_properties?.orphan_channel_cleanup]);
const handleOrphanCleanupChange = async (mode) => {
if (!playlist?.id) return;
const previousMode = orphanCleanupMode;
setOrphanCleanupMode(mode);
const nextProps = {
...(playlist.custom_properties || {}),
orphan_channel_cleanup: mode,
};
try {
await updatePlaylist(playlist, { custom_properties: nextProps });
} catch (err) {
setOrphanCleanupMode(previousMode);
showNotification({
title: 'Failed to update cleanup mode',
message: err?.body?.detail || err?.message || 'Please try again.',
color: 'red',
});
}
};
return (
<Box>
<Group gap="sm" align="center" wrap="nowrap">
<Tooltip
label="Controls what sync does with auto-synced channels whose source streams have been removed from this provider. Manual channels and hidden channels are never affected by this setting."
withArrow
multiline
w={320}
openDelay={400}
>
<Text size="sm" fw={500}>
Auto-sync orphan cleanup
</Text>
</Tooltip>
<SegmentedControl
size="xs"
value={orphanCleanupMode}
onChange={handleOrphanCleanupChange}
data={[
{ label: 'Always remove', value: 'always' },
{ label: 'Preserve customized', value: 'preserve_customized' },
{ label: 'Never remove', value: 'never' },
]}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{orphanCleanupMode === 'always' &&
'Removes any auto-synced channel whose source stream is gone from this provider.'}
{orphanCleanupMode === 'preserve_customized' &&
'Removes orphaned auto-synced channels except those with active overrides.'}
{orphanCleanupMode === 'never' &&
'Keeps all orphaned auto-synced channels. You can clean up manually from the channels page.'}
</Text>
</Box>
);
};
export default OrphanCleanupControl;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,648 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Module mocks
vi.mock('../../../utils/forms/AutoSyncAdvancedUtils.js', () => ({
getEpgSourceValue: vi.fn(() => null),
getEpgSourceData: vi.fn(() => []),
repackGroupChannels: vi.fn(() => Promise.resolve({})),
formatPreviewSummary: vi.fn(() => null),
}));
vi.mock('../../../utils/forms/LiveGroupFilterUtils.js', () => ({
getRegexOptions: vi.fn(() => [
{ value: 'i', label: 'Case insensitive' },
{ value: 'g', label: 'Global' },
]),
}));
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../images/logo.png', () => ({ default: 'logo.png' }));
vi.mock('../../LazyLogo.jsx', () => ({
default: ({ src, alt }) => (
<img data-testid="lazy-logo" src={src} alt={alt} />
),
}));
vi.mock('react-window', () => ({
FixedSizeList: ({ children, itemCount, itemData }) => (
<div data-testid="fixed-size-list">
{Array.from({ length: Math.min(itemCount, 5) }, (_, i) =>
children({ index: i, style: {}, data: itemData })
)}
</div>
),
}));
vi.mock('lucide-react', () => ({
RefreshCw: () => <svg data-testid="icon-refresh-cw" />,
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, style }) => <div style={style}>{children}</div>,
Button: ({ children, onClick, disabled, loading, variant, size, color }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-variant={variant}
data-size={size}
data-color={color}
data-loading={loading}
>
{children}
</button>
),
Center: ({ children }) => <div data-testid="center">{children}</div>,
Checkbox: ({ checked, onChange, label, 'data-testid': testId }) => (
<label>
<input
type="checkbox"
data-testid={testId || label}
checked={!!checked}
onChange={(e) => onChange?.(e)}
/>
{label}
</label>
),
Divider: () => <hr data-testid="divider" />,
Flex: ({ children }) => <div data-testid="flex">{children}</div>,
Group: ({ children }) => <div data-testid="group">{children}</div>,
MultiSelect: ({ value, onChange, label, data, 'data-testid': testId }) => (
<div>
{label && <label>{label}</label>}
<select
multiple
data-testid={testId || label}
value={value ?? []}
onChange={(e) =>
onChange?.(Array.from(e.target.selectedOptions, (o) => o.value))
}
>
{(data || []).map((opt) => (
<option key={opt.value ?? opt} value={opt.value ?? opt}>
{opt.label ?? opt}
</option>
))}
</select>
</div>
),
Popover: ({ children }) => <div data-testid="popover">{children}</div>,
PopoverDropdown: ({ children }) => (
<div data-testid="popover-dropdown">{children}</div>
),
PopoverTarget: ({ children }) => (
<div data-testid="popover-target">{children}</div>
),
ScrollArea: ({ children }) => <div data-testid="scroll-area">{children}</div>,
Select: ({
value,
onChange,
label,
data,
'data-testid': testId,
clearable,
}) => (
<div>
{label && <label>{label}</label>}
<select
data-testid={testId || label}
value={value ?? ''}
onChange={(e) => onChange?.(e.target.value || null)}
>
{clearable && <option value="">--</option>}
{(data || []).map((opt) => (
<option key={opt.value ?? opt} value={opt.value ?? opt}>
{opt.label ?? opt}
</option>
))}
</select>
</div>
),
Stack: ({ children }) => <div data-testid="stack">{children}</div>,
Switch: ({ checked, onChange, label, 'data-testid': testId }) => (
<label>
<input
type="checkbox"
role="switch"
data-testid={testId || label}
checked={!!checked}
onChange={(e) => onChange?.(e)}
/>
{label}
</label>
),
Text: ({ children, size, c, fw }) => (
<span data-testid="text" data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
TextInput: ({
value,
onChange,
label,
placeholder,
'data-testid': testId,
}) => (
<div>
{label && <label>{label}</label>}
<input
data-testid={testId || label}
type="text"
value={value ?? ''}
placeholder={placeholder}
onChange={(e) => onChange?.(e)}
/>
</div>
),
Tooltip: ({ children, label }) => (
<div data-testid="tooltip" data-label={label}>
{children}
</div>
),
}));
// Imports after mocks
import AutoSyncAdvanced from '../AutoSyncAdvanced';
import useChannelsStore from '../../../store/channels.jsx';
import { showNotification } from '../../../utils/notificationUtils.js';
import {
getEpgSourceValue,
getEpgSourceData,
repackGroupChannels,
formatPreviewSummary,
} from '../../../utils/forms/AutoSyncAdvancedUtils.js';
import { getRegexOptions } from '../../../utils/forms/LiveGroupFilterUtils.js';
// Helpers
const makeGroup = (overrides = {}) => ({
channel_group: 1,
name: 'Sports',
logo_url: null,
auto_sync_enabled: true,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
custom_properties: {},
...overrides,
});
const makeEpgSource = (overrides = {}) => ({
id: 1,
name: 'My EPG',
source_type: 'xmltv',
...overrides,
});
const makePlaylist = (overrides = {}) => ({
id: 5,
custom_properties: {},
...overrides,
});
const defaultProps = (overrides = {}) => ({
group: makeGroup(),
epgSources: [makeEpgSource()],
channelGroups: [],
streamProfiles: [],
regexPreviewState: {},
onApplyGroupChange: vi.fn(),
onScheduleRegexPreview: vi.fn(),
onOpenLogoUpload: vi.fn(),
channelLogos: [],
playlist: makePlaylist(),
logosLoading: false,
ensureLogosLoaded: vi.fn(),
...overrides,
});
const renderComponent = (overrides = {}) => {
const props = defaultProps(overrides);
const utils = render(<AutoSyncAdvanced {...props} />);
return { ...props, ...utils };
};
const setupStore = () => {
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ fetchGroups: vi.fn(), profiles: {} })
);
};
//
describe('AutoSyncAdvanced', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getEpgSourceValue).mockReturnValue(null);
vi.mocked(getEpgSourceData).mockReturnValue([
{ value: '1', label: 'My EPG (xmltv)' },
]);
vi.mocked(repackGroupChannels).mockResolvedValue({});
vi.mocked(formatPreviewSummary).mockReturnValue(null);
setupStore();
});
// Basic rendering
describe('basic rendering', () => {
it('renders without crashing', () => {
renderComponent();
expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument();
});
it('calls getEpgSourceData with epgSources on render', () => {
const epgSources = [
makeEpgSource(),
makeEpgSource({ id: 2, name: 'EPG 2' }),
];
renderComponent({ epgSources });
expect(getEpgSourceData).toHaveBeenCalledWith(epgSources);
});
it('calls getEpgSourceValue with group.custom_properties on render', () => {
const group = makeGroup();
const epgSources = [makeEpgSource()];
renderComponent({ group, epgSources });
expect(getEpgSourceValue).toHaveBeenCalledWith({});
});
});
// RegexPreviewBox
describe('RegexPreviewBox', () => {
it('does not render preview box when no include regex is set', () => {
renderComponent({ group: makeGroup({ custom_properties: {} }) });
expect(screen.queryByText(/matched/i)).not.toBeInTheDocument();
});
it('renders include preview box when name_match_regex is set', () => {
const group = makeGroup({
custom_properties: { name_match_regex: 'ESPN' },
});
const regexPreviewState = {
1: {
filterResult: {
match_count: 3,
total_scanned: 50,
scan_limit_hit: false,
},
loading: false,
},
};
vi.mocked(formatPreviewSummary).mockReturnValue(
'3 filter matches in 50 streams'
);
renderComponent({ group, regexPreviewState });
expect(
screen.getByText('3 filter matches in 50 streams')
).toBeInTheDocument();
});
it('renders exclude preview box when name_match_exclude_regex is set', () => {
const group = makeGroup({
custom_properties: { name_match_exclude_regex: 'HD' },
});
const regexPreviewState = {
1: {
excludeResult: {
match_count: 2,
total_scanned: 50,
scan_limit_hit: false,
},
loading: false,
},
};
vi.mocked(formatPreviewSummary).mockReturnValue(
'2 exclude matches in 50 streams'
);
renderComponent({ group, regexPreviewState });
expect(
screen.getByText('2 exclude matches in 50 streams')
).toBeInTheDocument();
});
it('renders find preview box when name_regex_pattern is set', () => {
const group = makeGroup({
custom_properties: {
name_regex_pattern: 'News',
name_replace_pattern: '',
},
});
const regexPreviewState = {
1: {
findResult: {
matches: [
{ before: 'BBC News', after: 'BBC' },
{ before: 'Sky News', after: 'Sky' },
{ before: 'CNN News', after: 'CNN' },
{ before: 'Fox News', after: 'Fox' },
{ before: 'Al Jazeera News', after: 'Al Jazeera' },
],
total_in_group: 50,
},
loading: false,
},
};
vi.mocked(formatPreviewSummary).mockReturnValue(
'5 regex matches in 50 streams'
);
renderComponent({ group, regexPreviewState });
expect(
screen.getByText('5 regex matches in 50 streams')
).toBeInTheDocument();
expect(formatPreviewSummary).toHaveBeenCalledWith(
'rename',
regexPreviewState[1].findResult
);
});
it('shows loading state in preview box when state.loading is true', () => {
const group = makeGroup({
custom_properties: { name_match_regex: 'Sports' },
});
const regexPreviewState = { 1: { loading: true } };
renderComponent({ group, regexPreviewState });
// No summary rendered during loading
expect(formatPreviewSummary).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ match_count: expect.any(Number) })
);
});
it('does not render include preview when name_match_regex is empty string', () => {
const group = makeGroup({
custom_properties: { name_match_regex: '' },
});
renderComponent({ group, regexPreviewState: {} });
expect(formatPreviewSummary).not.toHaveBeenCalled();
});
it('renders regex error message when result has an error', () => {
const group = makeGroup({
custom_properties: { name_match_regex: 'bad(' },
});
const regexPreviewState = {
1: { filterResult: { error: 'Unterminated group' }, loading: false },
};
renderComponent({ group, regexPreviewState });
expect(
screen.getByText(/invalid regex.*unterminated group/i)
).toBeInTheDocument();
});
it('renders no-match message when filter result has empty matches', () => {
const group = makeGroup({
custom_properties: { name_match_regex: 'ZZZ' },
});
const regexPreviewState = {
1: {
filterResult: { matches: [], total_in_group: 10, error: null },
loading: false,
},
};
vi.mocked(formatPreviewSummary).mockReturnValue(
'0 filter matches in 10 streams'
);
renderComponent({ group, regexPreviewState });
expect(
screen.getByText(/no streams matched this pattern/i)
).toBeInTheDocument();
});
});
// EPG source select
describe('EPG source select', () => {
it('passes EPG source data from getEpgSourceData to the Select', () => {
vi.mocked(getEpgSourceData).mockReturnValue([
{ value: '1', label: 'My EPG (xmltv)' },
{ value: '2', label: 'EPG 2 (m3u)' },
]);
renderComponent();
const options = screen.getAllByRole('option');
const labels = options.map((o) => o.textContent);
expect(labels).toEqual(
expect.arrayContaining(['My EPG (xmltv)', 'EPG 2 (m3u)'])
);
});
it('pre-selects the value returned by getEpgSourceValue', () => {
vi.mocked(getEpgSourceValue).mockReturnValue('2');
vi.mocked(getEpgSourceData).mockReturnValue([
{ value: '1', label: 'EPG 1' },
{ value: '2', label: 'EPG 2' },
]);
renderComponent();
// The select with value '2' is present
const select = screen
.getAllByRole('combobox')
.find((s) => s.value === '2');
expect(select).toBeTruthy();
});
it('calls onApplyGroupChange when EPG source is changed', () => {
vi.mocked(getEpgSourceData).mockReturnValue([
{ value: '1', label: 'EPG 1' },
{ value: '2', label: 'EPG 2' },
]);
const { onApplyGroupChange } = renderComponent();
const selects = screen.getAllByRole('combobox');
fireEvent.change(selects[0], { target: { value: '2' } });
expect(onApplyGroupChange).toHaveBeenCalled();
});
});
// Regex text inputs
describe('regex text inputs', () => {
it('renders the include regex input', () => {
renderComponent();
// TextInput mocked with label as testid fallback
const inputs = screen.getAllByRole('textbox');
expect(inputs.length).toBeGreaterThan(0);
});
it('calls onScheduleRegexPreview when find regex changes', () => {
const { onScheduleRegexPreview } = renderComponent();
const findInput = screen.getByTestId('Find (Regex)');
fireEvent.change(findInput, { target: { value: 'ESPN' } });
expect(onScheduleRegexPreview).toHaveBeenCalled();
});
it('calls onScheduleRegexPreview when include regex changes', () => {
const { onScheduleRegexPreview } = renderComponent();
const includeInput = screen.getByTestId(
'Include if name matches (Regex)'
);
fireEvent.change(includeInput, { target: { value: 'ESPN' } });
expect(onScheduleRegexPreview).toHaveBeenCalled();
});
});
// Repack channels
describe('repack channels button', () => {
it('calls repackGroupChannels with playlist and group objects', async () => {
vi.mocked(repackGroupChannels).mockResolvedValue({
assigned: 5,
released: 2,
failed: 0,
});
const group = makeGroup({ channel_group: 7 });
const playlist = makePlaylist({ id: 3 });
renderComponent({ group, playlist });
const repackBtn = screen.getByRole('button', { name: /renumber now/i });
fireEvent.click(repackBtn);
await waitFor(() => {
expect(repackGroupChannels).toHaveBeenCalledWith(playlist, group);
});
});
it('shows success notification after repack resolves', async () => {
vi.mocked(repackGroupChannels).mockResolvedValue({});
renderComponent();
const repackBtn = screen.queryByRole('button', { name: /repack/i });
if (repackBtn) {
fireEvent.click(repackBtn);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
color: expect.stringMatching(/teal|green|blue/i),
})
);
});
}
});
it('shows error notification when repack fails', async () => {
vi.mocked(repackGroupChannels).mockRejectedValue(
new Error('Server error')
);
renderComponent();
const repackBtn = screen.queryByRole('button', { name: /repack/i });
if (repackBtn) {
fireEvent.click(repackBtn);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: expect.stringMatching(/red/i) })
);
});
}
});
});
// Logo upload
describe('logo upload', () => {
it('renders logo-related controls', () => {
renderComponent();
const logos = screen.queryAllByTestId('lazy-logo');
expect(logos.length).toBeGreaterThanOrEqual(0);
});
it('calls onOpenLogoUpload when logo upload button is clicked', () => {
const { onOpenLogoUpload } = renderComponent();
const uploadBtn = screen.queryByRole('button', { name: /upload|logo/i });
if (uploadBtn) {
fireEvent.click(uploadBtn);
expect(onOpenLogoUpload).toHaveBeenCalled();
}
});
it('passes channelLogos to the logo selector when provided', () => {
const channelLogos = [
{ id: 'logo1', url: '/logos/espn.png', name: 'ESPN' },
{ id: 'logo2', url: '/logos/cnn.png', name: 'CNN' },
];
renderComponent({ channelLogos });
// Just verify rendering doesn't throw with logos
expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument();
});
});
// Stream profiles
describe('stream profiles', () => {
it('populates profile options from streamProfiles prop', () => {
const streamProfiles = [
{ id: 1, name: 'HD Profile' },
{ id: 2, name: 'SD Profile' },
];
renderComponent({ streamProfiles });
expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument();
});
});
// Custom properties toggles
describe('custom properties toggles', () => {
it('renders switches/checkboxes for advanced options', () => {
renderComponent();
const switches = screen.queryAllByRole('switch');
const checkboxes = screen.queryAllByRole('checkbox');
expect(switches.length + checkboxes.length).toBeGreaterThan(0);
});
it('calls onApplyGroupChange with compact_numbering when switch is toggled on', () => {
const { onApplyGroupChange } = renderComponent();
const compactSwitch = screen.getByTestId('Compact numbering');
fireEvent.click(compactSwitch);
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({
custom_properties: expect.objectContaining({
compact_numbering: true,
}),
})
);
});
it('selects force_dummy_epg option via EPG select', () => {
vi.mocked(getEpgSourceData).mockReturnValue([
{ value: '0', label: 'No EPG' },
{ value: '1', label: 'My EPG' },
]);
const { onApplyGroupChange } = renderComponent();
const selects = screen.getAllByRole('combobox');
fireEvent.change(selects[0], { target: { value: '0' } });
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({
custom_properties: expect.objectContaining({ force_dummy_epg: true }),
})
);
});
});
// Regex options (MultiSelect)
describe('regex options MultiSelect', () => {
it('calls getRegexOptions when find input changes', () => {
renderComponent();
const findInput = screen.getByTestId('Find (Regex)');
fireEvent.change(findInput, { target: { value: 'ESPN' } });
expect(getRegexOptions).toHaveBeenCalled();
});
});
// useEffect / prop sync
describe('prop sync', () => {
it('re-calls getEpgSourceValue when group prop changes', () => {
const { rerender } = render(<AutoSyncAdvanced {...defaultProps()} />);
expect(getEpgSourceValue).toHaveBeenCalledTimes(1);
const newGroup = makeGroup({ channel_group: 2, name: 'News' });
rerender(<AutoSyncAdvanced {...defaultProps({ group: newGroup })} />);
expect(getEpgSourceValue).toHaveBeenCalledTimes(2);
});
it('re-calls getEpgSourceData when epgSources prop changes', () => {
const { rerender } = render(<AutoSyncAdvanced {...defaultProps()} />);
const callCount = vi.mocked(getEpgSourceData).mock.calls.length;
const newSources = [
makeEpgSource(),
makeEpgSource({ id: 99, name: 'New' }),
];
rerender(
<AutoSyncAdvanced {...defaultProps({ epgSources: newSources })} />
);
expect(vi.mocked(getEpgSourceData).mock.calls.length).toBeGreaterThan(
callCount
);
});
});
});

View file

@ -0,0 +1,429 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Utility mocks
vi.mock('../../../utils/forms/AutoSyncBasicUtils.js', () => ({
clampChannelNumber: vi.fn((v) =>
Math.min(999999, Math.max(1, Math.floor(Number(v) || 1)))
),
computeRangeOverlapsFor: vi.fn(() => []),
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Flex: ({ children }) => <div data-testid="flex">{children}</div>,
Stack: ({ children }) => <div data-testid="stack">{children}</div>,
Text: ({ children, size, c }) => (
<span data-testid="text" data-size={size} data-color={c}>
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-testid="tooltip" data-label={label}>
{children}
</div>
),
NumberInput: ({
value,
onChange,
label,
'data-testid': testId,
min,
max,
}) => (
<div>
{label && <label>{label}</label>}
<input
data-testid={testId || label}
type="number"
value={value ?? ''}
min={min}
max={max}
onChange={(e) =>
onChange?.(e.target.value === '' ? '' : Number(e.target.value))
}
/>
</div>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
AlertTriangle: () => <svg data-testid="icon-alert-triangle" />,
}));
// Imports after mocks
import AutoSyncBasic from '../AutoSyncBasic';
import {
clampChannelNumber,
computeRangeOverlapsFor,
} from '../../../utils/forms/AutoSyncBasicUtils.js';
// Helpers
const makeGroup = (overrides = {}) => ({
channel_group: 1,
name: 'Sports',
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
custom_properties: { channel_numbering_mode: 'fixed' },
...overrides,
});
const renderComponent = (groupOverrides = {}, props = {}) => {
const group = makeGroup(groupOverrides);
const onApplyGroupChange = vi.fn();
const utils = render(
<AutoSyncBasic
group={group}
groupStates={[]}
groupConflicts={{}}
onApplyGroupChange={onApplyGroupChange}
{...props}
/>
);
return { group, onApplyGroupChange, ...utils };
};
//
describe('AutoSyncBasic', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(computeRangeOverlapsFor).mockReturnValue([]);
vi.mocked(clampChannelNumber).mockImplementation((v) =>
Math.min(999999, Math.max(1, Math.floor(Number(v) || 1)))
);
});
// next_available mode
describe('next_available mode', () => {
it('renders the explanation text instead of inputs', () => {
renderComponent({
custom_properties: { channel_numbering_mode: 'next_available' },
});
expect(
screen.getByText(/Channels receive the lowest available numbers/i)
).toBeInTheDocument();
});
it('does not render any NumberInput', () => {
renderComponent({
custom_properties: { channel_numbering_mode: 'next_available' },
});
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
});
});
// fixed mode rendering
describe('fixed mode rendering', () => {
it('renders Start and End inputs', () => {
renderComponent();
expect(screen.getByTestId(/start/i)).toBeInTheDocument();
expect(screen.getByTestId(/end/i)).toBeInTheDocument();
});
it('displays auto_sync_channel_start as the start value', () => {
renderComponent({ auto_sync_channel_start: 150 });
expect(screen.getByTestId(/start/i)).toHaveValue(150);
});
it('defaults start to 1 when auto_sync_channel_start is undefined', () => {
renderComponent({ auto_sync_channel_start: undefined });
expect(screen.getByTestId(/start/i)).toHaveValue(1);
});
it('displays auto_sync_channel_end as the end value', () => {
renderComponent({ auto_sync_channel_end: 250 });
expect(screen.getByTestId(/end/i)).toHaveValue(250);
});
it('displays empty end when auto_sync_channel_end is undefined', () => {
renderComponent({ auto_sync_channel_end: undefined });
expect(screen.getByTestId(/end/i)).toHaveValue(null);
});
});
// provider mode rendering
describe('provider mode rendering', () => {
it('reads start from custom_properties.channel_numbering_fallback', () => {
renderComponent({
custom_properties: {
channel_numbering_mode: 'provider',
channel_numbering_fallback: 500,
},
});
expect(screen.getByTestId(/start/i)).toHaveValue(500);
});
it('defaults fallback start to 1 when channel_numbering_fallback is undefined', () => {
renderComponent({
custom_properties: {
channel_numbering_mode: 'provider',
},
});
expect(screen.getByTestId(/start/i)).toHaveValue(1);
});
});
// defaults when no mode is set
describe('defaults when mode is absent', () => {
it('treats missing mode as fixed and renders inputs', () => {
renderComponent({ custom_properties: {} });
expect(screen.getByTestId(/start/i)).toBeInTheDocument();
expect(screen.getByTestId(/end/i)).toBeInTheDocument();
});
});
// updateStart fixed mode
describe('updateStart in fixed mode', () => {
it('calls onApplyGroupChange with clamped start value', () => {
const { onApplyGroupChange } = renderComponent({
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
});
fireEvent.change(screen.getByTestId(/start/i), {
target: { value: '150' },
});
expect(clampChannelNumber).toHaveBeenCalledWith(150);
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({ auto_sync_channel_start: expect.any(Number) })
);
});
it('normalizes empty string input to 1', () => {
const { onApplyGroupChange } = renderComponent();
fireEvent.change(screen.getByTestId(/start/i), { target: { value: '' } });
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({ auto_sync_channel_start: 1 })
);
expect(clampChannelNumber).not.toHaveBeenCalled();
});
it('drops auto_sync_channel_end when new start exceeds current end', () => {
const { onApplyGroupChange } = renderComponent({
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
});
vi.mocked(clampChannelNumber).mockReturnValueOnce(300);
fireEvent.change(screen.getByTestId(/start/i), {
target: { value: '300' },
});
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({ auto_sync_channel_end: null })
);
});
it('keeps auto_sync_channel_end when new start is below end', () => {
const { onApplyGroupChange, group } = renderComponent({
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
});
vi.mocked(clampChannelNumber).mockReturnValueOnce(150);
fireEvent.change(screen.getByTestId(/start/i), {
target: { value: '150' },
});
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({
auto_sync_channel_end: group.auto_sync_channel_end,
})
);
});
it('keeps end when there is no existing end value', () => {
const { onApplyGroupChange } = renderComponent({
auto_sync_channel_start: 100,
auto_sync_channel_end: undefined,
});
vi.mocked(clampChannelNumber).mockReturnValueOnce(500);
fireEvent.change(screen.getByTestId(/start/i), {
target: { value: '500' },
});
const call = onApplyGroupChange.mock.calls[0][0];
expect(call.auto_sync_channel_end).toBeFalsy();
});
});
// updateStart provider mode
describe('updateStart in provider mode', () => {
it('writes to custom_properties.channel_numbering_fallback', () => {
const { onApplyGroupChange } = renderComponent({
custom_properties: {
channel_numbering_mode: 'provider',
channel_numbering_fallback: 100,
},
});
vi.mocked(clampChannelNumber).mockReturnValueOnce(400);
fireEvent.change(screen.getByTestId(/start/i), {
target: { value: '400' },
});
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({
custom_properties: expect.objectContaining({
channel_numbering_fallback: 400,
}),
})
);
});
it('merges existing custom_properties when updating fallback', () => {
const { onApplyGroupChange } = renderComponent({
custom_properties: {
channel_numbering_mode: 'provider',
channel_numbering_fallback: 100,
some_other_key: true,
},
});
vi.mocked(clampChannelNumber).mockReturnValueOnce(200);
fireEvent.change(screen.getByTestId(/start/i), {
target: { value: '200' },
});
const call = onApplyGroupChange.mock.calls[0][0];
expect(call.custom_properties.some_other_key).toBe(true);
expect(call.custom_properties.channel_numbering_mode).toBe('provider');
});
it('normalizes empty string to 1 in provider mode', () => {
const { onApplyGroupChange } = renderComponent({
custom_properties: { channel_numbering_mode: 'provider' },
});
fireEvent.change(screen.getByTestId(/start/i), { target: { value: '' } });
const call = onApplyGroupChange.mock.calls[0][0];
expect(call.custom_properties.channel_numbering_fallback).toBe(1);
});
});
// updateEnd
describe('updateEnd', () => {
it('calls onApplyGroupChange with clamped end value', () => {
const { onApplyGroupChange } = renderComponent();
vi.mocked(clampChannelNumber).mockReturnValueOnce(300);
fireEvent.change(screen.getByTestId(/end/i), {
target: { value: '300' },
});
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({ auto_sync_channel_end: 300 })
);
});
it('normalizes empty end to null/falsy', () => {
const { onApplyGroupChange } = renderComponent();
fireEvent.change(screen.getByTestId(/end/i), { target: { value: '' } });
const call = onApplyGroupChange.mock.calls[0][0];
expect(
call.auto_sync_channel_end == null || call.auto_sync_channel_end === ''
).toBe(true);
});
it('does not call clampChannelNumber when end is cleared', () => {
renderComponent();
fireEvent.change(screen.getByTestId(/end/i), { target: { value: '' } });
expect(clampChannelNumber).not.toHaveBeenCalled();
});
});
// Meta text
describe('meta text', () => {
it('shows range info when end is set', () => {
renderComponent({
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
});
expect(screen.getByTestId('text').textContent).toMatch(/100.*200|range/i);
});
it('shows stream count from group when available', () => {
const group = makeGroup({ auto_sync_channel_end: 200, stream_count: 42 });
const groupStates = [{ channel_group: 1 }];
render(
<AutoSyncBasic
group={group}
groupStates={groupStates}
groupConflicts={{}}
onApplyGroupChange={vi.fn()}
/>
);
expect(screen.getByTestId('text').textContent).toMatch(/42/);
});
});
// Overlap and conflict warnings
describe('warnings', () => {
it('does not render warning icon when no conflicts or overlaps', () => {
renderComponent({}, { groupConflicts: {} });
expect(
screen.queryByTestId('icon-alert-triangle')
).not.toBeInTheDocument();
});
it('renders warning icon when computeRangeOverlapsFor returns overlaps', () => {
vi.mocked(computeRangeOverlapsFor).mockReturnValue([
{ name: 'Other Group', start: 150, end: 160 },
]);
renderComponent();
expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument();
});
it('renders warning icon when group has a channel conflict', () => {
renderComponent(
{},
{ groupConflicts: { 1: { hasChannelConflict: true } } }
);
expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument();
});
it('passes overlap group name to tooltip', () => {
vi.mocked(computeRangeOverlapsFor).mockReturnValue([
{ name: 'News Group', start: 150, end: 160 },
]);
renderComponent();
const tooltip = screen
.getAllByTestId('tooltip')
.find((t) => t.getAttribute('data-label')?.includes('News Group'));
expect(tooltip).toBeDefined();
});
it('passes channel conflict message to tooltip', () => {
renderComponent(
{},
{ groupConflicts: { 1: { hasChannelConflict: true } } }
);
const tooltip = screen
.getAllByTestId('tooltip')
.find((t) =>
t.getAttribute('data-label')?.toLowerCase().includes('channel')
);
expect(tooltip).toBeDefined();
});
it('includes both conflict and overlap info in tooltip when both present', () => {
vi.mocked(computeRangeOverlapsFor).mockReturnValue([
{ name: 'Overlap Group', start: 110, end: 120 },
]);
renderComponent(
{},
{ groupConflicts: { 1: { hasChannelConflict: true } } }
);
const tooltip = screen.getAllByTestId('tooltip').find((t) => {
const label = t.getAttribute('data-label')?.toLowerCase();
return label?.includes('channel') && label.includes('overlap group');
});
expect(tooltip).toBeDefined();
});
it('calls computeRangeOverlapsFor with the group and groupStates', () => {
const group = makeGroup();
const groupStates = [makeGroup({ channel_group: 2, name: 'B' })];
render(
<AutoSyncBasic
group={group}
groupStates={groupStates}
groupConflicts={{}}
onApplyGroupChange={vi.fn()}
/>
);
expect(computeRangeOverlapsFor).toHaveBeenCalledWith(group, groupStates);
});
});
});

View file

@ -0,0 +1,232 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Module mocks
vi.mock('../../../utils/forms/M3uUtils.js', () => ({
updatePlaylist: vi.fn(() => Promise.resolve({})),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('@mantine/core', () => ({
Box: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
Tooltip: ({ children }) => <>{children}</>,
SegmentedControl: ({ value, onChange, data }) => (
<div data-testid="segmented-control" data-value={value}>
{data.map((opt) => (
<button
key={opt.value}
data-testid={`segmented-${opt.value}`}
data-active={value === opt.value}
onClick={() => onChange?.(opt.value)}
>
{opt.label}
</button>
))}
</div>
),
}));
// Imports after mocks
import OrphanCleanupControl from '../AutoSyncOrphanCleanup';
import { updatePlaylist } from '../../../utils/forms/M3uUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
// Helpers
const makePlaylist = (overrides = {}) => ({
id: 7,
custom_properties: null,
...overrides,
});
const renderControl = (playlistOverrides = {}) =>
render(<OrphanCleanupControl playlist={makePlaylist(playlistOverrides)} />);
const getControl = () => screen.getByTestId('segmented-control');
//
describe('OrphanCleanupControl', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Initial state
describe('initial state', () => {
it('defaults to "always" when custom_properties is null', () => {
renderControl({ custom_properties: null });
expect(getControl()).toHaveAttribute('data-value', 'always');
});
it('defaults to "always" when orphan_channel_cleanup key is absent', () => {
renderControl({ custom_properties: { compact_numbering: true } });
expect(getControl()).toHaveAttribute('data-value', 'always');
});
it('reads persisted "preserve_customized" mode from custom_properties', () => {
renderControl({
custom_properties: { orphan_channel_cleanup: 'preserve_customized' },
});
expect(getControl()).toHaveAttribute('data-value', 'preserve_customized');
});
it('reads persisted "never" mode from custom_properties', () => {
renderControl({
custom_properties: { orphan_channel_cleanup: 'never' },
});
expect(getControl()).toHaveAttribute('data-value', 'never');
});
});
// Optimistic update
describe('on mode change', () => {
it('updates the displayed value immediately before the PATCH resolves', () => {
vi.mocked(updatePlaylist).mockReturnValue(new Promise(() => {})); // never resolves
renderControl();
fireEvent.click(screen.getByTestId('segmented-never'));
expect(getControl()).toHaveAttribute('data-value', 'never');
});
it('calls updatePlaylist with merged custom_properties', async () => {
renderControl({ custom_properties: { compact_numbering: true } });
fireEvent.click(screen.getByTestId('segmented-never'));
await waitFor(() => {
expect(updatePlaylist).toHaveBeenCalledWith(
expect.objectContaining({ id: 7 }),
{
custom_properties: {
compact_numbering: true,
orphan_channel_cleanup: 'never',
},
}
);
});
});
it('does not call updatePlaylist when playlist has no id', async () => {
renderControl({ id: undefined });
fireEvent.click(screen.getByTestId('segmented-never'));
await Promise.resolve();
expect(updatePlaylist).not.toHaveBeenCalled();
});
it('preserves final "always" value after successful PATCH', async () => {
renderControl({ custom_properties: { orphan_channel_cleanup: 'never' } });
fireEvent.click(screen.getByTestId('segmented-always'));
await waitFor(() => expect(updatePlaylist).toHaveBeenCalled());
expect(getControl()).toHaveAttribute('data-value', 'always');
});
});
// Error / revert
describe('on PATCH failure', () => {
it('reverts to the previous mode', async () => {
vi.mocked(updatePlaylist).mockRejectedValueOnce(
new Error('Server error')
);
renderControl({
custom_properties: { orphan_channel_cleanup: 'always' },
});
fireEvent.click(screen.getByTestId('segmented-never'));
await waitFor(() => expect(showNotification).toHaveBeenCalled());
expect(getControl()).toHaveAttribute('data-value', 'always');
});
it('shows a red error notification', async () => {
vi.mocked(updatePlaylist).mockRejectedValueOnce(
new Error('Server error')
);
renderControl();
fireEvent.click(screen.getByTestId('segmented-never'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'red' })
);
});
});
it('includes err.message in the notification when body.detail is absent', async () => {
vi.mocked(updatePlaylist).mockRejectedValueOnce(new Error('Timeout'));
renderControl();
fireEvent.click(screen.getByTestId('segmented-never'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Timeout' })
);
});
});
it('prefers err.body.detail over err.message', async () => {
const err = Object.assign(new Error('generic'), {
body: { detail: 'Specific detail from server' },
});
vi.mocked(updatePlaylist).mockRejectedValueOnce(err);
renderControl();
fireEvent.click(screen.getByTestId('segmented-never'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Specific detail from server' })
);
});
});
it('falls back to "Please try again." when error has no message or body', async () => {
vi.mocked(updatePlaylist).mockRejectedValueOnce({});
renderControl();
fireEvent.click(screen.getByTestId('segmented-never'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Please try again.' })
);
});
});
});
// useEffect sync
describe('useEffect sync on prop change', () => {
it('syncs displayed mode when playlist.custom_properties changes', () => {
const { rerender } = render(
<OrphanCleanupControl
playlist={makePlaylist({
custom_properties: { orphan_channel_cleanup: 'always' },
})}
/>
);
expect(getControl()).toHaveAttribute('data-value', 'always');
rerender(
<OrphanCleanupControl
playlist={makePlaylist({
custom_properties: { orphan_channel_cleanup: 'never' },
})}
/>
);
expect(getControl()).toHaveAttribute('data-value', 'never');
});
it('resets to "always" when orphan_channel_cleanup is removed from custom_properties', () => {
const { rerender } = render(
<OrphanCleanupControl
playlist={makePlaylist({
custom_properties: { orphan_channel_cleanup: 'never' },
})}
/>
);
rerender(
<OrphanCleanupControl
playlist={makePlaylist({ custom_properties: {} })}
/>
);
expect(getControl()).toHaveAttribute('data-value', 'always');
});
});
});

View file

@ -0,0 +1,50 @@
import API from '../../api.js';
export const getEpgSourceValue = (cp) => {
// Show custom EPG if set
if (cp?.custom_epg_id !== undefined && cp?.custom_epg_id !== null) {
return cp.custom_epg_id.toString();
}
// Show "No EPG" if force_dummy_epg is set
if (cp?.force_dummy_epg) {
return '0';
}
// Otherwise show empty/placeholder
return null;
};
export const getEpgSourceData = (epgSources) => {
return [
{ value: '0', label: 'No EPG (Disabled)' },
...[...epgSources]
.sort((a, b) => a.name.localeCompare(b.name))
.map((source) => ({
value: source.id.toString(),
label: `${source.name} (${
source.source_type === 'dummy'
? 'Dummy'
: source.source_type === 'xmltv'
? 'XMLTV'
: source.source_type === 'schedules_direct'
? 'Schedules Direct'
: source.source_type
})`,
})),
];
};
export const repackGroupChannels = (playlist, group) => {
return API.repackGroupChannels(playlist.id, group.channel_group);
};
// Header line for the preview box. Adds a scan-cap suffix when the
// backend only scanned the first SCAN_CAP streams of the group.
export const formatPreviewSummary = (label, result) => {
if (!result) return null;
const { match_count, total_in_group, total_scanned, scan_limit_hit } = result;
const matchWord = `match${match_count === 1 ? '' : 'es'}`;
if (scan_limit_hit) {
return `${match_count} ${matchWord} in first ${total_scanned.toLocaleString()} streams scanned (of ${total_in_group.toLocaleString()} total)`;
}
return `${match_count} ${label} ${matchWord} in ${total_scanned.toLocaleString()} stream${total_scanned === 1 ? '' : 's'}`;
};

View file

@ -0,0 +1,25 @@
// Returns {name, start, end}[] for groups whose declared ranges
// intersect this group's range, or [] when there is no overlap.
import { getGroupReservation } from './GroupSyncUtils.js';
export const computeRangeOverlapsFor = (group, groupStates) => {
const myReservation = getGroupReservation(group);
if (!myReservation) return [];
const [myStart, myEnd] = myReservation;
const overlaps = [];
for (const other of groupStates) {
if (other.channel_group === group.channel_group) continue;
const otherReservation = getGroupReservation(other);
if (!otherReservation) continue;
const [oStart, oEnd] = otherReservation;
if (myStart <= oEnd && oStart <= myEnd) {
overlaps.push({ name: other.name, start: oStart, end: oEnd });
}
}
return overlaps;
};
const MAX_CHANNEL_NUMBER = 999999;
export const clampChannelNumber = (n) =>
Math.max(1, Math.min(MAX_CHANNEL_NUMBER, Math.floor(Number(n) || 1)));

View file

@ -4,141 +4,137 @@ export const getEPGs = () => {
return API.getEPGs();
};
export const ADVANCED_OPTIONS_CONFIG = [
{
value: 'force_epg',
label: 'Force EPG Source',
description:
'Force a specific EPG source for all auto-synced channels, or disable EPG assignment entirely',
isActive: (p) =>
p.custom_epg_id !== undefined ||
p.force_dummy_epg ||
p.force_epg_selected,
defaults: { force_dummy_epg: true },
removeKeys: ['force_dummy_epg', 'custom_epg_id', 'force_epg_selected'],
},
{
value: 'group_override',
label: 'Override Channel Group',
description: 'Override the group assignment for all channels in this group',
isActive: (p) => p.group_override !== undefined,
defaults: { group_override: null },
removeKeys: ['group_override'],
},
{
value: 'name_regex',
label: 'Channel Name Find & Replace (Regex)',
description:
'Find and replace part of the channel name using a regex pattern',
isActive: (p) =>
p.name_regex_pattern !== undefined ||
p.name_replace_pattern !== undefined,
defaults: { name_regex_pattern: '', name_replace_pattern: '' },
removeKeys: ['name_regex_pattern', 'name_replace_pattern'],
},
{
value: 'name_match_regex',
label: 'Channel Name Filter (Regex)',
description: 'Only sync channels whose name matches this regex.',
isActive: (p) => p.name_match_regex !== undefined,
defaults: { name_match_regex: '' },
removeKeys: ['name_match_regex'],
},
{
value: 'profile_assignment',
label: 'Channel Profile Assignment',
description:
'Specify which channel profiles the auto-synced channels should be added to',
isActive: (p) => p.channel_profile_ids !== undefined,
defaults: { channel_profile_ids: [] },
removeKeys: ['channel_profile_ids'],
},
{
value: 'channel_sort_order',
label: 'Channel Sort Order',
description:
'Specify the order in which channels are created (name, tvg_id, updated_at)',
isActive: (p) => p.channel_sort_order !== undefined,
defaults: { channel_sort_order: '', channel_sort_reverse: false },
removeKeys: ['channel_sort_order', 'channel_sort_reverse'],
},
{
value: 'stream_profile_assignment',
label: 'Stream Profile Assignment',
description:
'Assign a specific stream profile to all channels in this group during auto sync',
isActive: (p) => p.stream_profile_id !== undefined,
defaults: { stream_profile_id: null },
removeKeys: ['stream_profile_id'],
},
{
value: 'custom_logo',
label: 'Custom Logo',
description:
'Assign a custom logo to all auto-synced channels in this group',
isActive: (p) => p.custom_logo_id !== undefined,
defaults: { custom_logo_id: null },
removeKeys: ['custom_logo_id'],
},
];
export const getSelectedAdvancedOptions = (customProps) =>
ADVANCED_OPTIONS_CONFIG.filter((opt) => opt.isActive(customProps ?? {})).map(
(opt) => opt.value
);
export const applyAdvancedOptionsChange = (prevCustomProps, newValues) => {
const next = { ...prevCustomProps };
// Add defaults for newly selected options
for (const opt of ADVANCED_OPTIONS_CONFIG) {
if (newValues.includes(opt.value) && !opt.isActive(next)) {
Object.assign(next, opt.defaults);
}
}
// Remove keys for deselected options
for (const opt of ADVANCED_OPTIONS_CONFIG) {
if (!newValues.includes(opt.value) && opt.isActive(next)) {
for (const key of opt.removeKeys) delete next[key];
}
}
return next;
export const getChannelsInRange = (start, end, controller) => {
return API.getChannelsInRange(start, end, {
signal: controller.signal,
});
};
export const getEpgSourceValue = (group) => {
// Show custom EPG if set
export const getStreamsRegexPreview = (
group,
find,
replace,
match,
exclude,
controller,
playlist
) => {
return API.getStreamsRegexPreview(group.name, {
find: find || undefined,
replace: find ? replace : undefined,
match: match || undefined,
exclude: exclude || undefined,
limit: 10,
signal: controller.signal,
m3uAccountId: playlist?.id,
});
};
// "Expected" occupants are this group's own auto-sync output:
// auto_created, in this group on this account, no channel_number
// override. Channels from any other provider, group, or with a user
// pin all surface as a warning so the user is aware their range
// overlaps with existing assignments. Sync still merges shared
// ranges across providers, so the warning is informational rather
// than blocking.
export const isExpectedOccupantForGroup = (
occupant,
groupChannelGroupId,
playlist
) => {
if (!occupant) return false;
if (!occupant.auto_created) return false;
if (occupant.has_channel_number_override) return false;
if (
group.custom_properties?.custom_epg_id !== undefined &&
group.custom_properties?.custom_epg_id !== null
) {
return group.custom_properties.custom_epg_id.toString();
}
// Show "No EPG" if force_dummy_epg is set
if (group.custom_properties?.force_dummy_epg) {
return '0';
}
// Otherwise show empty/placeholder
return null;
occupant.channel_group_id !== undefined &&
occupant.channel_group_id !== groupChannelGroupId
)
return false;
return !(
occupant.auto_created_by_account_id !== undefined &&
playlist?.id !== undefined &&
occupant.auto_created_by_account_id !== playlist.id
);
};
export const getEpgSourceData = (epgSources) => {
return [
{ value: '0', label: 'No EPG (Disabled)' },
...[...epgSources]
.sort((a, b) => a.name.localeCompare(b.name))
.map((source) => ({
value: source.id.toString(),
label: `${source.name} (${
source.source_type === 'dummy'
? 'Dummy'
: source.source_type === 'xmltv'
? 'XMLTV'
: source.source_type === 'schedules_direct'
? 'Schedules Direct'
: source.source_type
})`,
})),
];
export const rangeFor = (g) => {
if (!g.enabled || !g.auto_channel_sync) return null;
const mode = g.custom_properties?.channel_numbering_mode || 'fixed';
if (mode === 'next_available') return null;
const startRaw =
mode === 'provider'
? (g.custom_properties?.channel_numbering_fallback ?? 1)
: (g.auto_sync_channel_start ?? 1);
const start = Number(startRaw);
if (!Number.isFinite(start)) return null;
const endRaw = g.auto_sync_channel_end;
const end =
endRaw === null || endRaw === undefined || endRaw === ''
? start
: Number(endRaw);
return { start, end, startRaw };
};
export const abortTimers = (timerRef, abortRef) => {
Object.values(timerRef.current).forEach((t) => clearTimeout(t));
timerRef.current = {};
Object.values(abortRef.current).forEach((c) => {
try {
c.abort();
} catch {
// ignore
}
});
abortRef.current = {};
};
export const getRegexOptions = (
findValue,
replaceValue,
filterValue,
excludeValue
) => {
return {
find: findValue,
replace: replaceValue,
match: filterValue,
exclude: excludeValue,
};
};
export const computeAutoSyncStart = (prev, id) => {
let proposedStart = 1;
for (const other of prev) {
if (other.channel_group == id) continue;
if (!other.enabled || !other.auto_channel_sync) continue;
const otherMode =
other.custom_properties?.channel_numbering_mode || 'fixed';
if (otherMode === 'next_available') continue;
const otherStart = Number(
otherMode === 'provider'
? (other.custom_properties?.channel_numbering_fallback ?? 1)
: (other.auto_sync_channel_start ?? 1)
);
if (!Number.isFinite(otherStart)) continue;
const otherEnd =
other.auto_sync_channel_end === null ||
other.auto_sync_channel_end === undefined ||
other.auto_sync_channel_end === ''
? otherStart
: Number(other.auto_sync_channel_end);
const upper = Math.max(otherStart, otherEnd);
if (upper + 1 > proposedStart) proposedStart = upper + 1;
}
return proposedStart;
};
export const isGroupVisible = (group, groupFilter, statusFilter) => {
const matchesText = group.name
.toLowerCase()
.includes(groupFilter.toLowerCase());
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'enabled' && group.enabled) ||
(statusFilter === 'disabled' && !group.enabled);
return matchesText && matchesStatus;
};

View file

@ -0,0 +1,257 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
getEpgSourceValue,
getEpgSourceData,
repackGroupChannels,
formatPreviewSummary,
} from '../AutoSyncAdvancedUtils.js';
vi.mock('../../../api.js', () => ({
default: {
repackGroupChannels: vi.fn(),
},
}));
import API from '../../../api.js';
const makeEpgSource = (overrides = {}) => ({
id: 1,
name: 'Source One',
source_type: 'xmltv',
...overrides,
});
const makeResult = (overrides = {}) => ({
match_count: 3,
total_in_group: 100,
total_scanned: 100,
scan_limit_hit: false,
...overrides,
});
describe('AutoSyncAdvancedUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── getEpgSourceValue ─────────────────────────────────────────────────────
describe('getEpgSourceValue', () => {
it('returns custom_epg_id as string when set', () => {
expect(getEpgSourceValue({ custom_epg_id: 5 })).toBe('5');
});
it('returns custom_epg_id as string when set to 0', () => {
expect(getEpgSourceValue({ custom_epg_id: 0 })).toBe('0');
});
it('returns "0" when force_dummy_epg is true and no custom_epg_id', () => {
expect(getEpgSourceValue({ force_dummy_epg: true })).toBe('0');
});
it('prefers custom_epg_id over force_dummy_epg', () => {
expect(
getEpgSourceValue({ custom_epg_id: 7, force_dummy_epg: true })
).toBe('7');
});
it('returns null when custom_epg_id is null', () => {
expect(getEpgSourceValue({ custom_epg_id: null })).toBe(null);
});
it('returns null when custom_epg_id is undefined and force_dummy_epg is false', () => {
expect(
getEpgSourceValue({ custom_epg_id: undefined, force_dummy_epg: false })
).toBe(null);
});
it('returns null for empty custom_properties object', () => {
expect(getEpgSourceValue({})).toBe(null);
});
it('returns null for null custom_properties', () => {
expect(getEpgSourceValue(null)).toBe(null);
});
it('returns null for undefined custom_properties', () => {
expect(getEpgSourceValue(undefined)).toBe(null);
});
});
// ── getEpgSourceData ──────────────────────────────────────────────────────
describe('getEpgSourceData', () => {
it('always includes "No EPG" as the first option', () => {
const result = getEpgSourceData([]);
expect(result[0]).toEqual({ value: '0', label: 'No EPG (Disabled)' });
});
it('returns only the No EPG option for an empty source list', () => {
expect(getEpgSourceData([])).toHaveLength(1);
});
it('labels xmltv sources correctly', () => {
const result = getEpgSourceData([
makeEpgSource({ source_type: 'xmltv' }),
]);
expect(result[1].label).toBe('Source One (XMLTV)');
});
it('labels dummy sources correctly', () => {
const result = getEpgSourceData([
makeEpgSource({ source_type: 'dummy' }),
]);
expect(result[1].label).toBe('Source One (Dummy)');
});
it('labels schedules_direct sources correctly', () => {
const result = getEpgSourceData([
makeEpgSource({ source_type: 'schedules_direct' }),
]);
expect(result[1].label).toBe('Source One (Schedules Direct)');
});
it('falls back to raw source_type for unknown types', () => {
const result = getEpgSourceData([
makeEpgSource({ source_type: 'custom_type' }),
]);
expect(result[1].label).toBe('Source One (custom_type)');
});
it('maps id to string value', () => {
const result = getEpgSourceData([makeEpgSource({ id: 42 })]);
expect(result[1].value).toBe('42');
});
it('sorts sources alphabetically by name', () => {
const sources = [
makeEpgSource({ id: 1, name: 'Zebra' }),
makeEpgSource({ id: 2, name: 'Alpha' }),
makeEpgSource({ id: 3, name: 'Mango' }),
];
const result = getEpgSourceData(sources);
expect(result.map((r) => r.label)).toEqual([
'No EPG (Disabled)',
'Alpha (XMLTV)',
'Mango (XMLTV)',
'Zebra (XMLTV)',
]);
});
it('does not mutate the original array', () => {
const sources = [
makeEpgSource({ id: 1, name: 'Zebra' }),
makeEpgSource({ id: 2, name: 'Alpha' }),
];
const original = [...sources];
getEpgSourceData(sources);
expect(sources).toEqual(original);
});
});
// ── repackGroupChannels ───────────────────────────────────────────────────
describe('repackGroupChannels', () => {
it('calls API.repackGroupChannels with playlist id and channel_group', () => {
const playlist = { id: 10 };
const group = { channel_group: 5 };
vi.mocked(API.repackGroupChannels).mockResolvedValue({ ok: true });
repackGroupChannels(playlist, group);
expect(API.repackGroupChannels).toHaveBeenCalledWith(10, 5);
});
it('returns the API promise', async () => {
const playlist = { id: 10 };
const group = { channel_group: 5 };
vi.mocked(API.repackGroupChannels).mockResolvedValue({ ok: true });
await expect(repackGroupChannels(playlist, group)).resolves.toEqual({
ok: true,
});
});
});
// ── formatPreviewSummary ──────────────────────────────────────────────────
describe('formatPreviewSummary', () => {
it('returns null when result is null', () => {
expect(formatPreviewSummary('streams', null)).toBeNull();
});
it('returns null when result is undefined', () => {
expect(formatPreviewSummary('streams', undefined)).toBeNull();
});
it('formats normal result with plural matches', () => {
const result = makeResult({ match_count: 3, total_scanned: 50 });
expect(formatPreviewSummary('streams', result)).toBe(
'3 streams matches in 50 streams'
);
});
it('formats normal result with singular match', () => {
const result = makeResult({ match_count: 1, total_scanned: 50 });
expect(formatPreviewSummary('streams', result)).toBe(
'1 streams match in 50 streams'
);
});
it('uses singular "stream" when total_scanned is 1', () => {
const result = makeResult({ match_count: 1, total_scanned: 1 });
expect(formatPreviewSummary('streams', result)).toBe(
'1 streams match in 1 stream'
);
});
it('formats scan_limit_hit result with plural matches', () => {
const result = makeResult({
match_count: 5,
total_scanned: 200,
total_in_group: 1000,
scan_limit_hit: true,
});
expect(formatPreviewSummary('streams', result)).toBe(
'5 matches in first 200 streams scanned (of 1,000 total)'
);
});
it('formats scan_limit_hit result with singular match', () => {
const result = makeResult({
match_count: 1,
total_scanned: 200,
total_in_group: 1000,
scan_limit_hit: true,
});
expect(formatPreviewSummary('streams', result)).toBe(
'1 match in first 200 streams scanned (of 1,000 total)'
);
});
it('uses toLocaleString formatting for large numbers in scan_limit_hit', () => {
const result = makeResult({
match_count: 2,
total_scanned: 5000,
total_in_group: 10000,
scan_limit_hit: true,
});
expect(formatPreviewSummary('streams', result)).toBe(
'2 matches in first 5,000 streams scanned (of 10,000 total)'
);
});
it('includes the label in normal result output', () => {
const result = makeResult({ match_count: 2, total_scanned: 10 });
expect(formatPreviewSummary('channels', result)).toContain('channels');
});
it('does not include the label in scan_limit_hit output', () => {
const result = makeResult({
scan_limit_hit: true,
match_count: 2,
total_scanned: 10,
total_in_group: 100,
});
expect(formatPreviewSummary('channels', result)).not.toContain(
'channels'
);
});
});
});

View file

@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
computeRangeOverlapsFor,
clampChannelNumber,
} from '../AutoSyncBasicUtils.js';
vi.mock('../GroupSyncUtils.js', () => ({
getGroupReservation: vi.fn(),
}));
import { getGroupReservation } from '../GroupSyncUtils.js';
const makeGroup = (overrides = {}) => ({
channel_group: 1,
name: 'Group A',
...overrides,
});
describe('AutoSyncBasicUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── computeRangeOverlapsFor ───────────────────────────────────────────────
describe('computeRangeOverlapsFor', () => {
it('returns [] when the target group has no reservation', () => {
getGroupReservation.mockReturnValue(null);
expect(computeRangeOverlapsFor(makeGroup(), [])).toEqual([]);
});
it('returns [] when there are no other groups', () => {
getGroupReservation.mockReturnValue([100, 200]);
expect(computeRangeOverlapsFor(makeGroup(), [])).toEqual([]);
});
it('skips groups with the same channel_group id', () => {
const group = makeGroup({ channel_group: 1 });
const same = makeGroup({ channel_group: 1, name: 'Same' });
getGroupReservation.mockReturnValue([100, 200]);
expect(computeRangeOverlapsFor(group, [same])).toEqual([]);
});
it('skips other groups with no reservation', () => {
const group = makeGroup({ channel_group: 1 });
const other = makeGroup({ channel_group: 2, name: 'Other' });
getGroupReservation
.mockReturnValueOnce([100, 200]) // target group
.mockReturnValueOnce(null); // other group
expect(computeRangeOverlapsFor(group, [other])).toEqual([]);
});
it('detects exact overlap', () => {
const group = makeGroup({ channel_group: 1 });
const other = makeGroup({ channel_group: 2, name: 'Other' });
getGroupReservation
.mockReturnValueOnce([100, 200])
.mockReturnValueOnce([100, 200]);
expect(computeRangeOverlapsFor(group, [other])).toEqual([
{ name: 'Other', start: 100, end: 200 },
]);
});
it('detects partial overlap at start boundary', () => {
const group = makeGroup({ channel_group: 1 });
const other = makeGroup({ channel_group: 2, name: 'Other' });
getGroupReservation
.mockReturnValueOnce([150, 250])
.mockReturnValueOnce([100, 150]);
expect(computeRangeOverlapsFor(group, [other])).toEqual([
{ name: 'Other', start: 100, end: 150 },
]);
});
it('detects partial overlap at end boundary', () => {
const group = makeGroup({ channel_group: 1 });
const other = makeGroup({ channel_group: 2, name: 'Other' });
getGroupReservation
.mockReturnValueOnce([100, 200])
.mockReturnValueOnce([200, 300]);
expect(computeRangeOverlapsFor(group, [other])).toEqual([
{ name: 'Other', start: 200, end: 300 },
]);
});
it('returns [] when other group is entirely before target', () => {
const group = makeGroup({ channel_group: 1 });
const other = makeGroup({ channel_group: 2, name: 'Other' });
getGroupReservation
.mockReturnValueOnce([200, 300])
.mockReturnValueOnce([100, 199]);
expect(computeRangeOverlapsFor(group, [other])).toEqual([]);
});
it('returns [] when other group is entirely after target', () => {
const group = makeGroup({ channel_group: 1 });
const other = makeGroup({ channel_group: 2, name: 'Other' });
getGroupReservation
.mockReturnValueOnce([100, 199])
.mockReturnValueOnce([200, 300]);
expect(computeRangeOverlapsFor(group, [other])).toEqual([]);
});
it('returns multiple overlapping groups', () => {
const group = makeGroup({ channel_group: 1 });
const other1 = makeGroup({ channel_group: 2, name: 'B' });
const other2 = makeGroup({ channel_group: 3, name: 'C' });
getGroupReservation
.mockReturnValueOnce([100, 300]) // target
.mockReturnValueOnce([150, 200]) // other1 overlaps
.mockReturnValueOnce([250, 350]); // other2 overlaps
expect(computeRangeOverlapsFor(group, [other1, other2])).toEqual([
{ name: 'B', start: 150, end: 200 },
{ name: 'C', start: 250, end: 350 },
]);
});
});
// ── clampChannelNumber ────────────────────────────────────────────────────
describe('clampChannelNumber', () => {
it('returns the value unchanged when within range', () => {
expect(clampChannelNumber(500)).toBe(500);
});
it('clamps below 1 to 1', () => {
expect(clampChannelNumber(0)).toBe(1);
expect(clampChannelNumber(-50)).toBe(1);
});
it('clamps above 999999 to 999999', () => {
expect(clampChannelNumber(1000000)).toBe(999999);
expect(clampChannelNumber(99999999)).toBe(999999);
});
it('floors decimal values', () => {
expect(clampChannelNumber(5.9)).toBe(5);
expect(clampChannelNumber(1.1)).toBe(1);
});
it('coerces numeric strings', () => {
expect(clampChannelNumber('42')).toBe(42);
});
it('falls back to 1 for NaN inputs', () => {
expect(clampChannelNumber('abc')).toBe(1);
expect(clampChannelNumber(NaN)).toBe(1);
expect(clampChannelNumber(undefined)).toBe(1);
expect(clampChannelNumber(null)).toBe(1);
});
it('returns 1 for boundary value 1', () => {
expect(clampChannelNumber(1)).toBe(1);
});
it('returns 999999 for boundary value 999999', () => {
expect(clampChannelNumber(999999)).toBe(999999);
});
});
});

View file

@ -1,15 +1,23 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
getEPGs,
getSelectedAdvancedOptions,
applyAdvancedOptionsChange,
getEpgSourceValue,
getEpgSourceData,
getChannelsInRange,
getStreamsRegexPreview,
isExpectedOccupantForGroup,
rangeFor,
abortTimers,
getRegexOptions,
computeAutoSyncStart,
isGroupVisible,
} from '../LiveGroupFilterUtils.js';
// ── API mock ─────────────────────────────────────────────────────────────────
vi.mock('../../../api.js', () => ({
default: { getEPGs: vi.fn() },
default: {
getEPGs: vi.fn(),
getChannelsInRange: vi.fn(),
getStreamsRegexPreview: vi.fn(),
},
}));
import API from '../../../api.js';
@ -22,9 +30,41 @@ const makeEpgSource = (overrides = {}) => ({
...overrides,
});
describe('LiveGroupFilterUtils', () => {
// ── getEPGs ────────────────────────────────────────────────────────────────
const makeGroup = (overrides = {}) => ({
name: 'Group A',
enabled: true,
auto_channel_sync: true,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
custom_properties: {},
channel_group: 1,
...overrides,
});
const makeOccupant = (overrides = {}) => ({
auto_created: true,
has_channel_number_override: false,
channel_group_id: 1,
auto_created_by_account_id: 42,
...overrides,
});
const makePlaylist = (overrides = {}) => ({
id: 42,
...overrides,
});
const makeController = () => {
const controller = { signal: {} };
return controller;
};
describe('LiveGroupFilterUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── getEPGs ────────────────────────────────────────────────────────────────
describe('getEPGs', () => {
it('delegates to API.getEPGs', () => {
const result = [makeEpgSource()];
@ -34,289 +74,425 @@ describe('LiveGroupFilterUtils', () => {
});
});
// ── getSelectedAdvancedOptions ───────────────────────────────────────────────
// ── getChannelsInRange ─────────────────────────────────────────────────────
describe('getChannelsInRange', () => {
it('calls API.getChannelsInRange with start, end, and signal', () => {
const controller = makeController();
const result = [{ id: 1 }];
vi.mocked(API.getChannelsInRange).mockResolvedValue(result);
describe('getSelectedAdvancedOptions', () => {
it('returns empty array when custom_properties is empty', () => {
expect(getSelectedAdvancedOptions({})).toEqual([]);
});
getChannelsInRange(100, 200, controller);
it('returns empty array when custom_properties is nullish', () => {
expect(getSelectedAdvancedOptions(null)).toEqual([]);
expect(getSelectedAdvancedOptions(undefined)).toEqual([]);
});
it('detects force_epg via custom_epg_id', () => {
expect(getSelectedAdvancedOptions({ custom_epg_id: 5 })).toContain(
'force_epg'
);
});
it('detects force_epg via force_dummy_epg', () => {
expect(getSelectedAdvancedOptions({ force_dummy_epg: true })).toContain(
'force_epg'
);
});
it('detects force_epg via force_epg_selected', () => {
expect(
getSelectedAdvancedOptions({ force_epg_selected: true })
).toContain('force_epg');
});
it('detects group_override', () => {
expect(getSelectedAdvancedOptions({ group_override: null })).toContain(
'group_override'
);
});
it('detects name_regex via name_regex_pattern', () => {
expect(getSelectedAdvancedOptions({ name_regex_pattern: '' })).toContain(
'name_regex'
);
});
it('detects name_regex via name_replace_pattern', () => {
expect(
getSelectedAdvancedOptions({ name_replace_pattern: '' })
).toContain('name_regex');
});
it('detects name_match_regex', () => {
expect(getSelectedAdvancedOptions({ name_match_regex: '' })).toContain(
'name_match_regex'
);
});
it('detects profile_assignment via channel_profile_ids', () => {
expect(getSelectedAdvancedOptions({ channel_profile_ids: [] })).toContain(
'profile_assignment'
);
});
it('detects channel_sort_order', () => {
expect(
getSelectedAdvancedOptions({ channel_sort_order: 'name' })
).toContain('channel_sort_order');
});
it('detects stream_profile_assignment', () => {
expect(getSelectedAdvancedOptions({ stream_profile_id: null })).toContain(
'stream_profile_assignment'
);
});
it('detects custom_logo', () => {
expect(getSelectedAdvancedOptions({ custom_logo_id: null })).toContain(
'custom_logo'
);
});
it('returns multiple active options', () => {
const result = getSelectedAdvancedOptions({
name_match_regex: '',
channel_sort_order: 'name',
expect(API.getChannelsInRange).toHaveBeenCalledWith(100, 200, {
signal: controller.signal,
});
expect(result).toContain('name_match_regex');
expect(result).toContain('channel_sort_order');
expect(result).toHaveLength(2);
});
it('returns the API promise', () => {
const controller = makeController();
const result = [{ id: 1 }];
vi.mocked(API.getChannelsInRange).mockResolvedValue(result);
expect(getChannelsInRange(100, 200, controller)).resolves.toEqual(result);
});
});
// ── applyAdvancedOptionsChange ───────────────────────────────────────────────
// ── getStreamsRegexPreview ─────────────────────────────────────────────────
describe('getStreamsRegexPreview', () => {
it('calls API with correct params when all values provided', () => {
const group = { name: 'Group A' };
const controller = makeController();
const playlist = makePlaylist();
vi.mocked(API.getStreamsRegexPreview).mockResolvedValue([]);
describe('applyAdvancedOptionsChange', () => {
describe('adding options', () => {
it('adds force_epg defaults when newly selected', () => {
const result = applyAdvancedOptionsChange({}, ['force_epg']);
expect(result).toMatchObject({ force_dummy_epg: true });
});
getStreamsRegexPreview(
group,
'find',
'replace',
'match',
'exclude',
controller,
playlist
);
it('adds name_regex defaults when newly selected', () => {
const result = applyAdvancedOptionsChange({}, ['name_regex']);
expect(result).toMatchObject({
name_regex_pattern: '',
name_replace_pattern: '',
});
});
it('adds channel_sort_order defaults including channel_sort_reverse', () => {
const result = applyAdvancedOptionsChange({}, ['channel_sort_order']);
expect(result).toMatchObject({
channel_sort_order: '',
channel_sort_reverse: false,
});
});
it('adds profile_assignment defaults', () => {
const result = applyAdvancedOptionsChange({}, ['profile_assignment']);
expect(result).toMatchObject({ channel_profile_ids: [] });
});
it('adds custom_logo defaults', () => {
const result = applyAdvancedOptionsChange({}, ['custom_logo']);
expect(result).toMatchObject({ custom_logo_id: null });
});
it('does not overwrite existing keys when option is already active', () => {
const prev = { name_match_regex: 'existing' };
const result = applyAdvancedOptionsChange(prev, ['name_match_regex']);
expect(result.name_match_regex).toBe('existing');
});
it('adds defaults for multiple options at once', () => {
const result = applyAdvancedOptionsChange({}, [
'name_match_regex',
'custom_logo',
]);
expect(result).toMatchObject({
name_match_regex: '',
custom_logo_id: null,
});
expect(API.getStreamsRegexPreview).toHaveBeenCalledWith('Group A', {
find: 'find',
replace: 'replace',
match: 'match',
exclude: 'exclude',
limit: 10,
signal: controller.signal,
m3uAccountId: 42,
});
});
describe('removing options', () => {
it('removes force_epg keys when deselected', () => {
const prev = {
force_dummy_epg: true,
custom_epg_id: 3,
force_epg_selected: true,
};
const result = applyAdvancedOptionsChange(prev, []);
expect(result).not.toHaveProperty('force_dummy_epg');
expect(result).not.toHaveProperty('custom_epg_id');
expect(result).not.toHaveProperty('force_epg_selected');
});
it('omits find/replace when find is falsy', () => {
const group = { name: 'Group A' };
const controller = makeController();
vi.mocked(API.getStreamsRegexPreview).mockResolvedValue([]);
it('removes name_regex keys when deselected', () => {
const prev = { name_regex_pattern: 'foo', name_replace_pattern: 'bar' };
const result = applyAdvancedOptionsChange(prev, []);
expect(result).not.toHaveProperty('name_regex_pattern');
expect(result).not.toHaveProperty('name_replace_pattern');
});
getStreamsRegexPreview(group, '', 'replace', '', '', controller, null);
it('removes channel_sort_order and channel_sort_reverse when deselected', () => {
const prev = { channel_sort_order: 'name', channel_sort_reverse: true };
const result = applyAdvancedOptionsChange(prev, []);
expect(result).not.toHaveProperty('channel_sort_order');
expect(result).not.toHaveProperty('channel_sort_reverse');
});
it('removes custom_logo_id when deselected', () => {
const prev = { custom_logo_id: 42 };
const result = applyAdvancedOptionsChange(prev, []);
expect(result).not.toHaveProperty('custom_logo_id');
});
it('does not remove keys for options that are still selected', () => {
const prev = { name_match_regex: 'foo', custom_logo_id: 1 };
const result = applyAdvancedOptionsChange(prev, ['name_match_regex']);
expect(result).toHaveProperty('name_match_regex', 'foo');
expect(result).not.toHaveProperty('custom_logo_id');
expect(API.getStreamsRegexPreview).toHaveBeenCalledWith('Group A', {
find: undefined,
replace: undefined,
match: undefined,
exclude: undefined,
limit: 10,
signal: controller.signal,
m3uAccountId: undefined,
});
});
it('does not mutate the original object', () => {
const prev = { name_match_regex: 'foo' };
applyAdvancedOptionsChange(prev, []);
expect(prev).toHaveProperty('name_match_regex', 'foo');
it('omits replace when find is falsy but keeps match/exclude if truthy', () => {
const group = { name: 'Group A' };
const controller = makeController();
vi.mocked(API.getStreamsRegexPreview).mockResolvedValue([]);
getStreamsRegexPreview(
group,
'',
'',
'match',
'exclude',
controller,
null
);
expect(API.getStreamsRegexPreview).toHaveBeenCalledWith('Group A', {
find: undefined,
replace: undefined,
match: 'match',
exclude: 'exclude',
limit: 10,
signal: controller.signal,
m3uAccountId: undefined,
});
});
});
// ── getEpgSourceValue ────────────────────────────────────────────────────────
describe('getEpgSourceValue', () => {
it('returns custom_epg_id as string when set', () => {
const group = { custom_properties: { custom_epg_id: 7 } };
expect(getEpgSourceValue(group)).toBe('7');
// ── isExpectedOccupantForGroup ─────────────────────────────────────────────
describe('isExpectedOccupantForGroup', () => {
it('returns false for null occupant', () => {
expect(isExpectedOccupantForGroup(null, 1, makePlaylist())).toBe(false);
});
it('returns "0" when force_dummy_epg is true and no custom_epg_id', () => {
const group = { custom_properties: { force_dummy_epg: true } };
expect(getEpgSourceValue(group)).toBe('0');
it('returns false when occupant is not auto_created', () => {
const occupant = makeOccupant({ auto_created: false });
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
false
);
});
it('returns null when neither custom_epg_id nor force_dummy_epg is set', () => {
const group = { custom_properties: {} };
expect(getEpgSourceValue(group)).toBeNull();
it('returns false when occupant has a channel number override', () => {
const occupant = makeOccupant({ has_channel_number_override: true });
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
false
);
});
it('prefers custom_epg_id over force_dummy_epg', () => {
const group = {
custom_properties: { custom_epg_id: 3, force_dummy_epg: true },
it('returns false when occupant belongs to a different group', () => {
const occupant = makeOccupant({ channel_group_id: 99 });
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
false
);
});
it('returns false when occupant was created by a different account', () => {
const occupant = makeOccupant({ auto_created_by_account_id: 99 });
expect(
isExpectedOccupantForGroup(occupant, 1, makePlaylist({ id: 42 }))
).toBe(false);
});
it('returns true for a valid expected occupant', () => {
const occupant = makeOccupant();
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
true
);
});
it('returns true when channel_group_id is undefined', () => {
const occupant = makeOccupant({ channel_group_id: undefined });
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
true
);
});
it('returns true when auto_created_by_account_id is undefined', () => {
const occupant = makeOccupant({ auto_created_by_account_id: undefined });
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
true
);
});
});
// ── rangeFor ──────────────────────────────────────────────────────────────
describe('rangeFor', () => {
it('returns null when group is disabled', () => {
expect(rangeFor(makeGroup({ enabled: false }))).toBeNull();
});
it('returns null when auto_channel_sync is off', () => {
expect(rangeFor(makeGroup({ auto_channel_sync: false }))).toBeNull();
});
it('returns null when mode is next_available', () => {
const group = makeGroup({
custom_properties: { channel_numbering_mode: 'next_available' },
});
expect(rangeFor(group)).toBeNull();
});
it('returns null when start is not finite', () => {
const group = makeGroup({ auto_sync_channel_start: 'abc' });
expect(rangeFor(group)).toBeNull();
});
it('returns correct range for fixed mode', () => {
const group = makeGroup({
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
custom_properties: { channel_numbering_mode: 'fixed' },
});
expect(rangeFor(group)).toEqual({ start: 100, end: 200, startRaw: 100 });
});
it('uses fallback start for provider mode', () => {
const group = makeGroup({
custom_properties: {
channel_numbering_mode: 'provider',
channel_numbering_fallback: 50,
},
auto_sync_channel_end: 150,
});
expect(rangeFor(group)).toEqual({ start: 50, end: 150, startRaw: 50 });
});
it('uses start as end when end is null', () => {
const group = makeGroup({
auto_sync_channel_start: 100,
auto_sync_channel_end: null,
});
expect(rangeFor(group)).toEqual({ start: 100, end: 100, startRaw: 100 });
});
it('uses start as end when end is empty string', () => {
const group = makeGroup({
auto_sync_channel_start: 100,
auto_sync_channel_end: '',
});
expect(rangeFor(group)).toEqual({ start: 100, end: 100, startRaw: 100 });
});
it('defaults start to 1 when auto_sync_channel_start is undefined', () => {
const group = makeGroup({
auto_sync_channel_start: undefined,
auto_sync_channel_end: 10,
});
expect(rangeFor(group)).toEqual({ start: 1, end: 10, startRaw: 1 });
});
});
// ── abortTimers ───────────────────────────────────────────────────────────
describe('abortTimers', () => {
it('clears all timeouts and aborts all controllers', () => {
const t1 = setTimeout(() => {}, 10000);
const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
const abortFn = vi.fn();
const timerRef = { current: { a: t1 } };
const abortRef = { current: { b: { abort: abortFn } } };
abortTimers(timerRef, abortRef);
expect(clearSpy).toHaveBeenCalledWith(t1);
expect(abortFn).toHaveBeenCalledOnce();
expect(timerRef.current).toEqual({});
expect(abortRef.current).toEqual({});
clearSpy.mockRestore();
});
it('does not throw when abort throws', () => {
const timerRef = { current: {} };
const abortRef = {
current: {
a: {
abort: () => {
throw new Error('already aborted');
},
},
},
};
expect(getEpgSourceValue(group)).toBe('3');
});
it('returns null when custom_epg_id is explicitly null', () => {
const group = { custom_properties: { custom_epg_id: null } };
expect(getEpgSourceValue(group)).toBeNull();
expect(() => abortTimers(timerRef, abortRef)).not.toThrow();
expect(abortRef.current).toEqual({});
});
});
// ── getEpgSourceData ─────────────────────────────────────────────────────────
describe('getEpgSourceData', () => {
it('always includes "No EPG (Disabled)" as the first entry', () => {
const result = getEpgSourceData([]);
expect(result[0]).toEqual({ value: '0', label: 'No EPG (Disabled)' });
});
it('maps an xmltv source correctly', () => {
const result = getEpgSourceData([
makeEpgSource({ id: 1, name: 'My XMLTV', source_type: 'xmltv' }),
]);
expect(result).toContainEqual({ value: '1', label: 'My XMLTV (XMLTV)' });
});
it('maps a dummy source correctly', () => {
const result = getEpgSourceData([
makeEpgSource({ id: 2, name: 'Dummy', source_type: 'dummy' }),
]);
expect(result).toContainEqual({ value: '2', label: 'Dummy (Dummy)' });
});
it('maps a schedules_direct source correctly', () => {
const result = getEpgSourceData([
makeEpgSource({ id: 3, name: 'SD', source_type: 'schedules_direct' }),
]);
expect(result).toContainEqual({
value: '3',
label: 'SD (Schedules Direct)',
// ── getRegexOptions ───────────────────────────────────────────────────────
describe('getRegexOptions', () => {
it('returns an object with the four regex fields', () => {
expect(getRegexOptions('find', 'replace', 'filter', 'exclude')).toEqual({
find: 'find',
replace: 'replace',
match: 'filter',
exclude: 'exclude',
});
});
it('falls back to raw source_type for unknown types', () => {
const result = getEpgSourceData([
makeEpgSource({ id: 4, name: 'Other', source_type: 'iptv' }),
]);
expect(result).toContainEqual({ value: '4', label: 'Other (iptv)' });
it('preserves empty string values', () => {
expect(getRegexOptions('', '', '', '')).toEqual({
find: '',
replace: '',
match: '',
exclude: '',
});
});
});
// ── computeAutoSyncStart ──────────────────────────────────────────────────
describe('computeAutoSyncStart', () => {
it('returns 1 when no other groups are active', () => {
expect(computeAutoSyncStart([], 1)).toBe(1);
});
it('sorts sources alphabetically by name', () => {
const sources = [
makeEpgSource({ id: 1, name: 'Zebra' }),
makeEpgSource({ id: 2, name: 'Apple' }),
makeEpgSource({ id: 3, name: 'Mango' }),
it('skips groups with the same id', () => {
const groups = [
makeGroup({
channel_group: 1,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
}),
];
const result = getEpgSourceData(sources);
const labels = result.slice(1).map((r) => r.label.split(' (')[0]);
expect(labels).toEqual(['Apple', 'Mango', 'Zebra']);
expect(computeAutoSyncStart(groups, 1)).toBe(1);
});
it('does not mutate the original sources array', () => {
const sources = [
makeEpgSource({ id: 1, name: 'Zebra' }),
makeEpgSource({ id: 2, name: 'Apple' }),
it('skips disabled groups', () => {
const groups = [
makeGroup({
channel_group: 2,
enabled: false,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
}),
];
const original = [...sources];
getEpgSourceData(sources);
expect(sources).toEqual(original);
expect(computeAutoSyncStart(groups, 1)).toBe(1);
});
it('returns only the "No EPG" entry when sources array is empty', () => {
expect(getEpgSourceData([])).toHaveLength(1);
it('skips groups with auto_channel_sync off', () => {
const groups = [
makeGroup({
channel_group: 2,
auto_channel_sync: false,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(1);
});
it('skips groups with next_available mode', () => {
const groups = [
makeGroup({
channel_group: 2,
custom_properties: { channel_numbering_mode: 'next_available' },
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(1);
});
it('returns upper + 1 of the highest active group range', () => {
const groups = [
makeGroup({
channel_group: 2,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(201);
});
it('handles multiple groups and picks the highest upper bound', () => {
const groups = [
makeGroup({
channel_group: 2,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
}),
makeGroup({
channel_group: 3,
auto_sync_channel_start: 300,
auto_sync_channel_end: 400,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(401);
});
it('uses start as end when end is null', () => {
const groups = [
makeGroup({
channel_group: 2,
auto_sync_channel_start: 50,
auto_sync_channel_end: null,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(51);
});
it('uses provider fallback for provider mode', () => {
const groups = [
makeGroup({
channel_group: 2,
custom_properties: {
channel_numbering_mode: 'provider',
channel_numbering_fallback: 500,
},
auto_sync_channel_end: 600,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(601);
});
});
// ── isGroupVisible ────────────────────────────────────────────────────────
describe('isGroupVisible', () => {
it('returns true when text and status both match', () => {
const group = makeGroup({ name: 'Sports', enabled: true });
expect(isGroupVisible(group, 'sport', 'enabled')).toBe(true);
});
it('returns false when text does not match', () => {
const group = makeGroup({ name: 'Sports', enabled: true });
expect(isGroupVisible(group, 'news', 'all')).toBe(false);
});
it('returns false when status filter is enabled but group is disabled', () => {
const group = makeGroup({ name: 'Sports', enabled: false });
expect(isGroupVisible(group, 'sport', 'enabled')).toBe(false);
});
it('returns false when status filter is disabled but group is enabled', () => {
const group = makeGroup({ name: 'Sports', enabled: true });
expect(isGroupVisible(group, 'sport', 'disabled')).toBe(false);
});
it('returns true for disabled group with disabled filter', () => {
const group = makeGroup({ name: 'Sports', enabled: false });
expect(isGroupVisible(group, 'sport', 'disabled')).toBe(true);
});
it('matches regardless of case', () => {
const group = makeGroup({ name: 'Sports HD', enabled: true });
expect(isGroupVisible(group, 'SPORTS', 'all')).toBe(true);
});
it('returns true with empty text filter', () => {
const group = makeGroup({ name: 'Sports', enabled: true });
expect(isGroupVisible(group, '', 'all')).toBe(true);
});
});
});