diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py
index fe0c2884..8d49287b 100755
--- a/apps/channels/tasks.py
+++ b/apps/channels/tasks.py
@@ -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
diff --git a/core/models.py b/core/models.py
index a694a76b..10f9073f 100644
--- a/core/models.py
+++ b/core/models.py
@@ -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):
diff --git a/frontend/src/components/modals/EPGMatchModal.jsx b/frontend/src/components/modals/EPGMatchModal.jsx
new file mode 100644
index 00000000..64873643
--- /dev/null
+++ b/frontend/src/components/modals/EPGMatchModal.jsx
@@ -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 (
+
+
+
+ Match channels to EPG data for {scopeText}.
+
+
+
+
+
+
+
+
+
+ {settingsMode === 'advanced' && (
+ <>
+
+ setFormValues((prev) => ({
+ ...prev,
+ epg_match_ignore_prefixes: value,
+ }))
+ }
+ splitChars={[]}
+ clearable
+ />
+
+
+ setFormValues((prev) => ({
+ ...prev,
+ epg_match_ignore_suffixes: value,
+ }))
+ }
+ splitChars={[]}
+ clearable
+ />
+
+
+ setFormValues((prev) => ({
+ ...prev,
+ epg_match_ignore_custom: value,
+ }))
+ }
+ splitChars={[]}
+ clearable
+ />
+
+
+ Channel display names are never modified. These settings only affect
+ the matching algorithm.
+
+ >
+ )}
+
+
+
+
+
+
+
+ );
+};
+
+export default EPGMatchModal;
diff --git a/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx b/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
new file mode 100644
index 00000000..3f149347
--- /dev/null
+++ b/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
@@ -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 (
+
+ );
+ };
+
+ 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 (
+
+ {label && {label}}
+ {enhancedChildren}
+
+ );
+ };
+
+ return {
+ Modal: ({ children, opened, title }) =>
+ opened ? (
+
+ ) : null,
+ Stack: ({ children }) => {children}
,
+ Radio: RadioComponent,
+ TagsInput: ({ label, value, onChange, ...props }) => (
+
+
+ onChange(e.target.value.split(',').filter(Boolean))}
+ {...props}
+ />
+
+ ),
+ Button: ({ children, onClick, loading, ...props }) => (
+
+ ),
+ Group: ({ children }) => {children}
,
+ Loader: () => Loading...
,
+ Text: ({ children }) => {children},
+ };
+});
+
+describe('EPGMatchModal', () => {
+ const defaultProps = {
+ opened: true,
+ onClose: vi.fn(),
+ selectedChannelIds: [],
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Rendering', () => {
+ it('should render the modal when opened', () => {
+ render();
+ expect(screen.getByText('EPG Match Settings')).toBeInTheDocument();
+ });
+
+ it('should show default mode selected by default', () => {
+ render();
+ const defaultRadio = screen.getByLabelText('Use default settings');
+ expect(defaultRadio).toBeChecked();
+ });
+
+ it('should not show advanced fields in default mode', () => {
+ render();
+ expect(screen.queryByLabelText('Ignore Prefixes')).not.toBeInTheDocument();
+ });
+
+ it('should show advanced fields when advanced mode is selected', async () => {
+ render();
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+ expect(screen.getByText(/Match channels to EPG data for 3 selected channel\(s\)/)).toBeInTheDocument();
+ });
+
+ it('should show correct text for all channels', () => {
+ render();
+ expect(screen.getByText(/Match channels to EPG data for all channels without EPG/)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
index 3410879a..4e549699 100644
--- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
+++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
@@ -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 = ({
}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
- onClick={matchEpg}
+ onClick={() => setEpgMatchModalOpen(true)}
>
{selectedTableIds.length > 0
@@ -465,6 +448,12 @@ const ChannelTableHeader = ({
onClose={() => setGroupManagerOpen(false)}
/>
+ setEpgMatchModalOpen(false)}
+ selectedChannelIds={selectedTableIds}
+ />
+
setConfirmDeleteProfileOpen(false)}
diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js
index 6ee12f60..c3bb9caf 100644
--- a/frontend/src/utils/pages/SettingsUtils.js
+++ b/frontend/src/utils/pages/SettingsUtils.js
@@ -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') {
diff --git a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
index 1611c7d3..17783f24 100644
--- a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
+++ b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
@@ -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'],
+ }
+ });
+ });
});
});