umedia 0.4.0 → 0.4.2
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 +1 -1
- package/lib/providers/snapchat.js +110 -39
- package/lib/registry.js +52 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ trailers, social posts with official embeds. `previewKind: "none"` beats a fake
|
|
|
88
88
|
| **X** | — | ✅ | ✅ | syndication token + guest GraphQL: photos/videos/gifs at real bitrates |
|
|
89
89
|
| **Facebook** | — | ✅ | ✅ | reels/watch/videos/shares/fb.watch via the web player JSON |
|
|
90
90
|
| **Threads** | — | ✅ | ✅ | post SSR state + og media (Meta gates some networks — typed honest error) |
|
|
91
|
-
| **Snapchat** | — | ✅
|
|
91
|
+
| **Snapchat** | — | ✅ | ✅ | spotlight videos (direct sc-cdn mp4) + public stories/highlights — ALL snaps in order |
|
|
92
92
|
| **Rumble** | — | ✅ best-effort | ✅ best-effort | embed API direct mp4s (site blocks datacenter IPs) |
|
|
93
93
|
| **Tumblr** | — | ✅ | ✅ | mobile API: video/audio/photo posts + reblog trails, all in order |
|
|
94
94
|
| **iTunes** | ✅ | — | ✅ previews | real 30s clips, rock solid |
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { fetchText, hostMatches } from "../util.js";
|
|
2
2
|
import { Codes, UMediaError } from "../errors.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Snapchat — Spotlight videos
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
8
10
|
*/
|
|
9
11
|
export const name = "snapchat";
|
|
10
12
|
|
|
11
|
-
const SPOTLIGHT_PRELOAD = /<link data-react-helmet="true" rel="preload" href="([^"]+)" as="video"\/>/;
|
|
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"\/>/;
|
|
13
15
|
|
|
14
16
|
export function canHandle(url) {
|
|
15
17
|
try {
|
|
@@ -17,6 +19,32 @@ export function canHandle(url) {
|
|
|
17
19
|
} catch { return false; }
|
|
18
20
|
}
|
|
19
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
|
+
|
|
20
48
|
export async function resolve(url, ctx = {}) {
|
|
21
49
|
const attempt = { provider: name, status: "skipped" };
|
|
22
50
|
ctx.attempts?.push(attempt);
|
|
@@ -33,53 +61,92 @@ export async function resolve(url, ctx = {}) {
|
|
|
33
61
|
const media = [];
|
|
34
62
|
let title = null;
|
|
35
63
|
let author = null;
|
|
64
|
+
let duration = null;
|
|
36
65
|
|
|
37
|
-
const
|
|
66
|
+
const sid = spotlightId(target);
|
|
38
67
|
const story = target.match(/\/add\/([\w.-]+)(?:\/([\w-]+))?/i);
|
|
39
68
|
|
|
40
|
-
if (
|
|
41
|
-
const r = await fetchText(`https://www.snapchat.com/spotlight/${
|
|
42
|
-
const
|
|
43
|
-
|
|
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) {
|
|
44
79
|
media.push({
|
|
45
|
-
type: "video", index: 0, url:
|
|
46
|
-
|
|
47
|
-
|
|
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: [],
|
|
48
87
|
});
|
|
49
|
-
title =
|
|
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;
|
|
50
91
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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;
|
|
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")) {
|
|
65
97
|
media.push({
|
|
66
|
-
type:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
duration: snap.duration ? Math.round(snap.duration / 1000) : null,
|
|
71
|
-
size: null, quality: null,
|
|
72
|
-
hasAudio: !isPhoto, hasVideo: !isPhoto, source: name, variants: [],
|
|
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: [],
|
|
73
102
|
});
|
|
74
|
-
}
|
|
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));
|
|
75
142
|
}
|
|
76
143
|
}
|
|
77
144
|
|
|
78
145
|
if (!media.length) {
|
|
79
146
|
attempt.status = "failed";
|
|
80
|
-
attempt.error = "no snap media exposed (removed, private, or
|
|
147
|
+
attempt.error = "no snap media exposed (removed, private, or an expired spotlight)";
|
|
81
148
|
throw new UMediaError(Codes.MEDIA_NOT_FOUND,
|
|
82
|
-
`Snapchat exposes no media for this URL (${attempt.error}) —
|
|
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] });
|
|
83
150
|
}
|
|
84
151
|
|
|
85
152
|
attempt.status = "success";
|
|
@@ -92,7 +159,11 @@ export async function resolve(url, ctx = {}) {
|
|
|
92
159
|
author,
|
|
93
160
|
thumbnail: media[0].thumbnail,
|
|
94
161
|
itemCount: media.length,
|
|
95
|
-
counts: {
|
|
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
|
+
},
|
|
96
167
|
truncated: false,
|
|
97
168
|
media,
|
|
98
169
|
streamUrlsAvailable: true,
|
package/lib/registry.js
CHANGED
|
@@ -31,6 +31,49 @@ export function detectPlatform(url) {
|
|
|
31
31
|
|
|
32
32
|
const DIRECT_MEDIA = /\.(mp4|webm|m4a|mp3|aac|wav|jpg|jpeg|png|gif|webp|mov|mkv)(\?|#|$)/i;
|
|
33
33
|
|
|
34
|
+
/** Signed CDN blob URLs have no file extension — probe the content-type honestly. */
|
|
35
|
+
async function probeDirectMedia(url) {
|
|
36
|
+
try {
|
|
37
|
+
let res = await fetch(url, {
|
|
38
|
+
method: "HEAD",
|
|
39
|
+
headers: { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" },
|
|
40
|
+
redirect: "follow",
|
|
41
|
+
signal: AbortSignal.timeout(12_000),
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok && res.status !== 405) {
|
|
44
|
+
res = await fetch(url, {
|
|
45
|
+
headers: { "user-agent": "Mozilla/5.0", range: "bytes=0-0" },
|
|
46
|
+
redirect: "follow",
|
|
47
|
+
signal: AbortSignal.timeout(12_000),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const ct = (res.headers.get("content-type") || "").split(";")[0].trim();
|
|
51
|
+
const kind = /^video\//.test(ct) ? "video" : /^audio\//.test(ct) ? "audio" : /^image\//.test(ct) ? "image" : null;
|
|
52
|
+
return kind ? { kind, mimeType: ct } : null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function directResult(url, kind, mimeType, note) {
|
|
59
|
+
return {
|
|
60
|
+
data: {
|
|
61
|
+
sourceUrl: url, platform: "direct",
|
|
62
|
+
title: String(url).split("/").pop()?.split("?")[0] || "Direct media",
|
|
63
|
+
author: null, thumbnail: kind === "video" ? null : url,
|
|
64
|
+
itemCount: 1,
|
|
65
|
+
counts: { images: kind === "image" ? 1 : 0, videos: kind === "video" ? 1 : 0, audios: kind === "audio" ? 1 : 0, other: 0 },
|
|
66
|
+
truncated: false,
|
|
67
|
+
media: [{
|
|
68
|
+
type: kind, index: 0, url: String(url), thumbnail: null, mimeType: mimeType ?? null,
|
|
69
|
+
width: null, height: null, duration: null, size: null, quality: "original",
|
|
70
|
+
hasAudio: kind !== "image", hasVideo: kind !== "audio", source: "direct", variants: [],
|
|
71
|
+
}],
|
|
72
|
+
},
|
|
73
|
+
engine: { attempts: [{ provider: "direct", status: "success", note }], finalProvider: "direct", totalLatencyMs: 0 },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
34
77
|
/** Resolve a URL: native adapter first, optional yt-dlp fallback. attempts[] is the honest trail. */
|
|
35
78
|
export async function resolve(url) {
|
|
36
79
|
// SSRF hygiene FIRST — never dispatch or fetch private/internal targets (bots pass user URLs!)
|
|
@@ -88,6 +131,14 @@ export async function resolve(url) {
|
|
|
88
131
|
}
|
|
89
132
|
}
|
|
90
133
|
const last = ctx.attempts[ctx.attempts.length - 1];
|
|
134
|
+
// extension-less signed CDN URLs: honest content-type probe before refusing
|
|
135
|
+
if (detectPlatform(url) === "unknown") {
|
|
136
|
+
const probe = await probeDirectMedia(url);
|
|
137
|
+
if (probe) return directResult(url, probe.kind, probe.mimeType, "direct media URL (content-type probe)");
|
|
138
|
+
throw new UMediaError(Codes.INVALID_URL,
|
|
139
|
+
`No provider could resolve this URL${last?.error ? " Last error: " + last.error : ""}. Tip: install yt-dlp to unlock 1800+ sites and hard networks.`,
|
|
140
|
+
{ attempts: ctx.attempts });
|
|
141
|
+
}
|
|
91
142
|
// surface the real provider error when every matching provider failed —
|
|
92
143
|
// never mask a typed outcome behind a generic message
|
|
93
144
|
if (lastTyped) {
|
|
@@ -95,7 +146,7 @@ export async function resolve(url) {
|
|
|
95
146
|
throw lastTyped;
|
|
96
147
|
}
|
|
97
148
|
throw new UMediaError(
|
|
98
|
-
|
|
149
|
+
Codes.PROVIDER_UNAVAILABLE,
|
|
99
150
|
`No provider could resolve this URL.${last?.error ? " Last error: " + last.error : ""}` +
|
|
100
151
|
(ytdlp.binary() ? "" : " Tip: install yt-dlp to unlock 1800+ sites and hard networks."),
|
|
101
152
|
{ attempts: ctx.attempts },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "umedia",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Standalone media engine — search, resolve and download media from YouTube, TikTok, Instagram, Reddit, X, iTunes (and 1800+ sites via the yt-dlp tier) entirely on your machine. No hosted API, no keys, no server bills. Honest quality labels, complete galleries, typed errors.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|