Merge pull request #894 from CodeBormen/Feature/771-Auto-Matching-Improvments

Feature/771 auto matching improvements
This commit is contained in:
SergeantPanda 2026-02-03 10:20:45 -06:00 committed by GitHub
commit 6fb4d42022
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 759 additions and 24 deletions

View file

@ -139,6 +139,7 @@ COMMON_EXTRANEOUS_WORDS = [
def normalize_name(name: str) -> str:
"""
A more aggressive normalization that:
- Removes user-configured prefixes/suffixes/custom strings (only if mode is 'advanced')
- Lowercases
- Removes bracketed/parenthesized text
- Removes punctuation
@ -148,7 +149,76 @@ def normalize_name(name: str) -> str:
if not name:
return ""
norm = name.lower()
# Load user-configured EPG matching rules (fail gracefully)
prefixes = []
suffixes = []
custom_strings = []
try:
from core.models import CoreSettings
settings = CoreSettings.get_epg_settings()
# 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)
logger.debug(f"Could not load EPG matching settings: {e}")
prefixes = []
suffixes = []
custom_strings = []
result = name
# Step 1: Remove prefixes (from START only - exact string match)
for prefix in prefixes:
# Skip empty or non-string entries
if not prefix or not isinstance(prefix, str):
continue
# Exact match at start
if result.startswith(prefix):
result = result[len(prefix):]
break # Only remove first matching prefix
# Step 2: Remove suffixes (from END only - exact string match)
for suffix in suffixes:
# Skip empty or non-string entries
if not suffix or not isinstance(suffix, str):
continue
# Exact match at end
if result.endswith(suffix):
result = result[:-len(suffix)]
break # Only remove first matching suffix
# Step 3: Remove custom strings (from ANYWHERE - exact string match)
for custom in custom_strings:
# Skip empty or non-string entries
if not custom or not isinstance(custom, str):
continue
try:
# Exact string removal (replace with empty string)
result = result.replace(custom, "")
except Exception as e:
# If removal fails for any reason, skip this entry
logger.debug(f"Failed to remove custom string '{custom}': {e}")
continue
# Step 4: Existing normalization logic (unchanged)
norm = result.lower()
norm = re.sub(r"\[.*?\]", "", norm)
# Extract and preserve important call signs from parentheses before removing them

View file

@ -155,6 +155,7 @@ BACKUP_SETTINGS_KEY = "backup_settings"
PROXY_SETTINGS_KEY = "proxy_settings"
NETWORK_ACCESS_KEY = "network_access"
SYSTEM_SETTINGS_KEY = "system_settings"
EPG_SETTINGS_KEY = "epg_settings"
class CoreSettings(models.Model):
@ -227,6 +228,29 @@ class CoreSettings(models.Model):
def get_auto_import_mapped_files(cls):
return cls.get_stream_settings().get("auto_import_mapped_files")
# EPG Settings
@classmethod
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": [],
})
@classmethod
def get_epg_match_ignore_prefixes(cls):
return cls.get_epg_settings().get("epg_match_ignore_prefixes", [])
@classmethod
def get_epg_match_ignore_suffixes(cls):
return cls.get_epg_settings().get("epg_match_ignore_suffixes", [])
@classmethod
def get_epg_match_ignore_custom(cls):
return cls.get_epg_settings().get("epg_match_ignore_custom", [])
# DVR Settings
@classmethod
def get_dvr_settings(cls):

View file

@ -0,0 +1,213 @@
import { useMemo, useState, useEffect, useRef } from 'react';
import {
Modal,
Stack,
Text,
TagsInput,
Group,
Button,
Loader,
Radio,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import useSettingsStore from '../../store/settings';
import API from '../../api';
import {
getChangedSettings,
saveChangedSettings,
} from '../../utils/pages/SettingsUtils';
// Extract EPG settings directly without parsing all settings
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
: [],
epg_match_ignore_suffixes: Array.isArray(epgSettings?.epg_match_ignore_suffixes)
? epgSettings.epg_match_ignore_suffixes
: [],
epg_match_ignore_custom: Array.isArray(epgSettings?.epg_match_ignore_custom)
? epgSettings.epg_match_ignore_custom
: [],
};
};
const EPGMatchModal = ({
opened,
onClose,
selectedChannelIds = [],
}) => {
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(
() => getEpgSettingsFromStore(settings),
[settings]
);
// Local form state
const [formValues, setFormValues] = useState(storedValues);
// 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(() => {
// 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 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);
}
// Then trigger auto-match
if (selectedChannelIds.length > 0) {
await API.matchEpg(selectedChannelIds);
notifications.show({
title: `EPG matching started for ${selectedChannelIds.length} selected channel(s)`,
color: 'green',
});
} else {
await API.matchEpg();
notifications.show({
title: 'EPG matching started for all channels without EPG',
color: 'green',
});
}
onClose();
} catch (error) {
console.error('Error during auto-match:', error);
notifications.show({
title: 'Error',
message: error.message || 'Failed to start EPG matching',
color: 'red',
});
} finally {
setLoading(false);
}
};
const scopeText = selectedChannelIds.length > 0
? `${selectedChannelIds.length} selected channel(s)`
: 'all channels without EPG';
return (
<Modal
opened={opened}
onClose={onClose}
title="EPG Match Settings"
size="md"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Match channels to EPG data for {scopeText}.
</Text>
<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>
{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 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
/>
<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}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={loading}>
{loading ? <Loader size="xs" /> : 'Start Auto-Match'}
</Button>
</Group>
</Stack>
</Modal>
);
};
export default EPGMatchModal;

View file

@ -0,0 +1,282 @@
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);
}),
}));
vi.mock('@mantine/core', () => {
const React = require('react');
const RadioComponent = ({ label, value, checked, description, groupValue, groupOnChange }) => {
const isChecked = checked !== undefined ? checked : groupValue === value;
const handleChange = groupOnChange || (() => {});
return (
<label>
<input
type="radio"
value={value}
checked={isChecked}
onChange={() => handleChange(value)}
aria-label={label}
/>
{label}
{description && <span>{description}</span>}
</label>
);
};
RadioComponent.Group = ({ children, value, onChange, label }) => {
// Clone children and inject group props
const enhancedChildren = React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
// If it's a Stack or other container, recursively enhance its children
if (child.type?.name === 'Stack' || child.props['data-testid'] === 'stack') {
return React.cloneElement(child, {
children: React.Children.map(child.props.children, (nestedChild) => {
if (React.isValidElement(nestedChild) && nestedChild.type === RadioComponent) {
return React.cloneElement(nestedChild, {
groupValue: value,
groupOnChange: onChange,
});
}
return nestedChild;
}),
});
}
// If it's a Radio component, inject props directly
if (child.type === RadioComponent) {
return React.cloneElement(child, {
groupValue: value,
groupOnChange: onChange,
});
}
}
return child;
});
return (
<div role="radiogroup" aria-label={label}>
{label && <span>{label}</span>}
{enhancedChildren}
</div>
);
};
return {
Modal: ({ children, opened, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
{children}
</div>
) : null,
Stack: ({ children }) => <div data-testid="stack">{children}</div>,
Radio: RadioComponent,
TagsInput: ({ label, value, onChange, ...props }) => (
<div>
<label htmlFor={label}>{label}</label>
<input
id={label}
aria-label={label}
value={value?.join(',') || ''}
onChange={(e) => onChange(e.target.value.split(',').filter(Boolean))}
{...props}
/>
</div>
),
Button: ({ children, onClick, loading, ...props }) => (
<button onClick={onClick} disabled={loading} {...props}>
{loading ? 'Loading...' : children}
</button>
),
Group: ({ children }) => <div data-testid="group">{children}</div>,
Loader: () => <div data-testid="loader">Loading...</div>,
Text: ({ children }) => <span>{children}</span>,
};
});
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

@ -44,6 +44,7 @@ import GroupManager from '../../forms/GroupManager';
import ConfirmationDialog from '../../ConfirmationDialog';
import useWarningsStore from '../../../store/warnings';
import ProfileModal, { renderProfileOption } from '../../modals/ProfileModal';
import EPGMatchModal from '../../modals/EPGMatchModal';
const CreateProfilePopover = React.memo(() => {
const [opened, setOpened] = useState(false);
@ -121,6 +122,7 @@ const ChannelTableHeader = ({
const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1);
const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false);
const [groupManagerOpen, setGroupManagerOpen] = useState(false);
const [epgMatchModalOpen, setEpgMatchModalOpen] = useState(false);
const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] =
useState(false);
const [profileToDelete, setProfileToDelete] = useState(null);
@ -178,26 +180,7 @@ const ChannelTableHeader = ({
}
};
const matchEpg = async () => {
try {
// Hit our new endpoint that triggers the fuzzy matching Celery task
// If channels are selected, only match those; otherwise match all
if (selectedTableIds.length > 0) {
await API.matchEpg(selectedTableIds);
notifications.show({
title: `EPG matching task started for ${selectedTableIds.length} selected channel(s)!`,
});
} else {
await API.matchEpg();
notifications.show({
title: 'EPG matching task started for all channels without EPG!',
});
}
} catch (err) {
notifications.show(`Error: ${err.message}`);
}
};
const assignChannels = async () => {
try {
// Call our custom API endpoint
@ -421,7 +404,7 @@ const ChannelTableHeader = ({
<Menu.Item
leftSection={<Binary size={18} />}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
onClick={matchEpg}
onClick={() => setEpgMatchModalOpen(true)}
>
<Text size="xs">
{selectedTableIds.length > 0
@ -465,6 +448,12 @@ const ChannelTableHeader = ({
onClose={() => setGroupManagerOpen(false)}
/>
<EPGMatchModal
opened={epgMatchModalOpen}
onClose={() => setEpgMatchModalOpen(false)}
selectedChannelIds={selectedTableIds}
/>
<ConfirmationDialog
opened={confirmDeleteProfileOpen}
onClose={() => setConfirmDeleteProfileOpen(false)}

View file

@ -20,6 +20,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
// Group changes by their setting group based on field name prefixes
const groupedChanges = {
stream_settings: {},
epg_settings: {},
dvr_settings: {},
backup_settings: {},
system_settings: {},
@ -27,6 +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_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',
@ -58,6 +60,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
}
// Type conversions for proper storage
// EPG fields should remain as arrays, don't convert them
if (formKey === 'm3u_hash_key' && Array.isArray(value)) {
value = value.join(',');
}
@ -79,6 +82,8 @@ export const saveChangedSettings = async (settings, changedSettings) => {
// Route to appropriate group
if (streamFields.includes(formKey)) {
groupedChanges.stream_settings[formKey] = value;
} else if (epgFields.includes(formKey)) {
groupedChanges.epg_settings[formKey] = value;
} else if (dvrFields.includes(formKey)) {
groupedChanges.dvr_settings[formKey] = value;
} else if (backupFields.includes(formKey)) {
@ -114,6 +119,9 @@ export const saveChangedSettings = async (settings, changedSettings) => {
export const getChangedSettings = (values, settings) => {
const changedSettings = {};
// EPG fields that should be kept as arrays
const epgFields = ['epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom'];
for (const settingKey in values) {
// Skip grouped settings that are handled by their own dedicated forms
if (settingKey === 'proxy_settings' || settingKey === 'network_access') {
@ -123,10 +131,26 @@ export const getChangedSettings = (values, settings) => {
// Only compare against existing value if the setting exists
const existing = settings[settingKey];
// Convert array values (like m3u_hash_key) to comma-separated strings for comparison
let compareValue;
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)) {
actualValue = [];
}
// Always include EPG fields in changes (even if empty)
changedSettings[settingKey] = actualValue;
continue;
}
// Convert array values (like m3u_hash_key) to comma-separated strings for comparison
if (Array.isArray(actualValue)) {
actualValue = actualValue.join(',');
compareValue = actualValue;
@ -173,6 +197,13 @@ export const parseSettings = (settings) => {
}
}
// EPG settings - direct mapping with underscore keys
const epgSettings = settings['epg_settings']?.value;
// Always set EPG fields (even if settings don't exist yet)
parsed.epg_match_ignore_prefixes = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_prefixes)) ? epgSettings.epg_match_ignore_prefixes : [];
parsed.epg_match_ignore_suffixes = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_suffixes)) ? epgSettings.epg_match_ignore_suffixes : [];
parsed.epg_match_ignore_custom = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom)) ? epgSettings.epg_match_ignore_custom : [];
// DVR settings - direct mapping with underscore keys
const dvrSettings = settings['dvr_settings']?.value;
if (dvrSettings && typeof dvrSettings === 'object') {

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'],
}
});
});
});
});