umedia 0.4.2 → 0.5.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.
@@ -1,146 +0,0 @@
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
- }
@@ -1,97 +0,0 @@
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 DELETED
@@ -1,49 +0,0 @@
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 DELETED
@@ -1,222 +0,0 @@
1
- import * as yt from "./providers/youtube.js";
2
- import * as tiktok from "./providers/tiktok.js";
3
- import * as pinterest from "./providers/pinterest.js";
4
- import * as soundcloud from "./providers/soundcloud.js";
5
- import * as dailymotion from "./providers/dailymotion.js";
6
- import * as vimeo from "./providers/vimeo.js";
7
- import * as streamable from "./providers/streamable.js";
8
- import * as twitch from "./providers/twitch.js";
9
- import * as bluesky from "./providers/bluesky.js";
10
- import * as reddit from "./providers/reddit.js";
11
- import * as xprov from "./providers/x.js";
12
- import * as ig from "./providers/instagram.js";
13
- import * as facebook from "./providers/facebook.js";
14
- import * as threads from "./providers/threads.js";
15
- import * as snapchat from "./providers/snapchat.js";
16
- import * as rumble from "./providers/rumble.js";
17
- import * as tumblr from "./providers/tumblr.js";
18
- import * as ytdlp from "./providers/ytdlp.js";
19
- import * as itunes from "./providers/itunes.js";
20
- import { Codes, UMediaError } from "./errors.js";
21
- import { validateUrl } from "./util.js";
22
-
23
- const NATIVE = [yt, tiktok, pinterest, soundcloud, dailymotion, vimeo, streamable, twitch, bluesky, reddit, xprov, ig, facebook, threads, snapchat, rumble, tumblr];
24
-
25
- export function detectPlatform(url) {
26
- for (const p of NATIVE) {
27
- try { if (p.canHandle(url)) return p.name; } catch { /* bad URL — keep scanning */ }
28
- }
29
- return ytdlp.binary() ? "ytdlp" : "unknown";
30
- }
31
-
32
- const DIRECT_MEDIA = /\.(mp4|webm|m4a|mp3|aac|wav|jpg|jpeg|png|gif|webp|mov|mkv)(\?|#|$)/i;
33
-
34
- /** Signed CDN blob URLs have no file extension — probe the content-type honestly. */
35
- async function probeDirectMedia(url) {
36
- try {
37
- let res = await fetch(url, {
38
- method: "HEAD",
39
- headers: { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" },
40
- redirect: "follow",
41
- signal: AbortSignal.timeout(12_000),
42
- });
43
- if (!res.ok && res.status !== 405) {
44
- res = await fetch(url, {
45
- headers: { "user-agent": "Mozilla/5.0", range: "bytes=0-0" },
46
- redirect: "follow",
47
- signal: AbortSignal.timeout(12_000),
48
- });
49
- }
50
- const ct = (res.headers.get("content-type") || "").split(";")[0].trim();
51
- const kind = /^video\//.test(ct) ? "video" : /^audio\//.test(ct) ? "audio" : /^image\//.test(ct) ? "image" : null;
52
- return kind ? { kind, mimeType: ct } : null;
53
- } catch {
54
- return null;
55
- }
56
- }
57
-
58
- function directResult(url, kind, mimeType, note) {
59
- return {
60
- data: {
61
- sourceUrl: url, platform: "direct",
62
- title: String(url).split("/").pop()?.split("?")[0] || "Direct media",
63
- author: null, thumbnail: kind === "video" ? null : url,
64
- itemCount: 1,
65
- counts: { images: kind === "image" ? 1 : 0, videos: kind === "video" ? 1 : 0, audios: kind === "audio" ? 1 : 0, other: 0 },
66
- truncated: false,
67
- media: [{
68
- type: kind, index: 0, url: String(url), thumbnail: null, mimeType: mimeType ?? null,
69
- width: null, height: null, duration: null, size: null, quality: "original",
70
- hasAudio: kind !== "image", hasVideo: kind !== "audio", source: "direct", variants: [],
71
- }],
72
- },
73
- engine: { attempts: [{ provider: "direct", status: "success", note }], finalProvider: "direct", totalLatencyMs: 0 },
74
- };
75
- }
76
-
77
- /** Resolve a URL: native adapter first, optional yt-dlp fallback. attempts[] is the honest trail. */
78
- export async function resolve(url) {
79
- // SSRF hygiene FIRST — never dispatch or fetch private/internal targets (bots pass user URLs!)
80
- validateUrl(url);
81
-
82
- // direct media URL (an image/video/audio file) -> single-item result, no platform needed
83
- const direct = String(url).split("#")[0].match(DIRECT_MEDIA);
84
- if (direct) {
85
- const ext = direct[1].toLowerCase();
86
- const kind = ["mp4", "webm", "mov", "mkv"].includes(ext) ? "video"
87
- : ["jpg", "jpeg", "png", "gif", "webp"].includes(ext) ? "image" : "audio";
88
- return {
89
- data: {
90
- sourceUrl: url, platform: "direct",
91
- title: String(url).split("/").pop() || "Direct media",
92
- author: null, thumbnail: kind === "video" ? null : url,
93
- itemCount: 1,
94
- counts: { images: kind === "image" ? 1 : 0, videos: kind === "video" ? 1 : 0, audios: kind === "audio" ? 1 : 0, other: 0 },
95
- truncated: false,
96
- media: [{
97
- type: kind, index: 0, url: String(url), thumbnail: null, mimeType: null,
98
- width: null, height: null, duration: null, size: null, quality: "original",
99
- hasAudio: kind !== "image", hasVideo: kind !== "audio", source: "direct", variants: [],
100
- }],
101
- },
102
- engine: { attempts: [{ provider: "direct", status: "success" }], finalProvider: "direct", totalLatencyMs: 0 },
103
- };
104
- }
105
-
106
- const ctx = { attempts: [] };
107
- let lastTyped = null;
108
- for (const p of NATIVE) {
109
- if (!p.canHandle(url)) continue;
110
- try {
111
- const r = await p.resolve(url, ctx);
112
- return { data: r.result, engine: r.engine };
113
- } catch (e) {
114
- if (e instanceof UMediaError && [Codes.MEDIA_NOT_FOUND, Codes.CONTENT_PRIVATE, Codes.SSRF_BLOCKED, Codes.INVALID_URL].includes(e.code)) {
115
- // definitive outcome for this platform — don't mask it with fallbacks
116
- e.attempts = ctx.attempts;
117
- throw e;
118
- }
119
- // transient/provider failure → try the next layer
120
- if (e instanceof UMediaError) lastTyped = e;
121
- continue;
122
- }
123
- }
124
- if (ytdlp.binary()) {
125
- try {
126
- const r = await ytdlp.resolve(url, ctx);
127
- return { data: r.result, engine: r.engine };
128
- } catch (e) {
129
- e.attempts = ctx.attempts;
130
- throw e;
131
- }
132
- }
133
- const last = ctx.attempts[ctx.attempts.length - 1];
134
- // extension-less signed CDN URLs: honest content-type probe before refusing
135
- if (detectPlatform(url) === "unknown") {
136
- const probe = await probeDirectMedia(url);
137
- if (probe) return directResult(url, probe.kind, probe.mimeType, "direct media URL (content-type probe)");
138
- throw new UMediaError(Codes.INVALID_URL,
139
- `No provider could resolve this URL${last?.error ? " Last error: " + last.error : ""}. Tip: install yt-dlp to unlock 1800+ sites and hard networks.`,
140
- { attempts: ctx.attempts });
141
- }
142
- // surface the real provider error when every matching provider failed —
143
- // never mask a typed outcome behind a generic message
144
- if (lastTyped) {
145
- lastTyped.attempts = ctx.attempts;
146
- throw lastTyped;
147
- }
148
- throw new UMediaError(
149
- Codes.PROVIDER_UNAVAILABLE,
150
- `No provider could resolve this URL.${last?.error ? " Last error: " + last.error : ""}` +
151
- (ytdlp.binary() ? "" : " Tip: install yt-dlp to unlock 1800+ sites and hard networks."),
152
- { attempts: ctx.attempts },
153
- );
154
- }
155
-
156
- /** Multi-source search. Music leads with iTunes 30s previews, then YouTube. */
157
- export async function search({ q, type = "video", limit = 12, adult = false } = {}) {
158
- const ctx = { attempts: [] };
159
- const out = [];
160
- const plan = type === "music" ? [itunes, yt] : [yt];
161
- for (const p of plan) {
162
- if (out.length >= limit) break;
163
- const items = await p.search({ q, type, limit: limit - out.length }, ctx);
164
- out.push(...items);
165
- }
166
- return { items: out.slice(0, limit), attempts: ctx.attempts };
167
- }
168
-
169
- export function capabilities() {
170
- return {
171
- platforms: [
172
- { 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" },
173
- { platform: "tiktok", search: false, resolve: true, download: true, notes: "video posts = direct CDN mp4; galleries = every slide the platform exposes (truncated flag honest)" },
174
- { platform: "pinterest", search: false, resolve: true, download: true, notes: "image pins, video pins (direct mp4), idea/story pins, pin.it links" },
175
- { platform: "soundcloud", search: false, resolve: true, download: true, notes: "full tracks: direct progressive MP3 + HLS, real titles/artists" },
176
- { platform: "dailymotion", search: false, resolve: true, download: true, notes: "public metadata API; HLS/progressive renditions" },
177
- { platform: "vimeo", search: false, resolve: true, download: true, notes: "player config: progressive mp4 when served, HLS otherwise" },
178
- { platform: "streamable", search: false, resolve: true, download: true, notes: "direct mp4 renditions" },
179
- { platform: "twitch", search: false, resolve: true, download: true, notes: "clips = direct mp4s; VODs/livestreams via yt-dlp tier" },
180
- { platform: "bluesky", search: false, resolve: true, download: true, notes: "public AppView API: full-size images + video playlists" },
181
- { platform: "instagram", search: false, resolve: true, download: true, notes: "oEmbed→mobile info API: photos, videos, full carousels" },
182
- { platform: "reddit", search: false, resolve: true, download: true, notes: "posts + galleries + video with audio sidecar" },
183
- { platform: "x", search: false, resolve: true, download: true, notes: "syndication token + guest GraphQL — photos/videos/gifs" },
184
- { platform: "facebook", search: false, resolve: true, download: true, notes: "reels/watch/videos/shares/fb.watch via the player JSON" },
185
- { platform: "threads", search: false, resolve: true, download: true, notes: "post SSR state + og media" },
186
- { platform: "snapchat", search: false, resolve: true, download: true, notes: "spotlight videos + public story snap lists" },
187
- { platform: "rumble", search: false, resolve: true, download: true, notes: "embed API: direct mp4 renditions" },
188
- { platform: "tumblr", search: false, resolve: true, download: true, notes: "mobile API: video/audio/photo posts + reblog trails" },
189
- { platform: "itunes", search: true, resolve: false, download: "previews", notes: "real 30s clips" },
190
- { platform: "ytdlp", search: false, resolve: Boolean(ytdlp.binary()), download: Boolean(ytdlp.binary()), notes: "optional tier: 1800+ sites — install yt-dlp" },
191
- ],
192
- qualityLadder: ["original", "best", "2160p", "1440p", "1080p", "720p", "480p", "360p", "240p", "144p", "audio"],
193
- };
194
- }
195
-
196
- export function status() {
197
- return {
198
- overall: "operational",
199
- runtime: { node: process.version, platform: process.platform },
200
- providers: [
201
- { name: "youtube", state: "operational", via: "youtubei.js" },
202
- { name: "tiktok", state: "operational", via: "embed state" },
203
- { name: "pinterest", state: "operational", via: "public widget API" },
204
- { name: "soundcloud", state: "operational", via: "public api-v2" },
205
- { name: "dailymotion", state: "operational", via: "player metadata" },
206
- { name: "vimeo", state: "operational", via: "player config" },
207
- { name: "streamable", state: "operational", via: "public video api" },
208
- { name: "twitch", state: "operational", via: "public GraphQL (clips)" },
209
- { name: "bluesky", state: "operational", via: "public AppView XRPC" },
210
- { name: "instagram", state: "operational", via: "oEmbed + mobile info api" },
211
- { name: "reddit", state: "operational", via: "public json" },
212
- { name: "x", state: "operational", via: "syndication + guest graphql" },
213
- { name: "facebook", state: "operational", via: "web player json" },
214
- { name: "threads", state: "operational", via: "share page state" },
215
- { name: "snapchat", state: "operational", via: "spotlight + story state" },
216
- { name: "rumble", state: "operational", via: "embed json api" },
217
- { name: "tumblr", state: "operational", via: "mobile api" },
218
- { name: "itunes", state: "operational", via: "search api" },
219
- { name: "ytdlp", state: ytdlp.binary() ? "operational" : "unavailable", via: "external binary" },
220
- ],
221
- };
222
- }
package/lib/util.js DELETED
@@ -1,93 +0,0 @@
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
- }