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 +21 -0
- package/README.md +112 -0
- package/bin/umedia.js +125 -0
- package/index.d.ts +232 -0
- package/index.js +220 -0
- package/package.json +53 -0
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
|
+
}
|