Merge pull request #1319 from FiveBoroughs:feature/epg-filter-multiword-search
Some checks failed
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Base Image Build / prepare (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled

feat(frontend): multi-word, accent-insensitive EPG channel filter
This commit is contained in:
SergeantPanda 2026-06-11 18:27:54 -05:00 committed by GitHub
commit a8a52f3251
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 164 additions and 5 deletions

View file

@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Progress, bulk completion, and single-channel results use `send_websocket_update` instead of `async_to_sync(channel_layer.group_send)`, so notifications work reliably under gevent-patched uWSGI and Celery workers.
- Single-channel and selected-channel auto-match always run, even when the channel already has EPG assigned; match-all (no channel IDs) still only processes channels without EPG.
- Rematching to the same EPG no longer re-saves the channel or queues program-parse tasks; only assignments that actually change are written and refreshed.
- **Easier EPG search when editing a channel.** The filter in the EPG picker now works more like a normal search box. You can type several words at once (e.g. `sky uk`) and it finds channels where every word appears somewhere in the name or TVG-ID, the order doesn't matter, and a word in the name can pair with one in the ID. Accents are ignored too, so `decale` matches `Décalé` and the other way around. — Thanks [@FiveBoroughs](https://github.com/FiveBoroughs)
### Performance

View file

@ -541,13 +541,21 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
return <></>;
}
// Case- and accent-insensitive.
const foldText = (text) =>
text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
// Every term must match, against name and id combined.
const tvgFilterTerms = foldText(tvgFilter).split(/\s+/).filter(Boolean);
const filteredTvgs = tvgs
.filter((tvg) => tvg.epg_source == selectedEPG)
.filter(
(tvg) =>
tvg.name.toLowerCase().includes(tvgFilter.toLowerCase()) ||
tvg.tvg_id.toLowerCase().includes(tvgFilter.toLowerCase())
);
.filter((tvg) => {
const haystack = foldText(`${tvg.name} ${tvg.tvg_id}`);
return tvgFilterTerms.every((term) => haystack.includes(term));
});
const filteredLogos = logoOptions.filter((logo) =>
logo.name.toLowerCase().includes(logoFilter.toLowerCase())
@ -1128,6 +1136,7 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
{/* Filter Input */}
<TextInput
label="Filter"
name="tvg-filter"
value={tvgFilter}
onChange={(event) =>
setTvgFilter(event.currentTarget.value)

View file

@ -1225,4 +1225,153 @@ describe('ChannelForm', () => {
expect(screen.queryByText(/Clear All Overrides/)).not.toBeInTheDocument();
});
});
// EPG filter search
describe('EPG filter search', () => {
const skyText = 'Sky Sports Ultra HD (SkySportUltraHD.1.uk)';
const btText = 'BT Sport 1 (BTSport1.uk)';
const epgTvgs = [
{
id: 'tvg-sky',
name: 'Sky Sports Ultra HD',
tvg_id: 'SkySportUltraHD.1.uk',
epg_source: 10,
icon_url: '',
},
{
id: 'tvg-bt',
name: 'BT Sport 1',
tvg_id: 'BTSport1.uk',
epg_source: 10,
icon_url: '',
},
// Accent in the name, but the id does NOT mirror it ("decale"): an ascii
// query only matches if the haystack is accent-folded.
{
id: 'tvg-canal',
name: 'Canal Décalé',
tvg_id: 'CPLUS42.fr',
epg_source: 10,
icon_url: '',
},
// Plain ascii everywhere: an accented query only matches if the query
// itself is accent-folded.
{
id: 'tvg-decaleplus',
name: 'Decale Plus',
tvg_id: 'DECP.fr',
epg_source: 10,
icon_url: '',
},
];
const canalText = 'Canal Décalé (CPLUS42.fr)';
const decaleText = 'Decale Plus (DECP.fr)';
// Render the form, point the store at our two same-source tvgs, and pick the
// EPG source so the dropdown list is populated.
const renderAndSelectSource = () => {
setupMocks();
// Stable references recreating these per selector call would retrigger
// the epgs-dependent effect and loop forever.
const epgs = makeEpgs();
const tvgsById = {};
vi.mocked(useEPGsStore).mockImplementation((sel) =>
sel({ epgs, tvgs: epgTvgs, tvgsById })
);
render(<ChannelForm {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-Source'), {
target: { value: '10' },
});
};
const typeFilter = (value) =>
fireEvent.change(screen.getByTestId('text-input-tvg-filter'), {
target: { value },
});
it('matches when space-separated terms each appear across name and id', () => {
renderAndSelectSource();
typeFilter('Sky uk');
expect(screen.getByText(skyText)).toBeInTheDocument();
expect(screen.queryByText(btText)).not.toBeInTheDocument();
});
it('matches regardless of term order', () => {
renderAndSelectSource();
typeFilter('uk sky');
expect(screen.getByText(skyText)).toBeInTheDocument();
expect(screen.queryByText(btText)).not.toBeInTheDocument();
});
it('still matches a single substring term', () => {
renderAndSelectSource();
typeFilter('sky');
expect(screen.getByText(skyText)).toBeInTheDocument();
expect(screen.queryByText(btText)).not.toBeInTheDocument();
});
it('shows every row for the source when the filter is empty', () => {
renderAndSelectSource();
expect(screen.getByText(skyText)).toBeInTheDocument();
expect(screen.getByText(btText)).toBeInTheDocument();
});
it('shows no rows when a term matches nothing', () => {
renderAndSelectSource();
typeFilter('sky zzz');
expect(screen.queryByText(skyText)).not.toBeInTheDocument();
expect(screen.queryByText(btText)).not.toBeInTheDocument();
});
it('ignores case, including all-caps', () => {
renderAndSelectSource();
typeFilter('SKY UK');
expect(screen.getByText(skyText)).toBeInTheDocument();
expect(screen.queryByText(btText)).not.toBeInTheDocument();
});
it('matches when one term hits the name and another hits the id', () => {
renderAndSelectSource();
typeFilter('Sports uk');
expect(screen.getByText(skyText)).toBeInTheDocument();
expect(screen.queryByText(btText)).not.toBeInTheDocument();
});
it('tolerates leading, trailing and repeated whitespace', () => {
renderAndSelectSource();
typeFilter(' sky uk ');
expect(screen.getByText(skyText)).toBeInTheDocument();
expect(screen.queryByText(btText)).not.toBeInTheDocument();
});
it('matches an id fragment that is not in the display name', () => {
renderAndSelectSource();
typeFilter('skysport');
expect(screen.getByText(skyText)).toBeInTheDocument();
expect(screen.queryByText(btText)).not.toBeInTheDocument();
});
it('matches a name word combined with a channel number', () => {
renderAndSelectSource();
typeFilter('bt 1');
expect(screen.getByText(btText)).toBeInTheDocument();
expect(screen.queryByText(skyText)).not.toBeInTheDocument();
});
it('folds accents in the data so a plain ascii query matches', () => {
renderAndSelectSource();
typeFilter('canal decale');
expect(screen.getByText(canalText)).toBeInTheDocument();
expect(screen.queryByText(decaleText)).not.toBeInTheDocument();
expect(screen.queryByText(skyText)).not.toBeInTheDocument();
});
it('folds accents in the query so it matches plain ascii data', () => {
renderAndSelectSource();
typeFilter('décalé');
expect(screen.getByText(decaleText)).toBeInTheDocument();
expect(screen.queryByText(skyText)).not.toBeInTheDocument();
});
});
});