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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { fetchJson, fetchText, hostMatches } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Snapchat — Spotlight videos (preload link on the page) and public stories
|
|
6
|
+
* (the page's __NEXT_DATA__ snap list — ALL snaps, in order).
|
|
7
|
+
* Reference: cobalt snapchat service.
|
|
8
|
+
*/
|
|
9
|
+
export const name = "snapchat";
|
|
10
|
+
|
|
11
|
+
const SPOTLIGHT_PRELOAD = /<link data-react-helmet="true" rel="preload" href="([^"]+)" as="video"\/>/;
|
|
12
|
+
const NEXT_DATA = /<script id="__NEXT_DATA__" type="application\/json">({.+?})<\/script>/;
|
|
13
|
+
|
|
14
|
+
export function canHandle(url) {
|
|
15
|
+
try {
|
|
16
|
+
return hostMatches(new URL(String(url)).hostname, ["snapchat.com", "t.snapchat.com"]);
|
|
17
|
+
} catch { return false; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function resolve(url, ctx = {}) {
|
|
21
|
+
const attempt = { provider: name, status: "skipped" };
|
|
22
|
+
ctx.attempts?.push(attempt);
|
|
23
|
+
const t0 = Date.now();
|
|
24
|
+
|
|
25
|
+
let target = String(url);
|
|
26
|
+
if (/t\.snapchat\.com\//i.test(target)) {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(target, { redirect: "follow", headers: { "user-agent": "Mozilla/5.0" } });
|
|
29
|
+
target = res.url || target;
|
|
30
|
+
} catch { /* keep original */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const media = [];
|
|
34
|
+
let title = null;
|
|
35
|
+
let author = null;
|
|
36
|
+
|
|
37
|
+
const spotlight = target.match(/\/spotlight\/([\w-]+)/i);
|
|
38
|
+
const story = target.match(/\/add\/([\w.-]+)(?:\/([\w-]+))?/i);
|
|
39
|
+
|
|
40
|
+
if (spotlight) {
|
|
41
|
+
const r = await fetchText(`https://www.snapchat.com/spotlight/${spotlight[1]}`, { headers: { "user-agent": "Mozilla/5.0" } }, 18_000).catch(() => null);
|
|
42
|
+
const v = r?.text?.match(SPOTLIGHT_PRELOAD)?.[1];
|
|
43
|
+
if (v && new URL(v).hostname.endsWith("sc-cdn.net")) {
|
|
44
|
+
media.push({
|
|
45
|
+
type: "video", index: 0, url: v, thumbnail: null,
|
|
46
|
+
mimeType: "video/mp4", width: null, height: null, duration: null, size: null,
|
|
47
|
+
quality: null, hasAudio: true, hasVideo: true, source: name, variants: [],
|
|
48
|
+
});
|
|
49
|
+
title = (r.text.match(/"title":"([^"]{1,120})"/) || r.text.match(/<title[^>]*>([^<]*)/i) || [])[1] || null;
|
|
50
|
+
}
|
|
51
|
+
} else if (story) {
|
|
52
|
+
const r = await fetchText(`https://www.snapchat.com/add/${story[1]}${story[2] ? "/" + story[2] : ""}`,
|
|
53
|
+
{ headers: { "user-agent": "Mozilla/5.0" } }, 18_000).catch(() => null);
|
|
54
|
+
const raw = r?.text?.match(NEXT_DATA)?.[1];
|
|
55
|
+
if (raw) {
|
|
56
|
+
const data = JSON.parse(raw);
|
|
57
|
+
author = story[1];
|
|
58
|
+
const snaps = data?.props?.pageProps?.story?.snapList
|
|
59
|
+
|| data?.props?.pageProps?.curatedHighlights?.[0]?.snapList
|
|
60
|
+
|| [];
|
|
61
|
+
snaps.forEach((snap) => {
|
|
62
|
+
const isPhoto = snap.snapMediaType === 0;
|
|
63
|
+
const u = snap.snapUrls?.mediaUrl;
|
|
64
|
+
if (!u) return;
|
|
65
|
+
media.push({
|
|
66
|
+
type: isPhoto ? "image" : "video", index: media.length, url: u,
|
|
67
|
+
thumbnail: snap.snapUrls?.mediaPreviewUrl?.value || u,
|
|
68
|
+
mimeType: isPhoto ? "image/jpeg" : "video/mp4",
|
|
69
|
+
width: null, height: null,
|
|
70
|
+
duration: snap.duration ? Math.round(snap.duration / 1000) : null,
|
|
71
|
+
size: null, quality: null,
|
|
72
|
+
hasAudio: !isPhoto, hasVideo: !isPhoto, source: name, variants: [],
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!media.length) {
|
|
79
|
+
attempt.status = "failed";
|
|
80
|
+
attempt.error = "no snap media exposed (removed, private, or login-gated)";
|
|
81
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND,
|
|
82
|
+
`Snapchat exposes no media for this URL (${attempt.error}) — the yt-dlp tier covers gated stories.`, { attempts: [attempt] });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
attempt.status = "success";
|
|
86
|
+
attempt.latencyMs = Date.now() - t0;
|
|
87
|
+
return {
|
|
88
|
+
result: {
|
|
89
|
+
sourceUrl: url,
|
|
90
|
+
platform: name,
|
|
91
|
+
title: title || (author ? `Snapchat story @${author}` : null),
|
|
92
|
+
author,
|
|
93
|
+
thumbnail: media[0].thumbnail,
|
|
94
|
+
itemCount: media.length,
|
|
95
|
+
counts: { images: media.filter((m) => m.type === "image").length, videos: media.filter((m) => m.type === "video").length, audios: 0, other: 0 },
|
|
96
|
+
truncated: false,
|
|
97
|
+
media,
|
|
98
|
+
streamUrlsAvailable: true,
|
|
99
|
+
},
|
|
100
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function search() {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { fetchJson, fetchText, hostMatches, UA } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SoundCloud — public api-v2 (the route the web player itself calls):
|
|
6
|
+
* resolve -> transcodings -> CDN hop -> direct progressive MP3 (or HLS).
|
|
7
|
+
* This is the music pipeline: search elsewhere, download REAL full tracks here.
|
|
8
|
+
*/
|
|
9
|
+
export const name = "soundcloud";
|
|
10
|
+
|
|
11
|
+
let _client_id = null; // cached; rotated by SoundCloud occasionally
|
|
12
|
+
let _cidFetchedAt = 0;
|
|
13
|
+
|
|
14
|
+
export function canHandle(url) {
|
|
15
|
+
try {
|
|
16
|
+
return hostMatches(new URL(String(url)).hostname, ["soundcloud.com", "snd.sc"]);
|
|
17
|
+
} catch { return false; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function trackId(url) {
|
|
21
|
+
const m = String(url).match(/soundcloud\.com\/([\w-]+)\/([\w-]+)/i);
|
|
22
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function clientId() {
|
|
26
|
+
if (_client_id && Date.now() - _cidFetchedAt < 6 * 3600_000) return _client_id;
|
|
27
|
+
// SoundCloud ships its public client_id inside its JS bundles — read it like the web player does
|
|
28
|
+
const page = await fetchText("https://soundcloud.com/discover", {}, 15_000);
|
|
29
|
+
const bundles = [...new Set((page.text.match(/https:\/\/a-v2\.sndcdn\.com\/assets\/[^"']+\.js/g) || []))].slice(0, 14);
|
|
30
|
+
for (const b of bundles) {
|
|
31
|
+
try {
|
|
32
|
+
const js = await fetchText(b, {}, 15_000);
|
|
33
|
+
const m = js.text.match(/client_id[":= ]{1,4}([A-Za-z0-9]{24,40})/);
|
|
34
|
+
if (m) {
|
|
35
|
+
_client_id = m[1];
|
|
36
|
+
_cidFetchedAt = Date.now();
|
|
37
|
+
return _client_id;
|
|
38
|
+
}
|
|
39
|
+
} catch { /* try the next bundle */ }
|
|
40
|
+
}
|
|
41
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, "SoundCloud client_id not retrievable (site changed) — use the yt-dlp tier");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function resolve(url, ctx = {}) {
|
|
45
|
+
const attempt = { provider: name, status: "skipped" };
|
|
46
|
+
ctx.attempts?.push(attempt);
|
|
47
|
+
const t0 = Date.now();
|
|
48
|
+
|
|
49
|
+
const slug = trackId(url);
|
|
50
|
+
if (!slug) {
|
|
51
|
+
attempt.status = "failed";
|
|
52
|
+
throw new UMediaError(Codes.INVALID_URL, "Not a SoundCloud track URL (expected soundcloud.com/{user}/{track})", { attempts: [attempt] });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const cid = await clientId();
|
|
56
|
+
let track;
|
|
57
|
+
try {
|
|
58
|
+
track = await fetchJson(
|
|
59
|
+
`https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(String(url).split("?")[0])}&client_id=${cid}`, {}, 20_000);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
attempt.status = "failed";
|
|
62
|
+
attempt.error = e.message;
|
|
63
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, `SoundCloud track not retrievable. ${e.message}`, { attempts: [attempt] });
|
|
64
|
+
}
|
|
65
|
+
if (!track || track.kind !== "track") {
|
|
66
|
+
attempt.status = "failed";
|
|
67
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, "SoundCloud: not a public track (removed, private, or a set)", { attempts: [attempt] });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const media = [];
|
|
71
|
+
const variants = [];
|
|
72
|
+
for (const tr of (track.media?.transcodings || [])) {
|
|
73
|
+
const fmt = tr.format || {};
|
|
74
|
+
const isProg = fmt.protocol === "progressive";
|
|
75
|
+
const isMp3 = /mpeg|mp3/i.test(fmt.mime_type || "");
|
|
76
|
+
variants.push({
|
|
77
|
+
url: `${tr.url}${tr.url.includes("?") ? "&" : "?"}client_id=${cid}`,
|
|
78
|
+
height: null, width: null,
|
|
79
|
+
hasAudio: true, hasVideo: false,
|
|
80
|
+
quality: tr.quality || (isProg ? "progressive" : fmt.protocol),
|
|
81
|
+
mimeType: fmt.mime_type || null,
|
|
82
|
+
protocol: fmt.protocol,
|
|
83
|
+
_mp3: isProg && isMp3,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// resolve each transcoding hop -> real CDN URL (progressive mp3 first, then HLS)
|
|
87
|
+
const order = [...variants].sort((a, b) => (b._mp3 ? 1 : 0) - (a._mp3 ? 1 : 0));
|
|
88
|
+
const resolvedVariants = [];
|
|
89
|
+
for (const v of order) {
|
|
90
|
+
try {
|
|
91
|
+
const hop = await fetchJson(v.url, {}, 15_000);
|
|
92
|
+
if (hop?.url) resolvedVariants.push({ ...v, url: hop.url });
|
|
93
|
+
} catch { /* rights-restricted encoding — skip honestly */ }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!resolvedVariants.length) {
|
|
97
|
+
attempt.status = "failed";
|
|
98
|
+
attempt.error = "no downloadable encodings (rights-restricted)";
|
|
99
|
+
throw new UMediaError(Codes.ACCESS_RESTRICTED,
|
|
100
|
+
"SoundCloud exposes no downloadable encoding for this track (rights-restricted) — stream it in-app or use the yt-dlp tier", { attempts: [attempt] });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const chosen = resolvedVariants.find((v) => v._mp3) || resolvedVariants[0];
|
|
104
|
+
media.push({
|
|
105
|
+
type: "audio", index: 0, url: chosen.url,
|
|
106
|
+
thumbnail: track.artwork_url || track.user?.avatar_url || null,
|
|
107
|
+
mimeType: chosen.mimeType || (/mpegurl/.test(chosen.mimeType || "") ? "application/x-mpegURL" : "audio/mpeg"),
|
|
108
|
+
width: null, height: null,
|
|
109
|
+
duration: track.duration ? Math.round(track.duration / 1000) : null,
|
|
110
|
+
size: null, quality: chosen.quality,
|
|
111
|
+
hasAudio: true, hasVideo: false, source: name,
|
|
112
|
+
variants: resolvedVariants.map((v) => ({
|
|
113
|
+
height: null, width: null, url: v.url,
|
|
114
|
+
hasAudio: true, hasVideo: false, quality: v.quality, mimeType: v.mimeType,
|
|
115
|
+
})),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
attempt.status = "success";
|
|
119
|
+
attempt.latencyMs = Date.now() - t0;
|
|
120
|
+
return {
|
|
121
|
+
result: {
|
|
122
|
+
sourceUrl: url,
|
|
123
|
+
platform: name,
|
|
124
|
+
title: track.title || null,
|
|
125
|
+
author: track.user?.username || null,
|
|
126
|
+
thumbnail: track.artwork_url || null,
|
|
127
|
+
itemCount: 1,
|
|
128
|
+
counts: { images: 0, videos: 0, audios: 1, other: 0 },
|
|
129
|
+
truncated: false,
|
|
130
|
+
media,
|
|
131
|
+
},
|
|
132
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function search() {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { fetchJson, hostMatches } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Streamable — the public video JSON (api.streamable.com/videos/{id}):
|
|
6
|
+
* direct mp4 renditions (mp4 / mp4-mobile / original) with real heights.
|
|
7
|
+
*/
|
|
8
|
+
export const name = "streamable";
|
|
9
|
+
|
|
10
|
+
export function canHandle(url) {
|
|
11
|
+
try {
|
|
12
|
+
return hostMatches(new URL(String(url)).hostname, ["streamable.com"]);
|
|
13
|
+
} catch { return false; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function videoId(url) {
|
|
17
|
+
const m = String(url).match(/streamable\.com\/(?:e\/|video\/)?([a-z0-9]+)/i);
|
|
18
|
+
return m ? m[1] : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function resolve(url, ctx = {}) {
|
|
22
|
+
const attempt = { provider: name, status: "skipped" };
|
|
23
|
+
ctx.attempts?.push(attempt);
|
|
24
|
+
const t0 = Date.now();
|
|
25
|
+
|
|
26
|
+
const id = videoId(url);
|
|
27
|
+
if (!id) {
|
|
28
|
+
attempt.status = "failed";
|
|
29
|
+
throw new UMediaError(Codes.INVALID_URL, "Not a Streamable URL", { attempts: [attempt] });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let v;
|
|
33
|
+
try {
|
|
34
|
+
v = await fetchJson(`https://api.streamable.com/videos/${id}`, {}, 15_000);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
attempt.status = "failed";
|
|
37
|
+
attempt.error = e.message;
|
|
38
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, `Streamable video not retrievable. ${e.message}`, { attempts: [attempt] });
|
|
39
|
+
}
|
|
40
|
+
if (v?.error || (v?.status && v.status !== 2 && !v.files)) {
|
|
41
|
+
attempt.status = "failed";
|
|
42
|
+
attempt.error = v?.error || v?.message || "video not found or still processing";
|
|
43
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, `Streamable: ${attempt.error}`, { attempts: [attempt] });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const variants = [];
|
|
47
|
+
for (const [key, f] of Object.entries(v.files || {})) {
|
|
48
|
+
// their API occasionally returns a doubled scheme — normalize, never silently drop
|
|
49
|
+
const raw = typeof f?.url === "string" ? f.url.replace(/^https:https:\/\//, "https://") : null;
|
|
50
|
+
if (!raw) continue;
|
|
51
|
+
variants.push({
|
|
52
|
+
height: f.height ?? null, width: f.width ?? null, url: raw,
|
|
53
|
+
hasAudio: true, hasVideo: true,
|
|
54
|
+
quality: key === "mp4-mobile" ? "360p" : (f.height ? `${f.height}p` : key),
|
|
55
|
+
mimeType: "video/mp4",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const chosen = variants.sort((a, b) => (b.height || 0) - (a.height || 0))[0];
|
|
59
|
+
if (!chosen) {
|
|
60
|
+
attempt.status = "failed";
|
|
61
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Streamable: no downloadable renditions (deleted or processing)", { attempts: [attempt] });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
attempt.status = "success";
|
|
65
|
+
attempt.latencyMs = Date.now() - t0;
|
|
66
|
+
return {
|
|
67
|
+
result: {
|
|
68
|
+
sourceUrl: url,
|
|
69
|
+
platform: name,
|
|
70
|
+
title: v.title || null,
|
|
71
|
+
author: null,
|
|
72
|
+
thumbnail: (typeof v.thumbnail_url === "string" ? v.thumbnail_url.replace(/^https:https:\/\//, "https://") : v.thumbnail_url) || null,
|
|
73
|
+
itemCount: 1,
|
|
74
|
+
counts: { images: 0, videos: 1, audios: 0, other: 0 },
|
|
75
|
+
truncated: false,
|
|
76
|
+
media: [{
|
|
77
|
+
type: "video", index: 0, url: chosen.url,
|
|
78
|
+
thumbnail: (typeof v.thumbnail_url === "string" ? v.thumbnail_url.replace(/^https:https:\/\//, "https://") : v.thumbnail_url) || null,
|
|
79
|
+
mimeType: "video/mp4",
|
|
80
|
+
width: chosen.width ?? null, height: chosen.height ?? null,
|
|
81
|
+
duration: v.video_duration ?? v.duration ?? null,
|
|
82
|
+
size: null, quality: chosen.quality,
|
|
83
|
+
hasAudio: true, hasVideo: true, source: name, variants,
|
|
84
|
+
}],
|
|
85
|
+
streamUrlsAvailable: true,
|
|
86
|
+
},
|
|
87
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function search() {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { fetchText, hostMatches } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Threads (Meta) — the share page's server-rendered state (`video_url` /
|
|
6
|
+
* `image_urls` in the post JSON) plus official og: meta. Best-effort like all
|
|
7
|
+
* Meta surfaces (login-gated on some networks); yt-dlp tier covers gated ones.
|
|
8
|
+
*/
|
|
9
|
+
export const name = "threads";
|
|
10
|
+
|
|
11
|
+
export function canHandle(url) {
|
|
12
|
+
try {
|
|
13
|
+
return hostMatches(new URL(String(url)).hostname, ["threads.net", "threads.com"]);
|
|
14
|
+
} catch { return false; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function resolve(url, ctx = {}) {
|
|
18
|
+
const attempt = { provider: name, status: "skipped" };
|
|
19
|
+
ctx.attempts?.push(attempt);
|
|
20
|
+
const t0 = Date.now();
|
|
21
|
+
|
|
22
|
+
let html;
|
|
23
|
+
try {
|
|
24
|
+
const r = await fetchText(String(url), {
|
|
25
|
+
headers: {
|
|
26
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
|
|
27
|
+
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
28
|
+
"accept-language": "en-US,en;q=0.9",
|
|
29
|
+
},
|
|
30
|
+
}, 18_000);
|
|
31
|
+
html = r.text;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
attempt.status = "failed";
|
|
34
|
+
attempt.error = e.message;
|
|
35
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Threads page not retrievable. ${e.message}`, { attempts: [attempt] });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const un = (s) => { try { return JSON.parse(`"${s}"`); } catch { return s.replace(/\\\//g, "/"); } };
|
|
39
|
+
const media = [];
|
|
40
|
+
|
|
41
|
+
// post state: video_url + display_url (escaped JSON strings inside the SSR payload)
|
|
42
|
+
const video = html.match(/\\"video_url\\":\\"((?:[^"\\]|\\.)+?)\\"/) || html.match(/"video_url":"((?:[^"\\]|\\.)+?)"/);
|
|
43
|
+
const image = html.match(/\\"display_url\\":\\"((?:[^"\\]|\\.)+?)\\"/) || html.match(/"display_url":"((?:[^"\\]|\\.)+?)"/);
|
|
44
|
+
if (video?.[1]) {
|
|
45
|
+
const u = un(video[1]);
|
|
46
|
+
if (/^https?:/.test(u)) media.push({
|
|
47
|
+
type: "video", index: 0, url: u, thumbnail: image ? un(image[1]) : u,
|
|
48
|
+
mimeType: "video/mp4", width: null, height: null, duration: null, size: null,
|
|
49
|
+
quality: null, hasAudio: true, hasVideo: true, source: name, variants: [],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (!media.length && image?.[1]) {
|
|
53
|
+
const u = un(image[1]);
|
|
54
|
+
if (/^https?:/.test(u)) media.push({
|
|
55
|
+
type: "image", index: 0, url: u, thumbnail: u,
|
|
56
|
+
mimeType: "image/jpeg", width: null, height: null, duration: null, size: null,
|
|
57
|
+
quality: null, hasAudio: false, hasVideo: false, source: name, variants: [],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// og: meta fallback
|
|
62
|
+
if (!media.length) {
|
|
63
|
+
const ogv = (html.match(/property="og:video"[^>]+content="([^"]+)"/) || html.match(/content="([^"]+)"[^>]+property="og:video"/) || [])[1];
|
|
64
|
+
const ogi = (html.match(/property="og:image"[^>]+content="([^"]+)"/) || html.match(/content="([^"]+)"[^>]+property="og:image"/) || [])[1];
|
|
65
|
+
const u = ogv || ogi;
|
|
66
|
+
if (u && /^https?:/.test(u)) media.push({
|
|
67
|
+
type: ogv ? "video" : "image", index: 0, url: u.replace(/&/g, "&"), thumbnail: (ogi || u).replace(/&/g, "&"),
|
|
68
|
+
mimeType: ogv ? "video/mp4" : "image/jpeg", width: null, height: null,
|
|
69
|
+
duration: null, size: null, quality: null,
|
|
70
|
+
hasAudio: Boolean(ogv), hasVideo: Boolean(ogv), source: name, variants: [],
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!media.length) {
|
|
75
|
+
attempt.status = "failed";
|
|
76
|
+
attempt.error = "no media in the public page (login-gated or removed)";
|
|
77
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND,
|
|
78
|
+
`Threads exposes no media for this URL (${attempt.error}) — the yt-dlp tier covers gated posts.`, { attempts: [attempt] });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const title = (html.match(/"caption":"((?:[^"\\]|\\.){1,200})"/) || html.match(/<title[^>]*>([^<]*)/i) || [])[1] || null;
|
|
82
|
+
attempt.status = "success";
|
|
83
|
+
attempt.latencyMs = Date.now() - t0;
|
|
84
|
+
return {
|
|
85
|
+
result: {
|
|
86
|
+
sourceUrl: url,
|
|
87
|
+
platform: name,
|
|
88
|
+
title: title ? un(title).slice(0, 200) : null,
|
|
89
|
+
author: (String(url).match(/@([\w.]+)/) || [])[1] || null,
|
|
90
|
+
thumbnail: media[0].thumbnail,
|
|
91
|
+
itemCount: media.length,
|
|
92
|
+
counts: { images: media.filter((m) => m.type === "image").length, videos: media.filter((m) => m.type === "video").length, audios: 0, other: 0 },
|
|
93
|
+
truncated: false,
|
|
94
|
+
media,
|
|
95
|
+
streamUrlsAvailable: true,
|
|
96
|
+
},
|
|
97
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function search() {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { fetchJson, hostMatches } from "../util.js";
|
|
2
|
+
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tumblr — the public mobile API (api-http2.tumblr.com, the same endpoint the
|
|
6
|
+
* official iPhone app calls with its public api_key). Videos, audio, photos —
|
|
7
|
+
* including reblog trails. Reference: cobalt tumblr service.
|
|
8
|
+
*/
|
|
9
|
+
export const name = "tumblr";
|
|
10
|
+
|
|
11
|
+
const API_KEY = "jrsCWX1XDuVxAFO4GkK147syAoN8BJZ5voz8tS80bPcj26Vc5Z";
|
|
12
|
+
const API_BASE = "https://api-http2.tumblr.com";
|
|
13
|
+
const MOBILE = {
|
|
14
|
+
"user-agent": "Tumblr/iPhone/33.3/333010/17.3.1/tumblr",
|
|
15
|
+
"x-version": "iPhone/33.3/333010/17.3.1/tumblr",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function canHandle(url) {
|
|
19
|
+
try {
|
|
20
|
+
return hostMatches(new URL(String(url)).hostname, ["tumblr.com"]);
|
|
21
|
+
} catch { return false; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseUrl(url) {
|
|
25
|
+
const u = new URL(String(url));
|
|
26
|
+
const host = u.hostname.replace(/^www\./, "");
|
|
27
|
+
let m;
|
|
28
|
+
// https://{user}.tumblr.com/post/{id}/...
|
|
29
|
+
m = u.pathname.match(/^\/post\/(\d+)/);
|
|
30
|
+
if (m && host !== "tumblr.com") return { user: host.split(".")[0], id: m[1] };
|
|
31
|
+
// https://www.tumblr.com/{user}/{id} or /blog/view/{user}/{id}
|
|
32
|
+
m = u.pathname.match(/^\/(?:blog\/view\/)?([\w.-]+)\/(\d+)/);
|
|
33
|
+
if (m) return { user: m[1], id: m[2] };
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function resolve(url, ctx = {}) {
|
|
38
|
+
const attempt = { provider: name, status: "skipped" };
|
|
39
|
+
ctx.attempts?.push(attempt);
|
|
40
|
+
const t0 = Date.now();
|
|
41
|
+
|
|
42
|
+
const parsed = parseUrl(url);
|
|
43
|
+
if (!parsed) {
|
|
44
|
+
attempt.status = "failed";
|
|
45
|
+
throw new UMediaError(Codes.INVALID_URL, "Not a Tumblr post URL (expected {user}.tumblr.com/post/{id} or tumblr.com/{user}/{id})", { attempts: [attempt] });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let data;
|
|
49
|
+
try {
|
|
50
|
+
data = await fetchJson(
|
|
51
|
+
`${API_BASE}/v2/blog/${encodeURIComponent(parsed.user)}.tumblr.com/posts/${parsed.id}/permalink?api_key=${API_KEY}`, { headers: MOBILE }, 20_000);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
attempt.status = "failed";
|
|
54
|
+
attempt.error = e.message;
|
|
55
|
+
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Tumblr API unavailable. ${e.message}`, { attempts: [attempt] });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const element = data?.response?.timeline?.elements?.[0];
|
|
59
|
+
if (!element) {
|
|
60
|
+
attempt.status = "failed";
|
|
61
|
+
attempt.error = data?.meta?.msg || "post not found";
|
|
62
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, `Tumblr: ${attempt.error}`, { attempts: [attempt] });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// contents: the post itself + any reblog trail, in order — ALL of it
|
|
66
|
+
const contents = [
|
|
67
|
+
...(element.content || []),
|
|
68
|
+
...(element.trail || []).flatMap((t) => t.content || []),
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const media = [];
|
|
72
|
+
for (const c of contents) {
|
|
73
|
+
if (c.type === "video" && (c.provider === "tumblr" || !c.provider) && c.media?.url) {
|
|
74
|
+
media.push({
|
|
75
|
+
type: "video", index: media.length, url: c.media.url,
|
|
76
|
+
thumbnail: c.poster?.[0]?.url || null,
|
|
77
|
+
mimeType: "video/mp4", width: c.width ?? null, height: c.height ?? null,
|
|
78
|
+
duration: null, size: null, quality: null,
|
|
79
|
+
hasAudio: true, hasVideo: true, source: name, variants: [],
|
|
80
|
+
});
|
|
81
|
+
} else if (c.type === "audio" && c.media?.url) {
|
|
82
|
+
media.push({
|
|
83
|
+
type: "audio", index: media.length, url: c.media.url,
|
|
84
|
+
thumbnail: null, mimeType: "audio/mpeg", width: null, height: null,
|
|
85
|
+
duration: null, size: null, quality: null,
|
|
86
|
+
hasAudio: true, hasVideo: false, source: name, variants: [],
|
|
87
|
+
});
|
|
88
|
+
} else if (c.type === "image" || c.type === "photo") {
|
|
89
|
+
// image content carries media as an ARRAY of renditions — take the best
|
|
90
|
+
// one as the item, keep the rest as variants. never inflate items.
|
|
91
|
+
const items = (Array.isArray(c.media) ? c.media : (c.media?.url ? [c.media] : []))
|
|
92
|
+
.filter((mm) => mm?.url);
|
|
93
|
+
const uniq = [...new Map(items.map((mm) => [mm.url, mm])).values()]
|
|
94
|
+
.sort((a, b) => ((b.width || 0) * (b.height || 0)) - ((a.width || 0) * (a.height || 0)));
|
|
95
|
+
const best = uniq[0];
|
|
96
|
+
if (best) {
|
|
97
|
+
media.push({
|
|
98
|
+
type: "image", index: media.length, url: best.url,
|
|
99
|
+
thumbnail: best.url, mimeType: "image/jpeg",
|
|
100
|
+
width: best.width ?? null, height: best.height ?? null,
|
|
101
|
+
duration: null, size: null, quality: best.width ? `${best.width}px` : null,
|
|
102
|
+
hasAudio: false, hasVideo: false, source: name,
|
|
103
|
+
variants: uniq.map((x) => ({
|
|
104
|
+
height: x.height ?? null, width: x.width ?? null, url: x.url,
|
|
105
|
+
hasAudio: false, hasVideo: false, quality: x.width ? `${x.width}px` : null, mimeType: "image/jpeg",
|
|
106
|
+
})),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// reblog trails repeat media from earlier hops — dedupe by URL, keep first order
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const deduped = [];
|
|
115
|
+
for (const mm of media) {
|
|
116
|
+
if (seen.has(mm.url)) continue;
|
|
117
|
+
seen.add(mm.url);
|
|
118
|
+
deduped.push({ ...mm, index: deduped.length });
|
|
119
|
+
}
|
|
120
|
+
media.length = 0;
|
|
121
|
+
media.push(...deduped);
|
|
122
|
+
|
|
123
|
+
if (!media.length) {
|
|
124
|
+
attempt.status = "failed";
|
|
125
|
+
throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Tumblr post contains no downloadable media (text/link post, or third-party embed — use the yt-dlp tier for YouTube/Vimeo embeds)", { attempts: [attempt] });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
attempt.status = "success";
|
|
129
|
+
attempt.latencyMs = Date.now() - t0;
|
|
130
|
+
return {
|
|
131
|
+
result: {
|
|
132
|
+
sourceUrl: url,
|
|
133
|
+
platform: name,
|
|
134
|
+
title: (element.summary || element.let || "").slice(0, 200) || null,
|
|
135
|
+
author: element.blog_name || parsed.user,
|
|
136
|
+
thumbnail: media[0].thumbnail,
|
|
137
|
+
itemCount: media.length,
|
|
138
|
+
counts: {
|
|
139
|
+
images: media.filter((m) => m.type === "image").length,
|
|
140
|
+
videos: media.filter((m) => m.type === "video").length,
|
|
141
|
+
audios: media.filter((m) => m.type === "audio").length, other: 0,
|
|
142
|
+
},
|
|
143
|
+
truncated: false,
|
|
144
|
+
media,
|
|
145
|
+
streamUrlsAvailable: true,
|
|
146
|
+
},
|
|
147
|
+
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function search() {
|
|
152
|
+
return [];
|
|
153
|
+
}
|