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 ? ( +
+
{title}
+ {children} +
+ ) : 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'], + } + }); + }); }); });