social-dl 0.9.5 → 0.9.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-dl",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
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",
@@ -9,72 +9,78 @@ import { CREATOR } from '../constants.js';
9
9
 
10
10
  export const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
11
11
  export const TMP_DIR = 'cache/temp';
12
+ export const IG_APP_ID = '936619743392459';
12
13
  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];
14
- async function headersFor(options = {}) { return options.cookiesPath ? { ...IG_HEADERS, Cookie: await readFile(options.cookiesPath, 'utf8') } : IG_HEADERS; }
14
+ const codeOf = url => new URL(url).pathname.match(/\/(?:reel|reels|p|tv)\/([A-Za-z0-9_-]+)/i)?.[1];
15
15
 
16
16
  export function isInstagramUrl(value) {
17
- try { return /instagram\.com\/(?:reel|p|tv)\//i.test(new URL(value).href); } catch { return false; }
17
+ try {
18
+ const parsed = new URL(String(value).trim());
19
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
20
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && host.endsWith('instagram.com') && /\/(?:reel|reels|p|tv)\//i.test(parsed.pathname);
21
+ } catch { return false; }
18
22
  }
19
23
  export function cleanInstagramUrl(value) { const parsed = new URL(value); return `${parsed.origin}${parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`}`; }
24
+ async function cookieText(options = {}) { const path = options.cookies || options.cookiesPath; if (!path) return ''; try { return await readFile(path, 'utf8'); } catch { return ''; } }
25
+ function cookieHeader(text) {
26
+ if (!text) return '';
27
+ if (!text.includes('\t')) return text.match(/(?:^|\n)Cookie:\s*(.+)/i)?.[1]?.trim() || text.trim();
28
+ return text.split(/\r?\n/).filter(line => line && !line.startsWith('#')).map(line => line.split('\t')).filter(parts => parts.length >= 7 && parts[5] && parts[6]).map(parts => `${parts[5]}=${parts[6]}`).join('; ');
29
+ }
30
+ async function headersFor(options = {}) { const cookie = cookieHeader(await cookieText(options)); return cookie ? { ...IG_HEADERS, Cookie: cookie } : IG_HEADERS; }
20
31
  function fromHtml(html) {
21
- const find = key => { const m = html.match(new RegExp(`"${key}":"([^"]+)"`)); return m && unescapeJson(m[1]); };
22
- const urls = [...html.matchAll(/"url":"(https:[^\"]+)"/g)].map(m => unescapeJson(m[1])).filter(url => /(?:scontent|cdninstagram)/i.test(url) && /\.mp4(?:[?&]|$)/i.test(url));
23
- return sortFormats([find('video_url') || urls[0], ...urls].filter(Boolean).map((url, i) => ({ tag: i ? 'SD' : 'HD', quality: i ? '480p' : '720p', type: 'mp4', url })));
32
+ const find = key => { const match = html.match(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`)); return match && unescapeJson(match[1]); };
33
+ const urls = [...html.matchAll(/"url"\s*:\s*"(https:[^\"]+)"/g)].map(match => unescapeJson(match[1])).filter(url => /(?:scontent|cdninstagram)/i.test(url) && /\.mp4(?:[?&]|$)/i.test(url));
34
+ return sortFormats([find('video_url') || urls[0], ...urls].filter(Boolean).map((url, index) => ({ tag: index ? 'SD' : 'HD', quality: index ? '480p' : '720p', type: 'mp4', url })));
24
35
  }
25
36
  function fallbackError(error) { return error instanceof PrivateError || error instanceof NotFoundError || /PRIVATE|NOT_FOUND|LOGIN|404|403|empty|accounts\/login/i.test(error?.message || ''); }
26
- function outputFile(base) { return join(TMP_DIR, `${base}.mp4`); }
27
37
  async function validFile(file) { try { return (await stat(file)).isFile() && (await stat(file)).size > 4096; } catch { return false; } }
28
- async function cleanup(file) { await unlink(file).catch(() => {}); }
29
- async function ytdlpInfo(url, options = {}) {
30
- const { stdout } = await runYtDlp(['--dump-json', '--no-download', '--no-playlist', '--skip-download', '--no-warnings', '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--user-agent', UA, '--add-header', 'Referer:https://www.instagram.com/', '-f', 'best', ...cookieArgs(options), url]);
31
- const line = stdout.trim().split(/\r?\n/).find(Boolean);
32
- if (!line) throw new Error('yt-dlp returned no Instagram video information.');
33
- return JSON.parse(line);
34
- }
35
- function normalizeFallback(data, url, id) {
36
- const direct = data.url || data.requested_formats?.find(item => item.url)?.url;
37
- 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 };
38
+
39
+ async function runInstagramYtDlp(url, options = {}) {
40
+ const dir = join(process.cwd(), TMP_DIR);
41
+ await mkdir(dir, { recursive: true });
42
+ const tmp = join(dir, `ig-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`);
43
+ const cookie = await cookieText(options);
44
+ const cookies = cookie ? cookieArgs({ cookiesPath: options.cookies || options.cookiesPath }) : [];
45
+ const common = ['--no-playlist', '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--no-warnings', '--user-agent', UA, '--add-header', 'Referer:https://www.instagram.com/'];
46
+ const attempts = [
47
+ [...common, '-f', 'best', '-o', tmp, ...cookies, url],
48
+ [...common, '-f', 'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best', '--merge-output-format', 'mp4', '-o', tmp, ...cookies, url]
49
+ ];
50
+ try {
51
+ for (const args of attempts) {
52
+ try { await runYtDlp(args); } catch { /* retry with progressive video plus audio */ }
53
+ if (await validFile(tmp)) return await readFile(tmp);
54
+ await unlink(tmp).catch(() => {});
55
+ }
56
+ return null;
57
+ } finally { await unlink(tmp).catch(() => {}); }
38
58
  }
39
- async function ddinstagramFallback(url, id, options = {}) {
40
- const cookie = options.cookiesPath ? await readFile(options.cookiesPath, 'utf8') : '';
41
- const headers = cookie ? { ...IG_HEADERS, Cookie: cookie } : IG_HEADERS;
42
- const parse = html => {
43
- const match = html.match(/"video_url":"(https:[^"]+)"/) || html.match(/video_versions.*?"url":"(https:[^"]+)"/s);
44
- if (!match) return null;
45
- try { return decodeURIComponent(unescapeJson(match[1])); } catch { return unescapeJson(match[1]); }
46
- };
47
- for (const endpoint of [`https://www.ddinstagram.com/reel/${id}/`, ...(cookie ? [`https://www.instagram.com/reel/${id}/`] : [])]) {
59
+
60
+ async function getDirect(id, options = {}) {
61
+ const cookie = cookieHeader(await cookieText(options));
62
+ const headers = cookie ? { 'User-Agent': UA, 'X-IG-App-ID': IG_APP_ID, Accept: '*/*', Cookie: cookie } : { 'User-Agent': UA, 'X-IG-App-ID': IG_APP_ID, Accept: '*/*' };
63
+ for (const pageUrl of [`https://www.instagram.com/reel/${id}/`, `https://www.instagram.com/p/${id}/`, `https://www.instagram.com/tv/${id}/`]) {
48
64
  try {
49
- const response = await fetchWithRetry(endpoint, { headers: endpoint.includes('instagram.com/reel') ? { ...headers, 'X-IG-App-ID': '936619743392459' } : headers });
50
- const video = parse(await response.text());
51
- if (video) return { creator: CREATOR, platform: 'instagram', id, originalUrl: url, formats: [{ tag: 'DDINSTAGRAM', quality: 'best', type: 'mp4', url: video, hasAudio: true }], fallback: true, fallbackMethod: 'ddinstagram' };
52
- } catch { /* try the next endpoint */ }
65
+ const response = await fetch(pageUrl, { headers, redirect: 'follow' });
66
+ if (!response.ok) continue;
67
+ const html = await response.text();
68
+ const match = html.match(/"video_url"\s*:\s*"(https:[^"]+)"/) || html.match(/"video_versions"\s*:\s*\[.*?\{"url"\s*:\s*"(https:[^"]+)"/s);
69
+ if (!match) continue;
70
+ const mediaUrl = unescapeJson(match[1]).replace(/\\u0026/g, '&').replace(/\\/g, '');
71
+ const media = await fetch(mediaUrl, { headers: { 'User-Agent': UA, Referer: 'https://www.instagram.com/' }, redirect: 'follow' });
72
+ if (!media.ok) continue;
73
+ const buffer = Buffer.from(await media.arrayBuffer());
74
+ if (buffer.length > 4096) return buffer;
75
+ } catch { /* try the next direct Instagram page */ }
53
76
  }
54
- throw new NotFoundError('DDINSTAGRAM_EMPTY_MEDIA');
55
- }
56
- async function instagramFallbackInfo(url, id, options = {}) {
57
- try { return normalizeFallback(await ytdlpInfo(url, options), url, id); }
58
- catch { return ddinstagramFallback(url, id, options); }
59
- }
60
- async function instagramFallbackDownload(url, dest, options = {}) {
61
- await mkdir(TMP_DIR, { recursive: true });
62
- const base = `ig-${Date.now()}-${Math.random().toString(36).slice(2)}`;
63
- const temp = outputFile(base);
64
- try {
65
- 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, ...cookieArgs(options), url]);
66
- if (!await validFile(temp)) throw new Error('Instagram yt-dlp output was empty or too small.');
67
- await rename(temp, dest);
68
- return dest;
69
- } catch (error) {
70
- 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');
71
- throw error;
72
- } finally { await cleanup(temp); }
77
+ return null;
73
78
  }
