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
package/lib/download.js
DELETED
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
import { createWriteStream } from "node:fs";
|
|
2
|
-
import { mkdir, stat, rename } from "node:fs/promises";
|
|
3
|
-
import { Readable } from "node:stream";
|
|
4
|
-
import { pipeline } from "node:stream/promises";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import { zipSync } from "fflate";
|
|
7
|
-
import { UMediaError, Codes } from "./errors.js";
|
|
8
|
-
import { UA, cleanName, rid, validateUrl } from "./util.js";
|
|
9
|
-
import { pickFormat } from "./quality.js";
|
|
10
|
-
import * as registry from "./registry.js";
|
|
11
|
-
import * as ytdlp from "./providers/ytdlp.js";
|
|
12
|
-
|
|
13
|
-
async function fetchToFile(url, dest, onProgress) {
|
|
14
|
-
const res = await fetch(url, { headers: { "user-agent": UA }, redirect: "follow" });
|
|
15
|
-
if (!res.ok) throw new UMediaError(Codes.PROVIDER_ERROR, `HTTP ${res.status} fetching media`);
|
|
16
|
-
const total = Number(res.headers.get("content-length") || 0);
|
|
17
|
-
await mkdir(path.dirname(dest), { recursive: true });
|
|
18
|
-
const ws = createWriteStream(dest);
|
|
19
|
-
let done = 0;
|
|
20
|
-
const readable = Readable.fromWeb(res.body);
|
|
21
|
-
readable.on("data", (chunk) => {
|
|
22
|
-
done += chunk.length;
|
|
23
|
-
onProgress?.(done, total);
|
|
24
|
-
});
|
|
25
|
-
await pipeline(readable, ws);
|
|
26
|
-
const s = await stat(dest);
|
|
27
|
-
return s.size;
|
|
28
|
-
}
|
|
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
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Download every media item of a URL to disk — order preserved, failures named.
|
|
100
|
-
* Direct media URLs (clips, images) download as a single item — no page-resolve needed.
|
|
101
|
-
* @returns {Promise<{data: DownloadResult, meta}>}
|
|
102
|
-
*/
|
|
103
|
-
const MEDIA_EXT = /\.(mp4|webm|m4a|mp3|aac|wav|jpg|jpeg|png|gif|webp|mov|mkv)(\?|#|$)/i;
|
|
104
|
-
|
|
105
|
-
export async function download({ url, quality = "best", type = "video", dir = "./umedia-downloads", onProgress, zip = false } = {}) {
|
|
106
|
-
const requestId = rid();
|
|
107
|
-
const t0 = Date.now();
|
|
108
|
-
|
|
109
|
-
// SSRF hygiene FIRST — user-supplied URLs must never reach private/internal hosts
|
|
110
|
-
validateUrl(url);
|
|
111
|
-
|
|
112
|
-
let resolved, engine;
|
|
113
|
-
if (MEDIA_EXT.test(String(url).split("#")[0])) {
|
|
114
|
-
const ext = (String(url).match(MEDIA_EXT) || [])[1].toLowerCase();
|
|
115
|
-
const kind = /^(jpg|jpeg|png|gif|webp)$/.test(ext) ? "image" : /^(m4a|mp3|aac|wav)$/.test(ext) ? "audio" : "video";
|
|
116
|
-
resolved = {
|
|
117
|
-
sourceUrl: url, platform: "direct",
|
|
118
|
-
title: decodeURIComponent(String(url).split("/").pop().split("?")[0]) || "media",
|
|
119
|
-
itemCount: 1, counts: { images: kind === "image" ? 1 : 0, videos: kind === "video" ? 1 : 0, audios: kind === "audio" ? 1 : 0, other: 0 },
|
|
120
|
-
truncated: false,
|
|
121
|
-
media: [{
|
|
122
|
-
type: kind, index: 0, url, thumbnail: null, mimeType: null,
|
|
123
|
-
width: null, height: null, duration: null, size: null, quality: null,
|
|
124
|
-
hasAudio: kind !== "image", hasVideo: kind !== "audio", source: "direct", variants: [],
|
|
125
|
-
}],
|
|
126
|
-
};
|
|
127
|
-
engine = { attempts: [{ provider: "direct", status: "success" }], finalProvider: "direct", totalLatencyMs: 0 };
|
|
128
|
-
} else {
|
|
129
|
-
({ data: resolved, engine } = await registry.resolve(url));
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// quality selection happens on real heights of the resolved items
|
|
133
|
-
const files = [];
|
|
134
|
-
const failedItems = [];
|
|
135
|
-
let selectedQuality = null;
|
|
136
|
-
let fallback = false;
|
|
137
|
-
|
|
138
|
-
await mkdir(dir, { recursive: true });
|
|
139
|
-
|
|
140
|
-
for (const item of resolved.media) {
|
|
141
|
-
try {
|
|
142
|
-
const pick = pickFormat(
|
|
143
|
-
item.variants?.length ? item.variants.map((v) => ({
|
|
144
|
-
height: v.height, audioOnly: v.hasAudio && !v.hasVideo, url: v.url,
|
|
145
|
-
})) : [{ height: item.height, audioOnly: item.type === "audio", url: item.url }],
|
|
146
|
-
item.type === "audio" ? "audio" : quality,
|
|
147
|
-
);
|
|
148
|
-
const chosenUrl = pick.format?.url || item.url;
|
|
149
|
-
if (!chosenUrl) {
|
|
150
|
-
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE,
|
|
151
|
-
"no direct media URL available on this network (bot-gated) — install yt-dlp for hard networks");
|
|
152
|
-
}
|
|
153
|
-
validateUrl(chosenUrl); // never fetch private/internal targets, even from a provider response
|
|
154
|
-
if (!selectedQuality) { selectedQuality = pick.selectedQuality; fallback = pick.fallback; }
|
|
155
|
-
|
|
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 });
|
|
178
|
-
} catch (e) {
|
|
179
|
-
failedItems.push({ index: item.index, type: item.type, url: String(item.url || "").slice(0, 120), error: e.message });
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (!files.length) {
|
|
184
|
-
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE,
|
|
185
|
-
`Nothing could be downloaded (${failedItems[0]?.error || "unknown"}). Every failure: ${JSON.stringify(failedItems)}`,
|
|
186
|
-
{ requestId, attempts: engine?.attempts ?? [] });
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (zip && files.length > 1) {
|
|
190
|
-
const chunks = {};
|
|
191
|
-
for (const f of files) {
|
|
192
|
-
const { readFile } = await import("node:fs/promises");
|
|
193
|
-
chunks[f.name] = new Uint8Array(await readFile(f.path));
|
|
194
|
-
}
|
|
195
|
-
const zipped = zipSync(chunks);
|
|
196
|
-
const { writeFile } = await import("node:fs/promises");
|
|
197
|
-
const zipName = `${cleanName(resolved.title)}.zip`;
|
|
198
|
-
const zipPath = path.join(dir, zipName);
|
|
199
|
-
await writeFile(zipPath, zipped);
|
|
200
|
-
files.push({ name: zipName, path: zipPath, size: zipped.length, ext: "zip", mimeType: "application/zip", index: files.length, quality: null });
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return {
|
|
204
|
-
data: {
|
|
205
|
-
url, title: resolved.title ?? null, source: resolved.platform ?? null,
|
|
206
|
-
requestedQuality: quality,
|
|
207
|
-
selectedQuality,
|
|
208
|
-
fallback,
|
|
209
|
-
files,
|
|
210
|
-
itemCount: files.length,
|
|
211
|
-
requestedItems: resolved.media.length,
|
|
212
|
-
truncated: Boolean(resolved.truncated),
|
|
213
|
-
failedItems,
|
|
214
|
-
},
|
|
215
|
-
meta: { requestId, source: resolved.platform ?? null, timestamp: new Date().toISOString(), elapsedMs: Date.now() - t0 },
|
|
216
|
-
engine,
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** yt-dlp escape hatch for downloads on hard networks / 1800+ sites. */
|
|
221
|
-
export async function downloadWithYtdlp({ url, quality = "best", dir = "./umedia-downloads", onProgress } = {}) {
|
|
222
|
-
const requestId = rid();
|
|
223
|
-
validateUrl(url);
|
|
224
|
-
if (!ytdlp.binary()) throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, "yt-dlp not installed — `pip install yt-dlp`");
|
|
225
|
-
await mkdir(dir, { recursive: true });
|
|
226
|
-
const { spawn } = await import("node:child_process");
|
|
227
|
-
return new Promise((resolve, reject) => {
|
|
228
|
-
const fmt = quality === "audio" ? "bestaudio/best" : quality === "best" || quality === "original" ? "bestvideo*+bestaudio/best" : `bestvideo[height<=${parseInt(quality, 10) || 1080}]+bestaudio/best`;
|
|
229
|
-
const args = ["--no-playlist", "-f", fmt, "-o", path.join(dir, "%(title).80s [%(id)s].%(ext)s"), "--newline", url];
|
|
230
|
-
const p = spawn(ytdlp.binary(), args);
|
|
231
|
-
let lastLine = "";
|
|
232
|
-
p.stdout.on("data", (d) => {
|
|
233
|
-
const s = d.toString();
|
|
234
|
-
lastLine = s.split("\n").filter(Boolean).pop() || lastLine;
|
|
235
|
-
const m = lastLine.match(/(\d+\.\d+)%/);
|
|
236
|
-
if (m && onProgress) onProgress(Number(m[1]), 100);
|
|
237
|
-
});
|
|
238
|
-
let err = "";
|
|
239
|
-
p.stderr.on("data", (d) => { err += d.toString(); });
|
|
240
|
-
p.on("close", (code) => {
|
|
241
|
-
if (code !== 0) return reject(new UMediaError(Codes.PROVIDER_ERROR, `yt-dlp exited ${code}: ${err.split("\n").slice(-2)[0] || ""}`, { requestId }));
|
|
242
|
-
resolve({
|
|
243
|
-
data: { url, requestedQuality: quality, selectedQuality: quality, fallback: false, files: [], itemCount: 0, requestedItems: 0, truncated: false, failedItems: [], note: "saved by yt-dlp into " + dir },
|
|
244
|
-
meta: { requestId, source: "ytdlp", timestamp: new Date().toISOString(), elapsedMs: 0 },
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
});
|
|
248
|
-
}
|
package/lib/errors.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/** Stable error contract — every failure is typed, honest, traceable. */
|
|
2
|
-
export const Codes = Object.freeze({
|
|
3
|
-
INVALID_REQUEST: "INVALID_REQUEST",
|
|
4
|
-
INVALID_URL: "INVALID_URL",
|
|
5
|
-
SSRF_BLOCKED: "SSRF_BLOCKED",
|
|
6
|
-
AUTH_REQUIRED: "AUTH_REQUIRED",
|
|
7
|
-
AUTH_INVALID: "AUTH_INVALID",
|
|
8
|
-
RATE_LIMITED: "RATE_LIMITED",
|
|
9
|
-
MEDIA_NOT_FOUND: "MEDIA_NOT_FOUND",
|
|
10
|
-
CONTENT_PRIVATE: "CONTENT_PRIVATE",
|
|
11
|
-
ACCESS_RESTRICTED: "ACCESS_RESTRICTED",
|
|
12
|
-
QUALITY_UNAVAILABLE: "QUALITY_UNAVAILABLE",
|
|
13
|
-
PROVIDER_ERROR: "PROVIDER_ERROR",
|
|
14
|
-
PROVIDER_UNAVAILABLE: "PROVIDER_UNAVAILABLE",
|
|
15
|
-
TIMEOUT: "TIMEOUT",
|
|
16
|
-
NOT_FOUND: "NOT_FOUND",
|
|
17
|
-
INTERNAL: "INTERNAL",
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
export class UMediaError extends Error {
|
|
21
|
-
constructor(code, message, opts = {}) {
|
|
22
|
-
super(message);
|
|
23
|
-
this.name = "UMediaError";
|
|
24
|
-
this.code = code || Codes.INTERNAL;
|
|
25
|
-
this.status = opts.status ?? 0;
|
|
26
|
-
this.requestId = opts.requestId ?? null;
|
|
27
|
-
this.attempts = opts.attempts ?? [];
|
|
28
|
-
this.details = opts.details ?? null;
|
|
29
|
-
}
|
|
30
|
-
}
|
package/lib/providers/bluesky.js
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,100 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,126 +0,0 @@
|
|
|
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
|
-
}
|