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 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** | — | ✅ best-effort | ✅ best-effort | spotlight + story snap lists (edge-gated on datacenter IPs) |
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 { fetchJson, fetchText, hostMatches } from "../util.js";
1
+ import { fetchText, hostMatches } from "../util.js";
2
2
  import { Codes, UMediaError } from "../errors.js";
3
3
 
4
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.
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 spotlight = target.match(/\/spotlight\/([\w-]+)/i);
66
+ const sid = spotlightId(target);
38
67
  const story = target.match(/\/add\/([\w.-]+)(?:\/([\w-]+))?/i);
39
68
 
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")) {
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: 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: [],
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 = (r.text.match(/"title":"([^"]{1,120})"/) || r.text.match(/<title[^>]*>([^<]*)/i) || [])[1] || null;
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
- } 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;
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: 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: [],
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 login-gated)";
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}) — the yt-dlp tier covers gated stories.`, { attempts: [attempt] });
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: { images: media.filter((m) => m.type === "image").length, videos: media.filter((m) => m.type === "video").length, audios: 0, other: 0 },
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
- detectPlatform(url) === "unknown" ? Codes.INVALID_URL : Codes.PROVIDER_UNAVAILABLE,
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.0",
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",