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.
@@ -0,0 +1,108 @@
1
+ import { fetchJson, hostMatches } from "../util.js";
2
+ import { Codes, UMediaError } from "../errors.js";
3
+
4
+ /**
5
+ * Twitch clips — the public GraphQL endpoint the web player calls (its web
6
+ * client-id ships in Twitch's own JS; nothing secret). Direct CloudFront mp4
7
+ * renditions per quality. VODs/livestreams need the yt-dlp tier (honest).
8
+ */
9
+ export const name = "twitch";
10
+
11
+ // Twitch's own public web client id (shipped in their frontend bundle)
12
+ const CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
13
+
14
+ export function canHandle(url) {
15
+ try {
16
+ return hostMatches(new URL(String(url)).hostname, ["twitch.tv", "clips.twitch.tv"]);
17
+ } catch { return false; }
18
+ }
19
+
20
+ export function clipSlug(url) {
21
+ const m = String(url).match(/(?:clips\.twitch\.tv\/|\/clip\/)([\w-]+)/i);
22
+ return m ? m[1] : null;
23
+ }
24
+
25
+ export async function resolve(url, ctx = {}) {
26
+ const attempt = { provider: name, status: "skipped" };
27
+ ctx.attempts?.push(attempt);
28
+ const t0 = Date.now();
29
+
30
+ const slug = clipSlug(url);
31
+ if (!slug) {
32
+ attempt.status = "failed";
33
+ throw new UMediaError(Codes.INVALID_URL,
34
+ "Only Twitch CLIP urls resolve natively (clips.twitch.tv/{slug} or twitch.tv/{ch}/clip/{slug}). VODs/livestreams: use downloadWithYtdlp().", { attempts: [attempt] });
35
+ }
36
+
37
+ let gql;
38
+ try {
39
+ gql = await fetchJson("https://gql.twitch.tv/gql",
40
+ {
41
+ method: "POST",
42
+ headers: { "client-id": CLIENT_ID, "content-type": "text/plain" },
43
+ body: JSON.stringify({
44
+ query: `query{clip(slug:"${slug}"){title durationSeconds broadcaster{displayName} thumbnailURL videoQualities{frameRate quality sourceURL} playbackAccessToken(params:{platform:"web",playerType:"site"}){value signature}}}`,
45
+ }),
46
+ }, 15_000);
47
+ } catch (e) {
48
+ attempt.status = "failed";
49
+ attempt.error = e.message;
50
+ throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Twitch GraphQL unavailable. ${e.message}`, { attempts: [attempt] });
51
+ }
52
+ const clip = gql?.data?.clip;
53
+ if (!clip) {
54
+ attempt.status = "failed";
55
+ attempt.error = "clip not found (expired clips are removed with their VOD)";
56
+ throw new UMediaError(Codes.MEDIA_NOT_FOUND, `Twitch: ${attempt.error}`, { attempts: [attempt] });
57
+ }
58
+
59
+ // Twitch CDN (nauth) URLs only serve with the clip's playback token — sign them like the web player
60
+ const tok = clip.playbackAccessToken;
61
+ const sign = (u) => (tok?.value && tok?.signature
62
+ ? `${u}${u.includes("?") ? "&" : "?"}token=${encodeURIComponent(tok.value)}&sig=${tok.signature}`
63
+ : u);
64
+
65
+ const variants = (clip.videoQualities || [])
66
+ .filter((q) => q?.sourceURL)
67
+ .map((q) => ({
68
+ height: parseInt(q.quality, 10) || null, width: null, url: sign(q.sourceURL),
69
+ hasAudio: true, hasVideo: true,
70
+ quality: q.quality ? `${q.quality}p` : null, mimeType: "video/mp4",
71
+ }))
72
+ .sort((a, b) => (b.height || 0) - (a.height || 0));
73
+ const chosen = variants[0];
74
+ if (!chosen) {
75
+ attempt.status = "failed";
76
+ throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Twitch clip has no downloadable renditions", { attempts: [attempt] });
77
+ }
78
+
79
+ attempt.status = "success";
80
+ attempt.latencyMs = Date.now() - t0;
81
+ return {
82
+ result: {
83
+ sourceUrl: url,
84
+ platform: name,
85
+ title: clip.title || null,
86
+ author: clip.broadcaster?.displayName || null,
87
+ thumbnail: clip.thumbnailURL || null,
88
+ itemCount: 1,
89
+ counts: { images: 0, videos: 1, audios: 0, other: 0 },
90
+ truncated: false,
91
+ media: [{
92
+ type: "video", index: 0, url: chosen.url,
93
+ thumbnail: clip.thumbnailURL || null,
94
+ mimeType: "video/mp4",
95
+ width: null, height: chosen.height ?? null,
96
+ duration: clip.durationSeconds ?? null,
97
+ size: null, quality: chosen.quality,
98
+ hasAudio: true, hasVideo: true, source: name, variants,
99
+ }],
100
+ streamUrlsAvailable: true,
101
+ },
102
+ engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
103
+ };
104
+ }
105
+
106
+ export async function search() {
107
+ return [];
108
+ }
@@ -0,0 +1,108 @@
1
+ import { fetchJson, hostMatches } from "../util.js";
2
+ import { Codes, UMediaError } from "../errors.js";
3
+
4
+ /**
5
+ * Vimeo — the public player config JSON (player.vimeo.com/video/{id}/config):
6
+ * progressive renditions when Vimeo serves them, otherwise HLS masters with
7
+ * real per-rendition quality labels.
8
+ */
9
+ export const name = "vimeo";
10
+
11
+ export function canHandle(url) {
12
+ try {
13
+ return hostMatches(new URL(String(url)).hostname, ["vimeo.com", "player.vimeo.com"]);
14
+ } catch { return false; }
15
+ }
16
+
17
+ export function videoId(url) {
18
+ const m = String(url).match(/(?:video\/|vimeo\.com\/(?:video\/)?)(\d{6,12})/);
19
+ return m ? m[1] : null;
20
+ }
21
+
22
+ export async function resolve(url, ctx = {}) {
23
+ const attempt = { provider: name, status: "skipped" };
24
+ ctx.attempts?.push(attempt);
25
+ const t0 = Date.now();
26
+
27
+ const id = videoId(url);
28
+ if (!id) {
29
+ attempt.status = "failed";
30
+ throw new UMediaError(Codes.INVALID_URL, "Not a Vimeo video URL (password-protected/album URLs need the yt-dlp tier)", { attempts: [attempt] });
31
+ }
32
+
33
+ let cfg;
34
+ try {
35
+ cfg = await fetchJson(`https://player.vimeo.com/video/${id}/config`,
36
+ { headers: { referer: "https://vimeo.com/" } }, 15_000);
37
+ } catch (e) {
38
+ attempt.status = "failed";
39
+ attempt.error = e.message;
40
+ throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Vimeo player config unavailable. ${e.message}`, { attempts: [attempt] });
41
+ }
42
+ const v = cfg?.video;
43
+ if (!v) {
44
+ attempt.status = "failed";
45
+ attempt.error = cfg?.title || "video unavailable";
46
+ throw new UMediaError(Codes.MEDIA_NOT_FOUND, `Vimeo: ${attempt.error}`, { attempts: [attempt] });
47
+ }
48
+
49
+ const files = cfg.request?.files || {};
50
+ const variants = [];
51
+ for (const p of (files.progressive || [])) {
52
+ if (!p?.url) continue;
53
+ variants.push({
54
+ height: p.height ?? null, width: p.width ?? null, url: p.url,
55
+ hasAudio: true, hasVideo: true,
56
+ quality: p.quality || (p.height ? `${p.height}p` : null),
57
+ mimeType: p.mime || "video/mp4",
58
+ });
59
+ }
60
+ const hls = files.hls || {};
61
+ const cdns = hls.cdns || {};
62
+ const defaultUrl = cdns[hls.default_cdn]?.avc_url || Object.values(cdns)[0]?.avc_url || null;
63
+ if (defaultUrl) {
64
+ variants.push({
65
+ height: null, width: null, url: defaultUrl,
66
+ hasAudio: !hls.separate_av, hasVideo: true,
67
+ quality: "hls", mimeType: "application/x-mpegURL",
68
+ });
69
+ }
70
+
71
+ const progressive = variants.find((v) => /mp4/.test(v.mimeType || ""));
72
+ const chosen = progressive || variants[0];
73
+ if (!chosen) {
74
+ attempt.status = "failed";
75
+ throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Vimeo serves no stream URLs (private or domain-restricted video)", { attempts: [attempt] });
76
+ }
77
+
78
+ attempt.status = "success";
79
+ attempt.latencyMs = Date.now() - t0;
80
+ return {
81
+ result: {
82
+ sourceUrl: url,
83
+ platform: name,
84
+ title: v.title || null,
85
+ author: cfg.owner?.display_name || cfg.user?.name || null,
86
+ thumbnail: v.thumbs?.base || v.thumb || null,
87
+ itemCount: 1,
88
+ counts: { images: 0, videos: 1, audios: 0, other: 0 },
89
+ truncated: false,
90
+ media: [{
91
+ type: "video", index: 0, url: chosen.url,
92
+ thumbnail: v.thumbs?.base || v.thumb || null,
93
+ mimeType: chosen.mimeType,
94
+ width: v.width ?? null, height: v.height ?? null,
95
+ duration: v.duration ?? null,
96
+ size: null, quality: chosen.quality,
97
+ hasAudio: true, hasVideo: true, source: name,
98
+ variants,
99
+ }],
100
+ streamUrlsAvailable: true,
101
+ },
102
+ engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
103
+ };
104
+ }
105
+
106
+ export async function search() {
107
+ return [];
108
+ }
@@ -1,69 +1,213 @@
1
1
  import { fetchJson, hostMatches } from "../util.js";
2
2
  import { Codes, UMediaError } from "../errors.js";
3
3
 
4
- /** X (Twitter) — syndication endpoint for public posts. Best-effort, honestly labeled. */
4
+ /**
5
+ * X / Twitter — two proven public routes, no login:
6
+ * 1. syndication embed API with the computed token (the route embed widgets use)
7
+ * 2. GraphQL TweetDetail with a guest token (the route the logged-out web uses)
8
+ * Both are the exact techniques from yt-dlp's twitter extractor and cobalt's
9
+ * twitter service. Photos, videos (best bitrate mp4), gifs.
10
+ */
5
11
  export const name = "x";
6
12
 
13
+ const BEARER = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
14
+ const GQL_FEATURES = JSON.stringify({
15
+ rweb_video_screen_enabled: false, payments_enabled: false, rweb_xchat_enabled: false,
16
+ profile_label_improvements_pcf_label_in_post_enabled: true, rweb_tipjar_consumption_enabled: true,
17
+ verified_phone_label_enabled: false, creator_subscriptions_tweet_preview_api_enabled: true,
18
+ responsive_web_graphql_timeline_navigation_enabled: true, responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
19
+ premium_content_api_read_enabled: false, communities_web_enable_tweet_community_results_fetch: true,
20
+ c9s_tweet_anatomy_moderator_badge_enabled: true, responsive_web_grok_analyze_button_fetch_trends_enabled: false,
21
+ responsive_web_grok_analyze_post_followups_enabled: true, responsive_web_jetfuel_frame: true,
22
+ responsive_web_grok_share_attachment_enabled: true, articles_preview_enabled: true,
23
+ responsive_web_edit_tweet_api_enabled: true, graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
24
+ view_counts_everywhere_api_enabled: true, longform_notetweets_consumption_enabled: true,
25
+ responsive_web_twitter_article_tweet_consumption_enabled: true, tweet_awards_web_tipping_enabled: false,
26
+ creator_subscriptions_quote_tweet_preview_enabled: false, freedom_of_speech_not_reach_fetch_enabled: true,
27
+ standardized_nudges_misinfo: true, tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
28
+ longform_notetweets_rich_text_read_enabled: true, longform_notetweets_inline_media_enabled: true,
29
+ responsive_web_grok_image_annotation_enabled: true, responsive_web_grok_imagine_annotation_enabled: true,
30
+ responsive_web_grok_community_note_auto_translation_is_enabled: false, responsive_web_enhance_cards_enabled: false,
31
+ });
32
+
33
+ const GQL_HEADERS = {
34
+ authorization: `Bearer ${BEARER}`,
35
+ "x-twitter-client-language": "en",
36
+ "x-twitter-active-user": "yes",
37
+ "accept-language": "en",
38
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
39
+ };
40
+
7
41
  export function canHandle(url) {
8
42
  try {
9
43
  return hostMatches(new URL(String(url)).hostname, ["twitter.com", "x.com"]);
10
44
  } catch { return false; }
11
45
  }
12
46
 
47
+ export function tweetId(url) {
48
+ const m = String(url).match(/\/status(?:es)?\/(\d{8,25})/);
49
+ return m ? m[1] : null;
50
+ }
51
+
52
+ // the syndication embed token — computed from the tweet id (yt-dlp technique)
53
+ const syndicationToken = (id) => ((Number(id) / 1e15) * Math.PI).toString(36).replace(/(0+|\.)/g, "");
54
+
55
+ let _guestToken = null;
56
+ async function guestToken() {
57
+ if (_guestToken) return _guestToken;
58
+ try {
59
+ const j = await fetchJson("https://api.x.com/1.1/guest/activate.json", {
60
+ method: "POST", headers: GQL_HEADERS,
61
+ }, 15_000);
62
+ if (j?.guest_token) return (_guestToken = j.guest_token);
63
+ } catch { /* syndication may still work */ }
64
+ return null;
65
+ }
66
+
67
+ function mediaFromEntities(entities, media) {
68
+ for (const m of entities || []) {
69
+ const isPhoto = m.type === "photo";
70
+ const isVideo = m.type === "video" || m.type === "animated_gif";
71
+ if (isPhoto && m.media_url_https) {
72
+ media.push({
73
+ type: "image", index: media.length, url: m.media_url_https, thumbnail: m.media_url_https,
74
+ mimeType: "image/jpeg", width: m.original_info?.width ?? null, height: m.original_info?.height ?? null,
75
+ duration: null, size: null, quality: "original",
76
+ hasAudio: false, hasVideo: false, source: name, variants: [],
77
+ });
78
+ } else if (isVideo && m.video_info?.variants?.length) {
79
+ const mp4s = m.video_info.variants
80
+ .filter((v) => v.content_type === "video/mp4" && v.url)
81
+ .map((v) => ({
82
+ height: null, width: null,
83
+ // cobalt strips the flaky `tag` param from twitter mp4 urls
84
+ url: (() => { try { const u = new URL(v.url); u.searchParams.delete("tag"); return u.toString(); } catch { return v.url; } })(),
85
+ hasAudio: true, hasVideo: true,
86
+ quality: v.bitrate ? `${Math.round(v.bitrate / 1000)}kbps` : "mp4",
87
+ mimeType: "video/mp4", bitrate: v.bitrate ?? null,
88
+ }))
89
+ .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
90
+ const best = mp4s[0]
91
+ || m.video_info.variants.filter((v) => v.url).map((v) => ({
92
+ height: null, width: null, url: v.url, hasAudio: true, hasVideo: true,
93
+ quality: null, mimeType: v.content_type || "application/x-mpegURL", bitrate: null,
94
+ }))[0];
95
+ if (best) {
96
+ media.push({
97
+ type: "video", index: media.length, url: best.url,
98
+ thumbnail: m.media_url_https || null,
99
+ mimeType: best.mimeType, width: m.original_info?.width ?? null, height: m.original_info?.height ?? null,
100
+ duration: m.video_info.duration_millis ? Math.round(m.video_info.duration_millis / 1000) : null,
101
+ size: null, quality: best.quality,
102
+ hasAudio: true, hasVideo: true, source: name, variants: mp4s,
103
+ });
104
+ }
105
+ }
106
+ }
107
+ }
108
+
13
109
  export async function resolve(url, ctx = {}) {
14
110
  const attempt = { provider: name, status: "skipped" };
15
111
  ctx.attempts?.push(attempt);
16
- const m = String(url).match(/status\/(\d+)/);
17
- if (!m) throw new UMediaError(Codes.INVALID_URL, `No status id in ${url}`);
18
112
  const t0 = Date.now();
113
+
114
+ const id = tweetId(url);
115
+ if (!id) {
116
+ attempt.status = "failed";
117
+ throw new UMediaError(Codes.INVALID_URL, "Not a tweet URL (expected x.com/{user}/status/{id})", { attempts: [attempt] });
118
+ }
119
+
120
+ const media = [];
121
+ let title = null;
122
+ let author = null;
123
+
124
+ // 1) GraphQL TweetDetail with guest token (fullest data)
19
125
  try {
20
- const tw = await fetchJson(`https://cdn.syndication.twimg.com/tweet-result?id=${m[1]}&lang=en`, {}, 12_000);
21
- const media = [];
22
- const photos = tw.photos || [];
23
- photos.forEach((p, i) => media.push({
24
- type: "image", index: i, url: p.url, thumbnail: p.url,
25
- mimeType: "image/jpeg", width: p.width ?? null, height: p.height ?? null,
26
- duration: null, size: null, quality: p.width && p.height ? `${p.width}x${p.height}` : null,
27
- hasAudio: false, hasVideo: false, source: name, variants: [],
28
- }));
29
- const vids = tw.video?.variants || [];
30
- const mp4s = vids.filter((v) => v.src?.includes(".mp4")).sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
31
- if (mp4s.length) {
32
- const best = mp4s[0];
33
- media.push({
34
- type: "video", index: media.length, url: best.src, thumbnail: tw.video?.poster ?? null,
35
- mimeType: "video/mp4", width: null, height: null,
36
- duration: tw.video?.duration_millis ? tw.video.duration_millis / 1000 : null,
37
- size: null, quality: best.bitrate ? `~${Math.round(best.bitrate / 1000)}kbps` : null,
38
- hasAudio: true, hasVideo: true, source: name,
39
- variants: mp4s.map((v) => ({
40
- quality: v.bitrate ? `~${Math.round(v.bitrate / 1000)}kbps` : "unknown",
41
- url: v.src, mimeType: "video/mp4", hasAudio: true, hasVideo: true,
42
- })),
43
- });
126
+ const token = await guestToken();
127
+ if (token) {
128
+ const u = new URL("https://api.x.com/graphql/4Siu98E55GquhG52zHdY5w/TweetDetail");
129
+ u.searchParams.set("variables", JSON.stringify({
130
+ focalTweetId: id, with_rux_injections: false, rankingMode: "Relevance",
131
+ includePromotedContent: true, withCommunity: true,
132
+ withQuickPromoteEligibilityTweetFields: true, withBirdwatchNotes: true, withVoice: true,
133
+ }));
134
+ u.searchParams.set("features", GQL_FEATURES);
135
+ u.searchParams.set("fieldToggles", JSON.stringify({ withArticleRichContentState: true, withArticlePlainText: false }));
136
+ const j = await fetchJson(u.toString(), {
137
+ headers: { ...GQL_HEADERS, "content-type": "application/json", "x-guest-token": token, cookie: `guest_id=v1%3A${token}` },
138
+ }, 20_000);
139
+ const entries = j?.data?.threaded_conversation_with_injections_v2?.instructions
140
+ ?.find((i) => i.type === "TimelineAddEntries")?.entries || [];
141
+ const node = entries.find((e) => e.entryId === `tweet-${id}`)?.content?.itemContent?.tweet_results?.result;
142
+ let base = node?.legacy;
143
+ if (node?.__typename === "TweetWithVisibilityResults") base = node.tweet?.legacy;
144
+ const rep = base?.retweeted_status_result?.result?.legacy?.extended_entities
145
+ || base?.retweeted_status_result?.result?.tweet?.legacy?.extended_entities;
146
+ if (base?.full_text) title = base.full_text;
147
+ author = node?.core?.user_results?.result?.legacy?.screen_name
148
+ || node?.tweet?.core?.user_results?.result?.legacy?.screen_name || null;
149
+ mediaFromEntities(rep?.media || base?.extended_entities?.media, media);
44
150
  }
45
- if (!media.length) throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Post has no media (or it is not public)");
46
- attempt.status = "success";
47
- attempt.latencyMs = Date.now() - t0;
48
- return {
49
- result: {
50
- sourceUrl: url, platform: "x", title: (tw.text || "").slice(0, 120) || null,
51
- author: tw.user?.screen_name ?? null, thumbnail: media[0].thumbnail ?? null,
52
- itemCount: media.length,
53
- counts: {
54
- images: media.filter((x) => x.type === "image").length,
55
- videos: media.filter((x) => x.type === "video").length, audios: 0, other: 0,
56
- },
57
- truncated: false, media,
58
- },
59
- engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
60
- };
61
151
  } catch (e) {
152
+ attempt.note = `graphql: ${String(e.message).slice(0, 60)}`;
153
+ }
154
+
155
+ // 2) syndication embed API with the computed token (embed-widget route)
156
+ if (!media.length) {
157
+ try {
158
+ const j = await fetchJson(
159
+ `https://cdn.syndication.twimg.com/tweet-result?id=${id}&token=${syndicationToken(id)}&lang=en`, {}, 15_000);
160
+ title = title || j?.text || null;
161
+ author = author || j?.user?.screen_name || null;
162
+ mediaFromEntities(j?.mediaDetails, media);
163
+ if (!media.length && j?.video?.variants) {
164
+ const mp4s = j.video.variants.filter((v) => v.type === "video/mp4").sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
165
+ if (mp4s[0]) media.push({
166
+ type: "video", index: 0, url: mp4s[0].src, thumbnail: j.thumbnail?.image?.src || null,
167
+ mimeType: "video/mp4", width: null, height: null,
168
+ duration: j.video.duration_millis ? Math.round(j.video.duration_millis / 1000) : null,
169
+ size: null, quality: mp4s[0].bitrate ? `${Math.round(mp4s[0].bitrate / 1000)}kbps` : null,
170
+ hasAudio: true, hasVideo: true, source: name,
171
+ variants: mp4s.map((v) => ({ height: null, width: null, url: v.src, hasAudio: true, hasVideo: true, quality: v.bitrate ? `${Math.round(v.bitrate / 1000)}kbps` : null, mimeType: "video/mp4", bitrate: v.bitrate ?? null })),
172
+ });
173
+ }
174
+ } catch (e) {
175
+ attempt.note = `${attempt.note ? attempt.note + " · " : ""}syndication: ${String(e.message).slice(0, 60)}`;
176
+ }
177
+ }
178
+
179
+ if (!media.length) {
62
180
  attempt.status = "failed";
63
- attempt.error = e.message;
64
- if (e instanceof UMediaError) throw e;
65
- throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `X unavailable: ${e.message}`, { attempts: [attempt] });
181
+ attempt.error = j_err(attempt.note);
182
+ throw new UMediaError(Codes.MEDIA_NOT_FOUND,
183
+ `X exposes no media for this tweet (${attempt.error}) — protected/removed tweets stay inaccessible; the yt-dlp tier adds cookie support.`, { attempts: [attempt] });
66
184
  }
185
+
186
+ attempt.status = "success";
187
+ attempt.latencyMs = Date.now() - t0;
188
+ return {
189
+ result: {
190
+ sourceUrl: url,
191
+ platform: name,
192
+ title: title ? title.slice(0, 200) : null,
193
+ author,
194
+ thumbnail: media[0].thumbnail,
195
+ itemCount: media.length,
196
+ counts: {
197
+ images: media.filter((m) => m.type === "image").length,
198
+ videos: media.filter((m) => m.type === "video").length,
199
+ audios: 0, other: 0,
200
+ },
201
+ truncated: false,
202
+ media,
203
+ streamUrlsAvailable: true,
204
+ },
205
+ engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
206
+ };
207
+ }
208
+
209
+ function j_err(note) {
210
+ return (note || "no media in public routes").replace(/^.*?(graphql|syndication)/, "$1");
67
211
  }
68
212
 
69
213
  export async function search() {
package/lib/registry.js CHANGED
@@ -1,14 +1,26 @@
1
1
  import * as yt from "./providers/youtube.js";
2
2
  import * as tiktok from "./providers/tiktok.js";
3
+ import * as pinterest from "./providers/pinterest.js";
4
+ import * as soundcloud from "./providers/soundcloud.js";
5
+ import * as dailymotion from "./providers/dailymotion.js";
6
+ import * as vimeo from "./providers/vimeo.js";
7
+ import * as streamable from "./providers/streamable.js";
8
+ import * as twitch from "./providers/twitch.js";
9
+ import * as bluesky from "./providers/bluesky.js";
3
10
  import * as reddit from "./providers/reddit.js";
4
11
  import * as xprov from "./providers/x.js";
5
12
  import * as ig from "./providers/instagram.js";
13
+ import * as facebook from "./providers/facebook.js";
14
+ import * as threads from "./providers/threads.js";
15
+ import * as snapchat from "./providers/snapchat.js";
16
+ import * as rumble from "./providers/rumble.js";
17
+ import * as tumblr from "./providers/tumblr.js";
6
18
  import * as ytdlp from "./providers/ytdlp.js";
7
19
  import * as itunes from "./providers/itunes.js";
8
20
  import { Codes, UMediaError } from "./errors.js";
9
21
  import { validateUrl } from "./util.js";
10
22
 
11
- const NATIVE = [yt, tiktok, reddit, xprov, ig];
23
+ const NATIVE = [yt, tiktok, pinterest, soundcloud, dailymotion, vimeo, streamable, twitch, bluesky, reddit, xprov, ig, facebook, threads, snapchat, rumble, tumblr];
12
24
 
13
25
  export function detectPlatform(url) {
14
26
  for (const p of NATIVE) {
@@ -49,6 +61,7 @@ export async function resolve(url) {
49
61
  }
50
62
 
51
63
  const ctx = { attempts: [] };
64
+ let lastTyped = null;
52
65
  for (const p of NATIVE) {
53
66
  if (!p.canHandle(url)) continue;
54
67
  try {
@@ -61,6 +74,7 @@ export async function resolve(url) {
61
74
  throw e;
62
75
  }
63
76
  // transient/provider failure → try the next layer
77
+ if (e instanceof UMediaError) lastTyped = e;
64
78
  continue;
65
79
  }
66
80
  }
@@ -74,6 +88,12 @@ export async function resolve(url) {
74
88
  }
75
89
  }
76
90
  const last = ctx.attempts[ctx.attempts.length - 1];
91
+ // surface the real provider error when every matching provider failed —
92
+ // never mask a typed outcome behind a generic message
93
+ if (lastTyped) {
94
+ lastTyped.attempts = ctx.attempts;
95
+ throw lastTyped;
96
+ }
77
97
  throw new UMediaError(
78
98
  detectPlatform(url) === "unknown" ? Codes.INVALID_URL : Codes.PROVIDER_UNAVAILABLE,
79
99
  `No provider could resolve this URL.${last?.error ? " Last error: " + last.error : ""}` +
@@ -99,10 +119,22 @@ export function capabilities() {
99
119
  return {
100
120
  platforms: [
101
121
  { platform: "youtube", search: true, resolve: true, download: "network-dependent (yt-dlp tier recommended)", notes: "search+metadata always; stream URLs depend on network/PO-token state" },
102
- { platform: "tiktok", search: false, resolve: true, download: true, notes: "photo galleries + video posts" },
103
- { platform: "instagram", search: false, resolve: true, download: true, notes: "public embed best-effort" },
104
- { platform: "reddit", search: false, resolve: true, download: true, notes: "posts + galleries" },
105
- { platform: "x", search: false, resolve: true, download: true, notes: "public posts via syndication" },
122
+ { platform: "tiktok", search: false, resolve: true, download: true, notes: "video posts = direct CDN mp4; galleries = every slide the platform exposes (truncated flag honest)" },
123
+ { platform: "pinterest", search: false, resolve: true, download: true, notes: "image pins, video pins (direct mp4), idea/story pins, pin.it links" },
124
+ { platform: "soundcloud", search: false, resolve: true, download: true, notes: "full tracks: direct progressive MP3 + HLS, real titles/artists" },
125
+ { platform: "dailymotion", search: false, resolve: true, download: true, notes: "public metadata API; HLS/progressive renditions" },
126
+ { platform: "vimeo", search: false, resolve: true, download: true, notes: "player config: progressive mp4 when served, HLS otherwise" },
127
+ { platform: "streamable", search: false, resolve: true, download: true, notes: "direct mp4 renditions" },
128
+ { platform: "twitch", search: false, resolve: true, download: true, notes: "clips = direct mp4s; VODs/livestreams via yt-dlp tier" },
129
+ { platform: "bluesky", search: false, resolve: true, download: true, notes: "public AppView API: full-size images + video playlists" },
130
+ { platform: "instagram", search: false, resolve: true, download: true, notes: "oEmbed→mobile info API: photos, videos, full carousels" },
131
+ { platform: "reddit", search: false, resolve: true, download: true, notes: "posts + galleries + video with audio sidecar" },
132
+ { platform: "x", search: false, resolve: true, download: true, notes: "syndication token + guest GraphQL — photos/videos/gifs" },
133
+ { platform: "facebook", search: false, resolve: true, download: true, notes: "reels/watch/videos/shares/fb.watch via the player JSON" },
134
+ { platform: "threads", search: false, resolve: true, download: true, notes: "post SSR state + og media" },
135
+ { platform: "snapchat", search: false, resolve: true, download: true, notes: "spotlight videos + public story snap lists" },
136
+ { platform: "rumble", search: false, resolve: true, download: true, notes: "embed API: direct mp4 renditions" },
137
+ { platform: "tumblr", search: false, resolve: true, download: true, notes: "mobile API: video/audio/photo posts + reblog trails" },
106
138
  { platform: "itunes", search: true, resolve: false, download: "previews", notes: "real 30s clips" },
107
139
  { platform: "ytdlp", search: false, resolve: Boolean(ytdlp.binary()), download: Boolean(ytdlp.binary()), notes: "optional tier: 1800+ sites — install yt-dlp" },
108
140
  ],
@@ -116,10 +148,22 @@ export function status() {
116
148
  runtime: { node: process.version, platform: process.platform },
117
149
  providers: [
118
150
  { name: "youtube", state: "operational", via: "youtubei.js" },
119
- { name: "tiktok", state: "operational", via: "embed/v2" },
120
- { name: "instagram", state: "degraded", via: "public embed (best-effort)" },
151
+ { name: "tiktok", state: "operational", via: "embed state" },
152
+ { name: "pinterest", state: "operational", via: "public widget API" },
153
+ { name: "soundcloud", state: "operational", via: "public api-v2" },
154
+ { name: "dailymotion", state: "operational", via: "player metadata" },
155
+ { name: "vimeo", state: "operational", via: "player config" },
156
+ { name: "streamable", state: "operational", via: "public video api" },
157
+ { name: "twitch", state: "operational", via: "public GraphQL (clips)" },
158
+ { name: "bluesky", state: "operational", via: "public AppView XRPC" },
159
+ { name: "instagram", state: "operational", via: "oEmbed + mobile info api" },
121
160
  { name: "reddit", state: "operational", via: "public json" },
122
- { name: "x", state: "degraded", via: "syndication (best-effort)" },
161
+ { name: "x", state: "operational", via: "syndication + guest graphql" },
162
+ { name: "facebook", state: "operational", via: "web player json" },
163
+ { name: "threads", state: "operational", via: "share page state" },
164
+ { name: "snapchat", state: "operational", via: "spotlight + story state" },
165
+ { name: "rumble", state: "operational", via: "embed json api" },
166
+ { name: "tumblr", state: "operational", via: "mobile api" },
123
167
  { name: "itunes", state: "operational", via: "search api" },
124
168
  { name: "ytdlp", state: ytdlp.binary() ? "operational" : "unavailable", via: "external binary" },
125
169
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "umedia",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
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",