diff --git a/README.md b/README.md index 117b16b..fe9a7b2 100644 --- a/README.md +++ b/README.md @@ -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/`, `/channels/`, `/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/`, `/channels/`, `/favorites`, `/recent`, `/guide` (lists take `?q=` to filter), `/vod/movies/`, `/vod/shows/`, `/vod/show/`, `/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). @@ -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 diff --git a/package.json b/package.json index 5339704..407aebe 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/core/vod.js b/src/core/vod.js new file mode 100644 index 0000000..9e0d0bb --- /dev/null +++ b/src/core/vod.js @@ -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} 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; diff --git a/src/core/xtream.js b/src/core/xtream.js index 6261645..ddbc516 100644 --- a/src/core/xtream.js +++ b/src/core/xtream.js @@ -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(); @@ -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) => { diff --git a/src/server/mcp.js b/src/server/mcp.js index b091ee2..57f09cb 100644 --- a/src/server/mcp.js +++ b/src/server/mcp.js @@ -3,6 +3,7 @@ import { addPlaylist, channelsFor, findChannels, readConfig } from '../core/config.js'; import { guideFor, loadGuide, nowNextFor } from '../core/guide.js'; import { catchupUrl, groupsOf, loadSource } from '../core/sources.js'; +import { browseVod } from '../core/vod.js'; import { VERSION } from '../version.js'; const PROTOCOL = '2025-06-18'; @@ -98,6 +99,23 @@ export const TOOLS = [ }, }, }, + { + name: 'movies_shows', + description: + "Films and series on an Xtream line (or an M3U's /movie/ and /series/ entries): with nothing else, the categories; with category, its titles; with query, a search of every title; with seriesId, that show's episodes. Films and episodes come with their stream URL.", + inputSchema: { + type: 'object', + properties: { + source: sourceArg, + playlist: playlistArg, + kind: { type: 'string', enum: ['movie', 'series'], default: 'movie' }, + category: { type: 'string', description: 'Category id from the category list' }, + query: { type: 'string', description: 'Words that must all be in the title' }, + seriesId: { type: 'number', description: "A show's seriesId, for its episodes" }, + limit: { type: 'number', default: 50 }, + }, + }, + }, ]; const fmt = (t) => new Date(t).toISOString().replace(/:\d\d\.\d{3}Z$/, 'Z'); @@ -128,6 +146,12 @@ async function playlistOf(a, ctx) { return channelsFor(a.playlist); } +/** A saved playlist's source (its login), for tools that ask the provider directly. */ +async function savedSource(name) { + const cfg = await readConfig(); + return cfg.playlists[name || cfg.current] || null; +} + async function pickOne(a, ctx, q) { const pl = await playlistOf(a, ctx); const hits = findChannels(pl.channels, q); @@ -192,6 +216,28 @@ export async function callTool(name, a = {}, ctx = {}) { if (!url) throw new Error('that channel has no catch-up'); return { channel: brief(ch), programme: prog(p), url }; } + case 'movies_shows': { + const source = a.source || (!ctx.public && (await savedSource(a.playlist))); + if (!source) throw new Error(ctx.public ? 'this server keeps no playlists; pass source' : 'no such playlist'); + const kind = a.kind === 'series' ? 'series' : 'movie'; + const limit = a.limit || 50; + const r = await browseVod( + source, + { kind, category: a.category, q: a.query, seriesId: a.seriesId }, + { fetch: ctx.fetch }, + ); + if (r.local) { + // A plain M3U: its films are channels already, grouped by group-title. + let list = (await playlistOf(a, ctx)).channels.filter((c) => c.kind === (kind === 'series' ? 'series' : 'vod')); + if (!a.category && !a.query) return { categories: groupsOf(list) }; + if (a.category) list = list.filter((c) => c.group === a.category); + list = findChannels(list, a.query); + return { total: list.length, items: list.slice(0, limit).map((c) => ({ ...brief(c), url: c.url })) }; + } + if (r.show) return r.show; + if (r.categories) return r; + return { total: r.total ?? r.items.length, items: r.items.slice(0, limit) }; + } default: throw new Error(`unknown tool ${name}`); } diff --git a/src/server/remux.js b/src/server/remux.js index 8cd528c..b5c6ef6 100644 --- a/src/server/remux.js +++ b/src/server/remux.js @@ -8,6 +8,14 @@ // // One session per stream URL, shared by everyone watching it; sessions with no // request for IDLE_MS are killed and their directory removed. +// +// Films and episodes (vod) are the other job. Providers serve them as they +// were ripped: MKV, HEVC or H.264, with AC-3 / E-AC-3 / DTS audio, and asking +// for .ts or .mp4 instead is a 500. Browsers play none of that audio, so the +// video is copied and only the audio is converted (stereo AAC, cheap), into +// fMP4 HLS (HEVC in a .ts segment plays in neither hls.js nor Safari). Seeking +// past what has been converted starts a new session there (`at`), so a vod +// session is one url + one start, and its playlist keeps every segment. import { spawn, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; @@ -17,6 +25,7 @@ import { join } from 'node:path'; const IDLE_MS = 30_000; const START_TIMEOUT_MS = 20_000; +const MAX_PROBES = 8; let ffmpegOk = null; export function hasFfmpeg(bin = process.env.HDTILT_FFMPEG || 'ffmpeg') { @@ -24,6 +33,56 @@ export function hasFfmpeg(bin = process.env.HDTILT_FFMPEG || 'ffmpeg') { return ffmpegOk; } +/** ffprobe beside the ffmpeg in use, unless HDTILT_FFPROBE says otherwise. */ +const ffprobeOf = (bin) => process.env.HDTILT_FFPROBE || bin.replace(/ffmpeg(\.exe)?$/i, 'ffprobe$1'); + +/** What a probe says about a file, in the terms the player cares about. */ +export function probeSummary(json) { + const streams = json?.streams || []; + const video = streams.find((x) => x.codec_type === 'video'); + const audio = streams.find((x) => x.codec_type === 'audio'); + const duration = Number(json?.format?.duration); + return { + duration: Number.isFinite(duration) && duration > 0 ? duration : null, + video: video?.codec_name || null, + audio: audio?.codec_name || null, + }; +} + +/** ffmpeg's output arguments for a film or an episode (`info` from probeSummary). */ +export function vodOutputArgs(info = {}) { + return [ + '-map', + '0:v:0?', + '-map', + '0:a:0?', + '-c:v', + 'copy', + // Safari only plays HEVC in fMP4 tagged hvc1; a copy keeps the hev1 tag. + ...(info.video === 'hevc' ? ['-tag:v', 'hvc1'] : []), + '-c:a', + 'aac', + '-ac', + '2', + '-b:a', + '160k', + '-f', + 'hls', + '-hls_time', + '6', + '-hls_list_size', + '0', + '-hls_playlist_type', + 'event', + '-hls_segment_type', + 'fmp4', + '-hls_flags', + 'independent_segments', + '-hls_fmp4_init_filename', + 'init.mp4', + ]; +} + /** * @param {{ inputFor: (url: string) => string, maxSessions?: number, root?: string, bin?: string }} opts * inputFor turns an upstream URL into the address ffmpeg reads (our proxy). @@ -38,7 +97,74 @@ export function createRemuxer({ const base = root || join(process.env.HDTILT_CACHE || tmpdir(), 'hdtilt-hls'); const sessions = new Map(); // key -> { dir, proc, last, url, ready, ended } - const keyOf = (url) => createHash('sha256').update(url).digest('hex').slice(0, 24); + const keyOf = (url, { vod = false, at = 0 } = {}) => + createHash('sha256') + .update(vod ? `${url}\n${Math.floor(at)}` : url) + .digest('hex') + .slice(0, 24); + const headerArgs = () => + Object.keys(headers).length + ? [ + '-headers', + Object.entries(headers) + .map(([k, v]) => `${k}: ${v}\r\n`) + .join(''), + ] + : []; + + // One probe per file, remembered for an hour: every seek would ask again. + // A few at a time: each is a process and a connection to the provider. + const probes = new Map(); // url -> { at, p } + let probing = 0; + function probe(url) { + const have = probes.get(url); + if (have && Date.now() - have.at < 3600_000) return have.p; + if (probing >= MAX_PROBES) + return Promise.reject(Object.assign(new Error('busy reading other files; try again'), { status: 503 })); + probing++; + const p = new Promise((resolve, reject) => { + const proc = spawn( + ffprobeOf(bin), + [ + '-v', + 'error', + '-protocol_whitelist', + 'http,tcp', + ...headerArgs(), + '-show_entries', + 'stream=codec_type,codec_name:format=duration', + '-of', + 'json', + inputFor(url), + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let out = ''; + let err = ''; + const timer = setTimeout(() => proc.kill('SIGKILL'), START_TIMEOUT_MS); + proc.stdout.on('data', (d) => (out += d)); + proc.stderr.on('data', (d) => (err = (err + d).slice(-500))); + proc.on('error', (e) => { + clearTimeout(timer); + reject(e); + }); + proc.on('exit', (code) => { + clearTimeout(timer); + const fail = () => + reject(Object.assign(new Error(`could not read this file${err ? `: ${err.trim()}` : ''}`), { status: 502 })); + if (code !== 0) return fail(); + try { + resolve(probeSummary(JSON.parse(out))); + } catch { + fail(); + } + }); + }); + probes.set(url, { at: Date.now(), p }); + p.catch(() => probes.delete(url)).finally(() => probing--); + if (probes.size > 500) probes.delete(probes.keys().next().value); + return p; + } function stop(key) { const s = sessions.get(key); @@ -50,14 +176,19 @@ export function createRemuxer({ const reaper = setInterval(() => { const now = Date.now(); - for (const [key, s] of sessions) if (now - s.last > IDLE_MS || s.ended) stop(key); + for (const [key, s] of sessions) if (now - s.last > IDLE_MS || (s.ended && !s.complete)) stop(key); }, 5_000); reaper.unref?.(); - async function start(url) { - const key = keyOf(url); + // A film converted to the end is still being watched: its playlist is whole. + const usable = (s) => s && (!s.ended || s.complete); + + /** opts.vod: a film or episode (see the top of this file); opts.at: where it starts, in seconds. */ + async function start(url, { vod = false, at = 0 } = {}) { + at = vod ? Math.max(0, Math.floor(Number(at) || 0)) : 0; + const key = keyOf(url, { vod, at }); const have = sessions.get(key); - if (have && !have.ended) { + if (usable(have)) { have.last = Date.now(); return key; } @@ -66,53 +197,69 @@ export function createRemuxer({ const dir = join(base, key); await rm(dir, { recursive: true, force: true }); await mkdir(dir, { recursive: true }); - const proc = spawn( - bin, - [ - '-nostdin', - '-loglevel', - 'error', - '-protocol_whitelist', - 'http,tcp', - '-fflags', - '+genpts', - // Real time: a catch-up recording (or any file) arrives faster than it - // plays, and the rolling playlist would delete segments before Safari - // asked for them. A live channel is real time already. - '-re', - ...(Object.keys(headers).length - ? [ - '-headers', - Object.entries(headers) - .map(([k, v]) => `${k}: ${v}\r\n`) - .join(''), - ] - : []), - '-i', - inputFor(url), - '-map', - '0:v:0?', - '-map', - '0:a:0?', - '-c', - 'copy', - '-f', - 'hls', - '-hls_time', - '4', - '-hls_list_size', - '6', - '-hls_flags', - 'delete_segments+omit_endlist+independent_segments', - '-hls_segment_filename', - join(dir, 'seg%05d.ts'), - join(dir, 'index.m3u8'), - ], - { stdio: ['ignore', 'ignore', 'pipe'] }, - ); - const s = { dir, proc, last: Date.now(), url, ended: false, error: '' }; + const info = vod ? await probe(url).catch(() => ({})) : null; + const args = vod + ? [ + '-nostdin', + '-loglevel', + 'error', + '-protocol_whitelist', + 'http,tcp', + // A burst to start quickly, then real time: a film is fetched as it + // is watched, not all at once onto this disk. + '-readrate', + '1', + '-readrate_initial_burst', + '30', + ...(at ? ['-ss', String(at)] : []), + ...headerArgs(), + '-i', + inputFor(url), + ...vodOutputArgs(info), + '-hls_segment_filename', + join(dir, 'seg%05d.m4s'), + join(dir, 'index.m3u8'), + ] + : [ + '-nostdin', + '-loglevel', + 'error', + '-protocol_whitelist', + 'http,tcp', + '-fflags', + '+genpts', + // Real time: a catch-up recording (or any file) arrives faster than it + // plays, and the rolling playlist would delete segments before Safari + // asked for them. A live channel is real time already. + '-re', + ...headerArgs(), + '-i', + inputFor(url), + '-map', + '0:v:0?', + '-map', + '0:a:0?', + '-c', + 'copy', + '-f', + 'hls', + '-hls_time', + '4', + '-hls_list_size', + '6', + '-hls_flags', + 'delete_segments+omit_endlist+independent_segments', + '-hls_segment_filename', + join(dir, 'seg%05d.ts'), + join(dir, 'index.m3u8'), + ]; + const proc = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] }); + const s = { dir, proc, last: Date.now(), url, vod, ended: false, complete: false, error: '' }; proc.stderr.on('data', (d) => (s.error = (s.error + d).slice(-500))); - proc.on('exit', () => (s.ended = true)); + proc.on('exit', (code) => { + s.ended = true; + s.complete = vod && code === 0; + }); sessions.set(key, s); return key; } @@ -140,7 +287,7 @@ export function createRemuxer({ async function segment(key, name) { const s = sessions.get(key); - if (!s || !/^seg\d+\.ts$/.test(name)) return null; + if (!s || !/^(seg\d+\.(ts|m4s)|init\.mp4)$/.test(name)) return null; s.last = Date.now(); const file = join(s.dir, name); try { @@ -153,11 +300,13 @@ export function createRemuxer({ return { start, + probe, playlist, segment, stop, keyOf, - has: (key) => sessions.has(key) && !sessions.get(key).ended, + urlOf: (key) => sessions.get(key)?.url, + has: (key) => usable(sessions.get(key)), get size() { return sessions.size; }, diff --git a/src/server/server.js b/src/server/server.js index 3981fa4..e21101e 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -15,6 +15,7 @@ import { isPlaylist, rewritePlaylist } from '../core/hls.js'; import { fetchPublic } from '../core/publicurl.js'; import { groupsOf, loadSource } from '../core/sources.js'; import { b64urlDecode, proxyPath } from '../core/util.js'; +import { browseVod } from '../core/vod.js'; import { VERSION } from '../version.js'; import { createAccounts } from './accounts.js'; import { withFooter } from './footer.js'; @@ -201,6 +202,7 @@ export function createApp(opts = {}) { public: isPublic, accounts: Boolean(accounts), hls: Boolean(remux), + vod: true, }); if ( @@ -214,7 +216,7 @@ export function createApp(opts = {}) { } // hdtilt.com needs an account before it loads anything; a local install has none. - if (gated && (path === '/api/load' || path === '/api/guide' || path === '/api/nownext')) { + if (gated && (path === '/api/load' || path === '/api/vod' || path === '/api/guide' || path === '/api/nownext')) { if (!(await accounts.whoFrom(req.headers.authorization))) return send(res, 401, { error: 'Sign in first', code: 'signin' }); } @@ -227,6 +229,15 @@ export function createApp(opts = {}) { return send(res, 200, { ...data, groups: groupsOf(data.channels) }); } + // Movies & Shows, a category or a show at a time (see core/vod.js). Stateless too. + if (path === '/api/vod' && req.method === 'POST') { + let { source, kind, category, q, seriesId } = await readJson(req); + if (!source?.type) return send(res, 400, { error: 'source required' }); + // A playlist `hdtilt add` saved on this machine: its login is in the config. + if (source.type === 'saved' && !isPublic) source = (await readConfig()).playlists[source.name] || source; + return send(res, 200, await browseVod(source, { kind, category, q, seriesId }, { fetch: guardedFetch })); + } + // Programmes for a set of channels. channels: [{id, name, tvgId}] if (path === '/api/guide' && req.method === 'POST') { const { url, channels, from, to } = await readJson(req, 16 << 20); @@ -373,18 +384,18 @@ export function createApp(opts = {}) { /** /hls//index.m3u8 starts or joins a conversion; /hls/s// serves it. */ async function hls(req, res, path) { if (!remux) return send(res, 501, 'this server cannot convert transport streams (no ffmpeg)'); - const seg = /^\/hls\/s\/([a-f0-9]{24})\/(seg\d+\.ts)$/.exec(path); + const seg = /^\/hls\/s\/([a-f0-9]{24})\/(seg\d+\.(?:ts|m4s)|init\.mp4)$/.exec(path); if (seg) { const file = await remux.segment(seg[1], seg[2]); if (!file) return send(res, 404, 'gone'); res.writeHead(200, { - 'content-type': 'video/mp2t', + 'content-type': seg[2].endsWith('.ts') ? 'video/mp2t' : 'video/mp4', 'cache-control': 'no-store', 'access-control-allow-origin': '*', }); return createReadStream(file).pipe(res); } - const m = /^\/hls\/([A-Za-z0-9_-]+)\/index\.m3u8$/.exec(path); + const m = /^\/hls\/([A-Za-z0-9_-]+)\/(index\.m3u8|info\.json)$/.exec(path); if (!m) return send(res, 404, 'not found'); let url; try { @@ -394,16 +405,30 @@ export function createApp(opts = {}) { return send(res, 400, 'bad stream address'); } try { + // A film or episode: its length and codecs, for the seek bar. + if (m[2] === 'info.json') return send(res, 200, await remux.probe(url)); + const q = new URL(req.url, 'http://x').searchParams; + const opts = q.get('vod') === '1' ? { vod: true, at: Number(q.get('t')) || 0 } : {}; // A viewer starts at most as many conversions as streams; joining one is free. const ip = clientIp(req); const startedBy = (remuxBy.get(ip) || []).filter((k) => remux.has(k)); - const key0 = remux.keyOf(url); - if (!remux.has(key0) && startedBy.length >= maxStreams) - return send(res, 429, 'too many streams from this address'); - const key = await remux.start(url); - if (!startedBy.includes(key)) remuxBy.set(ip, [...startedBy, key]); + const key0 = remux.keyOf(url, opts); + // Seeking a film replaces that viewer's previous position in it. + if (opts.vod) { + for (const k of startedBy) if (k !== key0 && remux.urlOf(k) === url) remux.stop(k); + } + const live = startedBy.filter((k) => remux.has(k)); + if (!remux.has(key0) && live.length >= maxStreams) return send(res, 429, 'too many streams from this address'); + const key = await remux.start(url, opts); + if (!live.includes(key)) remuxBy.set(ip, [...live, key]); const text = await remux.playlist(key); - const body = text.replace(/^(seg\d+\.ts)$/gm, `/hls/s/${key}/$1`); + let body = text + .replace(/^(seg\d+\.(?:ts|m4s))$/gm, `/hls/s/${key}/$1`) + .replace(/URI="(init\.mp4)"/g, `URI="/hls/s/${key}/$1"`); + // A film still being converted looks live to a player, which would start + // it near the newest segment instead of at the beginning. + if (opts.vod && !body.includes('#EXT-X-START')) + body = body.replace(/^#EXTM3U\s*$/m, '#EXTM3U\n#EXT-X-START:TIME-OFFSET=0,PRECISE=YES'); return send(res, 200, body, { 'content-type': 'application/vnd.apple.mpegurl' }); } catch (e) { return send(res, e.status || 502, e.message); diff --git a/src/version.js b/src/version.js index cedef9d..ffd80fd 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const VERSION = '0.2.11'; +export const VERSION = '0.3.0'; diff --git a/test/fixtures.js b/test/fixtures.js index b18672e..22b2cbb 100644 --- a/test/fixtures.js +++ b/test/fixtures.js @@ -30,10 +30,20 @@ ${base}/live/news.ts ${base}/hls/film.m3u8 #EXTINF:-1 group-title="Movies, Classics",Film & Co ${base}/hls/film2.m3u8 +`; + // An M3U with films and episodes in it, the way m3u_plus lists them. + const vodM3u = () => `#EXTM3U +#EXTINF:-1 group-title="News",News One +${base}/live/u/pw/1.ts +#EXTINF:-1 tvg-logo="${base}/logo.png" group-title="Action",The Matrix (1999) +${base}/movie/u/pw/101.mkv +#EXTINF:-1 group-title="Drama",Breaking Bad S01 E01 +${base}/series/u/pw/911.mkv `; const server = createServer((req, res) => { const u = new URL(req.url, 'http://x'); if (u.pathname === '/list.m3u') return res.end(m3u()); + if (u.pathname === '/vod.m3u') return res.end(vodM3u()); if (u.pathname === '/guide.xml.gz') { res.setHeader('content-type', 'application/gzip'); return res.end(gzipSync(guide())); @@ -86,6 +96,69 @@ ${base}/hls/film2.m3u8 }, ]), ); + // Movies & Shows. Series 9 lists episodes by season (an object), 10 as a list of lists. + if (a === 'get_vod_categories') + return res.end( + JSON.stringify([ + { category_id: '1', category_name: 'Action' }, + { category_id: '2', category_name: 'Comedy' }, + ]), + ); + if (a === 'get_vod_streams') { + const all = [ + { + stream_id: 101, + name: 'EN - The Matrix (1999)', + category_id: '1', + container_extension: 'mkv', + rating: '8.7', + added: '100', + }, + { stream_id: 102, name: 'EN - Matrix Reloaded', category_id: '1', container_extension: 'mp4', added: '300' }, + { stream_id: 103, name: 'Café Society', category_id: '2', container_extension: 'mkv', added: '200' }, + ]; + const c = u.searchParams.get('category_id'); + return res.end(JSON.stringify(c ? all.filter((m) => m.category_id === c) : all)); + } + if (a === 'get_series_categories') return res.end(JSON.stringify([{ category_id: '5', category_name: 'Drama' }])); + if (a === 'get_series') { + const all = [ + { + series_id: 9, + name: 'Breaking Bad', + category_id: '5', + cover: '', + releaseDate: '2008-01-20', + last_modified: '5', + }, + { series_id: 10, name: 'Better Call Saul', category_id: '5', last_modified: '9' }, + ]; + const c = u.searchParams.get('category_id'); + return res.end(JSON.stringify(c ? all.filter((s) => s.category_id === c) : all)); + } + if (a === 'get_series_info') { + const ep = (id, n, season) => ({ + id: String(id), + episode_num: n, + season, + title: `Episode ${n}`, + container_extension: 'mkv', + info: { duration_secs: 2900, plot: 'Things happen.' }, + }); + const id = u.searchParams.get('series_id'); + if (id === '9') + return res.end( + JSON.stringify({ + info: { name: 'Breaking Bad', plot: 'A teacher.', genre: 'Drama', releaseDate: '2008-01-20' }, + episodes: { 2: [ep(921, 1, 2)], 1: [ep(912, 2, 1), ep(911, 1, 1)] }, + }), + ); + if (id === '10') + return res.end( + JSON.stringify({ info: { name: 'Better Call Saul' }, episodes: [[ep(1011, 1, 1)], [ep(1021, 1, 2)]] }), + ); + return res.end(''); + } if (a === 'get_short_epg') return res.end( JSON.stringify({ diff --git a/test/vod.test.js b/test/vod.test.js new file mode 100644 index 0000000..597988d --- /dev/null +++ b/test/vod.test.js @@ -0,0 +1,281 @@ +// Movies & Shows: the catalogue a category at a time, search, shows, and the +// conversion that makes an MKV with Dolby audio play in a browser. +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadSource } from '../src/core/sources.js'; +import { b64urlEncode } from '../src/core/util.js'; +import { _indexes, browseVod, buildIndex, fold, searchIndex } from '../src/core/vod.js'; +import * as xtream from '../src/core/xtream.js'; +import { callTool, TOOLS } from '../src/server/mcp.js'; +import { hasFfmpeg, probeSummary, vodOutputArgs } from '../src/server/remux.js'; +import { startServer } from '../src/server/server.js'; +import { fakeProvider } from './fixtures.js'; + +let P; +let local; +const scratch = process.env.CLAUDE_JOB_DIR ? join(process.env.CLAUDE_JOB_DIR, 'tmp') : tmpdir(); +const login = () => ({ type: 'xtream', server: P.base, username: 'u', password: 'pw' }); +const post = (path, body) => + fetch(`http://127.0.0.1:${local.address().port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + +beforeAll(async () => { + P = await fakeProvider(); + local = await startServer({ port: 0, public: false, accounts: null }); +}); +afterAll(() => { + P.close(); + local.close(); +}); + +describe('catalogue', () => { + it('lists categories, then one category newest first', async () => { + const c = await browseVod(login(), { kind: 'movie' }); + expect(c.categories).toEqual([ + { id: '1', name: 'Action' }, + { id: '2', name: 'Comedy' }, + ]); + const r = await browseVod(login(), { kind: 'movie', category: '1' }); + expect(r.items.map((x) => x.name)).toEqual(['EN - Matrix Reloaded', 'EN - The Matrix (1999)']); + expect(r.items[1]).toMatchObject({ + id: 'm101', + year: 1999, + rating: 8.7, + kind: 'vod', + url: `${P.base}/movie/u/pw/101.mkv`, + }); + }); + + it('treats a get.php M3U URL as the Xtream line it is', async () => { + const r = await browseVod( + { type: 'm3u', url: `${P.base}/get.php?username=u&password=pw&type=m3u_plus` }, + { + kind: 'series', + }, + ); + expect(r.categories).toEqual([{ id: '5', name: 'Drama' }]); + }); + + it('opens a show with its episodes in order, whichever shape the panel sends', async () => { + const a = (await browseVod(login(), { seriesId: 9 })).show; + expect(a).toMatchObject({ name: 'Breaking Bad', genre: 'Drama', year: 2008 }); + expect(a.episodes.map((e) => [e.season, e.episode])).toEqual([ + [1, 1], + [1, 2], + [2, 1], + ]); + expect(a.episodes[0]).toMatchObject({ id: 'e911', duration: 2900, url: `${P.base}/series/u/pw/911.mkv` }); + const b = (await browseVod(login(), { seriesId: 10 })).show; + expect(b.episodes.map((e) => e.id)).toEqual(['e1011', 'e1021']); + }); + + it('says a plain M3U is browsed from its own entries', async () => { + expect(await browseVod({ type: 'm3u', url: `${P.base}/vod.m3u` })).toEqual({ local: true }); + const data = await loadSource({ type: 'm3u', url: `${P.base}/vod.m3u` }); + expect(data.channels.map((c) => c.kind)).toEqual(['live', 'vod', 'series']); + }); + + it('titles an episode without its show and code', () => { + expect(xtream.episodeTitle('EN - Lost In Space (2018) - S01E01 - Impact', 1)).toBe('Impact'); + expect(xtream.episodeTitle('S2E10: The End', 10)).toBe('The End'); + expect(xtream.episodeTitle('AR - Show - S01E01', 1)).toBe('AR - Show - S01E01'); + expect(xtream.episodeTitle('', 3)).toBe('Episode 3'); + }); + + it('refuses a show the provider does not have', async () => { + await expect(xtream.seriesInfo(login(), 404)).rejects.toThrow('nothing for that show'); + }); +}); + +describe('search', () => { + it('folds accents and case, and wants every word', () => { + expect(fold('Café SOCIETY!')).toBe('cafe society'); + const ix = buildIndex( + 'movie', + [ + { stream_id: 1, name: 'LAT - Matrix (1999)', category_id: '3', container_extension: 'mkv' }, + { stream_id: 2, name: 'Matrix Reloaded', category_id: '3', container_extension: 'mp4', rating: '7.04' }, + { stream_id: 3, name: 'The Animatrix', category_id: '4' }, + ], + [{ id: '3', name: 'Sci-fi' }], + { server: 'http://h', username: 'u', password: 'p' }, + ); + const r = searchIndex(ix, 'matrix'); + expect(r.total).toBe(3); + // A title with "matrix" as a word beats one with it inside a word. + expect(r.items.map((x) => x.name)).toEqual(['LAT - Matrix (1999)', 'Matrix Reloaded', 'The Animatrix']); + expect(r.items[1]).toMatchObject({ id: 'm2', group: 'Sci-fi', rating: 7, url: 'http://h/movie/u/p/2.mp4' }); + expect(searchIndex(ix, 'matrix 1999').items.map((x) => x.id)).toEqual(['m1']); + expect(searchIndex(ix, ' ').total).toBe(0); + }); + + it('searches a line through the server, remembering the index', async () => { + _indexes.clear(); + const r = await (await post('/api/vod', { source: login(), kind: 'movie', q: 'cafe' })).json(); + expect(r.items.map((x) => x.name)).toEqual(['Café Society']); + expect(_indexes.size).toBe(1); + const s = await (await post('/api/vod', { source: login(), kind: 'series', q: 'saul' })).json(); + expect(s.items[0]).toMatchObject({ id: 's10', seriesId: 10, group: 'Drama', kind: 'series' }); + }); + + it('needs a source', async () => { + expect((await post('/api/vod', {})).status).toBe(400); + }); +}); + +describe('MCP', () => { + it('lists, searches and opens shows on an Xtream line', async () => { + const cats = await callTool('movies_shows', { source: login(), kind: 'series' }); + expect(cats.categories).toEqual([{ id: '5', name: 'Drama' }]); + const found = await callTool('movies_shows', { source: login(), query: 'matrix', limit: 1 }); + expect(found.total).toBe(2); + expect(found.items).toHaveLength(1); + expect(found.items[0].url).toContain('/movie/u/pw/'); + const show = await callTool('movies_shows', { source: login(), seriesId: 9 }); + expect(show.episodes).toHaveLength(3); + }); + it("browses a plain M3U's films by group", async () => { + const source = { type: 'm3u', url: `${P.base}/vod.m3u` }; + expect((await callTool('movies_shows', { source })).categories).toEqual([{ name: 'Action', count: 1 }]); + const r = await callTool('movies_shows', { source, kind: 'series', category: 'Drama' }); + expect(r.items[0]).toMatchObject({ name: 'Breaking Bad S01 E01', url: `${P.base}/series/u/pw/911.mkv` }); + }); + it('is listed', () => { + expect(TOOLS.some((t) => t.name === 'movies_shows')).toBe(true); + }); +}); + +describe('conversion arguments', () => { + it('copies the picture, converts the sound, and tags HEVC for Safari', () => { + const h264 = vodOutputArgs({ video: 'h264' }); + expect(h264.join(' ')).toContain('-c:v copy'); + expect(h264.join(' ')).toContain('-c:a aac'); + expect(h264.join(' ')).toContain('-hls_segment_type fmp4'); + expect(h264).not.toContain('hvc1'); + expect(vodOutputArgs({ video: 'hevc' })).toContain('hvc1'); + }); + it('reads a probe', () => { + expect( + probeSummary({ + streams: [ + { codec_type: 'video', codec_name: 'hevc' }, + { codec_type: 'audio', codec_name: 'eac3' }, + ], + format: { duration: '6470.72' }, + }), + ).toEqual({ duration: 6470.72, video: 'hevc', audio: 'eac3' }); + expect(probeSummary({})).toEqual({ duration: null, video: null, audio: null }); + }); +}); + +// The real thing: an MKV with AC-3 audio, which no browser plays, comes out as +// fMP4 HLS with AAC. Needs ffmpeg (CI installs it; locally set HDTILT_FFMPEG). +const ffmpeg = hasFfmpeg(); +describe.skipIf(!ffmpeg)('films through ffmpeg', () => { + let dir; + let files; + let srv; + let base; + let mkvUrl; + + beforeAll(async () => { + dir = await mkdtemp(join(scratch, 'hdtilt-vod-')); + const clip = join(dir, 'clip.mkv'); + const out = spawnSync(process.env.HDTILT_FFMPEG || 'ffmpeg', [ + '-loglevel', + 'error', + '-y', + '-f', + 'lavfi', + '-i', + 'testsrc2=size=320x240:rate=25', + '-f', + 'lavfi', + '-i', + 'sine', + '-t', + '20', + '-c:v', + 'libx264', + '-preset', + 'ultrafast', + '-g', + '25', + '-c:a', + 'ac3', + clip, + ]); + if (out.status !== 0) throw new Error(String(out.stderr)); + const bytes = await readFile(clip); + // Ranges, like a real provider's file server: ffmpeg seeks with them. + files = createServer((req, res) => { + const m = /bytes=(\d+)-(\d*)/.exec(req.headers.range || ''); + if (!m) { + res.writeHead(200, { + 'content-type': 'video/x-matroska', + 'content-length': bytes.length, + 'accept-ranges': 'bytes', + }); + return res.end(bytes); + } + const start = Number(m[1]); + const end = m[2] ? Number(m[2]) : bytes.length - 1; + res.writeHead(206, { + 'content-type': 'video/x-matroska', + 'content-length': end - start + 1, + 'content-range': `bytes ${start}-${end}/${bytes.length}`, + 'accept-ranges': 'bytes', + }); + res.end(bytes.subarray(start, end + 1)); + }); + await new Promise((r) => files.listen(0, '127.0.0.1', r)); + mkvUrl = `http://127.0.0.1:${files.address().port}/movie/u/p/7.mkv`; + process.env.HDTILT_CACHE = dir; + srv = await startServer({ port: 0, public: false, accounts: null }); + base = `http://127.0.0.1:${srv.address().port}`; + }); + afterAll(async () => { + srv?.close(); + files?.close(); + if (dir) await rm(dir, { recursive: true, force: true }); + }); + + it('reads the length and codecs', async () => { + const r = await (await fetch(`${base}/hls/${b64urlEncode(mkvUrl)}/info.json`)).json(); + expect(r).toMatchObject({ video: 'h264', audio: 'ac3' }); + expect(r.duration).toBeGreaterThan(19); + }); + + it('makes HLS that starts at the beginning, with AAC sound', async () => { + const res = await fetch(`${base}/hls/${b64urlEncode(mkvUrl)}/index.m3u8?vod=1&t=0`); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toContain('#EXT-X-START:TIME-OFFSET=0'); + const init = /#EXT-X-MAP:URI="([^"]+)"/.exec(text)?.[1]; + expect(init).toMatch(/^\/hls\/s\/[a-f0-9]{24}\/init\.mp4$/); + const initBytes = Buffer.from(await (await fetch(`${base}${init}`)).arrayBuffer()); + expect(initBytes.includes('mp4a')).toBe(true); + expect(initBytes.includes('avc1')).toBe(true); + const seg = text.split('\n').find((l) => l.endsWith('.m4s')); + const s = await fetch(`${base}${seg}`); + expect(s.status).toBe(200); + expect(s.headers.get('content-type')).toBe('video/mp4'); + expect((await s.arrayBuffer()).byteLength).toBeGreaterThan(1000); + }); + + it('starts somewhere else as its own session', async () => { + const a = await (await fetch(`${base}/hls/${b64urlEncode(mkvUrl)}/index.m3u8?vod=1&t=0`)).text(); + const b = await (await fetch(`${base}/hls/${b64urlEncode(mkvUrl)}/index.m3u8?vod=1&t=12`)).text(); + const key = (t) => /\/hls\/s\/([a-f0-9]{24})\//.exec(t)?.[1]; + expect(key(a)).toBeTruthy(); + expect(key(b)).toBeTruthy(); + expect(key(a)).not.toBe(key(b)); + }); +}); diff --git a/web/public/app.css b/web/public/app.css index 9d404f3..9de8712 100644 --- a/web/public/app.css +++ b/web/public/app.css @@ -1105,3 +1105,115 @@ body.gated .nav-items button:not(#nav-account) { --pfs-footer-muted: var(--dim); border-top: 0; } + +/* ---- Movies & Shows ---------------------------------------------------------- */ +.list.vod { + width: min(100%, 72rem); +} +.vod .channels-wrap { + max-width: none; +} +/* A wide screen keeps the categories in view; a phone slides them in (☰ or ◀). */ +@media (min-width: 701px) { + .list.vod:not(.groups-open) .groups { + width: 15rem; + padding: 1.4rem 0.6rem; + overflow-y: auto; + border-right: 1px solid var(--line); + } + .vod-cats { + display: none; + } +} +.vod-kinds { + display: flex; + gap: 0.4rem; + padding: 0 0.3rem 0.8rem; +} +.vod-kinds .chip { + flex: 1; + width: auto; + justify-content: center; + padding: 0.45rem 0.6rem; + border-radius: 999px; +} +.vod-kinds .chip.cur { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-ink); + font-weight: 700; +} +.groups .vod-kinds button.focus { + outline: 3px solid var(--accent); + outline-offset: 2px; +} +.vod-plot { + width: 100%; + margin: 0; + color: var(--dim); + font-size: 0.85rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.vod-seasons { + width: 100%; + display: flex; + gap: 0.4rem; + overflow-x: auto; + scrollbar-width: none; +} +.vod-seasons .chip.cur { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-ink); + font-weight: 700; +} +.row.vrow { + grid-template-columns: 2.4rem 1fr auto; +} +.row.vrow.ep { + grid-template-columns: 4.6rem 1fr; +} +.row.vrow.ep .no { + text-align: left; + font-size: 0.85rem; +} +.logo.poster { + width: 2.4rem; + height: 3.4rem; + max-height: calc(var(--row) - 10px); + border-radius: 6px; +} +.row .logo.poster { + width: 2.4rem; + height: calc(var(--row) - 12px); +} +.logo.poster img { + max-width: 100%; + max-height: 100%; + width: 100%; + height: 100%; + object-fit: cover; +} +.banner .logo.poster { + width: 3.6rem; + height: 5.2rem; +} +.row .go { + color: var(--dim); + font-size: 1.1rem; + padding: 0 0.4rem; +} +.vod .empty .btn { + margin-left: 0.5rem; +} +@media (max-width: 700px) { + .row.vrow { + grid-template-columns: 2.2rem 1fr auto; + } + .row.vrow.ep { + grid-template-columns: 4.2rem 1fr; + } +} diff --git a/web/public/index.html b/web/public/index.html index 542c116..d7b3a61 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -43,6 +43,15 @@ + + +