social-dl 0.9.0 → 0.9.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/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export interface Format { tag: string; quality: string; type: string; url: string; hasAudio?: boolean; isAudioOnly?: boolean; isNoWatermark?: boolean; }
2
- export interface DlResult { creator?: string; platform: string; id: string; title?: string; author?: string; thumbnail?: string; originalUrl: string; isAudioOnly?: boolean; formats: Format[]; needsMux?: boolean; videoUrl?: string; audioUrl?: string; }
2
+ export interface DlResult { creator?: string; platform: string; id: string; client?: string; title?: string; author?: string; thumbnail?: string; originalUrl: string; isAudioOnly?: boolean; formats: Format[]; needsMux?: boolean; videoUrl?: string; audioUrl?: string; }
3
3
  export interface UniversalOptions { audioOnly?: boolean; quality?: string; noWatermark?: boolean; }
4
4
  export interface StreamResult { stream: ReadableStream<Uint8Array>; filename: string; info: DlResult; contentType: string; contentLength?: string | null; }
5
5
  export function detectPlatform(url: string): string;
@@ -17,6 +17,7 @@ export function instagramDl(url: string): Promise<DlResult>;
17
17
  export function instagramSave(url: string, dest?: string, quality?: string): Promise<string>;
18
18
  export function tiktokDl(url: string): Promise<DlResult>;
19
19
  export function tiktokSave(url: string, dest?: string, quality?: string): Promise<string>;
20
+ export function tiktokDownload(url: string, format?: 'mp3' | 'mp4'): Promise<{ buffer: Buffer; mimetype: string; extension: string }>;
20
21
  export function twitterDl(url: string): Promise<DlResult>;
21
22
  export function twitterSave(url: string, dest?: string, quality?: string): Promise<string>;
22
23
  export function pinterestDl(url: string): Promise<DlResult>;
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "social-dl",
3
- "version": "0.9.0",
4
- "license": "SEE LICENSE IN LICENSE ⚉",
3
+ "version": "0.9.2",
4
+ "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "Direct CDN social downloader - YouTube, TikTok, Facebook, Instagram, Twitter, Pinterest, CapCut - No API, No keys, Never dies",
6
6
  "main": "src/index.js",
7
7
  "type": "module",
8
- "bin": { "social-dl": "./bin/cli.js" },
8
+ "bin": {
9
+ "social-dl": "./bin/cli.js"
10
+ },
9
11
  "exports": {
10
12
  ".": "./src/index.js",
11
13
  "./facebook": "./src/platforms/facebook.js",
@@ -22,9 +24,27 @@
22
24
  "test": "node tests/test.js",
23
25
  "dev": "node src/index.js"
24
26
  },
25
- "keywords": ["youtube downloader", "tiktok downloader", "facebook downloader", "instagram downloader", "twitter downloader", "pinterest downloader", "capcut downloader", "youtube mp3", "youtube to mp3", "reels downloader", "no api downloader", "direct cdn", "social-dl", "ytmp4", "ytmp3"],
27
+ "optionalDependencies": {
28
+ "yt-dlp-exec": "^1.2.0"
29
+ },
30
+ "keywords": [
31
+ "youtube downloader",
32
+ "tiktok downloader",
33
+ "facebook downloader",
34
+ "instagram downloader",
35
+ "twitter downloader",
36
+ "pinterest downloader",
37
+ "capcut downloader",
38
+ "youtube mp3",
39
+ "youtube to mp3",
40
+ "reels downloader",
41
+ "no api downloader",
42
+ "direct cdn",
43
+ "social-dl",
44
+ "ytmp4",
45
+ "ytmp3"
46
+ ],
26
47
  "author": "@plogme",