74
79
 
75
80
  async function instagramTier1(url, id, options = {}) {
76
81
  const requestHeaders = await headersFor(options);
77
- const base = `https://www.instagram.com/${url.match(/\/(reel|p|tv)\//i)?.[1] || 'p'}/${id}/`;
82
+ const type = url.match(/\/(reel|reels|p|tv)\//i)?.[1] || 'p';
83
+ const base = `https://www.instagram.com/${type}/${id}/`;
78
84
  let response = await fetchWithRetry(`${base}?__a=1&__d=dis`, { headers: requestHeaders });
79
85
  let formats = [];
80
86
  let item;
@@ -84,7 +90,7 @@ async function instagramTier1(url, id, options = {}) {
84
90
  item = json.items?.[0] || json.data?.xdt_shortcode_media;
85
91
  const media = item?.carousel_media || [item];
86
92
  formats = sortFormats(media.flatMap((entry, index) => [
87
- ...(entry?.video_versions || []).map(v => ({ tag: index ? `${index + 1}` : 'HD', quality: `${v.height || 720}p`, type: 'mp4', url: v.url })),
93
+ ...(entry?.video_versions || []).map(video => ({ tag: index ? `${index + 1}` : 'HD', quality: `${video.height || 720}p`, type: 'mp4', url: video.url })),
88
94
  entry?.image_versions2?.candidates?.[0] && { tag: 'IMG', quality: `${entry.image_versions2.candidates[0].width || 1080}x${entry.image_versions2.candidates[0].height || 1080}`, type: 'jpg', url: entry.image_versions2.candidates[0].url }
89
95
  ]));
90
96
  } catch { /* use embed fallback */ }
@@ -100,23 +106,24 @@ async function instagramTier1(url, id, options = {}) {
100
106
  return { creator: CREATOR, platform: 'instagram', id, username: item?.user?.username, author: item?.user?.username, caption: item?.caption?.text, originalUrl: url, formats };
101
107
  }
102
108
 
103
- /** Download metadata and direct formats for an Instagram post or reel. */
104
109
  export async function instagramDl(url, options = {}) {
105
110
  if (!isInstagramUrl(url)) throw new UnsupportedError('Invalid Instagram URL.');
106
111
  const cleanUrl = cleanInstagramUrl(url);
107
112
  const id = codeOf(cleanUrl);
108
113
  try { return await instagramTier1(cleanUrl, id, options); }
109
- catch (error) {
110
- if (!fallbackError(error)) throw error;
111
- try { return await instagramFallbackInfo(cleanUrl, id, options); }
112
- 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; }
113
- }
114
+ catch (error) { if (!fallbackError(error)) throw error; return { creator: CREATOR, platform: 'instagram', id, originalUrl: cleanUrl, formats: [], fallback: true }; }
114
115
  }
115
116
 
116
- /** Save an Instagram video or image. */
117
117
  export async function instagramSave(url, dest = './ig.mp4', quality = 'best', options = {}) {
118
- const result = await instagramDl(url, options);
119
- if (result.fallback && result.fallbackMethod === 'ddinstagram') return downloadTo(result.formats[0].url, dest, { headers: await headersFor(options) });
120
- if (result.fallback) return instagramFallbackDownload(cleanInstagramUrl(url), dest, options);
121
- return saveResult(result, dest, quality, await headersFor(options));
118
+ if (!isInstagramUrl(url)) throw new UnsupportedError('Invalid Instagram URL.');
119
+ const cleanUrl = cleanInstagramUrl(url);
120
+ const id = codeOf(cleanUrl);
121
+ const ytDlpBuffer = await runInstagramYtDlp(cleanUrl, options);
122
+ if (ytDlpBuffer) { const { writeFile } = await import('node:fs/promises'); await writeFile(dest, ytDlpBuffer); return dest; }
123
+ const directBuffer = await getDirect(id, options);
124
+ if (directBuffer) { const { writeFile } = await import('node:fs/promises'); await writeFile(dest, directBuffer); return dest; }
125
+ try {
126
+ const result = await instagramTier1(cleanUrl, id, options);
127
+ return saveResult(result, dest, quality, await headersFor(options));
128
+ } catch { throw new NotFoundError('NOT_FOUND: Instagram empty media - use --cookies ~/cookies.txt'); }
122
129
  }
@@ -8,7 +8,7 @@ import { saveResult, sortFormats } from '../utils/media.js';
8
8
  import { extractJson, getPath, unescapeJson } from '../utils/parser.js';
9
9
  import { NotFoundError, PrivateError, UnsupportedError } from '../errors.js';
10
10
  import { CREATOR } from '../constants.js';
11
- import { ytDlpDownloadUrl, ytDlpInfoUrl } from '../utils/ytdlp.js';
11
+ import { cookieArgs, runYtDlp, ytDlpDownloadUrl, ytDlpInfoUrl } from '../utils/ytdlp.js';
12
12
 
13
13
  const execFileAsync = promisify(execFile);
14
14
  const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
@@ -16,68 +16,79 @@ export const TIKWM_API = 'https://www.tikwm.com/api/';
16
16
  const clean = value => value && unescapeJson(value);
17
17
 
18
18
  export function isTikTokUrl(value) {
19
- try { return new URL(value).hostname.toLowerCase().includes('tiktok.com'); } catch { return false; }
19
+ try {
20
+ const parsed = new URL(String(value).trim());
21
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
22
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && (host === 'tiktok.com' || host.endsWith('.tiktok.com'));
23
+ } catch { return false; }
20
24
  }
21
25
 
22
26
  export function validMediaBuffer(buffer) { return Buffer.isBuffer(buffer) && buffer.length > 4096; }
23
27
 
24
- async function tikwmApi(url) {
28
+ async function resolveTikTokUrl(url) {
29
+ try {
30
+ const response = await fetch(url, { method: 'HEAD', redirect: 'follow', headers: { 'User-Agent': UA } });
31
+ return response.url || url;
32
+ } catch { return url; }
33
+ }
34
+
35
+ async function tikwmApi(inputUrl) {
36
+ const url = await resolveTikTokUrl(inputUrl);
25
37
  const response = await fetchWithRetry(TIKWM_API, {
26
38
  method: 'POST',
27
- headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'User-Agent': UA, Referer: 'https://www.tikwm.com/' },
28
- body: new URLSearchParams({ url, hd: '1' }).toString()
39
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA, Accept: '*/*', Referer: 'https://www.tikwm.com/', Origin: 'https://www.tikwm.com' },
40
+ body: new URLSearchParams({ url, hd: '1', full_data: '1' }).toString()
29
41
  });
30
- if (!response.ok) throw new Error(`TIKWM_HTTP_${response.status}`);
31
- const json = await response.json();
42
+ let json = response.ok ? await response.json() : null;
43
+ if (!json?.data) {
44
+ const retry = await fetchWithRetry(TIKWM_API, {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA, Accept: '*/*', Referer: 'https://www.tikwm.com/' },
47
+ body: new URLSearchParams({ url, hd: '0' }).toString()
48
+ });
49
+ if (!retry.ok) throw new Error(`TIKWM_HTTP_${response.status || retry.status}`);
50
+ json = await retry.json();
51
+ }
32
52
  if (json?.code !== 0 || !json.data) throw new NotFoundError('TIKTOK_PRIVATE_OR_NOT_FOUND');
