umedia 0.2.1 → 0.3.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/README.md +9 -1
- package/lib/download.js +80 -6
- package/lib/providers/bluesky.js +118 -0
- package/lib/providers/dailymotion.js +100 -0
- package/lib/providers/ogembed.js +111 -0
- package/lib/providers/pinterest.js +181 -0
- package/lib/providers/soundcloud.js +138 -0
- package/lib/providers/streamable.js +93 -0
- package/lib/providers/twitch.js +108 -0
- package/lib/providers/vimeo.js +108 -0
- package/lib/registry.js +31 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
No hosted API. No API keys. No rate limits. **No server bill.**
|
|
10
10
|
|
|
11
|
-
YouTube · TikTok · Instagram · Reddit · X · iTunes · 1800+ sites (via the optional yt-dlp tier)
|
|
11
|
+
YouTube · TikTok · Pinterest · SoundCloud · Dailymotion · Vimeo · Streamable · Twitch · Bluesky · Instagram · Reddit · X · Facebook · iTunes · 1800+ sites (via the optional yt-dlp tier)
|
|
12
12
|
|
|
13
13
|
[](https://www.npmjs.com/package/umedia)
|
|
14
14
|
[](https://nodejs.org)
|
|
@@ -76,9 +76,17 @@ trailers, social posts with official embeds. `previewKind: "none"` beats a fake
|
|
|
76
76
|
|---|---|---|---|---|
|
|
77
77
|
| **YouTube** | ✅ | ✅ | ✅ * | InnerTube — search & metadata always; stream URLs depend on your network (see below) |
|
|
78
78
|
| **TikTok** | — | ✅ | ✅ | video posts = direct CDN mp4; photo galleries = every slide the platform publicly exposes, `truncated: true` when it degrades galleries |
|
|
79
|
+
| **Pinterest** | — | ✅ | ✅ | image pins, video pins (direct mp4 + HLS variants), idea/story pins, `pin.it` short links |
|
|
80
|
+
| **SoundCloud** | — | ✅ | ✅ | full tracks as direct progressive MP3 (+ HLS) with real titles/artists |
|
|
81
|
+
| **Dailymotion** | — | ✅ | ✅ | public player metadata — HLS/progressive renditions (HLS saved as `.ts`) |
|
|
82
|
+
| **Vimeo** | — | ✅ | ✅ | player config — progressive mp4 when served, HLS otherwise |
|
|
83
|
+
| **Streamable** | — | ✅ | ✅ | direct mp4 renditions |
|
|
84
|
+
| **Twitch** | — | ✅ | ✅ | clips = direct CloudFront mp4s; VODs/livestreams via the yt-dlp tier |
|
|
85
|
+
| **Bluesky** | — | ✅ | ✅ | public AppView API — full-size images + video playlists |
|
|
79
86
|
| **Instagram** | — | ✅ best-effort | ✅ | public embeds (gated on some networks — the yt-dlp tier is the reliable path) |
|
|
80
87
|
| **Reddit** | — | ✅ best-effort | ✅ | public JSON across reddit edges (datacenter IPs often 403 — typed honest error) |
|
|
81
88
|
| **X** | — | ✅ best-effort | ✅ | public syndication (media login-gated on some networks — typed honest error) |
|
|
89
|
+
| **Facebook · Threads · Snapchat · Rumble · Tumblr** | — | ✅ best-effort | ✅ best-effort | official Open Graph media on their share pages; often login-gated on datacenter IPs |
|
|
82
90
|
| **iTunes** | ✅ | — | ✅ previews | real 30s clips, rock solid |
|
|
83
91
|
| **1800+ sites** | — | ✅ | ✅ | optional tier: `pip install yt-dlp` — labeled `source: "ytdlp"` |
|
|
84
92
|
|
package/lib/download.js
CHANGED
|
@@ -5,7 +5,7 @@ import { pipeline } from "node:stream/promises";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { zipSync } from "fflate";
|
|
7
7
|
import { UMediaError, Codes } from "./errors.js";
|
|
8
|
-
import { UA, cleanName, rid } from "./util.js";
|
|
8
|
+
import { UA, cleanName, rid, validateUrl } from "./util.js";
|
|
9
9
|
import { pickFormat } from "./quality.js";
|
|
10
10
|
import * as registry from "./registry.js";
|
|
11
11
|
import * as ytdlp from "./providers/ytdlp.js";
|
|
@@ -27,6 +27,66 @@ async function fetchToFile(url, dest, onProgress) {
|
|
|
27
27
|
return s.size;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* HLS (.m3u8) -> real file on disk: pick a variant by its REAL RESOLUTION label,
|
|
32
|
+
* fetch segments in order, concat to MPEG-TS (playable, no ffmpeg needed).
|
|
33
|
+
* Video-only (separate audio) or fMP4 streams fail honestly and point at yt-dlp.
|
|
34
|
+
* @returns {Promise<{size:number, height:number|null}>}
|
|
35
|
+
*/
|
|
36
|
+
async function downloadHls(url, dest, onProgress) {
|
|
37
|
+
const getText = async (u) => {
|
|
38
|
+
const res = await fetch(u, { headers: { "user-agent": UA }, redirect: "follow" });
|
|
39
|
+
if (!res.ok) throw new UMediaError(Codes.PROVIDER_ERROR, `HTTP ${res.status} fetching HLS manifest`);
|
|
40
|
+
return res.text();
|
|
41
|
+
};
|
|
42
|
+
let manifest = await getText(url);
|
|
43
|
+
let base = url;
|
|
44
|
+
let pickedHeight = null;
|
|
45
|
+
|
|
46
|
+
if (manifest.includes("#EXT-X-STREAM-INF")) {
|
|
47
|
+
const lines = manifest.split(/\r?\n/);
|
|
48
|
+
let best = null;
|
|
49
|
+
for (let i = 0; i < lines.length; i++) {
|
|
50
|
+
if (!lines[i].startsWith("#EXT-X-STREAM-INF")) continue;
|
|
51
|
+
const res = /RESOLUTION=(\d+)x(\d+)/.exec(lines[i]);
|
|
52
|
+
const codecs = /CODECS="([^"]+)"/.exec(lines[i])?.[1] || "";
|
|
53
|
+
const h = res ? parseInt(res[2], 10) : 0;
|
|
54
|
+
const urlLine = lines.slice(i + 1).find((l) => l && !l.startsWith("#"));
|
|
55
|
+
if (!urlLine) continue;
|
|
56
|
+
if (!best || h > best.h) best = { url: new URL(urlLine, base).href, codecs, h };
|
|
57
|
+
}
|
|
58
|
+
if (!best) throw new UMediaError(Codes.PROVIDER_ERROR, "HLS master has no variants");
|
|
59
|
+
if (best.codecs && !/mp4a|opus|ac-3|ec-3/.test(best.codecs)) {
|
|
60
|
+
throw new UMediaError(Codes.QUALITY_UNAVAILABLE,
|
|
61
|
+
"HLS variant is video-only (separate audio track) — muxing needs the yt-dlp tier: downloadWithYtdlp()");
|
|
62
|
+
}
|
|
63
|
+
pickedHeight = best.h || null;
|
|
64
|
+
base = best.url;
|
|
65
|
+
manifest = await getText(base);
|
|
66
|
+
}
|
|
67
|
+
if (manifest.includes("#EXT-X-MAP")) {
|
|
68
|
+
throw new UMediaError(Codes.QUALITY_UNAVAILABLE,
|
|
69
|
+
"fMP4 HLS stream — single-file muxing needs the yt-dlp tier: downloadWithYtdlp()");
|
|
70
|
+
}
|
|
71
|
+
const segs = manifest.split(/\r?\n/).filter((l) => l && !l.startsWith("#")).map((l) => new URL(l, base).href);
|
|
72
|
+
if (!segs.length) throw new UMediaError(Codes.PROVIDER_ERROR, "HLS media playlist has no segments");
|
|
73
|
+
|
|
74
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
75
|
+
const ws = createWriteStream(dest);
|
|
76
|
+
let done = 0;
|
|
77
|
+
for (const seg of segs) {
|
|
78
|
+
const res = await fetch(seg, { headers: { "user-agent": UA }, redirect: "follow" });
|
|
79
|
+
if (!res.ok) throw new UMediaError(Codes.PROVIDER_ERROR, `HTTP ${res.status} fetching HLS segment`);
|
|
80
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
81
|
+
await new Promise((r, j) => ws.write(buf, (e) => (e ? j(e) : r())));
|
|
82
|
+
done += 1;
|
|
83
|
+
onProgress?.(done, segs.length);
|
|
84
|
+
}
|
|
85
|
+
await new Promise((r) => ws.end(r));
|
|
86
|
+
const st = await stat(dest);
|
|
87
|
+
return { size: st.size, height: pickedHeight };
|
|
88
|
+
}
|
|
89
|
+
|
|
30
90
|
/**
|
|
31
91
|
* Download every media item of a URL to disk — order preserved, failures named.
|
|
32
92
|
* Direct media URLs (clips, images) download as a single item — no page-resolve needed.
|
|
@@ -38,6 +98,9 @@ export async function download({ url, quality = "best", type = "video", dir = ".
|
|
|
38
98
|
const requestId = rid();
|
|
39
99
|
const t0 = Date.now();
|
|
40
100
|
|
|
101
|
+
// SSRF hygiene FIRST — user-supplied URLs must never reach private/internal hosts
|
|
102
|
+
validateUrl(url);
|
|
103
|
+
|
|
41
104
|
let resolved, engine;
|
|
42
105
|
if (MEDIA_EXT.test(String(url).split("#")[0])) {
|
|
43
106
|
const ext = (String(url).match(MEDIA_EXT) || [])[1].toLowerCase();
|
|
@@ -79,14 +142,24 @@ export async function download({ url, quality = "best", type = "video", dir = ".
|
|
|
79
142
|
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE,
|
|
80
143
|
"no direct media URL available on this network (bot-gated) — install yt-dlp for hard networks");
|
|
81
144
|
}
|
|
145
|
+
validateUrl(chosenUrl); // never fetch private/internal targets, even from a provider response
|
|
82
146
|
if (!selectedQuality) { selectedQuality = pick.selectedQuality; fallback = pick.fallback; }
|
|
83
147
|
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
148
|
+
const isHls = /\.m3u8$/i.test(chosenUrl.split(/[?#]/)[0]);
|
|
149
|
+
const ext = isHls ? "ts" : ((chosenUrl.split("?")[0].match(/\.(mp4|webm|m4a|mp3|jpg|jpeg|png|gif|webp)$/) || [])[1]
|
|
150
|
+
|| (item.type === "image" ? "jpg" : item.type === "audio" ? "m4a" : "mp4"));
|
|
151
|
+
const base = cleanName(resolved.title);
|
|
152
|
+
const name = `${String(item.index + 1).padStart(3, "0")} - ${base.toLowerCase().endsWith(`.${ext}`) ? base.slice(0, -(ext.length + 1)) : base}.${ext}`;
|
|
87
153
|
const dest = path.join(dir, name);
|
|
88
|
-
|
|
89
|
-
|
|
154
|
+
let size, hlsHeight = null;
|
|
155
|
+
if (isHls) {
|
|
156
|
+
({ size, height: hlsHeight } = await downloadHls(chosenUrl, dest, onProgress));
|
|
157
|
+
if (hlsHeight) selectedQuality = `${hlsHeight}p`; // the REAL rendition downloaded
|
|
158
|
+
} else {
|
|
159
|
+
size = await fetchToFile(chosenUrl, dest, onProgress);
|
|
160
|
+
}
|
|
161
|
+
const fileQuality = (isHls && hlsHeight) ? `${hlsHeight}p` : (pick.selectedQuality ?? item.quality ?? null);
|
|
162
|
+
files.push({ name, path: dest, size, ext, mimeType: item.mimeType ?? null, index: item.index, quality: fileQuality });
|
|
90
163
|
} catch (e) {
|
|
91
164
|
failedItems.push({ index: item.index, type: item.type, url: String(item.url || "").slice(0, 120), error: e.message });
|
|
92
165
|
}
|
|
@@ -132,6 +205,7 @@ export async function download({ url, quality = "best", type = "video", dir = ".
|
|
|
132
205
|
/** yt-dlp escape hatch for downloads on hard networks / 1800+ sites. */
|
|
133
206
|
export async function downloadWithYtdlp({ url, quality = "best", dir = "./umedia-downloads", onProgress } = {}) {
|
|
134
207
|
const requestId = rid();
|
|
208
|
+
validateUrl(url);
|
|
135
209
|
if (!ytdlp.binary()) throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, "yt-dlp not installed — `pip install yt-dlp`");
|
|
136
210
|
await mkdir(dir, { recursive: true });
|
|
137
211
|
const { spawn } = await import("node:child_process");
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { fetchJson, hostMatches } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bluesky — the PUBLIC AppView XRPC API (api.bsky.app / public.api.bsky.app):
|
|
6
|
+
* full-size images and video playlists, no auth, no key. Handles and DIDs both
|
|
7
|
+
* work in bsky.app URLs.
|
|
8
|
+
*/
|
|
9
|
+
export const name = "bluesky";
|
|
10
|
+
|
|
11
|
+
export function canHandle(url) {
|
|
12
|
+
try {
|
|
13
|
+
return hostMatches(new URL(String(url)).hostname, ["bsky.app", "bsky.social"]);
|
|
14
|
+
} catch { return false; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseUrl(url) {
|
|
18
|
+
const m = String(url).match(/bsky\.app\/profile\/([\w.\-:%]+)\/post\/([\w]+)/i);
|
|
19
|
+
return m ? { actor: decodeURIComponent(m[1]), rkey: m[2] } : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function resolve(url, ctx = {}) {
|
|
23
|
+
const attempt = { provider: name, status: "skipped" };
|
|
24
|
+
ctx.attempts?.push(attempt);
|
|
25
|
+
const t0 = Date.now();
|
|
26
|
+
|
|
27
|
+
const parsed = parseUrl(url);
|
|
28
|
+
if (!parsed) {
|
|
29
|
+
attempt.status = "failed";
|
|
30
|
+
throw new UMediaError(Codes.INVALID_URL, "Not a Bluesky post URL (expected bsky.app/profile/{handle}/post/{rkey})", { attempts: [attempt] });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// handle -> DID (posts are addressed by DID)
|
|
34
|
+
let did = parsed.actor.startsWith("did:") ? parsed.actor : null;
|
|
35
|
+
if (!did) {
|
|
36
|
+
try {
|
|
37
|
+
const prof = await fetchJson(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(parsed.actor)}`, {}, 15_000);
|
|
38
|
+
did = prof?.did;
|
|
39
|
+
} catch { /* fall through to at://handle attempt */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let post = null;
|
|
43
|
+
for (const uri of [`at://${did || parsed.actor}/app.bsky.feed.post/${parsed.rkey}`]) {
|
|
44
|
+
try {
|
|
45
|
+
const j = await fetchJson(
|
|
46
|
+
`https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}&depth=0`, {}, 15_000);
|
|
47
|
+
post = j?.thread?.post || null;
|
|
48
|
+
if (post) break;
|
|
49
|
+
} catch (e) { attempt.error = e.message; }
|
|
50
|
+
}
|
|
51
|
+
if (!post) {
|
|
52
|
+
attempt.status = "failed";
|
|
53
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, `Bluesky post not retrievable. ${attempt.error || "deleted or private"}`, { attempts: [attempt] });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const media = [];
|
|
57
|
+
const record = post.record || {};
|
|
58
|
+
const embed = post.embed || {};
|
|
59
|
+
|
|
60
|
+
// images: full-size originals, in post order (view embed carries real URLs)
|
|
61
|
+
const embType = String(embed.$type || "");
|
|
62
|
+
const mediaEmb = embed.media ? String(embed.media.$type || "") : "";
|
|
63
|
+
const images = (embType.startsWith("app.bsky.embed.images") && embed.images)
|
|
64
|
+
|| (mediaEmb.startsWith("app.bsky.embed.images") && embed.media?.images)
|
|
65
|
+
|| [];
|
|
66
|
+
for (const img of images) {
|
|
67
|
+
const u = img.fullsize || img.url;
|
|
68
|
+
if (!u) continue;
|
|
69
|
+
media.push({
|
|
70
|
+
type: "image", index: media.length, url: u, thumbnail: img.thumb || u,
|
|
71
|
+
mimeType: "image/jpeg", width: null, height: null, duration: null, size: null,
|
|
72
|
+
quality: "original", hasAudio: false, hasVideo: false, source: name, variants: [],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// video: HLS playlist (honest label) + poster
|
|
77
|
+
const vid = embType.startsWith("app.bsky.embed.video") ? embed
|
|
78
|
+
: mediaEmb.startsWith("app.bsky.embed.video") ? embed.media
|
|
79
|
+
: null;
|
|
80
|
+
const playlist = vid?.playlist || null;
|
|
81
|
+
if (playlist) {
|
|
82
|
+
media.push({
|
|
83
|
+
type: "video", index: media.length, url: playlist, thumbnail: vid?.thumbnail || null,
|
|
84
|
+
mimeType: "application/x-mpegURL", width: null, height: null, duration: null, size: null,
|
|
85
|
+
quality: "hls", hasAudio: true, hasVideo: true, source: name, variants: [],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!media.length) {
|
|
90
|
+
attempt.status = "failed";
|
|
91
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Bluesky post contains no media (text or link-only)", { attempts: [attempt] });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
attempt.status = "success";
|
|
95
|
+
attempt.latencyMs = Date.now() - t0;
|
|
96
|
+
return {
|
|
97
|
+
result: {
|
|
98
|
+
sourceUrl: url,
|
|
99
|
+
platform: name,
|
|
100
|
+
title: (record.text || "").slice(0, 200) || null,
|
|
101
|
+
author: post.author?.handle || null,
|
|
102
|
+
thumbnail: media[0].thumbnail,
|
|
103
|
+
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: 0,
|
|
108
|
+
},
|
|
109
|
+
truncated: false,
|
|
110
|
+
media,
|
|
111
|
+
},
|
|
112
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function search() {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { fetchJson, hostMatches } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dailymotion — public player metadata API (no key): title, thumbnails,
|
|
6
|
+
* HLS master + any progressive renditions the platform serves.
|
|
7
|
+
*/
|
|
8
|
+
export const name = "dailymotion";
|
|
9
|
+
|
|
10
|
+
export function canHandle(url) {
|
|
11
|
+
try {
|
|
12
|
+
return hostMatches(new URL(String(url)).hostname, ["dailymotion.com", "dai.ly"]);
|
|
13
|
+
} catch { return false; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function videoId(url) {
|
|
17
|
+
const m = String(url).match(/(?:video\/|dai\.ly\/)([a-z0-9]+)/i);
|
|
18
|
+
return m ? m[1] : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function resolve(url, ctx = {}) {
|
|
22
|
+
const attempt = { provider: name, status: "skipped" };
|
|
23
|
+
ctx.attempts?.push(attempt);
|
|
24
|
+
const t0 = Date.now();
|
|
25
|
+
|
|
26
|
+
const id = videoId(url);
|
|
27
|
+
if (!id) {
|
|
28
|
+
attempt.status = "failed";
|
|
29
|
+
throw new UMediaError(Codes.INVALID_URL, "Not a Dailymotion video URL", { attempts: [attempt] });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let meta;
|
|
33
|
+
try {
|
|
34
|
+
meta = await fetchJson(`https://www.dailymotion.com/player/metadata/video/${id}`, {}, 15_000);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
attempt.status = "failed";
|
|
37
|
+
attempt.error = e.message;
|
|
38
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Dailymotion metadata unavailable. ${e.message}`, { attempts: [attempt] });
|
|
39
|
+
}
|
|
40
|
+
if (meta?.error) {
|
|
41
|
+
attempt.status = "failed";
|
|
42
|
+
attempt.error = meta.error.title || "video unavailable";
|
|
43
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, `Dailymotion: ${attempt.error}`, { attempts: [attempt] });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const q = meta.qualities || {};
|
|
47
|
+
const media = [];
|
|
48
|
+
const variants = [];
|
|
49
|
+
for (const [label, arr] of Object.entries(q)) {
|
|
50
|
+
for (const r of (Array.isArray(arr) ? arr : [arr])) {
|
|
51
|
+
if (!r?.url) continue;
|
|
52
|
+
variants.push({
|
|
53
|
+
height: r.height ?? null, width: r.width ?? null, url: r.url,
|
|
54
|
+
hasAudio: true, hasVideo: true,
|
|
55
|
+
quality: label === "auto" ? "hls" : label,
|
|
56
|
+
mimeType: r.type || (/\.m3u8/.test(r.url) ? "application/x-mpegURL" : "video/mp4"),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const progressive = variants.find((v) => /mp4/.test(v.mimeType || "")) || variants.find((v) => !/m3u8|mpegurl/i.test(v.url + (v.mimeType || "")));
|
|
61
|
+
const chosen = progressive || variants[0];
|
|
62
|
+
if (!chosen) {
|
|
63
|
+
attempt.status = "failed";
|
|
64
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Dailymotion serves no stream URLs for this video", { attempts: [attempt] });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
media.push({
|
|
68
|
+
type: "video", index: 0, url: chosen.url,
|
|
69
|
+
thumbnail: meta.thumbnails?.["720"] || meta.thumbnails?.["480"] || Object.values(meta.thumbnails || {})[0] || null,
|
|
70
|
+
mimeType: chosen.mimeType,
|
|
71
|
+
width: null, height: null,
|
|
72
|
+
duration: meta.duration ? Math.round(meta.duration) : null,
|
|
73
|
+
size: null, quality: chosen.quality,
|
|
74
|
+
hasAudio: true, hasVideo: true, source: name,
|
|
75
|
+
variants,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
attempt.status = "success";
|
|
79
|
+
attempt.latencyMs = Date.now() - t0;
|
|
80
|
+
return {
|
|
81
|
+
result: {
|
|
82
|
+
sourceUrl: url,
|
|
83
|
+
platform: name,
|
|
84
|
+
title: meta.title || null,
|
|
85
|
+
author: meta.owner?.screenname || meta.owner?.username || null,
|
|
86
|
+
thumbnail: media[0].thumbnail,
|
|
87
|
+
itemCount: 1,
|
|
88
|
+
counts: { images: 0, videos: 1, audios: 0, other: 0 },
|
|
89
|
+
truncated: false,
|
|
90
|
+
media,
|
|
91
|
+
// honest: HLS-only means a manifest, not a single muxed file — yt-dlp tier muxes to mp4
|
|
92
|
+
streamUrlsAvailable: true,
|
|
93
|
+
},
|
|
94
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function search() {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { fetchText, hostMatches } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* og-embed tier — Facebook, Threads, Snapchat Spotlight, Rumble, Tumblr.
|
|
6
|
+
* These platforms publish official media previews as Open Graph meta tags on
|
|
7
|
+
* their public share pages; some networks gate them (typed honest errors there,
|
|
8
|
+
* the yt-dlp tier is the reliable path). No scraping tricks — their own tags.
|
|
9
|
+
*/
|
|
10
|
+
export const name = "ogembed";
|
|
11
|
+
|
|
12
|
+
const HOSTS = ["facebook.com", "fb.watch", "threads.net", "threads.com", "snapchat.com", "rumble.com", "tumblr.com"];
|
|
13
|
+
|
|
14
|
+
export function canHandle(url) {
|
|
15
|
+
try {
|
|
16
|
+
return hostMatches(new URL(String(url)).hostname, HOSTS);
|
|
17
|
+
} catch { return false; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function meta(h, key) {
|
|
21
|
+
const pats = [
|
|
22
|
+
new RegExp(`<meta[^>]+(?:property|name)="${key}"[^>]+content="([^"]*)"`, "i"),
|
|
23
|
+
new RegExp(`<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="${key}"`, "i"),
|
|
24
|
+
];
|
|
25
|
+
for (const p of pats) {
|
|
26
|
+
const m = h.match(p);
|
|
27
|
+
if (m) return m[1].replace(/&/g, "&").replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n));
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function resolve(url, ctx = {}) {
|
|
33
|
+
const attempt = { provider: name, status: "skipped" };
|
|
34
|
+
ctx.attempts?.push(attempt);
|
|
35
|
+
const t0 = Date.now();
|
|
36
|
+
const host = (() => { try { return new URL(String(url)).hostname.replace(/^www\./, ""); } catch { return "?"; } })();
|
|
37
|
+
|
|
38
|
+
let html;
|
|
39
|
+
try {
|
|
40
|
+
const r = await fetchText(String(url), {}, 18_000);
|
|
41
|
+
if (r.status >= 400) {
|
|
42
|
+
attempt.status = "failed";
|
|
43
|
+
attempt.error = `HTTP ${r.status}`;
|
|
44
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `${host} returned HTTP ${r.status}`, { attempts: [attempt] });
|
|
45
|
+
}
|
|
46
|
+
html = r.text;
|
|
47
|
+
} catch (e) {
|
|
48
|
+
if (e instanceof UMediaError) throw e;
|
|
49
|
+
attempt.status = "failed";
|
|
50
|
+
attempt.error = e.message;
|
|
51
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `${host} not retrievable. ${e.message}`, { attempts: [attempt] });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const video = meta(html, "og:video:secure_url") || meta(html, "og:video") || meta(html, "og:video:url");
|
|
55
|
+
const image = meta(html, "og:image:secure_url") || meta(html, "og:image");
|
|
56
|
+
const title = meta(html, "og:title");
|
|
57
|
+
const desc = meta(html, "og:description");
|
|
58
|
+
const author = meta(html, "og:article:author") || meta(html, "twitter:creator") || null;
|
|
59
|
+
|
|
60
|
+
const media = [];
|
|
61
|
+
if (video && /\.mp4|\.m3u8|video/i.test(video)) {
|
|
62
|
+
media.push({
|
|
63
|
+
type: "video", index: 0, url: video, thumbnail: image,
|
|
64
|
+
mimeType: /\.m3u8/i.test(video) ? "application/x-mpegURL" : "video/mp4",
|
|
65
|
+
width: parseInt(meta(html, "og:video:width") || "", 10) || null,
|
|
66
|
+
height: parseInt(meta(html, "og:video:height") || "", 10) || null,
|
|
67
|
+
duration: parseInt(meta(html, "video:duration") || meta(html, "og:video:duration") || "", 10) || null,
|
|
68
|
+
size: null, quality: null,
|
|
69
|
+
hasAudio: true, hasVideo: true, source: name, variants: [],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (image && (!video || !/\.mp4/i.test(video))) {
|
|
73
|
+
media.push({
|
|
74
|
+
type: "image", index: media.length, url: image, thumbnail: image,
|
|
75
|
+
mimeType: "image/jpeg", width: null, height: null, duration: null, size: null,
|
|
76
|
+
quality: null, hasAudio: false, hasVideo: false, source: name, variants: [],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!media.length) {
|
|
81
|
+
attempt.status = "failed";
|
|
82
|
+
attempt.error = "no media in the public share page (login-gated or removed)";
|
|
83
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND,
|
|
84
|
+
`${host} exposes no media on its public page (often login-gated from datacenter IPs) — the yt-dlp tier is the reliable path. ${attempt.error}`, { attempts: [attempt] });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
attempt.status = "success";
|
|
88
|
+
attempt.latencyMs = Date.now() - t0;
|
|
89
|
+
return {
|
|
90
|
+
result: {
|
|
91
|
+
sourceUrl: url,
|
|
92
|
+
platform: host,
|
|
93
|
+
title: title || desc || null,
|
|
94
|
+
author,
|
|
95
|
+
thumbnail: image || media[0].thumbnail,
|
|
96
|
+
itemCount: media.length,
|
|
97
|
+
counts: {
|
|
98
|
+
images: media.filter((m) => m.type === "image").length,
|
|
99
|
+
videos: media.filter((m) => m.type === "video").length,
|
|
100
|
+
audios: 0, other: 0,
|
|
101
|
+
},
|
|
102
|
+
truncated: false,
|
|
103
|
+
media,
|
|
104
|
+
},
|
|
105
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function search() {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { fetchJson, fetchText, hostMatches, UA } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SoundCloud — public api-v2 (the route the web player itself calls):
|
|
6
|
+
* resolve -> transcodings -> CDN hop -> direct progressive MP3 (or HLS).
|
|
7
|
+
* This is the music pipeline: search elsewhere, download REAL full tracks here.
|
|
8
|
+
*/
|
|
9
|
+
export const name = "soundcloud";
|
|
10
|
+
|
|
11
|
+
let _client_id = null; // cached; rotated by SoundCloud occasionally
|
|
12
|
+
let _cidFetchedAt = 0;
|
|
13
|
+
|
|
14
|
+
export function canHandle(url) {
|
|
15
|
+
try {
|
|
16
|
+
return hostMatches(new URL(String(url)).hostname, ["soundcloud.com", "snd.sc"]);
|
|
17
|
+
} catch { return false; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function trackId(url) {
|
|
21
|
+
const m = String(url).match(/soundcloud\.com\/([\w-]+)\/([\w-]+)/i);
|
|
22
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function clientId() {
|
|
26
|
+
if (_client_id && Date.now() - _cidFetchedAt < 6 * 3600_000) return _client_id;
|
|
27
|
+
// SoundCloud ships its public client_id inside its JS bundles — read it like the web player does
|
|
28
|
+
const page = await fetchText("https://soundcloud.com/discover", {}, 15_000);
|
|
29
|
+
const bundles = [...new Set((page.text.match(/https:\/\/a-v2\.sndcdn\.com\/assets\/[^"']+\.js/g) || []))].slice(0, 14);
|
|
30
|
+
for (const b of bundles) {
|
|
31
|
+
try {
|
|
32
|
+
const js = await fetchText(b, {}, 15_000);
|
|
33
|
+
const m = js.text.match(/client_id[":= ]{1,4}([A-Za-z0-9]{24,40})/);
|
|
34
|
+
if (m) {
|
|
35
|
+
_client_id = m[1];
|
|
36
|
+
_cidFetchedAt = Date.now();
|
|
37
|
+
return _client_id;
|
|
38
|
+
}
|
|
39
|
+
} catch { /* try the next bundle */ }
|
|
40
|
+
}
|
|
41
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, "SoundCloud client_id not retrievable (site changed) — use the yt-dlp tier");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function resolve(url, ctx = {}) {
|
|
45
|
+
const attempt = { provider: name, status: "skipped" };
|
|
46
|
+
ctx.attempts?.push(attempt);
|
|
47
|
+
const t0 = Date.now();
|
|
48
|
+
|
|
49
|
+
const slug = trackId(url);
|
|
50
|
+
if (!slug) {
|
|
51
|
+
attempt.status = "failed";
|
|
52
|
+
throw new UMediaError(Codes.INVALID_URL, "Not a SoundCloud track URL (expected soundcloud.com/{user}/{track})", { attempts: [attempt] });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const cid = await clientId();
|
|
56
|
+
let track;
|
|
57
|
+
try {
|
|
58
|
+
track = await fetchJson(
|
|
59
|
+
`https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(String(url).split("?")[0])}&client_id=${cid}`, {}, 20_000);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
attempt.status = "failed";
|
|
62
|
+
attempt.error = e.message;
|
|
63
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, `SoundCloud track not retrievable. ${e.message}`, { attempts: [attempt] });
|
|
64
|
+
}
|
|
65
|
+
if (!track || track.kind !== "track") {
|
|
66
|
+
attempt.status = "failed";
|
|
67
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, "SoundCloud: not a public track (removed, private, or a set)", { attempts: [attempt] });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const media = [];
|
|
71
|
+
const variants = [];
|
|
72
|
+
for (const tr of (track.media?.transcodings || [])) {
|
|
73
|
+
const fmt = tr.format || {};
|
|
74
|
+
const isProg = fmt.protocol === "progressive";
|
|
75
|
+
const isMp3 = /mpeg|mp3/i.test(fmt.mime_type || "");
|
|
76
|
+
variants.push({
|
|
77
|
+
url: `${tr.url}${tr.url.includes("?") ? "&" : "?"}client_id=${cid}`,
|
|
78
|
+
height: null, width: null,
|
|
79
|
+
hasAudio: true, hasVideo: false,
|
|
80
|
+
quality: tr.quality || (isProg ? "progressive" : fmt.protocol),
|
|
81
|
+
mimeType: fmt.mime_type || null,
|
|
82
|
+
protocol: fmt.protocol,
|
|
83
|
+
_mp3: isProg && isMp3,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// resolve each transcoding hop -> real CDN URL (progressive mp3 first, then HLS)
|
|
87
|
+
const order = [...variants].sort((a, b) => (b._mp3 ? 1 : 0) - (a._mp3 ? 1 : 0));
|
|
88
|
+
const resolvedVariants = [];
|
|
89
|
+
for (const v of order) {
|
|
90
|
+
try {
|
|
91
|
+
const hop = await fetchJson(v.url, {}, 15_000);
|
|
92
|
+
if (hop?.url) resolvedVariants.push({ ...v, url: hop.url });
|
|
93
|
+
} catch { /* rights-restricted encoding — skip honestly */ }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!resolvedVariants.length) {
|
|
97
|
+
attempt.status = "failed";
|
|
98
|
+
attempt.error = "no downloadable encodings (rights-restricted)";
|
|
99
|
+
throw new UMediaError(Codes.ACCESS_RESTRICTED,
|
|
100
|
+
"SoundCloud exposes no downloadable encoding for this track (rights-restricted) — stream it in-app or use the yt-dlp tier", { attempts: [attempt] });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const chosen = resolvedVariants.find((v) => v._mp3) || resolvedVariants[0];
|
|
104
|
+
media.push({
|
|
105
|
+
type: "audio", index: 0, url: chosen.url,
|
|
106
|
+
thumbnail: track.artwork_url || track.user?.avatar_url || null,
|
|
107
|
+
mimeType: chosen.mimeType || (/mpegurl/.test(chosen.mimeType || "") ? "application/x-mpegURL" : "audio/mpeg"),
|
|
108
|
+
width: null, height: null,
|
|
109
|
+
duration: track.duration ? Math.round(track.duration / 1000) : null,
|
|
110
|
+
size: null, quality: chosen.quality,
|
|
111
|
+
hasAudio: true, hasVideo: false, source: name,
|
|
112
|
+
variants: resolvedVariants.map((v) => ({
|
|
113
|
+
height: null, width: null, url: v.url,
|
|
114
|
+
hasAudio: true, hasVideo: false, quality: v.quality, mimeType: v.mimeType,
|
|
115
|
+
})),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
attempt.status = "success";
|
|
119
|
+
attempt.latencyMs = Date.now() - t0;
|
|
120
|
+
return {
|
|
121
|
+
result: {
|
|
122
|
+
sourceUrl: url,
|
|
123
|
+
platform: name,
|
|
124
|
+
title: track.title || null,
|
|
125
|
+
author: track.user?.username || null,
|
|
126
|
+
thumbnail: track.artwork_url || null,
|
|
127
|
+
itemCount: 1,
|
|
128
|
+
counts: { images: 0, videos: 0, audios: 1, other: 0 },
|
|
129
|
+
truncated: false,
|
|
130
|
+
media,
|
|
131
|
+
},
|
|
132
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function search() {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { fetchJson, hostMatches } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Streamable — the public video JSON (api.streamable.com/videos/{id}):
|
|
6
|
+
* direct mp4 renditions (mp4 / mp4-mobile / original) with real heights.
|
|
7
|
+
*/
|
|
8
|
+
export const name = "streamable";
|
|
9
|
+
|
|
10
|
+
export function canHandle(url) {
|
|
11
|
+
try {
|
|
12
|
+
return hostMatches(new URL(String(url)).hostname, ["streamable.com"]);
|
|
13
|
+
} catch { return false; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function videoId(url) {
|
|
17
|
+
const m = String(url).match(/streamable\.com\/(?:e\/|video\/)?([a-z0-9]+)/i);
|
|
18
|
+
return m ? m[1] : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function resolve(url, ctx = {}) {
|
|
22
|
+
const attempt = { provider: name, status: "skipped" };
|
|
23
|
+
ctx.attempts?.push(attempt);
|
|
24
|
+
const t0 = Date.now();
|
|
25
|
+
|
|
26
|
+
const id = videoId(url);
|
|
27
|
+
if (!id) {
|
|
28
|
+
attempt.status = "failed";
|
|
29
|
+
throw new UMediaError(Codes.INVALID_URL, "Not a Streamable URL", { attempts: [attempt] });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let v;
|
|
33
|
+
try {
|
|
34
|
+
v = await fetchJson(`https://api.streamable.com/videos/${id}`, {}, 15_000);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
attempt.status = "failed";
|
|
37
|
+
attempt.error = e.message;
|
|
38
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, `Streamable video not retrievable. ${e.message}`, { attempts: [attempt] });
|
|
39
|
+
}
|
|
40
|
+
if (v?.error || (v?.status && v.status !== 2 && !v.files)) {
|
|
41
|
+
attempt.status = "failed";
|
|
42
|
+
attempt.error = v?.error || v?.message || "video not found or still processing";
|
|
43
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, `Streamable: ${attempt.error}`, { attempts: [attempt] });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const variants = [];
|
|
47
|
+
for (const [key, f] of Object.entries(v.files || {})) {
|
|
48
|
+
// their API occasionally returns a doubled scheme — normalize, never silently drop
|
|
49
|
+
const raw = typeof f?.url === "string" ? f.url.replace(/^https:https:\/\//, "https://") : null;
|
|
50
|
+
if (!raw) continue;
|
|
51
|
+
variants.push({
|
|
52
|
+
height: f.height ?? null, width: f.width ?? null, url: raw,
|
|
53
|
+
hasAudio: true, hasVideo: true,
|
|
54
|
+
quality: key === "mp4-mobile" ? "360p" : (f.height ? `${f.height}p` : key),
|
|
55
|
+
mimeType: "video/mp4",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const chosen = variants.sort((a, b) => (b.height || 0) - (a.height || 0))[0];
|
|
59
|
+
if (!chosen) {
|
|
60
|
+
attempt.status = "failed";
|
|
61
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Streamable: no downloadable renditions (deleted or processing)", { attempts: [attempt] });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
attempt.status = "success";
|
|
65
|
+
attempt.latencyMs = Date.now() - t0;
|
|
66
|
+
return {
|
|
67
|
+
result: {
|
|
68
|
+
sourceUrl: url,
|
|
69
|
+
platform: name,
|
|
70
|
+
title: v.title || null,
|
|
71
|
+
author: null,
|
|
72
|
+
thumbnail: (typeof v.thumbnail_url === "string" ? v.thumbnail_url.replace(/^https:https:\/\//, "https://") : v.thumbnail_url) || null,
|
|
73
|
+
itemCount: 1,
|
|
74
|
+
counts: { images: 0, videos: 1, audios: 0, other: 0 },
|
|
75
|
+
truncated: false,
|
|
76
|
+
media: [{
|
|
77
|
+
type: "video", index: 0, url: chosen.url,
|
|
78
|
+
thumbnail: (typeof v.thumbnail_url === "string" ? v.thumbnail_url.replace(/^https:https:\/\//, "https://") : v.thumbnail_url) || null,
|
|
79
|
+
mimeType: "video/mp4",
|
|
80
|
+
width: chosen.width ?? null, height: chosen.height ?? null,
|
|
81
|
+
duration: v.video_duration ?? v.duration ?? null,
|
|
82
|
+
size: null, quality: chosen.quality,
|
|
83
|
+
hasAudio: true, hasVideo: true, source: name, variants,
|
|
84
|
+
}],
|
|
85
|
+
streamUrlsAvailable: true,
|
|
86
|
+
},
|
|
87
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function search() {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { fetchJson, hostMatches } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Twitch clips — the public GraphQL endpoint the web player calls (its web
|
|
6
|
+
* client-id ships in Twitch's own JS; nothing secret). Direct CloudFront mp4
|
|
7
|
+
* renditions per quality. VODs/livestreams need the yt-dlp tier (honest).
|
|
8
|
+
*/
|
|
9
|
+
export const name = "twitch";
|
|
10
|
+
|
|
11
|
+
// Twitch's own public web client id (shipped in their frontend bundle)
|
|
12
|
+
const CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
|
|
13
|
+
|
|
14
|
+
export function canHandle(url) {
|
|
15
|
+
try {
|
|
16
|
+
return hostMatches(new URL(String(url)).hostname, ["twitch.tv", "clips.twitch.tv"]);
|
|
17
|
+
} catch { return false; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function clipSlug(url) {
|
|
21
|
+
const m = String(url).match(/(?:clips\.twitch\.tv\/|\/clip\/)([\w-]+)/i);
|
|
22
|
+
return m ? m[1] : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function resolve(url, ctx = {}) {
|
|
26
|
+
const attempt = { provider: name, status: "skipped" };
|
|
27
|
+
ctx.attempts?.push(attempt);
|
|
28
|
+
const t0 = Date.now();
|
|
29
|
+
|
|
30
|
+
const slug = clipSlug(url);
|
|
31
|
+
if (!slug) {
|
|
32
|
+
attempt.status = "failed";
|
|
33
|
+
throw new UMediaError(Codes.INVALID_URL,
|
|
34
|
+
"Only Twitch CLIP urls resolve natively (clips.twitch.tv/{slug} or twitch.tv/{ch}/clip/{slug}). VODs/livestreams: use downloadWithYtdlp().", { attempts: [attempt] });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let gql;
|
|
38
|
+
try {
|
|
39
|
+
gql = await fetchJson("https://gql.twitch.tv/gql",
|
|
40
|
+
{
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "client-id": CLIENT_ID, "content-type": "text/plain" },
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
query: `query{clip(slug:"${slug}"){title durationSeconds broadcaster{displayName} thumbnailURL videoQualities{frameRate quality sourceURL} playbackAccessToken(params:{platform:"web",playerType:"site"}){value signature}}}`,
|
|
45
|
+
}),
|
|
46
|
+
}, 15_000);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
attempt.status = "failed";
|
|
49
|
+
attempt.error = e.message;
|
|
50
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Twitch GraphQL unavailable. ${e.message}`, { attempts: [attempt] });
|
|
51
|
+
}
|
|
52
|
+
const clip = gql?.data?.clip;
|
|
53
|
+
if (!clip) {
|
|
54
|
+
attempt.status = "failed";
|
|
55
|
+
attempt.error = "clip not found (expired clips are removed with their VOD)";
|
|
56
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, `Twitch: ${attempt.error}`, { attempts: [attempt] });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Twitch CDN (nauth) URLs only serve with the clip's playback token — sign them like the web player
|
|
60
|
+
const tok = clip.playbackAccessToken;
|
|
61
|
+
const sign = (u) => (tok?.value && tok?.signature
|
|
62
|
+
? `${u}${u.includes("?") ? "&" : "?"}token=${encodeURIComponent(tok.value)}&sig=${tok.signature}`
|
|
63
|
+
: u);
|
|
64
|
+
|
|
65
|
+
const variants = (clip.videoQualities || [])
|
|
66
|
+
.filter((q) => q?.sourceURL)
|
|
67
|
+
.map((q) => ({
|
|
68
|
+
height: parseInt(q.quality, 10) || null, width: null, url: sign(q.sourceURL),
|
|
69
|
+
hasAudio: true, hasVideo: true,
|
|
70
|
+
quality: q.quality ? `${q.quality}p` : null, mimeType: "video/mp4",
|
|
71
|
+
}))
|
|
72
|
+
.sort((a, b) => (b.height || 0) - (a.height || 0));
|
|
73
|
+
const chosen = variants[0];
|
|
74
|
+
if (!chosen) {
|
|
75
|
+
attempt.status = "failed";
|
|
76
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Twitch clip has no downloadable renditions", { attempts: [attempt] });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
attempt.status = "success";
|
|
80
|
+
attempt.latencyMs = Date.now() - t0;
|
|
81
|
+
return {
|
|
82
|
+
result: {
|
|
83
|
+
sourceUrl: url,
|
|
84
|
+
platform: name,
|
|
85
|
+
title: clip.title || null,
|
|
86
|
+
author: clip.broadcaster?.displayName || null,
|
|
87
|
+
thumbnail: clip.thumbnailURL || null,
|
|
88
|
+
itemCount: 1,
|
|
89
|
+
counts: { images: 0, videos: 1, audios: 0, other: 0 },
|
|
90
|
+
truncated: false,
|
|
91
|
+
media: [{
|
|
92
|
+
type: "video", index: 0, url: chosen.url,
|
|
93
|
+
thumbnail: clip.thumbnailURL || null,
|
|
94
|
+
mimeType: "video/mp4",
|
|
95
|
+
width: null, height: chosen.height ?? null,
|
|
96
|
+
duration: clip.durationSeconds ?? null,
|
|
97
|
+
size: null, quality: chosen.quality,
|
|
98
|
+
hasAudio: true, hasVideo: true, source: name, variants,
|
|
99
|
+
}],
|
|
100
|
+
streamUrlsAvailable: true,
|
|
101
|
+
},
|
|
102
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function search() {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { fetchJson, hostMatches } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Vimeo — the public player config JSON (player.vimeo.com/video/{id}/config):
|
|
6
|
+
* progressive renditions when Vimeo serves them, otherwise HLS masters with
|
|
7
|
+
* real per-rendition quality labels.
|
|
8
|
+
*/
|
|
9
|
+
export const name = "vimeo";
|
|
10
|
+
|
|
11
|
+
export function canHandle(url) {
|
|
12
|
+
try {
|
|
13
|
+
return hostMatches(new URL(String(url)).hostname, ["vimeo.com", "player.vimeo.com"]);
|
|
14
|
+
} catch { return false; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function videoId(url) {
|
|
18
|
+
const m = String(url).match(/(?:video\/|vimeo\.com\/(?:video\/)?)(\d{6,12})/);
|
|
19
|
+
return m ? m[1] : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function resolve(url, ctx = {}) {
|
|
23
|
+
const attempt = { provider: name, status: "skipped" };
|
|
24
|
+
ctx.attempts?.push(attempt);
|
|
25
|
+
const t0 = Date.now();
|
|
26
|
+
|
|
27
|
+
const id = videoId(url);
|
|
28
|
+
if (!id) {
|
|
29
|
+
attempt.status = "failed";
|
|
30
|
+
throw new UMediaError(Codes.INVALID_URL, "Not a Vimeo video URL (password-protected/album URLs need the yt-dlp tier)", { attempts: [attempt] });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let cfg;
|
|
34
|
+
try {
|
|
35
|
+
cfg = await fetchJson(`https://player.vimeo.com/video/${id}/config`,
|
|
36
|
+
{ headers: { referer: "https://vimeo.com/" } }, 15_000);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
attempt.status = "failed";
|
|
39
|
+
attempt.error = e.message;
|
|
40
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Vimeo player config unavailable. ${e.message}`, { attempts: [attempt] });
|
|
41
|
+
}
|
|
42
|
+
const v = cfg?.video;
|
|
43
|
+
if (!v) {
|
|
44
|
+
attempt.status = "failed";
|
|
45
|
+
attempt.error = cfg?.title || "video unavailable";
|
|
46
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, `Vimeo: ${attempt.error}`, { attempts: [attempt] });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const files = cfg.request?.files || {};
|
|
50
|
+
const variants = [];
|
|
51
|
+
for (const p of (files.progressive || [])) {
|
|
52
|
+
if (!p?.url) continue;
|
|
53
|
+
variants.push({
|
|
54
|
+
height: p.height ?? null, width: p.width ?? null, url: p.url,
|
|
55
|
+
hasAudio: true, hasVideo: true,
|
|
56
|
+
quality: p.quality || (p.height ? `${p.height}p` : null),
|
|
57
|
+
mimeType: p.mime || "video/mp4",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const hls = files.hls || {};
|
|
61
|
+
const cdns = hls.cdns || {};
|
|
62
|
+
const defaultUrl = cdns[hls.default_cdn]?.avc_url || Object.values(cdns)[0]?.avc_url || null;
|
|
63
|
+
if (defaultUrl) {
|
|
64
|
+
variants.push({
|
|
65
|
+
height: null, width: null, url: defaultUrl,
|
|
66
|
+
hasAudio: !hls.separate_av, hasVideo: true,
|
|
67
|
+
quality: "hls", mimeType: "application/x-mpegURL",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const progressive = variants.find((v) => /mp4/.test(v.mimeType || ""));
|
|
72
|
+
const chosen = progressive || variants[0];
|
|
73
|
+
if (!chosen) {
|
|
74
|
+
attempt.status = "failed";
|
|
75
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Vimeo serves no stream URLs (private or domain-restricted video)", { attempts: [attempt] });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
attempt.status = "success";
|
|
79
|
+
attempt.latencyMs = Date.now() - t0;
|
|
80
|
+
return {
|
|
81
|
+
result: {
|
|
82
|
+
sourceUrl: url,
|
|
83
|
+
platform: name,
|
|
84
|
+
title: v.title || null,
|
|
85
|
+
author: cfg.owner?.display_name || cfg.user?.name || null,
|
|
86
|
+
thumbnail: v.thumbs?.base || v.thumb || null,
|
|
87
|
+
itemCount: 1,
|
|
88
|
+
counts: { images: 0, videos: 1, audios: 0, other: 0 },
|
|
89
|
+
truncated: false,
|
|
90
|
+
media: [{
|
|
91
|
+
type: "video", index: 0, url: chosen.url,
|
|
92
|
+
thumbnail: v.thumbs?.base || v.thumb || null,
|
|
93
|
+
mimeType: chosen.mimeType,
|
|
94
|
+
width: v.width ?? null, height: v.height ?? null,
|
|
95
|
+
duration: v.duration ?? null,
|
|
96
|
+
size: null, quality: chosen.quality,
|
|
97
|
+
hasAudio: true, hasVideo: true, source: name,
|
|
98
|
+
variants,
|
|
99
|
+
}],
|
|
100
|
+
streamUrlsAvailable: true,
|
|
101
|
+
},
|
|
102
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function search() {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
package/lib/registry.js
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import * as yt from "./providers/youtube.js";
|
|
2
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";
|
|
3
10
|
import * as reddit from "./providers/reddit.js";
|
|
4
11
|
import * as xprov from "./providers/x.js";
|
|
5
12
|
import * as ig from "./providers/instagram.js";
|
|
13
|
+
import * as ogembed from "./providers/ogembed.js";
|
|
6
14
|
import * as ytdlp from "./providers/ytdlp.js";
|
|
7
15
|
import * as itunes from "./providers/itunes.js";
|
|
8
16
|
import { Codes, UMediaError } from "./errors.js";
|
|
17
|
+
import { validateUrl } from "./util.js";
|
|
9
18
|
|
|
10
|
-
const NATIVE = [yt, tiktok, reddit, xprov, ig];
|
|
19
|
+
const NATIVE = [yt, tiktok, pinterest, soundcloud, dailymotion, vimeo, streamable, twitch, bluesky, reddit, xprov, ig, ogembed];
|
|
11
20
|
|
|
12
21
|
export function detectPlatform(url) {
|
|
13
22
|
for (const p of NATIVE) {
|
|
@@ -20,6 +29,9 @@ const DIRECT_MEDIA = /\.(mp4|webm|m4a|mp3|aac|wav|jpg|jpeg|png|gif|webp|mov|mkv)
|
|
|
20
29
|
|
|
21
30
|
/** Resolve a URL: native adapter first, optional yt-dlp fallback. attempts[] is the honest trail. */
|
|
22
31
|
export async function resolve(url) {
|
|
32
|
+
// SSRF hygiene FIRST — never dispatch or fetch private/internal targets (bots pass user URLs!)
|
|
33
|
+
validateUrl(url);
|
|
34
|
+
|
|
23
35
|
// direct media URL (an image/video/audio file) -> single-item result, no platform needed
|
|
24
36
|
const direct = String(url).split("#")[0].match(DIRECT_MEDIA);
|
|
25
37
|
if (direct) {
|
|
@@ -95,10 +107,18 @@ export function capabilities() {
|
|
|
95
107
|
return {
|
|
96
108
|
platforms: [
|
|
97
109
|
{ 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: "
|
|
110
|
+
{ platform: "tiktok", search: false, resolve: true, download: true, notes: "video posts = direct CDN mp4; galleries = every slide the platform exposes (truncated flag honest)" },
|
|
111
|
+
{ platform: "pinterest", search: false, resolve: true, download: true, notes: "image pins, video pins (direct mp4), idea/story pins, pin.it links" },
|
|
112
|
+
{ platform: "soundcloud", search: false, resolve: true, download: true, notes: "full tracks: direct progressive MP3 + HLS, real titles/artists" },
|
|
113
|
+
{ platform: "dailymotion", search: false, resolve: true, download: true, notes: "public metadata API; HLS/progressive renditions" },
|
|
114
|
+
{ platform: "vimeo", search: false, resolve: true, download: true, notes: "player config: progressive mp4 when served, HLS otherwise" },
|
|
115
|
+
{ platform: "streamable", search: false, resolve: true, download: true, notes: "direct mp4 renditions" },
|
|
116
|
+
{ platform: "twitch", search: false, resolve: true, download: true, notes: "clips = direct mp4s; VODs/livestreams via yt-dlp tier" },
|
|
117
|
+
{ platform: "bluesky", search: false, resolve: true, download: true, notes: "public AppView API: full-size images + video playlists" },
|
|
99
118
|
{ platform: "instagram", search: false, resolve: true, download: true, notes: "public embed best-effort" },
|
|
100
119
|
{ platform: "reddit", search: false, resolve: true, download: true, notes: "posts + galleries" },
|
|
101
120
|
{ platform: "x", search: false, resolve: true, download: true, notes: "public posts via syndication" },
|
|
121
|
+
{ platform: "facebook · threads · snapchat · rumble · tumblr", search: false, resolve: "best-effort", download: "best-effort", notes: "official Open Graph media; often login-gated on datacenter IPs — yt-dlp tier reliable" },
|
|
102
122
|
{ platform: "itunes", search: true, resolve: false, download: "previews", notes: "real 30s clips" },
|
|
103
123
|
{ platform: "ytdlp", search: false, resolve: Boolean(ytdlp.binary()), download: Boolean(ytdlp.binary()), notes: "optional tier: 1800+ sites — install yt-dlp" },
|
|
104
124
|
],
|
|
@@ -112,10 +132,18 @@ export function status() {
|
|
|
112
132
|
runtime: { node: process.version, platform: process.platform },
|
|
113
133
|
providers: [
|
|
114
134
|
{ name: "youtube", state: "operational", via: "youtubei.js" },
|
|
115
|
-
{ name: "tiktok", state: "operational", via: "embed
|
|
135
|
+
{ name: "tiktok", state: "operational", via: "embed state" },
|
|
136
|
+
{ name: "pinterest", state: "operational", via: "public widget API" },
|
|
137
|
+
{ name: "soundcloud", state: "operational", via: "public api-v2" },
|
|
138
|
+
{ name: "dailymotion", state: "operational", via: "player metadata" },
|
|
139
|
+
{ name: "vimeo", state: "operational", via: "player config" },
|
|
140
|
+
{ name: "streamable", state: "operational", via: "public video api" },
|
|
141
|
+
{ name: "twitch", state: "operational", via: "public GraphQL (clips)" },
|
|
142
|
+
{ name: "bluesky", state: "operational", via: "public AppView XRPC" },
|
|
116
143
|
{ name: "instagram", state: "degraded", via: "public embed (best-effort)" },
|
|
117
144
|
{ name: "reddit", state: "operational", via: "public json" },
|
|
118
145
|
{ name: "x", state: "degraded", via: "syndication (best-effort)" },
|
|
146
|
+
{ name: "ogembed", state: "degraded", via: "open graph meta (best-effort)" },
|
|
119
147
|
{ name: "itunes", state: "operational", via: "search api" },
|
|
120
148
|
{ name: "ytdlp", state: ytdlp.binary() ? "operational" : "unavailable", via: "external binary" },
|
|
121
149
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "umedia",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
|
-
"umedia": "
|
|
15
|
+
"umedia": "bin/umedia.js"
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"index.js",
|