umedia 0.1.1 → 0.2.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 +71 -140
- package/bin/umedia.js +25 -51
- package/index.d.ts +59 -111
- package/index.js +45 -189
- package/lib/download.js +159 -0
- package/lib/errors.js +30 -0
- package/lib/providers/instagram.js +67 -0
- package/lib/providers/itunes.js +47 -0
- package/lib/providers/reddit.js +78 -0
- package/lib/providers/tiktok.js +113 -0
- package/lib/providers/x.js +69 -0
- package/lib/providers/youtube.js +144 -0
- package/lib/providers/ytdlp.js +97 -0
- package/lib/quality.js +49 -0
- package/lib/registry.js +95 -0
- package/lib/util.js +87 -0
- package/package.json +12 -8
package/lib/download.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { mkdir, stat } 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 } 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
|
+
* Download every media item of a URL to disk — order preserved, failures named.
|
|
32
|
+
* Direct media URLs (clips, images) download as a single item — no page-resolve needed.
|
|
33
|
+
* @returns {Promise<{data: DownloadResult, meta}>}
|
|
34
|
+
*/
|
|
35
|
+
const MEDIA_EXT = /\.(mp4|webm|m4a|mp3|aac|wav|jpg|jpeg|png|gif|webp|mov|mkv)(\?|#|$)/i;
|
|
36
|
+
|
|
37
|
+
export async function download({ url, quality = "best", type = "video", dir = "./umedia-downloads", onProgress, zip = false } = {}) {
|
|
38
|
+
const requestId = rid();
|
|
39
|
+
const t0 = Date.now();
|
|
40
|
+
|
|
41
|
+
let resolved, engine;
|
|
42
|
+
if (MEDIA_EXT.test(String(url).split("#")[0])) {
|
|
43
|
+
const ext = (String(url).match(MEDIA_EXT) || [])[1].toLowerCase();
|
|
44
|
+
const kind = /^(jpg|jpeg|png|gif|webp)$/.test(ext) ? "image" : /^(m4a|mp3|aac|wav)$/.test(ext) ? "audio" : "video";
|
|
45
|
+
resolved = {
|
|
46
|
+
sourceUrl: url, platform: "direct",
|
|
47
|
+
title: decodeURIComponent(String(url).split("/").pop().split("?")[0]) || "media",
|
|
48
|
+
itemCount: 1, counts: { images: kind === "image" ? 1 : 0, videos: kind === "video" ? 1 : 0, audios: kind === "audio" ? 1 : 0, other: 0 },
|
|
49
|
+
truncated: false,
|
|
50
|
+
media: [{
|
|
51
|
+
type: kind, index: 0, url, thumbnail: null, mimeType: null,
|
|
52
|
+
width: null, height: null, duration: null, size: null, quality: null,
|
|
53
|
+
hasAudio: kind !== "image", hasVideo: kind !== "audio", source: "direct", variants: [],
|
|
54
|
+
}],
|
|
55
|
+
};
|
|
56
|
+
engine = { attempts: [{ provider: "direct", status: "success" }], finalProvider: "direct", totalLatencyMs: 0 };
|
|
57
|
+
} else {
|
|
58
|
+
({ data: resolved, engine } = await registry.resolve(url));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// quality selection happens on real heights of the resolved items
|
|
62
|
+
const files = [];
|
|
63
|
+
const failedItems = [];
|
|
64
|
+
let selectedQuality = null;
|
|
65
|
+
let fallback = false;
|
|
66
|
+
|
|
67
|
+
await mkdir(dir, { recursive: true });
|
|
68
|
+
|
|
69
|
+
for (const item of resolved.media) {
|
|
70
|
+
try {
|
|
71
|
+
const pick = pickFormat(
|
|
72
|
+
item.variants?.length ? item.variants.map((v) => ({
|
|
73
|
+
height: v.height, audioOnly: v.hasAudio && !v.hasVideo, url: v.url,
|
|
74
|
+
})) : [{ height: item.height, audioOnly: item.type === "audio", url: item.url }],
|
|
75
|
+
item.type === "audio" ? "audio" : quality,
|
|
76
|
+
);
|
|
77
|
+
const chosenUrl = pick.format?.url || item.url;
|
|
78
|
+
if (!chosenUrl) {
|
|
79
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE,
|
|
80
|
+
"no direct media URL available on this network (bot-gated) — install yt-dlp for hard networks");
|
|
81
|
+
}
|
|
82
|
+
if (!selectedQuality) { selectedQuality = pick.selectedQuality; fallback = pick.fallback; }
|
|
83
|
+
|
|
84
|
+
const ext = (chosenUrl.split("?")[0].match(/\.(mp4|webm|m4a|mp3|jpg|jpeg|png|gif|webp)$/) || [])[1]
|
|
85
|
+
|| (item.type === "image" ? "jpg" : item.type === "audio" ? "m4a" : "mp4");
|
|
86
|
+
const name = `${String(item.index + 1).padStart(3, "0")} - ${cleanName(resolved.title)}.${ext}`;
|
|
87
|
+
const dest = path.join(dir, name);
|
|
88
|
+
const size = await fetchToFile(chosenUrl, dest, onProgress);
|
|
89
|
+
files.push({ name, path: dest, size, ext, mimeType: item.mimeType ?? null, index: item.index, quality: pick.selectedQuality ?? item.quality ?? null });
|
|
90
|
+
} catch (e) {
|
|
91
|
+
failedItems.push({ index: item.index, type: item.type, url: String(item.url || "").slice(0, 120), error: e.message });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!files.length) {
|
|
96
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE,
|
|
97
|
+
`Nothing could be downloaded (${failedItems[0]?.error || "unknown"}). Every failure: ${JSON.stringify(failedItems)}`,
|
|
98
|
+
{ requestId, attempts: engine?.attempts ?? [] });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (zip && files.length > 1) {
|
|
102
|
+
const chunks = {};
|
|
103
|
+
for (const f of files) {
|
|
104
|
+
const { readFile } = await import("node:fs/promises");
|
|
105
|
+
chunks[f.name] = new Uint8Array(await readFile(f.path));
|
|
106
|
+
}
|
|
107
|
+
const zipped = zipSync(chunks);
|
|
108
|
+
const { writeFile } = await import("node:fs/promises");
|
|
109
|
+
const zipName = `${cleanName(resolved.title)}.zip`;
|
|
110
|
+
const zipPath = path.join(dir, zipName);
|
|
111
|
+
await writeFile(zipPath, zipped);
|
|
112
|
+
files.push({ name: zipName, path: zipPath, size: zipped.length, ext: "zip", mimeType: "application/zip", index: files.length, quality: null });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
data: {
|
|
117
|
+
url, title: resolved.title ?? null, source: resolved.platform ?? null,
|
|
118
|
+
requestedQuality: quality,
|
|
119
|
+
selectedQuality,
|
|
120
|
+
fallback,
|
|
121
|
+
files,
|
|
122
|
+
itemCount: files.length,
|
|
123
|
+
requestedItems: resolved.media.length,
|
|
124
|
+
truncated: Boolean(resolved.truncated),
|
|
125
|
+
failedItems,
|
|
126
|
+
},
|
|
127
|
+
meta: { requestId, source: resolved.platform ?? null, timestamp: new Date().toISOString(), elapsedMs: Date.now() - t0 },
|
|
128
|
+
engine,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** yt-dlp escape hatch for downloads on hard networks / 1800+ sites. */
|
|
133
|
+
export async function downloadWithYtdlp({ url, quality = "best", dir = "./umedia-downloads", onProgress } = {}) {
|
|
134
|
+
const requestId = rid();
|
|
135
|
+
if (!ytdlp.binary()) throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, "yt-dlp not installed — `pip install yt-dlp`");
|
|
136
|
+
await mkdir(dir, { recursive: true });
|
|
137
|
+
const { spawn } = await import("node:child_process");
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const fmt = quality === "audio" ? "bestaudio/best" : quality === "best" || quality === "original" ? "bestvideo*+bestaudio/best" : `bestvideo[height<=${parseInt(quality, 10) || 1080}]+bestaudio/best`;
|
|
140
|
+
const args = ["--no-playlist", "-f", fmt, "-o", path.join(dir, "%(title).80s [%(id)s].%(ext)s"), "--newline", url];
|
|
141
|
+
const p = spawn(ytdlp.binary(), args);
|
|
142
|
+
let lastLine = "";
|
|
143
|
+
p.stdout.on("data", (d) => {
|
|
144
|
+
const s = d.toString();
|
|
145
|
+
lastLine = s.split("\n").filter(Boolean).pop() || lastLine;
|
|
146
|
+
const m = lastLine.match(/(\d+\.\d+)%/);
|
|
147
|
+
if (m && onProgress) onProgress(Number(m[1]), 100);
|
|
148
|
+
});
|
|
149
|
+
let err = "";
|
|
150
|
+
p.stderr.on("data", (d) => { err += d.toString(); });
|
|
151
|
+
p.on("close", (code) => {
|
|
152
|
+
if (code !== 0) return reject(new UMediaError(Codes.PROVIDER_ERROR, `yt-dlp exited ${code}: ${err.split("\n").slice(-2)[0] || ""}`, { requestId }));
|
|
153
|
+
resolve({
|
|
154
|
+
data: { url, requestedQuality: quality, selectedQuality: quality, fallback: false, files: [], itemCount: 0, requestedItems: 0, truncated: false, failedItems: [], note: "saved by yt-dlp into " + dir },
|
|
155
|
+
meta: { requestId, source: "ytdlp", timestamp: new Date().toISOString(), elapsedMs: 0 },
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { fetchText } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/** Instagram — public embed page parse. Best-effort: IG gates aggressively; failures are typed. */
|
|
5
|
+
export const name = "instagram";
|
|
6
|
+
|
|
7
|
+
export function canHandle(url) {
|
|
8
|
+
return /(?:^|\.)(instagram\.com|instagr\.am)\//i.test(String(url)) && /\/(p|reel|tv)\//i.test(String(url));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function resolve(url, ctx = {}) {
|
|
12
|
+
const attempt = { provider: name, status: "skipped" };
|
|
13
|
+
ctx.attempts?.push(attempt);
|
|
14
|
+
const t0 = Date.now();
|
|
15
|
+
const clean = String(url).split("?")[0].replace(/\/$/, "");
|
|
16
|
+
try {
|
|
17
|
+
const { status, text } = await fetchText(`${clean}/embed/captioned/`, {}, 15_000);
|
|
18
|
+
if (status !== 200) throw new UMediaError(Codes.PROVIDER_ERROR, `Instagram embed returned HTTP ${status}`);
|
|
19
|
+
const media = [];
|
|
20
|
+
// video first (reels), then display images
|
|
21
|
+
for (const m of text.matchAll(/"video_url":"(https:[^"]+?)"/g)) {
|
|
22
|
+
const u = m[1].replace(/\\u0026/g, "&").replace(/\\\//g, "/");
|
|
23
|
+
media.push({
|
|
24
|
+
type: "video", index: media.length, url: u, thumbnail: null, mimeType: "video/mp4",
|
|
25
|
+
width: null, height: null, duration: null, size: null, quality: null,
|
|
26
|
+
hasAudio: true, hasVideo: true, source: name, variants: [],
|
|
27
|
+
});
|
|
28
|
+
break; // first video per embed page
|
|
29
|
+
}
|
|
30
|
+
if (!media.length) {
|
|
31
|
+
for (const m of text.matchAll(/"display_url":"(https:[^"]+?)"/g)) {
|
|
32
|
+
const u = m[1].replace(/\\u0026/g, "&").replace(/\\\//g, "/");
|
|
33
|
+
if (!media.some((x) => x.url === u)) media.push({
|
|
34
|
+
type: "image", index: media.length, url: u, thumbnail: u, mimeType: "image/jpeg",
|
|
35
|
+
width: null, height: null, duration: null, size: null, quality: null,
|
|
36
|
+
hasAudio: false, hasVideo: false, source: name, variants: [],
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!media.length) {
|
|
41
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, "No media exposed in the public embed (post may be private)");
|
|
42
|
+
}
|
|
43
|
+
attempt.status = "success";
|
|
44
|
+
attempt.latencyMs = Date.now() - t0;
|
|
45
|
+
return {
|
|
46
|
+
result: {
|
|
47
|
+
sourceUrl: url, platform: "instagram", title: null, author: null,
|
|
48
|
+
thumbnail: media[0].thumbnail, itemCount: media.length,
|
|
49
|
+
counts: {
|
|
50
|
+
images: media.filter((m) => m.type === "image").length,
|
|
51
|
+
videos: media.filter((m) => m.type === "video").length, audios: 0, other: 0,
|
|
52
|
+
},
|
|
53
|
+
truncated: false, media,
|
|
54
|
+
},
|
|
55
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
56
|
+
};
|
|
57
|
+
} catch (e) {
|
|
58
|
+
attempt.status = "failed";
|
|
59
|
+
attempt.error = e.message;
|
|
60
|
+
if (e instanceof UMediaError) throw e;
|
|
61
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Instagram unavailable: ${e.message}`, { attempts: [attempt] });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function search() {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { fetchJson } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/** iTunes Search API — reliable everywhere. Music leads with REAL 30s previews. */
|
|
5
|
+
export const name = "itunes";
|
|
6
|
+
|
|
7
|
+
export async function search({ q, type = "music", limit = 12 }, ctx = {}) {
|
|
8
|
+
const attempt = { provider: name, status: "skipped" };
|
|
9
|
+
ctx.attempts?.push(attempt);
|
|
10
|
+
if (type !== "music") {
|
|
11
|
+
attempt.error = "itunes serves music only (movie endpoint is dead upstream)";
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
const t0 = Date.now();
|
|
15
|
+
try {
|
|
16
|
+
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(q)}&media=music&entity=song&limit=${Math.min(limit, 25)}`;
|
|
17
|
+
const json = await fetchJson(url, {}, 15_000);
|
|
18
|
+
attempt.status = "success";
|
|
19
|
+
attempt.latencyMs = Date.now() - t0;
|
|
20
|
+
return (json.results || []).map((r) => ({
|
|
21
|
+
title: r.trackName,
|
|
22
|
+
author: r.artistName,
|
|
23
|
+
pageUrl: r.trackViewUrl,
|
|
24
|
+
thumbnail: (r.artworkUrl100 || "").replace("100x100bb", "600x600bb") || null,
|
|
25
|
+
previewUrl: r.previewUrl || null,
|
|
26
|
+
previewKind: r.previewUrl ? "clip30s" : "none",
|
|
27
|
+
duration: r.trackTimeMillis ? r.trackTimeMillis / 1000 : null,
|
|
28
|
+
mediaType: "music",
|
|
29
|
+
source: name,
|
|
30
|
+
explicit: Boolean(r.trackExplicitness === "explicit"),
|
|
31
|
+
}));
|
|
32
|
+
} catch (e) {
|
|
33
|
+
attempt.status = "failed";
|
|
34
|
+
attempt.error = e.message;
|
|
35
|
+
if (e instanceof UMediaError && e.code === Codes.RATE_LIMITED) throw e;
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** iTunes media (preview clips) resolve straight from search metadata. */
|
|
41
|
+
export async function resolve(url, ctx = {}) {
|
|
42
|
+
return { items: [], attempt: { provider: name, status: "skipped", error: "itunes media comes from search previews" } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function canHandle() {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { fetchJson, UA } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/** Reddit — public JSON endpoints. Datacenter IPs may get 403; the error is honest. */
|
|
5
|
+
export const name = "reddit";
|
|
6
|
+
|
|
7
|
+
export function canHandle(url) {
|
|
8
|
+
return /(?:^|\.)(reddit\.com|redd\.it)\//i.test(String(url));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function resolve(url, ctx = {}) {
|
|
12
|
+
const attempt = { provider: name, status: "skipped" };
|
|
13
|
+
ctx.attempts?.push(attempt);
|
|
14
|
+
const t0 = Date.now();
|
|
15
|
+
const base = String(url).split("?")[0].replace(/\/$/, "");
|
|
16
|
+
try {
|
|
17
|
+
const json = await fetchJson(`${base}.json?raw_json=1`, { headers: { "user-agent": UA + " umedia-sdk" } }, 15_000);
|
|
18
|
+
const post = json?.[0]?.data?.children?.[0]?.data;
|
|
19
|
+
if (!post) throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Reddit post not found");
|
|
20
|
+
const media = [];
|
|
21
|
+
const add = (type, u, extra = {}) => u && media.push({
|
|
22
|
+
type, index: media.length, url: u, thumbnail: extra.thumbnail ?? null,
|
|
23
|
+
mimeType: extra.mimeType ?? null, width: extra.width ?? null, height: extra.height ?? null,
|
|
24
|
+
duration: extra.duration ?? null, size: null, quality: extra.quality ?? null,
|
|
25
|
+
hasAudio: type === "video", hasVideo: type === "video", source: name, variants: [],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const rv = post.secure_media?.reddit_video || post.media?.reddit_video;
|
|
29
|
+
if (rv) add("video", rv.fallback_url, {
|
|
30
|
+
width: rv.width, height: rv.height, duration: rv.duration,
|
|
31
|
+
quality: rv.height ? `${rv.height}p` : null, mimeType: "video/mp4",
|
|
32
|
+
thumbnail: post.preview?.images?.[0]?.source?.url?.replace(/&/g, "&") ?? null,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (post.gallery_data?.items && post.media_metadata) {
|
|
36
|
+
for (const it of post.gallery_data.items) {
|
|
37
|
+
const m = post.media_metadata[it.media_id];
|
|
38
|
+
const src = m?.s;
|
|
39
|
+
const u = src?.u?.replace(/&/g, "&") || src?.gif || src?.mp4;
|
|
40
|
+
if (u) add(m?.e === "AnimatedImage" ? "gif" : "image", u, {
|
|
41
|
+
width: src?.x, height: src?.y, mimeType: m?.m ?? null,
|
|
42
|
+
quality: src?.x && src?.y ? `${src.x}x${src.y}` : null,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!media.length && post.preview?.images?.length) {
|
|
47
|
+
add("image", post.preview.images[0].source.url.replace(/&/g, "&"), {
|
|
48
|
+
width: post.preview.images[0].source.width, height: post.preview.images[0].source.height,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (!media.length) throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Reddit post has no retrievable media");
|
|
52
|
+
|
|
53
|
+
attempt.status = "success";
|
|
54
|
+
attempt.latencyMs = Date.now() - t0;
|
|
55
|
+
return {
|
|
56
|
+
result: {
|
|
57
|
+
sourceUrl: url, platform: "reddit", title: post.title ?? null, author: post.author ?? null,
|
|
58
|
+
thumbnail: media[0].thumbnail ?? null, itemCount: media.length,
|
|
59
|
+
counts: {
|
|
60
|
+
images: media.filter((m) => m.type === "image").length,
|
|
61
|
+
videos: media.filter((m) => m.type === "video").length,
|
|
62
|
+
audios: 0, other: media.filter((m) => m.type === "gif").length,
|
|
63
|
+
},
|
|
64
|
+
truncated: false, media,
|
|
65
|
+
},
|
|
66
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
67
|
+
};
|
|
68
|
+
} catch (e) {
|
|
69
|
+
attempt.status = "failed";
|
|
70
|
+
attempt.error = e.message;
|
|
71
|
+
if (e instanceof UMediaError) throw e;
|
|
72
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Reddit unavailable: ${e.message}`, { attempts: [attempt] });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function search() {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { fetchJson, fetchText } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/** TikTok — oEmbed metadata + embed/v2 for photo posts (proven adapter), CDN media. */
|
|
5
|
+
export const name = "tiktok";
|
|
6
|
+
|
|
7
|
+
export function canHandle(url) {
|
|
8
|
+
return /tiktok\.com\//i.test(String(url)) || /vm\.tiktok\.com\//i.test(String(url));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function postId(url) {
|
|
12
|
+
const m = String(url).match(/\/(?:photo|video|embed\/v2?)\/(\d+)/);
|
|
13
|
+
return m ? m[1] : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const _cache = new Map(); // id -> {at, data}
|
|
17
|
+
|
|
18
|
+
export async function resolve(url, ctx = {}) {
|
|
19
|
+
const attempt = { provider: name, status: "skipped" };
|
|
20
|
+
ctx.attempts?.push(attempt);
|
|
21
|
+
const t0 = Date.now();
|
|
22
|
+
|
|
23
|
+
// 1) oEmbed: title/author/thumbnail (works for public posts)
|
|
24
|
+
let meta = {};
|
|
25
|
+
try {
|
|
26
|
+
meta = await fetchJson(`https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`, {}, 12_000);
|
|
27
|
+
} catch { /* oEmbed is advisory */ }
|
|
28
|
+
|
|
29
|
+
// 2) embed/v2: the reliable path for photo galleries + video playAddr
|
|
30
|
+
const id = postId(url);
|
|
31
|
+
const attempts = [];
|
|
32
|
+
let payload = id ? _cache.get(id) : null;
|
|
33
|
+
if (!payload?.data) {
|
|
34
|
+
for (let i = 0; i < 3 && !payload; i++) {
|
|
35
|
+
try {
|
|
36
|
+
const target = id
|
|
37
|
+
? `https://www.tiktok.com/embed/v2/${id}`
|
|
38
|
+
: `https://www.tiktok.com/embed/${encodeURIComponent(url)}`;
|
|
39
|
+
const { status, text } = await fetchText(target, {}, 15_000);
|
|
40
|
+
if (status === 200 && text.includes("__UNIVERSAL_DATA")) {
|
|
41
|
+
const raw = text.split('<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">')[1]?.split("</script>")[0];
|
|
42
|
+
payload = { data: JSON.parse(raw) };
|
|
43
|
+
if (id) _cache.set(id, { at: Date.now(), data: payload.data });
|
|
44
|
+
} else if (status === 200) {
|
|
45
|
+
const m = text.match(/<script id="[^"]*" type="application\/json">(.*?)<\/script>/s);
|
|
46
|
+
if (m) { payload = { data: JSON.parse(m[1]) }; if (id) _cache.set(id, { at: Date.now(), data: payload.data }); }
|
|
47
|
+
}
|
|
48
|
+
if (!payload) attempts.push(`embed fetch ${status}`);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
attempts.push(e.message);
|
|
51
|
+
await new Promise((r) => setTimeout(r, 800 * (i + 1)));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const media = [];
|
|
57
|
+
const scope = payload?.data?.__DEFAULT_SCOPE__?.["webapp.video-detail"]?.itemInfo?.itemStruct
|
|
58
|
+
|| payload?.data?.itemInfo?.itemStruct
|
|
59
|
+
|| null;
|
|
60
|
+
|
|
61
|
+
if (scope?.imagePost?.images?.length) {
|
|
62
|
+
scope.imagePost.images.forEach((img, i) => {
|
|
63
|
+
const u = img.imageURL?.urlList?.[0] || img.imageURL?.url;
|
|
64
|
+
if (u) media.push({
|
|
65
|
+
type: "image", index: i, url: u, thumbnail: u, mimeType: "image/jpeg",
|
|
66
|
+
width: img.width ?? null, height: img.height ?? null, duration: null, size: null,
|
|
67
|
+
quality: img.width && img.height ? `${img.width}x${img.height}` : null,
|
|
68
|
+
hasAudio: false, hasVideo: false, source: name, variants: [],
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
} else if (scope?.video) {
|
|
72
|
+
const u = scope.video.playAddr || scope.video.downloadAddr || scope.video.bitrateInfo?.[0]?.PlayAddr?.UrlList?.[0];
|
|
73
|
+
if (u) media.push({
|
|
74
|
+
type: "video", index: 0, url: u, thumbnail: scope.video.cover || meta.thumbnail_url || null,
|
|
75
|
+
mimeType: "video/mp4", width: scope.video.width ?? null, height: scope.video.height ?? null,
|
|
76
|
+
duration: scope.video.duration ? scope.video.duration / 1000 : null, size: null,
|
|
77
|
+
quality: scope.video.height ? `${scope.video.height}p` : null,
|
|
78
|
+
hasAudio: true, hasVideo: true, source: name, variants: [],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!media.length) {
|
|
83
|
+
attempt.status = "failed";
|
|
84
|
+
attempt.error = attempts.length ? attempts[attempts.length - 1] : "no media exposed (post may be private or region-locked)";
|
|
85
|
+
// honest failure — unless oEmbed gave us at least a thumbnail-level record
|
|
86
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, `TikTok media not retrievable for this URL. ${attempt.error}`, { attempts: [attempt] });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
attempt.status = "success";
|
|
90
|
+
attempt.latencyMs = Date.now() - t0;
|
|
91
|
+
return {
|
|
92
|
+
result: {
|
|
93
|
+
sourceUrl: url,
|
|
94
|
+
platform: "tiktok",
|
|
95
|
+
title: meta.title ?? scope?.desc ?? null,
|
|
96
|
+
author: meta.author_name ?? scope?.author?.nickname ?? null,
|
|
97
|
+
thumbnail: meta.thumbnail_url ?? media[0].thumbnail,
|
|
98
|
+
itemCount: media.length,
|
|
99
|
+
counts: {
|
|
100
|
+
images: media.filter((m) => m.type === "image").length,
|
|
101
|
+
videos: media.filter((m) => m.type === "video").length,
|
|
102
|
+
audios: 0, other: 0,
|
|
103
|
+
},
|
|
104
|
+
truncated: false,
|
|
105
|
+
media,
|
|
106
|
+
},
|
|
107
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function search() {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { fetchJson } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/** X (Twitter) — syndication endpoint for public posts. Best-effort, honestly labeled. */
|
|
5
|
+
export const name = "x";
|
|
6
|
+
|
|
7
|
+
export function canHandle(url) {
|
|
8
|
+
return /(?:^|\.)(twitter\.com|x\.com)\//i.test(String(url));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function resolve(url, ctx = {}) {
|
|
12
|
+
const attempt = { provider: name, status: "skipped" };
|
|
13
|
+
ctx.attempts?.push(attempt);
|
|
14
|
+
const m = String(url).match(/status\/(\d+)/);
|
|
15
|
+
if (!m) throw new UMediaError(Codes.INVALID_URL, `No status id in ${url}`);
|
|
16
|
+
const t0 = Date.now();
|
|
17
|
+
try {
|
|
18
|
+
const tw = await fetchJson(`https://cdn.syndication.twimg.com/tweet-result?id=${m[1]}&lang=en`, {}, 12_000);
|
|
19
|
+
const media = [];
|
|
20
|
+
const photos = tw.photos || [];
|
|
21
|
+
photos.forEach((p, i) => media.push({
|
|
22
|
+
type: "image", index: i, url: p.url, thumbnail: p.url,
|
|
23
|
+
mimeType: "image/jpeg", width: p.width ?? null, height: p.height ?? null,
|
|
24
|
+
duration: null, size: null, quality: p.width && p.height ? `${p.width}x${p.height}` : null,
|
|
25
|
+
hasAudio: false, hasVideo: false, source: name, variants: [],
|
|
26
|
+
}));
|
|
27
|
+
const vids = tw.video?.variants || [];
|
|
28
|
+
const mp4s = vids.filter((v) => v.src?.includes(".mp4")).sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
|
|
29
|
+
if (mp4s.length) {
|
|
30
|
+
const best = mp4s[0];
|
|
31
|
+
media.push({
|
|
32
|
+
type: "video", index: media.length, url: best.src, thumbnail: tw.video?.poster ?? null,
|
|
33
|
+
mimeType: "video/mp4", width: null, height: null,
|
|
34
|
+
duration: tw.video?.duration_millis ? tw.video.duration_millis / 1000 : null,
|
|
35
|
+
size: null, quality: best.bitrate ? `~${Math.round(best.bitrate / 1000)}kbps` : null,
|
|
36
|
+
hasAudio: true, hasVideo: true, source: name,
|
|
37
|
+
variants: mp4s.map((v) => ({
|
|
38
|
+
quality: v.bitrate ? `~${Math.round(v.bitrate / 1000)}kbps` : "unknown",
|
|
39
|
+
url: v.src, mimeType: "video/mp4", hasAudio: true, hasVideo: true,
|
|
40
|
+
})),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (!media.length) throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Post has no media (or it is not public)");
|
|
44
|
+
attempt.status = "success";
|
|
45
|
+
attempt.latencyMs = Date.now() - t0;
|
|
46
|
+
return {
|
|
47
|
+
result: {
|
|
48
|
+
sourceUrl: url, platform: "x", title: (tw.text || "").slice(0, 120) || null,
|
|
49
|
+
author: tw.user?.screen_name ?? null, thumbnail: media[0].thumbnail ?? null,
|
|
50
|
+
itemCount: media.length,
|
|
51
|
+
counts: {
|
|
52
|
+
images: media.filter((x) => x.type === "image").length,
|
|
53
|
+
videos: media.filter((x) => x.type === "video").length, audios: 0, other: 0,
|
|
54
|
+
},
|
|
55
|
+
truncated: false, media,
|
|
56
|
+
},
|
|
57
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
58
|
+
};
|
|
59
|
+
} catch (e) {
|
|
60
|
+
attempt.status = "failed";
|
|
61
|
+
attempt.error = e.message;
|
|
62
|
+
if (e instanceof UMediaError) throw e;
|
|
63
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `X unavailable: ${e.message}`, { attempts: [attempt] });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function search() {
|
|
68
|
+
return [];
|
|
69
|
+
}
|