33
53
  return json.data;
34
54
  }
35
55
 
36
56
  async function getMedia(url) {
37
- const response = await fetchWithRetry(url, { headers: { 'User-Agent': UA, Referer: 'https://www.tikwm.com/' } });
38
- if (!response.ok) throw new Error(`TIKTOK_MEDIA_HTTP_${response.status}`);
39
- const buffer = Buffer.from(await response.arrayBuffer());
40
- if (!validMediaBuffer(buffer)) throw new Error('TIKTOK_MEDIA_TOO_SMALL');
41
- return buffer;
57
+ const headersList = [
58
+ { Referer: 'https://www.tiktok.com/', Origin: 'https://www.tiktok.com' },
59
+ { Referer: 'https://www.tikwm.com/', Origin: 'https://www.tikwm.com' },
60
+ { Referer: 'https://m.tiktok.com/' }
61
+ ];
62
+ let lastError;
63
+ for (const extra of headersList) {
64
+ try {
65
+ const response = await fetchWithRetry(url, { headers: { 'User-Agent': UA, Accept: '*/*', ...extra } }, 0);
66
+ if (!response.ok) { lastError = new Error(`TIKTOK_MEDIA_HTTP_${response.status}`); continue; }
67
+ const buffer = Buffer.from(await response.arrayBuffer());
68
+ if (validMediaBuffer(buffer)) return buffer;
69
+ lastError = new Error('TIKTOK_MEDIA_TOO_SMALL');
70
+ } catch (error) { lastError = error; }
71
+ }
72
+ throw lastError || new Error('TIKTOK_MEDIA_FAILED');
42
73
  }
