umedia 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,20 +4,25 @@
4
4
 
5
5
  # umedia
6
6
 
7
- **One API for every media platform — in your code and in your terminal.**
7
+ **The standalone media engine — search, resolve and download, entirely on YOUR machine.**
8
8
 
9
- Search, resolve and download media from YouTube · TikTok · Instagram · Reddit · X · iTunes · 1800+ sites
9
+ No hosted API. No API keys. No rate limits. **No server bill.**
10
+
11
+ YouTube · TikTok · Instagram · Reddit · X · iTunes · 1800+ sites (via the optional yt-dlp tier)
10
12
 
11
13
  [![npm version](https://img.shields.io/npm/v/umedia?color=67e8f9&label=npm)](https://www.npmjs.com/package/umedia)
12
14
  [![node](https://img.shields.io/badge/node-%E2%89%A5%2018-brightgreen)](https://nodejs.org)
13
- [![dependencies](https://img.shields.io/badge/dependencies-0-8b5cf6)](#-zero-dependencies)
14
15
  [![license](https://img.shields.io/npm/l/umedia?color=22d3ee)](LICENSE)
15
16
 
16
17
  </div>
17
18
 
18
19
  ---
19
20
 
20
- ## ⚡ From zero to media in 60 seconds
21
+ ## 💸 Why this exists
22
+
23
+ Hosted media APIs cost money **every month** — per request, per user, per server.
24
+ `umedia` flips the model: the engine runs **inside your app**. Your users' machines do the work,
25
+ so your costs are… zero. Install it, call it, ship.
21
26
 
22
27
  ```bash
23
28
  npm install umedia
@@ -26,139 +31,83 @@ npm install umedia
26
31
  ```js
27
32
  import { UMedia } from "umedia";
28
33
 
29
- const client = new UMedia({ apiKey: process.env.UMedia_API_KEY }); // key optional
30
-
31
- const { data } = await client.search({ q: "afrobeats", type: "music", limit: 3 });
32
- for (const item of data.items) {
33
- console.log(item.title, "—", item.previewKind, "→", item.previewUrl);
34
- }
35
- ```
36
-
37
- That's it. No SDK zoo, no six-service setup — one small client, the whole media web behind it.
38
-
39
- ---
40
-
41
- ## 🎯 Why developers pick it
34
+ const media = new UMedia();
42
35
 
43
- ### 🏷️ Honest quality — the industry's favorite lie, removed
36
+ // 1) Search — music leads with REAL 30-second previews
37
+ const { data } = await media.search({ q: "afrobeats", type: "music", limit: 3 });
38
+ for (const item of data.items) console.log(item.title, "—", item.previewKind, "→", item.previewUrl);
44
39
 
45
- Every download reports what you **asked for** vs what the source **actually has**:
40
+ // 2) Resolve a post → ALL media, in order
41
+ const { data: post } = await media.resolve("https://www.tiktok.com/@user/photo/7690630628697459990");
42
+ console.log(post.itemCount, post.counts); // 30 { images: 30, … } — never truncated silently
46
43
 
47
- ```js
48
- const { data: handle } = await client.createDownload({ url: oldVideo, quality: "best" });
49
- // … poll …
50
- const r = (await client.getDownload(handle.jobId)).data.result;
51
- console.log(r.requestedQuality, "→", r.selectedQuality); // "best" → "240p" ← real, never invented
44
+ // 3) Download everything to disk
45
+ const { data: job } = await media.download({ url: post.sourceUrl, quality: "best", dir: "./out" });
46
+ console.log(job.requestedQuality, "→", job.selectedQuality); // "best" → "720p" — real, never invented
52
47
  ```
53
48
 
54
- Quality is derived from the real source height. There is no "1080p" sticker on a 240p file. Ever.
55
-
56
- ### 🖼️ Galleries arrive complete — 100 photos means 100 photos
49
+ That's the whole model: **your code, your machine, your files.**
57
50
 
58
- A TikTok photo post with 30 slides resolves to **30 MediaObjects, in order**. A mixed post keeps
59
- its sequence. If anything is dropped, it's named — `truncated: true`, `failedItems[]` — never silent.
60
-
61
- ### 🎧 Previews drop first
62
-
63
- Music search leads with real **30-second clips** (`previewKind: "clip30s"`), movies with trailers,
64
- social posts with official embeds. `previewKind: "none"` when nothing honest exists — we'd rather
65
- say "none" than fake one.
51
+ ---
66
52
 
67
- ### 🧯 Typed failures instead of mystery crashes
53
+ ## 🎯 The contract that keeps your app honest
68
54
 
69
- ```js
70
- import { UMediaError, Codes } from "umedia";
71
-
72
- try {
73
- await client.resolve(url);
74
- } catch (e) {
75
- if (e instanceof UMediaError) {
76
- console.error(e.code, e.message, e.requestId); // e.g. PROVIDER_UNAVAILABLE, req_9f3a…
77
- if (e.code === Codes.RATE_LIMITED) await sleep(60_000);
78
- if (e.code === Codes.PROVIDER_UNAVAILABLE) await retryWithBackoff();
79
- }
80
- }
81
- ```
55
+ ### 🏷️ Quality is never manufactured
56
+ `requestedQuality` vs `selectedQuality` on every download. A 240p source never arrives wearing a
57
+ 1080p sticker. `fallback: true` tells you exactly when reality differed from the request.
82
58
 
83
- Every response — success or failure — carries a `meta.requestId`. Quote it and support can trace it.
59
+ ### 🖼️ Galleries arrive complete
60
+ 100 photos → **100 files, in order** (`001 - …`, `002 - …`). Anything that failed is named in
61
+ `failedItems[]` with the reason — never silent, never partial-by-accident.
84
62
 
85
- ### 📦 Zero dependencies
63
+ ### 🎧 Previews drop first
64
+ Music search leads with real **30s iTunes clips** (`previewKind: "clip30s"`), movies with YouTube
65
+ trailers, social posts with official embeds. `previewKind: "none"` beats a fake preview.
86
66
 
87
- Node 18+, browsers, Deno, Bun. If you have `fetch`, you have `umedia`.
67
+ ### 🧯 Typed failures, always
68
+ `UMediaError` with a stable `code` (`RATE_LIMITED`, `MEDIA_NOT_FOUND`, `PROVIDER_UNAVAILABLE`,
69
+ `SSRF_BLOCKED`, …), plus `engine.attempts[]` — the real trail of which providers were tried and why.
88
70
 
89
71
  ---
90
72
 
91
- ## 🧭 The tour
92
-
93
- ### Resolve a post → every photo, every video, in order
94
-
95
- ```js
96
- const { data: post, engine } = await client.resolve(
97
- "https://www.tiktok.com/@user/photo/7690630628697459990"
98
- );
99
-
100
- console.log(post.itemCount); // 30
101
- console.log(post.counts); // { images: 30, videos: 0, audios: 0, other: 0 }
102
- console.log(post.truncated); // false — we tell you, always
73
+ ## 🗺️ What runs where (honest edition)
103
74
 
104
- for (const m of post.media) {
105
- console.log(m.index, m.type, m.quality, m.url);
106
- }
75
+ | Platform | Search | Resolve | Download | How |
76
+ |---|---|---|---|---|
77
+ | **YouTube** | ✅ | ✅ | ✅ * | InnerTube — search & metadata always; stream URLs depend on your network (see below) |
78
+ | **TikTok** | — | ✅ | ✅ | video posts = direct CDN mp4; photo galleries = every slide the platform publicly exposes, `truncated: true` when it degrades galleries |
79
+ | **Instagram** | — | ✅ best-effort | ✅ | public embeds (gated on some networks — the yt-dlp tier is the reliable path) |
80
+ | **Reddit** | — | ✅ best-effort | ✅ | public JSON across reddit edges (datacenter IPs often 403 — typed honest error) |
81
+ | **X** | — | ✅ best-effort | ✅ | public syndication (media login-gated on some networks — typed honest error) |
82
+ | **iTunes** | ✅ | — | ✅ previews | real 30s clips, rock solid |
83
+ | **1800+ sites** | — | ✅ | ✅ | optional tier: `pip install yt-dlp` — labeled `source: "ytdlp"` |
107
84
 
108
- // who actually served this — multi-source transparency
109
- console.log(engine.attempts, engine.finalProvider, engine.totalLatencyMs + "ms");
110
- ```
85
+ **\* About YouTube stream URLs:** YouTube bot-gates datacenter IPs and serves formats without URLs.
86
+ On typical home connections `download()` just works; on hard networks you'll get a typed
87
+ `PROVIDER_UNAVAILABLE` with the real reason — or run `download({ … })` via
88
+ `downloadWithYtdlp()` / `umedia download <url> --ytdlp` which handles PO tokens, cookies and muxing.
89
+ We'd rather tell you the truth than fake a download.
111
90
 
112
- ### Download — jobs, honest results, streaming files
91
+ **Direct media URLs always work:** give `resolve()`/`download()` any direct `.mp4/.m4a/.jpg/…` link and it
92
+ goes straight to disk — no platform needed.
113
93
 
114
94
  ```js
115
- const { data: handle } = await client.createDownload({
116
- url: "https://youtu.be/jNQXAC9IVRw",
117
- type: "video",
118
- quality: "best", // original | best | 2160p … 144p | audio
119
- });
120
-
121
- // poll the job
122
- let job;
123
- do {
124
- await new Promise((r) => setTimeout(r, 2000));
125
- job = (await client.getDownload(handle.jobId)).data;
126
- } while (!["done", "failed", "cancelled"].includes(job.status));
127
-
128
- // honest outcome
129
- const r = job.result;
130
- console.log(r.requestedQuality, "→", r.selectedQuality, r.fallback ? "(fallback)" : "");
131
-
132
- // stream or save
133
- const res = await client.fetchFile(handle.jobId, 0);
134
- // multi-item job? files come as an ordered zip: "001 - name.jpg", "002 - …"
135
- for (const f of r.files) console.log(f.index, f.name, client.fileUrl(handle.jobId, f.index));
136
- ```
137
-
138
- ### Platform metadata — real, not marketing
139
-
140
- ```js
141
- const { data: caps } = await client.capabilities(); // live capability registry
142
- const { data: st } = await client.status(); // real component + provider health
143
- console.log(st.overall); // "operational" — checked, not hard-coded
95
+ // the power tier — one function, 1800+ sites, hard networks
96
+ await media.downloadWithYtdlp({ url: "https://example.com/anything", quality: "720p", dir: "./out" });
144
97
  ```
145
98
 
146
99
  ---
147
100
 
148
- ## 📚 Reference
149
-
150
- | Method | Endpoint | Notes |
151
- |---|---|---|
152
- | `search({q, type, limit, adult})` | `GET /api/v1/social/search` | `type`: `video` \| `music` \| `movie` \| `short_video` · `limit ≤ 25` |
153
- | `resolve(url)` | `POST /api/v1/social/resolve` | returns `{ data: ResolveResult, engine }` |
154
- | `createDownload({url, type, quality})` | `POST /api/v1/download` | returns `{ jobId, status, progress, links }` |
155
- | `getDownload(jobId)` | `GET /api/v1/download/{id}` | poll until `done` / `failed` / `cancelled` |
156
- | `listDownloads()` | `GET /api/v1/download` | jobs for this key |
157
- | `cancelDownload(jobId)` | `POST /api/v1/download/{id}/cancel` | |
158
- | `fileUrl(jobId, index)` | `GET /api/v1/download/{id}/file?index=N` | single = file · multi = ordered zip |
159
- | `fetchFile(jobId, index)` | same | returns a streamable `Response` |
160
- | `capabilities()` | `GET /api/v1/capabilities` | live platform registry |
161
- | `status()` | `GET /api/v1/status` | real health, refreshed continuously |
101
+ ## 📚 API
102
+
103
+ | Call | What it does |
104
+ |---|---|
105
+ | `media.search({q, type, limit, adult})` | Multi-source search — `type`: `video` \| `music` \| `movie` \| `short_video` |
106
+ | `media.resolve(url)` | Every media item of a post, in order + honest `engine.attempts` |
107
+ | `media.download({url, quality, dir, zip, onProgress})` | Saves files to disk — `quality`: `original` \| `best` \| `2160p` … `144p` \| `audio` |
108
+ | `media.downloadWithYtdlp({url, quality, dir})` | yt-dlp tier (requires `yt-dlp` on PATH) |
109
+ | `media.capabilities()` | What THIS machine can actually do — never overstated |
110
+ | `media.status()` | Real adapter status |
162
111
 
163
112
  **Error codes** — `INVALID_REQUEST` · `INVALID_URL` · `SSRF_BLOCKED` · `AUTH_REQUIRED` ·
164
113
  `AUTH_INVALID` · `RATE_LIMITED` · `MEDIA_NOT_FOUND` · `CONTENT_PRIVATE` · `ACCESS_RESTRICTED` ·
@@ -166,45 +115,30 @@ console.log(st.overall); // "operational" — checked, not hard-coded
166
115
 
167
116
  ---
168
117
 
169
- ## 🖥️ The CLI
118
+ ## 🖥️ CLI
170
119
 
171
120
  ```bash
172
121
  npx umedia search "lofi beats" --type music --limit 5
173
122
  npx umedia resolve "https://www.tiktok.com/@user/photo/7690630628697459990"
174
- npx umedia download "https://youtu.be/jNQXAC9IVRw" --quality best --wait
123
+ npx umedia download "https://youtu.be/jNQXAC9IVRw" --quality best -o ./out
124
+ npx umedia download "https://example.com/video" --ytdlp # power tier
175
125
  npx umedia status
176
- npx umedia capabilities
177
126
  ```
178
127
 
179
- `--wait` polls the job and prints ready-to-use file URLs. Environment: `UMedia_API_KEY` (optional),
180
- `UMedia_BASE_URL` (optional override).
181
-
182
128
  ---
183
129
 
184
- ## 🤝 The honest reliability contract
185
-
186
- Let's be straight about guarantees, because the industry isn't:
187
-
188
- - **Our layer** (auth, search, resolve, downloads, files): designed and measured for high
189
- availability — hundreds of requests per second sustained on this platform, 4-worker jobs,
190
- typed errors with `requestId` on every failure. A correct integration **degrades gracefully;
191
- it does not break**.
192
- - **Their layer** (the upstream platforms): YouTube, TikTok and friends change, throttle and
193
- geo-block. **No media API on earth can honestly promise zero upstream failures** — and this one
194
- won't insult you by pretending.
195
- - **What you get instead**: real data or a typed, honest error. Never fake quality, never silent
196
- truncation, never a `200` with garbage. `requestedQuality` vs `selectedQuality`, `truncated`,
197
- `failedItems[]`, `engine.attempts[]` — the truth is in every payload.
130
+ ## 🧯 Safety, built in
198
131
 
199
- Build against the contract and your users see graceful, explainable behavior — the thing that
200
- *makes apps feel reliable*.
132
+ - **SSRF hygiene** — private, loopback and internal hosts are refused (`SSRF_BLOCKED`).
133
+ - **No secrets to manage** — there are none. No keys, no tokens, no dashboards.
134
+ - **Zero telemetry.** Your users' requests never touch anyone's server — because there isn't one.
201
135
 
202
136
  ---
203
137
 
204
138
  <div align="center">
205
139
 
206
- **`umedia` ships the media engine only** — account and key management live in the web platform.
140
+ **Built to save you the hosting bill.** Ship the engine, not the infrastructure.
207
141
 
208
- MIT © 2026 · [Unified Media API](https://dev-pappy-api-downloader.duckdns.org)
142
+ MIT © 2026 · part of the [Unified Media API](https://dev-pappy-api-downloader.duckdns.org) project
209
143
 
210
144
  </div>
package/bin/umedia.js CHANGED
@@ -1,14 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * umedia CLI — search · resolve · download through the Unified Media API.
3
+ * umedia CLI — the standalone media engine, in your terminal.
4
4
  *
5
5
  * umedia search "afrobeats" --type music --limit 5
6
6
  * umedia resolve "https://www.tiktok.com/@user/photo/123"
7
- * umedia download "https://youtu.be/jNQXAC9IVRw" --quality best --wait
7
+ * umedia download "https://youtu.be/jNQXAC9IVRw" --quality best -o ./out
8
8
  * umedia status
9
9
  * umedia capabilities
10
- *
11
- * Env: UMedia_API_KEY (optional), UMedia_BASE_URL (optional).
12
10
  */
13
11
  import { UMedia, UMediaError } from "../index.js";
14
12
 
@@ -16,94 +14,70 @@ function parseArgs(argv) {
16
14
  const out = { _: [] };
17
15
  for (let i = 0; i < argv.length; i++) {
18
16
  const a = argv[i];
19
- if (a.startsWith("--")) {
20
- const key = a.slice(2);
17
+ if (a.startsWith("--") || a.startsWith("-")) {
18
+ const key = a.replace(/^-+/, "");
21
19
  const next = argv[i + 1];
22
- if (next !== undefined && !next.startsWith("--")) { out[key] = next; i++; }
20
+ if (next !== undefined && !next.startsWith("-")) { out[key] = next; i++; }
23
21
  else out[key] = true;
24
22
  } else out._.push(a);
25
23
  }
26
24
  return out;
27
25
  }
28
26
 
29
- const HELP = `umedia — Unified Media API client
27
+ const HELP = `umedia — standalone media engine (runs on YOUR machine, no API needed)
30
28
 
31
- umedia search "<query>" [--type video|music|movie|short_video] [--limit N] [--adult]
29
+ umedia search "<query>" [--type video|music|movie|short_video] [--limit N]
32
30
  umedia resolve <url>
33
- umedia download <url> [--type video] [--quality best] [--wait]
31
+ umedia download <url> [--quality best|720p|audio] [-o <dir>] [--zip] [--ytdlp]
34
32
  umedia status
35
- umedia capabilities
36
-
37
- Env: UMedia_API_KEY (optional key), UMedia_BASE_URL (optional)`;
33
+ umedia capabilities`;
38
34
 
39
35
  const args = parseArgs(process.argv.slice(2));
40
36
  const cmd = args._[0];
41
-
42
37
  if (!cmd || cmd === "help" || args.help) {
43
38
  console.log(HELP);
44
39
  process.exit(0);
45
40
  }
46
41
 
47
- const client = new UMedia({
48
- apiKey: process.env.UMedia_API_KEY,
49
- baseUrl: process.env.UMedia_BASE_URL,
50
- });
51
-
42
+ const client = new UMedia({ downloadDir: args.o || args.out || "./umedia-downloads" });
52
43
  const print = (x) => console.log(JSON.stringify(x, null, 2));
53
44
 
54
45
  async function main() {
55
46
  switch (cmd) {
56
47
  case "search": {
57
48
  const q = args._.slice(1).join(" ");
58
- if (!q) throw new UMediaError("INVALID_REQUEST", "usage: umedia search \"<query>\"");
59
- const { data, meta } = await client.search({
60
- q,
61
- type: args.type || "video",
62
- limit: args.limit ? Number(args.limit) : 12,
63
- adult: Boolean(args.adult),
49
+ if (!q) throw new UMediaError("INVALID_REQUEST", 'usage: umedia search "<query>"');
50
+ const { data, meta, engine } = await client.search({
51
+ q, type: args.type || "video", limit: args.limit ? Number(args.limit) : 12, adult: Boolean(args.adult),
64
52
  });
65
- print({ items: data.items, meta });
53
+ print({ items: data.items, meta, engine });
66
54
  break;
67
55
  }
68
56
  case "resolve": {
69
57
  const url = args._[1];
70
58
  if (!url) throw new UMediaError("INVALID_REQUEST", "usage: umedia resolve <url>");
71
- const { data, meta } = await client.resolve(url);
72
- print({ ...data, meta });
59
+ const r = await client.resolve(url);
60
+ print(r);
73
61
  break;
74
62
  }
75
63
  case "download": {
76
64
  const url = args._[1];
77
65
  if (!url) throw new UMediaError("INVALID_REQUEST", "usage: umedia download <url>");
78
- let { data } = await client.createDownload({
79
- url,
80
- type: args.type || "video",
81
- quality: args.quality || "best",
82
- });
83
- const jobId = data.jobId;
84
- if (args.wait) {
85
- for (;;) {
86
- await new Promise((r) => setTimeout(r, 2000));
87
- ({ data } = await client.getDownload(jobId));
88
- if (["done", "failed", "cancelled"].includes(data.status)) break;
89
- process.stderr.write(` status: ${data.status}${data.stage ? " · " + data.stage : ""}\n`);
90
- }
91
- }
92
- const result = data.result || null;
93
- if (result && result.files) {
94
- result.files = result.files.map((f) => ({ ...f, url: client.fileUrl(jobId, f.index) }));
95
- }
96
- print({ ...data, result });
66
+ const onProgress = args.json ? undefined : (done, total) => {
67
+ if (total) process.stderr.write(`\r ${((done / total) * 100).toFixed(1)}%`);
68
+ };
69
+ const fn = args.ytdlp ? client.downloadWithYtdlp.bind(client) : client.download.bind(client);
70
+ const r = await fn({ url, quality: args.quality || "best", type: args.type || "video", zip: Boolean(args.zip), onProgress });
71
+ if (!args.json) process.stderr.write("\n");
72
+ print(r.data);
97
73
  break;
98
74
  }
99
75
  case "status": {
100
- const { data, meta } = await client.status();
101
- print({ ...data, meta });
76
+ print((await client.status()).data);
102
77
  break;
103
78
  }
104
79
  case "capabilities": {
105
- const { data, meta } = await client.capabilities();
106
- print({ ...data, meta });
80
+ print((await client.capabilities()).data);
107
81
  break;
108
82
  }
109
83
  default: