diff --git a/README.md b/README.md index fe9a7b2..d381c68 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ hdtilt is a player. It ships with no content. You add a playlist from a provider - **A global nav** with Live TV (with a filter box), Movies & Shows, TV Guide, ★ Favorites, Recent, Account and Settings. It is always on screen outside the picture; over the picture, Back or Menu brings it up, focused for the remote. - **iPhone and iPad Safari:** they have no Media Source Extensions, so they cannot play an MPEG-TS stream directly. hdtilt asks an Xtream line for its HLS version when it offers one, and otherwise the server repackages the stream as HLS (`ffmpeg -c copy`, no transcoding, shared by everyone watching that channel). Self-hosters need `ffmpeg` on the PATH, or `HDTILT_FFMPEG`. - **Favorites:** tap ☆ on any channel row or on the channel banner, or press F. They sit under ★ Favorites in the nav and sync with your account. -- **The TiviMate basics:** ↑/↓ zaps channels, OK opens the list, ◀ opens groups, digits jump to a channel number, Back returns to the last channel. Favorites, recents and search are built in. Works with a mouse, touch, a keyboard or a TV remote (Fire TV, Android TV, webOS, Tizen keys). +- **The TiviMate basics:** ↑/↓ zaps channels, OK opens the list, ◀ opens groups, digits jump to a channel number, Back returns to the last channel. Favorites and recents are built in. Every filter (Live TV, the guide, Movies & Shows, the CLI and MCP) finds all the words you type in any order, ignoring case, accents and punctuation, so `espn us` finds "US| ESPN HD" and `sports1` finds "Sports 1". Works with a mouse, touch, a keyboard or a TV remote (Fire TV, Android TV, webOS, Tizen keys). ## Accounts diff --git a/package.json b/package.json index 407aebe..505f324 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hdtilt", - "version": "0.3.0", + "version": "0.3.1", "description": "Open-source IPTV player: M3U, Xtream Codes and XMLTV, with a TV guide. Web/PWA, desktop, CLI, TUI, MCP and API.", "license": "MIT", "author": { diff --git a/src/core/config.js b/src/core/config.js index 76e281c..ee67a9f 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -4,6 +4,7 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import { filterByWords } from './match.js'; import { loadSource } from './sources.js'; export const configDir = () => @@ -79,7 +80,7 @@ export async function channelsFor(name, { refresh = false } = {}) { return { name: pick, source, ...data }; } -/** Find channels by number, exact id, or name substring. */ +/** Find channels by number, exact id or name, else every word of the query in any order. */ export function findChannels(channels, query) { const q = String(query || '') .trim() @@ -92,5 +93,5 @@ export function findChannels(channels, query) { } const exact = channels.filter((c) => c.id.toLowerCase() === q || c.name.toLowerCase() === q); if (exact.length) return exact; - return channels.filter((c) => c.name.toLowerCase().includes(q)); + return filterByWords(channels, q); } diff --git a/src/core/match.js b/src/core/match.js new file mode 100644 index 0000000..060451f --- /dev/null +++ b/src/core/match.js @@ -0,0 +1,60 @@ +// One way to filter, everywhere: Live TV, the guide, Movies & Shows, the CLI, +// the TUI and MCP. Every word typed must be somewhere in the text, in any +// order, ignoring case, accents and punctuation. "espn us" finds "US| ESPN HD". +// +// On a TV the filter is often the only way to a channel (Silk on a Fire TV +// cannot scroll the lists with its pointer), so a near miss is not good enough: +// a word also matches across a missing space ("sports1" finds "Sports 1", +// "bbc one" finds "BBCOne"), and a lone number is the channel number. + +/** Lower case, no accents, punctuation as spaces, single spaces. */ +export function fold(s) { + return String(s ?? '') + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim(); +} + +/** The words of a query, folded. */ +export const wordsOf = (q) => fold(q).split(' ').filter(Boolean); + +/** A searchable form of some text: folded, plus the same without spaces. */ +export function haystack(...parts) { + const f = fold(parts.filter(Boolean).join(' ')); + return { f, c: f.replace(/ /g, '') }; +} + +/** Every word in it, anywhere, in any order. */ +export function hasWords(hay, words) { + for (const w of words) if (!hay.f.includes(w) && !hay.c.includes(w)) return false; + return true; +} + +// Folding 50,000 names on every key press is slow on a TV stick, so each item +// keeps its haystack. Items are plain objects, never mutated in place. +const caches = new Map(); // textOf -> WeakMap(item -> haystack) +const hayOf = (x, textOf) => { + let cache = caches.get(textOf); + if (!cache) caches.set(textOf, (cache = new WeakMap())); + let h = cache.get(x); + if (!h) { + h = haystack(...textOf(x)); + cache.set(x, h); + } + return h; +}; + +const channelText = (c) => [c.name, c.group]; + +/** + * Filter a list by a query. `textOf` gives the strings an item is found by + * (default: its name and group). A query of only digits also matches `chno`. + */ +export function filterByWords(list, query, textOf = channelText) { + const words = wordsOf(query); + if (!words.length) return list; + const n = /^\d+$/.test(String(query).trim()) ? Number(String(query).trim()) : null; + return list.filter((x) => (n != null && x.chno === n) || hasWords(hayOf(x, textOf), words)); +} diff --git a/src/core/vod.js b/src/core/vod.js index 9e0d0bb..f418ab5 100644 --- a/src/core/vod.js +++ b/src/core/vod.js @@ -10,6 +10,7 @@ // A plain M3U playlist has its films in the channel list already (entries // under /movie/ and /series/), so the app browses those itself: `local`. +import { fold, wordsOf } from './match.js'; import { normaliseSource } from './sources.js'; import { hash } from './util.js'; import * as xtream from './xtream.js'; @@ -19,18 +20,11 @@ const MAX_INDEXES = 4; const MAX_RESULTS = 300; const indexes = new Map(); // key -> { at, p } -/** Lower case, no accents, no punctuation: what a search compares. */ -export const fold = (s) => - String(s || '') - .normalize('NFD') - .replace(/[̀-ͯ]/g, '') - .toLowerCase() - .replace(/[^\p{L}\p{N}]+/gu, ' ') - .trim(); +export { fold }; -/** Every word of the query, anywhere in the title, in any order. */ +/** Every word of the query, anywhere in the title, in any order (core/match.js rules). */ export function matches(foldedName, words) { - for (const w of words) if (!foldedName.includes(w)) return false; + for (const w of words) if (!foldedName.includes(w) && !foldedName.replace(/ /g, '').includes(w)) return false; return true; } @@ -122,7 +116,7 @@ function indexKey(login, kind) { /** Titles whose name has every word of `q`. Starts-with ranks first. */ export function searchIndex(ix, q, limit = MAX_RESULTS) { - const words = fold(q).split(' ').filter(Boolean); + const words = wordsOf(q); if (!words.length) return { items: [], total: 0 }; const first = []; const rest = []; diff --git a/src/version.js b/src/version.js index ffd80fd..d9a6219 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const VERSION = '0.3.0'; +export const VERSION = '0.3.1'; diff --git a/test/match.test.js b/test/match.test.js new file mode 100644 index 0000000..c1af55d --- /dev/null +++ b/test/match.test.js @@ -0,0 +1,75 @@ +// Filtering: every word, any order, everywhere. +import { describe, expect, it } from 'bun:test'; +import { findChannels } from '../src/core/config.js'; +import { filterByWords, fold, wordsOf } from '../src/core/match.js'; +import { buildIndex, searchIndex } from '../src/core/vod.js'; + +const ch = (name, group = 'All', chno) => ({ id: name, name, group, chno }); +const list = [ + ch('US| ESPN HD', 'US Sports', 101), + ch('ESPN 2 HD', 'US Sports', 102), + ch('Sky Sports 1 UK', 'UK Sports', 201), + ch('BBCOne', 'UK', 202), + ch('Télé Québec', 'CA | Français', 301), + ch('CNN International', 'News', 401), +]; +const names = (q) => filterByWords(list, q).map((c) => c.name); + +describe('filterByWords', () => { + it('wants every word, in any order', () => { + expect(names('espn us')).toEqual(['US| ESPN HD', 'ESPN 2 HD']); // "us" from the name or the group + expect(names('hd espn 2')).toEqual(['ESPN 2 HD']); + expect(names('uk sky')).toEqual(['Sky Sports 1 UK']); + expect(names('espn fox')).toEqual([]); + }); + it('ignores case, accents and punctuation', () => { + expect(names('TELE quebec')).toEqual(['Télé Québec']); + expect(names('us|espn')).toEqual(['US| ESPN HD', 'ESPN 2 HD']); + expect(names('québec télé')).toEqual(['Télé Québec']); + expect(names(' cnn ')).toEqual(['CNN International']); + }); + it('matches across a missing space either way', () => { + expect(names('sports1')).toEqual(['Sky Sports 1 UK']); + expect(names('bbc one')).toEqual(['BBCOne']); + }); + it('finds a group by its name', () => { + expect(names('news')).toEqual(['CNN International']); + }); + it('takes a lone number as the channel number, or a number in a name', () => { + expect(names('201')).toEqual(['Sky Sports 1 UK']); + expect(names('2')).toEqual(['ESPN 2 HD']); // no channel 2; "2" is in one name + }); + it('leaves the list alone with nothing typed', () => { + expect(filterByWords(list, '')).toBe(list); + expect(filterByWords(list, ' ,; ')).toBe(list); + }); + it('folds the way the server does', () => { + expect(fold('Café, SOCIETY!')).toBe('cafe society'); + expect(wordsOf(' A+ b ')).toEqual(['a', 'b']); + }); +}); + +describe('the CLI, TUI and MCP lookup', () => { + it('prefers a number, then an exact name, then every word', () => { + expect(findChannels(list, '101').map((c) => c.name)).toEqual(['US| ESPN HD']); + expect(findChannels(list, 'espn 2 hd').map((c) => c.name)).toEqual(['ESPN 2 HD']); + expect(findChannels(list, 'international cnn').map((c) => c.name)).toEqual(['CNN International']); + }); +}); + +describe('Movies & Shows search', () => { + it('matches across a missing space too', () => { + const ix = buildIndex( + 'movie', + [ + { stream_id: 1, name: 'Spider-Man: No Way Home', category_id: '1' }, + { stream_id: 2, name: 'Spiderman 2', category_id: '1' }, + ], + [], + { server: 'http://h', username: 'u', password: 'p' }, + ); + // Both found; the one whose name starts with the word ranks first. + expect(searchIndex(ix, 'spiderman').items.map((x) => x.name)).toEqual(['Spiderman 2', 'Spider-Man: No Way Home']); + expect(searchIndex(ix, 'home spider').items.map((x) => x.name)).toEqual(['Spider-Man: No Way Home']); + }); +}); diff --git a/web/src/app.js b/web/src/app.js index a321f87..7c2367a 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -3,6 +3,7 @@ import { attachSource, isTvBrowser } from '@profullstack/player'; import { indexAt, nowNext, progress } from '../../src/core/epg.js'; +import { filterByWords } from '../../src/core/match.js'; import { catchupUrl, groupsOf } from '../../src/core/sources.js'; import { b64urlEncode, proxyPath } from '../../src/core/util.js'; import * as account from './account.js'; @@ -216,12 +217,9 @@ function groupList() { return S.live.filter((c) => c.group === S.group); } -/** Name contains it, or it is the channel number. */ +/** Every word in the name or group, any order (core/match.js), or the channel number. */ function filterChannels(list, query) { - if (!query) return list; - const q = query.toLowerCase(); - const n = /^\d+$/.test(q) ? Number(q) : null; - return list.filter((c) => c.name.toLowerCase().includes(q) || (n != null && c.chno === n)); + return filterByWords(list, query); } function setView() { diff --git a/web/src/vod.js b/web/src/vod.js index d69dc31..d182c54 100644 --- a/web/src/vod.js +++ b/web/src/vod.js @@ -6,6 +6,7 @@ // hundreds of thousands of titles; a plain M3U's films are already in the // channel list (its /movie/ and /series/ entries), so those are browsed here. +import { filterByWords, fold } from '../../src/core/match.js'; import * as store from './store.js'; const CONTINUE = 'continue'; @@ -97,20 +98,12 @@ function localCats(kind) { return [...seen].map(([name, n]) => ({ id: `g:${name}`, name, n })); } -const fold = (s) => - String(s || '') - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase(); +// A title is found by its name, and an episode in Continue watching by its show's too. +const titleText = (x) => [x.name, x.show?.name]; -/** Every word, anywhere in the name. */ +/** Every word, anywhere in the title, any order (core/match.js). */ export function filterTitles(list, q) { - const words = fold(q).split(/\s+/).filter(Boolean); - if (!words.length) return list; - return list.filter((x) => { - const n = fold(x.name); - return words.every((w) => n.includes(w)); - }); + return filterByWords(list, q, titleText); } /**