Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changes/search-token-partial-match.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
type: feature
area: search
---

Advanced search now supports multiple search chips. Each chip is matched as a
whole (all its words must be present), and a result appears if it matches any
chip — ranked so results matching the most chips come first. Searching
`[FR beIN]` `[1968]` surfaces titles hitting both chips ahead of ones hitting
just one.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
getCompoundResidualTokenGroups,
getCompoundSearchWords,
getSearchWordPlans,
parseSearchChips,
scoreGlobalSearchChips,
scoreSearchTextMatch,
shouldUseContentTitlePrefixIndex,
} from './content-search.util';
Expand Down Expand Up @@ -148,4 +150,74 @@ describe('content-search.util', () => {
expect(scoreSearchTextMatch('Test TV', 'tv')).toBeNull();
});
});

describe('parseSearchChips', () => {
it('splits committed chips on newlines and trims blanks', () => {
expect(parseSearchChips('fr bein\n1968')).toEqual([
'fr bein',
'1968',
]);
expect(parseSearchChips(' fr \n\n 1968 \n')).toEqual([
'fr',
'1968',
]);
});

it('treats a plain query as a single chip', () => {
expect(parseSearchChips('France 1968')).toEqual(['France 1968']);
});
});

describe('scoreGlobalSearchChips', () => {
it('collapses a single chip to the plain text score', () => {
const chip = 'France 1968';
expect(scoreGlobalSearchChips('France 1968', [chip])).toBe(
scoreSearchTextMatch('France 1968', chip)
);
});

it('requires every word of a chip (joined, not segmented)', () => {
// "France 2000" is missing the "1968" word of the single chip.
expect(
scoreGlobalSearchChips('France 2000', ['France 1968'])
).toBeNull();
});

it('matches any chip and ranks more matched chips first', () => {
const chips = ['fr', 'bein', '1968'];
const three = scoreGlobalSearchChips('FR beIN 1968', chips);
const two = scoreGlobalSearchChips('FR beIN HD', chips);
const one = scoreGlobalSearchChips('FR Movies', chips);
expect(three).not.toBeNull();
expect(two).not.toBeNull();
expect(one).not.toBeNull();
expect(three as number).toBeLessThan(two as number);
expect(two as number).toBeLessThan(one as number);
});

it('returns null when no chip matches', () => {
expect(
scoreGlobalSearchChips('Spain 2020', ['fr', 'bein', '1968'])
).toBeNull();
});

it('counts a chip as matched when it hits any of several fields', () => {
// "France" is in field 0, "1968" in field 2 -> both chips matched.
const both = scoreGlobalSearchChips(
['France TV', '', '1968 Movies'],
['France', '1968']
);
// Only "France" is present -> a one-chip (missing 1) match.
const one = scoreGlobalSearchChips(
['France TV', '', 'Drama'],
['France', '1968']
);

expect(both).not.toBeNull();
expect(one).not.toBeNull();
expect(both as number).toBeLessThan(1000);
expect(one as number).toBeGreaterThanOrEqual(1000);
expect(both as number).toBeLessThan(one as number);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -368,3 +368,73 @@ export function scoreSearchTextMatch(

return null;
}

/**
* Splits a global ("advanced") search query into chips. Chips are the
* user-committed search units of the header chip input, joined on the wire by
* newlines (a char the single-line chip input can't contain). A query without
* a newline is a single chip — the whole term — which keeps every non-chip
* caller (and older `?q=` links) behaving exactly as before.
*/
export function parseSearchChips(query: unknown): string[] {
if (typeof query !== 'string') {
return [];
}

return query
.split('\n')
.map((chip) => chip.trim())
.filter((chip) => chip.length > 0);
}

/**
* OR-across-chips score for global ("advanced") search. Each chip is matched
* as a joined unit (all its words present, via {@link scoreSearchTextMatch}),
* and a candidate matches if ANY chip matches. Candidates are ranked by how
* many chips they satisfy — more matched chips sort first — with the strongest
* single-chip match breaking ties. Returns null when no chip matches.
*
* `value` may be several searchable fields (M3U channel name, TVG name, group
* title): a chip counts as matched when it matches ANY field, so a candidate
* satisfying different chips in different fields is still ranked by the total
* number of distinct chips matched. A single string field collapses to
* `scoreSearchTextMatch`, so one-chip / single-field searches keep the exact
* ordering they had before chips existed.
*/
export function scoreGlobalSearchChips(
value: string | readonly string[],
chips: readonly string[]
): number | null {
if (chips.length === 0) {
return null;
}

const fields = typeof value === 'string' ? [value] : value;
let matchedChips = 0;
let bestChipScore = Number.POSITIVE_INFINITY;
for (const chip of chips) {
let chipScore: number | null = null;
for (const field of fields) {
const fieldScore = scoreSearchTextMatch(field, chip);
if (fieldScore !== null) {
chipScore =
chipScore === null
? fieldScore
: Math.min(chipScore, fieldScore);
}
}
if (chipScore !== null) {
matchedChips += 1;
bestChipScore = Math.min(bestChipScore, chipScore);
}
}

if (matchedChips === 0) {
return null;
}

// Chip count dominates (fewer missing => lower => ranked first); the best
// single-chip score (0..50) breaks ties within the same count.
const missingChips = chips.length - matchedChips;
return missingChips * 1000 + bestChipScore;
}
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,48 @@ describe('content.operations', () => {
expect(results.map((item) => item.title)).toEqual(['A&E', 'US: A&E']);
});

it('ranks multi-chip (OR) matches by chips matched and drops non-matches', () => {
const makeChannel = (id: string, name: string) => ({
id,
url: `https://stream.test/${id}.m3u8`,
name,
group: { title: 'Movies' },
tvg: { id, name: '', url: '', logo: '', rec: '' },
http: { referrer: '', 'user-agent': '', origin: '' },
radio: '',
});

const results = buildM3uGlobalSearchResults(
[
{
id: 'm3u-1',
name: 'M3U One',
payload: JSON.stringify({
playlist: {
items: [
makeChannel('one-year', 'Spain 1968'),
makeChannel('both', 'France 1968 Movie'),
makeChannel('none', 'Germany 2020'),
makeChannel('one-name', 'France 2000'),
],
},
}),
},
],
// Two committed chips: [France] OR [1968].
'France\n1968',
false
);

// Both-chips match first; each single-chip match still appears (tie
// broken by title); the title matching neither chip is dropped.
expect(results.map((item) => item.title)).toEqual([
'France 1968 Movie',
'France 2000',
'Spain 1968',
]);
});

it('finds compound-word titles anywhere via per-playlist search (issue #1161)', async () => {
const makeRow = (id: number, title: string) => ({
id,
Expand Down
Loading
Loading