From e39cf39401d2b3a5a43129c6b612e2abff7f86fe Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 6 May 2026 23:51:41 -0700 Subject: [PATCH] Reverted LiveGroupFilter/using new version --- .../src/components/forms/LiveGroupFilter.jsx | 2247 ++++++++++++----- .../forms/__tests__/LiveGroupFilter.test.jsx | 1165 ++------- 2 files changed, 1777 insertions(+), 1635 deletions(-) diff --git a/frontend/src/components/forms/LiveGroupFilter.jsx b/frontend/src/components/forms/LiveGroupFilter.jsx index 74f19c38..c4193433 100644 --- a/frontend/src/components/forms/LiveGroupFilter.jsx +++ b/frontend/src/components/forms/LiveGroupFilter.jsx @@ -1,29 +1,37 @@ -// Modal.js -import React, { forwardRef, useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { + TextInput, + Button, + Checkbox, + Flex, + Select, + Stack, + Group, + SimpleGrid, + Text, + NumberInput, + Divider, Alert, Box, - Button, - Center, - Checkbox, - Divider, - Flex, - Group, MultiSelect, - NumberInput, - Popover, - PopoverDropdown, - PopoverTarget, - ScrollArea, - SegmentedControl, - Select, - SimpleGrid, - Stack, - Text, - TextInput, Tooltip, + Popover, + ScrollArea, + Center, + SegmentedControl, + ActionIcon, + Switch, } from '@mantine/core'; -import { CircleCheck, CircleX, Info } from 'lucide-react'; +import { + Info, + CircleCheck, + CircleX, + Settings as Cog, + AlertTriangle, + RefreshCw, +} from 'lucide-react'; +import GroupConfigureModal from './GroupConfigureModal'; +import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../store/channels'; import useStreamProfilesStore from '../../store/streamProfiles'; import { useChannelLogoSelection } from '../../hooks/useSmartLogos'; @@ -31,171 +39,16 @@ import { FixedSizeList as List } from 'react-window'; import LazyLogo from '../LazyLogo'; import LogoForm from './Logo'; import logo from '../../images/logo.png'; -import { - ADVANCED_OPTIONS_CONFIG, - applyAdvancedOptionsChange, - getEPGs, - getEpgSourceData, - getEpgSourceValue, - getSelectedAdvancedOptions, -} from '../../utils/forms/LiveGroupFilterUtils.js'; - -// Custom item component for MultiSelect with tooltip -const OptionWithTooltip = forwardRef( - ({ label, description, ...others }, ref) => ( - -
- {label} -
-
- ) -); - -const LogoPopover = ({ - group, - channelLogos, - logosLoading, - onPopoverChange, - onLogoInputClick, - onFilterChange, - onLogoSelect, -}) => { - const renderLogoList = () => { - const logoOptions = [ - { id: '0', name: 'Default' }, - ...Object.values(channelLogos), - ]; - const filteredLogos = logoOptions.filter((logo) => - logo.name.toLowerCase().includes((group.logoFilter || '').toLowerCase()) - ); - - if (filteredLogos.length === 0) { - return ( -
- - {group.logoFilter - ? 'No logos match your filter' - : 'No logos available'} - -
- ); - } - - return ( - - {({ index, style }) => { - const logoItem = filteredLogos[index]; - return ( -
- onLogoSelect(logoItem.id === '0' ? null : logoItem.id) - } - onMouseEnter={(e) => { - e.currentTarget.style.backgroundColor = 'rgb(68, 68, 68)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = 'transparent'; - }} - > -
- {logoItem.name { - if (e.target.src !== logo) { - e.target.src = logo; - } - }} - /> - - {logoItem.name || 'Default'} - -
-
- ); - }} -
- ); - }; - - return ( - - - - - - e.stopPropagation()}> - - onFilterChange(e.currentTarget.value)} - /> - {logosLoading && ( - - Loading... - - )} - - - {renderLogoList()} - - - ); -}; +import API from '../../api'; +import { getGroupReservation } from '../../utils/forms/GroupSyncUtils'; const LiveGroupFilter = ({ - playlist, - groupStates, - setGroupStates, - autoEnableNewGroupsLive, - setAutoEnableNewGroupsLive, -}) => { + playlist, + groupStates, + setGroupStates, + autoEnableNewGroupsLive, + setAutoEnableNewGroupsLive, + }) => { const channelGroups = useChannelsStore((s) => s.channelGroups); const profiles = useChannelsStore((s) => s.profiles); const streamProfiles = useStreamProfilesStore((s) => s.profiles); @@ -204,7 +57,6 @@ const LiveGroupFilter = ({ const [statusFilter, setStatusFilter] = useState('all'); const [epgSources, setEpgSources] = useState([]); - // Logo selection functionality const { logos: channelLogos, ensureLogosLoaded, @@ -212,6 +64,373 @@ const LiveGroupFilter = ({ } = useChannelLogoSelection(); const [logoModalOpen, setLogoModalOpen] = useState(false); const [currentEditingGroupId, setCurrentEditingGroupId] = useState(null); + const [configuringGroupId, setConfiguringGroupId] = useState(null); + // Snapshot of the configuring group's state taken when the Configure + // modal opens. Cancel restores from this; Done discards it. + const configureSnapshotRef = useRef(null); + // Merged per-group conflict state: { id: { hasChannelConflict: bool } } + // sourced from the debounced /numbers-in-range/ scan plus an in-memory + // overlap check against other groups' ranges in modal state. + const [groupConflicts, setGroupConflicts] = useState({}); + const conflictTimersRef = useRef({}); + // Aborts the previous /numbers-in-range/ call so a slow response cannot + // overwrite newer state. + const conflictAbortRef = useRef({}); + // Conflict state split by source ('occupant' DB scan vs 'form' overlap). + // The render-time `hasChannelConflict` is `occupant || form`; tracking + // both lets the sweep refresh form-overlap synchronously while only + // firing the DB scan when a group's own range changes. + const conflictSourcesRef = useRef({}); + // Signature of each group's conflict-relevant fields from the last sweep. + // The sweep skips the (debounced) DB scan when the signature is + // unchanged, so unrelated state changes do not fan out HTTP requests. + const lastConflictSigRef = useRef({}); + // Per-group regex preview state mirroring the /streams/regex-preview/ + // payload (find/filter results, counts, scan_limit_hit). Cached by + // pattern args; cache lifetime = modal lifetime. + const [regexPreviewState, setRegexPreviewState] = useState({}); + const regexPreviewTimersRef = useRef({}); + const regexPreviewCacheRef = useRef({}); + // Aborts the previous regex preview request so out-of-order responses + // cannot stomp newer state. + const regexPreviewAbortRef = useRef({}); + const configuringGroup = configuringGroupId + ? groupStates.find((g) => g.channel_group === configuringGroupId) + : null; + const applyGroupChange = (nextGroupState) => { + setGroupStates((prev) => + prev.map((state) => + state.channel_group === nextGroupState.channel_group + ? nextGroupState + : state + ) + ); + }; + + // "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. + const isExpectedOccupantForGroup = (occupant, groupChannelGroupId) => { + if (!occupant) return false; + if (!occupant.auto_created) return false; + if (occupant.has_channel_number_override) return false; + if ( + occupant.channel_group_id !== undefined && + occupant.channel_group_id !== groupChannelGroupId + ) + return false; + if ( + occupant.auto_created_by_account_id !== undefined && + playlist?.id !== undefined && + occupant.auto_created_by_account_id !== playlist.id + ) + return false; + return true; + }; + + // Update one source ('occupant' or 'form') of a group's conflict + // tracking and re-merge into the public `groupConflicts` state. + const setConflictSource = (groupId, source, value) => { + const prev = conflictSourcesRef.current[groupId] || { + occupant: false, + form: false, + }; + if (prev[source] === value) return; + const next = { ...prev, [source]: value }; + conflictSourcesRef.current[groupId] = next; + setGroupConflicts((prevState) => ({ + ...prevState, + [groupId]: { hasChannelConflict: next.occupant || next.form }, + })); + }; + + // Debounced /numbers-in-range/ scan; sets `occupant` conflict source + // when any returned channel is not this group's own auto-sync output. + // + // Design: three refs (timer, abort, signature) cooperate to keep the + // request volume tied to user intent rather than render frequency. + // The timer debounces fast keystrokes; the abort controller cancels + // any in-flight request so a slow response cannot stomp newer state; + // and the parent sweep effect skips this scheduler entirely when a + // group's start/end signature has not changed since the last sweep. + // The conflict result is split into 'occupant' (DB scan) and 'form' + // (in-memory range overlap with sibling groups) sources so the sweep + // can refresh form-overlap synchronously without firing HTTP for + // groups that did not change. + const scheduleConflictScan = (groupId, rawStart, rawEnd) => { + if (conflictTimersRef.current[groupId]) { + clearTimeout(conflictTimersRef.current[groupId]); + } + if (conflictAbortRef.current[groupId]) { + conflictAbortRef.current[groupId].abort(); + } + const start = Number(rawStart); + const end = + rawEnd === null || rawEnd === undefined || rawEnd === '' + ? start + : Number(rawEnd); + if (!Number.isFinite(start) || start <= 0) { + setConflictSource(groupId, 'occupant', false); + return; + } + conflictTimersRef.current[groupId] = setTimeout(async () => { + const controller = new AbortController(); + conflictAbortRef.current[groupId] = controller; + try { + const result = await API.getChannelsInRange(start, end, { + signal: controller.signal, + }); + const occupants = Array.isArray(result?.occupants) + ? result.occupants + : []; + const unexpected = occupants.filter( + (o) => !isExpectedOccupantForGroup(o, groupId) + ); + setConflictSource(groupId, 'occupant', unexpected.length > 0); + } catch (e) { + // Aborted by a newer keystroke; the newer call will replace state. + if (e?.name === 'AbortError') return; + throw e; + } + }, 300); + }; + + useEffect(() => { + // Clear pending timers and abort in-flight conflict-scan requests on + // unmount so a late response cannot setState on an unmounted component. + return () => { + Object.values(conflictTimersRef.current).forEach((t) => clearTimeout(t)); + conflictTimersRef.current = {}; + Object.values(conflictAbortRef.current).forEach((c) => { + try { + c.abort(); + } catch { + // ignore + } + }); + conflictAbortRef.current = {}; + }; + }, []); + + // Sweep effect: recomputes form-overlap in-memory for every group + // (cheap). The HTTP-bound DB scan only runs for groups whose own + // range fields changed since the last sweep. + useEffect(() => { + 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 }; + }; + + const ranges = new Map(); + for (const g of groupStates) { + const r = rangeFor(g); + if (r) ranges.set(g.channel_group, r); + } + + for (const g of groupStates) { + const range = ranges.get(g.channel_group); + if (!range) { + // Group out of scope (disabled, mode flipped, or start blanked). + // Abort any in-flight scan so its late response cannot stamp a + // stale 'occupant' value onto the cleared state. + if (conflictTimersRef.current[g.channel_group]) { + clearTimeout(conflictTimersRef.current[g.channel_group]); + delete conflictTimersRef.current[g.channel_group]; + } + if (conflictAbortRef.current[g.channel_group]) { + conflictAbortRef.current[g.channel_group].abort(); + delete conflictAbortRef.current[g.channel_group]; + } + setConflictSource(g.channel_group, 'form', false); + setConflictSource(g.channel_group, 'occupant', false); + delete lastConflictSigRef.current[g.channel_group]; + continue; + } + + let hasFormConflict = false; + for (const [otherId, otherRange] of ranges) { + if (otherId === g.channel_group) continue; + if (range.start <= otherRange.end && otherRange.start <= range.end) { + hasFormConflict = true; + break; + } + } + setConflictSource(g.channel_group, 'form', hasFormConflict); + + const sig = `${range.start}|${range.end}`; + if (lastConflictSigRef.current[g.channel_group] !== sig) { + lastConflictSigRef.current[g.channel_group] = sig; + scheduleConflictScan( + g.channel_group, + range.startRaw, + g.auto_sync_channel_end + ); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groupStates]); + + // Debounced regex preview fetcher. Each call computes a cache key from + // the group + pattern args; identical arg sets reuse the cached result + // instantly. Distinct keys schedule a backend round-trip 500ms after + // the last change so the user can finish typing before the request + // fires. Backend caps in-memory iteration at 5000 streams per call so + // groups with tens of thousands of streams stay performant. Three + // independent patterns are supported per call: find/replace, include + // filter, exclude filter. + const scheduleRegexPreview = (group, opts) => { + const groupId = group.channel_group; + const find = opts.find || ''; + const replace = opts.replace ?? ''; + const match = opts.match || ''; + const exclude = opts.exclude || ''; + const emptyState = { + findResult: null, + filterResult: null, + excludeResult: null, + loading: false, + }; + // Clear any pending request whenever the inputs settle on a state that + // does not require a backend round-trip (all-empty or cache hit). + // Otherwise a 500ms-old timer would still fire and stomp the new state. + const cancelPending = () => { + if (regexPreviewTimersRef.current[groupId]) { + clearTimeout(regexPreviewTimersRef.current[groupId]); + regexPreviewTimersRef.current[groupId] = null; + } + if (regexPreviewAbortRef.current[groupId]) { + regexPreviewAbortRef.current[groupId].abort(); + regexPreviewAbortRef.current[groupId] = null; + } + }; + if (!find && !match && !exclude) { + cancelPending(); + setRegexPreviewState((prev) => ({ ...prev, [groupId]: emptyState })); + return; + } + // Account ID in the cache key so previews stay correct when the + // user switches between accounts that share a group name. + const accountId = playlist?.id ?? ''; + const cacheKey = `${accountId}|${groupId}|${find}|${replace}|${match}|${exclude}`; + const cached = regexPreviewCacheRef.current[cacheKey]; + if (cached) { + cancelPending(); + setRegexPreviewState((prev) => ({ + ...prev, + [groupId]: { ...cached, loading: false }, + })); + return; + } + if (regexPreviewTimersRef.current[groupId]) { + clearTimeout(regexPreviewTimersRef.current[groupId]); + } + if (regexPreviewAbortRef.current[groupId]) { + regexPreviewAbortRef.current[groupId].abort(); + } + setRegexPreviewState((prev) => ({ + ...prev, + [groupId]: { + ...(prev[groupId] || { + findResult: null, + filterResult: null, + excludeResult: null, + }), + loading: true, + }, + })); + regexPreviewTimersRef.current[groupId] = setTimeout(async () => { + const controller = new AbortController(); + regexPreviewAbortRef.current[groupId] = controller; + let response; + try { + response = await 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, + }); + } catch (e) { + if (e?.name === 'AbortError') return; + throw e; + } + if (!response) { + setRegexPreviewState((prev) => ({ ...prev, [groupId]: emptyState })); + return; + } + const buildResult = (key, errorKey) => ({ + matches: response[`${key}_matches`] || [], + match_count: response[`${key}_match_count`] || 0, + total_in_group: response.total_in_group || 0, + total_scanned: response.total_scanned || 0, + scan_limit_hit: !!response.scan_limit_hit, + error: response[errorKey] || null, + }); + const next = { + findResult: find ? buildResult('find', 'find_error') : null, + filterResult: match ? buildResult('filter', 'match_error') : null, + excludeResult: exclude ? buildResult('exclude', 'exclude_error') : null, + loading: false, + }; + regexPreviewCacheRef.current[cacheKey] = next; + setRegexPreviewState((prev) => ({ + ...prev, + [groupId]: next, + })); + }, 500); + }; + + useEffect(() => { + return () => { + Object.values(regexPreviewTimersRef.current).forEach((t) => + clearTimeout(t) + ); + regexPreviewTimersRef.current = {}; + Object.values(regexPreviewAbortRef.current).forEach((c) => { + try { + c.abort(); + } catch { + // ignore + } + }); + regexPreviewAbortRef.current = {}; + }; + }, []); + + // When the gear modal opens (or its open group changes), trigger a + // preview fetch using whatever patterns are already saved on that + // group. Subsequent edits to the patterns trigger their own scheduled + // fetches via the field handlers. + useEffect(() => { + if (!configuringGroup) return; + const cp = configuringGroup.custom_properties || {}; + scheduleRegexPreview(configuringGroup, { + find: cp.name_regex_pattern || '', + replace: cp.name_replace_pattern ?? '', + match: cp.name_match_regex || '', + exclude: cp.name_match_exclude_regex || '', + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [configuringGroup?.channel_group]); // Ensure logos are loaded when component mounts useEffect(() => { @@ -229,7 +448,7 @@ const LiveGroupFilter = ({ useEffect(() => { const fetchEPGSources = async () => { try { - const sources = await getEPGs(); + const sources = await API.getEPGs(); setEpgSources(sources || []); } catch (error) { console.error('Failed to fetch EPG sources:', error); @@ -238,16 +457,28 @@ const LiveGroupFilter = ({ fetchEPGSources(); }, []); + // Build group state once per playlist, not on every prop reference change. + // The parent re-renders this component on WebSocket sync-progress updates, + // which would otherwise blow away in-progress edits while the modal is open. + const lastInitKey = useRef(null); useEffect(() => { if (Object.keys(channelGroups).length === 0) { return; } + const groupIds = (playlist.channel_groups || []) + .map((g) => g.channel_group) + .sort() + .join(','); + const initKey = `${playlist.id}:${groupIds}`; + if (lastInitKey.current === initKey) { + return; + } + lastInitKey.current = initKey; setGroupStates( playlist.channel_groups - .filter((group) => channelGroups[group.channel_group]) // Filter out groups that don't exist + .filter((group) => channelGroups[group.channel_group]) .map((group) => { - // Parse custom_properties if present let customProps = {}; if (group.custom_properties) { try { @@ -264,6 +495,7 @@ const LiveGroupFilter = ({ name: channelGroups[group.channel_group].name, auto_channel_sync: group.auto_channel_sync || false, auto_sync_channel_start: group.auto_sync_channel_start || 1.0, + auto_sync_channel_end: group.auto_sync_channel_end ?? null, custom_properties: customProps, original_enabled: group.enabled, }; @@ -271,59 +503,75 @@ const LiveGroupFilter = ({ ); }, [playlist, channelGroups]); - const updateGroupState = useCallback( - (id, updater) => { - setGroupStates((prev) => - prev.map((state) => - state.channel_group == id ? { ...state, ...updater(state) } : state - ) - ); - }, - [setGroupStates] - ); + const toggleGroupEnabled = (id) => { + setGroupStates((prev) => + prev.map((state) => ({ + ...state, + enabled: state.channel_group == id ? !state.enabled : state.enabled, + })) + ); + }; - const updateCustomProps = useCallback( - (id, propsUpdater) => { - updateGroupState(id, (state) => ({ - custom_properties: { - ...state.custom_properties, - ...propsUpdater(state.custom_properties || {}), - }, - })); - }, - [updateGroupState] - ); + const toggleAutoSync = (id) => { + setGroupStates((prev) => + prev.map((state) => { + if (state.channel_group != id) return state; + const turningOn = !state.auto_channel_sync; + const next = { ...state, auto_channel_sync: turningOn }; + if (!turningOn) return next; - const toggleGroupEnabled = useCallback( - (id) => { - updateGroupState(id, (state) => ({ enabled: !state.enabled })); - }, - [updateGroupState] - ); + // Pick a sensible start when enabling auto-sync: max of other + // groups' end (or start) plus 1, so multiple groups don't all + // default to 1. Skipped if a non-default start is already set. + const currentStart = state.auto_sync_channel_start; + if (currentStart && currentStart > 1) return next; - const toggleAutoSync = useCallback( - (id) => { - updateGroupState(id, (state) => ({ - auto_channel_sync: !state.auto_channel_sync, - })); - }, - [updateGroupState] - ); - - const updateChannelStart = useCallback( - (id, value) => { - updateGroupState(id, () => ({ auto_sync_channel_start: value })); - }, - [updateGroupState] - ); + 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; + } + next.auto_sync_channel_start = proposedStart; + return next; + }) + ); + }; // Handle logo selection from LogoForm const handleLogoSuccess = ({ logo }) => { if (logo && logo.id && currentEditingGroupId !== null) { - updateCustomProps(currentEditingGroupId, () => ({ - custom_logo_id: logo.id, - })); - ensureLogosLoaded(); // Refresh logos + setGroupStates((prev) => + prev.map((state) => { + if (state.channel_group === currentEditingGroupId) { + return { + ...state, + custom_properties: { + ...state.custom_properties, + custom_logo_id: logo.id, + }, + }; + } + return state; + }) + ); + ensureLogosLoaded(); } setLogoModalOpen(false); setCurrentEditingGroupId(null); @@ -358,27 +606,1002 @@ const LiveGroupFilter = ({ ); }; - const handleChangeEpgSource = (group, value) => { - updateGroupState(group.channel_group, (state) => { - const newProps = { ...state.custom_properties }; - if (value === '0') { - // "No EPG (Disabled)" selected - delete newProps.custom_epg_id; - delete newProps.force_epg_selected; - newProps.force_dummy_epg = true; - } else if (value) { - // Specific EPG source selected - newProps.custom_epg_id = parseInt(value); - delete newProps.force_dummy_epg; - delete newProps.force_epg_selected; - } else { - // Cleared - remove all EPG settings - delete newProps.custom_epg_id; - delete newProps.force_dummy_epg; - delete newProps.force_epg_selected; + // Returns {name, start, end}[] for groups whose declared ranges + // intersect this group's range, or [] when there is no overlap. + const computeRangeOverlapsFor = (group) => { + 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 { custom_properties: newProps }; - }); + } + return overlaps; + }; + + // 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. + const renderNumberingRange = (group) => { + const mode = group.custom_properties?.channel_numbering_mode || 'fixed'; + if (mode === 'next_available') { + return ( + + Channels receive the lowest available numbers starting at 1. + + ); + } + 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 MAX_CHANNEL_NUMBER = 999999; + const clampChannelNumber = (n) => + Math.max(1, Math.min(MAX_CHANNEL_NUMBER, Math.floor(Number(n) || 1))); + const updateStart = (value) => { + const normalized = + value === '' || value === null || value === undefined + ? 1 + : clampChannelNumber(value); + if (mode === 'provider') { + applyGroupChange({ + ...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; + } + applyGroupChange(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); + applyGroupChange({ + ...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); + + // 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 ( + + + + + + + + + + + + {metaText} + + {showWarning && ( + + + + )} + + + ); + }; + + // Header line for the preview box. Adds a scan-cap suffix when the + // backend only scanned the first SCAN_CAP streams of the group. + 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'}`; + }; + + // Find/replace regex preview backed by /streams/regex-preview/, so + // counts reflect the whole group (or up to SCAN_CAP) rather than a + // small client-side sample. + const renderRegexPreview = (group) => { + const find = group.custom_properties?.name_regex_pattern || ''; + if (!find) return null; + const state = regexPreviewState[group.channel_group] || {}; + const result = state.findResult; + const loading = state.loading; + return ( + + + {result ? formatPreviewSummary('rename', result) : 'Preview'} + + {result?.error && ( + + Invalid regex: {result.error} + + )} + {loading && !result && ( + + Scanning streams... + + )} + {result && !result.error && result.matches.length === 0 && ( + + {result.total_in_group === 0 + ? 'No streams in this group yet. Run an M3U refresh first to populate streams.' + : 'No streams matched this pattern.'} + + )} + {result?.matches?.map((row, idx) => ( + + + {row.before} + + + {' -> '} + + + {row.after} + + + ))} + + ); + }; + + // 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. + const renderFilterPreview = (group, kind) => { + const pattern = + kind === 'exclude' + ? group.custom_properties?.name_match_exclude_regex || '' + : group.custom_properties?.name_match_regex || ''; + if (!pattern) return null; + const state = regexPreviewState[group.channel_group] || {}; + const result = + kind === 'exclude' ? state.excludeResult : state.filterResult; + const loading = state.loading; + const summaryLabel = kind === 'exclude' ? 'exclude' : 'filter'; + const placeholderLabel = + kind === 'exclude' ? 'Exclude preview' : 'Filter 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 ( + + + {result + ? formatPreviewSummary(summaryLabel, result) + : placeholderLabel} + + {result?.error && ( + + Invalid regex: {result.error} + + )} + {loading && !result && ( + + Scanning streams... + + )} + {result && !result.error && result.matches.length === 0 && ( + + {result.total_in_group === 0 + ? 'No streams in this group yet. Run an M3U refresh first to populate streams.' + : emptyText} + + )} + {result?.matches?.map((row, idx) => ( + + + {markerChar} + + + {row.name} + + + ))} + + ); + }; + + const renderMatchPreview = (group) => renderFilterPreview(group, 'include'); + const renderExcludePreview = (group) => renderFilterPreview(group, 'exclude'); + + // 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 renderAdvancedOptions = (group) => { + const cp = group.custom_properties || {}; + const setCp = (patch, clears = []) => { + const next = { ...cp, ...patch }; + clears.forEach((k) => delete next[k]); + applyGroupChange({ ...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, + }); + } + scheduleRegexPreview(group, { + find: val, + replace: replaceValue, + match: filterValue, + exclude: excludeValue, + }); + }; + const updateReplace = (val) => { + if (!val && !findValue) { + setCp({}, ['name_regex_pattern', 'name_replace_pattern']); + } else { + setCp({ + name_regex_pattern: findValue, + name_replace_pattern: val, + }); + } + scheduleRegexPreview(group, { + find: findValue, + replace: val, + match: filterValue, + exclude: excludeValue, + }); + }; + const updateFilter = (val) => { + if (!val) setCp({}, ['name_match_regex']); + else setCp({ name_match_regex: val }); + scheduleRegexPreview(group, { + find: findValue, + replace: replaceValue, + match: val, + exclude: excludeValue, + }); + }; + const updateExclude = (val) => { + if (!val) setCp({}, ['name_match_exclude_regex']); + else setCp({ name_match_exclude_regex: val }); + scheduleRegexPreview(group, { + find: findValue, + replace: replaceValue, + match: filterValue, + exclude: val, + }); + }; + + // --- EPG --- + + const epgValue = (() => { + if (cp.custom_epg_id !== undefined && cp.custom_epg_id !== null) { + return cp.custom_epg_id.toString(); + } + if (cp.force_dummy_epg) return '0'; + return ''; + })(); + 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); + } + applyGroupChange({ ...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 ( + + + + Name Transforms + + } + labelPosition="left" + size="sm" + color="gray.6" + /> + + + + updateFind(e.currentTarget.value)} + size="xs" + style={{ flex: 1 }} + /> + updateReplace(e.currentTarget.value)} + size="xs" + style={{ flex: 1 }} + /> + + {findValue && renderRegexPreview(group)} + + + + + + updateFilter(e.currentTarget.value)} + size="xs" + /> + {filterValue && renderMatchPreview(group)} + + + + + updateExclude(e.currentTarget.value)} + size="xs" + /> + {excludeValue && renderExcludePreview(group)} + + + + + + + + EPG & Logo + + } + labelPosition="left" + size="sm" + color="gray.6" + /> + + + ({ + value: g.id.toString(), + label: g.name, + }))} + clearable + searchable + size="xs" + style={{ flex: 1 }} + /> + + + ({ + value: profile.id.toString(), + label: profile.name, + }))} + clearable + searchable + size="xs" + style={{ flex: 1 }} + /> + + + + + + {sortReverseEnabled && ( + + updateSortReverse(event.currentTarget.checked) + } + size="xs" + mt="xs" + /> + )} + + + + + + + { + if (event.currentTarget.checked) { + setCp({ compact_numbering: true }); + } else { + setCp({}, ['compact_numbering']); + } + }} + size="xs" + /> + + + + + + + + + ); + }; + + // 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 API.updatePlaylist({ + id: playlist.id, + custom_properties: nextProps, + }); + } catch (err) { + setOrphanCleanupMode(previousMode); + notifications.show({ + title: 'Failed to update cleanup mode', + message: err?.body?.detail || err?.message || 'Please try again.', + color: 'red', + }); + } }; return ( @@ -402,6 +1625,40 @@ const LiveGroupFilter = ({ description="When disabled, new groups from the M3U source will be created but disabled by default. You can enable them manually later." /> + + + + + Auto-sync orphan cleanup + + + + + + {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.'} + + + - + - + toggleAutoSync(group.channel_group)} size="xs" /> + {group.auto_channel_sync && group.enabled && ( + + { + // Snapshot at open time so Cancel can restore + // pre-edit state. custom_properties needs a + // one-level clone since the rest of group + // state is flat. + configureSnapshotRef.current = { + ...group, + custom_properties: { + ...(group.custom_properties || {}), + }, + }; + setConfiguringGroupId(group.channel_group); + }} + aria-label="Configure group" + > + + + + )} {group.auto_channel_sync && group.enabled && ( @@ -526,396 +1810,60 @@ const LiveGroupFilter = ({ w={280} openDelay={500} > - + + Channel Numbering Mode + + { - updateCustomProps(group.channel_group, () => ({ - channel_sort_order: value || '', - })); + setGroupStates((prev) => + prev.map((state) => { + if ( + state.channel_group === group.channel_group + ) { + return { + ...state, + custom_properties: { + ...state.custom_properties, + channel_numbering_mode: + value || 'fixed', + }, + }; + } + return state; + }) + ); }} data={[ - { - value: '', - label: 'Provider Order (Default)', - }, - { value: 'name', label: 'Name' }, - { value: 'tvg_id', label: 'TVG ID' }, - { - value: 'updated_at', - label: 'Updated At', - }, + { value: 'fixed', label: 'Fixed' }, + { value: 'provider', label: 'Provider' }, + { value: 'next_available', label: 'Next Avail' }, ]} - clearable - searchable size="xs" - /> - - {/* Add reverse sort checkbox when sort order is selected (including default) */} - - { - updateCustomProps(group.channel_group, () => ({ - channel_sort_reverse: event.target.checked, - })); - }} - size="xs" - /> - - - )} - - {/* Show profile selection only if profile_assignment is selected */} - {group.custom_properties?.channel_profile_ids !== - undefined && ( - - { - updateCustomProps(group.channel_group, () => ({ - channel_profile_ids: value || [], - })); - }} - data={Object.values(profiles).map((profile) => ({ - value: profile.id.toString(), - label: profile.name, - }))} - clearable - searchable - size="xs" - /> - - )} - - {/* Show group select only if group_override is selected */} - {group.custom_properties?.group_override !== - undefined && ( - - { - const newValue = value ? parseInt(value) : null; - updateCustomProps(group.channel_group, () => ({ - stream_profile_id: newValue, - })); - }} - data={streamProfiles.map((profile) => ({ - value: profile.id.toString(), - label: profile.name, - }))} - clearable - searchable - size="xs" - /> - - )} - - {/* Show regex fields only if name_regex is selected */} - {(group.custom_properties?.name_regex_pattern !== - undefined || - group.custom_properties?.name_replace_pattern !== - undefined) && ( - <> - - { - const val = e.currentTarget.value; - updateCustomProps(group.channel_group, () => ({ - name_regex_pattern: val, - })); - }} - size="xs" - /> - - - { - const val = e.currentTarget.value; - updateCustomProps(group.channel_group, () => ({ - name_replace_pattern: val, - })); - }} - size="xs" - /> - - - )} - - {/* Show name_match_regex field only if selected */} - {group.custom_properties?.name_match_regex !== - undefined && ( - - { - const val = e.currentTarget.value; - updateCustomProps(group.channel_group, () => ({ - name_match_regex: val, - })); - }} - size="xs" - /> - - )} - - {/* Show logo selector only if custom_logo is selected */} - {group.custom_properties?.custom_logo_id !== - undefined && ( - - - { - updateGroupState(group.channel_group, () => ({ - logoPopoverOpened: opened, - })); - if (opened) ensureLogosLoaded(); - }} - onLogoInputClick={() => { - setGroupStates((prev) => - prev.map((state) => ({ - ...state, - logoPopoverOpened: - state.channel_group === - group.channel_group, - })) - ); - }} - onFilterChange={(val) => { - updateGroupState(group.channel_group, () => ({ - logoFilter: val, - })); - }} - onLogoSelect={(logoId) => { - updateCustomProps(group.channel_group, () => ({ - custom_logo_id: logoId, - })); - }} - /> - - - - - - - - - )} - - {/* Show EPG selector when force_epg is selected */} - {(group.custom_properties?.custom_epg_id !== undefined || - group.custom_properties?.force_dummy_epg || - group.custom_properties?.force_epg_selected) && ( - - +// ── @mantine/core minimal mocks ──────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + TextInput: ({ value, onChange, placeholder }) => ( + ), - Button: ({ children, onClick, disabled, variant, size, leftSection }) => ( - - ), - Checkbox: ({ label, checked, onChange, disabled, size, description }) => ( + Button: ({ children, onClick }) => , + Checkbox: ({ label, checked, onChange }) => ( ), - Flex: ({ children, gap }) =>
{children}
, - Select: ({ label, placeholder, value, onChange, data, size }) => ( - onChange?.(e.target.value)}> + {(data ?? []).map((opt) => { + const v = typeof opt === 'string' ? opt : opt.value; + const l = typeof opt === 'string' ? opt : opt.label; + return ( + + ); + })} ), - Stack: ({ children, gap }) =>
{children}
, - Group: ({ children, justify }) => ( -
{children}
- ), - SimpleGrid: ({ children }) =>
{children}
, - Text: ({ children, size, c }) => ( - - {children} - - ), - NumberInput: ({ label, value, onChange, min, step, size }) => ( + Stack: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + SimpleGrid: ({ children }) =>
{children}
, + Text: ({ children }) => {children}, + NumberInput: ({ value, onChange }) => ( onChange(Number(e.target.value))} - min={min} - step={step} - data-size={size} + value={value ?? ''} + onChange={(e) => onChange?.(Number(e.target.value))} /> ), Divider: ({ label }) =>
, - Alert: ({ children, icon }) => ( -
- {icon} - {children} -
- ), - Box: ({ children, style }) =>
{children}
, - MultiSelect: ({ label, placeholder, value, onChange, data, size }) => ( - + {(value ?? []).map((v) => ( + ))} ), - Tooltip: ({ children, label, disabled }) => ( -
- {children} -
- ), - Popover: ({ children, opened }) => ( -
- {children} -
- ), - ScrollArea: ({ children, style }) =>
{children}
, - Center: ({ children }) =>
{children}
, + Tooltip: ({ children }) => <>{children}, + Popover: ({ children }) =>
{children}
, + ScrollArea: ({ children }) =>
{children}
, + Center: ({ children }) =>
{children}
, SegmentedControl: ({ value, onChange, data }) => ( -
- {data?.map((d) => ( +
+ {data.map((opt) => ( ))}
), - PopoverTarget: ({ children }) =>
{children}
, - PopoverDropdown: ({ children, onMouseDown }) => ( -
- {children} -
+ ActionIcon: ({ children, onClick }) => , + Switch: ({ label, checked, onChange }) => ( + ), })); -// ── lucide-react ─────────────────────────────────────────────────────────────── -vi.mock('lucide-react', () => ({ - Info: () => , - CircleCheck: () => , - CircleX: () => , -})); - -// ────────────────────────────────────────────────────────────────────────────── -// Imports after mocks -// ────────────────────────────────────────────────────────────────────────────── +// ── Imports after mocks ──────────────────────────────────────────────────────── +import LiveGroupFilter from '../LiveGroupFilter'; +import API from '../../../api'; +import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../../store/channels'; import useStreamProfilesStore from '../../../store/streamProfiles'; -import { useChannelLogoSelection } from '../../../hooks/useSmartLogos'; -import { - getEPGs, - getEpgSourceData, -} from '../../../utils/forms/LiveGroupFilterUtils.js'; -// ── Fixtures ─────────────────────────────────────────────────────────────────── -const makeChannelGroup = (id, name) => ({ id, name }); +// ────────────────────────────────────────────────────────────────────────────── -const makeGroupState = (overrides = {}) => ({ - channel_group: 1, - name: 'Sports', - enabled: true, - auto_channel_sync: false, - auto_sync_channel_start: 1, - original_enabled: true, - custom_properties: {}, +const makePlaylist = (overrides = {}) => ({ + id: 7, + channel_groups: [], + custom_properties: null, ...overrides, }); -const makePlaylist = (groups = []) => ({ - channel_groups: groups, -}); - -const defaultChannelGroups = { - 1: makeChannelGroup(1, 'Sports'), - 2: makeChannelGroup(2, 'News'), - 3: makeChannelGroup(3, 'Movies'), -}; - -const defaultProfiles = { - 1: { id: 1, name: 'Profile A' }, - 2: { id: 2, name: 'Profile B' }, -}; - -const defaultStreamProfiles = [ - { id: 10, name: 'Stream HD' }, - { id: 11, name: 'Stream SD' }, -]; - -const setupStoreMocks = ({ - channelGroups = defaultChannelGroups, - profiles = defaultProfiles, - streamProfiles = defaultStreamProfiles, - fetchStreamProfiles = vi.fn(), -} = {}) => { +const setupStores = () => { vi.mocked(useChannelsStore).mockImplementation((sel) => - sel({ channelGroups, profiles }) + sel({ channelGroups: {}, profiles: [] }) ); vi.mocked(useStreamProfilesStore).mockImplementation((sel) => - sel({ profiles: streamProfiles, fetchProfiles: fetchStreamProfiles }) + sel({ profiles: [], fetchProfiles: vi.fn() }) ); }; -const setupLogoMock = ({ - logos = { 5: { id: 5, name: 'My Logo', url: '/logos/5.png' } }, - ensureLogosLoaded = vi.fn(), - isLoading = false, -} = {}) => { - vi.mocked(useChannelLogoSelection).mockReturnValue({ - logos, - ensureLogosLoaded, - isLoading, - }); - return { ensureLogosLoaded }; -}; +const renderFilter = (playlistOverrides = {}) => + render( + + ); -const defaultProps = ({ - groupStates = [], - setGroupStates = vi.fn(), - autoEnableNewGroupsLive = true, - setAutoEnableNewGroupsLive = vi.fn(), - playlist = makePlaylist([]), -} = {}) => ({ - playlist, - groupStates, - setGroupStates, - autoEnableNewGroupsLive, - setAutoEnableNewGroupsLive, -}); +// The orphan-cleanup SegmentedControl shares its mocked testid with the +// status-filter SegmentedControl that lives elsewhere in the form, so +// disambiguate by walking from a uniquely-testided child button up to +// its parent SegmentedControl element. +const findCleanupControl = () => + screen.getByTestId('segmented-always').closest('[data-testid="segmented-control"]'); -// ────────────────────────────────────────────────────────────────────────────── - -describe('LiveGroupFilter', () => { +describe('LiveGroupFilter orphan-cleanup SegmentedControl', () => { beforeEach(() => { vi.clearAllMocks(); - setupStoreMocks(); - setupLogoMock(); - vi.mocked(getEPGs).mockResolvedValue([]); + setupStores(); }); - // ── Rendering ──────────────────────────────────────────────────────────── - - describe('rendering', () => { - it('renders the info alert', async () => { - render(); - await waitFor(() => { - expect(screen.getByTestId('alert')).toBeInTheDocument(); - expect(screen.getByText(/Auto Channel Sync/)).toBeInTheDocument(); - }); - }); - - it('renders auto-enable checkbox with correct checked state', async () => { - render( - - ); - const checkbox = screen.getByRole('checkbox', { - name: /Automatically enable new groups/i, - }); - await waitFor(() => { - expect(checkbox).toBeChecked(); - }); - }); - - it('renders auto-enable checkbox unchecked when false', () => { - render( - - ); - const checkbox = screen.getByRole('checkbox', { - name: /Automatically enable new groups/i, - }); - expect(checkbox).not.toBeChecked(); - }); - - it('renders filter input', async () => { - render(); - await waitFor(() => { - expect( - screen.getByPlaceholderText('Filter groups...') - ).toBeInTheDocument(); - }); - }); - - it('renders All/Enabled/Disabled segmented control', async () => { - render(); - await waitFor(() => { - expect(screen.getByText('All')).toBeInTheDocument(); - expect(screen.getByText('Enabled')).toBeInTheDocument(); - expect(screen.getByText('Disabled')).toBeInTheDocument(); - }); - }); - - it('renders Select Visible and Deselect Visible buttons', async () => { - render(); - await waitFor(() => { - expect(screen.getByText('Select Visible')).toBeInTheDocument(); - expect(screen.getByText('Deselect Visible')).toBeInTheDocument(); - }); - }); - - it('renders group cards', async () => { - const groupStates = [makeGroupState()]; - render(); - await waitFor(() => { - expect(screen.getByText('Sports')).toBeInTheDocument(); - }); - }); - - it('renders no group cards when groupStates is empty', () => { - render(); - expect(screen.queryByTestId('icon-circle-check')).not.toBeInTheDocument(); - }); - - it('renders enabled group with CircleCheck icon', async () => { - const groupStates = [makeGroupState({ enabled: true })]; - render(); - await waitFor(() => { - expect(screen.getByTestId('icon-circle-check')).toBeInTheDocument(); - }); - }); - - it('renders disabled group with CircleX icon', async () => { - const groupStates = [makeGroupState({ enabled: false })]; - render(); - await waitFor(() => { - expect(screen.getByTestId('icon-circle-x')).toBeInTheDocument(); - }); - }); + it('defaults to "always" when custom_properties is null', () => { + renderFilter({ custom_properties: null }); + expect(findCleanupControl()).toHaveAttribute('data-value', 'always'); }); - // ── Initialization ─────────────────────────────────────────────────────── - - describe('initialization', () => { - it('calls ensureLogosLoaded on mount', () => { - const { ensureLogosLoaded } = setupLogoMock(); - render(); - expect(ensureLogosLoaded).toHaveBeenCalled(); - }); - - it('calls fetchStreamProfiles when streamProfiles is empty', () => { - const fetchStreamProfiles = vi.fn(); - setupStoreMocks({ streamProfiles: [], fetchStreamProfiles }); - render(); - expect(fetchStreamProfiles).toHaveBeenCalled(); - }); - - it('does not call fetchStreamProfiles when streamProfiles already loaded', () => { - const fetchStreamProfiles = vi.fn(); - setupStoreMocks({ fetchStreamProfiles }); - render(); - expect(fetchStreamProfiles).not.toHaveBeenCalled(); - }); - - it('calls getEPGs on mount', async () => { - render(); - await waitFor(() => expect(getEPGs).toHaveBeenCalled()); - }); - - it('handles getEPGs failure gracefully', async () => { - vi.mocked(getEPGs).mockRejectedValue(new Error('Network error')); - expect(() => - render() - ).not.toThrow(); - }); + it('defaults to "always" when the orphan_channel_cleanup key is absent', () => { + renderFilter({ custom_properties: { compact_numbering: true } }); + expect(findCleanupControl()).toHaveAttribute('data-value', 'always'); }); - // ── setGroupStates on playlist/channelGroups change ────────────────────── + it('reads the persisted mode from custom_properties on mount', () => { + renderFilter({ + custom_properties: { orphan_channel_cleanup: 'preserve_customized' }, + }); + expect(findCleanupControl()).toHaveAttribute( + 'data-value', + 'preserve_customized' + ); + }); - describe('setGroupStates from playlist', () => { - it('calls setGroupStates when channelGroups and playlist are set', () => { - const setGroupStates = vi.fn(); - const playlist = makePlaylist([ - { - channel_group: 1, - enabled: true, - auto_channel_sync: false, - auto_sync_channel_start: 1, + it('PATCHes the playlist with merged custom_properties on click and updates the displayed value', async () => { + renderFilter({ + custom_properties: { compact_numbering: true }, + }); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => { + expect(API.updatePlaylist).toHaveBeenCalledWith({ + id: 7, + custom_properties: { + compact_numbering: true, + orphan_channel_cleanup: 'never', }, - ]); - render( - - ); - expect(setGroupStates).toHaveBeenCalled(); - }); - - it('skips groups that are not in channelGroups', () => { - const setGroupStates = vi.fn(); - const playlist = makePlaylist([{ channel_group: 999, enabled: true }]); - render( - - ); - expect(setGroupStates).toHaveBeenCalledWith([]); - }); - - it('parses custom_properties string in playlist groups', () => { - const setGroupStates = vi.fn(); - const playlist = makePlaylist([ - { - channel_group: 1, - enabled: true, - auto_channel_sync: false, - auto_sync_channel_start: 1, - custom_properties: JSON.stringify({ foo: 'bar' }), - }, - ]); - render( - - ); - const [mappedGroups] = - setGroupStates.mock.calls[setGroupStates.mock.calls.length - 1]; - expect(mappedGroups[0].custom_properties).toEqual({ foo: 'bar' }); - }); - - it('handles invalid custom_properties JSON gracefully', () => { - const setGroupStates = vi.fn(); - const playlist = makePlaylist([ - { channel_group: 1, enabled: true, custom_properties: '{invalid}' }, - ]); - expect(() => - render( - - ) - ).not.toThrow(); - }); - - it('does not call setGroupStates when channelGroups is empty', () => { - setupStoreMocks({ channelGroups: {} }); - const setGroupStates = vi.fn(); - render(); - expect(setGroupStates).not.toHaveBeenCalled(); + }); }); + expect(findCleanupControl()).toHaveAttribute('data-value', 'never'); }); - // ── autoEnableNewGroupsLive checkbox ───────────────────────────────────── - - describe('autoEnableNewGroupsLive toggle', () => { - it('calls setAutoEnableNewGroupsLive when checkbox is changed', () => { - const setAutoEnableNewGroupsLive = vi.fn(); - render( - - ); - fireEvent.click( - screen.getByRole('checkbox', { - name: /Automatically enable new groups/i, - }) - ); - expect(setAutoEnableNewGroupsLive).toHaveBeenCalled(); + it('reverts to the previous mode and surfaces an error toast when the PATCH fails', async () => { + vi.mocked(API.updatePlaylist).mockRejectedValueOnce( + new Error('Server error') + ); + renderFilter({ + custom_properties: { orphan_channel_cleanup: 'always' }, }); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => { + expect(notifications.show).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ); + }); + expect(findCleanupControl()).toHaveAttribute('data-value', 'always'); }); - - // ── toggleGroupEnabled ─────────────────────────────────────────────────── - - describe('toggleGroupEnabled', () => { - it('calls setGroupStates toggling enabled for matching group', async () => { - const groupStates = [makeGroupState({ enabled: true })]; - let lastUpdater; - const setGroupStates = vi.fn((arg) => { - if (typeof arg === 'function') lastUpdater = arg; - }); - render( - - ); - - fireEvent.click(screen.getByText('Sports')); - await waitFor(() => { - expect(setGroupStates).toHaveBeenCalled(); - const result = lastUpdater(groupStates); - expect(result).toEqual( - expect.arrayContaining([ - expect.objectContaining({ channel_group: 1, enabled: false }), - ]) - ); - }); - }); - - it('does not toggle unrelated group', async () => { - const groupStates = [ - makeGroupState({ channel_group: 1, name: 'Sports', enabled: true }), - makeGroupState({ channel_group: 2, name: 'News', enabled: true }), - ]; - let lastUpdater; - const setGroupStates = vi.fn((arg) => { - if (typeof arg === 'function') lastUpdater = arg; - }); - render( - - ); - const buttons = screen.getAllByRole('button', { name: /Sports/ }); - fireEvent.click(buttons[0]); // click Sports - await waitFor(() => { - expect(setGroupStates).toHaveBeenCalled(); - const result = lastUpdater(groupStates); - expect(result[0].enabled).toBe(false); - expect(result[1].enabled).toBe(true); - }); - }); - }); - - // ── Filter ─────────────────────────────────────────────────────────────── - - describe('group text filter', () => { - it('filters groups by name text', () => { - const groupStates = [ - makeGroupState({ channel_group: 1, name: 'Sports' }), - makeGroupState({ channel_group: 2, name: 'News' }), - ]; - render(); - fireEvent.change(screen.getByPlaceholderText('Filter groups...'), { - target: { value: 'sport' }, - }); - expect(screen.getByText('Sports')).toBeInTheDocument(); - expect(screen.queryByText('News')).not.toBeInTheDocument(); - }); - - it('shows all groups when filter is cleared', () => { - const groupStates = [ - makeGroupState({ channel_group: 1, name: 'Sports' }), - makeGroupState({ channel_group: 2, name: 'News' }), - ]; - render(); - const input = screen.getByPlaceholderText('Filter groups...'); - fireEvent.change(input, { target: { value: 'sport' } }); - fireEvent.change(input, { target: { value: '' } }); - expect(screen.getByText('Sports')).toBeInTheDocument(); - expect(screen.getByText('News')).toBeInTheDocument(); - }); - }); - - // ── Status filter ───────────────────────────────────────────────────────── - - describe('status filter', () => { - it('filters to only enabled groups', () => { - const groupStates = [ - makeGroupState({ channel_group: 1, name: 'Sports', enabled: true }), - makeGroupState({ channel_group: 2, name: 'News', enabled: false }), - ]; - render(); - fireEvent.click(screen.getByText('Enabled')); - expect(screen.getByText('Sports')).toBeInTheDocument(); - expect(screen.queryByText('News')).not.toBeInTheDocument(); - }); - - it('filters to only disabled groups', () => { - const groupStates = [ - makeGroupState({ channel_group: 1, name: 'Sports', enabled: true }), - makeGroupState({ channel_group: 2, name: 'News', enabled: false }), - ]; - render(); - fireEvent.click(screen.getByText('Disabled')); - expect(screen.queryByText('Sports')).not.toBeInTheDocument(); - expect(screen.getByText('News')).toBeInTheDocument(); - }); - - it('shows all groups when All is selected', () => { - const groupStates = [ - makeGroupState({ channel_group: 1, name: 'Sports', enabled: true }), - makeGroupState({ channel_group: 2, name: 'News', enabled: false }), - ]; - render(); - fireEvent.click(screen.getByText('Disabled')); - fireEvent.click(screen.getByText('All')); - expect(screen.getByText('Sports')).toBeInTheDocument(); - expect(screen.getByText('News')).toBeInTheDocument(); - }); - }); - - // ── selectAll / deselectAll ─────────────────────────────────────────────── - - describe('Select / Deselect Visible', () => { - it('selectAll enables all visible groups', async () => { - let lastUpdater; - const setGroupStates = vi.fn((arg) => { - if (typeof arg === 'function') lastUpdater = arg; - }); - const groupStates = [ - makeGroupState({ channel_group: 1, name: 'Sports', enabled: false }), - makeGroupState({ channel_group: 2, name: 'News', enabled: false }), - ]; - render( - - ); - fireEvent.click(screen.getByText('Select Visible')); - await waitFor(() => { - expect(setGroupStates).toHaveBeenCalled(); - const result = lastUpdater(groupStates); - expect(result[0].enabled).toBe(true); - expect(result[1].enabled).toBe(true); - }); - }); - - it('deselectAll disables all visible groups', async () => { - let lastUpdater; - const setGroupStates = vi.fn((arg) => { - if (typeof arg === 'function') lastUpdater = arg; - }); - const groupStates = [ - makeGroupState({ channel_group: 1, name: 'Sports', enabled: true }), - makeGroupState({ channel_group: 2, name: 'News', enabled: true }), - ]; - render( - - ); - fireEvent.click(screen.getByText('Deselect Visible')); - await waitFor(() => { - expect(setGroupStates).toHaveBeenCalled(); - const result = lastUpdater(groupStates); - expect(result[0].enabled).toBe(false); - expect(result[1].enabled).toBe(false); - }); - }); - - it('selectAll only enables visible (filtered) groups', async () => { - let lastUpdater; - const setGroupStates = vi.fn((arg) => { - if (typeof arg === 'function') lastUpdater = arg; - }); - const groupStates = [ - makeGroupState({ channel_group: 1, name: 'Sports', enabled: false }), - makeGroupState({ channel_group: 2, name: 'News', enabled: false }), - ]; - render( - - ); - fireEvent.change(screen.getByPlaceholderText('Filter groups...'), { - target: { value: 'Sports' }, - }); - fireEvent.click(screen.getByText('Select Visible')); - await waitFor(() => { - expect(setGroupStates).toHaveBeenCalled(); - const result = lastUpdater(groupStates); - expect(result[0].enabled).toBe(true); // Sports - visible - expect(result[1].enabled).toBe(false); // News - hidden - }); - }); - }); - - // ── Auto Channel Sync checkbox ──────────────────────────────────────────── - - describe('Auto Channel Sync', () => { - it('renders Auto Channel Sync checkbox when group is enabled', () => { - const groupStates = [makeGroupState({ enabled: true })]; - render(); - expect( - screen.getByRole('checkbox', { name: /Auto Channel Sync/i }) - ).toBeInTheDocument(); - }); - - it('Auto Channel Sync checkbox is disabled when group is disabled', () => { - const groupStates = [makeGroupState({ enabled: false })]; - render(); - expect( - screen.getByRole('checkbox', { name: /Auto Channel Sync/i }) - ).toBeDisabled(); - }); - - it('toggleAutoSync calls setGroupStates toggling auto_channel_sync', async () => { - let lastUpdater; - const setGroupStates = vi.fn((arg) => { - if (typeof arg === 'function') lastUpdater = arg; - }); - const groupStates = [ - makeGroupState({ enabled: true, auto_channel_sync: false }), - ]; - render( - - ); - fireEvent.click( - screen.getByRole('checkbox', { name: /Auto Channel Sync/i }) - ); - await waitFor(() => { - expect(setGroupStates).toHaveBeenCalled(); - const result = lastUpdater(groupStates); - expect(result[0].auto_channel_sync).toBe(true); - }); - }); - - it('shows Start Channel # input when auto_channel_sync is enabled', () => { - const groupStates = [ - makeGroupState({ enabled: true, auto_channel_sync: true }), - ]; - render(); - expect( - screen.getByRole('spinbutton', { name: /Start Channel #/i }) - ).toBeInTheDocument(); - }); - - it('hides Start Channel # when auto_channel_sync is false', () => { - const groupStates = [ - makeGroupState({ enabled: true, auto_channel_sync: false }), - ]; - render(); - expect( - screen.queryByRole('spinbutton', { name: /Start Channel #/i }) - ).not.toBeInTheDocument(); - }); - - it('updateChannelStart calls setGroupStates with new value', async () => { - let lastUpdater; - const setGroupStates = vi.fn((arg) => { - if (typeof arg === 'function') lastUpdater = arg; - }); - const groupStates = [ - makeGroupState({ enabled: true, auto_channel_sync: true }), - ]; - render( - - ); - fireEvent.change( - screen.getByRole('spinbutton', { name: /Start Channel #/i }), - { - target: { value: '100' }, - } - ); - await waitFor(() => { - expect(setGroupStates).toHaveBeenCalled(); - const result = lastUpdater(groupStates); - expect(result[0].auto_sync_channel_start).toBe(100); - }); - }); - }); - - // ── Channel Numbering Mode ──────────────────────────────────────────────── - - describe('Channel Numbering Mode', () => { - it('shows Channel Numbering Mode select when auto_channel_sync enabled', () => { - const groupStates = [ - makeGroupState({ enabled: true, auto_channel_sync: true }), - ]; - render(); - expect( - screen.getByRole('combobox', { name: /Channel Numbering Mode/i }) - ).toBeInTheDocument(); - }); - - it('shows Fallback Channel # when numbering mode is provider', () => { - const groupStates = [ - makeGroupState({ - enabled: true, - auto_channel_sync: true, - custom_properties: { channel_numbering_mode: 'provider' }, - }), - ]; - render(); - expect( - screen.getByRole('spinbutton', { name: /Fallback Channel #/i }) - ).toBeInTheDocument(); - }); - - it('hides Start Channel # when mode is not fixed', () => { - const groupStates = [ - makeGroupState({ - enabled: true, - auto_channel_sync: true, - custom_properties: { channel_numbering_mode: 'next_available' }, - }), - ]; - render(); - expect( - screen.queryByRole('spinbutton', { name: /Start Channel #/i }) - ).not.toBeInTheDocument(); - }); - - it('updating Channel Numbering Mode calls setGroupStates', () => { - const setGroupStates = vi.fn(); - const groupStates = [ - makeGroupState({ enabled: true, auto_channel_sync: true }), - ]; - render( - - ); - fireEvent.change( - screen.getByRole('combobox', { name: /Channel Numbering Mode/i }), - { - target: { value: 'provider' }, - } - ); - expect(setGroupStates).toHaveBeenCalled(); - }); - }); - - // ── Advanced Options MultiSelect ────────────────────────────────────────── - - describe('Advanced Options MultiSelect', () => { - const makeAutoSyncGroup = (customProps = {}) => - makeGroupState({ - enabled: true, - auto_channel_sync: true, - custom_properties: customProps, - }); - - it('renders Advanced Options multiselect when auto_channel_sync is enabled', () => { - render( - - ); - expect( - screen.getByRole('listbox', { name: /Advanced Options/i }) - ).toBeInTheDocument(); - }); - - it('shows Channel Name Find input when name_regex_pattern is set', () => { - const groupStates = [ - makeAutoSyncGroup({ - name_regex_pattern: '.*', - name_replace_pattern: '$1', - }), - ]; - render(); - expect( - screen.getByRole('textbox', { name: /Channel Name Find/i }) - ).toBeInTheDocument(); - }); - - it('shows Channel Name Replace input when name_replace_pattern is set', () => { - const groupStates = [ - makeAutoSyncGroup({ - name_regex_pattern: '.*', - name_replace_pattern: '$1', - }), - ]; - render(); - expect( - screen.getByRole('textbox', { name: /Channel Name Replace/i }) - ).toBeInTheDocument(); - }); - - it('shows Channel Name Filter input when name_match_regex is set', () => { - const groupStates = [makeAutoSyncGroup({ name_match_regex: '^Sports' })]; - render(); - expect( - screen.getByRole('textbox', { name: /Channel Name Filter/i }) - ).toBeInTheDocument(); - }); - - it('shows Channel Profiles multiselect when channel_profile_ids is set', () => { - const groupStates = [makeAutoSyncGroup({ channel_profile_ids: [] })]; - render(); - expect( - screen.getByRole('listbox', { name: /Channel Profiles/i }) - ).toBeInTheDocument(); - }); - - it('shows Override Channel Group select when group_override is set', () => { - const groupStates = [makeAutoSyncGroup({ group_override: null })]; - render(); - expect( - screen.getByRole('combobox', { name: /Override Channel Group/i }) - ).toBeInTheDocument(); - }); - - it('shows Stream Profile select when stream_profile_id is set', () => { - const groupStates = [makeAutoSyncGroup({ stream_profile_id: null })]; - render(); - expect( - screen.getByRole('combobox', { name: /Stream Profile/i }) - ).toBeInTheDocument(); - }); - - it('shows Channel Sort Order select when channel_sort_order is set', () => { - const groupStates = [makeAutoSyncGroup({ channel_sort_order: '' })]; - render(); - expect( - screen.getByRole('combobox', { name: /Channel Sort Order/i }) - ).toBeInTheDocument(); - }); - - it('shows Reverse Sort Order checkbox when channel_sort_order is set', () => { - const groupStates = [ - makeAutoSyncGroup({ - channel_sort_order: '', - channel_sort_reverse: false, - }), - ]; - render(); - expect( - screen.getByRole('checkbox', { name: /Reverse Sort Order/i }) - ).toBeInTheDocument(); - }); - - it('shows EPG Source select when force_dummy_epg is set', () => { - const groupStates = [makeAutoSyncGroup({ force_dummy_epg: true })]; - render(); - expect( - screen.getByRole('combobox', { name: /EPG Source/i }) - ).toBeInTheDocument(); - }); - - it('shows EPG Source select when custom_epg_id is set', () => { - const groupStates = [makeAutoSyncGroup({ custom_epg_id: 42 })]; - render(); - expect( - screen.getByRole('combobox', { name: /EPG Source/i }) - ).toBeInTheDocument(); - }); - }); - - // ── Logo handling ───────────────────────────────────────────────────────── - - describe('custom logo', () => { - const makeLogoGroup = () => - makeGroupState({ - enabled: true, - auto_channel_sync: true, - custom_properties: { custom_logo_id: null }, - }); - - it('shows Upload or Create Logo button when custom_logo_id is set', () => { - const groupStates = [makeLogoGroup()]; - render(); - expect(screen.getByText('Upload or Create Logo')).toBeInTheDocument(); - }); - - it('opens LogoForm when Upload or Create Logo is clicked', () => { - const groupStates = [makeLogoGroup()]; - render(); - fireEvent.click(screen.getByText('Upload or Create Logo')); - expect(screen.getByTestId('logo-form')).toBeInTheDocument(); - }); - - it('closes LogoForm when close is clicked', () => { - const groupStates = [makeLogoGroup()]; - render(); - fireEvent.click(screen.getByText('Upload or Create Logo')); - fireEvent.click(screen.getByTestId('logo-form-close')); - expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); - }); - - it('calls setGroupStates with new logo id on success', async () => { - let lastUpdater; - const setGroupStates = vi.fn((arg) => { - if (typeof arg === 'function') lastUpdater = arg; - }); - const groupStates = [makeLogoGroup()]; - render( - - ); - fireEvent.click(screen.getByText('Upload or Create Logo')); - fireEvent.click(screen.getByTestId('logo-form-success')); - expect(setGroupStates).toHaveBeenCalled(); - await waitFor(() => { - expect(setGroupStates).toHaveBeenCalled(); - const result = lastUpdater(groupStates); - expect(result[0].custom_properties.custom_logo_id).toBe(99); - }); - }); - - it('closes LogoForm after successful logo upload', () => { - const groupStates = [makeLogoGroup()]; - render(); - fireEvent.click(screen.getByText('Upload or Create Logo')); - fireEvent.click(screen.getByTestId('logo-form-success')); - expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); - }); - }); - - // ── EPG sources loaded from API ─────────────────────────────────────────── - - describe('EPG sources', () => { - it('populates EPG Source select with sources from API', async () => { - vi.mocked(getEpgSourceData).mockReturnValue([ - { id: 1, value: 'epg_one', label: 'EPG One' }, - { id: 2, value: 'epg_two', label: 'EPG Two' }, - ]); - const groupStates = [ - makeGroupState({ - enabled: true, - auto_channel_sync: true, - custom_properties: { force_dummy_epg: true }, - }), - ]; - render(); - await waitFor(() => { - expect( - screen.getByRole('option', { name: /EPG One/i }) - ).toBeInTheDocument(); - expect( - screen.getByRole('option', { name: /EPG Two/i }) - ).toBeInTheDocument(); - }); - }); - }); - - // ── Group sorting ───────────────────────────────────────────────────────── - - describe('group sorting', () => { - it('renders groups sorted alphabetically', () => { - const groupStates = [ - makeGroupState({ channel_group: 2, name: 'Zebra' }), - makeGroupState({ channel_group: 1, name: 'Alpha' }), - ]; - render(); - const buttons = screen.getAllByRole('button', { name: /Alpha|Zebra/ }); - expect(buttons[0]).toHaveTextContent('Alpha'); - expect(buttons[1]).toHaveTextContent('Zebra'); - }); - }); -}); +}); \ No newline at end of file