Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<channel>`, `/channels/<group>`, `/favorites`, `/recent`, `/guide` (lists take `?q=` to filter), `/vod/movies/<category>`, `/vod/shows/<category>`, `/vod/show/<id>`, `/settings`, `/account`. Each can be bookmarked or reloaded, and the browser's Back button (or a remote's) steps back through them.
- **A global nav** with Live TV (with a filter box), Movies & Shows, TV Guide, ★ Favorites, Recent, Account and Settings. It is always on screen outside the picture; over the picture, Back or Menu brings it up, focused for the remote.
- **iPhone and iPad Safari:** they have no Media Source Extensions, so they cannot play an MPEG-TS stream directly. hdtilt asks an Xtream line for its HLS version when it offers one, and otherwise the server repackages the stream as HLS (`ffmpeg -c copy`, no transcoding, shared by everyone watching that channel). Self-hosters need `ffmpeg` on the PATH, or `HDTILT_FFMPEG`.
Expand Down Expand Up @@ -81,6 +82,8 @@ hdtilt guide <channel> [--hours 12] [--from -6]
hdtilt url <channel> [--at <iso-time>] # --at = catch-up
hdtilt play <channel> [--player mpv|vlc|ffplay]
hdtilt fav <channel>
hdtilt subs --key <opensubtitles-api-key> [--user <u> --pass <p>]
hdtilt subs <title> [--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
```
Expand All @@ -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

Expand All @@ -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 |
Expand All @@ -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

Expand Down
56 changes: 56 additions & 0 deletions bin/hdtilt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hdtilt",
"version": "0.3.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": {
Expand Down
Loading
Loading