43
74
 
44
75
  function normalizeTikwm(data, originalUrl, id) {
45
76
  const author = data.author || {};
46
- const video = data.hdplay || data.play;
77
+ const video = data.hdplay || data.play || data.wmplay;
47
78
  const formats = sortFormats([video && { tag: 'NOWM', quality: '720p', type: 'mp4', isNoWatermark: true, url: video }]);
48
- return {
49
- creator: CREATOR,
50
- platform: 'tiktok',
51
- id: String(data.id || id),
52
- username: author.unique_id ? `@${author.unique_id}` : undefined,
53
- author: author.nickname,
54
- title: data.title,
55
- views: data.play_count,
56
- likes: data.digg_count,
57
- comments: data.comment_count,
58
- shares: data.share_count,
59
- downloads: data.download_count,
60
- originalUrl: data.share_url || originalUrl,
61
- formats,
62
- music: data.music,
63
- fallback: true
64
- };
79
+ return { creator: CREATOR, platform: 'tiktok', id: String(data.id || id), username: author.unique_id ? `@${author.unique_id}` : undefined, author: author.nickname, title: data.title, views: data.play_count, likes: data.digg_count, comments: data.comment_count, shares: data.share_count, downloads: data.download_count, originalUrl: data.share_url || originalUrl, formats, music: data.music, fallback: true };
65
80
  }
