umedia 0.1.1 → 0.2.0

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
@@ -1,23 +1,28 @@
1
1
  <div align="center">
2
2
 
3
- <img src="https://raw.githubusercontent.com/Anonymous20666/unified-media-api/main/web/og-image.jpg" alt="Unified Media API — one API for every media platform" width="760" />
3
+ <img src="https://dev-pappy-api-downloader.duckdns.org/og-image.jpg" alt="Unified Media API — one API for every media platform" width="760" />
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,80 @@ 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
57
-
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.
66
-
67
- ### 🧯 Typed failures instead of mystery crashes
68
-
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
- ```
82
-
83
- Every response — success or failure — carries a `meta.requestId`. Quote it and support can trace it.
84
-
85
- ### 📦 Zero dependencies
86
-
87
- Node 18+, browsers, Deno, Bun. If you have `fetch`, you have `umedia`.
49
+ That's the whole model: **your code, your machine, your files.**
88
50
 
89
51
  ---
90
52
 
91
- ## 🧭 The tour
53
+ ## 🎯 The contract that keeps your app honest
92
54
 
93
- ### Resolve a post → every photo, every video, in order
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.
94
58
 
95
- ```js
96
- const { data: post, engine } = await client.resolve(
97
- "https://www.tiktok.com/@user/photo/7690630628697459990"
98
- );
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.
99
62
 
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
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.
103
66
 
104
- for (const m of post.media) {
105
- console.log(m.index, m.type, m.quality, m.url);
106
- }
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.
107
70
 
108
- // who actually served this — multi-source transparency
109
- console.log(engine.attempts, engine.finalProvider, engine.totalLatencyMs + "ms");
110
- ```
71
+ ---
111
72
 
112
- ### Download — jobs, honest results, streaming files
73
+ ## 🗺️ What runs where (honest edition)
113
74
 
114
- ```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
- ```
75
+ | Platform | Search | Resolve | Download | How |
76
+ |---|---|---|---|---|
77
+ | **YouTube** | ✅ | ✅ | ✅ * | InnerTube — search & metadata always; stream URLs depend on your network (see below) |
78
+ | **TikTok** | — | ✅ | ✅ | photo galleries + video posts |
79
+ | **Instagram** | — | ✅ best-effort | ✅ | public embeds |
80
+ | **Reddit** | — | ✅ | ✅ | posts + galleries |
81
+ | **X** | — | ✅ best-effort | ✅ | public posts |
82
+ | **iTunes** | ✅ | — | ✅ previews | real 30s clips, rock solid |
83
+ | **1800+ sites** | — | ✅ | ✅ | optional tier: `pip install yt-dlp` — labeled `source: "ytdlp"` |
137
84
 
138
- ### Platform metadata — real, not marketing
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.
139
90
 
140
91
  ```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
92
+ // the power tier — one function, 1800+ sites, hard networks
93
+ await media.downloadWithYtdlp({ url: "https://example.com/anything", quality: "720p", dir: "./out" });
144
94
  ```
145
95
 
146
96
  ---
147
97
 
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 |
98
+ ## 📚 API
99
+
100
+ | Call | What it does |
101
+ |---|---|
102
+ | `media.search({q, type, limit, adult})` | Multi-source search — `type`: `video` \| `music` \| `movie` \| `short_video` |
103
+ | `media.resolve(url)` | Every media item of a post, in order + honest `engine.attempts` |
104
+ | `media.download({url, quality, dir, zip, onProgress})` | Saves files to disk — `quality`: `original` \| `best` \| `2160p` … `144p` \| `audio` |
105
+ | `media.downloadWithYtdlp({url, quality, dir})` | yt-dlp tier (requires `yt-dlp` on PATH) |
106
+ | `media.capabilities()` | What THIS machine can actually do — never overstated |
107
+ | `media.status()` | Real adapter status |
162
108
 
163
109
  **Error codes** — `INVALID_REQUEST` · `INVALID_URL` · `SSRF_BLOCKED` · `AUTH_REQUIRED` ·
164
110
  `AUTH_INVALID` · `RATE_LIMITED` · `MEDIA_NOT_FOUND` · `CONTENT_PRIVATE` · `ACCESS_RESTRICTED` ·
@@ -166,45 +112,30 @@ console.log(st.overall); // "operational" — checked, not hard-coded
166
112
 
167
113
  ---
168
114
 
169
- ## 🖥️ The CLI
115
+ ## 🖥️ CLI
170
116
 
171
117
  ```bash
172
118
  npx umedia search "lofi beats" --type music --limit 5
173
119
  npx umedia resolve "https://www.tiktok.com/@user/photo/7690630628697459990"
174
- npx umedia download "https://youtu.be/jNQXAC9IVRw" --quality best --wait
120
+ npx umedia download "https://youtu.be/jNQXAC9IVRw" --quality best -o ./out
121
+ npx umedia download "https://example.com/video" --ytdlp # power tier
175
122
  npx umedia status
176
- npx umedia capabilities
177
123
  ```
178
124
 
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
125
  ---
183
126
 
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.
127
+ ## 🧯 Safety, built in
198
128
 
199
- Build against the contract and your users see graceful, explainable behavior — the thing that
200
- *makes apps feel reliable*.
129
+ - **SSRF hygiene** — private, loopback and internal hosts are refused (`SSRF_BLOCKED`).
130
+ - **No secrets to manage** — there are none. No keys, no tokens, no dashboards.
131
+ - **Zero telemetry.** Your users' requests never touch anyone's server — because there isn't one.
201
132
 
202
133
  ---
203
134
 
204
135
  <div align="center">
205
136
 
206
- **`umedia` ships the media engine only** — account and key management live in the web platform.
137
+ **Built to save you the hosting bill.** Ship the engine, not the infrastructure.
207
138
 
208
- MIT © 2026 · [Unified Media API](https://dev-pappy-api-downloader.duckdns.org)
139
+ MIT © 2026 · part of the [Unified Media API](https://dev-pappy-api-downloader.duckdns.org) project
209
140
 
210
141
  </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: