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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ hdtilt is a player. It ships with no content. You add a playlist from a provider
- **TV guide:** XMLTV (plain or `.gz`), taken from the playlist's `url-tvg` or given by hand. Channels are matched by `tvg-id`, then by name. Shows what's on now and next, with progress bars and a full guide grid.
- **Catch-up:** Xtream timeshift, plus the M3U `catchup` / `catchup-source` / `tvg-rec` forms (`default`, `append`, `shift`, `flussonic`).
- **Playback** through [`@profullstack/player`](https://github.com/profullstack/player): HLS (hls.js), MPEG-TS (mpegts.js) and MP4. It restarts stalled live streams and explains codec failures.
- **One page per view:** `/watch/<channel>`, `/channels/<group>`, `/favorites`, `/recent`, `/guide` (lists take `?q=` to filter), `/settings`, `/account`. Each can be bookmarked or reloaded, and the browser's Back button (or a remote's) steps back through them.
- **A global nav** with Live TV (with a filter box), 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.
- **Movies & Shows:** an Xtream line's films and series in their own tab, a category at a time (a big line has hundreds of thousands of titles, so nothing loads them all at once). Search covers every title; a show opens into seasons and episodes; Continue watching resumes where you stopped, and the next episode follows. Providers serve films as ripped, usually MKV with AC-3 / E-AC-3 / DTS sound that no browser plays, so the server converts them on the fly: the picture is copied, only the sound becomes AAC (cheap), delivered as HLS. Seeking past what is converted restarts the conversion there. ◀ / ▶ jump 10 s / 30 s, `n` is the next episode. A plain M3U's `/movie/` and `/series/` entries show up there too.
- **One page per view:** `/watch/<channel>`, `/channels/<group>`, `/favorites`, `/recent`, `/guide` (lists take `?q=` to filter), `/vod/movies/<category>`, `/vod/shows/<category>`, `/vod/show/<id>`, `/settings`, `/account`. Each can be bookmarked or reloaded, and the browser's Back button (or a remote's) steps back through them.
- **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).
Expand All @@ -35,8 +36,8 @@ On hdtilt.com an account comes first: sign up (username, email, password), confi
| Desktop | Electron app (Linux AppImage/deb, macOS dmg, Windows exe) from [Releases](https://github.com/profullstack/hdtilt.com/releases) |
| CLI | `hdtilt now`, `hdtilt play cnn`, `hdtilt guide 101` … |
| TUI | `hdtilt tui`: groups, channels and what's on; Enter plays in mpv or VLC |
| MCP | `hdtilt mcp` (stdio), or `POST /mcp` on any server |
| API | JSON under `/api`, plus the stream proxy at `/p/` |
| MCP | `hdtilt mcp` (stdio), or `POST /mcp` on any server; `movies_shows` browses and searches films and series |
| API | JSON under `/api` (`POST /api/vod` for Movies & Shows), plus the stream proxy at `/p/` and conversions at `/hls/` |

## Install

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.2.11",
"version": "0.3.0",
"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
183 changes: 183 additions & 0 deletions src/core/vod.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Movies & Shows: an Xtream line's films and series, browsed a category at a
// time. A big line has hundreds of thousands of films (one measured 213,348,
// 108 MB of JSON), so nothing ever sends the whole catalogue to a screen:
// categories first, then one category's titles, then one show's episodes.
//
// Search is the exception, and it needs every title. The server fetches the
// full list once, keeps only what a match needs (no posters, no plots), and
// remembers it for an hour.
//
// 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 { normaliseSource } from './sources.js';
import { hash } from './util.js';
import * as xtream from './xtream.js';

const INDEX_TTL = 3600_000;
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();

/** Every word of the query, anywhere in the title, in any order. */
export function matches(foldedName, words) {
for (const w of words) if (!foldedName.includes(w)) return false;
return true;
}

const yearIn = (...vs) => {
for (const v of vs) {
const y = /\b(19|20)\d{2}\b/.exec(String(v || ''))?.[0];
if (y) return Number(y);
}
return 0;
};

/**
* A search index from a raw get_vod_streams / get_series list: two strings per
* title (as listed, and folded) and the rest in typed arrays. JSON.parse gives
* every title its own copy of "10660" and "mkv"; 213k of those add up.
*/
export function buildIndex(kind, rawList, cats = [], login = null) {
const list = Array.isArray(rawList) ? rawList : [];
const n = list.length;
const catIds = [];
const catAt = new Map();
const exts = [];
const extAt = new Map();
const intern = (v, arr, at) => {
const k = String(v ?? '');
let i = at.get(k);
if (i === undefined) {
i = arr.push(k) - 1;
at.set(k, i);
}
return i;
};
const ix = {
kind,
login,
names: new Array(n),
folded: new Array(n),
ids: new Float64Array(n),
cat: new Uint32Array(n),
ext: new Uint16Array(n),
year: new Uint16Array(n),
rating: new Float32Array(n),
catIds,
exts,
catName: new Map(cats.map((c) => [String(c.id), c.name])),
};
for (let i = 0; i < n; i++) {
const x = list[i] || {};
const name = String(x.name || '').trim();
ix.names[i] = name;
ix.folded[i] = fold(name);
ix.ids[i] = Number(kind === 'series' ? x.series_id : x.stream_id) || 0;
ix.cat[i] = intern(x.category_id, catIds, catAt);
ix.ext[i] = kind === 'series' ? 0 : intern(x.container_extension || 'mp4', exts, extAt);
ix.year[i] = yearIn(x.year, x.releaseDate, x.release_date, x.releasedate, name);
ix.rating[i] = Number(x.rating) || 0;
}
return ix;
}

// Building one holds a 100+ MB response and its parse at once: one at a time.
let building = Promise.resolve();

/** The whole catalogue of one kind, cut down to what a search needs. */
function indexFor(login, kind, fetchImpl) {
const key = indexKey(login, kind);
const have = indexes.get(key);
if (have && Date.now() - have.at < INDEX_TTL) return have.p;
const p = building
.catch(() => {})
.then(async () => {
const action = kind === 'series' ? 'get_series' : 'get_vod_streams';
const [cats, raw] = await Promise.all([
xtream.vodCategories(login, kind, fetchImpl),
xtream.getJson(xtream.apiUrl(login, action), fetchImpl),
]);
return buildIndex(kind, raw, cats, login);
});
building = p;
indexes.set(key, { at: Date.now(), p });
p.catch(() => indexes.delete(key));
while (indexes.size > MAX_INDEXES) indexes.delete(indexes.keys().next().value);
return p;
}

function indexKey(login, kind) {
return `${kind}:${hash(`${xtream.serverOf(login.server)}|${login.username}|${login.password}`)}`;
}

/** 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);
if (!words.length) return { items: [], total: 0 };
const first = [];
const rest = [];
for (let i = 0; i < ix.folded.length; i++) {
if (!matches(ix.folded[i], words)) continue;
// "LAT - Matrix" starts with a language tag, so "starts with" means any word does.
const f = ix.folded[i];
(f.startsWith(words[0]) || f.includes(` ${words[0]}`) ? first : rest).push(i);
}
const hits = first.concat(rest);
const items = hits.slice(0, limit).map((i) => {
const id = ix.ids[i];
const category = ix.catIds[ix.cat[i]];
const base = {
name: ix.names[i],
group: ix.catName.get(category) || (ix.kind === 'series' ? 'Series' : 'Movies'),
category,
year: ix.year[i] || undefined,
rating: ix.rating[i] ? Math.round(ix.rating[i] * 10) / 10 : undefined,
};
return ix.kind === 'series'
? { id: `s${id}`, seriesId: id, ...base, kind: 'series' }
: { id: `m${id}`, ...base, url: xtream.movieUrl(ix.login, id, ix.exts[ix.ext[i]]), kind: 'vod' };
});
return { items, total: hits.length };
}

/**
* One request from the Movies & Shows screen.
* @param {object} source the playlist's source
* @param {{ kind?: 'movie'|'series', category?: string, q?: string, seriesId?: number }} ask
* @returns {Promise<object>} one of { local }, { categories }, { items }, { items, total }, { show }
*/
export async function browseVod(source, ask = {}, { fetch: fetchImpl = fetch } = {}) {
const src = normaliseSource(source);
if (src.type !== 'xtream') return { local: true };
const kind = ask.kind === 'series' ? 'series' : 'movie';
if (ask.seriesId != null) return { show: await xtream.seriesInfo(src, Number(ask.seriesId), fetchImpl) };
if (ask.q && String(ask.q).trim()) {
const ix = await indexFor(src, kind, fetchImpl);
return searchIndex(ix, String(ask.q));
}
if (ask.category != null && ask.category !== '') {
const category = String(ask.category);
const items =
kind === 'series'
? await xtream.series(src, { category, fetchImpl })
: await xtream.movies(src, { category, fetchImpl });
// Newest first: a category is usually a service or a genre, and what was
// added last week is what someone opened it for.
items.sort((a, b) => (b.added || 0) - (a.added || 0) || (b.year || 0) - (a.year || 0));
return { items };
}
return { categories: await xtream.vodCategories(src, kind, fetchImpl) };
}

/** For tests. */
export const _indexes = indexes;
135 changes: 106 additions & 29 deletions src/core/xtream.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export function timeshiftUrl(login, streamId, startMs, minutes, timeZone = 'UTC'
return `${serverOf(login.server)}/timeshift/${path(login)}/${Math.ceil(minutes)}/${at}/${streamId}.ts`;
}

async function getJson(url, fetchImpl) {
export async function getJson(url, fetchImpl = fetch) {
const res = await fetchImpl(url, { headers: { 'user-agent': 'hdtilt' } });
if (!res.ok) throw new Error(`provider answered ${res.status}`);
const text = await res.text();
Expand Down Expand Up @@ -136,56 +136,133 @@ export async function liveChannels(login, { fetchImpl = fetch, ext } = {}) {
});
}

export async function movies(login, fetchImpl = fetch) {
const [cats, items] = await Promise.all([
getJson(apiUrl(login, 'get_vod_categories'), fetchImpl),
getJson(apiUrl(login, 'get_vod_streams'), fetchImpl),
]);
const catName = new Map((cats || []).map((c) => [String(c.category_id), c.category_name]));
return (items || []).map((m) => ({
/** Movie or series categories: [{ id, name }]. kind is 'movie' or 'series'. */
export async function vodCategories(login, kind, fetchImpl = fetch) {
const action = kind === 'series' ? 'get_series_categories' : 'get_vod_categories';
const cats = await getJson(apiUrl(login, action), fetchImpl);
return (Array.isArray(cats) ? cats : []).map((c) => ({
id: String(c.category_id),
name: String(c.category_name || ''),
}));
}

const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? undefined : Number(v));
const yearOf = (...vs) => {
for (const v of vs) {
const y = /\b(19|20)\d{2}\b/.exec(String(v || ''))?.[0];
if (y) return Number(y);
}
return undefined;
};

/** One movie as get_vod_streams lists it, in the shape the app shows. */
export function movieOf(login, m, catName = new Map()) {
return {
id: `m${m.stream_id}`,
name: m.name,
name: String(m.name || '').trim() || `Movie ${m.stream_id}`,
group: catName.get(String(m.category_id)) || 'Movies',
category: String(m.category_id ?? ''),
logo: m.stream_icon || undefined,
rating: m.rating ? Number(m.rating) : undefined,
rating: num(m.rating) || undefined,
year: yearOf(m.year, m.releasedate, m.name),
added: num(m.added),
url: movieUrl(login, m.stream_id, m.container_extension || 'mp4'),
kind: 'vod',
}));
};
}

export async function series(login, fetchImpl = fetch) {
const [cats, items] = await Promise.all([
getJson(apiUrl(login, 'get_series_categories'), fetchImpl),
getJson(apiUrl(login, 'get_series'), fetchImpl),
]);
const catName = new Map((cats || []).map((c) => [String(c.category_id), c.category_name]));
return (items || []).map((s) => ({
/** One show as get_series lists it. */
export function showOf(s, catName = new Map()) {
return {
id: `s${s.series_id}`,
seriesId: Number(s.series_id),
name: s.name,
name: String(s.name || '').trim() || `Series ${s.series_id}`,
group: catName.get(String(s.category_id)) || 'Series',
category: String(s.category_id ?? ''),
logo: s.cover || undefined,
plot: s.plot || undefined,
rating: num(s.rating) || undefined,
year: yearOf(s.releaseDate, s.release_date, s.name),
added: num(s.last_modified),
kind: 'series',
}));
};
}

export async function episodes(login, seriesId, fetchImpl = fetch) {
/** Movies, all of them or one category's. All of them can be 100+ MB of JSON. */
export async function movies(login, { category, fetchImpl = fetch } = {}) {
const [cats, items] = await Promise.all([
category ? [] : getJson(apiUrl(login, 'get_vod_categories'), fetchImpl),
getJson(apiUrl(login, 'get_vod_streams', category ? { category_id: category } : {}), fetchImpl),
]);
const catName = new Map((cats || []).map((c) => [String(c.category_id), c.category_name]));
return (Array.isArray(items) ? items : []).map((m) => movieOf(login, m, catName));
}

/** Shows, all of them or one category's. */
export async function series(login, { category, fetchImpl = fetch } = {}) {
const [cats, items] = await Promise.all([
category ? [] : getJson(apiUrl(login, 'get_series_categories'), fetchImpl),
getJson(apiUrl(login, 'get_series', category ? { category_id: category } : {}), fetchImpl),
]);
const catName = new Map((cats || []).map((c) => [String(c.category_id), c.category_name]));
return (Array.isArray(items) ? items : []).map((s) => showOf(s, catName));
}

/**
* "EN - Lost In Space (2018) - S01E01 - Impact" is how panels title an
* episode; beside its show and its S01 E01 it is "Impact". A title that is
* nothing but the code keeps the whole thing.
*/
export function episodeTitle(raw, n) {
const t = String(raw || '').trim();
const m = /(?:^|[\s\-–|:])S\d{1,3}\s*E\d{1,4}\b\s*(?:[-–|:]\s*)?(.*)$/i.exec(t);
const rest = m?.[1]?.trim();
return rest || t || `Episode ${n}`;
}

/**
* A show's details and episodes. Panels send `episodes` as an object keyed by
* season, or as a list of lists; both come out as one flat list in order.
*/
export async function seriesInfo(login, seriesId, fetchImpl = fetch) {
const data = await getJson(apiUrl(login, 'get_series_info', { series_id: seriesId }), fetchImpl);
const out = [];
for (const [season, list] of Object.entries(data?.episodes || {})) {
for (const e of list) {
out.push({
if (!data || typeof data !== 'object') throw new Error('the provider has nothing for that show');
const raw = data.episodes || {};
const groups = Array.isArray(raw) ? raw.map((list, i) => [list?.[0]?.season ?? i + 1, list]) : Object.entries(raw);
const episodes = [];
for (const [season, list] of groups) {
for (const e of Array.isArray(list) ? list : []) {
const info = e.info && typeof e.info === 'object' ? e.info : {};
episodes.push({
id: `e${e.id}`,
season: Number(season),
episode: Number(e.episode_num),
name: e.title,
season: Number(e.season ?? season) || 0,
episode: Number(e.episode_num) || 0,
name: episodeTitle(e.title, e.episode_num),
plot: info.plot || undefined,
duration: num(info.duration_secs),
logo: info.movie_image || undefined,
url: episodeUrl(login, e.id, e.container_extension || 'mp4'),
kind: 'vod',
});
}
}
return out;
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
const i = data.info || {};
return {
seriesId: Number(seriesId),
name: String(i.name || '').trim(),
plot: i.plot || undefined,
logo: i.cover || undefined,
genre: i.genre || undefined,
rating: num(i.rating) || undefined,
year: yearOf(i.releaseDate, i.release_date),
episodes,
};
}

/** Back-compat: a show's episodes only. */
export async function episodes(login, seriesId, fetchImpl = fetch) {
return (await seriesInfo(login, seriesId, fetchImpl)).episodes;
}

const b64 = (s) => {
Expand Down
Loading
Loading