umedia 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anonymous20666
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # umedia
2
+
3
+ Official client for the **Unified Media API** — search, resolve and download media from
4
+ YouTube, TikTok, Instagram, Reddit, X, iTunes and 1800+ sites through one REST API.
5
+
6
+ - **Zero dependencies.** Node 18+, browsers, Deno, Bun — anything with `fetch`.
7
+ - **Honest quality labels.** Every result reports `requestedQuality` vs `selectedQuality`
8
+ and a `fallback` flag. Quality is derived from the real source height — never manufactured.
9
+ - **Full galleries preserved.** A 100-photo post resolves to 100 items, in order.
10
+ `truncated` / `failedItems[]` are always explicit — never silent.
11
+ - **Real previews first.** 30s clips (`clip30s`), trailers, or official `embed`s —
12
+ `previewKind: "none"` when nothing honest exists.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install umedia
18
+ ```
19
+
20
+ ## Quickstart
21
+
22
+ ```js
23
+ import { UMedia } from "umedia";
24
+
25
+ const client = new UMedia({ apiKey: process.env.UMedia_API_KEY }); // key optional
26
+
27
+ // 1) Search — music results lead with real 30s previews
28
+ const { data } = await client.search({ q: "afrobeats mix", type: "music", limit: 5 });
29
+ for (const item of data.items) {
30
+ console.log(item.title, item.previewKind, item.previewUrl);
31
+ }
32
+
33
+ // 2) Resolve a post URL → ALL media, in order
34
+ const { data: post } = await client.resolve("https://www.tiktok.com/@user/photo/7690630628697459990");
35
+ console.log(post.itemCount, post.counts, post.truncated);
36
+ for (const m of post.media) console.log(m.index, m.type, m.quality, m.url);
37
+
38
+ // 3) Download — honest quality, multi-item jobs return an ordered zip
39
+ const { data: handle } = await client.createDownload({ url: "https://youtu.be/jNQXAC9IVRw", quality: "best" });
40
+ // poll until done
41
+ let job;
42
+ do {
43
+ await new Promise((r) => setTimeout(r, 2000));
44
+ job = (await client.getDownload(handle.jobId)).data;
45
+ } while (job.status !== "done" && job.status !== "failed");
46
+
47
+ const r = job.result;
48
+ console.log(r.requestedQuality, "->", r.selectedQuality, r.fallback ? "(fallback)" : "");
49
+ for (const f of r.files) console.log(f.index, f.name, client.fileUrl(handle.jobId, f.index));
50
+ // stream a file
51
+ const res = await client.fetchFile(handle.jobId, 0);
52
+ ```
53
+
54
+ ## API
55
+
56
+ All methods return `{ data, meta }` — the platform's normalized envelope.
57
+ `meta.requestId` is included in every response (quote it when reporting issues).
58
+
59
+ | Method | Endpoint | Notes |
60
+ |---|---|---|
61
+ | `search({q, type, limit, adult})` | `GET /api/v1/social/search` | `type`: `video` \| `music` \| `movie` \| `short_video`, `limit ≤ 25` |
62
+ | `resolve(url)` | `POST /api/v1/social/resolve` | returns `ResolveResult` |
63
+ | `createDownload({url, type, quality})` | `POST /api/v1/download` | `quality`: `original` \| `best` \| `2160p` … `144p` \| `audio` |
64
+ | `getDownload(jobId)` | `GET /api/v1/download/{id}` | poll until `status` is `done` / `failed` / `cancelled` |
65
+ | `listDownloads()` | `GET /api/v1/download` | jobs for this key |
66
+ | `cancelDownload(jobId)` | `POST /api/v1/download/{id}/cancel` | |
67
+ | `fileUrl(jobId, index)` | `GET /api/v1/download/{id}/file?index=N` | single file streams; multi-item = ordered zip |
68
+ | `fetchFile(jobId, index)` | same | returns a `Response` (streamable) |
69
+ | `capabilities()` | `GET /api/v1/capabilities` | live platform registry |
70
+ | `status()` | `GET /api/v1/status` | real component + provider health |
71
+
72
+ ## Errors
73
+
74
+ Failures throw `UMediaError` with an honest, typed reason — never fake data:
75
+
76
+ ```js
77
+ import { UMediaError, Codes } from "umedia";
78
+
79
+ try {
80
+ await client.resolve(url);
81
+ } catch (e) {
82
+ if (e instanceof UMediaError) {
83
+ console.log(e.code, e.message, e.requestId);
84
+ if (e.code === Codes.RATE_LIMITED) await wait(60_000);
85
+ if (e.code === Codes.PROVIDER_UNAVAILABLE) retryWithBackoff();
86
+ }
87
+ }
88
+ ```
89
+
90
+ Codes: `INVALID_REQUEST`, `INVALID_URL`, `SSRF_BLOCKED`, `AUTH_REQUIRED`, `AUTH_INVALID`,
91
+ `RATE_LIMITED`, `MEDIA_NOT_FOUND`, `CONTENT_PRIVATE`, `ACCESS_RESTRICTED`,
92
+ `QUALITY_UNAVAILABLE`, `PROVIDER_ERROR`, `PROVIDER_UNAVAILABLE`, `TIMEOUT`, `NOT_FOUND`, `INTERNAL`.
93
+
94
+ > Third-party platforms change, throttle and geo-block — no media API can promise zero
95
+ > failures upstream. What this client guarantees: **typed, honest errors with a
96
+ > `requestId` on every failure**, multi-provider attempts surfaced where available,
97
+ > and graceful degradation instead of breakage.
98
+
99
+ ## CLI
100
+
101
+ ```bash
102
+ npx umedia search "lofi beats" --type music --limit 5
103
+ npx umedia resolve "https://www.tiktok.com/@user/photo/7690630628697459990"
104
+ npx umedia download "https://youtu.be/jNQXAC9IVRw" --quality best --wait
105
+ npx umedia status
106
+ ```
107
+
108
+ Environment: `UMedia_API_KEY` (optional), `UMedia_BASE_URL` (optional override).
109
+
110
+ ## License
111
+
112
+ MIT
package/bin/umedia.js ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * umedia CLI — search · resolve · download through the Unified Media API.
4
+ *
5
+ * umedia search "afrobeats" --type music --limit 5
6
+ * umedia resolve "https://www.tiktok.com/@user/photo/123"
7
+ * umedia download "https://youtu.be/jNQXAC9IVRw" --quality best --wait
8
+ * umedia status
9
+ * umedia capabilities
10
+ *
11
+ * Env: UMedia_API_KEY (optional), UMedia_BASE_URL (optional).
12
+ */
13
+ import { UMedia, UMediaError } from "../index.js";
14
+
15
+ function parseArgs(argv) {
16
+ const out = { _: [] };
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const a = argv[i];
19
+ if (a.startsWith("--")) {
20
+ const key = a.slice(2);
21
+ const next = argv[i + 1];
22
+ if (next !== undefined && !next.startsWith("--")) { out[key] = next; i++; }
23
+ else out[key] = true;
24
+ } else out._.push(a);
25
+ }
26
+ return out;
27
+ }
28
+
29
+ const HELP = `umedia — Unified Media API client
30
+
31
+ umedia search "<query>" [--type video|music|movie|short_video] [--limit N] [--adult]
32
+ umedia resolve <url>
33
+ umedia download <url> [--type video] [--quality best] [--wait]
34
+ umedia status
35
+ umedia capabilities
36
+
37
+ Env: UMedia_API_KEY (optional key), UMedia_BASE_URL (optional)`;
38
+
39
+ const args = parseArgs(process.argv.slice(2));
40
+ const cmd = args._[0];
41
+
42
+ if (!cmd || cmd === "help" || args.help) {
43
+ console.log(HELP);
44
+ process.exit(0);
45
+ }
46
+
47
+ const client = new UMedia({
48
+ apiKey: process.env.UMedia_API_KEY,
49
+ baseUrl: process.env.UMedia_BASE_URL,
50
+ });
51
+
52
+ const print = (x) => console.log(JSON.stringify(x, null, 2));
53
+
54
+ async function main() {
55
+ switch (cmd) {
56
+ case "search": {
57
+ 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),
64
+ });
65
+ print({ items: data.items, meta });
66
+ break;
67
+ }
68
+ case "resolve": {
69
+ const url = args._[1];
70
+ if (!url) throw new UMediaError("INVALID_REQUEST", "usage: umedia resolve <url>");
71
+ const { data, meta } = await client.resolve(url);
72
+ print({ ...data, meta });
73
+ break;
74
+ }
75
+ case "download": {
76
+ const url = args._[1];
77
+ 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 });
97
+ break;
98
+ }
99
+ case "status": {
100
+ const { data, meta } = await client.status();
101
+ print({ ...data, meta });
102
+ break;
103
+ }
104
+ case "capabilities": {
105
+ const { data, meta } = await client.capabilities();
106
+ print({ ...data, meta });
107
+ break;
108
+ }
109
+ default:
110
+ console.log(HELP);
111
+ process.exit(1);
112
+ }
113
+ }
114
+
115
+ main().catch((e) => {
116
+ if (e instanceof UMediaError) {
117
+ console.error(JSON.stringify({
118
+ success: false,
119
+ error: { code: e.code, message: e.message, requestId: e.requestId, attempts: e.attempts },
120
+ }, null, 2));
121
+ } else {
122
+ console.error(String(e && e.message ? e.message : e));
123
+ }
124
+ process.exit(1);
125
+ });
package/index.d.ts ADDED
@@ -0,0 +1,232 @@
1
+ /** umedia — official client for the Unified Media API (core media engine). */
2
+
3
+ export const DEFAULT_BASE_URL: string;
4
+
5
+ export type ErrorCode =
6
+ | "INVALID_REQUEST" | "INVALID_URL" | "SSRF_BLOCKED" | "AUTH_REQUIRED" | "AUTH_INVALID"
7
+ | "RATE_LIMITED" | "MEDIA_NOT_FOUND" | "CONTENT_PRIVATE" | "ACCESS_RESTRICTED"
8
+ | "QUALITY_UNAVAILABLE" | "PROVIDER_ERROR" | "PROVIDER_UNAVAILABLE" | "TIMEOUT"
9
+ | "NOT_FOUND" | "INTERNAL";
10
+
11
+ export const Codes: Readonly<Record<ErrorCode, ErrorCode>>;
12
+
13
+ export class UMediaError extends Error {
14
+ name: "UMediaError";
15
+ code: ErrorCode;
16
+ status: number;
17
+ requestId: string | null;
18
+ attempts: ProviderAttempt[];
19
+ details: unknown;
20
+ constructor(code: ErrorCode, message: string, opts?: {
21
+ status?: number;
22
+ requestId?: string | null;
23
+ attempts?: ProviderAttempt[];
24
+ details?: unknown;
25
+ });
26
+ }
27
+
28
+ /** Honest provider-selection record — real attempts, never invented. */
29
+ export interface ProviderAttempt {
30
+ provider: string;
31
+ status: "success" | "failed" | "skipped";
32
+ latencyMs?: number | null;
33
+ error?: string | null;
34
+ }
35
+
36
+ /** One real, honestly-labeled rendition of a media item. */
37
+ export interface MediaVariant {
38
+ quality: string; // derived from ACTUAL source height — never upscaled
39
+ width?: number | null;
40
+ height?: number | null;
41
+ url?: string | null;
42
+ mimeType?: string | null;
43
+ bitrateKbps?: number | null;
44
+ hasAudio?: boolean | null;
45
+ hasVideo?: boolean | null;
46
+ }
47
+
48
+ /** One media item. Multi-item posts yield ONE MediaObject per item, in order. */
49
+ export interface MediaObject {
50
+ type: "image" | "video" | "audio" | "short_video" | "gif";
51
+ index: number;
52
+ url: string;
53
+ thumbnail?: string | null;
54
+ mimeType?: string | null;
55
+ width?: number | null;
56
+ height?: number | null;
57
+ duration?: number | null;
58
+ size?: number | null;
59
+ quality?: string | null; // best real quality of this item
60
+ hasAudio?: boolean | null;
61
+ hasVideo?: boolean | null;
62
+ source?: string;
63
+ variants: MediaVariant[];
64
+ }
65
+
66
+ export interface MediaCounts {
67
+ images: number;
68
+ videos: number;
69
+ audios: number;
70
+ other: number;
71
+ }
72
+
73
+ export interface ResolveResult {
74
+ sourceUrl: string;
75
+ platform?: string | null;
76
+ title?: string | null;
77
+ author?: string | null;
78
+ thumbnail?: string | null;
79
+ itemCount: number;
80
+ counts: MediaCounts;
81
+ /** true only when the max-media-items cap was hit — never silent truncation */
82
+ truncated: boolean;
83
+ media: MediaObject[];
84
+ [k: string]: unknown;
85
+ }
86
+
87
+ /** Provider-selection transparency (multi-source engine visibility). */
88
+ export interface ResolveMeta {
89
+ attempts: ProviderAttempt[];
90
+ finalProvider?: string | null;
91
+ totalLatencyMs?: number | null;
92
+ }
93
+
94
+ export interface SearchItem {
95
+ id?: string;
96
+ title?: string;
97
+ author?: string;
98
+ pageUrl?: string;
99
+ url?: string;
100
+ thumbnail?: string | null;
101
+ duration?: number | null;
102
+ mediaType?: string;
103
+ source?: string;
104
+ quality?: string | null;
105
+ explicit?: boolean;
106
+ /** real 30s clip / trailer / official embed — `none` when nothing honest exists */
107
+ previewUrl?: string | null;
108
+ previewKind?: "clip30s" | "trailer" | "embed" | "none";
109
+ providers?: ProviderAttempt[];
110
+ [k: string]: unknown;
111
+ }
112
+
113
+ export interface SearchResponse {
114
+ items: SearchItem[];
115
+ adultFilter?: unknown;
116
+ [k: string]: unknown;
117
+ }
118
+
119
+ export interface DownloadFile {
120
+ name: string;
121
+ size?: number;
122
+ ext?: string;
123
+ mimeType?: string | null;
124
+ index: number;
125
+ [k: string]: unknown;
126
+ }
127
+
128
+ /** Honest download outcome — quality truth lives here. */
129
+ export interface DownloadResult {
130
+ title?: string;
131
+ source?: string;
132
+ duration?: number | null;
133
+ width?: number | null;
134
+ height?: number | null;
135
+ requestedQuality?: string;
136
+ selectedQuality?: string | null;
137
+ fallback?: boolean | null;
138
+ mimeType?: string | null;
139
+ file?: DownloadFile | null;
140
+ files?: DownloadFile[];
141
+ itemCount?: number;
142
+ requestedItems?: number;
143
+ truncated?: boolean;
144
+ failedItems?: Array<{ index?: number; type?: string; url?: string; error?: string }>;
145
+ completedAt?: number | null;
146
+ [k: string]: unknown;
147
+ }
148
+
149
+ export interface DownloadJob {
150
+ jobId: string;
151
+ url: string;
152
+ type?: string;
153
+ status?: string; // queued | running | done | failed | cancelled
154
+ stage?: string | null;
155
+ progress?: number;
156
+ requestedQuality?: string;
157
+ createdAt?: number | null;
158
+ startedAt?: number | null;
159
+ finishedAt?: number | null;
160
+ /** present when status === "done" */
161
+ result?: DownloadResult | null;
162
+ [k: string]: unknown;
163
+ }
164
+
165
+ export interface DownloadHandle {
166
+ jobId: string;
167
+ status: string;
168
+ progress: number;
169
+ links: { status: string; file: string; cancel: string };
170
+ }
171
+
172
+ /** Normalized envelope meta — every response carries meta.requestId. */
173
+ export interface Meta {
174
+ requestId: string;
175
+ cached: boolean;
176
+ source?: string | null;
177
+ timestamp: string;
178
+ [k: string]: unknown;
179
+ }
180
+
181
+ export interface Envelope<T> {
182
+ data: T;
183
+ meta: Meta;
184
+ }
185
+
186
+ export interface UMediaOptions {
187
+ /** Optional API key — sends `X-API-Key` (higher rate limits). */
188
+ apiKey?: string;
189
+ baseUrl?: string;
190
+ timeoutMs?: number;
191
+ fetch?: typeof fetch;
192
+ }
193
+
194
+ export class UMedia {
195
+ constructor(opts?: UMediaOptions);
196
+ apiKey: string | null;
197
+ baseUrl: string;
198
+ timeoutMs: number;
199
+
200
+ search(p?: {
201
+ q?: string;
202
+ query?: string;
203
+ type?: "video" | "music" | "movie" | "short_video";
204
+ limit?: number;
205
+ adult?: boolean;
206
+ }): Promise<Envelope<SearchResponse>>;
207
+
208
+ resolve(url: string): Promise<{ data: ResolveResult; engine?: ResolveMeta; meta: Meta }>;
209
+
210
+ createDownload(p: {
211
+ url: string;
212
+ type?: "video" | "music" | "movie" | "short_video";
213
+ /** original | best | 2160p … 144p | audio */
214
+ quality?: string;
215
+ }): Promise<Envelope<DownloadHandle>>;
216
+
217
+ listDownloads(): Promise<Envelope<{ jobs?: DownloadJob[]; [k: string]: unknown }>>;
218
+
219
+ getDownload(jobId: string): Promise<Envelope<DownloadJob>>;
220
+
221
+ cancelDownload(jobId: string): Promise<Envelope<DownloadJob>>;
222
+
223
+ fileUrl(jobId: string, index?: number): string;
224
+
225
+ fetchFile(jobId: string, index?: number): Promise<Response>;
226
+
227
+ capabilities(): Promise<Envelope<{ platforms?: unknown[]; [k: string]: unknown }>>;
228
+
229
+ status(): Promise<Envelope<{ overall?: string; [k: string]: unknown }>>;
230
+ }
231
+
232
+ export default UMedia;
package/index.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * umedia — official client for the Unified Media API (core media engine).
3
+ * Search · Resolve · Download — honest quality labels, real previews.
4
+ * Zero dependencies. Node 18+ / any fetch-capable runtime.
5
+ */
6
+
7
+ export const DEFAULT_BASE_URL = "https://pappy-api-downloader.duckdns.org";
8
+
9
+ /** Error codes returned in `error.code` (stable API contract). */
10
+ export const Codes = Object.freeze({
11
+ INVALID_REQUEST: "INVALID_REQUEST",
12
+ INVALID_URL: "INVALID_URL",
13
+ SSRF_BLOCKED: "SSRF_BLOCKED",
14
+ AUTH_REQUIRED: "AUTH_REQUIRED",
15
+ AUTH_INVALID: "AUTH_INVALID",
16
+ RATE_LIMITED: "RATE_LIMITED",
17
+ MEDIA_NOT_FOUND: "MEDIA_NOT_FOUND",
18
+ CONTENT_PRIVATE: "CONTENT_PRIVATE",
19
+ ACCESS_RESTRICTED: "ACCESS_RESTRICTED",
20
+ QUALITY_UNAVAILABLE: "QUALITY_UNAVAILABLE",
21
+ PROVIDER_ERROR: "PROVIDER_ERROR",
22
+ PROVIDER_UNAVAILABLE: "PROVIDER_UNAVAILABLE",
23
+ TIMEOUT: "TIMEOUT",
24
+ NOT_FOUND: "NOT_FOUND",
25
+ INTERNAL: "INTERNAL",
26
+ });
27
+
28
+ export class UMediaError extends Error {
29
+ /**
30
+ * @param {string} code one of {@link Codes}
31
+ * @param {string} message human-readable, honest failure reason
32
+ * @param {{status?: number, requestId?: string|null, attempts?: object[]}} [opts]
33
+ */
34
+ constructor(code, message, opts = {}) {
35
+ super(message);
36
+ this.name = "UMediaError";
37
+ this.code = code || Codes.INTERNAL;
38
+ this.status = opts.status ?? 0;
39
+ this.requestId = opts.requestId ?? null;
40
+ this.attempts = opts.attempts ?? [];
41
+ this.details = opts.details ?? null;
42
+ }
43
+ }
44
+
45
+ export class UMedia {
46
+ /**
47
+ * @param {{apiKey?: string, baseUrl?: string, timeoutMs?: number, fetch?: typeof fetch}} [opts]
48
+ * apiKey — optional; sends `X-API-Key` (higher rate limits). Never required for public reads.
49
+ */
50
+ constructor(opts = {}) {
51
+ const { apiKey, baseUrl = DEFAULT_BASE_URL, timeoutMs = 60_000, fetch: fetchImpl } = opts;
52
+ this.apiKey = apiKey || null;
53
+ this.baseUrl = String(baseUrl).replace(/\/+$/, "");
54
+ this.timeoutMs = timeoutMs;
55
+ this._fetch = fetchImpl || globalThis.fetch;
56
+ if (typeof this._fetch !== "function") {
57
+ throw new TypeError("umedia: no fetch available — use Node 18+ or pass { fetch }");
58
+ }
59
+ }
60
+
61
+ async _call(method, path, body) {
62
+ const headers = { accept: "application/json" };
63
+ if (this.apiKey) headers["x-api-key"] = this.apiKey;
64
+ const init = { method, headers };
65
+ if (body !== undefined) {
66
+ headers["content-type"] = "application/json";
67
+ init.body = JSON.stringify(body);
68
+ }
69
+ const ctl = new AbortController();
70
+ const timer = setTimeout(() => ctl.abort(), this.timeoutMs);
71
+ init.signal = ctl.signal;
72
+
73
+ let res;
74
+ try {
75
+ res = await this._fetch(this.baseUrl + path, init);
76
+ } catch (e) {
77
+ const timedOut = e && (e.name === "AbortError" || e.code === "ABORT_ERR");
78
+ throw new UMediaError(
79
+ timedOut ? Codes.TIMEOUT : Codes.PROVIDER_UNAVAILABLE,
80
+ timedOut ? `Request timed out after ${this.timeoutMs}ms` : `Network error: ${e.message}`,
81
+ );
82
+ } finally {
83
+ clearTimeout(timer);
84
+ }
85
+
86
+ let env;
87
+ try {
88
+ env = await res.json();
89
+ } catch {
90
+ throw new UMediaError(Codes.INTERNAL, `Non-JSON response (HTTP ${res.status})`, { status: res.status });
91
+ }
92
+
93
+ if (!res.ok || env?.success === false) {
94
+ const err = env?.error || {};
95
+ const code = err.code
96
+ || (res.status === 429 ? Codes.RATE_LIMITED
97
+ : res.status === 401 ? Codes.AUTH_REQUIRED
98
+ : res.status === 404 ? Codes.NOT_FOUND
99
+ : Codes.INTERNAL);
100
+ throw new UMediaError(code, err.message || `HTTP ${res.status}`, {
101
+ status: res.status,
102
+ requestId: env?.meta?.requestId ?? null,
103
+ attempts: err.attempts || env?.meta?.attempts || [],
104
+ details: err.details ?? null,
105
+ });
106
+ }
107
+ return { data: env.data, meta: env.meta };
108
+ }
109
+
110
+ // ---------- core: search / resolve ----------
111
+
112
+ /**
113
+ * Search across providers (music leads with real 30s previews).
114
+ * @param {{q?: string, query?: string, type?: "video"|"music"|"movie"|"short_video", limit?: number, adult?: boolean}} [p]
115
+ * @returns {Promise<{data: {items: object[], adultFilter?: object}, meta: object}>}
116
+ */
117
+ search(p = {}) {
118
+ const q = p.q ?? p.query ?? "";
119
+ const type = p.type ?? "video";
120
+ const limit = Math.max(1, Math.min(25, p.limit ?? 12));
121
+ const adult = p.adult ? "true" : "false";
122
+ return this._call(
123
+ "GET",
124
+ `/api/v1/social/search?q=${encodeURIComponent(q)}&type=${encodeURIComponent(type)}&limit=${limit}&adult=${adult}`,
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Resolve a post URL into ALL of its media — every photo, every video, in order.
130
+ * Multi-item posts return one MediaObject per item; `truncated` is never silent.
131
+ * `engine` surfaces the provider attempts behind the result (multi-source transparency).
132
+ * @param {string} url
133
+ * @returns {Promise<{data: ResolveResult, engine?: {attempts: object[], finalProvider?: string|null, totalLatencyMs?: number|null}, meta: object}>}
134
+ */
135
+ async resolve(url) {
136
+ const { data, meta } = await this._call("POST", "/api/v1/social/resolve", { url });
137
+ return { data: data?.result ?? data, engine: data?.engine, meta };
138
+ }
139
+
140
+ // ---------- core: downloads ----------
141
+
142
+ /**
143
+ * Enqueue a download job. Quality is honest: the result reports
144
+ * {requestedQuality, selectedQuality, fallback} — never manufactured.
145
+ * @param {{url: string, type?: "video"|"music"|"movie"|"short_video", quality?: string}} p
146
+ * quality: original | best | 2160p … 144p | audio
147
+ * @returns {Promise<{data: {jobId: string, status: string, progress: number, links: {status: string, file: string, cancel: string}}, meta: object}>}
148
+ */
149
+ createDownload(p = {}) {
150
+ if (!p.url) throw new UMediaError(Codes.INVALID_REQUEST, "createDownload: { url } is required");
151
+ return this._call("POST", "/api/v1/download", {
152
+ url: p.url,
153
+ type: p.type ?? "video",
154
+ quality: p.quality ?? "best",
155
+ });
156
+ }
157
+
158
+ /** List download jobs for this API key. */
159
+ listDownloads() {
160
+ return this._call("GET", "/api/v1/download");
161
+ }
162
+
163
+ /**
164
+ * Poll a download job. When done, `data.result` carries files[], itemCount,
165
+ * requestedItems, truncated, failedItems[], requestedQuality, selectedQuality.
166
+ */
167
+ getDownload(jobId) {
168
+ return this._call("GET", `/api/v1/download/${encodeURIComponent(jobId)}`);
169
+ }
170
+
171
+ /** Cancel a queued/running job. */
172
+ cancelDownload(jobId) {
173
+ return this._call("POST", `/api/v1/download/${encodeURIComponent(jobId)}/cancel`);
174
+ }
175
+
176
+ /**
177
+ * Direct URL of one result file. Single-file jobs stream the file;
178
+ * multi-item jobs serve an ordered zip (`001 - name`).
179
+ */
180
+ fileUrl(jobId, index = 0) {
181
+ return `${this.baseUrl}/api/v1/download/${encodeURIComponent(jobId)}/file?index=${index}`;
182
+ }
183
+
184
+ /** Fetch one result file as a Response (streams in Node and browsers). */
185
+ async fetchFile(jobId, index = 0) {
186
+ const headers = {};
187
+ if (this.apiKey) headers["x-api-key"] = this.apiKey;
188
+ let res;
189
+ try {
190
+ res = await this._fetch(this.fileUrl(jobId, index), { headers });
191
+ } catch (e) {
192
+ throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Network error: ${e.message}`);
193
+ }
194
+ if (!res.ok) {
195
+ let message = `HTTP ${res.status}`;
196
+ try {
197
+ const env = await res.json();
198
+ if (env?.error?.message) message = env.error.message;
199
+ } catch { /* binary error body */ }
200
+ throw new UMediaError(res.status === 404 ? Codes.NOT_FOUND : Codes.PROVIDER_ERROR, message, {
201
+ status: res.status,
202
+ });
203
+ }
204
+ return res;
205
+ }
206
+
207
+ // ---------- platform metadata (public) ----------
208
+
209
+ /** Live capability registry: which platforms/media types the engine serves. */
210
+ capabilities() {
211
+ return this._call("GET", "/api/v1/capabilities");
212
+ }
213
+
214
+ /** Real operational status — components and provider intelligence. */
215
+ status() {
216
+ return this._call("GET", "/api/v1/status");
217
+ }
218
+ }
219
+
220
+ export default UMedia;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "umedia",
3
+ "version": "0.1.0",
4
+ "description": "Official client for the Unified Media API — search, resolve and download media from YouTube, TikTok, Instagram, Reddit, X, iTunes and 1800+ sites through one REST API. Honest quality labels, multi-item galleries preserved, zero dependencies.",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "types": "./index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "umedia": "./bin/umedia.js"
16
+ },
17
+ "files": [
18
+ "index.js",
19
+ "index.d.ts",
20
+ "bin/",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "scripts": {
28
+ "test": "node --test test/"
29
+ },
30
+ "keywords": [
31
+ "media",
32
+ "api",
33
+ "sdk",
34
+ "youtube",
35
+ "tiktok",
36
+ "instagram",
37
+ "reddit",
38
+ "downloader",
39
+ "search",
40
+ "resolve",
41
+ "rest"
42
+ ],
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/Anonymous20666/unified-media-api.git",
47
+ "directory": "sdk"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/Anonymous20666/unified-media-api/issues"
51
+ },
52
+ "homepage": "https://github.com/Anonymous20666/unified-media-api#readme"
53
+ }