umedia 0.1.2 → 0.2.1

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.
@@ -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,69 @@
1
+ import { fetchText, hostMatches } 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
+ try {
9
+ return hostMatches(new URL(String(url)).hostname, ["instagram.com", "instagr.am"]) && /\/(p|reel|tv)\//i.test(String(url));
10
+ } catch { return false; }
11
+ }
12
+
13
+ export async function resolve(url, ctx = {}) {
14
+ const attempt = { provider: name, status: "skipped" };
15
+ ctx.attempts?.push(attempt);
16
+ const t0 = Date.now();
17
+ const clean = String(url).split("?")[0].replace(/\/$/, "");
18
+ try {
19
+ const { status, text } = await fetchText(`${clean}/embed/captioned/`, {}, 15_000);
20
+ if (status !== 200) throw new UMediaError(Codes.PROVIDER_ERROR, `Instagram embed returned HTTP ${status}`);
21
+ const media = [];
22
+ // video first (reels), then display images
23
+ for (const m of text.matchAll(/"video_url":"(https:[^"]+?)"/g)) {
24
+ const u = m[1].replace(/\\u0026/g, "&").replace(/\\\//g, "/");
25
+ media.push({
26
+ type: "video", index: media.length, url: u, thumbnail: null, mimeType: "video/mp4",
27
+ width: null, height: null, duration: null, size: null, quality: null,
28
+ hasAudio: true, hasVideo: true, source: name, variants: [],
29
+ });
30
+ break; // first video per embed page
31
+ }
32
+ if (!media.length) {
33
+ for (const m of text.matchAll(/"display_url":"(https:[^"]+?)"/g)) {
34
+ const u = m[1].replace(/\\u0026/g, "&").replace(/\\\//g, "/");
35
+ if (!media.some((x) => x.url === u)) media.push({
36
+ type: "image", index: media.length, url: u, thumbnail: u, mimeType: "image/jpeg",
37
+ width: null, height: null, duration: null, size: null, quality: null,
38
+ hasAudio: false, hasVideo: false, source: name, variants: [],
39
+ });
40
+ }
41
+ }
42
+ if (!media.length) {
43
+ throw new UMediaError(Codes.MEDIA_NOT_FOUND, "No media exposed in the public embed (post may be private)");
44
+ }
45
+ attempt.status = "success";
46
+ attempt.latencyMs = Date.now() - t0;
47
+ return {
48
+ result: {
49
+ sourceUrl: url, platform: "instagram", title: null, author: null,
50
+ thumbnail: media[0].thumbnail, itemCount: media.length,
51
+ counts: {
52
+ images: media.filter((m) => m.type === "image").length,
53
+ videos: media.filter((m) => m.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, `Instagram unavailable: ${e.message}`, { attempts: [attempt] });
64
+ }
65
+ }
66
+
67
+ export async function search() {
68
+ return [];
69
+ }
@@ -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,97 @@
1
+ import { fetchJson, UA, hostMatches } from "../util.js";
2
+ import { Codes, UMediaError } from "../errors.js";
3
+
4
+ /** Reddit — public JSON endpoints. Datacenter IPs may get 403; the error is honest. */
5
+ export const name = "reddit";
6
+
7
+ export function canHandle(url) {
8
+ try {
9
+ return hostMatches(new URL(String(url)).hostname, ["reddit.com", "redd.it"]);
10
+ } catch { return false; }
11
+ }
12
+
13
+ export async function resolve(url, ctx = {}) {
14
+ const attempt = { provider: name, status: "skipped" };
15
+ ctx.attempts?.push(attempt);
16
+ const t0 = Date.now();
17
+ const base = String(url).split("?")[0].replace(/\/$/, "");
18
+
19
+ // Reddit edge-gates datacenter IPs per-host — try the alternate public edges, honestly.
20
+ async function fetchRedditJson() {
21
+ const path = base.replace(/^https?:\/\/[^/]+/, "");
22
+ const hosts = [...new Set([
23
+ new URL(base).host,
24
+ "old.reddit.com", "api.reddit.com", "www.reddit.com",
25
+ ])];
26
+ let lastErr = null;
27
+ for (const host of hosts) {
28
+ try {
29
+ return await fetchJson(`https://${host}${path}.json?raw_json=1`, { headers: { "user-agent": UA + " umedia-sdk" } }, 15_000);
30
+ } catch (e) { lastErr = e; }
31
+ }
32
+ throw lastErr;
33
+ }
34
+
35
+ try {
36
+ const json = await fetchRedditJson();
37
+ const post = json?.[0]?.data?.children?.[0]?.data;
38
+ if (!post) throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Reddit post not found");
39
+ const media = [];
40
+ const add = (type, u, extra = {}) => u && media.push({
41
+ type, index: media.length, url: u, thumbnail: extra.thumbnail ?? null,
42
+ mimeType: extra.mimeType ?? null, width: extra.width ?? null, height: extra.height ?? null,
43
+ duration: extra.duration ?? null, size: null, quality: extra.quality ?? null,
44
+ hasAudio: type === "video", hasVideo: type === "video", source: name, variants: [],
45
+ });
46
+
47
+ const rv = post.secure_media?.reddit_video || post.media?.reddit_video;
48
+ if (rv) add("video", rv.fallback_url, {
49
+ width: rv.width, height: rv.height, duration: rv.duration,
50
+ quality: rv.height ? `${rv.height}p` : null, mimeType: "video/mp4",
51
+ thumbnail: post.preview?.images?.[0]?.source?.url?.replace(/&amp;/g, "&") ?? null,
52
+ });
53
+
54
+ if (post.gallery_data?.items && post.media_metadata) {
55
+ for (const it of post.gallery_data.items) {
56
+ const m = post.media_metadata[it.media_id];
57
+ const src = m?.s;
58
+ const u = src?.u?.replace(/&amp;/g, "&") || src?.gif || src?.mp4;
59
+ if (u) add(m?.e === "AnimatedImage" ? "gif" : "image", u, {
60
+ width: src?.x, height: src?.y, mimeType: m?.m ?? null,
61
+ quality: src?.x && src?.y ? `${src.x}x${src.y}` : null,
62
+ });
63
+ }
64
+ }
65
+ if (!media.length && post.preview?.images?.length) {
66
+ add("image", post.preview.images[0].source.url.replace(/&amp;/g, "&"), {
67
+ width: post.preview.images[0].source.width, height: post.preview.images[0].source.height,
68
+ });
69
+ }
70
+ if (!media.length) throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Reddit post has no retrievable media");
71
+
72
+ attempt.status = "success";
73
+ attempt.latencyMs = Date.now() - t0;
74
+ return {
75
+ result: {
76
+ sourceUrl: url, platform: "reddit", title: post.title ?? null, author: post.author ?? null,
77
+ thumbnail: media[0].thumbnail ?? null, itemCount: media.length,
78
+ counts: {
79
+ images: media.filter((m) => m.type === "image").length,
80
+ videos: media.filter((m) => m.type === "video").length,
81
+ audios: 0, other: media.filter((m) => m.type === "gif").length,
82
+ },
83
+ truncated: false, media,
84
+ },
85
+ engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
86
+ };
87
+ } catch (e) {
88
+ attempt.status = "failed";
89
+ attempt.error = e.message;
90
+ if (e instanceof UMediaError) throw e;
91
+ throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Reddit unavailable: ${e.message}`, { attempts: [attempt] });
92
+ }
93
+ }
94
+
95
+ export async function search() {
96
+ return [];
97
+ }
@@ -0,0 +1,165 @@
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
+ // Frontity state (current embed/v2 shape) first, then any JSON script that carries media data
46
+ const frontity = text.split('<script id="__FRONTITY_CONNECT_STATE__" type="application/json">')[1]?.split("</script>")[0];
47
+ if (frontity) {
48
+ payload = { data: JSON.parse(frontity) };
49
+ if (id) _cache.set(id, { at: Date.now(), data: payload.data });
50
+ } else {
51
+ for (const m of text.matchAll(/<script[^>]*type="application\/json"[^>]*>([\s\S]*?)<\/script>/g)) {
52
+ try {
53
+ const parsed = JSON.parse(m[1]);
54
+ const rawStr = JSON.stringify(parsed);
55
+ if (rawStr.includes("urlList") || rawStr.includes("playAddr") || rawStr.includes("imagePost")) {
56
+ payload = { data: parsed };
57
+ if (id) _cache.set(id, { at: Date.now(), data: payload.data });
58
+ break;
59
+ }
60
+ } catch { /* not state JSON */ }
61
+ }
62
+ }
63
+ }
64
+ if (!payload) attempts.push(`embed fetch ${status}`);
65
+ } catch (e) {
66
+ attempts.push(e.message);
67
+ await new Promise((r) => setTimeout(r, 800 * (i + 1)));
68
+ }
69
+ }
70
+ }
71
+
72
+ const media = [];
73
+ const scope = payload?.data?.__DEFAULT_SCOPE__?.["webapp.video-detail"]?.itemInfo?.itemStruct
74
+ || payload?.data?.itemInfo?.itemStruct
75
+ || null;
76
+
77
+ // Frontity state — the CURRENT embed/v2 shape (Sept 2026+): source.data[<route>].videoData
78
+ let frontity = null;
79
+ const sourceData = payload?.data?.source?.data;
80
+ if (sourceData && typeof sourceData === "object") {
81
+ for (const node of Object.values(sourceData)) {
82
+ if (node && typeof node === "object" && node.videoData) { frontity = node.videoData; break; }
83
+ }
84
+ }
85
+
86
+ // images: canonical imagePost.images[] (complete) OR displayImages[] (public surface — possibly partial)
87
+ let galleryDegraded = false;
88
+ const imageList = scope?.imagePost?.images;
89
+ if (imageList?.length) {
90
+ imageList.forEach((img, i) => {
91
+ const u = img.imageURL?.urlList?.[0] || img.imageURL?.url;
92
+ if (u) media.push({
93
+ type: "image", index: i, url: u, thumbnail: u, mimeType: "image/jpeg",
94
+ width: img.width ?? null, height: img.height ?? null, duration: null, size: null,
95
+ quality: img.width && img.height ? `${img.width}x${img.height}` : null,
96
+ hasAudio: false, hasVideo: false, source: name, variants: [],
97
+ });
98
+ });
99
+ } else if (frontity?.imagePostInfo?.displayImages?.length) {
100
+ galleryDegraded = true; // platform now serves a partial gallery publicly — honest truncated flag
101
+ frontity.imagePostInfo.displayImages.forEach((img, i) => {
102
+ const u = img?.urlList?.[0];
103
+ if (u) media.push({
104
+ type: "image", index: i, url: u, thumbnail: u, mimeType: "image/jpeg",
105
+ width: img.width ?? null, height: img.height ?? null, duration: null, size: null,
106
+ quality: img.width && img.height ? `${img.width}x${img.height}` : null,
107
+ hasAudio: false, hasVideo: false, source: name, variants: [],
108
+ });
109
+ });
110
+ }
111
+
112
+ // video: classic playAddr OR Frontity itemInfos.video.urls (direct CDN mp4)
113
+ const fv = frontity?.itemInfos?.video;
114
+ const videoUrl = scope?.video?.playAddr || scope?.video?.downloadAddr
115
+ || scope?.video?.bitrateInfo?.[0]?.PlayAddr?.UrlList?.[0]
116
+ || (fv?.urls?.length ? fv.urls[0] : null);
117
+ if (videoUrl) {
118
+ const meta = scope?.video || {};
119
+ const fmeta = fv?.videoMeta || {};
120
+ media.push({
121
+ type: "video", index: media.length, url: videoUrl,
122
+ thumbnail: meta.cover || frontity?.itemInfos?.covers?.[0] || null,
123
+ mimeType: "video/mp4",
124
+ width: meta.width ?? fmeta.width ?? null, height: meta.height ?? fmeta.height ?? null,
125
+ duration: meta.duration ? meta.duration / 1000 : (fmeta.duration ?? null),
126
+ size: null,
127
+ quality: (meta.height || fmeta.height) ? `${meta.height || fmeta.height}p` : null,
128
+ hasAudio: true, hasVideo: true, source: name, variants: [],
129
+ });
130
+ }
131
+
132
+ if (!media.length) {
133
+ attempt.status = "failed";
134
+ attempt.error = attempts.length ? attempts[attempts.length - 1] : "no media exposed (post may be private or region-locked)";
135
+ // honest failure — unless oEmbed gave us at least a thumbnail-level record
136
+ throw new UMediaError(Codes.MEDIA_NOT_FOUND, `TikTok media not retrievable for this URL. ${attempt.error}`, { attempts: [attempt] });
137
+ }
138
+
139
+ attempt.status = "success";
140
+ attempt.latencyMs = Date.now() - t0;
141
+ return {
142
+ result: {
143
+ sourceUrl: url,
144
+ platform: "tiktok",
145
+ title: meta.title ?? scope?.desc ?? frontity?.itemInfos?.text ?? null,
146
+ author: meta.author_name ?? scope?.author?.nickname ?? frontity?.authorInfos?.nickName ?? frontity?.authorInfos?.uniqueId ?? null,
147
+ thumbnail: meta.thumbnail_url ?? media[0].thumbnail,
148
+ itemCount: media.length,
149
+ counts: {
150
+ images: media.filter((m) => m.type === "image").length,
151
+ videos: media.filter((m) => m.type === "video").length,
152
+ audios: 0, other: 0,
153
+ },
154
+ // honest: true when the public surface exposed only a partial gallery —
155
+ // the full slides need the yt-dlp tier (TikTok degrades embeds since 2026-09)
156
+ truncated: galleryDegraded,
157
+ media,
158
+ },
159
+ engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
160
+ };
161
+ }
162
+
163
+ export async function search() {
164
+ return [];
165
+ }
@@ -0,0 +1,71 @@
1
+ import { fetchJson, hostMatches } 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
+ try {
9
+ return hostMatches(new URL(String(url)).hostname, ["twitter.com", "x.com"]);
10
+ } catch { return false; }
11
+ }
12
+
13
+ export async function resolve(url, ctx = {}) {
14
+ const attempt = { provider: name, status: "skipped" };
15
+ ctx.attempts?.push(attempt);
16
+ const m = String(url).match(/status\/(\d+)/);
17
+ if (!m) throw new UMediaError(Codes.INVALID_URL, `No status id in ${url}`);
18
+ const t0 = Date.now();
19
+ try {
20
+ const tw = await fetchJson(`https://cdn.syndication.twimg.com/tweet-result?id=${m[1]}&lang=en`, {}, 12_000);
21
+ const media = [];
22
+ const photos = tw.photos || [];
23
+ photos.forEach((p, i) => media.push({
24
+ type: "image", index: i, url: p.url, thumbnail: p.url,
25
+ mimeType: "image/jpeg", width: p.width ?? null, height: p.height ?? null,
26
+ duration: null, size: null, quality: p.width && p.height ? `${p.width}x${p.height}` : null,
27
+ hasAudio: false, hasVideo: false, source: name, variants: [],
28
+ }));
29
+ const vids = tw.video?.variants || [];
30
+ const mp4s = vids.filter((v) => v.src?.includes(".mp4")).sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
31
+ if (mp4s.length) {
32
+ const best = mp4s[0];
33
+ media.push({
34
+ type: "video", index: media.length, url: best.src, thumbnail: tw.video?.poster ?? null,
35
+ mimeType: "video/mp4", width: null, height: null,
36
+ duration: tw.video?.duration_millis ? tw.video.duration_millis / 1000 : null,
37
+ size: null, quality: best.bitrate ? `~${Math.round(best.bitrate / 1000)}kbps` : null,
38
+ hasAudio: true, hasVideo: true, source: name,
39
+ variants: mp4s.map((v) => ({
40
+ quality: v.bitrate ? `~${Math.round(v.bitrate / 1000)}kbps` : "unknown",
41
+ url: v.src, mimeType: "video/mp4", hasAudio: true, hasVideo: true,
42
+ })),
43
+ });
44
+ }
45
+ if (!media.length) throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Post has no media (or it is not public)");
46
+ attempt.status = "success";
47
+ attempt.latencyMs = Date.now() - t0;
48
+ return {
49
+ result: {
50
+ sourceUrl: url, platform: "x", title: (tw.text || "").slice(0, 120) || null,
51
+ author: tw.user?.screen_name ?? null, thumbnail: media[0].thumbnail ?? null,
52
+ itemCount: media.length,
53
+ counts: {
54
+ images: media.filter((x) => x.type === "image").length,
55
+ videos: media.filter((x) => x.type === "video").length, audios: 0, other: 0,
56
+ },
57
+ truncated: false, media,
58
+ },
59
+ engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
60
+ };
61
+ } catch (e) {
62
+ attempt.status = "failed";
63
+ attempt.error = e.message;
64
+ if (e instanceof UMediaError) throw e;
65
+ throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `X unavailable: ${e.message}`, { attempts: [attempt] });
66
+ }
67
+ }
68
+
69
+ export async function search() {
70
+ return [];
71
+ }