Add default/advanced mode toggle and test coverage for EPG matching

Enhanced EPG matching UX by adding radio button toggle to more clearly define optional advanced options. Also added test coverage for stability.

Frontend Changes:
- Added radio button UI to EPGMatchModal for default/advanced mode selection
- Default mode: Uses built-in normalization
- Advanced mode: Shows TagsInput fields for custom prefixes/suffixes/strings
- Mode persists across sessions and settings are preserved when switching modes

Test Coverage:
- Created EPGMatchModal.test.jsx with test cases covering:
  - Component rendering and mode switching
  - Form submission and settings persistence
  - Error handling and UI text variations

- Added test cases to SettingsUtils.test.js for EPG mode handling:
  - epg_match_mode routing to epg_settings group
  - Settings creation and preservation

Since advanced settings are saved persistently but ignored when default settings are used, this adds an easy way in the future to add additional advanced settings to EPG matching like  region influence, match aggression, and match preview, since users can easily switch between modes.
This commit is contained in:
None 2026-01-31 23:11:20 -06:00
parent 0172a7cc00
commit d7b98fef8d
6 changed files with 429 additions and 65 deletions

View file

@ -139,7 +139,7 @@ COMMON_EXTRANEOUS_WORDS = [
def normalize_name(name: str) -> str:
"""
A more aggressive normalization that:
- Removes user-configured prefixes/suffixes/custom strings (if configured)
- Removes user-configured prefixes/suffixes/custom strings (only if mode is 'advanced')
- Lowercases
- Removes bracketed/parenthesized text
- Removes punctuation
@ -157,17 +157,23 @@ def normalize_name(name: str) -> str:
try:
from core.models import CoreSettings
settings = CoreSettings.get_epg_settings()
prefixes = settings.get("epg_match_ignore_prefixes", [])
suffixes = settings.get("epg_match_ignore_suffixes", [])
custom_strings = settings.get("epg_match_ignore_custom", [])
# Ensure we have lists
if not isinstance(prefixes, list):
prefixes = []
if not isinstance(suffixes, list):
suffixes = []
if not isinstance(custom_strings, list):
custom_strings = []
# Check if user has enabled advanced mode
mode = settings.get("epg_match_mode", "default")
# Only use custom settings if mode is 'advanced'
if mode == "advanced":
prefixes = settings.get("epg_match_ignore_prefixes", [])
suffixes = settings.get("epg_match_ignore_suffixes", [])
custom_strings = settings.get("epg_match_ignore_custom", [])
# Ensure we have lists
if not isinstance(prefixes, list):
prefixes = []
if not isinstance(suffixes, list):
suffixes = []
if not isinstance(custom_strings, list):
custom_strings = []
except Exception as e:
# Settings unavailable or error - continue with empty lists (graceful degradation)

View file

@ -233,6 +233,7 @@ class CoreSettings(models.Model):
def get_epg_settings(cls):
"""Get all EPG-related settings."""
return cls._get_group(EPG_SETTINGS_KEY, {
"epg_match_mode": "default",
"epg_match_ignore_prefixes": [],
"epg_match_ignore_suffixes": [],
"epg_match_ignore_custom": [],

View file

@ -1,4 +1,4 @@
import { useMemo, useState, useEffect } from 'react';
import { useMemo, useState, useEffect, useRef } from 'react';
import {
Modal,
Stack,
@ -7,6 +7,7 @@ import {
Group,
Button,
Loader,
Radio,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import useSettingsStore from '../../store/settings';
@ -20,6 +21,7 @@ import {
const getEpgSettingsFromStore = (settings) => {
const epgSettings = settings?.['epg_settings']?.value;
return {
epg_match_mode: epgSettings?.epg_match_mode || 'default',
epg_match_ignore_prefixes: Array.isArray(epgSettings?.epg_match_ignore_prefixes)
? epgSettings.epg_match_ignore_prefixes
: [],
@ -40,6 +42,7 @@ const EPGMatchModal = ({
const settings = useSettingsStore((s) => s.settings);
const [loading, setLoading] = useState(false);
const [settingsMode, setSettingsMode] = useState('default');
// Compute form values directly from settings - memoized for performance
const storedValues = useMemo(
@ -50,18 +53,28 @@ const EPGMatchModal = ({
// Local form state
const [formValues, setFormValues] = useState(storedValues);
// Reset to stored values when modal opens
// Track previous opened state to detect transitions
const prevOpened = useRef(false);
// Reset to stored values and mode only when modal opens (not on storedValues changes)
useEffect(() => {
if (opened) {
// Only reset when transitioning from closed to open
if (opened && !prevOpened.current) {
setFormValues(storedValues);
setSettingsMode(storedValues.epg_match_mode);
}
prevOpened.current = opened;
}, [opened, storedValues]);
const handleConfirm = async () => {
setLoading(true);
try {
// Save settings first
const changedSettings = getChangedSettings(formValues, settings);
// Save mode and settings (backend will ignore custom settings if mode is 'default')
const settingsToSave = {
...formValues,
epg_match_mode: settingsMode,
};
const changedSettings = getChangedSettings(settingsToSave, settings);
if (Object.keys(changedSettings).length > 0) {
await saveChangedSettings(settings, changedSettings);
}
@ -108,59 +121,81 @@ const EPGMatchModal = ({
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Configure how channel names are normalized during matching, then start
the auto-match process for {scopeText}.
Match channels to EPG data for {scopeText}.
</Text>
<TagsInput
label="Ignore Prefixes"
description="Removed from START of channel names (e.g., Prime:, Sling:, US:)"
placeholder="Type and press Enter"
value={formValues.epg_match_ignore_prefixes}
onChange={(value) =>
setFormValues((prev) => ({
...prev,
epg_match_ignore_prefixes: value,
}))
}
splitChars={[]}
clearable
/>
<Radio.Group
value={settingsMode}
onChange={setSettingsMode}
label="Matching Mode"
>
<Stack gap="xs" mt="xs">
<Radio
value="default"
label="Use default settings"
description="Recommended for most users. Handles standard channel name variations automatically."
/>
<Radio
value="advanced"
label="Configure advanced options"
description="Use if channels aren't matching correctly. Add custom prefixes, suffixes, or strings to ignore."
/>
</Stack>
</Radio.Group>
<TagsInput
label="Ignore Suffixes"
description="Removed from END of channel names (e.g., HD, 4K, +1)"
placeholder="Type and press Enter"
value={formValues.epg_match_ignore_suffixes}
onChange={(value) =>
setFormValues((prev) => ({
...prev,
epg_match_ignore_suffixes: value,
}))
}
splitChars={[]}
clearable
/>
{settingsMode === 'advanced' && (
<>
<TagsInput
label="Ignore Prefixes"
description="Removed from START of channel names (e.g., Prime:, Sling:, US:)"
placeholder="Type and press Enter"
value={formValues.epg_match_ignore_prefixes}
onChange={(value) =>
setFormValues((prev) => ({
...prev,
epg_match_ignore_prefixes: value,
}))
}
splitChars={[]}
clearable
/>
<TagsInput
label="Ignore Custom Strings"
description="Removed from ANYWHERE in channel names (e.g., 24/7, LIVE)"
placeholder="Type and press Enter"
value={formValues.epg_match_ignore_custom}
onChange={(value) =>
setFormValues((prev) => ({
...prev,
epg_match_ignore_custom: value,
}))
}
splitChars={[]}
clearable
/>
<TagsInput
label="Ignore Suffixes"
description="Removed from END of channel names (e.g., HD, 4K, +1)"
placeholder="Type and press Enter"
value={formValues.epg_match_ignore_suffixes}
onChange={(value) =>
setFormValues((prev) => ({
...prev,
epg_match_ignore_suffixes: value,
}))
}
splitChars={[]}
clearable
/>
<Text size="xs" c="dimmed">
Channel display names are never modified. These settings only affect
the matching algorithm.
</Text>
<TagsInput
label="Ignore Custom Strings"
description="Removed from ANYWHERE in channel names (e.g., 24/7, LIVE)"
placeholder="Type and press Enter"
value={formValues.epg_match_ignore_custom}
onChange={(value) =>
setFormValues((prev) => ({
...prev,
epg_match_ignore_custom: value,
}))
}
splitChars={[]}
clearable
/>
<Text size="xs" c="dimmed">
Channel display names are never modified. These settings only affect
the matching algorithm.
</Text>
</>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose} disabled={loading}>

View file

@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import EPGMatchModal from '../EPGMatchModal';
import * as SettingsUtils from '../../../utils/pages/SettingsUtils';
import API from '../../../api';
// Mock dependencies
vi.mock('../../../api', () => ({
default: {
matchEpg: vi.fn(),
},
}));
vi.mock('../../../utils/pages/SettingsUtils', () => ({
getChangedSettings: vi.fn(),
saveChangedSettings: vi.fn(),
}));
vi.mock('@mantine/notifications', () => ({
notifications: {
show: vi.fn(),
},
}));
vi.mock('../../../store/settings', () => ({
default: vi.fn((selector) => {
const mockState = {
settings: {
epg_settings: {
value: {
epg_match_mode: 'default',
epg_match_ignore_prefixes: [],
epg_match_ignore_suffixes: [],
epg_match_ignore_custom: [],
},
},
},
};
return selector(mockState);
}),
}));
describe('EPGMatchModal', () => {
const defaultProps = {
opened: true,
onClose: vi.fn(),
selectedChannelIds: [],
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render the modal when opened', () => {
render(<EPGMatchModal {...defaultProps} />);
expect(screen.getByText('EPG Match Settings')).toBeInTheDocument();
});
it('should show default mode selected by default', () => {
render(<EPGMatchModal {...defaultProps} />);
const defaultRadio = screen.getByLabelText('Use default settings');
expect(defaultRadio).toBeChecked();
});
it('should not show advanced fields in default mode', () => {
render(<EPGMatchModal {...defaultProps} />);
expect(screen.queryByLabelText('Ignore Prefixes')).not.toBeInTheDocument();
});
it('should show advanced fields when advanced mode is selected', async () => {
render(<EPGMatchModal {...defaultProps} />);
const advancedRadio = screen.getByLabelText('Configure advanced options');
fireEvent.click(advancedRadio);
await waitFor(() => {
expect(screen.getByLabelText('Ignore Prefixes')).toBeInTheDocument();
expect(screen.getByLabelText('Ignore Suffixes')).toBeInTheDocument();
expect(screen.getByLabelText('Ignore Custom Strings')).toBeInTheDocument();
});
});
});
describe('Mode Switching', () => {
it('should allow switching between default and advanced modes', async () => {
render(<EPGMatchModal {...defaultProps} />);
const defaultRadio = screen.getByLabelText('Use default settings');
const advancedRadio = screen.getByLabelText('Configure advanced options');
expect(defaultRadio).toBeChecked();
fireEvent.click(advancedRadio);
await waitFor(() => {
expect(advancedRadio).toBeChecked();
expect(defaultRadio).not.toBeChecked();
});
fireEvent.click(defaultRadio);
await waitFor(() => {
expect(defaultRadio).toBeChecked();
expect(advancedRadio).not.toBeChecked();
});
});
});
describe('Form Submission', () => {
it('should save mode and trigger auto-match', async () => {
SettingsUtils.getChangedSettings.mockReturnValue({ epg_match_mode: 'default' });
SettingsUtils.saveChangedSettings.mockResolvedValue();
API.matchEpg.mockResolvedValue();
render(<EPGMatchModal {...defaultProps} />);
const submitButton = screen.getByText('Start Auto-Match');
fireEvent.click(submitButton);
await waitFor(() => {
expect(SettingsUtils.saveChangedSettings).toHaveBeenCalled();
expect(API.matchEpg).toHaveBeenCalled();
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
it('should pass selectedChannelIds to matchEpg when provided', async () => {
const selectedIds = [1, 2, 3];
SettingsUtils.getChangedSettings.mockReturnValue({});
API.matchEpg.mockResolvedValue();
render(<EPGMatchModal {...defaultProps} selectedChannelIds={selectedIds} />);
const submitButton = screen.getByText('Start Auto-Match');
fireEvent.click(submitButton);
await waitFor(() => {
expect(API.matchEpg).toHaveBeenCalledWith(selectedIds);
});
});
it('should handle save errors gracefully', async () => {
const error = new Error('Save failed');
SettingsUtils.getChangedSettings.mockReturnValue({ epg_match_mode: 'default' });
SettingsUtils.saveChangedSettings.mockRejectedValue(error);
render(<EPGMatchModal {...defaultProps} />);
const submitButton = screen.getByText('Start Auto-Match');
fireEvent.click(submitButton);
await waitFor(() => {
expect(defaultProps.onClose).not.toHaveBeenCalled();
});
});
});
describe('Settings Persistence', () => {
it('should include epg_match_mode in settings to save', async () => {
SettingsUtils.getChangedSettings.mockImplementation((values) => values);
SettingsUtils.saveChangedSettings.mockResolvedValue();
API.matchEpg.mockResolvedValue();
render(<EPGMatchModal {...defaultProps} />);
const submitButton = screen.getByText('Start Auto-Match');
fireEvent.click(submitButton);
await waitFor(() => {
expect(SettingsUtils.getChangedSettings).toHaveBeenCalledWith(
expect.objectContaining({
epg_match_mode: 'default',
}),
expect.anything()
);
});
});
});
describe('UI Text', () => {
it('should show correct text for selected channels', () => {
render(<EPGMatchModal {...defaultProps} selectedChannelIds={[1, 2, 3]} />);
expect(screen.getByText(/Match channels to EPG data for 3 selected channel\(s\)/)).toBeInTheDocument();
});
it('should show correct text for all channels', () => {
render(<EPGMatchModal {...defaultProps} selectedChannelIds={[]} />);
expect(screen.getByText(/Match channels to EPG data for all channels without EPG/)).toBeInTheDocument();
});
});
});

View file

@ -28,7 +28,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
// Map of field prefixes to their groups
const streamFields = ['default_user_agent', 'default_stream_profile', 'm3u_hash_key', 'preferred_region', 'auto_import_mapped_files'];
const epgFields = ['epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom'];
const epgFields = ['epg_match_mode', 'epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom'];
const dvrFields = ['tv_template', 'movie_template', 'tv_fallback_dir', 'tv_fallback_template', 'movie_fallback_template',
'comskip_enabled', 'comskip_custom_path', 'pre_offset_minutes', 'post_offset_minutes', 'series_rules'];
const backupFields = ['schedule_enabled', 'schedule_frequency', 'schedule_time', 'schedule_day_of_week',
@ -134,6 +134,12 @@ export const getChangedSettings = (values, settings) => {
let actualValue = values[settingKey];
let compareValue;
// Handle EPG mode field - always include (defaults to 'default' if not set)
if (settingKey === 'epg_match_mode') {
changedSettings[settingKey] = actualValue || 'default';
continue;
}
// Handle EPG fields specially - keep as arrays, don't skip empty arrays
if (epgFields.includes(settingKey)) {
if (!Array.isArray(actualValue)) {

View file

@ -407,5 +407,131 @@ describe('SettingsUtils', () => {
expect(changes.network_access).toBeUndefined();
expect(changes.time_zone).toBe('America/New_York');
});
it('should always include epg_match_mode', () => {
const values = {
epg_match_mode: 'advanced',
epg_match_ignore_prefixes: ['HD:'],
};
const settings = {};
const changes = SettingsUtils.getChangedSettings(values, settings);
expect(changes.epg_match_mode).toBe('advanced');
});
it('should default epg_match_mode to "default" if not provided', () => {
const values = {
epg_match_ignore_prefixes: ['HD:'],
};
const settings = {};
const changes = SettingsUtils.getChangedSettings(values, settings);
// epg_match_mode should not be included if not in values
expect(changes.epg_match_mode).toBeUndefined();
});
it('should always include EPG array fields even if empty', () => {
const values = {
epg_match_ignore_prefixes: [],
epg_match_ignore_suffixes: [],
epg_match_ignore_custom: [],
};
const settings = {};
const changes = SettingsUtils.getChangedSettings(values, settings);
expect(changes.epg_match_ignore_prefixes).toEqual([]);
expect(changes.epg_match_ignore_suffixes).toEqual([]);
expect(changes.epg_match_ignore_custom).toEqual([]);
});
});
describe('saveChangedSettings - EPG Mode', () => {
it('should save epg_match_mode to epg_settings group', async () => {
const settings = {
epg_settings: {
id: 3,
key: 'epg_settings',
value: {
epg_match_mode: 'default',
epg_match_ignore_prefixes: [],
epg_match_ignore_suffixes: [],
epg_match_ignore_custom: [],
}
}
};
const changedSettings = {
epg_match_mode: 'advanced',
epg_match_ignore_prefixes: ['HD:'],
};
API.updateSetting.mockResolvedValue({});
await SettingsUtils.saveChangedSettings(settings, changedSettings);
expect(API.updateSetting).toHaveBeenCalledWith({
id: 3,
key: 'epg_settings',
value: {
epg_match_mode: 'advanced',
epg_match_ignore_prefixes: ['HD:'],
epg_match_ignore_suffixes: [],
epg_match_ignore_custom: [],
}
});
});
it('should create epg_settings if it does not exist', async () => {
const settings = {};
const changedSettings = {
epg_match_mode: 'advanced',
epg_match_ignore_prefixes: ['Sling:'],
};
API.createSetting.mockResolvedValue({});
await SettingsUtils.saveChangedSettings(settings, changedSettings);
expect(API.createSetting).toHaveBeenCalledWith({
key: 'epg_settings',
name: 'Epg Settings',
value: {
epg_match_mode: 'advanced',
epg_match_ignore_prefixes: ['Sling:'],
}
});
});
it('should preserve existing EPG settings when updating mode', async () => {
const settings = {
epg_settings: {
id: 3,
key: 'epg_settings',
value: {
epg_match_mode: 'advanced',
epg_match_ignore_prefixes: ['HD:'],
epg_match_ignore_suffixes: [' 4K'],
epg_match_ignore_custom: ['Plus'],
}
}
};
const changedSettings = {
epg_match_mode: 'default',
};
API.updateSetting.mockResolvedValue({});
await SettingsUtils.saveChangedSettings(settings, changedSettings);
expect(API.updateSetting).toHaveBeenCalledWith({
id: 3,
key: 'epg_settings',
value: {
epg_match_mode: 'default',
epg_match_ignore_prefixes: ['HD:'],
epg_match_ignore_suffixes: [' 4K'],
epg_match_ignore_custom: ['Plus'],
}
});
});
});
});