umedia 0.2.2 → 0.4.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 +16 -4
- package/lib/download.js +91 -7
- package/lib/providers/bluesky.js +118 -0
- package/lib/providers/dailymotion.js +100 -0
- package/lib/providers/facebook.js +126 -0
- package/lib/providers/instagram.js +166 -44
- package/lib/providers/pinterest.js +181 -0
- package/lib/providers/reddit.js +32 -6
- package/lib/providers/rumble.js +110 -0
- package/lib/providers/snapchat.js +106 -0
- package/lib/providers/soundcloud.js +138 -0
- package/lib/providers/streamable.js +93 -0
- package/lib/providers/threads.js +103 -0
- package/lib/providers/tumblr.js +153 -0
- package/lib/providers/twitch.js +108 -0
- package/lib/providers/vimeo.js +108 -0
- package/lib/providers/x.js +190 -46
- package/lib/registry.js +52 -8
- package/package.json +1 -1
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 · Threads · Snapchat · Rumble · Tumblr · 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,21 @@ 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
|
-
| **
|
|
80
|
-
| **
|
|
81
|
-
| **
|
|
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 |
|
|
86
|
+
| **Instagram** | — | ✅ | ✅ | oEmbed→mobile info API: photos, videos, full carousels |
|
|
87
|
+
| **Reddit** | — | ✅ | ✅ | posts + galleries + video with its audio sidecar (DC IPs may 403 — typed honest error) |
|
|
88
|
+
| **X** | — | ✅ | ✅ | syndication token + guest GraphQL: photos/videos/gifs at real bitrates |
|
|
89
|
+
| **Facebook** | — | ✅ | ✅ | reels/watch/videos/shares/fb.watch via the web player JSON |
|
|
90
|
+
| **Threads** | — | ✅ | ✅ | post SSR state + og media (Meta gates some networks — typed honest error) |
|
|
91
|
+
| **Snapchat** | — | ✅ best-effort | ✅ best-effort | spotlight + story snap lists (edge-gated on datacenter IPs) |
|
|
92
|
+
| **Rumble** | — | ✅ best-effort | ✅ best-effort | embed API direct mp4s (site blocks datacenter IPs) |
|
|
93
|
+
| **Tumblr** | — | ✅ | ✅ | mobile API: video/audio/photo posts + reblog trails, all in order |
|
|
82
94
|
| **iTunes** | ✅ | — | ✅ previews | real 30s clips, rock solid |
|
|
83
95
|
| **1800+ sites** | — | ✅ | ✅ | optional tier: `pip install yt-dlp` — labeled `source: "ytdlp"` |
|
|
84
96
|
|
package/lib/download.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createWriteStream } from "node:fs";
|
|
2
|
-
import { mkdir, stat } from "node:fs/promises";
|
|
2
|
+
import { mkdir, stat, rename } from "node:fs/promises";
|
|
3
3
|
import { Readable } from "node:stream";
|
|
4
4
|
import { pipeline } from "node:stream/promises";
|
|
5
5
|
import path from "node:path";
|
|
@@ -27,6 +27,74 @@ 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
|
+
let fmp4 = false;
|
|
46
|
+
|
|
47
|
+
if (manifest.includes("#EXT-X-STREAM-INF")) {
|
|
48
|
+
const lines = manifest.split(/\r?\n/);
|
|
49
|
+
let best = null;
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
if (!lines[i].startsWith("#EXT-X-STREAM-INF")) continue;
|
|
52
|
+
const res = /RESOLUTION=(\d+)x(\d+)/.exec(lines[i]);
|
|
53
|
+
const codecs = /CODECS="([^"]+)"/.exec(lines[i])?.[1] || "";
|
|
54
|
+
const h = res ? parseInt(res[2], 10) : 0;
|
|
55
|
+
const urlLine = lines.slice(i + 1).find((l) => l && !l.startsWith("#"));
|
|
56
|
+
if (!urlLine) continue;
|
|
57
|
+
if (!best || h > best.h) best = { url: new URL(urlLine, base).href, codecs, h };
|
|
58
|
+
}
|
|
59
|
+
if (!best) throw new UMediaError(Codes.PROVIDER_ERROR, "HLS master has no variants");
|
|
60
|
+
if (best.codecs && !/mp4a|opus|ac-3|ec-3/.test(best.codecs)) {
|
|
61
|
+
throw new UMediaError(Codes.QUALITY_UNAVAILABLE,
|
|
62
|
+
"HLS variant is video-only (separate audio track) — muxing needs the yt-dlp tier: downloadWithYtdlp()");
|
|
63
|
+
}
|
|
64
|
+
pickedHeight = best.h || null;
|
|
65
|
+
base = best.url;
|
|
66
|
+
manifest = await getText(base);
|
|
67
|
+
}
|
|
68
|
+
if (manifest.includes("#EXT-X-MAP")) {
|
|
69
|
+
// fMP4: init segment + media fragments concat to a valid fragmented MP4
|
|
70
|
+
fmp4 = true;
|
|
71
|
+
}
|
|
72
|
+
const mapUri = /#EXT-X-MAP:URI="([^"]+)"/.exec(manifest)?.[1] || null;
|
|
73
|
+
const segs = manifest.split(/\r?\n/).filter((l) => l && !l.startsWith("#")).map((l) => new URL(l, base).href);
|
|
74
|
+
if (!segs.length) throw new UMediaError(Codes.PROVIDER_ERROR, "HLS media playlist has no segments");
|
|
75
|
+
|
|
76
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
77
|
+
const ws = createWriteStream(dest);
|
|
78
|
+
if (fmp4 && mapUri) {
|
|
79
|
+
const initRes = await fetch(new URL(mapUri, base).href, { headers: { "user-agent": UA }, redirect: "follow" });
|
|
80
|
+
if (!initRes.ok) throw new UMediaError(Codes.PROVIDER_ERROR, `HTTP ${initRes.status} fetching fMP4 init segment`);
|
|
81
|
+
const initBuf = Buffer.from(await initRes.arrayBuffer());
|
|
82
|
+
await new Promise((r, j) => ws.write(initBuf, (e) => (e ? j(e) : r())));
|
|
83
|
+
}
|
|
84
|
+
let done = 0;
|
|
85
|
+
for (const seg of segs) {
|
|
86
|
+
const res = await fetch(seg, { headers: { "user-agent": UA }, redirect: "follow" });
|
|
87
|
+
if (!res.ok) throw new UMediaError(Codes.PROVIDER_ERROR, `HTTP ${res.status} fetching HLS segment`);
|
|
88
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
89
|
+
await new Promise((r, j) => ws.write(buf, (e) => (e ? j(e) : r())));
|
|
90
|
+
done += 1;
|
|
91
|
+
onProgress?.(done, segs.length);
|
|
92
|
+
}
|
|
93
|
+
await new Promise((r) => ws.end(r));
|
|
94
|
+
const st = await stat(dest);
|
|
95
|
+
return { size: st.size, height: pickedHeight, ext: fmp4 ? "mp4" : "ts" };
|
|
96
|
+
}
|
|
97
|
+
|
|
30
98
|
/**
|
|
31
99
|
* Download every media item of a URL to disk — order preserved, failures named.
|
|
32
100
|
* Direct media URLs (clips, images) download as a single item — no page-resolve needed.
|
|
@@ -85,12 +153,28 @@ export async function download({ url, quality = "best", type = "video", dir = ".
|
|
|
85
153
|
validateUrl(chosenUrl); // never fetch private/internal targets, even from a provider response
|
|
86
154
|
if (!selectedQuality) { selectedQuality = pick.selectedQuality; fallback = pick.fallback; }
|
|
87
155
|
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
156
|
+
const isHls = /\.m3u8$/i.test(chosenUrl.split(/[?#]/)[0]);
|
|
157
|
+
let ext = isHls ? "ts" : ((chosenUrl.split("?")[0].match(/\.(mp4|webm|m4a|mp3|jpg|jpeg|png|gif|webp)$/) || [])[1]
|
|
158
|
+
|| (item.type === "image" ? "jpg" : item.type === "audio" ? "m4a" : "mp4"));
|
|
159
|
+
const base = cleanName(resolved.title);
|
|
160
|
+
let name = `${String(item.index + 1).padStart(3, "0")} - ${base.toLowerCase().endsWith(`.${ext}`) ? base.slice(0, -(ext.length + 1)) : base}.${ext}`;
|
|
161
|
+
let dest = path.join(dir, name);
|
|
162
|
+
let size, hlsHeight = null, hlsExt = null;
|
|
163
|
+
if (isHls) {
|
|
164
|
+
({ size, height: hlsHeight, ext: hlsExt } = await downloadHls(chosenUrl, dest, onProgress));
|
|
165
|
+
if (hlsHeight) selectedQuality = `${hlsHeight}p`; // the REAL rendition downloaded
|
|
166
|
+
if (hlsExt && hlsExt !== ext) {
|
|
167
|
+
// fMP4 concat produces a real .mp4 — rename so the file never lies about its container
|
|
168
|
+
ext = hlsExt;
|
|
169
|
+
name = name.replace(/\.ts$/, `.${ext}`);
|
|
170
|
+
dest = path.join(dir, name);
|
|
171
|
+
await rename(`${dest.replace(new RegExp(`\\.${ext}$`), ".ts")}`, dest).catch(() => {});
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
size = await fetchToFile(chosenUrl, dest, onProgress);
|
|
175
|
+
}
|
|
176
|
+
const fileQuality = (isHls && hlsHeight) ? `${hlsHeight}p` : (pick.selectedQuality ?? item.quality ?? null);
|
|
177
|
+
files.push({ name, path: dest, size, ext, mimeType: item.mimeType ?? null, index: item.index, quality: fileQuality });
|
|
94
178
|
} catch (e) {
|
|
95
179
|
failedItems.push({ index: item.index, type: item.type, url: String(item.url || "").slice(0, 120), error: e.message });
|
|
96
180
|
}
|
|
@@ -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,126 @@
|
|
|
1
|
+
import { fetchText, hostMatches } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Facebook — the platform's own player JSON embedded in the page (the exact
|
|
6
|
+
* fields Facebook's web player reads: browser_native_hd/sd_url,
|
|
7
|
+
* playable_url_quality_hd/playable_url). Reels, watch, videos, shares, fb.watch.
|
|
8
|
+
* Reference: yt-dlp facebook extractor + cobalt facebook service.
|
|
9
|
+
*/
|
|
10
|
+
export const name = "facebook";
|
|
11
|
+
|
|
12
|
+
const HEADERS = {
|
|
13
|
+
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
14
|
+
"accept-language": "en-US,en;q=0.5",
|
|
15
|
+
"sec-fetch-mode": "navigate",
|
|
16
|
+
"sec-fetch-site": "none",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function canHandle(url) {
|
|
20
|
+
try {
|
|
21
|
+
return hostMatches(new URL(String(url)).hostname, ["facebook.com", "fb.watch", "fb.com"]);
|
|
22
|
+
} catch { return false; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function videoId(url) {
|
|
26
|
+
const u = String(url);
|
|
27
|
+
return (u.match(/\/(?:reel|videos|watch)\/(?:\?v=)?(\d+)/) || u.match(/[?&]v=(\d+)/) || u.match(/\/share\/[^/]+\/([\w-]+)/) || [])[1] || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pickJsonStr(html, key) {
|
|
31
|
+
const m = html.match(new RegExp(`"${key}":("(?:[^"\\\\]|\\\\.)*")`));
|
|
32
|
+
if (!m) return null;
|
|
33
|
+
try { return JSON.parse(m[1]); } catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function resolveShort(url, ctx) {
|
|
37
|
+
// fb.watch / facebook.com/share short links -> canonical page (Location or Link header)
|
|
38
|
+
const res = await fetch(url, { headers: HEADERS, redirect: "follow" }).catch(() => null);
|
|
39
|
+
if (!res) return url;
|
|
40
|
+
return res.url || url;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function resolve(url, ctx = {}) {
|
|
44
|
+
const attempt = { provider: name, status: "skipped" };
|
|
45
|
+
ctx.attempts?.push(attempt);
|
|
46
|
+
const t0 = Date.now();
|
|
47
|
+
|
|
48
|
+
let target = String(url);
|
|
49
|
+
if (/fb\.watch\//i.test(target) || /facebook\.com\/share\//i.test(target)) {
|
|
50
|
+
try { target = await resolveShort(target, ctx); } catch { /* use original */ }
|
|
51
|
+
}
|
|
52
|
+
// normalize watch/video.php/reel forms onto a fetchable page
|
|
53
|
+
const id = videoId(target) || videoId(url);
|
|
54
|
+
if (/\/watch\/?\?/i.test(target) && id) target = `https://www.facebook.com/watch/?v=${id}`;
|
|
55
|
+
if (/video\.php/i.test(target) && id) target = `https://www.facebook.com/video.php?v=${id}`;
|
|
56
|
+
if (/\/reel\//i.test(target) && id) target = `https://www.facebook.com/reel/${id}`;
|
|
57
|
+
|
|
58
|
+
let html;
|
|
59
|
+
try {
|
|
60
|
+
const r = await fetchText(target, { headers: HEADERS }, 20_000);
|
|
61
|
+
html = r.text;
|
|
62
|
+
} catch (e) {
|
|
63
|
+
attempt.status = "failed";
|
|
64
|
+
attempt.error = e.message;
|
|
65
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Facebook page not retrievable. ${e.message}`, { attempts: [attempt] });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const hd = pickJsonStr(html, "browser_native_hd_url") || pickJsonStr(html, "playable_url_quality_hd");
|
|
69
|
+
const sd = pickJsonStr(html, "browser_native_sd_url") || pickJsonStr(html, "playable_url");
|
|
70
|
+
|
|
71
|
+
const media = [];
|
|
72
|
+
const variants = [];
|
|
73
|
+
for (const [u, q] of [[hd, "hd"], [sd, "sd"]]) {
|
|
74
|
+
if (u && /^https?:/.test(u)) variants.push({ url: u, quality: q, height: null, width: null, hasAudio: true, hasVideo: true, mimeType: "video/mp4" });
|
|
75
|
+
}
|
|
76
|
+
if (variants.length) {
|
|
77
|
+
media.push({
|
|
78
|
+
type: "video", index: 0, url: variants[0].url,
|
|
79
|
+
thumbnail: pickJsonStr(html, "playable_url_thumbnail") || null,
|
|
80
|
+
mimeType: "video/mp4", width: null, height: null, duration: null, size: null,
|
|
81
|
+
quality: variants[0].quality, hasAudio: true, hasVideo: true, source: name, variants,
|
|
82
|
+
});
|
|
83
|
+
} else {
|
|
84
|
+
// og fallback (public photos / gated networks)
|
|
85
|
+
const og = (html.match(/property="og:(?:video|image)"[^>]+content="([^"]+)"/) || html.match(/content="([^"]+)"[^>]+property="og:(?:video|image)"/) || [])[1];
|
|
86
|
+
const isVideo = /property="og:video"/.test(html);
|
|
87
|
+
if (og && /^https?:/.test(og.replace(/&/g, "&"))) {
|
|
88
|
+
media.push({
|
|
89
|
+
type: isVideo ? "video" : "image", index: 0, url: og.replace(/&/g, "&"), thumbnail: og,
|
|
90
|
+
mimeType: isVideo ? "video/mp4" : "image/jpeg", width: null, height: null,
|
|
91
|
+
duration: null, size: null, quality: null,
|
|
92
|
+
hasAudio: isVideo, hasVideo: isVideo, source: name, variants: [],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!media.length) {
|
|
98
|
+
attempt.status = "failed";
|
|
99
|
+
attempt.error = "no player JSON or og media in the page (login-gated, private, or removed)";
|
|
100
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND,
|
|
101
|
+
`Facebook exposes no media for this URL (${attempt.error}) — public videos work from most networks; the yt-dlp tier covers gated ones.`, { attempts: [attempt] });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const title = (html.match(/"title":"((?:[^"\\]|\\.)*)"/) || html.match(/<title[^>]*>([^<]*)/i) || [])[1] || null;
|
|
105
|
+
attempt.status = "success";
|
|
106
|
+
attempt.latencyMs = Date.now() - t0;
|
|
107
|
+
return {
|
|
108
|
+
result: {
|
|
109
|
+
sourceUrl: url,
|
|
110
|
+
platform: name,
|
|
111
|
+
title: title ? title.replace(/\\u0025/g, "%").slice(0, 200) : null,
|
|
112
|
+
author: null,
|
|
113
|
+
thumbnail: media[0].thumbnail,
|
|
114
|
+
itemCount: media.length,
|
|
115
|
+
counts: { images: media.filter((m) => m.type === "image").length, videos: media.filter((m) => m.type === "video").length, audios: 0, other: 0 },
|
|
116
|
+
truncated: false,
|
|
117
|
+
media,
|
|
118
|
+
streamUrlsAvailable: true,
|
|
119
|
+
},
|
|
120
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function search() {
|
|
125
|
+
return [];
|
|
126
|
+
}
|