27
- "license": "SEE LICENSE IN LICENSE",
28
48
  "repository": {
29
49
  "type": "git",
30
50
  "url": "git+https://github.com/crysnovax/social-dl.git"
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { UnsupportedError } from './errors.js';
2
2
  import { facebookDl, facebookSave } from './platforms/facebook.js';
3
- import { youtubeDl, youtubeSave } from './platforms/youtube.js';
3
+ import { IOS_MEDIA_HEADERS, YOUTUBE_MEDIA_HEADERS, youtubeDl, youtubeSave } from './platforms/youtube.js';
4
4
  import { youtubeMp3Dl, youtubeMp3Save } from './platforms/ytmp3.js';
5
5
  import { instagramDl, instagramSave } from './platforms/instagram.js';
6
6
  import { tiktokDl, tiktokSave } from './platforms/tiktok.js';
@@ -39,8 +39,10 @@ export async function universalDl(url, opts = {}) {
39
39
  /** Save a selected format from the correct platform. */
40
40
  export async function universalSave(url, dest = './video.mp4', opts = {}) {
41
41
  const quality = opts.quality || 'best';
42
+ if (detectPlatform(url) === 'youtube') return opts.audioOnly ? youtubeMp3Save(url, dest, quality) : youtubeSave(url, dest, quality);
42
43
  const result = await universalDl(url, opts);
43
- return saveResult(result, dest, quality, {}, opts.noWatermark === true);
44
+ const headers = detectPlatform(url) === 'youtube' ? { ...YOUTUBE_MEDIA_HEADERS, fallbackHeaders: IOS_MEDIA_HEADERS } : {};
45
+ return saveResult(result, dest, quality, headers, opts.noWatermark === true);
44
46
  }
45
47
 
46
48
  export * from './errors.js';
@@ -1,11 +1,27 @@
1
- import { fetchWithRetry, downloadTo } from '../utils/fetcher.js';
1
+ import { mkdir, rename, stat, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { fetchWithRetry } from '../utils/fetcher.js';
2
4
  import { sortFormats, saveResult } from '../utils/media.js';
3
- import { NotFoundError, PrivateError } from '../errors.js';
5
+ import { runYtDlp } from '../utils/ytdlp.js';
6
+ import { NotFoundError, PrivateError, UnsupportedError } from '../errors.js';
4
7
  import { CREATOR } from '../constants.js';
5
8
 
6
- const DESKTOP = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36';
9
+ export const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
10
+ export const TMP_DIR = 'cache/temp';
11
+ const FB_HEADERS = { 'User-Agent': UA, Accept: '*/*', 'Accept-Language': 'en-US,en;q=0.9', Referer: 'https://www.facebook.com/', Origin: 'https://www.facebook.com/' };
7
12
  const unescape = value => { try { return JSON.parse(`"${value}"`); } catch { return value.replace(/\\u0025/g, '%').replace(/\\u0026/g, '&').replace(/\\\//g, '/'); } };
8
13
 
14
+ export function isFacebookUrl(value) {
15
+ try {
16
+ const parsed = new URL(value);
17
+ return /facebook\.com\/share\/r\//i.test(parsed.href)
18
+ || /fb\.watch/i.test(parsed.hostname)
19
+ || /facebook\.com\/reel\//i.test(parsed.href)
20
+ || /web\.facebook\.com\/reel\//i.test(parsed.href)
21
+ || /facebook\.com\/watch/i.test(parsed.href);
22
+ } catch { return false; }
23
+ }
24
+
9
25
  function extract(html) {
10
26
  const find = name => { const match = html.match(new RegExp(`"${name}":"([^"]+)"`)); return match && unescape(match[1]); };
11
27
  const hd = find('browser_native_hd_url') || find('playable_url_quality_hd');
@@ -16,27 +32,69 @@ function extract(html) {
16
32
  ]);
17
33
  }
18
34
 
19
- /** Download metadata for a Facebook video or reel. */
20
- export async function facebookDl(url) {
21
- const resolved = await fetchWithRetry(url, { headers: { 'User-Agent': DESKTOP } });
35
+ function idOf(url) { return url.match(/reel\/(\d+)|videos\/(\d+)|[?&]v=(\d+)|\/(\d+)\/?/)?.slice(1).find(Boolean) || url; }
36
+ function fallbackError(error) { return error instanceof PrivateError || error instanceof NotFoundError || /PRIVATE|NOT_FOUND|empty|404|403|login|checkpoint/i.test(error?.message || ''); }
37
+ function outputFile(base) { return join(TMP_DIR, `${base}.mp4`); }
38
+ async function validFile(file) { try { return (await stat(file)).isFile() && (await stat(file)).size > 4096; } catch { return false; } }
39
+ async function cleanup(file) { await unlink(file).catch(() => {}); }
40
+
41
+ async function ytdlpInfo(url) {
42
+ const { stdout } = await runYtDlp(['--dump-json', '--no-download', '--no-playlist', '--skip-download', '--no-warnings', '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--user-agent', UA, url]);
43
+ const line = stdout.trim().split(/\r?\n/).find(Boolean);
44
+ if (!line) throw new Error('yt-dlp returned no Facebook video information.');
45
+ return JSON.parse(line);
46
+ }
47
+ function normalizeFallback(data, url) {
48
+ const direct = data.url || data.requested_formats?.find(item => item.url)?.url;
49
+ return { creator: CREATOR, platform: 'facebook', id: data.id || idOf(url), title: data.title, author: data.uploader || data.channel, originalUrl: url, formats: direct ? [{ tag: 'BEST', quality: `${data.height || 720}p`, type: 'mp4', url: direct, hasAudio: true }] : [], fallback: true };
50
+ }
51
+ async function facebookFallbackInfo(url) { return normalizeFallback(await ytdlpInfo(url), url); }
52
+
53
+ async function facebookFallbackDownload(url, dest) {
54
+ await mkdir(TMP_DIR, { recursive: true });
55
+ const base = `fb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
56
+ const temp = outputFile(base);
57
+ try {
58
+ await runYtDlp(['--no-playlist', '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--no-warnings', '--user-agent', UA, '-f', 'bv*[ext=mp4][vcodec^=avc1][height<=720]+ba[ext=m4a]/b[ext=mp4]/best', '--merge-output-format', 'mp4', '-o', temp, url]);
59
+ if (!await validFile(temp)) throw new Error('Facebook yt-dlp output was empty or too small.');
60
+ await rename(temp, dest);
61
+ return dest;
62
+ } catch (error) {
63
+ if (/ENOENT|not found|spawn/i.test(error.message)) throw new Error('Facebook blocked. Run npm i yt-dlp-exec or pip install yt-dlp');
64
+ throw error;
65
+ } finally { await cleanup(temp); }
66
+ }
67
+
68
+ async function facebookTier1(url) {
69
+ const resolved = await fetchWithRetry(url, { headers: FB_HEADERS });
22
70
  const originalUrl = resolved.url || url;
23
- const id = originalUrl.match(/reel\/(\d+)|videos\/(\d+)|[?&]v=(\d+)|\/(\d+)\/?$/)?.slice(1).find(Boolean) || originalUrl;
24
- let html = await (await fetchWithRetry(originalUrl, { headers: { 'User-Agent': DESKTOP } })).text();
71
+ const id = idOf(originalUrl);
72
+ let html = await (await fetchWithRetry(originalUrl, { headers: FB_HEADERS })).text();
25
73
  let formats = extract(html);
26
74
  if (html.length < 5000 || formats.length === 0) {
27
75
  await new Promise(resolve => setTimeout(resolve, 1000));
28
76
  const basicUrl = originalUrl.replace(/www\.facebook\.com/i, 'mbasic.facebook.com');
29
- html = await (await fetchWithRetry(basicUrl, { headers: { 'User-Agent': DESKTOP } })).text();
77
+ html = await (await fetchWithRetry(basicUrl, { headers: FB_HEADERS })).text();
30
78
  formats = extract(html);
31
79
  }
32
- if (!formats.length) {
33
- if (/login|checkpoint|not found/i.test(html)) throw new PrivateError();
34
- throw new NotFoundError('PRIVATE_OR_NOT_FOUND');
35
- }
80
+ if (!formats.length) { if (/login|checkpoint|not found/i.test(html)) throw new PrivateError(); throw new NotFoundError('PRIVATE_OR_NOT_FOUND'); }
36
81
  return { creator: CREATOR, platform: 'facebook', id, originalUrl, formats };
37
82
  }
38
83
 
84
+ /** Download metadata for a Facebook video or reel. */
85
+ export async function facebookDl(url) {
86
+ if (!isFacebookUrl(url)) throw new UnsupportedError('Invalid Facebook URL.');
87
+ try { return await facebookTier1(url); }
88
+ catch (error) {
89
+ if (!fallbackError(error)) throw error;
90
+ try { return await facebookFallbackInfo(url); }
91
+ catch (fallback) { if (/ENOENT|not found|spawn/i.test(fallback.message)) throw new Error('Facebook blocked. Run npm i yt-dlp-exec or pip install yt-dlp'); throw fallback; }
92
+ }
93
+ }
94
+
39
95
  /** Save a Facebook video to disk. */
40
96
  export async function facebookSave(url, dest = './video.mp4', quality = 'best') {
41
- return saveResult(await facebookDl(url), dest, quality, { 'User-Agent': DESKTOP, Referer: 'https://www.facebook.com/' });
97
+ const result = await facebookDl(url);
98
+ if (result.fallback) return facebookFallbackDownload(url, dest);
99
+ return saveResult(result, dest, quality, FB_HEADERS);
42
100
  }
@@ -1,25 +1,59 @@
1
+ import { mkdir, rename, stat, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
1
3
  import { fetchWithRetry } from '../utils/fetcher.js';
2
4
  import { saveResult, sortFormats } from '../utils/media.js';
5
+ import { runYtDlp } from '../utils/ytdlp.js';
3
6
  import { unescapeJson } from '../utils/parser.js';
4
7
  import { NotFoundError, PrivateError, UnsupportedError } from '../errors.js';
5
8
  import { CREATOR } from '../constants.js';
6
9
 
7
- const UAS = ['Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15', 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36'];
8
- const headers = () => ({ 'User-Agent': UAS[Math.floor(Math.random() * UAS.length)], 'X-IG-App-ID': '936619743392459', Accept: 'application/json' });
9
- const codeOf = url => new URL(url).pathname.match(/\/(?:p|reel|tv)\/([A-Za-z0-9_-]+)/)?.[1] || new URL(url).pathname.match(/share\/([A-Za-z0-9_-]+)/)?.[1];
10
+ export const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
11
+ export const TMP_DIR = 'cache/temp';
12
+ const IG_HEADERS = { 'User-Agent': UA, Accept: '*/*', 'Accept-Language': 'en-US,en;q=0.9', Referer: 'https://www.instagram.com/', Origin: 'https://www.instagram.com/' };
13
+ const codeOf = url => new URL(url).pathname.match(/\/(?:p|reel|tv)\/([A-Za-z0-9_-]+)/)?.[1];
10
14
 
15
+ export function isInstagramUrl(value) {
16
+ try { return /instagram\.com\/(?:reel|p|tv)\//i.test(new URL(value).href); } catch { return false; }
17
+ }
18
+ export function cleanInstagramUrl(value) { const parsed = new URL(value); return `${parsed.origin}${parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`}`; }
11
19
  function fromHtml(html) {
12
20
  const find = key => { const m = html.match(new RegExp(`"${key}":"([^"]+)"`)); return m && unescapeJson(m[1]); };
13
21
  const urls = [...html.matchAll(/"url":"(https:[^\"]+)"/g)].map(m => unescapeJson(m[1])).filter(url => /(?:scontent|cdninstagram)/i.test(url) && /\.mp4(?:[?&]|$)/i.test(url));
14
22
  return sortFormats([find('video_url') || urls[0], ...urls].filter(Boolean).map((url, i) => ({ tag: i ? 'SD' : 'HD', quality: i ? '480p' : '720p', type: 'mp4', url })));
15
23
  }
24
+ function fallbackError(error) { return error instanceof PrivateError || error instanceof NotFoundError || /PRIVATE|NOT_FOUND|LOGIN|404|403|empty|accounts\/login/i.test(error?.message || ''); }
25
+ function outputFile(base) { return join(TMP_DIR, `${base}.mp4`); }
26
+ async function validFile(file) { try { return (await stat(file)).isFile() && (await stat(file)).size > 4096; } catch { return false; } }
27
+ async function cleanup(file) { await unlink(file).catch(() => {}); }
28
+ async function ytdlpInfo(url) {
29
+ const { stdout } = await runYtDlp(['--dump-json', '--no-download', '--no-playlist', '--skip-download', '--no-warnings', '--user-agent', UA, '--add-header', 'Referer:https://www.instagram.com/', '-f', 'best', url]);
30
+ const line = stdout.trim().split(/\r?\n/).find(Boolean);
31
+ if (!line) throw new Error('yt-dlp returned no Instagram video information.');
32
+ return JSON.parse(line);
33
+ }
34
+ function normalizeFallback(data, url, id) {
35
+ const direct = data.url || data.requested_formats?.find(item => item.url)?.url;
36
+ return { creator: CREATOR, platform: 'instagram', id: data.id || id, username: data.uploader_id, author: data.uploader, caption: data.description, originalUrl: url, formats: direct ? [{ tag: 'BEST', quality: `${data.height || 720}p`, type: 'mp4', url: direct, hasAudio: true }] : [], fallback: true };
37
+ }
38
+ async function instagramFallbackInfo(url, id) { return normalizeFallback(await ytdlpInfo(url), url, id); }
39
+ async function instagramFallbackDownload(url, dest) {
40
+ await mkdir(TMP_DIR, { recursive: true });
41
+ const base = `ig-${Date.now()}-${Math.random().toString(36).slice(2)}`;
42
+ const temp = outputFile(base);
43
+ try {
44
+ await runYtDlp(['--no-playlist', '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--no-warnings', '--user-agent', UA, '--add-header', 'Referer:https://www.instagram.com/', '-f', 'best', '--merge-output-format', 'mp4', '-o', temp, url]);
45
+ if (!await validFile(temp)) throw new Error('Instagram yt-dlp output was empty or too small.');
46
+ await rename(temp, dest);
47
+ return dest;
48
+ } catch (error) {
49
+ if (/ENOENT|not found|spawn/i.test(error.message)) throw new Error('Instagram blocked. Run npm i yt-dlp-exec or pip install yt-dlp');
50
+ throw error;
51
+ } finally { await cleanup(temp); }
52
+ }
16
53
 
17
- /** Download metadata and direct formats for an Instagram post or reel. */
18
- export async function instagramDl(url) {
19
- const id = codeOf(url);
20
- if (!id) throw new UnsupportedError('Invalid Instagram URL.');
21
- const base = `https://www.instagram.com/p/${id}/`;
22
- let response = await fetchWithRetry(`${base}?__a=1&__d=dis`, { headers: headers() });
54
+ async function instagramTier1(url, id) {
55
+ const base = `https://www.instagram.com/${url.match(/\/(reel|p|tv)\//i)?.[1] || 'p'}/${id}/`;
56
+ let response = await fetchWithRetry(`${base}?__a=1&__d=dis`, { headers: IG_HEADERS });
23
57
  let formats = [];
24
58
  let item;
25
59
  if (response.ok) {
@@ -34,7 +68,7 @@ export async function instagramDl(url) {
34
68
  } catch { /* use embed fallback */ }
35
69
  }
36
70
  if (!formats.length) {
37
- response = await fetchWithRetry(`https://www.instagram.com/reel/${id}/embed/`, { headers: headers() });
71
+ response = await fetchWithRetry(`${base}embed/`, { headers: IG_HEADERS });
38
72
  const html = await response.text();
39
73
  if (response.url.includes('/accounts/login')) throw new PrivateError();
40
74
  formats = fromHtml(html);
@@ -44,5 +78,22 @@ export async function instagramDl(url) {
44
78
  return { creator: CREATOR, platform: 'instagram', id, username: item?.user?.username, author: item?.user?.username, caption: item?.caption?.text, originalUrl: url, formats };
45
79
  }
46
80
 
81
+ /** Download metadata and direct formats for an Instagram post or reel. */
82
+ export async function instagramDl(url) {
83
+ if (!isInstagramUrl(url)) throw new UnsupportedError('Invalid Instagram URL.');
84
+ const cleanUrl = cleanInstagramUrl(url);
85
+ const id = codeOf(cleanUrl);
86
+ try { return await instagramTier1(cleanUrl, id); }
87
+ catch (error) {
88
+ if (!fallbackError(error)) throw error;
89
+ try { return await instagramFallbackInfo(cleanUrl, id); }
90
+ catch (fallback) { if (/ENOENT|not found|spawn/i.test(fallback.message)) throw new Error('Instagram blocked. Run npm i yt-dlp-exec or pip install yt-dlp'); throw fallback; }
91
+ }
92
+ }
93
+
47
94
  /** Save an Instagram video or image. */
48
- export async function instagramSave(url, dest = './ig.mp4', quality = 'best') { return saveResult(await instagramDl(url), dest, quality, { Referer: 'https://www.instagram.com/' }); }
95
+ export async function instagramSave(url, dest = './ig.mp4', quality = 'best') {
96
+ const result = await instagramDl(url);
97
+ if (result.fallback) return instagramFallbackDownload(cleanInstagramUrl(url), dest);
98
+ return saveResult(result, dest, quality, IG_HEADERS);
99
+ }
@@ -1,3 +1,7 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
1
5
  import { fetchWithRetry } from '../utils/fetcher.js';
2
6
  import { resolveShortUrl } from '../utils/resolver.js';
3
7
  import { saveResult, sortFormats } from '../utils/media.js';
@@ -5,29 +9,129 @@ import { extractJson, getPath, unescapeJson } from '../utils/parser.js';
5
9
  import { NotFoundError, PrivateError, UnsupportedError } from '../errors.js';
6
10
  import { CREATOR } from '../constants.js';
7
11
 
8
- const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 Version/16.6 Mobile/15E148 Safari/604.1';
12
+ const execFileAsync = promisify(execFile);
13
+ const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
14
+ export const TIKWM_API = 'https://www.tikwm.com/api/';
9
15
  const clean = value => value && unescapeJson(value);
10
16
 
17
+ export function isTikTokUrl(value) {
18
+ try { return new URL(value).hostname.toLowerCase().includes('tiktok.com'); } catch { return false; }
19
+ }
20
+
21
+ export function validMediaBuffer(buffer) { return Buffer.isBuffer(buffer) && buffer.length > 4096; }
22
+
23
+ async function tikwmApi(url) {
24
+ const response = await fetchWithRetry(TIKWM_API, {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'User-Agent': UA, Referer: 'https://www.tikwm.com/' },
27
+ body: new URLSearchParams({ url, hd: '1' }).toString()
28
+ });
29
+ if (!response.ok) throw new Error(`TIKWM_HTTP_${response.status}`);
30
+ const json = await response.json();
31
+ if (json?.code !== 0 || !json.data) throw new NotFoundError('TIKTOK_PRIVATE_OR_NOT_FOUND');
32
+ return json.data;
33
+ }
34
+
35
+ async function getMedia(url) {
36
+ const response = await fetchWithRetry(url, { headers: { 'User-Agent': UA, Referer: 'https://www.tikwm.com/' } });
37
+ if (!response.ok) throw new Error(`TIKTOK_MEDIA_HTTP_${response.status}`);
38
+ const buffer = Buffer.from(await response.arrayBuffer());
39
+ if (!validMediaBuffer(buffer)) throw new Error('TIKTOK_MEDIA_TOO_SMALL');
40
+ return buffer;
41
+ }
42
+
43
+ function normalizeTikwm(data, originalUrl, id) {
44
+ const author = data.author || {};
45
+ const video = data.hdplay || data.play;
46
+ const formats = sortFormats([video && { tag: 'NOWM', quality: '720p', type: 'mp4', isNoWatermark: true, url: video }]);
47
+ return {
48
+ creator: CREATOR,
49
+ platform: 'tiktok',
50
+ id: String(data.id || id),
51
+ username: author.unique_id ? `@${author.unique_id}` : undefined,
52
+ author: author.nickname,
53
+ title: data.title,
54
+ views: data.play_count,
55
+ likes: data.digg_count,
56
+ comments: data.comment_count,
57
+ shares: data.share_count,
58
+ downloads: data.download_count,
59
+ originalUrl: data.share_url || originalUrl,
60
+ formats,
61
+ music: data.music,
62
+ fallback: true
63
+ };
64
+ }
65
+
66
+ async function tiktokFallback(url, id) {
67
+ const data = await tikwmApi(url);
68
+ return normalizeTikwm(data, url, id);
69
+ }
70
+
11
71
  /** Download metadata and direct CDN formats for a TikTok video. */
12
72
  export async function tiktokDl(url) {
73
+ if (!isTikTokUrl(url)) throw new UnsupportedError('Invalid TikTok URL.');
13
74
  const resolved = await resolveShortUrl(url);
14
- const id = resolved.match(/\/video\/(\d+)/)?.[1] || resolved.match(/\/v\/(\d+)/)?.[1];
75
+ const id = resolved.match(/\/video\/(\d+)/)?.[1] || resolved.match(/\/v\/(\d+)/)?.[1] || new URL(resolved).pathname.split('/').filter(Boolean).pop();
15
76
  if (!id) throw new UnsupportedError('Invalid TikTok URL.');
16
77
  const pageUrl = resolved.match(/\/video\//) ? resolved : `https://www.tiktok.com/@a/video/${id}`;
17
- const response = await fetchWithRetry(pageUrl, { headers: { 'User-Agent': UA, Accept: 'text/html' } });
18
- const html = await response.text();
19
- const json = extractJson(html, '__UNIVERSAL_DATA_FOR_REHYDRATION__');
20
- const video = getPath(json, '__DEFAULT_SCOPE__.webapp.video-detail.videoData.itemInfo.itemStruct.video');
21
- const item = getPath(json, '__DEFAULT_SCOPE__.webapp.video-detail.videoData.itemInfo.itemStruct');
22
- const play = clean(video?.playAddr) || clean(html.match(/"playAddr":"([^"]+)"/)?.[1]);
23
- const download = clean(video?.downloadAddr) || clean(html.match(/"downloadAddr":"([^"]+)"/)?.[1]);
24
- const formats = sortFormats([
25
- download && { tag: 'NOWM', quality: '720p', type: 'mp4', isNoWatermark: true, url: download },
26
- play && { tag: 'WM', quality: '720p', type: 'mp4', isNoWatermark: false, url: play }
27
- ]);
28
- if (!formats.length) { if (/user-posts|login/i.test(html)) throw new PrivateError('PRIVATE_OR_NOT_FOUND'); throw new NotFoundError('PRIVATE_OR_NOT_FOUND'); }
29
- return { creator: CREATOR, platform: 'tiktok', id, username: item?.author?.uniqueId ? `@${item.author.uniqueId}` : undefined, author: item?.author?.nickname, title: item?.desc, originalUrl: resolved, formats };
78
+ try {
79
+ const response = await fetchWithRetry(pageUrl, { headers: { 'User-Agent': UA, Accept: 'text/html' } });
80
+ const html = await response.text();
81
+ const json = extractJson(html, '__UNIVERSAL_DATA_FOR_REHYDRATION__');
82
+ const video = getPath(json, '__DEFAULT_SCOPE__.webapp.video-detail.videoData.itemInfo.itemStruct.video');
83
+ const item = getPath(json, '__DEFAULT_SCOPE__.webapp.video-detail.videoData.itemInfo.itemStruct');
84
+ const play = clean(video?.playAddr) || clean(html.match(/"playAddr":"([^"]+)"/)?.[1]);
85
+ const download = clean(video?.downloadAddr) || clean(html.match(/"downloadAddr":"([^"]+)"/)?.[1]);
86
+ const formats = sortFormats([
87
+ download && { tag: 'NOWM', quality: '720p', type: 'mp4', isNoWatermark: true, url: download },
88
+ play && { tag: 'WM', quality: '720p', type: 'mp4', isNoWatermark: false, url: play }
89
+ ]);
90
+ if (!formats.length) throw new PrivateError('PRIVATE_OR_NOT_FOUND');
91
+ return { creator: CREATOR, platform: 'tiktok', id, username: item?.author?.uniqueId ? `@${item.author.uniqueId}` : undefined, author: item?.author?.nickname, title: item?.desc, originalUrl: resolved, formats };
92
+ } catch (error) {
93
+ if (!(error instanceof PrivateError) && !/PRIVATE|LOGIN|404|TIKTOK|NOT_FOUND/i.test(error?.message || '')) throw error;
94
+ return tiktokFallback(url, id);
95
+ }
96
+ }
97
+
98
+ /** Download TikTok MP4 or MP3 bytes using Tier 1 first, then TikWM. */
99
+ export async function tiktokDownload(url, format = 'mp4') {
100
+ const normalizedFormat = String(format).toLowerCase();
101
+ if (!['mp3', 'mp4'].includes(normalizedFormat)) throw new UnsupportedError('Invalid TikTok format.');
102
+ const result = await tiktokDl(url);
103
+ if (!result.fallback) {
104
+ const wanted = result.formats[0];
105
+ if (!wanted) throw new NotFoundError('PRIVATE_OR_NOT_FOUND');
106
+ if (normalizedFormat === 'mp4') return { buffer: await getMedia(wanted.url), mimetype: 'video/mp4', extension: 'mp4' };
107
+ }
108
+ const data = await tikwmApi(url);
109
+ if (normalizedFormat === 'mp4') return { buffer: await getMedia(data.hdplay || data.play), mimetype: 'video/mp4', extension: 'mp4' };
110
+ if (data.music) return { buffer: await getMedia(data.music), mimetype: 'audio/mpeg', extension: 'mp3' };
111
+ const video = await getMedia(data.hdplay || data.play);
112
+ const dir = join(process.cwd(), 'cache', 'temp');
113
+ const base = join(dir, `tt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
114
+ const input = `${base}.mp4`;
115
+ const output = `${base}.mp3`;
116
+ await mkdir(dir, { recursive: true });
117
+ try {
118
+ await writeFile(input, video);
119
+ await execFileAsync('ffmpeg', ['-y', '-i', input, '-vn', '-c:a', 'libmp3lame', '-q:a', '2', output], { maxBuffer: 32 * 1024 * 1024 });
120
+ const buffer = await readFile(output);
121
+ if (!validMediaBuffer(buffer)) throw new Error('TIKTOK_AUDIO_TOO_SMALL');
122
+ return { buffer, mimetype: 'audio/mpeg', extension: 'mp3' };
123
+ } finally {
124
+ await Promise.allSettled([unlink(input), unlink(output)]);
125
+ }
30
126
  }
31
127
 
32
128
  /** Save a TikTok video to disk. */
33
- export async function tiktokSave(url, dest = './tt.mp4', quality = 'best') { return saveResult(await tiktokDl(url), dest, quality, { Referer: 'https://www.tiktok.com/' }); }
129
+ export async function tiktokSave(url, dest = './tt.mp4', quality = 'best') {
130
+ const result = await tiktokDl(url);
131
+ if (result.fallback) {
132
+ const output = await tiktokDownload(url, dest.toLowerCase().endsWith('.mp3') ? 'mp3' : 'mp4');
133
+ await writeFile(dest, output.buffer);
134
+ return dest;
135
+ }
136
+ return saveResult(result, dest, quality, { Referer: 'https://www.tiktok.com/' });
137
+ }
@@ -1,22 +1,49 @@
1
1
  import { fetchWithRetry, getVisitorData } from '../utils/fetcher.js';
2
2
  import { saveResult, sortFormats, qualityOf } from '../utils/media.js';
3
- import { NotFoundError, UnsupportedError } from '../errors.js';
3
+ import { BotDetectedError, NotFoundError, UnsupportedError } from '../errors.js';
4
4
  import { CREATOR } from '../constants.js';
5
+ import { fallbackInstallMessage, ytDlpDownload, ytDlpInfo } from '../utils/ytdlp.js';
5
6
 
6
7
  const KEY = 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w';
8
+ export const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
9
+ export const INNERTUBE_CONTEXT = { client: { clientName: 'WEB', clientVersion: '2.20260813.01.00', hl: 'es', gl: 'DO' } };
10
+ export const INNERTUBE_URL = 'https://www.youtube.com/youtubei/v1/search?prettyPrint=false';
7
11
  const ENDPOINT = `https://www.youtube.com/youtubei/v1/player?key=${KEY}`;
8
12
  const CLIENTS = [
9
- { name: 'ANDROID', version: '20.10.38', ua: 'com.google.android.youtube/20.10.38 (Linux; U; Android 14) gzip' },
10
- { name: 'IOS', version: '19.45.4', deviceModel: 'iPhone15,2', ua: 'com.google.ios.youtube/19.45.4 (iPhone15,2; U; CPU iOS 17_5_1 like Mac OS X)' },
11
- { name: 'ANDROID_MUSIC', version: '6.51', ua: 'com.google.android.apps.youtube.music/6.51 (Linux; U; Android 14) gzip' }
13
+ { name: 'WEB', version: '2.20260813.01.00', ua: UA },
14
+ { name: 'IOS', version: '19.09.3', deviceModel: 'iPhone16,2', ua: 'com.google.ios.youtube/19.09.3 (iPhone16,2; U; iOS 17.2; en_US)' }
12
15
  ];
16
+ export const YOUTUBE_MEDIA_HEADERS = {
17
+ 'User-Agent': UA,
18
+ Accept: '*/*',
19
+ 'Accept-Language': 'en-US,en;q=0.9',
20
+ Referer: 'https://www.youtube.com/',
21
+ Origin: 'https://www.youtube.com'
22
+ };
23
+ export const IOS_MEDIA_HEADERS = {
24
+ ...YOUTUBE_MEDIA_HEADERS,
25
+ 'User-Agent': 'com.google.ios.youtube/19.09.3 (iPhone16,2; U17D201)'
26
+ };
27
+ function fallbackAllowed(error) { return error?.code === 'BOT_DETECTED' || /YOUTUBE_HTTP_4\d\d|403|automated|unusual traffic|NO_PLAYABLE|UNAVAILABLE/i.test(error?.message || ''); }
28
+ function fromYtDlp(data, url, id) {
29
+ const streams = data.requested_formats || [];
30
+ const video = streams.find(item => item.vcodec && item.vcodec !== 'none' && item.url) || (data.vcodec && data.url ? data : null);
31
+ const audio = streams.find(item => item.acodec && item.acodec !== 'none' && item.url) || (data.acodec && data.url ? data : null);
32
+ const direct = video?.url || data.url;
33
+ const format = direct ? [{ tag: data.height ? `${data.height}p` : 'best', quality: data.height ? `${data.height}p` : 'best', type: 'mp4', hasAudio: Boolean(video?.acodec && video.acodec !== 'none') || Boolean(data.acodec && data.acodec !== 'none'), url: direct, height: data.height, bitrate: data.tbr }] : [];
34
+ return { creator: CREATOR, platform: 'youtube', id, client: 'YT-DLP', title: data.title, author: data.uploader || data.channel, thumbnail: data.thumbnail, originalUrl: url, formats: format, ...(video?.url && audio?.url && video.url !== audio.url ? { needsMux: true, videoUrl: video.url, audioUrl: audio.url } : {}), fallback: true };
35
+ }
36
+ async function ytDlpFallback(url, id, cause) {
37
+ try { return fromYtDlp(await ytDlpInfo(id), url, id); }
38
+ catch (error) { throw new Error(`${fallbackInstallMessage()} (${cause?.message || error.message})`); }
39
+ }
13
40
 
14
41
  /** Extract an 11-character YouTube video id. */
15
42
  export function videoId(url) { return new URL(url).toString().match(/(?:youtu\.be\/|[?&]v=|shorts\/)([A-Za-z0-9_-]{11})/)?.[1]; }
16
43
 
17
44
  /** Send one direct Innertube player request for a specific client profile. */
18
45
  export async function innertubeRequest(id, client, visitorData) {
19
- const context = { client: { clientName: client.name, clientVersion: client.version, ...(client.deviceModel ? { deviceModel: client.deviceModel } : {}), hl: 'en', gl: 'US' }, ...(visitorData ? { visitorData } : {}) };
46
+ const context = { ...INNERTUBE_CONTEXT, client: { ...INNERTUBE_CONTEXT.client, clientName: client.name, clientVersion: client.version, ...(client.deviceModel ? { deviceModel: client.deviceModel } : {}) }, ...(visitorData ? { visitorData } : {}) };
20
47
  const response = await fetchWithRetry(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': client.ua, ...(visitorData ? { 'X-Goog-Visitor-Id': visitorData } : {}), Origin: 'https://www.youtube.com', Referer: 'https://www.youtube.com/' }, body: JSON.stringify({ context, videoId: id, contentCheckOk: true, racyCheckOk: true }) });
21
48
  if (!response.ok) throw new Error(`YOUTUBE_HTTP_${response.status}`);
22
49
  return response.json();
@@ -30,14 +57,20 @@ export async function player(id) {
30
57
  for (const client of CLIENTS) {
31
58
  try {
32
59
  data = await innertubeRequest(id, client, visitorData);
33
- if (data?.streamingData?.formats || data?.streamingData?.adaptiveFormats) return data;
60
+ if (data?.streamingData?.formats || data?.streamingData?.adaptiveFormats) {
61
+ data.__socialDlClient = client.name;
62
+ return data;
63
+ }
34
64
  if (data?.playabilityStatus?.status === 'ERROR' && JSON.stringify(data).includes('403')) continue;
35
65
  lastError = new Error(data?.playabilityStatus?.reason || 'UNAVAILABLE');
36
66
  } catch (error) {
37
67
  lastError = error;
38
68
  }
39
69
  }
40
- if (!data?.streamingData) throw lastError || new Error(data?.playabilityStatus?.reason || 'UNAVAILABLE');
70
+ if (!data?.streamingData) {
71
+ if (lastError?.code === 'BOT_DETECTED' || /403|automated|unusual traffic/i.test(lastError?.message || '')) throw new BotDetectedError();
72
+ throw lastError || new Error(data?.playabilityStatus?.reason || 'UNAVAILABLE');
73
+ }
41
74
  return data;
42
75
  }
43
76
 
@@ -45,18 +78,32 @@ export async function player(id) {
45
78
  export async function youtubeDl(url) {
46
79
  const id = videoId(url);
47
80
  if (!id) throw new UnsupportedError('Invalid YouTube URL.');
48
- const data = await player(id);
49
- if (!data.streamingData) throw new NotFoundError('PRIVATE_OR_AGE_RESTRICTED');
81
+ let data;
82
+ try { data = await player(id); }
83
+ catch (error) {
84
+ if (!fallbackAllowed(error)) throw error;
85
+ return ytDlpFallback(url, id, error);
86
+ }
87
+ if (!data.streamingData) return ytDlpFallback(url, id, new NotFoundError('PRIVATE_OR_AGE_RESTRICTED'));
50
88
  const all = [...(data.streamingData.formats || []), ...(data.streamingData.adaptiveFormats || [])];
51
- const formats = sortFormats(all.filter(item => item.url && /^video\/mp4/.test(item.mimeType || '')).map(item => ({ tag: qualityOf(item, 'video'), quality: qualityOf(item, 'video'), type: 'mp4', hasAudio: !item.mimeType?.includes('video-only'), url: item.url, bitrate: item.bitrate, height: item.height })));
89
+ const formats = sortFormats(all.filter(item => item.url && /^video\/mp4/.test(item.mimeType || '')).map(item => ({ tag: qualityOf(item, 'video'), quality: qualityOf(item, 'video'), type: 'mp4', hasAudio: !item.mimeType?.includes('video-only'), url: item.url, bitrate: item.bitrate, height: item.height }))).sort((a, b) => Number(b.hasAudio) - Number(a.hasAudio));
52
90
  if (!formats.length) throw new NotFoundError('NO_PLAYABLE_MP4');
53
91
  const best = formats[0];
54
92
  const audio = all.find(item => item.url && String(item.itag) === '140' && item.mimeType?.startsWith('audio/')) || all.find(item => item.url && item.mimeType?.startsWith('audio/'));
55
93
  const mux = best.height >= 1080 && best.hasAudio === false && audio;
56
- return { creator: CREATOR, platform: 'youtube', id, title: data.videoDetails?.title, thumbnail: `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`, originalUrl: url, formats, ...(mux ? { needsMux: true, videoUrl: best.url, audioUrl: audio.url } : {}) };
94
+ return { creator: CREATOR, platform: 'youtube', id, client: data.__socialDlClient, title: data.videoDetails?.title, thumbnail: `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`, originalUrl: url, formats, ...(mux ? { needsMux: true, videoUrl: best.url, audioUrl: audio.url } : {}) };
57
95
  }
58
96
 
59
97
  /** Save a YouTube video to disk. */
60
- export async function youtubeSave(url, dest = './yt.mp4', quality = 'best') { return saveResult(await youtubeDl(url), dest, quality); }
98
+ export async function youtubeSave(url, dest = './yt.mp4', quality = 'best') {
99
+ const result = await youtubeDl(url);
100
+ if (result.fallback) {
101
+ const { writeFile } = await import('node:fs/promises');
102
+ const output = await ytDlpDownload(result.id, 'mp4');
103
+ await writeFile(dest, output.buffer);
104
+ return dest;
105
+ }
106
+ return saveResult(result, dest, quality, { ...YOUTUBE_MEDIA_HEADERS, fallbackHeaders: IOS_MEDIA_HEADERS });
107
+ }
61
108
 
62
109
  export { CLIENTS };
@@ -1,13 +1,24 @@
1
- import { player, videoId } from './youtube.js';
1
+ import { IOS_MEDIA_HEADERS, player, videoId, YOUTUBE_MEDIA_HEADERS } from './youtube.js';
2
2
  import { saveResult, sortFormats } from '../utils/media.js';
3
3
  import { NotFoundError, UnsupportedError } from '../errors.js';
4
4
  import { CREATOR } from '../constants.js';
5
+ import { fallbackInstallMessage, ytDlpDownload, ytDlpInfo } from '../utils/ytdlp.js';
5
6
 
6
7
  /** Download audio-only metadata from YouTube (m4a/opus, not transcoded MP3). */
7
8
  export async function youtubeMp3Dl(url) {
8
9
  const id = videoId(url);
9
10
  if (!id) throw new UnsupportedError('Invalid YouTube URL.');
10
- const data = await player(id);
11
+ let data;
12
+ try { data = await player(id); }
13
+ catch (error) {
14
+ if (!error?.code && !/403|automated|unusual traffic|UNAVAILABLE/i.test(error?.message || '')) throw error;
15
+ try {
16
+ const fallback = await ytDlpInfo(id);
17
+ return { creator: CREATOR, platform: 'youtube', id, client: 'YT-DLP', title: fallback.title, author: fallback.uploader || fallback.channel, thumbnail: fallback.thumbnail, isAudioOnly: true, originalUrl: url, formats: [], fallback: true };
18
+ } catch (fallbackError) {
19
+ throw new Error(`${fallbackInstallMessage()} (${fallbackError.message})`);
20
+ }
21
+ }
11
22
  const source = data.streamingData?.adaptiveFormats || [];
12
23
  const formats = sortFormats(source.filter(item => item.url && item.mimeType?.startsWith('audio/')).map(item => {
13
24
  const type = item.mimeType.includes('webm') ? 'opus' : 'm4a';
@@ -15,8 +26,25 @@ export async function youtubeMp3Dl(url) {
15
26
  return { tag: type === 'opus' ? `OPUS_${kbps}` : `MP3_${kbps}`, quality: `${kbps}kbps`, type, hasAudio: true, isAudioOnly: true, url: item.url, bitrate: item.bitrate };
16
27
  }));
17
28
  if (!formats.length) throw new NotFoundError('PRIVATE_OR_AGE_RESTRICTED');
18
- return { creator: CREATOR, platform: 'youtube', id, title: data.videoDetails?.title, thumbnail: `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`, isAudioOnly: true, originalUrl: url, formats };
29
+ if (!formats.length) {
30
+ try {
31
+ const fallback = await ytDlpInfo(id);
32
+ return { creator: CREATOR, platform: 'youtube', id, client: 'YT-DLP', title: fallback.title, author: fallback.uploader || fallback.channel, thumbnail: fallback.thumbnail, isAudioOnly: true, originalUrl: url, formats: [], fallback: true };
33
+ } catch (fallbackError) {
34
+ throw new Error(`${fallbackInstallMessage()} (${fallbackError.message})`);
35
+ }
36
+ }
37
+ return { creator: CREATOR, platform: 'youtube', id, client: data.__socialDlClient, title: data.videoDetails?.title, thumbnail: `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`, isAudioOnly: true, originalUrl: url, formats };
19
38
  }
20
39
 
21
40
  /** Save the best YouTube audio stream to disk. */
22
- export async function youtubeMp3Save(url, dest = './audio.m4a', quality = 'best') { return saveResult(await youtubeMp3Dl(url), dest, quality); }
41
+ export async function youtubeMp3Save(url, dest = './audio.m4a', quality = 'best') {
42
+ const result = await youtubeMp3Dl(url);
43
+ if (result.fallback) {
44
+ const { writeFile } = await import('node:fs/promises');
45
+ const output = await ytDlpDownload(result.id, 'mp3');
46
+ await writeFile(dest, output.buffer);
47
+ return dest;
48
+ }
49
+ return saveResult(result, dest, quality, { ...YOUTUBE_MEDIA_HEADERS, fallbackHeaders: IOS_MEDIA_HEADERS });
50
+ }
@@ -1,9 +1,7 @@
1
1
  import { BotDetectedError } from '../errors.js';
2
2
 
3
3
  const USER_AGENTS = [
4
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36',
5
- 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 Version/16.6 Mobile/15E148 Safari/604.1',
6
- 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/120.0 Mobile Safari/537.36'
4
+ 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36'
7
5
  ];
8
6
 
9
7
  /** Pick a browser-like user agent. */
@@ -16,7 +14,7 @@ export async function fetchWithRetry(input, options = {}, retries = 1) {
16
14
  let lastError;
17
15
  for (let attempt = 0; attempt <= retries; attempt += 1) {
18
16
  try {
19
- const headers = { 'User-Agent': randomUserAgent(), ...(options.headers || {}) };
17
+ const headers = { 'User-Agent': randomUserAgent(), Accept: '*/*', 'Accept-Language': 'en-US,en;q=0.9', ...(options.headers || {}) };
20
18
  const response = await fetch(input, { ...options, headers, redirect: options.redirect || 'follow' });
21
19
  if (response.status === 403) {
22
20
  const body = await response.clone().text();
@@ -56,8 +54,21 @@ export async function fetchText(input, options = {}) {
56
54
 
57
55
  /** Fetch a direct media URL and write it to disk. */
58
56
  export async function downloadTo(url, dest, options = {}) {
59
- const response = await fetchWithRetry(url, options);
60
- if (!response.ok) throw new Error(`DOWNLOAD_HTTP_${response.status}`);
57
+ const { fallbackHeaders, ...requestOptions } = options;
58
+ const attempts = [requestOptions.headers, fallbackHeaders].filter(Boolean);
59
+ let response;
60
+ let lastError;
61
+ for (let index = 0; index < Math.max(1, attempts.length); index += 1) {
62
+ try {
63
+ response = await fetchWithRetry(url, { ...requestOptions, headers: attempts[index] }, 0);
64
+ if (response.ok || response.status !== 403 || index === attempts.length - 1) break;
65
+ } catch (error) {
66
+ lastError = error;
67
+ if (index === attempts.length - 1) throw error;
68
+ }
69
+ }
70
+ if (lastError && !response) throw lastError;
71
+ if (!response?.ok) throw new Error(`DOWNLOAD_HTTP_${response?.status || 'FAILED'}`);
61
72
  const { writeFile } = await import('node:fs/promises');
62
73
  const data = Buffer.from(await response.arrayBuffer());
63
74
  await writeFile(dest, data);
@@ -4,7 +4,7 @@ import { fetchWithRetry } from './fetcher.js';
4
4
  export async function resolveShortUrl(url) {
5
5
  const input = new URL(url);
6
6
  const host = input.hostname.toLowerCase();
7
- const shortHosts = ['vm.tiktok.com', 'pin.it', 't.co', 'fb.watch'];
7
+ const shortHosts = ['vm.tiktok.com', 'vt.tiktok.com', 'm.tiktok.com', 'pin.it', 't.co', 'fb.watch'];
8
8
  const isCapcutShort = host.endsWith('capcut.com') && input.pathname.startsWith('/t/');
9
9
  if (!shortHosts.includes(host) && !isCapcutShort) return url;
10
10
  const response = await fetchWithRetry(url, { method: 'GET', redirect: 'follow' });
@@ -0,0 +1,92 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, readFile, readdir, stat, unlink } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
8
+ const BASE_ARGS = [
9
+ '--no-playlist', '--no-warnings', '--newline', '--no-part', '--no-continue',
10
+ '--retries', '3', '--fragment-retries', '3', '--file-access-retries', '3',
11
+ '--extractor-retries', '3', '--remote-components', 'ejs:github', '--js-runtimes', 'node',
12
+ '--user-agent', UA
13
+ ];
14
+
15
+ /** Run a yt-dlp-compatible executable and return stdout/stderr. */
16
+ export async function runYtDlp(args) {
17
+ const commands = [];
18
+ try {
19
+ const mod = await import('yt-dlp-exec');
20
+ const binary = mod.default?.default || mod.default || mod;
21
+ if (typeof binary === 'function') {
22
+ const result = await binary(args, { maxBuffer: 32 * 1024 * 1024, env: { ...process.env } });
23
+ return { stdout: String(result?.stdout || result || ''), stderr: String(result?.stderr || '') };
24
+ }
25
+ } catch {
26
+ // Optional dependency is intentionally allowed to be absent.
27
+ }
28
+ commands.push('yt-dlp', 'yt-dlp-exec');
29
+ let lastError;
30
+ for (const command of commands) {
31
+ try {
32
+ const result = await execFileAsync(command, args, { maxBuffer: 32 * 1024 * 1024, env: { ...process.env } });
33
+ return { stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
34
+ } catch (error) {
35
+ lastError = error;
36
+ if (!/ENOENT|not found|spawn/i.test(error?.code || error?.message || '')) throw error;
37
+ }
38
+ }
39
+ throw new Error(String(lastError?.stderr || lastError?.stdout || lastError?.message || 'yt-dlp is not installed').trim());
40
+ }
41
+
42
+ export function ytDlpUrl(id) { return `https://www.youtube.com/watch?v=${id}`; }
43
+
44
+ /** Get yt-dlp JSON metadata for a video. */
45
+ export async function ytDlpInfo(id) {
46
+ const { stdout } = await runYtDlp(['--dump-json', '--no-download', '--no-playlist', '--skip-download', '--no-warnings', '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--user-agent', UA, ytDlpUrl(id)]);
47
+ const line = stdout.trim().split(/\r?\n/).find(Boolean);
48
+ if (!line) throw new Error('yt-dlp returned no video information.');
49
+ return JSON.parse(line);
50
+ }
51
+
52
+ function valid(file) {
53
+ return stat(file).then(info => info.isFile() && info.size > 4096).catch(() => false);
54
+ }
55
+ async function locate(base, dir) {
56
+ for (const name of await readdir(dir).catch(() => [])) {
57
+ if (name.startsWith(base) && await valid(join(dir, name))) return join(dir, name);
58
+ }
59
+ return null;
60
+ }
61
+ async function cleanup(base, dir) {
62
+ await Promise.all((await readdir(dir).catch(() => [])).filter(name => name.startsWith(base)).map(name => unlink(join(dir, name)).catch(() => {})));
63
+ }
64
+
65
+ /** Download a YouTube video or audio fallback into memory, then clean temp files. */
66
+ export async function ytDlpDownload(id, format) {
67
+ if (!['mp3', 'mp4'].includes(format)) throw new Error('Invalid yt-dlp format.');
68
+ const dir = join(process.cwd(), 'cache', 'temp');
69
+ await mkdir(dir, { recursive: true });
70
+ const base = `yt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
71
+ const output = join(dir, `${base}.%(ext)s`);
72
+ const args = format === 'mp3'
73
+ ? [...BASE_ARGS, '-f', 'bestaudio[ext=m4a]/bestaudio', '-x', '--audio-format', 'mp3', '--audio-quality', '0', '-o', output, ytDlpUrl(id)]
74
+ : [...BASE_ARGS, '-f', 'bv*[ext=mp4][vcodec^=avc1][height<=720]+ba[ext=m4a][acodec^=mp4a]/b[ext=mp4][height<=720]', '--merge-output-format', 'mp4', '--remux-video', 'mp4', '-o', output, ytDlpUrl(id)];
75
+ let file;
76
+ try {
77
+ await runYtDlp(args);
78
+ file = await locate(base, dir);
79
+ if (!file) throw new Error(`yt-dlp did not produce a valid ${format.toUpperCase()} file.`);
80
+ const buffer = await readFile(file);
81
+ return format === 'mp3' ? { buffer, mimetype: 'audio/mpeg', extension: 'mp3' } : { buffer, mimetype: 'video/mp4', extension: 'mp4' };
82
+ } finally {
83
+ if (file) await unlink(file).catch(() => {});
84
+ await cleanup(base, dir);
85
+ }
86
+ }
87
+
88
+ export function fallbackInstallMessage() {
89
+ return 'YouTube blocked datacenter IP. Install fallback: npm i yt-dlp-exec or pip install yt-dlp --remote-components ejs:github';
90
+ }
91
+
92
+ export { BASE_ARGS, UA as YTDLP_USER_AGENT };