diff --git a/README.md b/README.md index a546141..4d264d4 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ hdtilt is a player. It ships with no content. You add a playlist from a provider - **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. - **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. +- **Subtitles** from [OpenSubtitles](https://www.opensubtitles.com) on any film or episode: the CC button (or `c`) lists what exists in your language, best match first. Matches use the provider's TMDB id when it has one, else the cleaned-up title and year (an episode by its show, season and episode), and a moviehash of the file itself puts subtitles made for that exact rip on top (marked THIS FILE). −½s / +½s (or ◀ / ▶ in the panel) fix timing; the pick and its timing are remembered per title. Each file is fetched from OpenSubtitles once and kept, so a film someone already watched with subtitles costs nothing. - **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`. @@ -81,6 +82,8 @@ hdtilt guide [--hours 12] [--from -6] hdtilt url [--at ] # --at = catch-up hdtilt play [--player mpv|vlc|ffplay] hdtilt fav +hdtilt subs --key [--user --pass

] +hdtilt subs [--year Y] [--season S --episode E] [--lang en,fr] [--tmdb id] [--get <n> -o file.vtt] hdtilt login [--manual] | whoami | sync | logout hdtilt tui | serve [--port] [--host] [--public] | mcp ``` @@ -95,7 +98,7 @@ Add it to any MCP client: { "mcpServers": { "hdtilt": { "command": "npx", "args": ["-y", "hdtilt", "mcp"] } } } ``` -Tools: `list_playlists`, `add_playlist`, `list_groups`, `list_channels`, `whats_on`, `channel_guide`, `stream_url` (with catch-up). Over HTTP (`POST /mcp`), a public server keeps no playlists, so every call passes its own `source`. +Tools: `list_playlists`, `add_playlist`, `list_groups`, `list_channels`, `whats_on`, `channel_guide`, `stream_url` (with catch-up), `movies_shows`, `find_subtitles`, `fetch_subtitle` (local only). Over HTTP (`POST /mcp`), a public server keeps no playlists, so every call passes its own `source`. ## API @@ -110,6 +113,8 @@ Tools: `list_playlists`, `add_playlist`, `list_groups`, `list_channels`, `whats_ | GET | `/api/me` | the signed-in account (`Authorization: Bearer`, a session or an OAuth token) | | GET / PUT | `/api/library` | the synced library; PUT `{ data, baseVersion }`, 409 if another device wrote first | | GET | `/.well-known/oauth-authorization-server` | OAuth 2.1 metadata; `/oauth/token`, `/oauth/revoke`; client `hdtilt-cli` | +| POST | `/api/subtitles` | `{ title?, year?, tmdbId?, season?, episode?, languages?, url? }` → `{ items: [{ fileId, language, release, downloads, hashMatch, … }], moviehash }` | +| GET | `/subs/<fileId>.vtt?shift=<s>` | one subtitle as WebVTT, cues moved by `shift` seconds | | GET | `/hls/<base64url>/index.m3u8` | an MPEG-TS stream repackaged as HLS (for browsers without MSE) | | GET | `/p/<base64url>/<name.ext>` | stream proxy; HLS playlists are rewritten so segments and keys come back through it | | POST | `/mcp` | MCP JSON-RPC | @@ -130,7 +135,7 @@ bun install && bun run build PORT=8930 node bin/hdtilt.js serve --host 0.0.0.0 --public # --public for a shared server ``` -Environment: `PORT`, `HOST`, `HDTILT_PUBLIC=1`, `HDTILT_JWT_SECRET` (turns accounts on), `DATABASE_URL` (Postgres for accounts; memory without it), `HDTILT_LIBRARY_KEY`, `RESEND_API_KEY`, `MAIL_FROM`, `SITE_URL`, `HDTILT_ISSUER` (the CLI's account server), `HDTILT_UA` (the user-agent sent to providers), `HDTILT_HOME` / `HDTILT_CACHE`, `HDTILT_PLAYER`. +Environment: `PORT`, `HOST`, `HDTILT_PUBLIC=1`, `HDTILT_JWT_SECRET` (turns accounts on), `DATABASE_URL` (Postgres for accounts; memory without it), `HDTILT_LIBRARY_KEY`, `RESEND_API_KEY`, `MAIL_FROM`, `SITE_URL`, `HDTILT_ISSUER` (the CLI's account server), `HDTILT_UA` (the user-agent sent to providers), `HDTILT_HOME` / `HDTILT_CACHE`, `HDTILT_PLAYER`, `OPENSUBTITLES_API_KEY` (turns subtitles on; free at [opensubtitles.com/consumers](https://www.opensubtitles.com/consumers)), `OPENSUBTITLES_USERNAME` / `OPENSUBTITLES_PASSWORD` (an account's daily download allowance instead of 5 per IP), `HDTILT_SUBS_PER_DAY` (new downloads per viewer per day on a public server, default 10). ## Development diff --git a/bin/hdtilt.js b/bin/hdtilt.js index 38396db..f63c56d 100644 --- a/bin/hdtilt.js +++ b/bin/hdtilt.js @@ -28,6 +28,11 @@ Watching hdtilt play <channel> [--at <iso-time>] [--player mpv|vlc|ffplay] hdtilt fav <channel> toggle a favorite +Subtitles (OpenSubtitles; a free API key from opensubtitles.com/consumers) + hdtilt subs --key <api-key> [--user <u> --pass <p>] save the key (and an account) + hdtilt subs <title> [--year Y] [--season S --episode E] [--lang en,fr] [--tmdb id] + hdtilt subs <title> ... --get <n> [-o file.vtt] download the nth result + Account (sync playlists and favorites with hdtilt.com) hdtilt login [--manual] sign in through the browser (OAuth 2.1 + PKCE) hdtilt whoami | sync | logout @@ -55,6 +60,14 @@ const { values: o, positionals } = parseArgs({ from: { type: 'string' }, at: { type: 'string' }, player: { type: 'string' }, + key: { type: 'string' }, + year: { type: 'string' }, + season: { type: 'string' }, + episode: { type: 'string' }, + lang: { type: 'string' }, + tmdb: { type: 'string' }, + get: { type: 'string' }, + out: { type: 'string', short: 'o' }, port: { type: 'string' }, host: { type: 'string' }, public: { type: 'boolean' }, @@ -233,6 +246,49 @@ const commands = { console.log(`${s.has(ch.id) ? '★ added' : 'removed'} ${ch.name}`); await (await import('../src/cli/sync.js')).pushQuietly(); }, + async subs() { + const { createSubtitles, credentialsFrom } = await import('../src/core/subtitles.js'); + const cfg = await readConfig(); + if (o.key) { + cfg.opensubtitles = { apiKey: o.key, ...(o.user && o.pass ? { username: o.user, password: o.pass } : {}) }; + await writeConfig(cfg); + return console.log(`OpenSubtitles key saved${o.user ? ` (account ${o.user})` : ''}`); + } + const subs = createSubtitles(credentialsFrom(process.env, cfg)); + if (!subs.enabled) + throw new Error('no OpenSubtitles key: hdtilt subs --key <api-key> (free at opensubtitles.com/consumers)'); + const title = args.join(' '); + if (!title && !o.tmdb) throw new Error('usage: hdtilt subs <title> [--season S --episode E] [--lang en]'); + const items = await subs.search({ + title, + year: Number(o.year) || undefined, + tmdbId: o.tmdb, + season: o.season, + episode: o.episode, + languages: o.lang || 'en', + }); + if (o.get) { + const n = Number(o.get); + const r = items[n - 1]; + if (!r) throw new Error(`no result ${o.get}; there are ${items.length}`); + const { vtt, remaining } = await subs.download(r.fileId); + if (!o.out) return process.stdout.write(vtt); + const { writeFile } = await import('node:fs/promises'); + await writeFile(o.out, vtt); + return console.error(`Saved ${o.out}${remaining != null ? ` (${remaining} downloads left today)` : ''}`); + } + const rows = items.slice(0, limit); + out(rows, () => + rows.length + ? rows + .map( + (r, i) => + `${String(i + 1).padStart(3)}. [${r.language}] ${r.release} ⬇${r.downloads}${r.hearingImpaired ? ' CC' : ''}${r.trusted ? ' trusted' : ''}`, + ) + .join('\n') + : 'No subtitles found', + ); + }, async login() { const { signIn, sync } = await import('../src/cli/sync.js'); const me = await signIn({ manual: o.manual }); diff --git a/package.json b/package.json index b6dee7a..7ab6170 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hdtilt", - "version": "0.3.2", + "version": "0.4.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/subtitles.js b/src/core/subtitles.js new file mode 100644 index 0000000..c0e368b --- /dev/null +++ b/src/core/subtitles.js @@ -0,0 +1,386 @@ +// Subtitles for films and episodes, from OpenSubtitles (api.opensubtitles.com). +// +// Searching is free; downloading is the scarce thing. A free API key gets 5 +// downloads a day per IP without a login, and a signed-in account its own +// daily allowance (20 on a free one), so every file is kept on disk by its +// file id once fetched: the second viewer of a film costs nothing. +// +// A provider names a film "EN - The Matrix (1999) 4K"; cleanTitle() turns that +// into what OpenSubtitles knows it as. When the panel knows the TMDB id, that +// is sent instead (precise), and a moviehash of the file itself, when the +// provider answers range requests, puts subtitles made for that exact rip first. + +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { VERSION } from '../version.js'; +import { fold } from './match.js'; + +const API = 'https://api.opensubtitles.com/api/v1'; +const SEARCH_TTL = 3600_000; +const TOKEN_TTL = 23 * 3600_000; // tokens last 24 h +const HASH_CHUNK = 65536; +const MAX_VTT = 2 << 20; + +/** Where the key and the optional account come from: env first, then the config's `opensubtitles`. */ +export function credentialsFrom(env = process.env, cfg = {}) { + const c = cfg?.opensubtitles || {}; + return { + apiKey: env.OPENSUBTITLES_API_KEY || c.apiKey || '', + username: env.OPENSUBTITLES_USERNAME || c.username || '', + password: env.OPENSUBTITLES_PASSWORD || c.password || '', + }; +} + +// Quality, source and language tags providers put around a title. +const NOISE = + /\b(4k|uhd|fhd|hd|sd|hevc|h\.?26[45]|x26[45]|1080p|2160p|720p|480p|hdr10?\+?|hdr|dv|dolby ?vision|web-?dl|web-?rip|webrip|blu-?ray|bdrip|brrip|dvdrip|hdtv|remux|multi|dual|vost(fr)?|subbed|dubbed|sub|dub|imax|extended|unrated|remastered|directors? cut)\b/gi; + +/** + * "EN - The Matrix (1999) 4K" -> { title: "The Matrix", year: 1999 }. + * Also "|EN| Title", "[4K] Title", "NF - Title", "Title [1999]". + */ +export function cleanTitle(raw) { + let t = String(raw || '').trim(); + let year = 0; + // Leading tags: |EN|, [4K], (EN), and "EN - " / "4K-NF: " style prefixes, repeatedly. + for (let i = 0; i < 4; i++) { + const before = t; + t = t + .replace(/^\s*[|[(][^|\])]{1,12}[|\])]\s*[-–:|]?\s*/, '') + .replace(/^\s*[A-Z0-9][A-Z0-9+]{0,5}(?:[-_ ][A-Z0-9+]{1,5})?\s*[-–:|]\s+/, ''); + if (t === before) break; + } + const y = /[([]\s*((?:19|20)\d{2})\s*[)\]]|\b((?:19|20)\d{2})\s*$/.exec(t); + if (y) { + year = Number(y[1] || y[2]); + t = t.slice(0, y.index) + t.slice(y.index + y[0].length); + } + t = t + .replace(/[([][^)\]]*[)\]]/g, ' ') + .replace(NOISE, ' ') + .replace(/\s*[-–|:]\s*$/, '') + .replace(/\s+/g, ' ') + .trim(); + return { title: t || String(raw || '').trim(), year }; +} + +/** + * The query string OpenSubtitles wants: keys sorted, values lowercase, no + * empty or default values (anything else is a 301 to the same thing). + */ +export function searchParams(ask = {}) { + const p = {}; + const set = (k, v) => { + if (v === undefined || v === null || v === '' || v === 0) return; + p[k] = String(v).toLowerCase(); + }; + const langs = String(ask.languages || 'en') + .split(/[,\s]+/) + .map((l) => l.trim().toLowerCase()) + .filter(Boolean) + .sort(); + set('languages', [...new Set(langs)].join(',')); + set('moviehash', ask.moviehash); + const episode = ask.season != null && ask.episode != null; + if (episode) { + set('season_number', Number(ask.season)); + set('episode_number', Number(ask.episode)); + } + if (ask.tmdbId) set(episode ? 'parent_tmdb_id' : 'tmdb_id', String(ask.tmdbId).replace(/^0+/, '')); + else if (ask.imdbId) set(episode ? 'parent_imdb_id' : 'imdb_id', String(ask.imdbId).replace(/^tt0*/i, '')); + else { + const { title, year } = cleanTitle(ask.title); + set('query', title); + // A year only narrows a film: an episode's year is not its show's. + if (!episode) set('year', ask.year || year); + } + set('type', episode ? 'episode' : ask.title || ask.tmdbId || ask.imdbId ? 'movie' : ''); + return new URLSearchParams( + Object.keys(p) + .sort() + .map((k) => [k, p[k]]), + ).toString(); +} + +/** One search result, in the terms the player shows. Best first is the caller's sort. */ +export function resultOf(d) { + const a = d?.attributes || {}; + const f = a.files?.[0] || {}; + const fd = a.feature_details || {}; + return { + fileId: Number(f.file_id) || 0, + language: a.language || '', + release: a.release || f.file_name || '', + fileName: f.file_name || '', + downloads: Number(a.download_count) || 0, + hearingImpaired: Boolean(a.hearing_impaired), + trusted: Boolean(a.from_trusted), + aiTranslated: Boolean(a.ai_translated), + hashMatch: Boolean(a.moviehash_match), + fps: Number(a.fps) || undefined, + cds: Number(a.nb_cd) || 1, + title: fd.parent_title ? `${fd.parent_title} S${fd.season_number}E${fd.episode_number}` : fd.title || fd.movie_name, + year: fd.year || undefined, + // The film's or the show's own name, for telling "Breaking Bad" from "The Bad Guys: Breaking In". + name: fd.parent_title || fd.title || fd.movie_name || '', + }; +} + +/** + * How well a result's name fits what was asked by title: 2 = the same name, + * 1 = every word in it, 0 = neither. A text search matches loosely. + */ +export function nameFit(name, wanted) { + const n = fold(name).trim(); + const w = fold(wanted).trim(); + if (!w) return 0; + if (n === w || n.replace(/^the /, '') === w.replace(/^the /, '')) return 2; + return w.split(' ').every((x) => ` ${n} `.includes(` ${x} `)) ? 1 : 0; +} + +/** + * Exact-file matches first, then (when asked by title) the right film or show, + * then one-file subtitles, then the most downloaded. + * @param {{ title?: string, year?: number }} [wanted] the cleaned title asked for, if no id was + */ +export function rankResults(list, wanted = null) { + const fit = (r) => + wanted?.title ? nameFit(r.name, wanted.title) * 2 + (wanted.year && r.year === wanted.year ? 1 : 0) : 0; + return list + .filter((r) => r.fileId) + .sort( + (a, b) => + b.hashMatch - a.hashMatch || + fit(b) - fit(a) || + (a.cds > 1) - (b.cds > 1) || + b.trusted - a.trusted || + a.aiTranslated - b.aiTranslated || + b.downloads - a.downloads, + ); +} + +/** SubRip (or anything close) to WebVTT, which is what a <track> plays. */ +export function toVtt(text) { + let t = String(text || '') + .replace(/^/, '') + .replace(/\r\n?/g, '\n'); + if (/^WEBVTT/.test(t)) return t; + t = t + // 00:01:02,345 --> 00:01:04,000 (also a single-digit hour, and a dot) + .replace( + /(\d{1,2}:\d{2}:\d{2})[,.](\d{1,3})\s*-->\s*(\d{1,2}:\d{2}:\d{2})[,.](\d{1,3})/g, + (_, a, ams, b, bms) => + `${a.padStart(8, '0')}.${ams.padEnd(3, '0')} --> ${b.padStart(8, '0')}.${bms.padEnd(3, '0')}`, + ) + // SRT's {\an8} positioning and <font> tags mean nothing to VTT. + .replace(/\{\\[^}]*\}/g, '') + .replace(/<\/?font[^>]*>/gi, ''); + return `WEBVTT\n\n${t.trim()}\n`; +} + +/** Every cue moved by `seconds` (negative = earlier); cues that end before 0 are dropped. */ +export function shiftVtt(vtt, seconds) { + if (!seconds) return vtt; + const toS = (h, m, s, ms) => Number(h) * 3600 + Number(m) * 60 + Number(s) + Number(ms) / 1000; + const fmt = (x) => { + const ms = Math.round(Math.max(0, x) * 1000); + const h = Math.floor(ms / 3600_000); + const m = Math.floor((ms % 3600_000) / 60_000); + const s = Math.floor((ms % 60_000) / 1000); + const r = (n, w = 2) => String(n).padStart(w, '0'); + return `${r(h)}:${r(m)}:${r(s)}.${r(ms % 1000, 3)}`; + }; + const time = /(?:(\d+):)?(\d{2}):(\d{2})\.(\d{3})/; + const [head, ...blocks] = String(vtt).split(/\n\n+/); + const kept = []; + for (const b of blocks) { + const lines = b.split('\n'); + const i = lines.findIndex((l) => l.includes('-->')); + if (i < 0) { + kept.push(b); + continue; + } + const [l, r] = lines[i].split('-->'); + const a = time.exec(l); + const z = time.exec(r); + if (!a || !z) continue; + const start = toS(a[1] || 0, a[2], a[3], a[4]) + seconds; + const end = toS(z[1] || 0, z[2], z[3], z[4]) + seconds; + if (end <= 0) continue; + const settings = r.replace(time, '').trim(); + lines[i] = `${fmt(start)} --> ${fmt(end)}${settings ? ` ${settings}` : ''}`; + kept.push(lines.join('\n')); + } + return `${[head, ...kept].join('\n\n')}\n`; +} + +/** + * OpenSubtitles' moviehash: the file size plus the 64-bit little-endian words + * of its first and last 64 KiB, mod 2^64, as 16 hex digits. + */ +export function moviehashOf(size, head, tail) { + const mask = (1n << 64n) - 1n; + let h = BigInt(size); + for (const buf of [head, tail]) { + const v = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + for (let i = 0; i + 8 <= buf.byteLength; i += 8) h = (h + v.getBigUint64(i, true)) & mask; + } + return h.toString(16).padStart(16, '0'); +} + +/** A file's moviehash by two range requests, or null if the provider will not say. */ +export async function moviehash(url, fetchImpl = fetch, timeoutMs = 8000) { + const signal = AbortSignal.timeout(timeoutMs); + const range = async (spec) => { + const res = await fetchImpl(url, { headers: { range: `bytes=${spec}` }, signal }); + if (res.status !== 206) { + res.body?.cancel?.().catch(() => {}); + return null; + } + const total = Number(/\/(\d+)$/.exec(res.headers.get('content-range') || '')?.[1]); + return { total, buf: new Uint8Array(await res.arrayBuffer()) }; + }; + try { + const head = await range(`0-${HASH_CHUNK - 1}`); + if (!head?.total || head.total < HASH_CHUNK * 2 || head.buf.byteLength !== HASH_CHUNK) return null; + const tail = await range(`${head.total - HASH_CHUNK}-${head.total - 1}`); + if (!tail || tail.buf.byteLength !== HASH_CHUNK) return null; + return moviehashOf(head.total, head.buf, tail.buf); + } catch { + return null; + } +} + +const quotaError = (msg, status = 429) => Object.assign(new Error(msg), { status, code: 'quota' }); + +/** + * @param {{ apiKey?: string, username?: string, password?: string, fetch?: typeof fetch, + * cacheDir?: string, appName?: string }} opts + */ +export function createSubtitles({ + apiKey = '', + username = '', + password = '', + fetch: fetchImpl = fetch, + cacheDir, + appName = 'hdtilt', +} = {}) { + const dir = cacheDir || join(process.env.HDTILT_CACHE || tmpdir(), 'hdtilt-subs'); + const searches = new Map(); // query -> { at, p } + let login = null; // { at, p: Promise<{ token, base }> } + let loginFailed = false; // a 401 is final: OpenSubtitles asks clients to stop retrying + + const headers = (extra = {}) => ({ + 'api-key': apiKey, + 'user-agent': `${appName} v${VERSION}`, + accept: 'application/json', + ...extra, + }); + + async function session() { + if (!username || !password || loginFailed) return null; + if (login && Date.now() - login.at < TOKEN_TTL) return login.p; + const p = (async () => { + const res = await fetchImpl(`${API}/login`, { + method: 'POST', + headers: headers({ 'content-type': 'application/json' }), + body: JSON.stringify({ username, password }), + }); + if (res.status === 401) { + loginFailed = true; + throw new Error('OpenSubtitles refused the username or password'); + } + if (!res.ok) throw new Error(`OpenSubtitles login answered ${res.status}`); + const j = await res.json(); + return { token: j.token, base: j.base_url ? `https://${j.base_url}/api/v1` : API }; + })(); + login = { at: Date.now(), p }; + p.catch(() => { + login = null; + }); + return p; + } + + async function call(path, init = {}) { + if (!apiKey) throw Object.assign(new Error('subtitles are not set up on this server'), { status: 501 }); + const s = await session().catch(() => null); + const auth = s?.token ? { authorization: `Bearer ${s.token}` } : {}; + const res = await fetchImpl(`${s?.base || API}${path}`, { + ...init, + headers: headers({ ...auth, ...(init.body ? { 'content-type': 'application/json' } : {}) }), + redirect: 'follow', + }); + const j = await res.json().catch(() => ({})); + if (res.status === 401 && s) login = null; // token expired early: next call logs in again + if (res.status === 406 || (res.status === 429 && path === '/download')) + throw quotaError( + `Today's subtitle downloads are used up${j.reset_time ? `; more in ${j.reset_time}` : ''}. Ones already fetched still work.`, + ); + if (res.status === 429) throw quotaError('OpenSubtitles is busy; try again in a moment', 503); + if (!res.ok) + throw Object.assign(new Error(j.message || j.errors?.[0] || `OpenSubtitles answered ${res.status}`), { + status: 502, + }); + return j; + } + + /** + * @param {{ title?: string, year?: number, tmdbId?: number|string, imdbId?: string, + * season?: number, episode?: number, languages?: string, moviehash?: string }} ask + */ + function search(ask) { + const qs = searchParams(ask); + const have = searches.get(qs); + if (have && Date.now() - have.at < SEARCH_TTL) return have.p; + const byTitle = !ask.tmdbId && !ask.imdbId && ask.title; + const wanted = byTitle ? { ...cleanTitle(ask.title), ...(ask.year ? { year: Number(ask.year) } : {}) } : null; + if (wanted && ask.season != null) wanted.year = 0; + const p = call(`/subtitles?${qs}`).then((j) => rankResults((j.data || []).map(resultOf), wanted)); + searches.set(qs, { at: Date.now(), p }); + p.catch(() => searches.delete(qs)); + while (searches.size > 500) searches.delete(searches.keys().next().value); + return p; + } + + const fileOf = (fileId) => join(dir, `${Number(fileId)}.vtt`); + + async function cached(fileId) { + try { + return await readFile(fileOf(fileId), 'utf8'); + } catch { + return null; + } + } + + // Two viewers asking for the same new file spend one download. + const inflight = new Map(); + /** A subtitle file as WebVTT. `fresh` is true when it spent a download. */ + async function download(fileId, { onFresh } = {}) { + fileId = Number(fileId); + if (!Number.isInteger(fileId) || fileId <= 0) throw Object.assign(new Error('bad subtitle id'), { status: 400 }); + const hit = await cached(fileId); + if (hit) return { vtt: hit, fresh: false }; + if (inflight.has(fileId)) return inflight.get(fileId); + const p = (async () => { + onFresh?.(); + const j = await call('/download', { method: 'POST', body: JSON.stringify({ file_id: fileId }) }); + if (!j.link) throw Object.assign(new Error(j.message || 'no download link'), { status: 502 }); + const res = await fetchImpl(j.link, { headers: { 'user-agent': `${appName} v${VERSION}` } }); + if (!res.ok) throw Object.assign(new Error(`subtitle file answered ${res.status}`), { status: 502 }); + const buf = await res.arrayBuffer(); + if (buf.byteLength > MAX_VTT) throw Object.assign(new Error('subtitle file is too large'), { status: 502 }); + const vtt = toVtt(new TextDecoder('utf-8').decode(buf)); + await mkdir(dir, { recursive: true }); + const tmp = `${fileOf(fileId)}.${process.pid}.tmp`; + await writeFile(tmp, vtt); + await rename(tmp, fileOf(fileId)); + return { vtt, fresh: true, remaining: j.remaining }; + })(); + inflight.set(fileId, p); + p.finally(() => inflight.delete(fileId)).catch(() => {}); + return p; + } + + return { enabled: Boolean(apiKey), search, download, cached, hasCached: async (id) => Boolean(await cached(id)) }; +} diff --git a/src/core/xtream.js b/src/core/xtream.js index ddbc516..f32c491 100644 --- a/src/core/xtream.js +++ b/src/core/xtream.js @@ -155,6 +155,15 @@ const yearOf = (...vs) => { return undefined; }; +/** A TMDB id, for subtitles: panels send it as a number, a string, or "0" for none. */ +const tmdbOf = (...vs) => { + for (const v of vs) { + const n = Number(String(v ?? '').trim()); + if (Number.isInteger(n) && n > 0) return n; + } + 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 { @@ -166,6 +175,7 @@ export function movieOf(login, m, catName = new Map()) { rating: num(m.rating) || undefined, year: yearOf(m.year, m.releasedate, m.name), added: num(m.added), + tmdbId: tmdbOf(m.tmdb, m.tmdb_id), url: movieUrl(login, m.stream_id, m.container_extension || 'mp4'), kind: 'vod', }; @@ -256,6 +266,7 @@ export async function seriesInfo(login, seriesId, fetchImpl = fetch) { genre: i.genre || undefined, rating: num(i.rating) || undefined, year: yearOf(i.releaseDate, i.release_date), + tmdbId: tmdbOf(i.tmdb, i.tmdb_id), episodes, }; } diff --git a/src/server/mcp.js b/src/server/mcp.js index 57f09cb..f926d27 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 { createSubtitles, credentialsFrom } from '../core/subtitles.js'; import { browseVod } from '../core/vod.js'; import { VERSION } from '../version.js'; @@ -116,8 +117,40 @@ export const TOOLS = [ }, }, }, + { + name: 'find_subtitles', + description: + 'Subtitles for a film or an episode, from OpenSubtitles. Give a title (a provider name like "EN - Film (1999) 4K" is cleaned up) or a tmdbId; for an episode add season and episode (title/tmdbId are then the show). Best match first; fetch one with fetch_subtitle.', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string' }, + year: { type: 'number' }, + tmdbId: { type: 'number', description: 'The film, or for an episode the show' }, + season: { type: 'number' }, + episode: { type: 'number' }, + languages: { type: 'string', description: 'Comma-separated codes, e.g. "en,fr"', default: 'en' }, + limit: { type: 'number', default: 20 }, + }, + }, + }, + { + name: 'fetch_subtitle', + description: + 'One subtitle file as WebVTT text, by the fileId find_subtitles gave. Spends one of the daily downloads.', + inputSchema: { type: 'object', properties: { fileId: { type: 'number' } }, required: ['fileId'] }, + }, ]; +// The server passes its own (ctx.subtitles); stdio reads the local config. +let localSubs = null; +async function subtitlesOf(ctx) { + if (ctx.subtitles !== undefined) return ctx.subtitles; + localSubs ??= readConfig().then((cfg) => createSubtitles(credentialsFrom(process.env, cfg))); + const s = await localSubs; + return s.enabled ? s : null; +} + const fmt = (t) => new Date(t).toISOString().replace(/:\d\d\.\d{3}Z$/, 'Z'); const brief = (c) => ({ id: c.id, @@ -238,6 +271,28 @@ export async function callTool(name, a = {}, ctx = {}) { if (r.categories) return r; return { total: r.total ?? r.items.length, items: r.items.slice(0, limit) }; } + case 'find_subtitles': { + const subs = await subtitlesOf(ctx); + if (!subs) throw new Error('subtitles are not set up: hdtilt subs --key <api-key>'); + if (!a.title && !a.tmdbId) throw new Error('give title or tmdbId'); + const items = await subs.search({ + title: a.title, + year: a.year, + tmdbId: a.tmdbId, + season: a.season, + episode: a.episode, + languages: a.languages || 'en', + }); + return { total: items.length, items: items.slice(0, a.limit || 20) }; + } + case 'fetch_subtitle': { + // hdtilt.com shares one daily allowance among everyone; the player fetches them there. + if (ctx.public) throw new Error('fetch subtitles in the player on this server; MCP can only find them here'); + const subs = await subtitlesOf(ctx); + if (!subs) throw new Error('subtitles are not set up: hdtilt subs --key <api-key>'); + const { vtt } = await subs.download(a.fileId); + return { fileId: Number(a.fileId), format: 'webvtt', text: vtt }; + } default: throw new Error(`unknown tool ${name}`); } @@ -260,7 +315,7 @@ export async function handleRpc(msg, ctx = {}) { } if (method === 'ping') return ok({}); if (method === 'tools/list') { - const hidden = ctx.public ? new Set(['add_playlist', 'list_playlists']) : new Set(); + const hidden = ctx.public ? new Set(['add_playlist', 'list_playlists', 'fetch_subtitle']) : new Set(); return ok({ tools: TOOLS.filter((t) => !hidden.has(t.name)) }); } if (method === 'tools/call') { diff --git a/src/server/server.js b/src/server/server.js index e21101e..d29c595 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -14,6 +14,7 @@ import { guideFor, loadGuide, nowNextFor } from '../core/guide.js'; import { isPlaylist, rewritePlaylist } from '../core/hls.js'; import { fetchPublic } from '../core/publicurl.js'; import { groupsOf, loadSource } from '../core/sources.js'; +import { createSubtitles, credentialsFrom, moviehash, shiftVtt } from '../core/subtitles.js'; import { b64urlDecode, proxyPath } from '../core/util.js'; import { browseVod } from '../core/vod.js'; import { VERSION } from '../version.js'; @@ -77,6 +78,32 @@ export function createApp(opts = {}) { const accounts = opts.accounts === undefined ? createAccounts() : opts.accounts; const gated = isPublic && Boolean(accounts); + // Subtitles (core/subtitles.js): the key comes from the environment, or on a + // local install from `hdtilt subs --key` in the config. Read once, lazily. + let subsP = null; + const subtitles = () => { + if (opts.subtitles !== undefined) return Promise.resolve(opts.subtitles || null); + subsP ??= (isPublic ? Promise.resolve({}) : readConfig().catch(() => ({}))).then((cfg) => { + const s = createSubtitles(credentialsFrom(process.env, cfg)); + return s.enabled ? s : null; + }); + return subsP; + }; + // A file's moviehash, once per URL (two range requests to the provider). + const hashes = new Map(); + const hashOf = (url) => { + if (!hashes.has(url)) { + hashes.set(url, moviehash(url, guardedFetch, 6000)); + if (hashes.size > 2000) hashes.delete(hashes.keys().next().value); + } + return hashes.get(url); + }; + // Downloads are the scarce part (a daily allowance for the whole server), so + // on hdtilt.com each account may spend a few a day; files already fetched are free. + const subsPerDay = + opts.subsPerDay ?? (Number(process.env.HDTILT_SUBS_PER_DAY) || (isPublic ? 10 : Number.POSITIVE_INFINITY)); + const subsSpent = new Map(); // user -> { day, n } + // Sign-in and sign-up are the guessable endpoints: 20 tries a minute per address. const authPerMinute = opts.authPerMinute ?? 20; const hits = new Map(); @@ -203,6 +230,7 @@ export function createApp(opts = {}) { accounts: Boolean(accounts), hls: Boolean(remux), vod: true, + subtitles: Boolean(await subtitles()), }); if ( @@ -216,7 +244,14 @@ 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/vod' || path === '/api/guide' || path === '/api/nownext')) { + if ( + gated && + (path === '/api/load' || + path === '/api/vod' || + path === '/api/subtitles' || + path === '/api/guide' || + path === '/api/nownext') + ) { if (!(await accounts.whoFrom(req.headers.authorization))) return send(res, 401, { error: 'Sign in first', code: 'signin' }); } @@ -238,6 +273,32 @@ export function createApp(opts = {}) { return send(res, 200, await browseVod(source, { kind, category, q, seriesId }, { fetch: guardedFetch })); } + // Subtitles for a film or an episode: { title, year, tmdbId, season, episode, languages, url }. + // With the stream url, a moviehash puts subtitles made for that very file first. + if (path === '/api/subtitles' && req.method === 'POST') { + const subs = await subtitles(); + if (!subs) return send(res, 501, { error: 'subtitles are not set up on this server' }); + const b = await readJson(req); + if (!b.title && !b.tmdbId && !b.imdbId) return send(res, 400, { error: 'title or tmdbId required' }); + let hash = null; + if (b.url && /^https?:/i.test(String(b.url))) hash = await hashOf(String(b.url)); + const ask = { + title: b.title, + year: Number(b.year) || undefined, + tmdbId: b.tmdbId, + imdbId: b.imdbId, + season: b.season ?? undefined, + episode: b.episode ?? undefined, + languages: b.languages, + moviehash: hash || undefined, + }; + let items = await subs.search(ask); + // An id the panel got wrong finds nothing; the title may still. + if (!items.length && (b.tmdbId || b.imdbId) && b.title) + items = await subs.search({ ...ask, tmdbId: undefined, imdbId: undefined }); + return send(res, 200, { items: items.slice(0, 60), moviehash: hash }); + } + // 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); @@ -381,6 +442,39 @@ export function createApp(opts = {}) { } } + /** /subs/<fileId>.vtt[?shift=-120.5]: one subtitle file as WebVTT, from the disk cache or OpenSubtitles. */ + async function subFile(req, res, fileId) { + const subs = await subtitles(); + if (!subs) return send(res, 501, 'subtitles are not set up on this server'); + const who = gated + ? accounts.mediaUser(cookieOf(req, 'hdtilt_media')) || (await accounts.whoFrom(req.headers.authorization))?.userId + : clientIp(req); + const key = String(who || clientIp(req)); + const day = new Date().toISOString().slice(0, 10); + const spent = subsSpent.get(key); + const used = spent?.day === day ? spent.n : 0; + if (used >= subsPerDay && !(await subs.hasCached(fileId))) + return send( + res, + 429, + `That's ${subsPerDay} new subtitles today; ones already fetched still work. More tomorrow.`, + ); + try { + const { vtt, fresh } = await subs.download(fileId); + if (fresh) { + subsSpent.set(key, { day, n: used + 1 }); + if (subsSpent.size > 50_000) subsSpent.clear(); + } + const shift = Number(new URL(req.url, 'http://x').searchParams.get('shift')) || 0; + return send(res, 200, shiftVtt(vtt, shift), { + 'content-type': 'text/vtt; charset=utf-8', + 'cache-control': shift ? 'private, max-age=3600' : 'private, max-age=86400', + }); + } catch (e) { + return send(res, e.status || 502, e.message); + } + } + /** /hls/<b64>/index.m3u8 starts or joins a conversion; /hls/s/<key>/<segment> serves it. */ async function hls(req, res, path) { if (!remux) return send(res, 501, 'this server cannot convert transport streams (no ffmpeg)'); @@ -437,6 +531,7 @@ export function createApp(opts = {}) { async function mcp(req, res) { if (req.method === 'GET') return send(res, 405, 'POST JSON-RPC here', { allow: 'POST' }); + ctx.subtitles ??= await subtitles(); const body = await readJson(req); // Tools need an account on hdtilt.com (a session or an `hdtilt login` token); // initialize and tools/list stay open so a client can discover them. @@ -507,8 +602,10 @@ export function createApp(opts = {}) { const pm = /^\/p\/([A-Za-z0-9_-]+)(?:\/[^/]*)?$/.exec(path); // On hdtilt.com the stream proxy is for signed-in viewers only (the // media cookie, or a bearer); ffmpeg's own reads come from inside. - if ((pm || path.startsWith('/hls/')) && gated && !isInternal(req) && !(await mediaAllowed(req))) + const sm = /^\/subs\/(\d{1,12})\.vtt$/.exec(path); + if ((pm || sm || path.startsWith('/hls/')) && gated && !isInternal(req) && !(await mediaAllowed(req))) return send(res, 401, 'Sign in to watch'); + if (sm) return await subFile(req, res, Number(sm[1])); if (pm) return await proxy(req, res, pm[1]); if (path.startsWith('/hls/')) return await hls(req, res, path); if (path === '/mcp') return await mcp(req, res); diff --git a/src/version.js b/src/version.js index 7195b10..465e390 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const VERSION = '0.3.2'; +export const VERSION = '0.4.0'; diff --git a/test/subtitles.test.js b/test/subtitles.test.js new file mode 100644 index 0000000..b6304f7 --- /dev/null +++ b/test/subtitles.test.js @@ -0,0 +1,311 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + cleanTitle, + createSubtitles, + credentialsFrom, + moviehash, + moviehashOf, + rankResults, + resultOf, + searchParams, + shiftVtt, + toVtt, +} from '../src/core/subtitles.js'; +import { startServer } from '../src/server/server.js'; + +let home; +beforeAll(async () => { + home = await mkdtemp(join(process.env.CLAUDE_JOB_DIR ? join(process.env.CLAUDE_JOB_DIR, 'tmp') : tmpdir(), 'subs-')); +}); +afterAll(() => rm(home, { recursive: true, force: true })); + +const SRT = `1 +00:00:01,500 --> 00:00:03,000 +{\\an8}Hello <font color="#fff">there</font> + +2 +00:01:00,000 --> 00:01:02,250 +Second line +`; + +describe('titles', () => { + it('cleans provider names', () => { + expect(cleanTitle('EN - The Matrix (1999) 4K')).toEqual({ title: 'The Matrix', year: 1999 }); + expect(cleanTitle('|EN| Dune: Part Two [2024] HEVC')).toEqual({ title: 'Dune: Part Two', year: 2024 }); + expect(cleanTitle('4K-NF - Glass Onion 2022')).toEqual({ title: 'Glass Onion', year: 2022 }); + expect(cleanTitle('[MULTI] Amélie')).toEqual({ title: 'Amélie', year: 0 }); + expect(cleanTitle('Heat')).toEqual({ title: 'Heat', year: 0 }); + }); + it('keeps a title that is all tags rather than empty it', () => { + expect(cleanTitle('4K').title).toBe('4K'); + }); +}); + +describe('search parameters', () => { + it('sorts keys and lowercases values, with no defaults', () => { + expect(searchParams({ title: 'EN - The Matrix (1999)', languages: 'FR,en' })).toBe( + 'languages=en%2Cfr&query=the+matrix&type=movie&year=1999', + ); + }); + it('prefers a TMDB id to a title', () => { + expect(searchParams({ title: 'Whatever', tmdbId: '0603', languages: 'en' })).toBe( + 'languages=en&tmdb_id=603&type=movie', + ); + }); + it('asks for an episode by its show', () => { + expect(searchParams({ title: 'Lost', tmdbId: 4607, season: 1, episode: 2 })).toBe( + 'episode_number=2&languages=en&parent_tmdb_id=4607&season_number=1&type=episode', + ); + // Without an id: the show's name, and no year. + expect(searchParams({ title: 'EN - Lost (2004)', season: 1, episode: 2 })).toBe( + 'episode_number=2&languages=en&query=lost&season_number=1&type=episode', + ); + }); +}); + +describe('results', () => { + const row = (id, a) => ({ id: String(id), attributes: { files: [{ file_id: id, file_name: `f${id}.srt` }], ...a } }); + it('ranks this-file matches, then single files, trusted, human, downloads', () => { + const list = [ + row(1, { download_count: 900 }), + row(2, { download_count: 10, moviehash_match: true }), + row(3, { download_count: 5000, nb_cd: 2 }), + row(4, { download_count: 100, from_trusted: true }), + row(5, { download_count: 99999, ai_translated: true }), + ].map(resultOf); + expect(rankResults(list).map((r) => r.fileId)).toEqual([2, 4, 1, 5, 3]); + }); +}); + +describe('asked by title', () => { + const ep = (id, show, downloads) => + resultOf({ + id: String(id), + attributes: { + download_count: downloads, + files: [{ file_id: id }], + feature_details: { parent_title: show, season_number: 1, episode_number: 1 }, + }, + }); + it('puts the show asked for above a looser match (seen on the real API)', () => { + const list = [ep(1, 'The Bad Guys: Breaking In', 9000), ep(2, 'Breaking Bad', 10)]; + expect(rankResults(list, { title: 'Breaking Bad' }).map((r) => r.fileId)).toEqual([2, 1]); + // Asked by id, the order is OpenSubtitles' own signals. + expect(rankResults(list).map((r) => r.fileId)).toEqual([1, 2]); + }); + it('prefers the year asked for', () => { + const film = (id, year) => + resultOf({ id: String(id), attributes: { files: [{ file_id: id }], feature_details: { title: 'Heat', year } } }); + expect(rankResults([film(1, 1986), film(2, 1995)], { title: 'Heat', year: 1995 })[0].fileId).toBe(2); + }); +}); + +describe('WebVTT', () => { + it('turns SubRip into WebVTT', () => { + const v = toVtt(`${SRT.replace(/\n/g, '\r\n')}`); + expect(v.startsWith('WEBVTT\n\n')).toBe(true); + expect(v).toContain('00:00:01.500 --> 00:00:03.000'); + expect(v).toContain('Hello there'); + expect(v).not.toContain('{\\an8}'); + }); + it('leaves WebVTT alone', () => { + expect(toVtt('WEBVTT\n\n00:00.000 --> 00:01.000\nx')).toBe('WEBVTT\n\n00:00.000 --> 00:01.000\nx'); + }); + it('shifts cues and drops the ones that end before zero', () => { + const v = shiftVtt(toVtt(SRT), -30); + expect(v).not.toContain('Hello'); + expect(v).toContain('00:00:30.000 --> 00:00:32.250'); + expect(shiftVtt(toVtt(SRT), 0.5)).toContain('00:00:02.000 --> 00:00:03.500'); + }); + it('keeps cue settings and short timestamps', () => { + expect(shiftVtt('WEBVTT\n\n00:05.000 --> 00:06.000 line:10%\nx\n', 1)).toContain( + '00:00:06.000 --> 00:00:07.000 line:10%', + ); + }); +}); + +describe('moviehash', () => { + it('adds the size and the 64-bit words of both ends', () => { + const head = new Uint8Array(65536); + const tail = new Uint8Array(65536); + head[0] = 1; // word 1 + tail[8] = 2; // word 2 + tail[65535] = 0x80; // the top byte of the last word: wraps past 2^64 with the rest + const size = 2 ** 20; + const want = (BigInt(size) + 1n + 2n + (0x80n << 56n)) & ((1n << 64n) - 1n); + expect(moviehashOf(size, head, tail)).toBe(want.toString(16).padStart(16, '0')); + }); + it('reads it with two range requests, and gives up without ranges', async () => { + const size = 300_000; + const file = new Uint8Array(size).map((_, i) => i % 251); + const fakeFetch = async (_url, { headers }) => { + const m = /bytes=(\d+)-(\d+)/.exec(headers.range); + const [a, b] = [Number(m[1]), Number(m[2])]; + return new Response(file.slice(a, b + 1), { + status: 206, + headers: { 'content-range': `bytes ${a}-${b}/${size}` }, + }); + }; + expect(await moviehash('http://x/f.mkv', fakeFetch)).toBe( + moviehashOf(size, file.slice(0, 65536), file.slice(size - 65536)), + ); + expect(await moviehash('http://x/f.mkv', async () => new Response('whole', { status: 200 }))).toBeNull(); + }); +}); + +describe('credentials', () => { + it('reads the environment first, then the config', () => { + expect(credentialsFrom({ OPENSUBTITLES_API_KEY: 'e' }, { opensubtitles: { apiKey: 'c', username: 'u' } })).toEqual({ + apiKey: 'e', + username: 'u', + password: '', + }); + }); +}); + +/** A stand-in for api.opensubtitles.com, recording what it was asked. */ +function fakeOpenSubtitles() { + const calls = []; + const fetchImpl = async (url, init = {}) => { + const u = new URL(url); + const h = Object.fromEntries(Object.entries(init.headers || {}).map(([k, v]) => [k.toLowerCase(), v])); + calls.push({ + path: u.pathname, + search: u.search, + host: u.host, + auth: h.authorization, + key: h['api-key'], + ua: h['user-agent'], + }); + if (u.pathname.endsWith('/login')) + return Response.json({ token: 'tok', base_url: 'vip-api.opensubtitles.com', user: {}, status: 200 }); + if (u.pathname.endsWith('/subtitles')) + return Response.json({ + data: [ + { id: '1', attributes: { language: 'en', download_count: 5, files: [{ file_id: 11, file_name: 'a.srt' }] } }, + { id: '2', attributes: { language: 'en', download_count: 50, files: [{ file_id: 22, file_name: 'b.srt' }] } }, + ], + }); + if (u.pathname.endsWith('/download')) { + const { file_id } = JSON.parse(init.body); + if (file_id === 99) return Response.json({ message: 'quota', reset_time: '3 hours' }, { status: 406 }); + return Response.json({ link: `https://dl.example/${file_id}.srt`, remaining: 19 }); + } + if (u.host === 'dl.example') return new Response(SRT); + return new Response('?', { status: 404 }); + }; + return { calls, fetchImpl }; +} + +describe('client', () => { + it('searches, best first, and caches the search', async () => { + const f = fakeOpenSubtitles(); + const s = createSubtitles({ apiKey: 'K', fetch: f.fetchImpl, cacheDir: join(home, 'c1') }); + const r = await s.search({ title: 'Heat', year: 1995 }); + expect(r.map((x) => x.fileId)).toEqual([22, 11]); + await s.search({ title: 'Heat', year: 1995 }); + expect(f.calls.filter((c) => c.path.endsWith('/subtitles'))).toHaveLength(1); + expect(f.calls[0].key).toBe('K'); + expect(f.calls[0].ua).toMatch(/^hdtilt v\d/); + }); + it('downloads once per file: the second time is the disk cache', async () => { + const f = fakeOpenSubtitles(); + const s = createSubtitles({ apiKey: 'K', fetch: f.fetchImpl, cacheDir: join(home, 'c2') }); + const a = await s.download(11); + expect(a.fresh).toBe(true); + expect(a.vtt.startsWith('WEBVTT')).toBe(true); + const b = await s.download(11); + expect(b.fresh).toBe(false); + expect(f.calls.filter((c) => c.path.endsWith('/download'))).toHaveLength(1); + }); + it('signs in when it has an account, and talks to the host it was given', async () => { + const f = fakeOpenSubtitles(); + const s = createSubtitles({ + apiKey: 'K', + username: 'u', + password: 'p', + fetch: f.fetchImpl, + cacheDir: join(home, 'c3'), + }); + await s.download(22); + const dl = f.calls.find((c) => c.path.endsWith('/download')); + expect(dl.auth).toBe('Bearer tok'); + expect(dl.host).toBe('vip-api.opensubtitles.com'); + expect(f.calls.filter((c) => c.path.endsWith('/login'))).toHaveLength(1); + }); + it('says when the day is used up', async () => { + const f = fakeOpenSubtitles(); + const s = createSubtitles({ apiKey: 'K', fetch: f.fetchImpl, cacheDir: join(home, 'c4') }); + await expect(s.download(99)).rejects.toThrow(/used up; more in 3 hours/); + }); + it('is off without a key', async () => { + const s = createSubtitles({ apiKey: '' }); + expect(s.enabled).toBe(false); + await expect(s.search({ title: 'x' })).rejects.toThrow(/not set up/); + }); +}); + +describe('server', () => { + let srv; + let file; + const at = (p) => `http://127.0.0.1:${srv.address().port}${p}`; + beforeAll(async () => { + // A provider's film that answers range requests, for the moviehash. + const size = 200_000; + const bytes = new Uint8Array(size).map((_, i) => (i * 7) % 256); + file = createServer((req, res) => { + const m = /bytes=(\d+)-(\d+)/.exec(req.headers.range || ''); + if (!m) return res.writeHead(200).end(Buffer.from(bytes)); + const [a, b] = [Number(m[1]), Number(m[2])]; + res.writeHead(206, { 'content-range': `bytes ${a}-${b}/${size}`, 'content-length': b - a + 1 }); + res.end(Buffer.from(bytes.slice(a, b + 1))); + }); + await new Promise((r) => file.listen(0, '127.0.0.1', r)); + const f = fakeOpenSubtitles(); + const subtitles = createSubtitles({ apiKey: 'K', fetch: f.fetchImpl, cacheDir: join(home, 'srv') }); + srv = await startServer({ port: 0, public: false, accounts: null, remux: false, subtitles, subsPerDay: 1 }); + srv.calls = f.calls; + }); + afterAll(() => { + srv.close(); + file.close(); + }); + + it('says it has subtitles', async () => { + expect((await (await fetch(at('/api/health'))).json()).subtitles).toBe(true); + }); + it('searches, with a moviehash of the file', async () => { + const res = await fetch(at('/api/subtitles'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title: 'EN - Heat (1995)', url: `http://127.0.0.1:${file.address().port}/movie/1.mkv` }), + }); + const r = await res.json(); + expect(res.status).toBe(200); + expect(r.items[0].fileId).toBe(22); + expect(r.moviehash).toMatch(/^[0-9a-f]{16}$/); + expect(srv.calls.find((c) => c.path.endsWith('/subtitles')).search).toContain(`moviehash=${r.moviehash}`); + }); + it('serves a file as WebVTT, shifted, and caps new downloads per viewer', async () => { + const res = await fetch(at('/subs/11.vtt?shift=-1')); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/vtt'); + expect(await res.text()).toContain('00:00:00.500 --> 00:00:02.000'); + // The same file again is free; a new one is over today's 1. + expect((await fetch(at('/subs/11.vtt'))).status).toBe(200); + expect((await fetch(at('/subs/22.vtt'))).status).toBe(429); + }); + it('answers 501 where there is no key', async () => { + const s2 = await startServer({ port: 0, public: false, accounts: null, remux: false, subtitles: null }); + const r = await fetch(`http://127.0.0.1:${s2.address().port}/api/subtitles`, { + method: 'POST', + body: JSON.stringify({ title: 'x' }), + }); + expect(r.status).toBe(501); + s2.close(); + }); +}); diff --git a/web/public/app.css b/web/public/app.css index 0371593..3499444 100644 --- a/web/public/app.css +++ b/web/public/app.css @@ -1282,3 +1282,150 @@ body.gated .nav-items button:not(#nav-account) { border-radius: 0.8rem; color: var(--text); } + +/* ---------------------------------------------------------- subtitles */ +.controls button.on { + background: var(--text); + color: #05080c; +} +#video::cue { + font-size: 1.4em; + line-height: 1.3; + color: #fff; + background: rgba(0, 0, 0, 0.6); + text-shadow: 0 1px 2px #000; +} +.subs { + position: absolute; + right: 1.6rem; + bottom: 7.5rem; + z-index: 6; + width: min(30rem, calc(100vw - 2rem)); + max-height: min(70vh, 34rem); + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 1rem; + border-radius: var(--radius); + background: var(--panel-2); + border: 1px solid var(--line); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6); +} +.subs header { + display: flex; + align-items: center; + justify-content: space-between; +} +.subs h3 { + margin: 0; + font-size: 1.05rem; +} +.subs-bar { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} +.subs select { + flex: 1; + min-width: 10rem; + padding: 0.5rem 0.6rem; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--panel); + color: var(--text); + font: inherit; +} +.subs select:focus-visible { + outline: 2px solid var(--accent); +} +.subs-sync { + display: flex; + align-items: center; + gap: 0.35rem; + font-variant-numeric: tabular-nums; + color: var(--dim); + font-size: 0.85rem; +} +.subs-sync button { + border: 0; + border-radius: 999px; + padding: 0.35rem 0.6rem; + background: rgba(255, 255, 255, 0.1); + color: var(--text); + font: inherit; + cursor: pointer; +} +.subs-sync button:hover, +.subs-sync button:focus-visible { + background: var(--accent); + color: var(--accent-ink); + outline: none; +} +.subs-list { + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; +} +.subs-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; + width: 100%; + text-align: left; + padding: 0.55rem 0.7rem; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text); + font: inherit; + cursor: pointer; +} +.subs-row:hover, +.subs-row:focus-visible { + background: var(--focus); + outline: 2px solid var(--accent); + outline-offset: -2px; +} +.subs-row.cur { + background: rgba(56, 189, 248, 0.12); +} +.subs-row.cur > .subs-rel::before { + content: "✓ "; + color: var(--accent); +} +.subs-rel { + font-size: 0.9rem; + word-break: break-all; +} +.subs-meta { + display: flex; + gap: 0.4rem; + align-items: center; + color: var(--dim); + font-size: 0.78rem; +} +.subs-meta .tag { + font-size: 0.68rem; +} +.subs-status, +.subs-credit { + margin: 0; + color: var(--dim); + font-size: 0.82rem; +} +.subs-credit a { + color: var(--dim); +} +@media (max-width: 700px) { + .subs { + left: 1rem; + right: 1rem; + width: auto; + bottom: 8.5rem; + max-height: 60vh; + } +} diff --git a/web/public/index.html b/web/public/index.html index d7b3a61..f0e111a 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -29,11 +29,15 @@ <input type="range" id="vol" min="0" max="1" step="0.05" value="1" aria-label="Volume"> <div class="seek" id="seekwrap" hidden><span id="t-cur">0:00</span><input type="range" id="seek" min="0" max="1" step="0.1" value="0" aria-label="Position"><span id="t-dur">0:00</span></div> <span class="spacer"></span> + <button type="button" data-c="cc" aria-label="Subtitles" hidden></button> <button type="button" data-c="pip" aria-label="Picture in picture"></button> <button type="button" data-c="fs" aria-label="Full screen"></button> </div> </section> + <!-- Subtitles for a film or an episode (web/src/subs.js). --> + <section id="subs" class="subs" hidden role="dialog" aria-label="Subtitles"></section> + <!-- Channel list: groups rail + channels. --> <section id="list" class="list" hidden> <nav id="groups" class="groups" aria-label="Groups"></nav> diff --git a/web/src/app.js b/web/src/app.js index 02c8c4d..1fc5b97 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -9,6 +9,7 @@ import { b64urlEncode, proxyPath } from '../../src/core/util.js'; import * as account from './account.js'; import * as api from './data.js'; import * as store from './store.js'; +import { createSubs } from './subs.js'; import * as vod from './vod.js'; const { V } = vod; @@ -58,6 +59,22 @@ const S = { vod: null, // { item, show, offset, remux, duration } when playing a film or an episode }; +/* ------------------------------------------------------------ subtitles */ + +const subs = createSubs({ + video: $('video'), + panel: $('subs'), + now: () => S.vod && { item: S.vod.item, show: S.vod.show, offset: S.vod.offset }, + onToggle: (open) => { + document.body.classList.toggle('subs-open', open); + if (!open) { + holdChrome(); + if (!$('banner').hidden) updateControls(); + } + }, + toast: (t) => toast(t), +}); + /* ---------------------------------------------------------------- boot */ async function boot() { @@ -239,6 +256,7 @@ async function play(ch, { viaProxy = false, url = null, programme = null } = {}) if (S.vod) { saveVodPos(); S.vod = null; + subs.detach(); } if (S.playing && S.playing.id !== ch.id && S.playing.kind !== 'vod') S.previous = S.playing; S.playing = ch; @@ -400,6 +418,8 @@ async function attachVod(video, target, mine, seekTo, watchStall, onError) { if (S.current !== mine) return att.destroy(); S.attached = att; if (att.unplayable) return onError(att.unplayable); + // The subtitle picked for this title, shifted to where this session starts. + subs.attach(); if (seekTo) video.addEventListener('loadedmetadata', () => S.current === mine && (video.currentTime = seekTo), { once: true, @@ -599,6 +619,7 @@ const ICON = { vol: '<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/></svg>', muted: '<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.6 3 2.7-2.7-1.4-1.4-2.7 2.7-2.7-2.7-1.4 1.4 2.7 2.7-2.7 2.7 1.4 1.4 2.7-2.7 2.7 2.7 1.4-1.4z"/></svg>', + cc: '<svg viewBox="0 0 24 24"><path d="M19 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1z"/></svg>', pip: '<svg viewBox="0 0 24 24"><path d="M19 7h-8v6h8V7zm2-4H3a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h18a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 16H3V5h18v14z"/></svg>', fs: '<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>', unfs: '<svg viewBox="0 0 24 24"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>', @@ -621,6 +642,10 @@ function updateControls() { ctl('mute').innerHTML = silent ? ICON.muted : ICON.vol; ctl('mute').setAttribute('aria-label', silent ? 'Unmute' : 'Mute'); $('vol').value = String(silent ? 0 : v.volume); + ctl('cc').innerHTML = ICON.cc; + ctl('cc').hidden = !(S.vod && api.serverInfo.subtitles); + ctl('cc').classList.toggle('on', subs.on); + ctl('cc').setAttribute('aria-pressed', String(subs.on)); ctl('pip').innerHTML = ICON.pip; ctl('pip').hidden = !document.pictureInPictureEnabled; ctl('fs').innerHTML = document.fullscreenElement ? ICON.unfs : ICON.fs; @@ -683,6 +708,7 @@ $('controls').addEventListener('click', (e) => { } else v.muted = true; } if (c === 'fs') toggleFullscreen(); + if (c === 'cc') subs.toggle(); if (c === 'pip') { if (document.pictureInPictureElement) document.exitPictureInPicture().catch(() => {}); else v.requestPictureInPicture?.().catch(() => {}); @@ -737,6 +763,8 @@ function soundOn(e) { for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, soundOn, true); function hideChrome() { + // The subtitles panel sits on the banner: it stays up until it is closed. + if (subs.isOpen()) return; $('banner').hidden = true; navPinned = false; if (S.mode === 'tv') navFocus = -1; @@ -2259,6 +2287,7 @@ function toast(text) { let lastTap = 0; $('video').addEventListener('click', () => { + if (subs.isOpen()) return subs.close(); // The picture is behind everything: clicking it means "just the picture". if (S.mode !== 'tv') return closeToPicture(); const now = Date.now(); @@ -2310,6 +2339,7 @@ function numberKey(d) { } addEventListener('keydown', (e) => { + if (subs.key(e)) return; if (navFocus >= 0 && !(e.target instanceof HTMLInputElement)) { const n = navButtons().length; const k = e.key; @@ -2425,7 +2455,10 @@ addEventListener('keydown', (e) => { else if (k === 'ArrowUp' || k === 'ArrowDown' || k === 'i' || k === 'Info') showBanner(); else if (k === 'Enter' || k === '/') go(V.show && V.plId === S.pl?.id ? vod.vodPath() : V.cat ? vodListPath() : '/vod'); - else if (k === 'n' || k === 'MediaTrackNext') { + else if ((k === 'c' || k === 'Subtitle') && api.serverInfo.subtitles) { + showBanner(false); + subs.open(); + } else if (k === 'n' || k === 'MediaTrackNext') { const next = vod.nextEpisode(S.vod.show, S.vod.item); if (next) playVod(next, { start: 0, show: S.vod.show }); } else if (k === 'f' || k === 'ColorF1Green' || /^\d$/.test(k)) handled = true; diff --git a/web/src/data.js b/web/src/data.js index cf56bb1..c14cdf8 100644 --- a/web/src/data.js +++ b/web/src/data.js @@ -58,6 +58,15 @@ export function vodInfo(url) { return json(`/hls/${b64urlEncode(url)}/info.json`); } +/** Subtitles for a film or an episode (OpenSubtitles, through the server). */ +export function subtitles(ask) { + return json('/api/subtitles', ask); +} + +/** A subtitle file as WebVTT, its cues moved by `shift` seconds. */ +export const subtitleUrl = (fileId, shift = 0) => + `/subs/${Number(fileId)}.vtt${shift ? `?shift=${Math.round(shift * 1000) / 1000}` : ''}`; + export function guide(url, channels, from, to) { const slim = channels.map((c) => ({ id: c.id, name: c.name, tvgId: c.tvgId })); return json('/api/guide', { url, channels: slim, from, to }).then((r) => r.programmes); diff --git a/web/src/subs.js b/web/src/subs.js new file mode 100644 index 0000000..4332b0f --- /dev/null +++ b/web/src/subs.js @@ -0,0 +1,409 @@ +// Subtitles on a film or an episode: the CC button opens a panel of what +// OpenSubtitles has in the chosen language, best match first, and the pick +// becomes a <track> on the video. +// +// The server converts and shifts the file (/subs/<id>.vtt?shift=), so this +// side never parses a cue. A converted film's clock starts where its ffmpeg +// session started (`offset`), so the cues are moved back by that much; after a +// seek past the converted part the track is fetched again with the new shift, +// from the server's disk cache, which costs no download. + +import * as api from './data.js'; +import * as store from './store.js'; + +// Common first, then alphabetical by name. OpenSubtitles codes (pt-BR is pt-br). +export const LANGS = [ + ['en', 'English'], + ['es', 'Spanish'], + ['fr', 'French'], + ['de', 'German'], + ['pt-br', 'Portuguese (Brazil)'], + ['pt-pt', 'Portuguese'], + ['it', 'Italian'], + ['ar', 'Arabic'], + ['bg', 'Bulgarian'], + ['zh-cn', 'Chinese (simplified)'], + ['zh-tw', 'Chinese (traditional)'], + ['hr', 'Croatian'], + ['cs', 'Czech'], + ['da', 'Danish'], + ['nl', 'Dutch'], + ['fi', 'Finnish'], + ['el', 'Greek'], + ['he', 'Hebrew'], + ['hi', 'Hindi'], + ['hu', 'Hungarian'], + ['id', 'Indonesian'], + ['ja', 'Japanese'], + ['ko', 'Korean'], + ['ms', 'Malay'], + ['no', 'Norwegian'], + ['fa', 'Persian'], + ['pl', 'Polish'], + ['ro', 'Romanian'], + ['ru', 'Russian'], + ['sr', 'Serbian'], + ['sk', 'Slovak'], + ['sl', 'Slovenian'], + ['sv', 'Swedish'], + ['th', 'Thai'], + ['tr', 'Turkish'], + ['uk', 'Ukrainian'], + ['vi', 'Vietnamese'], +]; + +/** The language to look in first: the last one picked, else the browser's. */ +export function defaultLang(nav = globalThis.navigator) { + const saved = store.prefs.read('subLang', ''); + if (saved) return saved; + const l = String(nav?.language || 'en').toLowerCase(); + if (l === 'pt-br' || l.startsWith('pt-br')) return 'pt-br'; + if (l.startsWith('pt')) return 'pt-pt'; + if (l.startsWith('zh')) return /tw|hk|hant/.test(l) ? 'zh-tw' : 'zh-cn'; + const two = l.slice(0, 2); + return LANGS.some(([c]) => c === two) ? two : 'en'; +} + +/** + * What to ask OpenSubtitles about the title playing. An episode is its show + + * season + episode (the show's TMDB id when the panel gave one); a film is its + * TMDB id, else its name and year. `url` lets the server hash the file. + */ +export function askFor(item, show, lang) { + if (!item) return null; + const ep = item.season != null && item.episode != null; + const ask = { languages: lang, url: item.url }; + if (ep) { + Object.assign(ask, { + title: show?.name || item.show?.name || item.group || item.name, + season: item.season, + episode: item.episode, + }); + const tmdb = show?.tmdbId || item.show?.tmdbId; + if (tmdb) ask.tmdbId = tmdb; + } else { + Object.assign(ask, { title: item.name, year: item.year || undefined }); + if (item.tmdbId) ask.tmdbId = item.tmdbId; + } + return ask; +} + +// What a title was watched with, so resuming it brings its subtitles back. +const PICKS = 'subsFor'; +const picks = () => store.prefs.read(PICKS, {}); +export function rememberPick(itemId, pick) { + const all = picks(); + delete all[itemId]; + if (pick) all[itemId] = pick; + const keys = Object.keys(all); + for (const k of keys.slice(0, Math.max(0, keys.length - 200))) delete all[k]; + store.prefs.write(PICKS, all); +} +export const pickFor = (itemId) => picks()[itemId] || null; + +const esc = (s) => + String(s ?? '').replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c], + ); +const short = (n) => (n >= 1000 ? `${Math.round(n / 100) / 10}k` : String(n)); + +/** + * @param {{ video: HTMLVideoElement, panel: HTMLElement, now: () => ({ item, show, offset } | null), + * onToggle?: (open: boolean) => void, toast?: (t: string) => void }} deps + */ +export function createSubs({ video, panel, now, onToggle = () => {}, toast = () => {} }) { + const st = { + itemId: null, // the title the state below belongs to + pick: null, // { fileId, lang, label } + delay: 0, // seconds, + = later + lang: defaultLang(), + results: null, // for st.itemId + st.lang + resultsKey: '', + loading: false, + error: '', + focus: 0, + seq: 0, + }; + + const track = () => video.querySelector('track[data-subs]'); + + // While the banner and controls are up they cover the bottom of the picture, + // where cues go: move them up a few lines (cue.line works in every engine). + const LIFT = -5; + function lift() { + const cues = track()?.track.cues; + if (!cues) return; + const up = document.body.classList.contains('chrome-on'); + for (const c of cues) c.line = up ? LIFT : 'auto'; + } + new MutationObserver(lift).observe(document.body, { attributes: true, attributeFilter: ['class'] }); + + function removeTrack() { + const t = track(); + if (t) { + t.track.mode = 'disabled'; + t.remove(); + } + } + + /** Put the picked subtitle on the video, shifted for the current offset and delay. */ + function apply() { + removeTrack(); + const cur = now(); + if (!st.pick || !cur) return; + const t = document.createElement('track'); + t.dataset.subs = '1'; + t.kind = 'subtitles'; + t.label = st.pick.label || st.pick.lang; + t.srclang = st.pick.lang.slice(0, 2); + t.src = api.subtitleUrl(st.pick.fileId, st.delay - (cur.offset || 0)); + t.default = true; + t.addEventListener('error', () => { + // The error body (quota, sign-in) is not readable from a <track>; ask again to say why. + fetch(t.src, { credentials: 'same-origin' }) + .then(async (r) => !r.ok && toast(await r.text())) + .catch(() => {}); + }); + video.appendChild(t); + // Some engines leave a new track hidden even with default set. + const show = () => { + for (const tt of video.textTracks) + tt.mode = tt === t.track ? 'showing' : tt.mode === 'showing' ? 'disabled' : tt.mode; + }; + show(); + t.addEventListener( + 'load', + () => { + show(); + lift(); + }, + { once: true }, + ); + } + + /** A new title started, or the same one restarted at a new offset. */ + function attach() { + const cur = now(); + if (!cur) return; + if (cur.item.id !== st.itemId) { + st.itemId = cur.item.id; + st.results = null; + st.resultsKey = ''; + st.error = ''; + const saved = pickFor(cur.item.id); + st.pick = saved ? { fileId: saved.fileId, lang: saved.lang, label: saved.label } : null; + st.delay = saved?.delay || 0; + } + apply(); + render(); + } + + /** Playing stopped being a film: nothing on the video. */ + function detach() { + st.itemId = null; + st.pick = null; + removeTrack(); + close(); + } + + async function load() { + const cur = now(); + if (!cur) return; + const ask = askFor(cur.item, cur.show, st.lang); + const key = `${cur.item.id}|${st.lang}`; + if (st.resultsKey === key && st.results) return render(); + const mine = ++st.seq; + st.loading = true; + st.error = ''; + st.results = null; + render(); + try { + const r = await api.subtitles(ask); + if (mine !== st.seq) return; + st.results = r.items || []; + st.resultsKey = key; + } catch (e) { + if (mine !== st.seq) return; + st.error = e.message || String(e); + } + st.loading = false; + render(); + } + + const isOpen = () => !panel.hidden; + + function open() { + if (!now()) return; + panel.hidden = false; + st.focus = 0; + onToggle(true); + render(); + load(); + focusRow(); + } + + function close() { + if (panel.hidden) return; + panel.hidden = true; + onToggle(false); + } + + function choose(r) { + const cur = now(); + if (!cur) return; + if (!r) { + st.pick = null; + rememberPick(cur.item.id, null); + apply(); + render(); + return toast('Subtitles off'); + } + st.pick = { fileId: r.fileId, lang: r.language || st.lang, label: labelOf(r.language || st.lang) }; + st.delay = 0; + rememberPick(cur.item.id, { ...st.pick, delay: 0 }); + apply(); + render(); + close(); + } + + function nudge(by) { + const cur = now(); + st.delay = Math.round((st.delay + by) * 10) / 10; + if (cur && st.pick) rememberPick(cur.item.id, { ...st.pick, delay: st.delay }); + apply(); + render(); + } + + const labelOf = (code) => LANGS.find(([c]) => c === code)?.[1] || code.toUpperCase(); + + function render() { + if (panel.hidden) return; + const rows = st.results || []; + const status = st.loading + ? 'Looking…' + : st.error + ? esc(st.error) + : st.results && !rows.length + ? `None in ${esc(labelOf(st.lang))}. Try another language.` + : ''; + panel.innerHTML = ` + <header><h3>Subtitles</h3><button type="button" class="close-btn" data-s="close" aria-label="Close subtitles">✕</button></header> + <div class="subs-bar"> + <select data-s="lang" aria-label="Language">${LANGS.map(([c, n]) => `<option value="${c}"${c === st.lang ? ' selected' : ''}>${esc(n)}</option>`).join('')}</select> + ${ + st.pick + ? `<span class="subs-sync" title="Move the subtitles if they come too early or too late"> + <button type="button" data-s="earlier" aria-label="Subtitles earlier">−½s</button> + <span>${st.delay > 0 ? '+' : ''}${st.delay.toFixed(1)}s</span> + <button type="button" data-s="later" aria-label="Subtitles later">+½s</button></span>` + : '' + } + </div> + <div class="subs-list" role="listbox"> + <button type="button" class="subs-row${st.pick ? '' : ' cur'}" data-i="-1" role="option" aria-selected="${!st.pick}"><span class="subs-rel">Off</span></button> + ${rows + .map( + ( + r, + i, + ) => `<button type="button" class="subs-row${st.pick?.fileId === r.fileId ? ' cur' : ''}" data-i="${i}" role="option" aria-selected="${st.pick?.fileId === r.fileId}"> + <span class="subs-rel">${esc(r.release || r.fileName || 'Untitled')}</span> + <span class="subs-meta">${r.hashMatch ? '<b class="tag">THIS FILE</b>' : ''}${r.hearingImpaired ? '<b class="tag">CC</b>' : ''}${r.trusted ? '<b class="tag">TRUSTED</b>' : ''}${r.aiTranslated ? '<b class="tag">AI</b>' : ''}<span>⬇ ${short(r.downloads)}</span></span> + </button>`, + ) + .join('')} + </div> + ${status ? `<p class="subs-status">${status}</p>` : ''} + <p class="subs-credit">From <a href="https://www.opensubtitles.com" target="_blank" rel="noopener">OpenSubtitles</a></p>`; + focusRow(false); + } + + const rowsEls = () => [...panel.querySelectorAll('.subs-row')]; + function focusRow(scroll = true) { + const els = rowsEls(); + if (!els.length) return; + st.focus = Math.max(0, Math.min(st.focus, els.length - 1)); + if (panel.contains(document.activeElement) && document.activeElement.tagName === 'SELECT') return; + els[st.focus].focus({ preventScroll: !scroll }); + if (scroll) els[st.focus].scrollIntoView({ block: 'nearest' }); + } + + panel.addEventListener('click', (e) => { + e.stopPropagation(); + const b = e.target.closest('[data-s], [data-i]'); + if (!b) return; + if (b.dataset.s === 'close') return close(); + if (b.dataset.s === 'earlier') return nudge(-0.5); + if (b.dataset.s === 'later') return nudge(0.5); + if (b.dataset.i != null) { + const i = Number(b.dataset.i); + st.focus = i + 1; + choose(i < 0 ? null : st.results?.[i]); + } + }); + panel.addEventListener('change', (e) => { + if (e.target.dataset.s !== 'lang') return; + st.lang = e.target.value; + store.prefs.write('subLang', st.lang); + load(); + }); + // Over the picture, a click outside the panel closes it. + panel.addEventListener('pointerdown', (e) => e.stopPropagation()); + + /** Keys while the panel is open: Up/Down through the rows, OK picks, Back closes. True if used. */ + function key(e) { + if (!isOpen()) return false; + const k = e.key; + if (e.target.tagName === 'SELECT') { + if (k === 'Escape' || k === 'GoBack') { + e.target.blur(); + focusRow(); + e.preventDefault(); + return true; + } + if (k === 'ArrowDown' && !e.altKey) { + // Down on a closed select moves on to the list (a remote has no other way out). + e.target.blur(); + st.focus = 0; + focusRow(); + e.preventDefault(); + return true; + } + return true; + } + const n = rowsEls().length; + if (k === 'ArrowDown') st.focus = Math.min(n - 1, st.focus + 1); + else if (k === 'ArrowUp') { + if (st.focus === 0) { + panel.querySelector('select')?.focus(); + e.preventDefault(); + return true; + } + st.focus = Math.max(0, st.focus - 1); + } else if (k === 'ArrowLeft' && st.pick) nudge(-0.5); + else if (k === 'ArrowRight' && st.pick) nudge(0.5); + else if (k === 'Enter' || k === ' ') { + if (document.activeElement?.closest?.('[data-s]')) return false; // its own click + const i = st.focus - 1; + choose(i < 0 ? null : st.results?.[i]); + } else if (k === 'Escape' || k === 'GoBack' || k === 'BrowserBack' || k === 'c') close(); + else return false; + e.preventDefault(); + if (isOpen()) focusRow(); + return true; + } + + return { + attach, + detach, + open, + close, + toggle: () => (isOpen() ? close() : open()), + isOpen, + key, + get on() { + return Boolean(st.pick); + }, + }; +} diff --git a/web/src/vod.js b/web/src/vod.js index d182c54..7b08f22 100644 --- a/web/src/vod.js +++ b/web/src/vod.js @@ -62,9 +62,11 @@ export function resumeAt(id) { export async function remember(plId, item, pos, dur, show = null) { if (!plId || !item) return; const slim = { id: item.id, name: item.name, url: item.url, kind: item.kind, logo: item.logo, group: item.group }; + if (item.year) slim.year = item.year; + if (item.tmdbId) slim.tmdbId = item.tmdbId; if (item.season != null) Object.assign(slim, { season: item.season, episode: item.episode }); const entry = { item: slim, pos: Math.floor(pos || 0), dur: Math.floor(dur || 0), at: Date.now() }; - if (show) entry.show = { seriesId: show.seriesId, name: show.name, logo: show.logo }; + if (show) entry.show = { seriesId: show.seriesId, name: show.name, logo: show.logo, tmdbId: show.tmdbId }; V.history = [entry, ...V.history.filter((x) => x.item.id !== item.id)].slice(0, 60); await store.set(hKey(plId), V.history); }