umedia 0.4.1 → 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 -171
- package/lib/util.js +0 -93
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { fetchText, hostMatches } from "../util.js";
|
|
2
|
-
import { Codes, UMediaError } from "../errors.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Snapchat — Spotlight videos and public story snap lists.
|
|
6
|
-
* Primary: the page's __NEXT_DATA__ -> spotlightFeed.spotlightStories, matched
|
|
7
|
-
* by storyId (the exact structure yt-dlp's snapchat extractor reads) ->
|
|
8
|
-
* videoMetadata.contentUrl (direct sc-cdn mp4). Fallback: the preload video
|
|
9
|
-
* link (cobalt). Stories: pageProps.story.snapList — ALL snaps, in order.
|
|
10
|
-
*/
|
|
11
|
-
export const name = "snapchat";
|
|
12
|
-
|
|
13
|
-
const NEXT_DATA = /<script id="__NEXT_DATA__" type="application\/json">({.+?})<\/script>/;
|
|
14
|
-
const PRELOAD_VIDEO = /<link data-react-helmet="true" rel="preload" href="([^"]+)" as="video"\/>/;
|
|
15
|
-
|
|
16
|
-
export function canHandle(url) {
|
|
17
|
-
try {
|
|
18
|
-
return hostMatches(new URL(String(url)).hostname, ["snapchat.com", "t.snapchat.com"]);
|
|
19
|
-
} catch { return false; }
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function spotlightId(url) {
|
|
23
|
-
const m = String(url).match(/\/spotlight\/([\w-]{10,120})/);
|
|
24
|
-
return m ? m[1] : null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function nextData(html) {
|
|
28
|
-
const m = html.match(NEXT_DATA);
|
|
29
|
-
if (!m) return null;
|
|
30
|
-
try { return JSON.parse(m[1]); } catch { return null; }
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function pushStorySnap(snap, media) {
|
|
34
|
-
const isPhoto = snap.snapMediaType === 0;
|
|
35
|
-
const u = snap.snapUrls?.mediaUrl;
|
|
36
|
-
if (!u) return;
|
|
37
|
-
media.push({
|
|
38
|
-
type: isPhoto ? "image" : "video", index: media.length, url: u,
|
|
39
|
-
thumbnail: snap.snapUrls?.mediaPreviewUrl?.value || u,
|
|
40
|
-
mimeType: isPhoto ? "image/jpeg" : "video/mp4",
|
|
41
|
-
width: null, height: null,
|
|
42
|
-
duration: snap.durationInMs ? Math.round(snap.durationInMs / 1000) : (snap.duration ? Math.round(snap.duration / 1000) : null),
|
|
43
|
-
size: null, quality: null,
|
|
44
|
-
hasAudio: !isPhoto, hasVideo: !isPhoto, source: name, variants: [],
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export async function resolve(url, ctx = {}) {
|
|
49
|
-
const attempt = { provider: name, status: "skipped" };
|
|
50
|
-
ctx.attempts?.push(attempt);
|
|
51
|
-
const t0 = Date.now();
|
|
52
|
-
|
|
53
|
-
let target = String(url);
|
|
54
|
-
if (/t\.snapchat\.com\//i.test(target)) {
|
|
55
|
-
try {
|
|
56
|
-
const res = await fetch(target, { redirect: "follow", headers: { "user-agent": "Mozilla/5.0" } });
|
|
57
|
-
target = res.url || target;
|
|
58
|
-
} catch { /* keep original */ }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const media = [];
|
|
62
|
-
let title = null;
|
|
63
|
-
let author = null;
|
|
64
|
-
let duration = null;
|
|
65
|
-
|
|
66
|
-
const sid = spotlightId(target);
|
|
67
|
-
const story = target.match(/\/add\/([\w.-]+)(?:\/([\w-]+))?/i);
|
|
68
|
-
|
|
69
|
-
if (sid) {
|
|
70
|
-
const r = await fetchText(`https://www.snapchat.com/spotlight/${sid}`, { headers: { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" } }, 20_000).catch(() => null);
|
|
71
|
-
const html = r?.text || "";
|
|
72
|
-
|
|
73
|
-
// primary: __NEXT_DATA__ spotlight feed entry matched by storyId (yt-dlp structure)
|
|
74
|
-
const d = nextData(html);
|
|
75
|
-
const stories = d?.props?.pageProps?.spotlightFeed?.spotlightStories || [];
|
|
76
|
-
const entry = stories.find((s) => s?.story?.storyId?.value === sid);
|
|
77
|
-
const vm = entry?.metadata?.videoMetadata;
|
|
78
|
-
if (vm?.contentUrl) {
|
|
79
|
-
media.push({
|
|
80
|
-
type: "video", index: 0, url: vm.contentUrl,
|
|
81
|
-
thumbnail: vm.thumbnailUrl || null,
|
|
82
|
-
mimeType: "video/mp4",
|
|
83
|
-
width: vm.width ?? null, height: vm.height ?? null,
|
|
84
|
-
duration: vm.durationMs ? Math.round(vm.durationMs / 1000) : null,
|
|
85
|
-
size: null, quality: vm.height ? `${vm.height}p` : null,
|
|
86
|
-
hasAudio: true, hasVideo: true, source: name, variants: [],
|
|
87
|
-
});
|
|
88
|
-
title = vm.name || null;
|
|
89
|
-
author = vm.creator?.personCreator?.username || vm.creator?.username || null;
|
|
90
|
-
duration = vm.durationMs ? Math.round(vm.durationMs / 1000) : null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// fallback: the preload video link (cobalt's route)
|
|
94
|
-
if (!media.length) {
|
|
95
|
-
const v = html.match(PRELOAD_VIDEO)?.[1];
|
|
96
|
-
if (v && /^https?:/.test(v) && new URL(v).hostname.endsWith("sc-cdn.net")) {
|
|
97
|
-
media.push({
|
|
98
|
-
type: "video", index: 0, url: v, thumbnail: null,
|
|
99
|
-
mimeType: "video/mp4", width: null, height: null,
|
|
100
|
-
duration: null, size: null, quality: null,
|
|
101
|
-
hasAudio: true, hasVideo: true, source: name, variants: [],
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
} else if (story) {
|
|
106
|
-
const r = await fetchText(`https://www.snapchat.com/add/${story[1]}${story[2] ? "/" + story[2] : ""}`,
|
|
107
|
-
{ headers: { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" } }, 20_000).catch(() => null);
|
|
108
|
-
const d = nextData(r?.text || "");
|
|
109
|
-
const pp = d?.props?.pageProps || {};
|
|
110
|
-
author = story[1];
|
|
111
|
-
|
|
112
|
-
const activeStory = pp.story?.snapList || [];
|
|
113
|
-
const highlights = pp.spotlightHighlights || [];
|
|
114
|
-
const curated = pp.curatedHighlights || [];
|
|
115
|
-
|
|
116
|
-
if (story[2]) {
|
|
117
|
-
// a single snap (by snapId) or one highlight reel (by storyId/highlightId)
|
|
118
|
-
const wanted = story[2];
|
|
119
|
-
const allLists = [activeStory, ...curated.map((c) => c.snapList || []), ...highlights.map((h) => h.snapList || [])];
|
|
120
|
-
for (const list of allLists) {
|
|
121
|
-
const snap = list.find((s) => s.snapId?.value === wanted || s.snapId === wanted);
|
|
122
|
-
if (snap) { pushStorySnap(snap, media); break; }
|
|
123
|
-
}
|
|
124
|
-
if (!media.length) {
|
|
125
|
-
const hl = highlights.find((h) => h.storyId === wanted || h.highlightId === wanted || h.storyShareId === wanted);
|
|
126
|
-
if (hl) {
|
|
127
|
-
title = hl.storyTitle || null;
|
|
128
|
-
(hl.snapList || []).forEach((snap) => pushStorySnap(snap, media));
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
if (!media.length) {
|
|
133
|
-
// bare /add/{user}: the current story if it exists, else their public
|
|
134
|
-
// highlight reels — ALL snaps, in order, nothing silently dropped
|
|
135
|
-
const list = activeStory.length
|
|
136
|
-
? activeStory
|
|
137
|
-
: (curated[0]?.snapList?.length ? curated[0].snapList : highlights.flatMap((h) => h.snapList || []));
|
|
138
|
-
if (!activeStory.length && highlights.length && !curated[0]?.snapList?.length) {
|
|
139
|
-
title = `${author} — ${highlights.length} public highlight${highlights.length > 1 ? "s" : ""}`;
|
|
140
|
-
}
|
|
141
|
-
list.forEach((snap) => pushStorySnap(snap, media));
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (!media.length) {
|
|
146
|
-
attempt.status = "failed";
|
|
147
|
-
attempt.error = "no snap media exposed (removed, private, or an expired spotlight)";
|
|
148
|
-
throw new UMediaError(Codes.MEDIA_NOT_FOUND,
|
|
149
|
-
`Snapchat exposes no media for this URL (${attempt.error}) — public spotlight links and stories work; removed snaps are gone from Snapchat itself.`, { attempts: [attempt] });
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
attempt.status = "success";
|
|
153
|
-
attempt.latencyMs = Date.now() - t0;
|
|
154
|
-
return {
|
|
155
|
-
result: {
|
|
156
|
-
sourceUrl: url,
|
|
157
|
-
platform: name,
|
|
158
|
-
title: title || (author ? `Snapchat story @${author}` : null),
|
|
159
|
-
author,
|
|
160
|
-
thumbnail: media[0].thumbnail,
|
|
161
|
-
itemCount: media.length,
|
|
162
|
-
counts: {
|
|
163
|
-
images: media.filter((m) => m.type === "image").length,
|
|
164
|
-
videos: media.filter((m) => m.type === "video").length,
|
|
165
|
-
audios: 0, other: 0,
|
|
166
|
-
},
|
|
167
|
-
truncated: false,
|
|
168
|
-
media,
|
|
169
|
-
streamUrlsAvailable: true,
|
|
170
|
-
},
|
|
171
|
-
engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export async function search() {
|
|
176
|
-
return [];
|
|
177
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,93 +0,0 @@
|
|
|
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
|
-
}
|
package/lib/providers/threads.js
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
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
|
-
}
|
package/lib/providers/tiktok.js
DELETED
|
@@ -1,165 +0,0 @@
|
|
|
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
|
-
}
|