From 8d69fd93743f2524ccffeba27712f2434c3b21d4 Mon Sep 17 00:00:00 2001 From: Matt Grutza Date: Sun, 25 Jan 2026 22:05:28 -0600 Subject: [PATCH 1/7] Add configurable EPG matching normalization settings (Feature #771) Implements user-configurable string removal for EPG matching to improve channel-to-EPG association accuracy. Settings are non-destructive and only affect EPG matching normalization, never modifying actual channel display names. --- apps/channels/tasks.py | 66 +++++++++++- core/models.py | 23 ++++ .../forms/settings/EPGSettingsForm.jsx | 102 ++++++++++++++++++ frontend/src/pages/Settings.jsx | 14 +++ .../forms/settings/EPGSettingsFormUtils.js | 11 ++ frontend/src/utils/pages/SettingsUtils.js | 29 ++++- 6 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/forms/settings/EPGSettingsForm.jsx create mode 100644 frontend/src/utils/forms/settings/EPGSettingsFormUtils.js diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index b9983b87..cd597ca3 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 (if configured) - Lowercases - Removes bracketed/parenthesized text - Removes punctuation @@ -148,7 +149,70 @@ 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() + 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 (defensive) + 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 683acb0d..1037a6e3 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,28 @@ 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_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/forms/settings/EPGSettingsForm.jsx b/frontend/src/components/forms/settings/EPGSettingsForm.jsx new file mode 100644 index 00000000..7cfcc513 --- /dev/null +++ b/frontend/src/components/forms/settings/EPGSettingsForm.jsx @@ -0,0 +1,102 @@ +import useSettingsStore from '../../../store/settings.jsx'; +import React, { useEffect, useState } from 'react'; +import { + getChangedSettings, + parseSettings, + saveChangedSettings, +} from '../../../utils/pages/SettingsUtils.js'; +import { Alert, Button, Flex, Stack, TagsInput, Text } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { getEPGSettingsFormInitialValues } from '../../../utils/forms/settings/EPGSettingsFormUtils.js'; + +const EPGSettingsForm = React.memo(({ active }) => { + const settings = useSettingsStore((s) => s.settings); + + const [saved, setSaved] = useState(false); + + const form = useForm({ + mode: 'controlled', + initialValues: getEPGSettingsFormInitialValues(), + }); + + useEffect(() => { + if (!active) setSaved(false); + }, [active]); + + useEffect(() => { + if (settings) { + const formValues = parseSettings(settings); + + form.setValues(formValues); + } + }, [settings]); + + const onSubmit = async () => { + setSaved(false); + + const changedSettings = getChangedSettings(form.getValues(), settings); + + // Update each changed setting in the backend (create if missing) + try { + await saveChangedSettings(settings, changedSettings); + + setSaved(true); + } catch (error) { + // Error notifications are already shown by API functions + // Just don't show the success message + console.error('Error saving settings:', error); + } + }; + + return ( + + {saved && ( + + )} + + Configure how channel names are normalized during EPG matching. + These settings help channels with different names match the same EPG data. + Channel display names are never modified. + + + + + + + + + + + + + ); +}); + +export default EPGSettingsForm; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 4ce519a3..bbe4631f 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -25,6 +25,8 @@ const ProxySettingsForm = React.lazy(() => import('../components/forms/settings/ProxySettingsForm.jsx')); const StreamSettingsForm = React.lazy(() => import('../components/forms/settings/StreamSettingsForm.jsx')); +const EPGSettingsForm = React.lazy(() => + import('../components/forms/settings/EPGSettingsForm.jsx')); const DvrSettingsForm = React.lazy(() => import('../components/forms/settings/DvrSettingsForm.jsx')); const SystemSettingsForm = React.lazy(() => @@ -78,6 +80,18 @@ const SettingsPage = () => { + + EPG Settings + + + }> + + + + + + System Settings diff --git a/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js b/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js new file mode 100644 index 00000000..7babbbf9 --- /dev/null +++ b/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js @@ -0,0 +1,11 @@ +export const getEPGSettingsFormInitialValues = () => { + return { + epg_match_ignore_prefixes: [], + epg_match_ignore_suffixes: [], + epg_match_ignore_custom: [], + }; +}; + +export const getEPGSettingsFormValidation = () => { + return {}; +}; diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js index 6ee12f60..9cd4b2c8 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_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,20 @@ 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 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 +191,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') { From a1fa737ccdbc260e06e51cf98499eae29d533c77 Mon Sep 17 00:00:00 2001 From: Matt Grutza Date: Sun, 25 Jan 2026 22:08:32 -0600 Subject: [PATCH 2/7] Update verbiage --- apps/channels/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index cd597ca3..8a0dffe1 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -161,7 +161,7 @@ def normalize_name(name: str) -> str: suffixes = settings.get("epg_match_ignore_suffixes", []) custom_strings = settings.get("epg_match_ignore_custom", []) - # Ensure we have lists (defensive) + # Ensure we have lists if not isinstance(prefixes, list): prefixes = [] if not isinstance(suffixes, list): From ad51f7341185ba29f5f42da43d9378631b9743fc Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 27 Jan 2026 13:17:59 -0600 Subject: [PATCH 3/7] Replace drf-yasg pip with drf-spectactular --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3416804d..1f3c63aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ djangorestframework==3.16.1 requests==2.32.5 psutil==7.1.3 pillow -drf-yasg>=1.21.11 +drf-spectacular>=0.28.0 streamlink python-vlc yt-dlp From dfc57c23cc5e3f12acff4f9c725e615e4d05ea0a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 27 Jan 2026 14:52:12 -0600 Subject: [PATCH 4/7] Add pytz to requirements. Was included as a dependency of drf-yasg before. --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1f3c63aa..6858baec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ djangorestframework==3.16.1 requests==2.32.5 psutil==7.1.3 pillow -drf-spectacular>=0.28.0 +drf-spectacular>=0.29.0 streamlink python-vlc yt-dlp @@ -18,6 +18,7 @@ m3u8 rapidfuzz==3.14.3 regex # Required by transformers but also used for advanced regex features tzlocal +pytz # PyTorch dependencies (CPU only) --extra-index-url https://download.pytorch.org/whl/cpu/ From ac48580e637d8b54ad89786056dcf4cdee0d24b7 Mon Sep 17 00:00:00 2001 From: None Date: Thu, 29 Jan 2026 20:35:20 -0600 Subject: [PATCH 5/7] Move EPG match settings from Settings page to auto-match modal Relocate EPG matching configuration (ignore prefixes, suffixes, and custom strings) from the Settings page to a dedicated modal that opens when triggering auto-match. Changes: - frontend/src/components/modals/EPGMatchModal.jsx: Added new modal that combines EPG match settings configuration with the auto-match trigger. - frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx: Added EPGMatchModal import and state. Changed menu item to open modal instead of directly calling API. Removed inline matchEpg function (logic moved to modal) - frontend/src/pages/Settings.jsx: Removed EPG Settings accordion (now accessible via modal) - frontend/src/components/forms/settings/EPGSettingsForm.jsx: Deleted - functionality replaced by EPGMatchModal - frontend/src/utils/forms/settings/EPGSettingsFormUtils.js: Deleted - was only used by EPGSettingsForm (now defunct) --- .../forms/settings/EPGSettingsForm.jsx | 102 ---------- .../src/components/modals/EPGMatchModal.jsx | 178 ++++++++++++++++++ .../ChannelsTable/ChannelTableHeader.jsx | 31 +-- frontend/src/pages/Settings.jsx | 14 -- .../forms/settings/EPGSettingsFormUtils.js | 11 -- 5 files changed, 188 insertions(+), 148 deletions(-) delete mode 100644 frontend/src/components/forms/settings/EPGSettingsForm.jsx create mode 100644 frontend/src/components/modals/EPGMatchModal.jsx delete mode 100644 frontend/src/utils/forms/settings/EPGSettingsFormUtils.js diff --git a/frontend/src/components/forms/settings/EPGSettingsForm.jsx b/frontend/src/components/forms/settings/EPGSettingsForm.jsx deleted file mode 100644 index 7cfcc513..00000000 --- a/frontend/src/components/forms/settings/EPGSettingsForm.jsx +++ /dev/null @@ -1,102 +0,0 @@ -import useSettingsStore from '../../../store/settings.jsx'; -import React, { useEffect, useState } from 'react'; -import { - getChangedSettings, - parseSettings, - saveChangedSettings, -} from '../../../utils/pages/SettingsUtils.js'; -import { Alert, Button, Flex, Stack, TagsInput, Text } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { getEPGSettingsFormInitialValues } from '../../../utils/forms/settings/EPGSettingsFormUtils.js'; - -const EPGSettingsForm = React.memo(({ active }) => { - const settings = useSettingsStore((s) => s.settings); - - const [saved, setSaved] = useState(false); - - const form = useForm({ - mode: 'controlled', - initialValues: getEPGSettingsFormInitialValues(), - }); - - useEffect(() => { - if (!active) setSaved(false); - }, [active]); - - useEffect(() => { - if (settings) { - const formValues = parseSettings(settings); - - form.setValues(formValues); - } - }, [settings]); - - const onSubmit = async () => { - setSaved(false); - - const changedSettings = getChangedSettings(form.getValues(), settings); - - // Update each changed setting in the backend (create if missing) - try { - await saveChangedSettings(settings, changedSettings); - - setSaved(true); - } catch (error) { - // Error notifications are already shown by API functions - // Just don't show the success message - console.error('Error saving settings:', error); - } - }; - - return ( - - {saved && ( - - )} - - Configure how channel names are normalized during EPG matching. - These settings help channels with different names match the same EPG data. - Channel display names are never modified. - - - - - - - - - - - - - ); -}); - -export default EPGSettingsForm; diff --git a/frontend/src/components/modals/EPGMatchModal.jsx b/frontend/src/components/modals/EPGMatchModal.jsx new file mode 100644 index 00000000..fc773d94 --- /dev/null +++ b/frontend/src/components/modals/EPGMatchModal.jsx @@ -0,0 +1,178 @@ +import { useMemo, useState, useEffect } from 'react'; +import { + Modal, + Stack, + Text, + TagsInput, + Group, + Button, + Loader, +} 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_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); + + // Compute form values directly from settings - memoized for performance + const storedValues = useMemo( + () => getEpgSettingsFromStore(settings), + [settings] + ); + + // Local form state + const [formValues, setFormValues] = useState(storedValues); + + // Reset to stored values when modal opens + useEffect(() => { + if (opened) { + setFormValues(storedValues); + } + }, [opened, storedValues]); + + const handleConfirm = async () => { + setLoading(true); + try { + // Save settings first + const changedSettings = getChangedSettings(formValues, 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 ( + + + + Configure how channel names are normalized during matching, then start + the auto-match process for {scopeText}. + + + + 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/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/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index bbe4631f..4ce519a3 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -25,8 +25,6 @@ const ProxySettingsForm = React.lazy(() => import('../components/forms/settings/ProxySettingsForm.jsx')); const StreamSettingsForm = React.lazy(() => import('../components/forms/settings/StreamSettingsForm.jsx')); -const EPGSettingsForm = React.lazy(() => - import('../components/forms/settings/EPGSettingsForm.jsx')); const DvrSettingsForm = React.lazy(() => import('../components/forms/settings/DvrSettingsForm.jsx')); const SystemSettingsForm = React.lazy(() => @@ -80,18 +78,6 @@ const SettingsPage = () => { - - EPG Settings - - - }> - - - - - - System Settings diff --git a/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js b/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js deleted file mode 100644 index 7babbbf9..00000000 --- a/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js +++ /dev/null @@ -1,11 +0,0 @@ -export const getEPGSettingsFormInitialValues = () => { - return { - epg_match_ignore_prefixes: [], - epg_match_ignore_suffixes: [], - epg_match_ignore_custom: [], - }; -}; - -export const getEPGSettingsFormValidation = () => { - return {}; -}; From d7b98fef8d3da676c0dd0f0cd015afc784bc5dce Mon Sep 17 00:00:00 2001 From: None Date: Sat, 31 Jan 2026 23:11:20 -0600 Subject: [PATCH 6/7] 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. --- apps/channels/tasks.py | 28 ++- core/models.py | 1 + .../src/components/modals/EPGMatchModal.jsx | 141 ++++++++----- .../modals/__tests__/EPGMatchModal.test.jsx | 190 ++++++++++++++++++ frontend/src/utils/pages/SettingsUtils.js | 8 +- .../pages/__tests__/SettingsUtils.test.js | 126 ++++++++++++ 6 files changed, 429 insertions(+), 65 deletions(-) create mode 100644 frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 25c0bfa0..8d49287b 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -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) diff --git a/core/models.py b/core/models.py index 1037a6e3..c72192db 100644 --- a/core/models.py +++ b/core/models.py @@ -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": [], diff --git a/frontend/src/components/modals/EPGMatchModal.jsx b/frontend/src/components/modals/EPGMatchModal.jsx index fc773d94..64873643 100644 --- a/frontend/src/components/modals/EPGMatchModal.jsx +++ b/frontend/src/components/modals/EPGMatchModal.jsx @@ -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 = ({ > - Configure how channel names are normalized during matching, then start - the auto-match process for {scopeText}. + Match channels to EPG data for {scopeText}. - - setFormValues((prev) => ({ - ...prev, - epg_match_ignore_prefixes: value, - })) - } - splitChars={[]} - clearable - /> + + + + + + - - setFormValues((prev) => ({ - ...prev, - epg_match_ignore_suffixes: value, - })) - } - splitChars={[]} - clearable - /> + {settingsMode === 'advanced' && ( + <> + + setFormValues((prev) => ({ + ...prev, + epg_match_ignore_prefixes: value, + })) + } + splitChars={[]} + clearable + /> - - setFormValues((prev) => ({ - ...prev, - epg_match_ignore_custom: value, - })) - } - splitChars={[]} - clearable - /> + + setFormValues((prev) => ({ + ...prev, + epg_match_ignore_suffixes: value, + })) + } + splitChars={[]} + clearable + /> - - Channel display names are never modified. These settings only affect - the matching algorithm. - + + setFormValues((prev) => ({ + ...prev, + epg_match_ignore_custom: value, + })) + } + splitChars={[]} + clearable + /> + + + Channel display names are never modified. These settings only affect + the matching algorithm. + + + )} + ), + Group: ({ children }) =>
{children}
, + Loader: () =>
Loading...
, + Text: ({ children }) => {children}, + }; +}); + describe('EPGMatchModal', () => { const defaultProps = { opened: true,