comfyclips 1.0.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.
- package/LICENSE +15 -0
- package/README.md +39 -0
- package/package.json +43 -0
- package/src/binaries.js +46 -0
- package/src/comfyClips-icon.png +0 -0
- package/src/downloader.js +121 -0
- package/src/index.js +168 -0
- package/src/platforms.js +38 -0
- package/src/prompts/boxedSelect.js +77 -0
- package/src/ui.js +93 -0
- package/src/ytdlp-lib.js +8 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hamza Imran
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ComfyClips
|
|
2
|
+
|
|
3
|
+
Interactive CLI to download video or audio from social media links (YouTube, Instagram, TikTok, Facebook, X/Twitter). Built on top of [yt-dlp](https://github.com/yt-dlp/yt-dlp).
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js >= 18
|
|
8
|
+
- [ffmpeg](https://ffmpeg.org/) on your PATH — required to merge separate video/audio streams and to extract/convert audio formats. Without it, downloads may fail or fall back to lower quality.
|
|
9
|
+
- Debian/Ubuntu: `sudo apt-get install ffmpeg`
|
|
10
|
+
- macOS: `brew install ffmpeg`
|
|
11
|
+
- Windows: `choco install ffmpeg` (or download from ffmpeg.org and add it to PATH)
|
|
12
|
+
- `yt-dlp` — not required ahead of time. If it isn't found on your PATH, ComfyClips downloads a copy automatically on first run into `~/.comfyclips/bin`.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install
|
|
18
|
+
npm link # exposes the `comfyclips` command globally
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
comfyclips
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
You'll be prompted to:
|
|
28
|
+
1. Select a platform (YouTube, Instagram, TikTok, Facebook, X/Twitter)
|
|
29
|
+
2. Paste the video link
|
|
30
|
+
3. Choose Video or Audio only
|
|
31
|
+
4. Pick a quality (video) or format (audio: mp3/m4a/wav/opus)
|
|
32
|
+
5. Choose the destination folder (created automatically if it doesn't exist)
|
|
33
|
+
|
|
34
|
+
Progress is shown live in the terminal, and the file is saved using its original title as the filename.
|
|
35
|
+
|
|
36
|
+
## Notes
|
|
37
|
+
|
|
38
|
+
- Downloading content you don't own or don't have rights to may violate a platform's Terms of Service or copyright law. Use responsibly.
|
|
39
|
+
- Platform selection is a light sanity check on the URL's domain; actual extraction is handled by yt-dlp regardless of the platform chosen.
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "comfyclips",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Interactive CLI to download video or audio from social media platforms (YouTube, Instagram, TikTok, Facebook, X/Twitter) via yt-dlp.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"comfyclips": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node src/index.js"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"cli",
|
|
20
|
+
"video-downloader",
|
|
21
|
+
"yt-dlp",
|
|
22
|
+
"youtube",
|
|
23
|
+
"instagram",
|
|
24
|
+
"tiktok"
|
|
25
|
+
],
|
|
26
|
+
"author": "Hamza Imran",
|
|
27
|
+
"license": "ISC",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@inquirer/core": "^12.0.0",
|
|
33
|
+
"boxen": "^8.0.1",
|
|
34
|
+
"chalk": "^5.3.0",
|
|
35
|
+
"cli-progress": "^3.12.0",
|
|
36
|
+
"cli-table3": "^0.6.5",
|
|
37
|
+
"figlet": "^1.11.4",
|
|
38
|
+
"inquirer": "^14.1.0",
|
|
39
|
+
"ora": "^8.1.0",
|
|
40
|
+
"yt-dlp-wrap-plus": "^2.5.0"
|
|
41
|
+
},
|
|
42
|
+
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c"
|
|
43
|
+
}
|
package/src/binaries.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
import YTDlpWrap from './ytdlp-lib.js';
|
|
8
|
+
|
|
9
|
+
const CACHE_DIR = path.join(os.homedir(), '.comfyclips', 'bin');
|
|
10
|
+
const YT_DLP_BIN_NAME = os.platform() === 'win32' ? 'yt-dlp.exe' : 'yt-dlp';
|
|
11
|
+
const CACHED_YT_DLP_PATH = path.join(CACHE_DIR, YT_DLP_BIN_NAME);
|
|
12
|
+
|
|
13
|
+
function commandExists(cmd, versionFlag = '--version') {
|
|
14
|
+
const result = spawnSync(cmd, [versionFlag], { stdio: 'ignore' });
|
|
15
|
+
return !result.error && result.status === 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isFfmpegAvailable() {
|
|
19
|
+
// ffmpeg uses single-dash flags (-version), unlike most CLIs.
|
|
20
|
+
return commandExists('ffmpeg', '-version');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function resolveYtDlpBinaryPath() {
|
|
24
|
+
if (commandExists('yt-dlp')) {
|
|
25
|
+
return 'yt-dlp';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (existsSync(CACHED_YT_DLP_PATH)) {
|
|
29
|
+
return CACHED_YT_DLP_PATH;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(chalk.yellow('yt-dlp was not found on your system.'));
|
|
33
|
+
const spinner = ora('Downloading yt-dlp binary (one-time setup)...').start();
|
|
34
|
+
try {
|
|
35
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
36
|
+
await YTDlpWrap.downloadFromGithub(CACHED_YT_DLP_PATH);
|
|
37
|
+
spinner.succeed(`yt-dlp downloaded to ${CACHED_YT_DLP_PATH}`);
|
|
38
|
+
return CACHED_YT_DLP_PATH;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
spinner.fail('Failed to download yt-dlp automatically.');
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Could not download yt-dlp: ${err.message}\n` +
|
|
43
|
+
'Please install it manually (https://github.com/yt-dlp/yt-dlp#installation) and ensure it is on your PATH.'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import cliProgress from 'cli-progress';
|
|
5
|
+
import YTDlpWrap from './ytdlp-lib.js';
|
|
6
|
+
import { printWarning } from './ui.js';
|
|
7
|
+
|
|
8
|
+
function heightCap(quality) {
|
|
9
|
+
if (quality === 'best') return '';
|
|
10
|
+
const height = parseInt(quality, 10);
|
|
11
|
+
return Number.isNaN(height) ? '' : `[height<=${height}]`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Prefer H.264/AAC streams in an mp4 container first — the only combination
|
|
15
|
+
// virtually every player (Windows Media Player, QuickTime, phones, smart TVs)
|
|
16
|
+
// can decode. Only fall back to VP9/AV1+Opus (remuxed into .mp4) when no
|
|
17
|
+
// compatible stream exists at all, since some players choke on that.
|
|
18
|
+
function videoFormatSelector(quality) {
|
|
19
|
+
const h = heightCap(quality);
|
|
20
|
+
return [
|
|
21
|
+
`bestvideo[ext=mp4][vcodec^=avc1]${h}+bestaudio[ext=m4a]`,
|
|
22
|
+
`bestvideo[ext=mp4]${h}+bestaudio[ext=m4a]`,
|
|
23
|
+
`best[ext=mp4]${h}`,
|
|
24
|
+
`bestvideo${h}+bestaudio`,
|
|
25
|
+
`best${h}`,
|
|
26
|
+
].join('/');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildArgs({ url, mode, quality, audioFormat, outputDir, ffmpegAvailable }) {
|
|
30
|
+
const outputTemplate = path.join(outputDir, '%(title)s.%(ext)s');
|
|
31
|
+
const args = [url, '-o', outputTemplate, '--no-playlist', '--newline'];
|
|
32
|
+
const warnings = [];
|
|
33
|
+
|
|
34
|
+
if (mode === 'audio') {
|
|
35
|
+
args.push('-x', '--audio-format', audioFormat, '-f', 'bestaudio/best');
|
|
36
|
+
if (!ffmpegAvailable) {
|
|
37
|
+
warnings.push(
|
|
38
|
+
'ffmpeg was not found — audio extraction may fail or the file may keep its original codec.'
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
} else if (ffmpegAvailable) {
|
|
42
|
+
args.push('-f', videoFormatSelector(quality), '--merge-output-format', 'mp4');
|
|
43
|
+
} else {
|
|
44
|
+
const h = heightCap(quality);
|
|
45
|
+
args.push('-f', `best[ext=mp4]${h}/best${h}`);
|
|
46
|
+
warnings.push(
|
|
47
|
+
'ffmpeg was not found — separate video/audio streams cannot be merged, so quality is limited to a single pre-merged format. Install ffmpeg for full quality and best compatibility.'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { args, warnings };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DESTINATION_PATTERNS = [/Destination:\s*(.+)$/, /Merging formats into "(.+)"$/];
|
|
55
|
+
|
|
56
|
+
export async function downloadMedia({
|
|
57
|
+
binaryPath,
|
|
58
|
+
url,
|
|
59
|
+
mode,
|
|
60
|
+
quality,
|
|
61
|
+
audioFormat,
|
|
62
|
+
outputDir,
|
|
63
|
+
ffmpegAvailable,
|
|
64
|
+
}) {
|
|
65
|
+
mkdirSync(outputDir, { recursive: true });
|
|
66
|
+
|
|
67
|
+
const ytDlpWrap = new YTDlpWrap(binaryPath);
|
|
68
|
+
const { args, warnings } = buildArgs({
|
|
69
|
+
url,
|
|
70
|
+
mode,
|
|
71
|
+
quality,
|
|
72
|
+
audioFormat,
|
|
73
|
+
outputDir,
|
|
74
|
+
ffmpegAvailable,
|
|
75
|
+
});
|
|
76
|
+
warnings.forEach(printWarning);
|
|
77
|
+
|
|
78
|
+
const bar = new cliProgress.SingleBar(
|
|
79
|
+
{
|
|
80
|
+
format: `${chalk.cyan('{bar}')} {percentage}% | {sizeStr} | {speedStr} | ETA {etaStr}`,
|
|
81
|
+
barCompleteChar: '█',
|
|
82
|
+
barIncompleteChar: '░',
|
|
83
|
+
hideCursor: true,
|
|
84
|
+
clearOnComplete: false,
|
|
85
|
+
},
|
|
86
|
+
cliProgress.Presets.shades_classic
|
|
87
|
+
);
|
|
88
|
+
bar.start(100, 0, { sizeStr: '--', speedStr: '--', etaStr: '--' });
|
|
89
|
+
|
|
90
|
+
let outputFile;
|
|
91
|
+
const startedAt = Date.now();
|
|
92
|
+
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
ytDlpWrap
|
|
95
|
+
.exec(args)
|
|
96
|
+
.on('progress', (progress) => {
|
|
97
|
+
bar.update(progress.percent ?? 0, {
|
|
98
|
+
sizeStr: progress.totalSize ?? '--',
|
|
99
|
+
speedStr: progress.currentSpeed ?? '--',
|
|
100
|
+
etaStr: progress.eta ?? '--',
|
|
101
|
+
});
|
|
102
|
+
})
|
|
103
|
+
.on('ytDlpEvent', (eventType, eventData) => {
|
|
104
|
+
for (const pattern of DESTINATION_PATTERNS) {
|
|
105
|
+
const match = eventData.match(pattern);
|
|
106
|
+
if (match) outputFile = match[1];
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
.on('error', (error) => {
|
|
110
|
+
bar.stop();
|
|
111
|
+
console.error(chalk.bold.red('✖ Download failed'));
|
|
112
|
+
reject(error);
|
|
113
|
+
})
|
|
114
|
+
.on('close', () => {
|
|
115
|
+
bar.update(100);
|
|
116
|
+
bar.stop();
|
|
117
|
+
console.log(chalk.bold.green('✔ Download finished'));
|
|
118
|
+
resolve({ outputFile, elapsedSeconds: ((Date.now() - startedAt) / 1000).toFixed(1) });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import inquirer from 'inquirer';
|
|
6
|
+
import { isFfmpegAvailable, resolveYtDlpBinaryPath } from './binaries.js';
|
|
7
|
+
import { downloadMedia } from './downloader.js';
|
|
8
|
+
import { PLATFORMS, hostMatchesPlatform } from './platforms.js';
|
|
9
|
+
import boxedSelect from './prompts/boxedSelect.js';
|
|
10
|
+
import { formatBytes, printHeader, printResultTable, printSummaryTable } from './ui.js';
|
|
11
|
+
|
|
12
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
13
|
+
|
|
14
|
+
const cliArgs = process.argv.slice(2);
|
|
15
|
+
|
|
16
|
+
if (cliArgs.includes('--version') || cliArgs.includes('-v')) {
|
|
17
|
+
console.log(pkg.version);
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (cliArgs.includes('--help') || cliArgs.includes('-h')) {
|
|
22
|
+
console.log(`
|
|
23
|
+
ComfyClips ${pkg.version}
|
|
24
|
+
${pkg.description}
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
comfyclips Start the interactive downloader
|
|
28
|
+
comfyclips --version Print the version number
|
|
29
|
+
comfyclips --help Show this help message
|
|
30
|
+
`);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
process.stdin.on('keypress', (_str, key) => {
|
|
35
|
+
if (key?.name === 'escape') {
|
|
36
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
37
|
+
console.log(chalk.yellow('\nCancelled.'));
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function isValidUrl(value) {
|
|
43
|
+
try {
|
|
44
|
+
new URL(value);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return 'Please enter a valid URL (including https://)';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function main() {
|
|
52
|
+
printHeader();
|
|
53
|
+
|
|
54
|
+
const platform = await boxedSelect({
|
|
55
|
+
message: 'Select a social media platform:',
|
|
56
|
+
choices: PLATFORMS,
|
|
57
|
+
});
|
|
58
|
+
const platformName = PLATFORMS.find((p) => p.value === platform).name;
|
|
59
|
+
|
|
60
|
+
const { url } = await inquirer.prompt([
|
|
61
|
+
{
|
|
62
|
+
type: 'input',
|
|
63
|
+
name: 'url',
|
|
64
|
+
message: 'Paste the video link:',
|
|
65
|
+
validate: isValidUrl,
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
if (!hostMatchesPlatform(url, platform)) {
|
|
70
|
+
const { proceedAnyway } = await inquirer.prompt([
|
|
71
|
+
{
|
|
72
|
+
type: 'confirm',
|
|
73
|
+
name: 'proceedAnyway',
|
|
74
|
+
message: `That link doesn't look like a ${platformName} URL. Continue anyway?`,
|
|
75
|
+
default: false,
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
if (!proceedAnyway) {
|
|
79
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const mode = await boxedSelect({
|
|
85
|
+
message: 'What do you want to download?',
|
|
86
|
+
choices: [
|
|
87
|
+
{ name: 'Video', value: 'video' },
|
|
88
|
+
{ name: 'Audio only', value: 'audio' },
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let quality = 'best';
|
|
93
|
+
let audioFormat = 'mp3';
|
|
94
|
+
|
|
95
|
+
if (mode === 'video') {
|
|
96
|
+
quality = await boxedSelect({
|
|
97
|
+
message: 'Select video quality:',
|
|
98
|
+
choices: [
|
|
99
|
+
{ name: 'Best available', value: 'best' },
|
|
100
|
+
{ name: 'Up to 1080p', value: '1080p' },
|
|
101
|
+
{ name: 'Up to 720p', value: '720p' },
|
|
102
|
+
{ name: 'Up to 480p', value: '480p' },
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
} else {
|
|
106
|
+
audioFormat = await boxedSelect({
|
|
107
|
+
message: 'Select audio format:',
|
|
108
|
+
choices: ['mp3', 'm4a', 'wav', 'opus'],
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { outputDir } = await inquirer.prompt([
|
|
113
|
+
{
|
|
114
|
+
type: 'input',
|
|
115
|
+
name: 'outputDir',
|
|
116
|
+
message: 'Where should the file be saved?',
|
|
117
|
+
default: path.join(process.cwd(), 'downloads'),
|
|
118
|
+
filter: (value) => path.resolve(value.trim()),
|
|
119
|
+
},
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
printSummaryTable({ platformName, url, mode, quality, audioFormat, outputDir });
|
|
123
|
+
|
|
124
|
+
const { confirmDownload } = await inquirer.prompt([
|
|
125
|
+
{
|
|
126
|
+
type: 'confirm',
|
|
127
|
+
name: 'confirmDownload',
|
|
128
|
+
message: 'Start download?',
|
|
129
|
+
default: true,
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
if (!confirmDownload) {
|
|
134
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const binaryPath = await resolveYtDlpBinaryPath();
|
|
140
|
+
const ffmpegAvailable = isFfmpegAvailable();
|
|
141
|
+
|
|
142
|
+
const { outputFile, elapsedSeconds } = await downloadMedia({
|
|
143
|
+
binaryPath,
|
|
144
|
+
url,
|
|
145
|
+
mode,
|
|
146
|
+
quality,
|
|
147
|
+
audioFormat,
|
|
148
|
+
outputDir,
|
|
149
|
+
ffmpegAvailable,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
let fileSize = 'Unknown';
|
|
153
|
+
if (outputFile) {
|
|
154
|
+
try {
|
|
155
|
+
fileSize = formatBytes(statSync(outputFile).size);
|
|
156
|
+
} catch {
|
|
157
|
+
// File path couldn't be resolved from yt-dlp output; leave as Unknown.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
printResultTable({ outputFile, fileSize, elapsedSeconds, outputDir });
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error(chalk.bold.red(`\n✖ Download failed: ${err.message}`));
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
main();
|
package/src/platforms.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const PLATFORMS = [
|
|
2
|
+
{
|
|
3
|
+
name: 'YouTube',
|
|
4
|
+
value: 'youtube',
|
|
5
|
+
hostPattern: /(^|\.)youtube\.com$|^youtu\.be$/i,
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
name: 'Instagram',
|
|
9
|
+
value: 'instagram',
|
|
10
|
+
hostPattern: /(^|\.)instagram\.com$/i,
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'TikTok',
|
|
14
|
+
value: 'tiktok',
|
|
15
|
+
hostPattern: /(^|\.)tiktok\.com$/i,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'Facebook',
|
|
19
|
+
value: 'facebook',
|
|
20
|
+
hostPattern: /(^|\.)facebook\.com$|^fb\.watch$/i,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'X / Twitter',
|
|
24
|
+
value: 'twitter',
|
|
25
|
+
hostPattern: /(^|\.)twitter\.com$|(^|\.)x\.com$/i,
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function hostMatchesPlatform(url, platformValue) {
|
|
30
|
+
const platform = PLATFORMS.find((p) => p.value === platformValue);
|
|
31
|
+
if (!platform) return true;
|
|
32
|
+
try {
|
|
33
|
+
const { hostname } = new URL(url);
|
|
34
|
+
return platform.hostPattern.test(hostname);
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPrompt,
|
|
3
|
+
useState,
|
|
4
|
+
useKeypress,
|
|
5
|
+
usePrefix,
|
|
6
|
+
makeTheme,
|
|
7
|
+
isEnterKey,
|
|
8
|
+
isUpKey,
|
|
9
|
+
isDownKey,
|
|
10
|
+
} from '@inquirer/core';
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
|
|
13
|
+
function normalizeChoices(choices) {
|
|
14
|
+
return choices.map((choice) =>
|
|
15
|
+
typeof choice === 'object' && choice !== null
|
|
16
|
+
? { name: choice.name ?? String(choice.value), value: choice.value }
|
|
17
|
+
: { name: String(choice), value: choice }
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const POINTER = '❯';
|
|
22
|
+
const BULLET_ACTIVE = '●';
|
|
23
|
+
const BULLET_IDLE = '○';
|
|
24
|
+
|
|
25
|
+
export default createPrompt((config, done) => {
|
|
26
|
+
const theme = makeTheme(config.theme);
|
|
27
|
+
const items = normalizeChoices(config.choices);
|
|
28
|
+
const [status, setStatus] = useState('idle');
|
|
29
|
+
const [active, setActive] = useState(0);
|
|
30
|
+
const prefix = usePrefix({ status, theme });
|
|
31
|
+
|
|
32
|
+
useKeypress((key) => {
|
|
33
|
+
if (isEnterKey(key)) {
|
|
34
|
+
setStatus('done');
|
|
35
|
+
done(items[active].value);
|
|
36
|
+
} else if (isUpKey(key)) {
|
|
37
|
+
setActive((active - 1 + items.length) % items.length);
|
|
38
|
+
} else if (isDownKey(key)) {
|
|
39
|
+
setActive((active + 1) % items.length);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const message = theme.style.message(config.message, status);
|
|
44
|
+
|
|
45
|
+
if (status === 'done') {
|
|
46
|
+
return `${prefix} ${message} ${theme.style.answer(items[active].name)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stripLength(text) {
|
|
50
|
+
// eslint-disable-next-line no-control-regex
|
|
51
|
+
return text.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const HEADER = 'Option';
|
|
55
|
+
const innerWidth = Math.max(HEADER.length, ...items.map((item) => item.name.length + 4));
|
|
56
|
+
|
|
57
|
+
const border = (left, fill, right) => `${left}${fill.repeat(innerWidth + 2)}${right}`;
|
|
58
|
+
const padRow = (raw) => `${raw}${' '.repeat(Math.max(0, innerWidth - stripLength(raw)))}`;
|
|
59
|
+
const row = (content) => `│ ${padRow(content)} │`;
|
|
60
|
+
|
|
61
|
+
const lines = [
|
|
62
|
+
chalk.gray(border('╭', '─', '╮')),
|
|
63
|
+
row(chalk.bold(HEADER)),
|
|
64
|
+
chalk.gray(border('├', '─', '┤')),
|
|
65
|
+
...items.map((item, index) => {
|
|
66
|
+
const isActive = index === active;
|
|
67
|
+
const bullet = isActive ? chalk.cyan(BULLET_ACTIVE) : chalk.gray(BULLET_IDLE);
|
|
68
|
+
const pointer = isActive ? chalk.cyan(POINTER) : ' ';
|
|
69
|
+
const label = isActive ? chalk.cyan.bold(item.name) : chalk.white(item.name);
|
|
70
|
+
return row(`${pointer} ${bullet} ${label}`);
|
|
71
|
+
}),
|
|
72
|
+
chalk.gray(border('╰', '─', '╯')),
|
|
73
|
+
chalk.dim(' ↑↓ move • enter to select • esc to quit'),
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
return `${prefix} ${message}\n${lines.join('\n')}`;
|
|
77
|
+
});
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import boxen from 'boxen';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import Table from 'cli-table3';
|
|
4
|
+
import figlet from 'figlet';
|
|
5
|
+
|
|
6
|
+
const BRAND = chalk.bold.hex('#00d1b2');
|
|
7
|
+
const LABEL = chalk.gray;
|
|
8
|
+
const VALUE = chalk.white;
|
|
9
|
+
|
|
10
|
+
export function printHeader() {
|
|
11
|
+
const logo = figlet.textSync('ComfyClips', { font: 'Small Slant' });
|
|
12
|
+
const title = `${BRAND(logo)}\n${chalk.dim('Social Media Video Downloader')}\n${chalk.dim('Press esc to quit')}`;
|
|
13
|
+
console.log(
|
|
14
|
+
boxen(title, {
|
|
15
|
+
padding: { top: 0, bottom: 0, left: 3, right: 3 },
|
|
16
|
+
margin: { top: 1, bottom: 1, left: 0, right: 0 },
|
|
17
|
+
borderStyle: 'round',
|
|
18
|
+
borderColor: '#00d1b2',
|
|
19
|
+
textAlignment: 'center',
|
|
20
|
+
})
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function baseTable() {
|
|
25
|
+
return new Table({
|
|
26
|
+
chars: {
|
|
27
|
+
top: '─',
|
|
28
|
+
'top-mid': '┬',
|
|
29
|
+
'top-left': '╭',
|
|
30
|
+
'top-right': '╮',
|
|
31
|
+
bottom: '─',
|
|
32
|
+
'bottom-mid': '┴',
|
|
33
|
+
'bottom-left': '╰',
|
|
34
|
+
'bottom-right': '╯',
|
|
35
|
+
left: '│',
|
|
36
|
+
'left-mid': '├',
|
|
37
|
+
mid: '─',
|
|
38
|
+
'mid-mid': '┼',
|
|
39
|
+
right: '│',
|
|
40
|
+
'right-mid': '┤',
|
|
41
|
+
middle: '│',
|
|
42
|
+
},
|
|
43
|
+
style: { head: [], border: ['gray'] },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function truncate(value, max = 60) {
|
|
48
|
+
if (typeof value !== 'string' || value.length <= max) return value;
|
|
49
|
+
return `${value.slice(0, max - 1)}…`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function printSummaryTable({ platformName, url, mode, quality, audioFormat, outputDir }) {
|
|
53
|
+
const table = baseTable();
|
|
54
|
+
table.push(
|
|
55
|
+
[LABEL('Platform'), VALUE(platformName)],
|
|
56
|
+
[LABEL('Link'), VALUE(truncate(url))],
|
|
57
|
+
[LABEL('Download'), VALUE(mode === 'audio' ? 'Audio only' : 'Video')],
|
|
58
|
+
mode === 'audio'
|
|
59
|
+
? [LABEL('Audio format'), VALUE(audioFormat.toUpperCase())]
|
|
60
|
+
: [LABEL('Quality'), VALUE(quality === 'best' ? 'Best available' : quality)],
|
|
61
|
+
[LABEL('Save to'), VALUE(truncate(outputDir))]
|
|
62
|
+
);
|
|
63
|
+
console.log(`\n${chalk.bold('Review your selection')}`);
|
|
64
|
+
console.log(table.toString());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function printWarning(message) {
|
|
68
|
+
console.log(chalk.yellow(`⚠ ${message}`));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function printResultTable({ outputFile, fileSize, elapsedSeconds, outputDir }) {
|
|
72
|
+
const table = baseTable();
|
|
73
|
+
table.push(
|
|
74
|
+
[LABEL('File'), VALUE(truncate(outputFile ?? '(unknown — check destination folder)'))],
|
|
75
|
+
[LABEL('Size'), VALUE(fileSize)],
|
|
76
|
+
[LABEL('Time taken'), VALUE(`${elapsedSeconds}s`)],
|
|
77
|
+
[LABEL('Location'), VALUE(truncate(outputDir))]
|
|
78
|
+
);
|
|
79
|
+
console.log(`\n${chalk.bold.green('✔ Download complete')}`);
|
|
80
|
+
console.log(table.toString());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatBytes(bytes) {
|
|
84
|
+
if (!bytes || Number.isNaN(bytes)) return 'Unknown';
|
|
85
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
86
|
+
let value = bytes;
|
|
87
|
+
let unitIndex = 0;
|
|
88
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
89
|
+
value /= 1024;
|
|
90
|
+
unitIndex += 1;
|
|
91
|
+
}
|
|
92
|
+
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
|
93
|
+
}
|
package/src/ytdlp-lib.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import YTDlpWrapModule from 'yt-dlp-wrap-plus';
|
|
2
|
+
|
|
3
|
+
// yt-dlp-wrap-plus's CJS build sometimes double-wraps its default export
|
|
4
|
+
// (module.default.default instead of module.default). Unwrap defensively.
|
|
5
|
+
const YTDlpWrap =
|
|
6
|
+
typeof YTDlpWrapModule === 'function' ? YTDlpWrapModule : YTDlpWrapModule.default;
|
|
7
|
+
|
|
8
|
+
export default YTDlpWrap;
|