Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
5 changes: 3 additions & 2 deletions src/core/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () =>
Expand Down Expand Up @@ -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()
Expand All @@ -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);
}
60 changes: 60 additions & 0 deletions src/core/match.js
Original file line number Diff line number Diff line change
@@ -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));
}
16 changes: 5 additions & 11 deletions src/core/vod.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
}

Expand Down Expand Up @@ -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 = [];
Expand Down
2 changes: 1 addition & 1 deletion src/version.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = '0.3.0';
export const VERSION = '0.3.1';
75 changes: 75 additions & 0 deletions test/match.test.js
Original file line number Diff line number Diff line change
@@ -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']);
});
});
8 changes: 3 additions & 5 deletions web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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() {
Expand Down
17 changes: 5 additions & 12 deletions web/src/vod.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Loading