mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
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
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:
commit
a8a52f3251
3 changed files with 164 additions and 5 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue