umedia 0.3.0 → 0.4.1

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.
@@ -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
@@ -10,13 +10,17 @@ import * as bluesky from "./providers/bluesky.js";
10
10
  import * as reddit from "./providers/reddit.js";
11
11
  import * as xprov from "./providers/x.js";
12
12
  import * as ig from "./providers/instagram.js";
13
- import * as ogembed from "./providers/ogembed.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";
14
18
  import * as ytdlp from "./providers/ytdlp.js";
15
19
  import * as itunes from "./providers/itunes.js";
16
20
  import { Codes, UMediaError } from "./errors.js";
17
21
  import { validateUrl } from "./util.js";
18
22
 
19
- const NATIVE = [yt, tiktok, pinterest, soundcloud, dailymotion, vimeo, streamable, twitch, bluesky, reddit, xprov, ig, ogembed];
23
+ const NATIVE = [yt, tiktok, pinterest, soundcloud, dailymotion, vimeo, streamable, twitch, bluesky, reddit, xprov, ig, facebook, threads, snapchat, rumble, tumblr];
20
24
 
21
25
  export function detectPlatform(url) {
22
26
  for (const p of NATIVE) {
@@ -57,6 +61,7 @@ export async function resolve(url) {
57
61
  }
58
62
 
59
63
  const ctx = { attempts: [] };
64
+ let lastTyped = null;
60
65
  for (const p of NATIVE) {
61
66
  if (!p.canHandle(url)) continue;
62
67
  try {
@@ -69,6 +74,7 @@ export async function resolve(url) {
69
74
  throw e;
70
75
  }
71
76
  // transient/provider failure → try the next layer
77
+ if (e instanceof UMediaError) lastTyped = e;
72
78
  continue;
73
79
  }
74
80
  }
@@ -82,6 +88,12 @@ export async function resolve(url) {
82
88
  }
83
89
  }
84
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
+ }
85
97
  throw new UMediaError(
86
98
  detectPlatform(url) === "unknown" ? Codes.INVALID_URL : Codes.PROVIDER_UNAVAILABLE,
87
99
  `No provider could resolve this URL.${last?.error ? " Last error: " + last.error : ""}` +
@@ -115,10 +127,14 @@ export function capabilities() {
115
127
  { platform: "streamable", search: false, resolve: true, download: true, notes: "direct mp4 renditions" },
116
128
  { platform: "twitch", search: false, resolve: true, download: true, notes: "clips = direct mp4s; VODs/livestreams via yt-dlp tier" },
117
129
  { platform: "bluesky", search: false, resolve: true, download: true, notes: "public AppView API: full-size images + video playlists" },
118
- { platform: "instagram", search: false, resolve: true, download: true, notes: "public embed best-effort" },
119
- { platform: "reddit", search: false, resolve: true, download: true, notes: "posts + galleries" },
120
- { platform: "x", search: false, resolve: true, download: true, notes: "public posts via syndication" },
121
- { platform: "facebook · threads · snapchat · rumble · tumblr", search: false, resolve: "best-effort", download: "best-effort", notes: "official Open Graph media; often login-gated on datacenter IPs — yt-dlp tier reliable" },
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" },
122
138
  { platform: "itunes", search: true, resolve: false, download: "previews", notes: "real 30s clips" },
123
139
  { platform: "ytdlp", search: false, resolve: Boolean(ytdlp.binary()), download: Boolean(ytdlp.binary()), notes: "optional tier: 1800+ sites — install yt-dlp" },
124
140
  ],
@@ -140,10 +156,14 @@ export function status() {
140
156
  { name: "streamable", state: "operational", via: "public video api" },
141
157
  { name: "twitch", state: "operational", via: "public GraphQL (clips)" },
142
158
  { name: "bluesky", state: "operational", via: "public AppView XRPC" },
143
- { name: "instagram", state: "degraded", via: "public embed (best-effort)" },
159
+ { name: "instagram", state: "operational", via: "oEmbed + mobile info api" },
144
160
  { name: "reddit", state: "operational", via: "public json" },
145
- { name: "x", state: "degraded", via: "syndication (best-effort)" },
146
- { name: "ogembed", state: "degraded", via: "open graph meta (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" },
147
167
  { name: "itunes", state: "operational", via: "search api" },
148
168
  { name: "ytdlp", state: ytdlp.binary() ? "operational" : "unavailable", via: "external binary" },
149
169
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "umedia",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",
@@ -1,111 +0,0 @@
1
- import { fetchText, hostMatches } from "../util.js";
2
- import { Codes, UMediaError } from "../errors.js";
3
-
4
- /**
5
- * og-embed tier — Facebook, Threads, Snapchat Spotlight, Rumble, Tumblr.
6
- * These platforms publish official media previews as Open Graph meta tags on
7
- * their public share pages; some networks gate them (typed honest errors there,
8
- * the yt-dlp tier is the reliable path). No scraping tricks — their own tags.
9
- */
10
- export const name = "ogembed";
11
-
12
- const HOSTS = ["facebook.com", "fb.watch", "threads.net", "threads.com", "snapchat.com", "rumble.com", "tumblr.com"];
13
-
14
- export function canHandle(url) {
15
- try {
16
- return hostMatches(new URL(String(url)).hostname, HOSTS);
17
- } catch { return false; }
18
- }
19
-
20
- function meta(h, key) {
21
- const pats = [
22
- new RegExp(`<meta[^>]+(?:property|name)="${key}"[^>]+content="([^"]*)"`, "i"),
23
- new RegExp(`<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="${key}"`, "i"),
24
- ];
25
- for (const p of pats) {
26
- const m = h.match(p);
27
- if (m) return m[1].replace(/&amp;/g, "&").replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n));
28
- }
29
- return null;
30
- }
31
-
32
- export async function resolve(url, ctx = {}) {
33
- const attempt = { provider: name, status: "skipped" };
34
- ctx.attempts?.push(attempt);
35
- const t0 = Date.now();
36
- const host = (() => { try { return new URL(String(url)).hostname.replace(/^www\./, ""); } catch { return "?"; } })();
37
-
38
- let html;
39
- try {
40
- const r = await fetchText(String(url), {}, 18_000);
41
- if (r.status >= 400) {
42
- attempt.status = "failed";
43
- attempt.error = `HTTP ${r.status}`;
44
- throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `${host} returned HTTP ${r.status}`, { attempts: [attempt] });
45
- }
46
- html = r.text;
47
- } catch (e) {
48
- if (e instanceof UMediaError) throw e;
49
- attempt.status = "failed";
50
- attempt.error = e.message;
51
- throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `${host} not retrievable. ${e.message}`, { attempts: [attempt] });
52
- }
53
-
54
- const video = meta(html, "og:video:secure_url") || meta(html, "og:video") || meta(html, "og:video:url");
55
- const image = meta(html, "og:image:secure_url") || meta(html, "og:image");
56
- const title = meta(html, "og:title");
57
- const desc = meta(html, "og:description");
58
- const author = meta(html, "og:article:author") || meta(html, "twitter:creator") || null;
59
-
60
- const media = [];
61
- if (video && /\.mp4|\.m3u8|video/i.test(video)) {
62
- media.push({
63
- type: "video", index: 0, url: video, thumbnail: image,
64
- mimeType: /\.m3u8/i.test(video) ? "application/x-mpegURL" : "video/mp4",
65
- width: parseInt(meta(html, "og:video:width") || "", 10) || null,
66
- height: parseInt(meta(html, "og:video:height") || "", 10) || null,
67
- duration: parseInt(meta(html, "video:duration") || meta(html, "og:video:duration") || "", 10) || null,
68
- size: null, quality: null,
69
- hasAudio: true, hasVideo: true, source: name, variants: [],
70
- });
71
- }
72
- if (image && (!video || !/\.mp4/i.test(video))) {
73
- media.push({
74
- type: "image", index: media.length, url: image, thumbnail: image,
75
- mimeType: "image/jpeg", width: null, height: null, duration: null, size: null,
76
- quality: null, hasAudio: false, hasVideo: false, source: name, variants: [],
77
- });
78
- }
79
-
80
- if (!media.length) {
81
- attempt.status = "failed";
82
- attempt.error = "no media in the public share page (login-gated or removed)";
83
- throw new UMediaError(Codes.MEDIA_NOT_FOUND,
84
- `${host} exposes no media on its public page (often login-gated from datacenter IPs) — the yt-dlp tier is the reliable path. ${attempt.error}`, { attempts: [attempt] });
85
- }
86
-
87
- attempt.status = "success";
88
- attempt.latencyMs = Date.now() - t0;
89
- return {
90
- result: {
91
- sourceUrl: url,
92
- platform: host,
93
- title: title || desc || null,
94
- author,
95
- thumbnail: image || media[0].thumbnail,
96
- itemCount: media.length,
97
- counts: {
98
- images: media.filter((m) => m.type === "image").length,
99
- videos: media.filter((m) => m.type === "video").length,
100
- audios: 0, other: 0,
101
- },
102
- truncated: false,
103
- media,
104
- },
105
- engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
106
- };
107
- }
108
-
109
- export async function search() {
110
- return [];
111
- }