A minimal, from-scratch media downloader library (and CLI) for Node. Minimal by design: a ~22 KB minified core with no runtime dependencies today (~9 KB gzip), fully typed, no terminal code in the core. Everything that isn't needed by every user is opt-in.
npm i @boy-offi9-inc/tidepull # as a library
npx @boy-offi9-inc/tidepull -F <url> # or run the CLI without installing
npm i -g @boy-offi9-inc/tidepull # installs the `tidepull` commandPublished under the @boy-offi9-inc npm scope; the command it installs is tidepull.
Requires Node >= 22.18. ffmpeg is optional (only needed to merge video+audio or remux).
import { createTidepull } from "@boy-offi9-inc/tidepull";
const g = createTidepull();
g.on((e) => e.type === "progress" && console.log(e.downloaded, e.total, e.speed));
const info = await g.extract("https://example.com/stream/master.m3u8");
const [video, audio] = g.select(info, "bv*[height<=720]+ba/b") ?? []; // pure, no network
const file = await g.download(info, { format: "bv*[height<=720]+ba/b", paths: "./out" });Cancel anything with an AbortSignal; partial .part files are kept so the next run resumes:
const ctl = new AbortController();
setTimeout(() => ctl.abort(), 5000);
await g.pull(url, { signal: ctl.signal }); // rejects with name === "AbortError"Cap the speed with limitRate (bytes per second, applied to each file): g.download(info, { limitRate: 1_000_000 }).
RSS/Atom feeds with audio or video enclosures and .m3u files are playlists. Enable them with the opt-in feeds plugin and download every item with pullAll:
import feeds from "@boy-offi9-inc/tidepull/feeds";
import { pullAll } from "@boy-offi9-inc/tidepull/playlist";
const g = createTidepull({ plugins: [feeds] });
const files = await pullAll(g, "https://example.com/podcast.rss", { items: "1-3,7", paths: "./out" });
// "01 - Episode title.mp3", "02 - ...": the number and the title come from the playlistg.resolve(url) returns an Info or a Playlist (isPlaylist(x) tells you which). g.extract(url) still returns a single Info and refuses a playlist with a clear error. A failing item is thrown by default; pass onError to report it and continue. Template fields for playlist items: %(playlist)s, %(playlist_index)02d, %(playlist_count)s, %(playlist_id)s.
| Export | Purpose |
|---|---|
createTidepull(opts?) → Tidepull |
.extract(url), .resolve(url), .select(info, selector), .download(info, opts), .pull(url, opts), .on(listener), .use(plugin), .extractors |
definePlugin, PLUGIN_API_VERSION |
write extractor plugins |
@boy-offi9-inc/tidepull/dash (opt-in) |
DASH VOD: createTidepull({ plugins: [dash] }) |
@boy-offi9-inc/tidepull/loader (opt-in) |
loadPlugins(paths) / loadPlugin(file): load plugins from disk |
@boy-offi9-inc/tidepull/cookies (opt-in) |
parseCookies, loadCookieFile, cookieJar: createTidepull({ http: { cookies: cookieJar(cookies) } }) |
@boy-offi9-inc/tidepull/feeds (opt-in) |
RSS/Atom feeds and .m3u files as playlists: createTidepull({ plugins: [feeds] }); also parseFeed, parseM3u |
@boy-offi9-inc/tidepull/playlist (opt-in) |
pullAll, resolveItems, parseItems, playlistFields |
selectFormats, renderTemplate, parseHlsMaster/Media |
the building blocks, usable standalone |
Http, TidepullError |
redirect-safe, header-scoped HTTP client; error type |
| types | Info, Format, Extractor, TidepullEvent, DownloadOptions, TidepullPlugin, ... |
extract · start · progress {downloaded,total,estimated,speed,eta} · finish · merge · remux · done {file} · log {level,tag,message} · error
CLI: tidepull --progress-json URL prints the same events as NDJSON on stdout.
// ~/.config/tidepull/plugins/mysite.ts (or --plugin ./mysite.ts, or g.use(...))
import { definePlugin } from "@boy-offi9-inc/tidepull";
export default definePlugin({
name: "mysite", apiVersion: 1,
extractors: [{
name: "mysite",
suitable: (u) => u.hostname === "mysite.example",
async extract(u, ctx) { /* ctx.http is redirect-safe + cancellable */ return { id, title, extractor: "mysite", webpage_url: u.href, formats: [...] }; },
}],
});Plugins run before built-ins; the generic fallback is always last. From disk: import { loadPlugins } from "@boy-offi9-inc/tidepull/loader" (the CLI does this for you, and bundles DASH). A plugin targeting a different apiVersion is refused with a clear message instead of failing mysteriously. Plugins execute with your privileges. Only load code you trust. See src/extractors/_template.ts.
An extractor may instead return a Playlist ({ kind: "playlist", id, title, extractor, webpage_url, entries: [{ url, title? }] }) for a page that lists several items; each entry is then extracted on its own by the same extractors.
tidepull -F URL # list formats
tidepull -f "bv*[height<=720]+ba/b" URL # select + merge (needs ffmpeg)
tidepull -o "%(title).60s [%(id)s].%(ext)s" -P ~/Videos URL
tidepull -j URL | jq .formats # data on stdout, logs on stderr
tidepull -a urls.txt --download-archive done.txt # batch input; skip what you already downloaded
tidepull --limit-rate 1M URL # cap the speed (500K, 1.5M, ...)
tidepull --cookies cookies.txt URL # use your own logged-in session
tidepull https://example.com/podcast.rss -P ~/Music # every item of a feed or .m3u playlist
tidepull --playlist-items 1-3,7 URL # only some items (-F lists a playlist's entries)Cookies. --cookies FILE reads a Netscape cookies.txt (the format browser extensions export). Each cookie is sent only to the host, path and scheme it was issued for. Keep the file private (chmod 600); you get a warning if other users can read it.
Playlists. A feed or .m3u URL downloads every item; --playlist-items 1-3,7,10- chooses some. Items are numbered in the file name (01 - Title.mp3). A failing item is reported and the rest continue (exit code 1). -j prints the playlist as one JSON object and -F lists its entries, without downloading.
Batch and archive. -a FILE reads one URL per line (# starts a comment, - reads stdin). --download-archive FILE records each finished item and skips it on later runs.
Config file. Defaults go in ~/.config/tidepull/config (or --config-location FILE): one or more options per line, # for comments, for example -P ~/Videos and --limit-rate 2M. Options on the command line win; --no-config ignores the file.
From a clone, node src/cli.ts <args> runs the TypeScript source directly (Node >= 22.18); npm run build emits JS + .d.ts.
What Tidepull does
- Downloads direct media files, HLS (VOD, AES-128) and DASH (VOD) streams, and pages that embed them
- Resumes interrupted downloads, caps speed, retries with backoff and cancels cleanly
- Downloads RSS/Atom feeds (audio and video enclosures) and
.m3ufiles as playlists, with item selection and numbered file names - Merges separate video and audio, or remuxes, with ffmpeg
- Selects formats (
bv*[height<=720]+ba/b) and names files from templates - Uses your own cookies (
--cookies) and headers, sending them only to the hosts they belong to - Works as a CLI or a typed library with JSON events; support for a specific site is added through plugins
What it does not do
- No site-specific extractors are built in. Platforms such as YouTube hide their media behind scripts and tokens and need a plugin
- No DRM: protected streams (Widevine, PlayReady, FairPlay, encrypted DASH) are not supported and will not be
- No circumvention: it cannot get past logins, paywalls or geo-blocks. With
--cookiesit only fetches what your own session can already see - Not implemented yet: live streams, multi-period DASH (first period only), SAMPLE-AES, byte-range HLS/DASH, subtitles, playlists from video sites (they need a plugin), a proxy option
- Cookies are read-only:
Set-Cookieresponses are not stored
| Area | Behavior |
|---|---|
| Direct files | resumable (.part + Range), retries with backoff |
| HLS VOD | master + media playlists, parallel segments written in order, AES-128 (explicit/default IV), fMP4 init maps, separate audio renditions |
DASH VOD (.mpd), opt-in via @boy-offi9-inc/tidepull/dash (built into the CLI) |
SegmentTemplate ($Number%05d$, $Time$, timelines, durations), SegmentList, single-file SegmentBase, nested BaseURL; encrypted (ContentProtection) representations are skipped, never decrypted |
| Generic page extractor | og:video, <video>/<source>, bare media URLs in HTML/JSON |
| Format selector | b bv bv* ba ba* w wv wa, + merge, / fallback, () groups, [height<=720] [filesize<?50M] [ext=mp4] |
| Output templates | %(title)s, %(x|default)s, %(title).50s, %(n)02d, sanitized |
| Playlists | RSS 2.0/1.0 and Atom feeds (<enclosure>, Atom enclosure links, Media RSS) and .m3u files; opt-in via /feeds (built into the CLI); entries are extracted one by one, in order |
| Cookies | Netscape cookies.txt; matched by domain, path, Secure and expiry; re-evaluated on every redirect hop |
| CLI conveniences | -a batch input, --download-archive, --limit-rate, --config-location |
| Merge / remux | ffmpeg |
The core does only what every user needs: extract → select → download, events, cancellation. Everything else is a plugin or an opt-in subpath.
| Import | Files | Raw | Gzip | Minified (gzip) |
|---|---|---|---|---|
@boy-offi9-inc/tidepull (core: direct, HLS, generic page, selector, templates, resume, merge, playlists contract) |
18 | 49.2 KB | 14.7 KB | 24 KB (9.8 KB) |
@boy-offi9-inc/tidepull/dash (adds XML + MPD parsing) |
+6 | +14 KB | +4.7 KB | +6.7 KB (+3.1 KB) |
@boy-offi9-inc/tidepull/feeds (RSS/Atom + .m3u; shares the XML parser with dash) |
+5 | +9 KB | +3.3 KB | +3.8 KB (+1.9 KB) |
@boy-offi9-inc/tidepull/playlist (pullAll, item selection) |
+1 | +3.9 KB | +1.5 KB | +1.9 KB (+1.0 KB) |
@boy-offi9-inc/tidepull/cookies (cookies.txt) |
+2 | +3.8 KB | +1.6 KB | +1.6 KB (+0.9 KB) |
@boy-offi9-inc/tidepull/loader (plugins from disk) |
+3 | +4 KB | +1.7 KB | n/a (Node only) |
npm run size fails the build if the core grows past its budget. CLI and terminal UI are not part of import "@boy-offi9-inc/tidepull".
- No shell, ever. ffmpeg is spawned with argv arrays. There is deliberately no
--execand no--netrc-cmd-style hook; the command-injection CVEs in this space came from exactly those features. - Redirect-safe HTTP. Redirects are followed manually and headers are recomputed on every hop: secrets and per-request headers stay with their own host, non-http(s) targets are refused, loops are capped.
- Origin-scoped secrets. Custom headers like
Authorization,Cookieor API tokens (--header/http.headers) are sent only to the origins you pass toextract()/pull(), plus any--header-host/http.headerHostspatterns (cdn.example.com,*.example.com).Referer,Origin,Accept*,User-AgentandRangeare never treated as secret. Using oneTidepullinstance for several sites trusts all of them, so use one instance per credential. The low-levelHttpclass (withoutheaderHosts) sends headers to each request's start origin only. - Per-host cookies.
--cookiessends each cookie only to the host, path and scheme it was issued for (Securecookies never go over http). A cookie with a single-label domain such as.comis treated as host-only. Cookie values are never logged and never appear in-joutput. - Untrusted playlist entries. URLs found inside a feed or playlist are extracted with
trust: false, so secret headers (--header) are never sent to hosts a feed names. Only the URL you gave is trusted. - Output filenames are built from sanitized fields only. Only
http(s)URLs are accepted.
Only download content you own or are permitted to download; you're responsible for complying with site terms and local law. MIT licensed.