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.
- package/bin/umedia.js +12 -98
- package/index.d.ts +2 -180
- package/index.js +6 -74
- package/package.json +10 -53
- package/LICENSE +0 -21
- package/README.md +0 -156
- package/lib/download.js +0 -248
- package/lib/errors.js +0 -30
- package/lib/providers/bluesky.js +0 -118
- package/lib/providers/dailymotion.js +0 -100
- package/lib/providers/facebook.js +0 -126
- package/lib/providers/instagram.js +0 -191
- package/lib/providers/itunes.js +0 -47
- package/lib/providers/pinterest.js +0 -181
- package/lib/providers/reddit.js +0 -123
- package/lib/providers/rumble.js +0 -110
- package/lib/providers/snapchat.js +0 -177
- package/lib/providers/soundcloud.js +0 -138
- package/lib/providers/streamable.js +0 -93
- package/lib/providers/threads.js +0 -103
- package/lib/providers/tiktok.js +0 -165
- package/lib/providers/tumblr.js +0 -153
- package/lib/providers/twitch.js +0 -108
- package/lib/providers/vimeo.js +0 -108
- package/lib/providers/x.js +0 -215
- package/lib/providers/youtube.js +0 -146
- package/lib/providers/ytdlp.js +0 -97
- package/lib/quality.js +0 -49
- package/lib/registry.js +0 -222
- package/lib/util.js +0 -93
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import { fetchJson, fetchText, hostMatches } from "../util.js";
|
|
2
|
-
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Instagram — the proven logged-out chain (yt-dlp instagram + cobalt instagram):
|
|
6
|
-
* 1. i.instagram.com oEmbed -> numeric media_id
|
|
7
|
-
* 2. i.instagram.com mobile media info API -> ALL carousel items, best video/photo
|
|
8
|
-
* 3. the /embed/captioned/ page's embedded contextJSON
|
|
9
|
-
* 4. official og: meta on the share page
|
|
10
|
-
*/
|
|
11
|
-
export const name = "instagram";
|
|
12
|
-
|
|
13
|
-
const MOBILE_HEADERS = {
|
|
14
|
-
"x-ig-app-locale": "en_US",
|
|
15
|
-
"x-ig-device-locale": "en_US",
|
|
16
|
-
"x-ig-mapped-locale": "en_US",
|
|
17
|
-
"user-agent": "Instagram 275.0.0.27.98 Android (33/13; 280dpi; 720x1423; Xiaomi; Redmi 7; onclite; qcom; en_US; 458229237)",
|
|
18
|
-
"accept-language": "en-US",
|
|
19
|
-
"x-fb-http-engine": "Liger",
|
|
20
|
-
"x-fb-client-ip": "True",
|
|
21
|
-
"x-fb-server-cluster": "True",
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
const EMBED_HEADERS = {
|
|
25
|
-
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
26
|
-
"accept-language": "en-GB,en;q=0.9",
|
|
27
|
-
"sec-fetch-dest": "document",
|
|
28
|
-
"sec-fetch-mode": "navigate",
|
|
29
|
-
"sec-fetch-site": "none",
|
|
30
|
-
"upgrade-insecure-requests": "1",
|
|
31
|
-
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export function canHandle(url) {
|
|
35
|
-
try {
|
|
36
|
-
return hostMatches(new URL(String(url)).hostname, ["instagram.com", "instagr.am"]) && /\/(p|reel|tv)\/[\w-]+/i.test(String(url));
|
|
37
|
-
} catch { return false; }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function shortcode(url) {
|
|
41
|
-
const m = String(url).match(/\/(?:p|reel|tv)\/([\w-]+)/);
|
|
42
|
-
return m ? m[1] : null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function pushMobileItem(item, media) {
|
|
46
|
-
const isVideo = Array.isArray(item.video_versions) && item.video_versions.length > 0;
|
|
47
|
-
const img = item.image_versions2?.candidates?.[0]?.url || null;
|
|
48
|
-
if (isVideo) {
|
|
49
|
-
const best = item.video_versions.reduce((a, b) => (a.width * a.height < b.width * b.height ? b : a), item.video_versions[0]);
|
|
50
|
-
media.push({
|
|
51
|
-
type: "video", index: media.length, url: best.url, thumbnail: img,
|
|
52
|
-
mimeType: "video/mp4", width: best.width ?? null, height: best.height ?? null,
|
|
53
|
-
duration: item.video_duration ? Math.round(item.video_duration) : null, size: null,
|
|
54
|
-
quality: best.height ? `${best.height}p` : null,
|
|
55
|
-
hasAudio: true, hasVideo: true, source: name,
|
|
56
|
-
variants: item.video_versions.map((v) => ({
|
|
57
|
-
height: v.height ?? null, width: v.width ?? null, url: v.url,
|
|
58
|
-
hasAudio: true, hasVideo: true, quality: v.height ? `${v.height}p` : null, mimeType: "video/mp4",
|
|
59
|
-
})),
|
|
60
|
-
});
|
|
61
|
-
} else if (img) {
|
|
62
|
-
media.push({
|
|
63
|
-
type: "image", index: media.length, url: img, thumbnail: img,
|
|
64
|
-
mimeType: "image/jpeg", width: item.original_width ?? null, height: item.original_height ?? null,
|
|
65
|
-
duration: null, size: null, quality: "original",
|
|
66
|
-
hasAudio: false, hasVideo: false, source: name,
|
|
67
|
-
variants: (item.image_versions2?.candidates || []).map((c) => ({
|
|
68
|
-
height: c.height ?? null, width: c.width ?? null, url: c.url,
|
|
69
|
-
hasAudio: false, hasVideo: false, quality: c.height ? `${c.height}p` : null, mimeType: "image/jpeg",
|
|
70
|
-
})),
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export async function resolve(url, ctx = {}) {
|
|
76
|
-
const attempt = { provider: name, status: "skipped" };
|
|
77
|
-
ctx.attempts?.push(attempt);
|
|
78
|
-
const t0 = Date.now();
|
|
79
|
-
const sc = shortcode(url);
|
|
80
|
-
if (!sc) {
|
|
81
|
-
attempt.status = "failed";
|
|
82
|
-
throw new UMediaError(Codes.INVALID_URL, "Not an Instagram post URL (expected instagram.com/p|reel|tv/{code})", { attempts: [attempt] });
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const media = [];
|
|
86
|
-
let title = null;
|
|
87
|
-
let author = null;
|
|
88
|
-
|
|
89
|
-
// 1+2) oEmbed -> mobile media info (the complete path: carousels included)
|
|
90
|
-
try {
|
|
91
|
-
const o = await fetchJson(`https://i.instagram.com/api/v1/oembed/?url=${encodeURIComponent(`https://www.instagram.com/p/${sc}/`)}`,
|
|
92
|
-
{ headers: MOBILE_HEADERS }, 15_000);
|
|
93
|
-
if (o?.media_id) {
|
|
94
|
-
author = o.author_name || null;
|
|
95
|
-
title = o.title || null;
|
|
96
|
-
const info = await fetchJson(`https://i.instagram.com/api/v1/media/${o.media_id}/info/`,
|
|
97
|
-
{ headers: MOBILE_HEADERS }, 15_000);
|
|
98
|
-
const item = info?.items?.[0];
|
|
99
|
-
if (item) {
|
|
100
|
-
if (Array.isArray(item.carousel_media) && item.carousel_media.length) {
|
|
101
|
-
// ALL slides, in post order — never truncate
|
|
102
|
-
item.carousel_media.forEach((c) => pushMobileItem(c, media));
|
|
103
|
-
} else {
|
|
104
|
-
pushMobileItem(item, media);
|
|
105
|
-
}
|
|
106
|
-
title = title || item.caption?.text?.slice(0, 200) || null;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
} catch (e) {
|
|
110
|
-
attempt.note = `mobile: ${String(e.message).slice(0, 60)}`;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// 3) embed page's embedded contextJSON
|
|
114
|
-
if (!media.length) {
|
|
115
|
-
try {
|
|
116
|
-
const r = await fetchText(`https://www.instagram.com/p/${sc}/embed/captioned/`, { headers: EMBED_HEADERS }, 18_000);
|
|
117
|
-
const init = r.text.match(/"init",\[\],\[(.*?)\]\],/);
|
|
118
|
-
if (init) {
|
|
119
|
-
const arr = JSON.parse(`[${init[1]}]`);
|
|
120
|
-
const ctxJson = arr.map((x) => x?.contextJSON).find(Boolean);
|
|
121
|
-
if (ctxJson) {
|
|
122
|
-
const d = JSON.parse(ctxJson);
|
|
123
|
-
const m = d?.media || d;
|
|
124
|
-
const isVideo = Boolean(m.video_url);
|
|
125
|
-
const u = m.video_url || m.display_url;
|
|
126
|
-
if (u) media.push({
|
|
127
|
-
type: isVideo ? "video" : "image", index: 0, url: u, thumbnail: m.display_url || u,
|
|
128
|
-
mimeType: isVideo ? "video/mp4" : "image/jpeg", width: null, height: null,
|
|
129
|
-
duration: null, size: null, quality: null,
|
|
130
|
-
hasAudio: isVideo, hasVideo: isVideo, source: name, variants: [],
|
|
131
|
-
});
|
|
132
|
-
title = title || m.caption?.text?.slice(0, 200) || null;
|
|
133
|
-
author = author || m.owner?.username || null;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
} catch (e) {
|
|
137
|
-
attempt.note = `${attempt.note ? attempt.note + " · " : ""}embed: ${String(e.message).slice(0, 60)}`;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// 4) og: meta (official share tags)
|
|
142
|
-
if (!media.length) {
|
|
143
|
-
try {
|
|
144
|
-
const r = await fetchText(`https://www.instagram.com/p/${sc}/`, { headers: EMBED_HEADERS }, 18_000);
|
|
145
|
-
const ogv = (r.text.match(/property="og:video"[^>]+content="([^"]+)"/) || r.text.match(/content="([^"]+)"[^>]+property="og:video"/) || [])[1];
|
|
146
|
-
const ogi = (r.text.match(/property="og:image"[^>]+content="([^"]+)"/) || r.text.match(/content="([^"]+)"[^>]+property="og:image"/) || [])[1];
|
|
147
|
-
const u = ogv || ogi;
|
|
148
|
-
if (u) media.push({
|
|
149
|
-
type: ogv ? "video" : "image", index: 0, url: u.replace(/&/g, "&"), thumbnail: (ogi || u).replace(/&/g, "&"),
|
|
150
|
-
mimeType: ogv ? "video/mp4" : "image/jpeg", width: null, height: null,
|
|
151
|
-
duration: null, size: null, quality: null,
|
|
152
|
-
hasAudio: Boolean(ogv), hasVideo: Boolean(ogv), source: name, variants: [],
|
|
153
|
-
});
|
|
154
|
-
} catch (e) {
|
|
155
|
-
attempt.note = `${attempt.note ? attempt.note + " · " : ""}og: ${String(e.message).slice(0, 60)}`;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (!media.length) {
|
|
160
|
-
attempt.status = "failed";
|
|
161
|
-
attempt.error = attempt.note || "no media exposed (private, removed, or login-gated)";
|
|
162
|
-
throw new UMediaError(Codes.MEDIA_NOT_FOUND,
|
|
163
|
-
`Instagram exposes no media for this URL (${attempt.error}) — the yt-dlp tier covers gated posts with cookies.`, { attempts: [attempt] });
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
attempt.status = "success";
|
|
167
|
-
attempt.latencyMs = Date.now() - t0;
|
|
168
|
-
return {
|
|
169
|
-
result: {
|
|
170
|
-
sourceUrl: url,
|
|
171
|
-
platform: name,
|
|
172
|
-
title: title || null,
|
|
173
|
-
author: author || null,
|
|
174
|
-
thumbnail: media[0].thumbnail,
|
|
175
|
-
itemCount: media.length,
|
|
176
|
-
counts: {
|
|
177
|
-
images: media.filter((m) => m.type === "image").length,
|
|
178
|
-
videos: media.filter((m) => m.type === "video").length,
|
|
179
|
-
audios: 0, other: 0,
|
|
180
|
-
},
|
|
181
|
-
truncated: false,
|
|
182
|
-
media,
|
|
183
|
-
streamUrlsAvailable: true,
|
|
184
|
-
},
|
|
185
|
-
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export async function search() {
|
|
190
|
-
return [];
|
|
191
|
-
}
|
package/lib/providers/itunes.js
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { fetchJson } from "../util.js";
|
|
2
|
-
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
-
|
|
4
|
-
/** iTunes Search API — reliable everywhere. Music leads with REAL 30s previews. */
|
|
5
|
-
export const name = "itunes";
|
|
6
|
-
|
|
7
|
-
export async function search({ q, type = "music", limit = 12 }, ctx = {}) {
|
|
8
|
-
const attempt = { provider: name, status: "skipped" };
|
|
9
|
-
ctx.attempts?.push(attempt);
|
|
10
|
-
if (type !== "music") {
|
|
11
|
-
attempt.error = "itunes serves music only (movie endpoint is dead upstream)";
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
const t0 = Date.now();
|
|
15
|
-
try {
|
|
16
|
-
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(q)}&media=music&entity=song&limit=${Math.min(limit, 25)}`;
|
|
17
|
-
const json = await fetchJson(url, {}, 15_000);
|
|
18
|
-
attempt.status = "success";
|
|
19
|
-
attempt.latencyMs = Date.now() - t0;
|
|
20
|
-
return (json.results || []).map((r) => ({
|
|
21
|
-
title: r.trackName,
|
|
22
|
-
author: r.artistName,
|
|
23
|
-
pageUrl: r.trackViewUrl,
|
|
24
|
-
thumbnail: (r.artworkUrl100 || "").replace("100x100bb", "600x600bb") || null,
|
|
25
|
-
previewUrl: r.previewUrl || null,
|
|
26
|
-
previewKind: r.previewUrl ? "clip30s" : "none",
|
|
27
|
-
duration: r.trackTimeMillis ? r.trackTimeMillis / 1000 : null,
|
|
28
|
-
mediaType: "music",
|
|
29
|
-
source: name,
|
|
30
|
-
explicit: Boolean(r.trackExplicitness === "explicit"),
|
|
31
|
-
}));
|
|
32
|
-
} catch (e) {
|
|
33
|
-
attempt.status = "failed";
|
|
34
|
-
attempt.error = e.message;
|
|
35
|
-
if (e instanceof UMediaError && e.code === Codes.RATE_LIMITED) throw e;
|
|
36
|
-
return [];
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** iTunes media (preview clips) resolve straight from search metadata. */
|
|
41
|
-
export async function resolve(url, ctx = {}) {
|
|
42
|
-
return { items: [], attempt: { provider: name, status: "skipped", error: "itunes media comes from search previews" } };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function canHandle() {
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
import { fetchJson, hostMatches } from "../util.js";
|
|
2
|
-
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Pinterest — public widget API (widgets.pinterest.com pidgets), the same
|
|
6
|
-
* route Pinterest's own embed widgets call. Images (all sizes), video pins
|
|
7
|
-
* (direct V_720P mp4 + HLS variants), story/idea pins. pin.it links resolve.
|
|
8
|
-
*/
|
|
9
|
-
export const name = "pinterest";
|
|
10
|
-
|
|
11
|
-
const PIN_RE = /\/pin\/(\d{6,22})/i;
|
|
12
|
-
|
|
13
|
-
export function canHandle(url) {
|
|
14
|
-
try {
|
|
15
|
-
const u = new URL(String(url));
|
|
16
|
-
return hostMatches(u.hostname, ["pinterest.com", "pin.it"]) || /^pinterest\.[a-z.]+$/.test(u.hostname.replace(/^www\./, ""));
|
|
17
|
-
} catch { return false; }
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function pinId(url) {
|
|
21
|
-
const m = String(url).match(PIN_RE);
|
|
22
|
-
return m ? m[1] : null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function followPinIt(url, ctx) {
|
|
26
|
-
// pin.it short links 30x to the canonical /pin/{id}/ URL
|
|
27
|
-
const ctl = AbortSignal.timeout?.(15_000);
|
|
28
|
-
const res = await fetch(url, { headers: { "user-agent": "Mozilla/5.0" }, redirect: "follow", signal: ctl });
|
|
29
|
-
const final = res.url || "";
|
|
30
|
-
const id = pinId(final);
|
|
31
|
-
if (id) return id;
|
|
32
|
-
const body = await res.text().catch(() => "");
|
|
33
|
-
return pinId(body) || pinId(final);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function mediaFromPin(p, media, ctx) {
|
|
37
|
-
const imgs = p.images || {};
|
|
38
|
-
// image(s): prefer orig, else the largest available size — all sizes kept as variants
|
|
39
|
-
const sizeKeys = Object.keys(imgs).sort((a, b) => {
|
|
40
|
-
const n = (k) => parseInt((imgs[k]?.width || k.match(/\d+/)?.[0] || 0), 10);
|
|
41
|
-
return n(b) - n(a);
|
|
42
|
-
});
|
|
43
|
-
const variants = sizeKeys
|
|
44
|
-
.filter((k) => imgs[k]?.url)
|
|
45
|
-
.map((k) => ({ height: imgs[k]?.height ?? null, width: imgs[k]?.width ?? null, url: imgs[k].url, hasAudio: false, hasVideo: false, quality: k }));
|
|
46
|
-
|
|
47
|
-
const vl = p.videos?.video_list || {};
|
|
48
|
-
const vKeys = Object.keys(vl);
|
|
49
|
-
if (vKeys.length) {
|
|
50
|
-
// video pin: direct mp4 first (V_720P / V_EXP7 / EXP), HLS as variants
|
|
51
|
-
const direct = vl.V_720P || vl.V_EXP7 || vl.V_EXP4 || null;
|
|
52
|
-
const vVariants = vKeys.map((k) => ({
|
|
53
|
-
height: vl[k]?.height ?? null, width: vl[k]?.width ?? null, url: vl[k].url,
|
|
54
|
-
hasAudio: true, hasVideo: true, quality: k,
|
|
55
|
-
mimeType: /\.m3u8/.test(vl[k]?.url || "") ? "application/x-mpegURL" : "video/mp4",
|
|
56
|
-
})).sort((a, b) => {
|
|
57
|
-
// direct mp4 first at equal height — never prefer an HLS manifest over a real file
|
|
58
|
-
const aMp4 = a.mimeType === "video/mp4" ? 1 : 0;
|
|
59
|
-
const bMp4 = b.mimeType === "video/mp4" ? 1 : 0;
|
|
60
|
-
return (b.height || 0) - (a.height || 0) || bMp4 - aMp4;
|
|
61
|
-
});
|
|
62
|
-
const chosen = direct || vVariants.find((v) => v.mimeType === "video/mp4") || vVariants[0];
|
|
63
|
-
media.push({
|
|
64
|
-
type: "video", index: media.length, url: chosen.url,
|
|
65
|
-
thumbnail: chosen.thumbnail || vl[vKeys[0]]?.thumbnail || variants[0]?.url || null,
|
|
66
|
-
mimeType: /\.m3u8/.test(chosen.url) ? "application/x-mpegURL" : "video/mp4",
|
|
67
|
-
width: chosen.width ?? null, height: chosen.height ?? null,
|
|
68
|
-
duration: vl[vKeys[0]]?.duration ? Math.round(vl[vKeys[0]].duration / 1000) : null,
|
|
69
|
-
size: null, quality: chosen.height ? `${chosen.height}p` : null,
|
|
70
|
-
hasAudio: true, hasVideo: true, source: name,
|
|
71
|
-
variants: vVariants.filter((v) => v.url),
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
if (!p.videos?.video_list && variants.length) {
|
|
75
|
-
media.push({
|
|
76
|
-
type: "image", index: media.length, url: variants[0].url,
|
|
77
|
-
thumbnail: variants[0].url, mimeType: "image/jpeg",
|
|
78
|
-
width: variants[0].width ?? null, height: variants[0].height ?? null,
|
|
79
|
-
duration: null, size: null, quality: variants[0].quality,
|
|
80
|
-
hasAudio: false, hasVideo: false, source: name, variants,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// story / idea pins: one media per page, in order — never silently truncate
|
|
85
|
-
const pages = p.story_pin_data?.pages || [];
|
|
86
|
-
pages.forEach((pg) => {
|
|
87
|
-
const blocks = pg.blocks || [];
|
|
88
|
-
for (const b of blocks) {
|
|
89
|
-
const vid = b.video?.video_list ? Object.values(b.video.video_list)[0] : null;
|
|
90
|
-
const img = b.image && (b.image.images?.orig?.url || b.image.images?.["564x"]?.url);
|
|
91
|
-
if (vid?.url) {
|
|
92
|
-
media.push({
|
|
93
|
-
type: "video", index: media.length, url: vid.url, thumbnail: vid.thumbnail ?? null,
|
|
94
|
-
mimeType: /\.m3u8/.test(vid.url) ? "application/x-mpegURL" : "video/mp4",
|
|
95
|
-
width: vid.width ?? null, height: vid.height ?? null,
|
|
96
|
-
duration: vid.duration ? Math.round(vid.duration / 1000) : null, size: null,
|
|
97
|
-
quality: vid.height ? `${vid.height}p` : null,
|
|
98
|
-
hasAudio: true, hasVideo: true, source: name, variants: [],
|
|
99
|
-
});
|
|
100
|
-
} else if (img) {
|
|
101
|
-
media.push({
|
|
102
|
-
type: "image", index: media.length, url: img, thumbnail: img, mimeType: "image/jpeg",
|
|
103
|
-
width: null, height: null, duration: null, size: null, quality: null,
|
|
104
|
-
hasAudio: false, hasVideo: false, source: name, variants: [],
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export async function resolve(url, ctx = {}) {
|
|
112
|
-
const attempt = { provider: name, status: "skipped" };
|
|
113
|
-
ctx.attempts?.push(attempt);
|
|
114
|
-
const t0 = Date.now();
|
|
115
|
-
|
|
116
|
-
let id = pinId(url);
|
|
117
|
-
if (!id && /pin\.it\//i.test(String(url))) {
|
|
118
|
-
try { id = await followPinIt(url, ctx); } catch (e) { attempt.error = e.message; }
|
|
119
|
-
}
|
|
120
|
-
if (!id) {
|
|
121
|
-
attempt.status = "failed";
|
|
122
|
-
throw new UMediaError(Codes.INVALID_URL,
|
|
123
|
-
"Not a pin URL — expected pinterest.* /pin/{id} or a pin.it share link", { attempts: [attempt] });
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// primary: public widget API (the route Pinterest's own embeds use)
|
|
127
|
-
let pin = null;
|
|
128
|
-
try {
|
|
129
|
-
const j = await fetchJson(
|
|
130
|
-
`https://widgets.pinterest.com/v3/pidgets/pins/info/?pin_ids=${id}`, {}, 15_000);
|
|
131
|
-
const row = (j?.data || [])[0];
|
|
132
|
-
if (row && !row.error) pin = row;
|
|
133
|
-
else attempt.error = row?.error || "pin not found (deleted or secret board)";
|
|
134
|
-
} catch (e) { attempt.error = e.message; }
|
|
135
|
-
|
|
136
|
-
// fallback: Pinterest's own PinResource endpoint
|
|
137
|
-
if (!pin) {
|
|
138
|
-
try {
|
|
139
|
-
const data = encodeURIComponent(JSON.stringify({ options: { id, field_set_key: "auth_web_main_pin", is_buyable_pin: false }, context: {} }));
|
|
140
|
-
const j = await fetchJson(
|
|
141
|
-
`https://www.pinterest.com/resource/PinResource/get/?source_url=/pin/${id}/&data=${data}`,
|
|
142
|
-
{ headers: { "x-pinterest-app-state": "active", accept: "application/json" } }, 15_000);
|
|
143
|
-
if (j?.resource_response?.data) pin = j.resource_response.data;
|
|
144
|
-
else attempt.error = j?.resource_response?.error?.message || attempt.error;
|
|
145
|
-
} catch (e) { attempt.error = e.message; }
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const media = [];
|
|
149
|
-
if (pin) mediaFromPin(pin, media, ctx);
|
|
150
|
-
|
|
151
|
-
if (!media.length) {
|
|
152
|
-
attempt.status = "failed";
|
|
153
|
-
throw new UMediaError(Codes.MEDIA_NOT_FOUND,
|
|
154
|
-
`Pinterest pin has no retrievable media. ${attempt.error || ""}`.trim(), { attempts: [attempt] });
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
attempt.status = "success";
|
|
158
|
-
attempt.latencyMs = Date.now() - t0;
|
|
159
|
-
return {
|
|
160
|
-
result: {
|
|
161
|
-
sourceUrl: url,
|
|
162
|
-
platform: name,
|
|
163
|
-
title: pin?.grid_title || pin?.description || pin?.title || null,
|
|
164
|
-
author: pin?.pinner?.username || pin?.native_creator?.username || null,
|
|
165
|
-
thumbnail: pin?.images?.orig?.url || pin?.images?.["564x"]?.url || media[0].thumbnail,
|
|
166
|
-
itemCount: media.length,
|
|
167
|
-
counts: {
|
|
168
|
-
images: media.filter((m) => m.type === "image").length,
|
|
169
|
-
videos: media.filter((m) => m.type === "video").length,
|
|
170
|
-
audios: 0, other: 0,
|
|
171
|
-
},
|
|
172
|
-
truncated: false,
|
|
173
|
-
media,
|
|
174
|
-
},
|
|
175
|
-
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export async function search() {
|
|
180
|
-
return [];
|
|
181
|
-
}
|
package/lib/providers/reddit.js
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { fetchJson, UA, hostMatches } from "../util.js";
|
|
2
|
-
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
-
|
|
4
|
-
/** Reddit — public JSON endpoints. Datacenter IPs may get 403; the error is honest. */
|
|
5
|
-
export const name = "reddit";
|
|
6
|
-
|
|
7
|
-
export function canHandle(url) {
|
|
8
|
-
try {
|
|
9
|
-
return hostMatches(new URL(String(url)).hostname, ["reddit.com", "redd.it"]);
|
|
10
|
-
} catch { return false; }
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export async function resolve(url, ctx = {}) {
|
|
14
|
-
const attempt = { provider: name, status: "skipped" };
|
|
15
|
-
ctx.attempts?.push(attempt);
|
|
16
|
-
const t0 = Date.now();
|
|
17
|
-
let base = String(url).split("?")[0].replace(/\/$/, "");
|
|
18
|
-
|
|
19
|
-
// short links: /video/{id} and /r/{sub}/s/{shareId} redirect to the full permalink
|
|
20
|
-
if (/reddit\.com\/video\/[\w-]+/i.test(base) || /\/s\/[\w-]+/i.test(base)) {
|
|
21
|
-
try {
|
|
22
|
-
const res = await fetch(base, { headers: { "user-agent": UA + " umedia-sdk" }, redirect: "follow" });
|
|
23
|
-
if (res.url && /comments\//.test(res.url)) base = res.url.split("?")[0].replace(/\/$/, "");
|
|
24
|
-
} catch { /* keep original */ }
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Reddit edge-gates datacenter IPs per-host — try the alternate public edges, honestly.
|
|
28
|
-
async function fetchRedditJson() {
|
|
29
|
-
const path = base.replace(/^https?:\/\/[^/]+/, "");
|
|
30
|
-
const hosts = [...new Set([
|
|
31
|
-
new URL(base).host,
|
|
32
|
-
"old.reddit.com", "api.reddit.com", "www.reddit.com",
|
|
33
|
-
])];
|
|
34
|
-
let lastErr = null;
|
|
35
|
-
for (const host of hosts) {
|
|
36
|
-
try {
|
|
37
|
-
return await fetchJson(`https://${host}${path}.json?raw_json=1`, { headers: { "user-agent": UA + " umedia-sdk" } }, 15_000);
|
|
38
|
-
} catch (e) { lastErr = e; }
|
|
39
|
-
}
|
|
40
|
-
throw lastErr;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
const json = await fetchRedditJson();
|
|
45
|
-
const post = json?.[0]?.data?.children?.[0]?.data;
|
|
46
|
-
if (!post) throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Reddit post not found");
|
|
47
|
-
const media = [];
|
|
48
|
-
const add = (type, u, extra = {}) => u && media.push({
|
|
49
|
-
type, index: media.length, url: u, thumbnail: extra.thumbnail ?? null,
|
|
50
|
-
mimeType: extra.mimeType ?? null, width: extra.width ?? null, height: extra.height ?? null,
|
|
51
|
-
duration: extra.duration ?? null, size: null, quality: extra.quality ?? null,
|
|
52
|
-
hasAudio: type === "video", hasVideo: type === "video", source: name, variants: [],
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
const rv = post.secure_media?.reddit_video || post.media?.reddit_video;
|
|
56
|
-
if (rv) {
|
|
57
|
-
add("video", rv.fallback_url, {
|
|
58
|
-
width: rv.width, height: rv.height, duration: rv.duration,
|
|
59
|
-
quality: rv.height ? `${rv.height}p` : null, mimeType: "video/mp4",
|
|
60
|
-
thumbnail: post.preview?.images?.[0]?.source?.url?.replace(/&/g, "&") ?? null,
|
|
61
|
-
});
|
|
62
|
-
// reddit serves audio as a SIDE CAR file — fetch it too so downloads are complete
|
|
63
|
-
const vidUrl = rv.fallback_url.split("?")[0];
|
|
64
|
-
const audioCandidates = [
|
|
65
|
-
vidUrl.includes(".mp4") ? `${vidUrl.split("_")[0]}_audio.mp4` : null,
|
|
66
|
-
vidUrl.replace(/DASH[^/]*\.mp4/, "DASH_audio"),
|
|
67
|
-
`${vidUrl.split("_")[0]}_AUDIO_128.mp4`,
|
|
68
|
-
].filter(Boolean);
|
|
69
|
-
for (const au of audioCandidates) {
|
|
70
|
-
try {
|
|
71
|
-
const head = await fetch(au, { method: "HEAD", headers: { "user-agent": UA + " umedia-sdk" }, redirect: "follow" });
|
|
72
|
-
if (head.ok && Number(head.headers.get("content-length") || 1) > 0) {
|
|
73
|
-
add("audio", au, { mimeType: "audio/mp4", quality: "audio" });
|
|
74
|
-
break;
|
|
75
|
-
}
|
|
76
|
-
} catch { /* try the next candidate */ }
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (post.gallery_data?.items && post.media_metadata) {
|
|
81
|
-
for (const it of post.gallery_data.items) {
|
|
82
|
-
const m = post.media_metadata[it.media_id];
|
|
83
|
-
const src = m?.s;
|
|
84
|
-
const u = src?.u?.replace(/&/g, "&") || src?.gif || src?.mp4;
|
|
85
|
-
if (u) add(m?.e === "AnimatedImage" ? "gif" : "image", u, {
|
|
86
|
-
width: src?.x, height: src?.y, mimeType: m?.m ?? null,
|
|
87
|
-
quality: src?.x && src?.y ? `${src.x}x${src.y}` : null,
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
if (!media.length && post.preview?.images?.length) {
|
|
92
|
-
add("image", post.preview.images[0].source.url.replace(/&/g, "&"), {
|
|
93
|
-
width: post.preview.images[0].source.width, height: post.preview.images[0].source.height,
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
if (!media.length) throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Reddit post has no retrievable media");
|
|
97
|
-
|
|
98
|
-
attempt.status = "success";
|
|
99
|
-
attempt.latencyMs = Date.now() - t0;
|
|
100
|
-
return {
|
|
101
|
-
result: {
|
|
102
|
-
sourceUrl: url, platform: "reddit", title: post.title ?? null, author: post.author ?? null,
|
|
103
|
-
thumbnail: media[0].thumbnail ?? null, itemCount: media.length,
|
|
104
|
-
counts: {
|
|
105
|
-
images: media.filter((m) => m.type === "image").length,
|
|
106
|
-
videos: media.filter((m) => m.type === "video").length,
|
|
107
|
-
audios: 0, other: media.filter((m) => m.type === "gif").length,
|
|
108
|
-
},
|
|
109
|
-
truncated: false, media,
|
|
110
|
-
},
|
|
111
|
-
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
112
|
-
};
|
|
113
|
-
} catch (e) {
|
|
114
|
-
attempt.status = "failed";
|
|
115
|
-
attempt.error = e.message;
|
|
116
|
-
if (e instanceof UMediaError) throw e;
|
|
117
|
-
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Reddit unavailable: ${e.message}`, { attempts: [attempt] });
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export async function search() {
|
|
122
|
-
return [];
|
|
123
|
-
}
|
package/lib/providers/rumble.js
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { fetchJson, hostMatches } from "../util.js";
|
|
2
|
-
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Rumble — the public embed JSON (rumble.com/embedJS/u3/?request=video&ver=2&v={id}),
|
|
6
|
-
* the same route Rumble's own embed player calls: direct mp4 renditions with real
|
|
7
|
-
* w/h/bitrate + HLS + audio. Reference: yt-dlp rumble extractor.
|
|
8
|
-
*/
|
|
9
|
-
export const name = "rumble";
|
|
10
|
-
|
|
11
|
-
export function canHandle(url) {
|
|
12
|
-
try {
|
|
13
|
-
return hostMatches(new URL(String(url)).hostname, ["rumble.com"]);
|
|
14
|
-
} catch { return false; }
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function videoId(url) {
|
|
18
|
-
const u = String(url);
|
|
19
|
-
// /embed/{id} | /v{id}-{slug}.html | /v{id} | /{id}.html (embed ids)
|
|
20
|
-
const m = u.match(/\/embed\/(?:[\w-]+\.)?([\w-]+)/) || u.match(/\/(v[\w]+)[-.]/) || u.match(/\/(v[\w]+)\/?$/) || u.match(/\/([\w]+)\.html/);
|
|
21
|
-
return m ? m[1] : null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export async function resolve(url, ctx = {}) {
|
|
25
|
-
const attempt = { provider: name, status: "skipped" };
|
|
26
|
-
ctx.attempts?.push(attempt);
|
|
27
|
-
const t0 = Date.now();
|
|
28
|
-
|
|
29
|
-
const id = videoId(url);
|
|
30
|
-
if (!id) {
|
|
31
|
-
attempt.status = "failed";
|
|
32
|
-
throw new UMediaError(Codes.INVALID_URL, "Not a Rumble video URL", { attempts: [attempt] });
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
let v;
|
|
36
|
-
try {
|
|
37
|
-
v = await fetchJson(`https://rumble.com/embedJS/u3/?request=video&ver=2&v=${encodeURIComponent(id)}`, {}, 15_000);
|
|
38
|
-
} catch (e) {
|
|
39
|
-
attempt.status = "failed";
|
|
40
|
-
attempt.error = e.message;
|
|
41
|
-
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Rumble embed API unavailable. ${e.message}`, { attempts: [attempt] });
|
|
42
|
-
}
|
|
43
|
-
if (v?.sys?.msg || !v?.ua) {
|
|
44
|
-
attempt.status = "failed";
|
|
45
|
-
attempt.error = v?.sys?.msg || "video not found";
|
|
46
|
-
throw new UMediaError(Codes.MEDIA_NOT_FOUND, `Rumble: ${attempt.error}`, { attempts: [attempt] });
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ua = { mp4: {...height-keyed or list}, hls: [...], audio: [...], timeline: [...] }
|
|
50
|
-
const variants = [];
|
|
51
|
-
const addList = (type, list, heightKeyed) => {
|
|
52
|
-
const entries = heightKeyed ? Object.entries(list).map(([h, info]) => [h, info]) : (list || []).map((info) => [info?.meta?.h, info]);
|
|
53
|
-
for (const [h, info] of entries) {
|
|
54
|
-
if (!info?.url) continue;
|
|
55
|
-
if (type === "tar" || type === "timeline") continue;
|
|
56
|
-
const isHls = type === "hls" || /\.m3u8/.test(info.url);
|
|
57
|
-
variants.push({
|
|
58
|
-
height: parseInt(h ?? info?.meta?.h, 10) || info?.meta?.h || null,
|
|
59
|
-
width: info?.meta?.w ?? null,
|
|
60
|
-
url: info.url,
|
|
61
|
-
hasAudio: type !== "audio", hasVideo: type !== "audio",
|
|
62
|
-
quality: h ? `${h}p` : (type === "audio" ? "audio" : type),
|
|
63
|
-
mimeType: type === "audio" ? "audio/mpeg" : isHls ? "application/x-mpegURL" : "video/mp4",
|
|
64
|
-
bitrate: info?.meta?.bitrate ?? null,
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
};
|
|
68
|
-
for (const [type, formatInfo] of Object.entries(v.ua || {})) {
|
|
69
|
-
if (type === "tar") continue;
|
|
70
|
-
addList(type, formatInfo, !Array.isArray(formatInfo));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const progressive = variants.filter((x) => /mp4/.test(x.mimeType || "") || (!/m3u8|mpegurl/i.test(x.url + (x.mimeType || "")) && x.hasVideo));
|
|
74
|
-
const chosen = progressive.sort((a, b) => (b.height || 0) - (a.height || 0))[0] || variants[0];
|
|
75
|
-
if (!chosen) {
|
|
76
|
-
attempt.status = "failed";
|
|
77
|
-
throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Rumble: no downloadable renditions", { attempts: [attempt] });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
attempt.status = "success";
|
|
81
|
-
attempt.latencyMs = Date.now() - t0;
|
|
82
|
-
return {
|
|
83
|
-
result: {
|
|
84
|
-
sourceUrl: url,
|
|
85
|
-
platform: name,
|
|
86
|
-
title: v.title || null,
|
|
87
|
-
author: v.author?.name || null,
|
|
88
|
-
thumbnail: Array.isArray(v.t) ? v.t[v.t.length - 1]?.i : v.t?.i || null,
|
|
89
|
-
itemCount: 1,
|
|
90
|
-
counts: { images: 0, videos: 1, audios: 0, other: 0 },
|
|
91
|
-
truncated: false,
|
|
92
|
-
media: [{
|
|
93
|
-
type: chosen.hasVideo ? "video" : "audio", index: 0, url: chosen.url,
|
|
94
|
-
thumbnail: Array.isArray(v.t) ? v.t[v.t.length - 1]?.i : null,
|
|
95
|
-
mimeType: chosen.mimeType,
|
|
96
|
-
width: chosen.width ?? null, height: chosen.height ?? null,
|
|
97
|
-
duration: v.duration ?? null, size: null,
|
|
98
|
-
quality: chosen.quality,
|
|
99
|
-
hasAudio: true, hasVideo: chosen.hasVideo !== false, source: name,
|
|
100
|
-
variants: variants.filter((x) => x.url),
|
|
101
|
-
}],
|
|
102
|
-
streamUrlsAvailable: true,
|
|
103
|
-
},
|
|
104
|
-
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export async function search() {
|
|
109
|
-
return [];
|
|
110
|
-
}
|