66
81
  function normalizeYtDlp(data, originalUrl, id) {
67
82
  const direct = data.url || data.requested_formats?.find(item => item.url)?.url;
68
83
  return { creator: CREATOR, platform: 'tiktok', id: data.id || id, author: data.uploader, title: data.title, originalUrl, formats: direct ? [{ tag: 'BEST', quality: `${data.height || 720}p`, type: 'mp4', url: direct, hasAudio: true }] : [], fallback: true, fallbackMethod: 'yt-dlp' };
69
84
  }
70
85
 
71
- async function tiktokFallback(url, id) {
72
- const data = await tikwmApi(url);
73
- return normalizeTikwm(data, url, id);
74
- }
86
+ async function tiktokFallback(url, id) { return normalizeTikwm(await tikwmApi(url), url, id); }
75
87
 
76
- /** Download metadata and direct CDN formats for a TikTok video. */
77
88
  export async function tiktokDl(url, options = {}) {
78
89
  if (!isTikTokUrl(url)) throw new UnsupportedError('Invalid TikTok URL.');
79
90
  let resolved = url;
80
- try { resolved = await resolveShortUrl(url); } catch { /* preserve the original URL for fallback providers */ }
91
+ try { resolved = await resolveShortUrl(url); } catch { resolved = await resolveTikTokUrl(url); }
81
92
  const id = resolved.match(/\/video\/(\d+)/)?.[1] || resolved.match(/\/v\/(\d+)/)?.[1] || new URL(resolved).pathname.split('/').filter(Boolean).pop() || url;
82
93
  if (!id) throw new UnsupportedError('Invalid TikTok URL.');
83
94
  const pageUrl = resolved.match(/\/video\//) ? resolved : `https://www.tiktok.com/@a/video/${id}`;
@@ -89,36 +100,38 @@ export async function tiktokDl(url, options = {}) {
89
100
  const item = getPath(json, '__DEFAULT_SCOPE__.webapp.video-detail.videoData.itemInfo.itemStruct');
90
101
  const play = clean(video?.playAddr) || clean(html.match(/"playAddr":"([^"]+)"/)?.[1]);
91
102
  const download = clean(video?.downloadAddr) || clean(html.match(/"downloadAddr":"([^"]+)"/)?.[1]);
92
- const formats = sortFormats([
93
- download && { tag: 'NOWM', quality: '720p', type: 'mp4', isNoWatermark: true, url: download },
94
- play && { tag: 'WM', quality: '720p', type: 'mp4', isNoWatermark: false, url: play }
95
- ]);
103
+ const formats = sortFormats([download && { tag: 'NOWM', quality: '720p', type: 'mp4', isNoWatermark: true, url: download }, play && { tag: 'WM', quality: '720p', type: 'mp4', isNoWatermark: false, url: play }]);
96
104
  if (!formats.length) throw new PrivateError('PRIVATE_OR_NOT_FOUND');
97
105
  return { creator: CREATOR, platform: 'tiktok', id, username: item?.author?.uniqueId ? `@${item.author.uniqueId}` : undefined, author: item?.author?.nickname, title: item?.desc, originalUrl: resolved, formats };
98
- } catch (error) {
106
+ } catch {
99
107
  try { return normalizeYtDlp(await ytDlpInfoUrl(url, options), url, id); }
100
108
  catch { return tiktokFallback(url, id); }
101
109
  }
102
110
  }
103
111
 
104
- /** Download TikTok MP4 or MP3 bytes using Tier 1 first, then TikWM. */
105
- export async function tiktokDownload(url, format = 'mp4') {
112
+ export async function tiktokDownload(url, format = 'mp4', options = {}) {
106
113
  const normalizedFormat = String(format).toLowerCase();
107
114
  if (!['mp3', 'mp4'].includes(normalizedFormat)) throw new UnsupportedError('Invalid TikTok format.');
108
- const result = await tiktokDl(url);
115
+ const result = await tiktokDl(url, options);
116
+ if (result.fallbackMethod === 'yt-dlp') return ytDlpDownloadUrl(url, 'mp4', options);
109
117
  if (!result.fallback) {
110
118
  const wanted = result.formats[0];
111
119
  if (!wanted) throw new NotFoundError('PRIVATE_OR_NOT_FOUND');
112
- if (normalizedFormat === 'mp4') return { buffer: await getMedia(wanted.url), mimetype: 'video/mp4', extension: 'mp4' };
120
+ if (normalizedFormat === 'mp4') {
121
+ try { return { buffer: await getMedia(wanted.url), mimetype: 'video/mp4', extension: 'mp4' }; }
122
+ catch { return ytDlpDownloadUrl(url, 'mp4', options); }
123
+ }
113
124
  }
114
125
  const data = await tikwmApi(url);
115
- if (normalizedFormat === 'mp4') return { buffer: await getMedia(data.hdplay || data.play), mimetype: 'video/mp4', extension: 'mp4' };
126
+ if (normalizedFormat === 'mp4') {
127
+ try { return { buffer: await getMedia(data.hdplay || data.play || data.wmplay), mimetype: 'video/mp4', extension: 'mp4' }; }
128
+ catch { return ytDlpDownloadUrl(url, 'mp4', options); }
129
+ }
116
130
  if (data.music) return { buffer: await getMedia(data.music), mimetype: 'audio/mpeg', extension: 'mp3' };
117
- const video = await getMedia(data.hdplay || data.play);
131
+ const video = await getMedia(data.hdplay || data.play || data.wmplay);
118
132
  const dir = join(process.cwd(), 'cache', 'temp');
119
133
  const base = join(dir, `tt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
120
- const input = `${base}.mp4`;
121
- const output = `${base}.mp3`;
134
+ const input = `${base}.mp4`, output = `${base}.mp3`;
122
135
  await mkdir(dir, { recursive: true });
123
136
  try {
124
137
  await writeFile(input, video);
@@ -126,12 +139,9 @@ export async function tiktokDownload(url, format = 'mp4') {
126
139
  const buffer = await readFile(output);
127
140
  if (!validMediaBuffer(buffer)) throw new Error('TIKTOK_AUDIO_TOO_SMALL');
128
141
  return { buffer, mimetype: 'audio/mpeg', extension: 'mp3' };
129
- } finally {
130
- await Promise.allSettled([unlink(input), unlink(output)]);
131
- }
142
+ } finally { await Promise.allSettled([unlink(input), unlink(output)]); }
132
143
  }
133
144
 
134
- /** Save a TikTok video to disk. */
135
145
  export async function tiktokSave(url, dest = './tt.mp4', quality = 'best', options = {}) {
136
146
  const result = await tiktokDl(url, options);
137
147
  if (result.fallbackMethod === 'yt-dlp') {
@@ -140,7 +150,7 @@ export async function tiktokSave(url, dest = './tt.mp4', quality = 'best', optio
140
150
  return dest;
141
151
  }
142
152
  if (result.fallback) {
143
- const output = await tiktokDownload(url, dest.toLowerCase().endsWith('.mp3') ? 'mp3' : 'mp4');
153
+ const output = await tiktokDownload(url, dest.toLowerCase().endsWith('.mp3') ? 'mp3' : 'mp4', options);
144
154
  await writeFile(dest, output.buffer);
145
155
  return dest;
146
156
  }