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 +72 -138
- package/bin/umedia.js +25 -51
- package/index.d.ts +59 -111
- package/index.js +45 -189
- package/lib/download.js +159 -0
- package/lib/errors.js +30 -0
- package/lib/providers/instagram.js +69 -0
- package/lib/providers/itunes.js +47 -0
- package/lib/providers/reddit.js +97 -0
- package/lib/providers/tiktok.js +165 -0
- package/lib/providers/x.js +71 -0
- package/lib/providers/youtube.js +146 -0
- package/lib/providers/ytdlp.js +97 -0
- package/lib/quality.js +49 -0
- package/lib/registry.js +123 -0
- package/lib/util.js +93 -0
- package/package.json +12 -8
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { parseDuration, hostMatches } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/** YouTube via InnerTube (youtubei.js) — search + metadata always; stream URLs when the network allows. */
|
|
5
|
+
export const name = "youtube";
|
|
6
|
+
|
|
7
|
+
let _engine = null;
|
|
8
|
+
async function engine() {
|
|
9
|
+
if (!_engine) {
|
|
10
|
+
const { Innertube } = await import("youtubei.js");
|
|
11
|
+
_engine = await Innertube.create({ retrieve_player: true });
|
|
12
|
+
}
|
|
13
|
+
return _engine;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function canHandle(url) {
|
|
17
|
+
try {
|
|
18
|
+
return hostMatches(new URL(String(url)).hostname, ["youtube.com", "youtu.be", "youtube-nocookie.com"]);
|
|
19
|
+
} catch { return false; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function videoId(url) {
|
|
23
|
+
const u = new URL(String(url));
|
|
24
|
+
if (u.hostname.includes("youtu.be")) return u.pathname.slice(1).split("/")[0];
|
|
25
|
+
if (u.searchParams.get("v")) return u.searchParams.get("v");
|
|
26
|
+
const m = u.pathname.match(/\/(shorts|embed|live|v)\/([^/?#]+)/);
|
|
27
|
+
return m ? m[2] : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function search({ q, type = "video", limit = 12 }, ctx = {}) {
|
|
31
|
+
const attempt = { provider: name, status: "skipped" };
|
|
32
|
+
ctx.attempts?.push(attempt);
|
|
33
|
+
const t0 = Date.now();
|
|
34
|
+
try {
|
|
35
|
+
const e = await engine();
|
|
36
|
+
let query = q;
|
|
37
|
+
if (type === "movie") query = `${q} trailer`;
|
|
38
|
+
const res = await e.search(query, { type: "video" });
|
|
39
|
+
attempt.status = "success";
|
|
40
|
+
attempt.latencyMs = Date.now() - t0;
|
|
41
|
+
return (res.videos || []).slice(0, limit).map((v) => {
|
|
42
|
+
const id = v.id;
|
|
43
|
+
return {
|
|
44
|
+
id,
|
|
45
|
+
title: v.title?.text ?? String(v.title ?? ""),
|
|
46
|
+
author: v.author?.name ?? v.author ?? null,
|
|
47
|
+
pageUrl: `https://www.youtube.com/watch?v=${id}`,
|
|
48
|
+
thumbnail: v.thumbnails?.[v.thumbnails.length - 1]?.url ?? null,
|
|
49
|
+
duration: parseDuration(v.duration?.text) ?? v.duration?.seconds ?? null,
|
|
50
|
+
mediaType: type === "music" ? "music" : v.type === "short" ? "short_video" : "video",
|
|
51
|
+
source: name,
|
|
52
|
+
previewUrl: `https://www.youtube.com/embed/${id}`,
|
|
53
|
+
previewKind: type === "movie" ? "trailer" : "embed",
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
} catch (e) {
|
|
57
|
+
attempt.status = "failed";
|
|
58
|
+
attempt.error = e.message;
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fmtToVariant(f, url) {
|
|
64
|
+
return {
|
|
65
|
+
quality: f.quality_label || (f.height ? `${f.height}p` : f.quality || "unknown"),
|
|
66
|
+
width: f.width ?? null,
|
|
67
|
+
height: f.height ?? null,
|
|
68
|
+
url: url ?? null,
|
|
69
|
+
mimeType: (f.mime_type || "").split(";")[0] || null,
|
|
70
|
+
bitrateKbps: f.bitrate ? Math.round(f.bitrate / 1000) : null,
|
|
71
|
+
hasAudio: Boolean(f.has_audio),
|
|
72
|
+
hasVideo: Boolean(f.has_video),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function resolve(url, ctx = {}) {
|
|
77
|
+
const attempt = { provider: name, status: "skipped" };
|
|
78
|
+
ctx.attempts?.push(attempt);
|
|
79
|
+
const id = videoId(url);
|
|
80
|
+
if (!id) throw new UMediaError(Codes.INVALID_URL, `Not a recognizable YouTube URL: ${url}`);
|
|
81
|
+
const t0 = Date.now();
|
|
82
|
+
try {
|
|
83
|
+
const e = await engine();
|
|
84
|
+
const info = await e.getInfo(id);
|
|
85
|
+
attempt.status = "success";
|
|
86
|
+
attempt.latencyMs = Date.now() - t0;
|
|
87
|
+
const b = info.basic_info || {};
|
|
88
|
+
const all = [...(info.streaming_data?.formats || []), ...(info.streaming_data?.adaptive_formats || [])];
|
|
89
|
+
|
|
90
|
+
// stream URLs: present when the network isn't bot-gated; otherwise honest nulls
|
|
91
|
+
const variants = [];
|
|
92
|
+
for (const f of all) {
|
|
93
|
+
let u = null;
|
|
94
|
+
if (f.url) u = f.url;
|
|
95
|
+
else if (f.signature_cipher || f.cipher) {
|
|
96
|
+
try {
|
|
97
|
+
u = await f.decipher(e.session?.player);
|
|
98
|
+
} catch { u = null; }
|
|
99
|
+
}
|
|
100
|
+
variants.push(fmtToVariant(f, u));
|
|
101
|
+
}
|
|
102
|
+
const withUrls = variants.filter((v) => v.url);
|
|
103
|
+
const best = withUrls.sort((a, b2) => (b2.height || 0) - (a.height || 0))[0] || variants.sort((a, b2) => (b2.height || 0) - (a.height || 0))[0];
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
result: {
|
|
107
|
+
sourceUrl: url,
|
|
108
|
+
platform: "youtube",
|
|
109
|
+
title: b.title ?? null,
|
|
110
|
+
author: b.author ?? null,
|
|
111
|
+
thumbnail: b.thumbnail?.[b.thumbnail.length - 1]?.url ?? null,
|
|
112
|
+
itemCount: 1,
|
|
113
|
+
counts: { images: 0, videos: 1, audios: 0, other: 0 },
|
|
114
|
+
truncated: false,
|
|
115
|
+
media: [{
|
|
116
|
+
type: "video",
|
|
117
|
+
index: 0,
|
|
118
|
+
url: best?.url ?? null,
|
|
119
|
+
thumbnail: b.thumbnail?.[b.thumbnail.length - 1]?.url ?? null,
|
|
120
|
+
mimeType: best?.mimeType ?? null,
|
|
121
|
+
width: best?.width ?? null,
|
|
122
|
+
height: best?.height ?? null,
|
|
123
|
+
duration: b.duration ?? null,
|
|
124
|
+
size: null,
|
|
125
|
+
quality: best?.quality ?? null,
|
|
126
|
+
hasAudio: best?.hasAudio ?? null,
|
|
127
|
+
hasVideo: best?.hasVideo ?? null,
|
|
128
|
+
source: name,
|
|
129
|
+
variants,
|
|
130
|
+
}],
|
|
131
|
+
streamUrlsAvailable: withUrls.length > 0,
|
|
132
|
+
streamAccess: withUrls.length > 0 ? "direct"
|
|
133
|
+
: "withheld — YouTube is bot-gating this network; install yt-dlp for hard networks",
|
|
134
|
+
},
|
|
135
|
+
engine: {
|
|
136
|
+
attempts: ctx.attempts || [attempt],
|
|
137
|
+
finalProvider: name,
|
|
138
|
+
totalLatencyMs: Date.now() - t0,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
} catch (e) {
|
|
142
|
+
attempt.status = "failed";
|
|
143
|
+
attempt.error = e.message;
|
|
144
|
+
throw new UMediaError(e instanceof UMediaError ? e.code : Codes.PROVIDER_ERROR, `YouTube resolve failed: ${e.message}`, { attempts: [attempt] });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Optional yt-dlp power tier. If `yt-dlp` is on PATH (or UMedia_YTDLP points at it),
|
|
6
|
+
* it unlocks 1800+ sites and hard networks (PO tokens, cookies, muxing).
|
|
7
|
+
* Labeled honestly as source: "ytdlp" — never silently mixed with native results.
|
|
8
|
+
*/
|
|
9
|
+
export const name = "ytdlp";
|
|
10
|
+
|
|
11
|
+
let _path;
|
|
12
|
+
export function binary() {
|
|
13
|
+
if (_path !== undefined) return _path;
|
|
14
|
+
const probe = process.env.UMedia_YTDLP || "yt-dlp";
|
|
15
|
+
try {
|
|
16
|
+
const r = spawnSync(probe, ["--version"], { encoding: "utf8", timeout: 8000 });
|
|
17
|
+
_path = r.status === 0 ? probe : null;
|
|
18
|
+
} catch { _path = null; }
|
|
19
|
+
return _path;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function canHandle() {
|
|
23
|
+
return Boolean(binary());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function probe(url, extra = []) {
|
|
27
|
+
const bin = binary();
|
|
28
|
+
if (!bin) throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, "yt-dlp not installed — `pip install yt-dlp` to enable the 1800+ sites tier");
|
|
29
|
+
const r = spawnSync(bin, ["--no-playlist", "-J", ...extra, url], {
|
|
30
|
+
encoding: "utf8", timeout: 120_000, maxBuffer: 64 * 1024 * 1024,
|
|
31
|
+
});
|
|
32
|
+
if (r.status !== 0) {
|
|
33
|
+
throw new UMediaError(Codes.PROVIDER_ERROR, `yt-dlp failed: ${(r.stderr || "").split("\n").slice(-2)[0] || "unknown error"}`);
|
|
34
|
+
}
|
|
35
|
+
return JSON.parse(r.stdout);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function resolve(url, ctx = {}) {
|
|
39
|
+
const attempt = { provider: name, status: "skipped" };
|
|
40
|
+
ctx.attempts?.push(attempt);
|
|
41
|
+
const t0 = Date.now();
|
|
42
|
+
try {
|
|
43
|
+
const info = probe(url);
|
|
44
|
+
attempt.status = "success";
|
|
45
|
+
attempt.latencyMs = Date.now() - t0;
|
|
46
|
+
const entries = info.entries || [info];
|
|
47
|
+
const media = entries.slice(0, 200).map((e, i) => ({
|
|
48
|
+
type: e._type === "image" ? "image" : (e.vcodec && e.vcodec !== "none") ? "video" : "audio",
|
|
49
|
+
index: i,
|
|
50
|
+
url: e.url || e.entries?.[0]?.url || null,
|
|
51
|
+
thumbnail: e.thumbnail ?? null,
|
|
52
|
+
mimeType: null,
|
|
53
|
+
width: e.width ?? null,
|
|
54
|
+
height: e.height ?? null,
|
|
55
|
+
duration: e.duration ?? null,
|
|
56
|
+
size: e.filesize ?? e.filesize_approx ?? null,
|
|
57
|
+
quality: e.height ? `${e.height}p` : null,
|
|
58
|
+
hasAudio: e.acodec && e.acodec !== "none",
|
|
59
|
+
hasVideo: e.vcodec && e.vcodec !== "none",
|
|
60
|
+
source: name,
|
|
61
|
+
variants: (e.formats || []).filter((f) => f.url).map((f) => ({
|
|
62
|
+
quality: f.format_note || (f.height ? `${f.height}p` : "unknown"),
|
|
63
|
+
width: f.width ?? null, height: f.height ?? null, url: f.url,
|
|
64
|
+
mimeType: (f.ext ? `video/${f.ext}` : null),
|
|
65
|
+
bitrateKbps: f.tbr ? Math.round(f.tbr) : null,
|
|
66
|
+
hasAudio: Boolean(f.acodec && f.acodec !== "none"),
|
|
67
|
+
hasVideo: Boolean(f.vcodec && f.vcodec !== "none"),
|
|
68
|
+
})),
|
|
69
|
+
}));
|
|
70
|
+
return {
|
|
71
|
+
result: {
|
|
72
|
+
sourceUrl: url,
|
|
73
|
+
platform: info.extractor_key?.toLowerCase() ?? "ytdlp",
|
|
74
|
+
title: info.title ?? null,
|
|
75
|
+
author: info.uploader ?? info.channel ?? null,
|
|
76
|
+
thumbnail: info.thumbnail ?? null,
|
|
77
|
+
itemCount: media.length,
|
|
78
|
+
counts: {
|
|
79
|
+
images: media.filter((m) => m.type === "image").length,
|
|
80
|
+
videos: media.filter((m) => m.type === "video").length,
|
|
81
|
+
audios: media.filter((m) => m.type === "audio").length, other: 0,
|
|
82
|
+
},
|
|
83
|
+
truncated: Boolean(info.entries && info.entries.length > 200),
|
|
84
|
+
media,
|
|
85
|
+
},
|
|
86
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
87
|
+
};
|
|
88
|
+
} catch (e) {
|
|
89
|
+
attempt.status = "failed";
|
|
90
|
+
attempt.error = e.message;
|
|
91
|
+
throw e instanceof UMediaError ? e : new UMediaError(Codes.PROVIDER_ERROR, e.message, { attempts: [attempt] });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function search() {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
package/lib/quality.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Honest quality ladder — heights come from the real source, never invented. */
|
|
2
|
+
export const LADDER = ["original", "best", "2160p", "1440p", "1080p", "720p", "480p", "360p", "240p", "144p", "audio"];
|
|
3
|
+
|
|
4
|
+
export function heightOf(quality) {
|
|
5
|
+
const m = /^(\d+)p$/.exec(String(quality || ""));
|
|
6
|
+
return m ? Number(m[1]) : null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function labelOfHeight(h) {
|
|
10
|
+
return h ? `${h}p` : "unknown";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pick the best format whose real height <= requested (fallback: closest available).
|
|
15
|
+
* @returns {{format: object|null, requestedQuality: string, selectedQuality: string|null, fallback: boolean}}
|
|
16
|
+
*/
|
|
17
|
+
export function pickFormat(formats, requestedQuality = "best") {
|
|
18
|
+
const req = String(requestedQuality || "best").toLowerCase();
|
|
19
|
+
const usable = (formats || []).filter((f) => f && (f.height || f.audioOnly));
|
|
20
|
+
if (!usable.length) return { format: null, requestedQuality: req, selectedQuality: null, fallback: false };
|
|
21
|
+
|
|
22
|
+
if (req === "audio") {
|
|
23
|
+
const audios = usable.filter((f) => f.audioOnly);
|
|
24
|
+
const format = audios[0] || null;
|
|
25
|
+
return { format, requestedQuality: req, selectedQuality: format ? "audio" : null, fallback: false };
|
|
26
|
+
}
|
|
27
|
+
const videos = usable.filter((f) => f.height);
|
|
28
|
+
if (!videos.length) {
|
|
29
|
+
return { format: null, requestedQuality: req, selectedQuality: null, fallback: false };
|
|
30
|
+
}
|
|
31
|
+
const sorted = [...videos].sort((a, b) => b.height - a.height);
|
|
32
|
+
if (req === "original" || req === "best") {
|
|
33
|
+
const format = sorted[0];
|
|
34
|
+
return { format, requestedQuality: req, selectedQuality: labelOfHeight(format.height), fallback: false };
|
|
35
|
+
}
|
|
36
|
+
const target = heightOf(req);
|
|
37
|
+
if (!target) {
|
|
38
|
+
const format = sorted[0];
|
|
39
|
+
return { format, requestedQuality: req, selectedQuality: labelOfHeight(format.height), fallback: true };
|
|
40
|
+
}
|
|
41
|
+
const atOrBelow = sorted.find((f) => f.height <= target);
|
|
42
|
+
const format = atOrBelow || sorted[sorted.length - 1];
|
|
43
|
+
return {
|
|
44
|
+
format,
|
|
45
|
+
requestedQuality: req,
|
|
46
|
+
selectedQuality: labelOfHeight(format.height),
|
|
47
|
+
fallback: format.height !== target,
|
|
48
|
+
};
|
|
49
|
+
}
|
package/lib/registry.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import * as yt from "./providers/youtube.js";
|
|
2
|
+
import * as tiktok from "./providers/tiktok.js";
|
|
3
|
+
import * as reddit from "./providers/reddit.js";
|
|
4
|
+
import * as xprov from "./providers/x.js";
|
|
5
|
+
import * as ig from "./providers/instagram.js";
|
|
6
|
+
import * as ytdlp from "./providers/ytdlp.js";
|
|
7
|
+
import * as itunes from "./providers/itunes.js";
|
|
8
|
+
import { Codes, UMediaError } from "./errors.js";
|
|
9
|
+
|
|
10
|
+
const NATIVE = [yt, tiktok, reddit, xprov, ig];
|
|
11
|
+
|
|
12
|
+
export function detectPlatform(url) {
|
|
13
|
+
for (const p of NATIVE) {
|
|
14
|
+
try { if (p.canHandle(url)) return p.name; } catch { /* bad URL — keep scanning */ }
|
|
15
|
+
}
|
|
16
|
+
return ytdlp.binary() ? "ytdlp" : "unknown";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DIRECT_MEDIA = /\.(mp4|webm|m4a|mp3|aac|wav|jpg|jpeg|png|gif|webp|mov|mkv)(\?|#|$)/i;
|
|
20
|
+
|
|
21
|
+
/** Resolve a URL: native adapter first, optional yt-dlp fallback. attempts[] is the honest trail. */
|
|
22
|
+
export async function resolve(url) {
|
|
23
|
+
// direct media URL (an image/video/audio file) -> single-item result, no platform needed
|
|
24
|
+
const direct = String(url).split("#")[0].match(DIRECT_MEDIA);
|
|
25
|
+
if (direct) {
|
|
26
|
+
const ext = direct[1].toLowerCase();
|
|
27
|
+
const kind = ["mp4", "webm", "mov", "mkv"].includes(ext) ? "video"
|
|
28
|
+
: ["jpg", "jpeg", "png", "gif", "webp"].includes(ext) ? "image" : "audio";
|
|
29
|
+
return {
|
|
30
|
+
data: {
|
|
31
|
+
sourceUrl: url, platform: "direct",
|
|
32
|
+
title: String(url).split("/").pop() || "Direct media",
|
|
33
|
+
author: null, thumbnail: kind === "video" ? null : url,
|
|
34
|
+
itemCount: 1,
|
|
35
|
+
counts: { images: kind === "image" ? 1 : 0, videos: kind === "video" ? 1 : 0, audios: kind === "audio" ? 1 : 0, other: 0 },
|
|
36
|
+
truncated: false,
|
|
37
|
+
media: [{
|
|
38
|
+
type: kind, index: 0, url: String(url), thumbnail: null, mimeType: null,
|
|
39
|
+
width: null, height: null, duration: null, size: null, quality: "original",
|
|
40
|
+
hasAudio: kind !== "image", hasVideo: kind !== "audio", source: "direct", variants: [],
|
|
41
|
+
}],
|
|
42
|
+
},
|
|
43
|
+
engine: { attempts: [{ provider: "direct", status: "success" }], finalProvider: "direct", totalLatencyMs: 0 },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ctx = { attempts: [] };
|
|
48
|
+
for (const p of NATIVE) {
|
|
49
|
+
if (!p.canHandle(url)) continue;
|
|
50
|
+
try {
|
|
51
|
+
const r = await p.resolve(url, ctx);
|
|
52
|
+
return { data: r.result, engine: r.engine };
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (e instanceof UMediaError && [Codes.MEDIA_NOT_FOUND, Codes.CONTENT_PRIVATE, Codes.SSRF_BLOCKED, Codes.INVALID_URL].includes(e.code)) {
|
|
55
|
+
// definitive outcome for this platform — don't mask it with fallbacks
|
|
56
|
+
e.attempts = ctx.attempts;
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
// transient/provider failure → try the next layer
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (ytdlp.binary()) {
|
|
64
|
+
try {
|
|
65
|
+
const r = await ytdlp.resolve(url, ctx);
|
|
66
|
+
return { data: r.result, engine: r.engine };
|
|
67
|
+
} catch (e) {
|
|
68
|
+
e.attempts = ctx.attempts;
|
|
69
|
+
throw e;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const last = ctx.attempts[ctx.attempts.length - 1];
|
|
73
|
+
throw new UMediaError(
|
|
74
|
+
detectPlatform(url) === "unknown" ? Codes.INVALID_URL : Codes.PROVIDER_UNAVAILABLE,
|
|
75
|
+
`No provider could resolve this URL.${last?.error ? " Last error: " + last.error : ""}` +
|
|
76
|
+
(ytdlp.binary() ? "" : " Tip: install yt-dlp to unlock 1800+ sites and hard networks."),
|
|
77
|
+
{ attempts: ctx.attempts },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Multi-source search. Music leads with iTunes 30s previews, then YouTube. */
|
|
82
|
+
export async function search({ q, type = "video", limit = 12, adult = false } = {}) {
|
|
83
|
+
const ctx = { attempts: [] };
|
|
84
|
+
const out = [];
|
|
85
|
+
const plan = type === "music" ? [itunes, yt] : [yt];
|
|
86
|
+
for (const p of plan) {
|
|
87
|
+
if (out.length >= limit) break;
|
|
88
|
+
const items = await p.search({ q, type, limit: limit - out.length }, ctx);
|
|
89
|
+
out.push(...items);
|
|
90
|
+
}
|
|
91
|
+
return { items: out.slice(0, limit), attempts: ctx.attempts };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function capabilities() {
|
|
95
|
+
return {
|
|
96
|
+
platforms: [
|
|
97
|
+
{ platform: "youtube", search: true, resolve: true, download: "network-dependent (yt-dlp tier recommended)", notes: "search+metadata always; stream URLs depend on network/PO-token state" },
|
|
98
|
+
{ platform: "tiktok", search: false, resolve: true, download: true, notes: "photo galleries + video posts" },
|
|
99
|
+
{ platform: "instagram", search: false, resolve: true, download: true, notes: "public embed best-effort" },
|
|
100
|
+
{ platform: "reddit", search: false, resolve: true, download: true, notes: "posts + galleries" },
|
|
101
|
+
{ platform: "x", search: false, resolve: true, download: true, notes: "public posts via syndication" },
|
|
102
|
+
{ platform: "itunes", search: true, resolve: false, download: "previews", notes: "real 30s clips" },
|
|
103
|
+
{ platform: "ytdlp", search: false, resolve: Boolean(ytdlp.binary()), download: Boolean(ytdlp.binary()), notes: "optional tier: 1800+ sites — install yt-dlp" },
|
|
104
|
+
],
|
|
105
|
+
qualityLadder: ["original", "best", "2160p", "1440p", "1080p", "720p", "480p", "360p", "240p", "144p", "audio"],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function status() {
|
|
110
|
+
return {
|
|
111
|
+
overall: "operational",
|
|
112
|
+
runtime: { node: process.version, platform: process.platform },
|
|
113
|
+
providers: [
|
|
114
|
+
{ name: "youtube", state: "operational", via: "youtubei.js" },
|
|
115
|
+
{ name: "tiktok", state: "operational", via: "embed/v2" },
|
|
116
|
+
{ name: "instagram", state: "degraded", via: "public embed (best-effort)" },
|
|
117
|
+
{ name: "reddit", state: "operational", via: "public json" },
|
|
118
|
+
{ name: "x", state: "degraded", via: "syndication (best-effort)" },
|
|
119
|
+
{ name: "itunes", state: "operational", via: "search api" },
|
|
120
|
+
{ name: "ytdlp", state: ytdlp.binary() ? "operational" : "unavailable", via: "external binary" },
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
}
|
package/lib/util.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { UMediaError, Codes } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
export const UA =
|
|
4
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
|
|
5
|
+
|
|
6
|
+
export function rid() {
|
|
7
|
+
return "req_" + Math.random().toString(16).slice(2, 10) + Date.now().toString(16).slice(-8);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Hostname match: exact or dot-suffix (www-insensitive). */
|
|
11
|
+
export function hostMatches(hostname, list) {
|
|
12
|
+
const h = String(hostname || "").toLowerCase().replace(/^www\./, "");
|
|
13
|
+
return list.some((d) => h === d || h.endsWith("." + d));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** http(s) only, no private/loopback/link-local targets (SSRF hygiene). */
|
|
17
|
+
export function validateUrl(raw) {
|
|
18
|
+
let u;
|
|
19
|
+
try {
|
|
20
|
+
u = new URL(String(raw || "").trim());
|
|
21
|
+
} catch {
|
|
22
|
+
throw new UMediaError(Codes.INVALID_URL, `Invalid URL: ${String(raw).slice(0, 120)}`);
|
|
23
|
+
}
|
|
24
|
+
if (!/^https?:$/.test(u.protocol)) {
|
|
25
|
+
throw new UMediaError(Codes.INVALID_URL, `Only http(s) URLs are supported — got ${u.protocol}`);
|
|
26
|
+
}
|
|
27
|
+
const host = u.hostname.toLowerCase();
|
|
28
|
+
if (
|
|
29
|
+
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
|
|
30
|
+
/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) ||
|
|
31
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(host) || /^169\.254\./.test(host) ||
|
|
32
|
+
host === "0.0.0.0" || host === "::1" || host === "[::1]"
|
|
33
|
+
) {
|
|
34
|
+
throw new UMediaError(Codes.SSRF_BLOCKED, `Refusing private/internal host: ${host}`);
|
|
35
|
+
}
|
|
36
|
+
return u;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function fetchJson(url, opts = {}, timeoutMs = 20_000) {
|
|
40
|
+
const ctl = new AbortController();
|
|
41
|
+
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(url, {
|
|
44
|
+
...opts,
|
|
45
|
+
signal: ctl.signal,
|
|
46
|
+
headers: { "user-agent": UA, ...(opts.headers || {}) },
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
throw new UMediaError(
|
|
50
|
+
res.status === 429 ? Codes.RATE_LIMITED : res.status === 404 ? Codes.NOT_FOUND : Codes.PROVIDER_ERROR,
|
|
51
|
+
`HTTP ${res.status} from ${new URL(url).hostname}`,
|
|
52
|
+
{ status: res.status },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return await res.json();
|
|
56
|
+
} catch (e) {
|
|
57
|
+
if (e instanceof UMediaError) throw e;
|
|
58
|
+
if (e.name === "AbortError") throw new UMediaError(Codes.TIMEOUT, `Timed out after ${timeoutMs}ms: ${new URL(url).hostname}`);
|
|
59
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Network error from ${new URL(url).hostname}: ${e.message}`);
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(t);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function fetchText(url, opts = {}, timeoutMs = 20_000) {
|
|
66
|
+
const ctl = new AbortController();
|
|
67
|
+
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(url, {
|
|
70
|
+
...opts,
|
|
71
|
+
signal: ctl.signal,
|
|
72
|
+
headers: { "user-agent": UA, ...(opts.headers || {}) },
|
|
73
|
+
});
|
|
74
|
+
return { status: res.status, text: await res.text(), headers: res.headers };
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (e.name === "AbortError") throw new UMediaError(Codes.TIMEOUT, `Timed out after ${timeoutMs}ms: ${new URL(url).hostname}`);
|
|
77
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Network error from ${new URL(url).hostname}: ${e.message}`);
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(t);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parseDuration(text) {
|
|
84
|
+
if (text == null) return null;
|
|
85
|
+
if (typeof text === "number") return text;
|
|
86
|
+
const p = String(text).split(":").map(Number);
|
|
87
|
+
if (p.some(Number.isNaN)) return null;
|
|
88
|
+
return p.reduce((acc, n) => acc * 60 + n, 0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function cleanName(s) {
|
|
92
|
+
return String(s || "media").replace(/[\\/:*?"<>|]/g, "_").slice(0, 120).trim() || "media";
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "umedia",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Standalone media engine — search, resolve and download media from YouTube, TikTok, Instagram, Reddit, X, iTunes (and 1800+ sites via the yt-dlp tier) entirely on your machine. No hosted API, no keys, no server bills. Honest quality labels, complete galleries, typed errors.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
7
7
|
"types": "./index.d.ts",
|
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
|
-
"umedia": "bin/umedia.js"
|
|
15
|
+
"umedia": "./bin/umedia.js"
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"index.js",
|
|
19
19
|
"index.d.ts",
|
|
20
|
+
"lib/",
|
|
20
21
|
"bin/",
|
|
21
22
|
"README.md",
|
|
22
23
|
"LICENSE"
|
|
@@ -27,18 +28,21 @@
|
|
|
27
28
|
"scripts": {
|
|
28
29
|
"test": "node --test test/"
|
|
29
30
|
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"fflate": "^0.8.2",
|
|
33
|
+
"youtubei.js": "^18.1.0"
|
|
34
|
+
},
|
|
30
35
|
"keywords": [
|
|
31
36
|
"media",
|
|
32
|
-
"
|
|
33
|
-
"sdk",
|
|
37
|
+
"downloader",
|
|
34
38
|
"youtube",
|
|
35
39
|
"tiktok",
|
|
36
40
|
"instagram",
|
|
37
41
|
"reddit",
|
|
38
|
-
"downloader",
|
|
39
42
|
"search",
|
|
40
|
-
"
|
|
41
|
-
"
|
|
43
|
+
"engine",
|
|
44
|
+
"ytdlp",
|
|
45
|
+
"cli"
|
|
42
46
|
],
|
|
43
47
|
"license": "MIT",
|
|
44
48
|
"repository": {
|