mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Merge pull request #894 from CodeBormen/Feature/771-Auto-Matching-Improvments
Feature/771 auto matching improvements
This commit is contained in:
commit
6fb4d42022
7 changed files with 759 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
213
frontend/src/components/modals/EPGMatchModal.jsx
Normal file
213
frontend/src/components/modals/EPGMatchModal.jsx
Normal 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;
|
||||
282
frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
Normal file
282
frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue