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.
@@ -1,153 +0,0 @@
1
- import { fetchJson, hostMatches } from "../util.js";
2
- import { Codes, UMediaError } from "../errors.js";
3
-
4
- /**
5
- * Tumblr — the public mobile API (api-http2.tumblr.com, the same endpoint the
6
- * official iPhone app calls with its public api_key). Videos, audio, photos —
7
- * including reblog trails. Reference: cobalt tumblr service.
8
- */
9
- export const name = "tumblr";
10
-
11
- const API_KEY = "jrsCWX1XDuVxAFO4GkK147syAoN8BJZ5voz8tS80bPcj26Vc5Z";
12
- const API_BASE = "https://api-http2.tumblr.com";
13
- const MOBILE = {
14
- "user-agent": "Tumblr/iPhone/33.3/333010/17.3.1/tumblr",
15
- "x-version": "iPhone/33.3/333010/17.3.1/tumblr",
16
- };
17
-
18
- export function canHandle(url) {
19
- try {
20
- return hostMatches(new URL(String(url)).hostname, ["tumblr.com"]);
21
- } catch { return false; }
22
- }
23
-
24
- export function parseUrl(url) {
25
- const u = new URL(String(url));
26
- const host = u.hostname.replace(/^www\./, "");
27
- let m;
28
- // https://{user}.tumblr.com/post/{id}/...
29
- m = u.pathname.match(/^\/post\/(\d+)/);
30
- if (m && host !== "tumblr.com") return { user: host.split(".")[0], id: m[1] };
31
- // https://www.tumblr.com/{user}/{id} or /blog/view/{user}/{id}
32
- m = u.pathname.match(/^\/(?:blog\/view\/)?([\w.-]+)\/(\d+)/);
33
- if (m) return { user: m[1], id: m[2] };
34
- return null;
35
- }
36
-
37
- export async function resolve(url, ctx = {}) {
38
- const attempt = { provider: name, status: "skipped" };
39
- ctx.attempts?.push(attempt);
40
- const t0 = Date.now();
41
-
42
- const parsed = parseUrl(url);
43
- if (!parsed) {
44
- attempt.status = "failed";
45
- throw new UMediaError(Codes.INVALID_URL, "Not a Tumblr post URL (expected {user}.tumblr.com/post/{id} or tumblr.com/{user}/{id})", { attempts: [attempt] });
46
- }
47
-
48
- let data;
49
- try {
50
- data = await fetchJson(
51
- `${API_BASE}/v2/blog/${encodeURIComponent(parsed.user)}.tumblr.com/posts/${parsed.id}/permalink?api_key=${API_KEY}`, { headers: MOBILE }, 20_000);
52
- } catch (e) {
53
- attempt.status = "failed";
54
- attempt.error = e.message;
55
- throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Tumblr API unavailable. ${e.message}`, { attempts: [attempt] });
56
- }
57
-
58
- const element = data?.response?.timeline?.elements?.[0];
59
- if (!element) {
60
- attempt.status = "failed";
61
- attempt.error = data?.meta?.msg || "post not found";
62
- throw new UMediaError(Codes.MEDIA_NOT_FOUND, `Tumblr: ${attempt.error}`, { attempts: [attempt] });
63
- }
64
-
65
- // contents: the post itself + any reblog trail, in order — ALL of it
66
- const contents = [
67
- ...(element.content || []),
68
- ...(element.trail || []).flatMap((t) => t.content || []),
69
- ];
70
-
71
- const media = [];
72
- for (const c of contents) {
73
- if (c.type === "video" && (c.provider === "tumblr" || !c.provider) && c.media?.url) {
74
- media.push({
75
- type: "video", index: media.length, url: c.media.url,
76
- thumbnail: c.poster?.[0]?.url || null,
77
- mimeType: "video/mp4", width: c.width ?? null, height: c.height ?? null,
78
- duration: null, size: null, quality: null,
79
- hasAudio: true, hasVideo: true, source: name, variants: [],
80
- });
81
- } else if (c.type === "audio" && c.media?.url) {
82
- media.push({
83
- type: "audio", index: media.length, url: c.media.url,
84
- thumbnail: null, mimeType: "audio/mpeg", width: null, height: null,
85
- duration: null, size: null, quality: null,
86
- hasAudio: true, hasVideo: false, source: name, variants: [],
87
- });
88
- } else if (c.type === "image" || c.type === "photo") {
89
- // image content carries media as an ARRAY of renditions — take the best
90
- // one as the item, keep the rest as variants. never inflate items.
91
- const items = (Array.isArray(c.media) ? c.media : (c.media?.url ? [c.media] : []))
92
- .filter((mm) => mm?.url);
93
- const uniq = [...new Map(items.map((mm) => [mm.url, mm])).values()]
94
- .sort((a, b) => ((b.width || 0) * (b.height || 0)) - ((a.width || 0) * (a.height || 0)));
95
- const best = uniq[0];
96
- if (best) {
97
- media.push({
98
- type: "image", index: media.length, url: best.url,
99
- thumbnail: best.url, mimeType: "image/jpeg",
100
- width: best.width ?? null, height: best.height ?? null,
101
- duration: null, size: null, quality: best.width ? `${best.width}px` : null,
102
- hasAudio: false, hasVideo: false, source: name,
103
- variants: uniq.map((x) => ({
104
- height: x.height ?? null, width: x.width ?? null, url: x.url,
105
- hasAudio: false, hasVideo: false, quality: x.width ? `${x.width}px` : null, mimeType: "image/jpeg",
106
- })),
107
- });
108
- }
109
- }
110
- }
111
-
112
- // reblog trails repeat media from earlier hops — dedupe by URL, keep first order
113
- const seen = new Set();
114
- const deduped = [];
115
- for (const mm of media) {
116
- if (seen.has(mm.url)) continue;
117
- seen.add(mm.url);
118
- deduped.push({ ...mm, index: deduped.length });
119
- }
120
- media.length = 0;
121
- media.push(...deduped);
122
-
123
- if (!media.length) {
124
- attempt.status = "failed";
125
- throw new UMediaError(Codes.MEDIA_NOT_FOUND, "Tumblr post contains no downloadable media (text/link post, or third-party embed — use the yt-dlp tier for YouTube/Vimeo embeds)", { attempts: [attempt] });
126
- }
127
-
128
- attempt.status = "success";
129
- attempt.latencyMs = Date.now() - t0;
130
- return {
131
- result: {
132
- sourceUrl: url,
133
- platform: name,
134
- title: (element.summary || element.let || "").slice(0, 200) || null,
135
- author: element.blog_name || parsed.user,
136
- thumbnail: media[0].thumbnail,
137
- itemCount: media.length,
138
- counts: {
139
- images: media.filter((m) => m.type === "image").length,
140
- videos: media.filter((m) => m.type === "video").length,
141
- audios: media.filter((m) => m.type === "audio").length, other: 0,
142
- },
143
- truncated: false,
144
- media,
145
- streamUrlsAvailable: true,
146
- },
147
- engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
148
- };
149
- }
150
-
151
- export async function search() {
152
- return [];
153
- }
@@ -1,108 +0,0 @@
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
- }
@@ -1,108 +0,0 @@
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,215 +0,0 @@
1
- import { fetchJson, hostMatches } from "../util.js";
2
- import { Codes, UMediaError } from "../errors.js";
3
-
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
- */
11
- export const name = "x";
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
-
41
- export function canHandle(url) {
42
- try {
43
- return hostMatches(new URL(String(url)).hostname, ["twitter.com", "x.com"]);
44
- } catch { return false; }
45
- }
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
-
109
- export async function resolve(url, ctx = {}) {
110
- const attempt = { provider: name, status: "skipped" };
111
- ctx.attempts?.push(attempt);
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)
125
- try {
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);
150
- }
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) {
180
- attempt.status = "failed";
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] });
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");
211
- }
212
-
213
- export async function search() {
214
- return [];
215
- }