mscfetch 0.0.0-stage → 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/README.md +36 -2
- package/bin/index.js +8 -0
- package/index.js +1 -0
- package/package.json +34 -4
- package/src/config/paths.js +11 -0
- package/src/controllers/appController.js +121 -0
- package/src/models/track.js +24 -0
- package/src/services/downloadService.js +85 -0
- package/src/services/searchService.js +36 -0
- package/src/utils/envCheck.js +13 -0
- package/src/views/terminalView.js +82 -0
package/README.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mscfetch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Interactive terminal music search and batch MP3 downloader.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- [Node.js](https://nodejs.org/) (v18+)
|
|
8
|
+
- [FFmpeg](https://ffmpeg.org/download.html) in your system PATH (required for audio conversion)
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
Run directly via `npx` without prior installation:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx mscfetch
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or install globally:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g mscfetch
|
|
22
|
+
mscfetch
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- **Interactive Search**: Search YouTube for songs or artists directly from the command line.
|
|
28
|
+
- **Multi-Search Queue**: Accumulate tracks from different searches into a batch queue before downloading.
|
|
29
|
+
- **High Quality Audio**: Extracts highest available audio quality and converts to MP3 (VBR 0, ~320kbps).
|
|
30
|
+
- **Metadata Tags**: Automatically embeds track title, artist, and metadata into MP3 files.
|
|
31
|
+
- **Concurrent Downloads**: Downloads batch items concurrently with a pool of 2 workers.
|
|
32
|
+
- **Sanitized Filenames**: Automatically cleans illegal Windows/Unix filesystem characters.
|
|
33
|
+
- **Cross-Platform**: Saves all downloads directly to `~/Downloads/MusicDownloads`.
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT
|
package/bin/index.js
ADDED
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './bin/index.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mscfetch",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Interactive terminal music search and batch MP3 downloader",
|
|
5
|
+
"main": "./bin/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mscfetch": "bin/index.js",
|
|
8
|
+
"music-fetch": "bin/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"index.js",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node ./bin/index.js"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"music",
|
|
22
|
+
"downloader",
|
|
23
|
+
"mp3",
|
|
24
|
+
"cli",
|
|
25
|
+
"yt-dlp",
|
|
26
|
+
"mscfetch"
|
|
27
|
+
],
|
|
28
|
+
"author": "",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@inquirer/prompts": "^8.7.4",
|
|
32
|
+
"chalk": "^6.0.1",
|
|
33
|
+
"ora": "^9.4.1",
|
|
34
|
+
"yt-dlp-exec": "^1.0.2"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
|
|
5
|
+
export function getDownloadDir() {
|
|
6
|
+
const downloadDir = path.join(os.homedir(), 'Downloads', 'MusicDownloads');
|
|
7
|
+
if (!fs.existsSync(downloadDir)) {
|
|
8
|
+
fs.mkdirSync(downloadDir, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
return downloadDir;
|
|
11
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { checkFFmpeg } from '../utils/envCheck.js';
|
|
2
|
+
import { getDownloadDir } from '../config/paths.js';
|
|
3
|
+
import { searchTracks } from '../services/searchService.js';
|
|
4
|
+
import { downloadBatch } from '../services/downloadService.js';
|
|
5
|
+
import {
|
|
6
|
+
displayBanner,
|
|
7
|
+
promptSearchQuery,
|
|
8
|
+
promptTrackSelection,
|
|
9
|
+
promptQueueAction,
|
|
10
|
+
displayQueueSummary,
|
|
11
|
+
promptContinue,
|
|
12
|
+
createSpinner,
|
|
13
|
+
} from '../views/terminalView.js';
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
|
|
16
|
+
export async function runController() {
|
|
17
|
+
checkFFmpeg();
|
|
18
|
+
getDownloadDir();
|
|
19
|
+
|
|
20
|
+
displayBanner();
|
|
21
|
+
|
|
22
|
+
let queue = [];
|
|
23
|
+
let isRunning = true;
|
|
24
|
+
|
|
25
|
+
while (isRunning) {
|
|
26
|
+
try {
|
|
27
|
+
const query = await promptSearchQuery();
|
|
28
|
+
const spinner = createSpinner('Searching YouTube...').start();
|
|
29
|
+
let tracks;
|
|
30
|
+
try {
|
|
31
|
+
tracks = await searchTracks(query);
|
|
32
|
+
} finally {
|
|
33
|
+
spinner.stop();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!tracks || tracks.length === 0) {
|
|
37
|
+
console.log(chalk.yellow('No tracks found for your query.'));
|
|
38
|
+
} else {
|
|
39
|
+
const selected = await promptTrackSelection(tracks);
|
|
40
|
+
if (selected.length > 0) {
|
|
41
|
+
const newTracks = selected.filter(
|
|
42
|
+
(item) => !queue.some((q) => q.id === item.id || q.url === item.url)
|
|
43
|
+
);
|
|
44
|
+
const duplicates = selected.length - newTracks.length;
|
|
45
|
+
queue.push(...newTracks);
|
|
46
|
+
|
|
47
|
+
const dupMsg = duplicates > 0 ? chalk.gray(` (${duplicates} duplicate(s) omitted)`) : '';
|
|
48
|
+
console.log(
|
|
49
|
+
chalk.green(`\n✔ Added ${newTracks.length} track(s) to queue.${dupMsg} Total in queue: ${queue.length}`)
|
|
50
|
+
);
|
|
51
|
+
} else {
|
|
52
|
+
console.log(chalk.yellow('\nNo tracks selected from this search.'));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Queue decision loop: allow searching again, downloading, clearing, or exiting
|
|
57
|
+
let deciding = true;
|
|
58
|
+
while (deciding) {
|
|
59
|
+
displayQueueSummary(queue);
|
|
60
|
+
const action = await promptQueueAction(queue);
|
|
61
|
+
|
|
62
|
+
if (action === 'search') {
|
|
63
|
+
deciding = false; // loops back to promptSearchQuery
|
|
64
|
+
} else if (action === 'download') {
|
|
65
|
+
if (queue.length === 0) {
|
|
66
|
+
console.log(chalk.yellow('Queue is empty. Search and select tracks first.'));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(chalk.cyan(`\nStarting batch download of ${queue.length} track(s)...`));
|
|
71
|
+
const downloadSpinner = createSpinner('Initializing downloads...').start();
|
|
72
|
+
|
|
73
|
+
let completedCount = 0;
|
|
74
|
+
const total = queue.length;
|
|
75
|
+
await downloadBatch(queue, (update) => {
|
|
76
|
+
if (update.status === 'started') {
|
|
77
|
+
downloadSpinner.text = `Downloading (${update.index + 1}/${total}): ${update.track.title}`;
|
|
78
|
+
} else if (update.status === 'completed') {
|
|
79
|
+
completedCount++;
|
|
80
|
+
if (update.result.success) {
|
|
81
|
+
downloadSpinner.succeed(chalk.green(`Downloaded: ${update.track.title}`));
|
|
82
|
+
} else {
|
|
83
|
+
downloadSpinner.fail(chalk.red(`Failed: ${update.track.title} - ${update.result.error}`));
|
|
84
|
+
}
|
|
85
|
+
if (completedCount < total) {
|
|
86
|
+
downloadSpinner.start('Processing downloads...');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
downloadSpinner.stop();
|
|
92
|
+
console.log(chalk.green(`\n✔ All downloads saved to: ${getDownloadDir()}`));
|
|
93
|
+
queue = []; // Reset queue after download
|
|
94
|
+
deciding = false;
|
|
95
|
+
|
|
96
|
+
isRunning = await promptContinue();
|
|
97
|
+
} else if (action === 'clear') {
|
|
98
|
+
queue = [];
|
|
99
|
+
console.log(chalk.yellow('\nQueue cleared.'));
|
|
100
|
+
} else if (action === 'exit') {
|
|
101
|
+
deciding = false;
|
|
102
|
+
isRunning = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.name === 'ExitPromptError') {
|
|
107
|
+
console.log(chalk.gray('\nOperation canceled.'));
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
111
|
+
try {
|
|
112
|
+
isRunning = await promptContinue();
|
|
113
|
+
} catch (promptError) {
|
|
114
|
+
if (promptError?.name === 'ExitPromptError') break;
|
|
115
|
+
throw promptError;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(chalk.magenta('\nThank you for using Music Fetcher CLI!\n'));
|
|
121
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class Track {
|
|
2
|
+
constructor({ id, title, channel, duration, url, thumbnail }) {
|
|
3
|
+
this.id = id;
|
|
4
|
+
this.title = title || 'Unknown Title';
|
|
5
|
+
this.channel = channel || 'Unknown Channel';
|
|
6
|
+
this.duration = duration ?? 0;
|
|
7
|
+
this.url = url;
|
|
8
|
+
this.thumbnail = thumbnail || null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
getFormattedDuration() {
|
|
12
|
+
if (!this.duration || this.duration === 'N/A') return 'Unknown';
|
|
13
|
+
if (typeof this.duration === 'string') {
|
|
14
|
+
if (this.duration.includes(':')) return this.duration;
|
|
15
|
+
const parsed = Number(this.duration);
|
|
16
|
+
if (Number.isNaN(parsed)) return 'Unknown';
|
|
17
|
+
this.duration = parsed;
|
|
18
|
+
}
|
|
19
|
+
const totalSeconds = Math.floor(this.duration);
|
|
20
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
21
|
+
const seconds = totalSeconds % 60;
|
|
22
|
+
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import ytDlp from 'yt-dlp-exec';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getDownloadDir } from '../config/paths.js';
|
|
4
|
+
|
|
5
|
+
export function sanitizeFilename(title) {
|
|
6
|
+
if (!title || typeof title !== 'string') {
|
|
7
|
+
return 'untitled_track';
|
|
8
|
+
}
|
|
9
|
+
const stripped = title
|
|
10
|
+
.replace(/[<>:"/\\|?*]/g, '')
|
|
11
|
+
.replace(/\s+/g, ' ')
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/[. ]+$/, '');
|
|
14
|
+
|
|
15
|
+
return stripped.length > 0 ? stripped : 'untitled_track';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function downloadTrack(track) {
|
|
19
|
+
if (!track || !track.url) {
|
|
20
|
+
return {
|
|
21
|
+
success: false,
|
|
22
|
+
title: track?.title || 'Unknown Track',
|
|
23
|
+
error: 'Missing track URL',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const downloadDir = getDownloadDir();
|
|
28
|
+
const sanitizedTitle = sanitizeFilename(track.title);
|
|
29
|
+
const safeTemplateName = sanitizedTitle.replace(/%/g, '%%');
|
|
30
|
+
const outputTemplate = path.join(downloadDir, `${safeTemplateName}.%(ext)s`);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await ytDlp.exec(track.url, {
|
|
34
|
+
extractAudio: true,
|
|
35
|
+
audioFormat: 'mp3',
|
|
36
|
+
audioQuality: '0', // VBR 0 (~320kbps)
|
|
37
|
+
output: outputTemplate,
|
|
38
|
+
embedMetadata: true,
|
|
39
|
+
noWarnings: true,
|
|
40
|
+
format: 'bestaudio/best',
|
|
41
|
+
jsRuntimes: 'node',
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
success: true,
|
|
45
|
+
title: track.title,
|
|
46
|
+
path: path.join(downloadDir, `${sanitizedTitle}.mp3`),
|
|
47
|
+
};
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return {
|
|
50
|
+
success: false,
|
|
51
|
+
title: track.title,
|
|
52
|
+
error: error.message,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function downloadBatch(tracks, onUpdate) {
|
|
58
|
+
if (!tracks || !Array.isArray(tracks) || tracks.length === 0) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const concurrency = 2;
|
|
63
|
+
const results = new Array(tracks.length);
|
|
64
|
+
let currentIndex = 0;
|
|
65
|
+
|
|
66
|
+
async function worker() {
|
|
67
|
+
while (currentIndex < tracks.length) {
|
|
68
|
+
const idx = currentIndex++;
|
|
69
|
+
const track = tracks[idx];
|
|
70
|
+
if (typeof onUpdate === 'function') {
|
|
71
|
+
onUpdate({ status: 'started', track, index: idx });
|
|
72
|
+
}
|
|
73
|
+
const res = await downloadTrack(track);
|
|
74
|
+
results[idx] = res;
|
|
75
|
+
if (typeof onUpdate === 'function') {
|
|
76
|
+
onUpdate({ status: 'completed', track, result: res, index: idx });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const workerCount = Math.min(concurrency, tracks.length);
|
|
82
|
+
const workers = Array.from({ length: workerCount }, () => worker());
|
|
83
|
+
await Promise.all(workers);
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import ytDlp from 'yt-dlp-exec';
|
|
2
|
+
import { Track } from '../models/track.js';
|
|
3
|
+
|
|
4
|
+
export async function searchTracks(query, maxResults = 8) {
|
|
5
|
+
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
|
6
|
+
throw new Error('Valid search query required');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const rawOutput = await ytDlp.exec(`ytsearch${maxResults}:${query.trim()}`, {
|
|
11
|
+
dumpSingleJson: true,
|
|
12
|
+
flatPlaylist: true,
|
|
13
|
+
noWarnings: true,
|
|
14
|
+
jsRuntimes: 'node',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const parsed = JSON.parse(rawOutput.stdout);
|
|
18
|
+
|
|
19
|
+
if (!parsed?.entries || !Array.isArray(parsed.entries)) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return parsed.entries
|
|
24
|
+
.filter((item) => item && (item.id || item.url))
|
|
25
|
+
.map((item) => new Track({
|
|
26
|
+
id: item.id,
|
|
27
|
+
title: item.title,
|
|
28
|
+
channel: item.uploader || item.channel || 'Unknown Artist',
|
|
29
|
+
duration: item.duration ?? 0,
|
|
30
|
+
url: item.url || (item.id ? `https://www.youtube.com/watch?v=${item.id}` : null),
|
|
31
|
+
thumbnail: item.thumbnails?.[0]?.url || item.thumbnail || null
|
|
32
|
+
}));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw new Error(`Search operation failed: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
export function checkFFmpeg() {
|
|
4
|
+
try {
|
|
5
|
+
execSync('ffmpeg -version', { stdio: 'ignore' });
|
|
6
|
+
return true;
|
|
7
|
+
} catch (error) {
|
|
8
|
+
console.error('Error: FFmpeg is required but was not found in your system PATH.');
|
|
9
|
+
console.error('Please install FFmpeg and ensure it is added to your PATH.');
|
|
10
|
+
console.error('Visit https://ffmpeg.org/download.html for installation instructions.');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { input, checkbox, confirm, select } from '@inquirer/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
|
|
5
|
+
export async function promptSearchQuery() {
|
|
6
|
+
return await input({
|
|
7
|
+
message: chalk.cyan('Enter search query (artist or song):'),
|
|
8
|
+
validate: (value) => value.trim().length > 0 || 'Query cannot be empty',
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function promptTrackSelection(tracks) {
|
|
13
|
+
const choices = tracks.map((track) => ({
|
|
14
|
+
name: `${track.title} ${chalk.gray(`(${track.channel} - ${track.getFormattedDuration()})`)}`,
|
|
15
|
+
value: track,
|
|
16
|
+
checked: false, // Default to unchecked per user preference
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
return await checkbox({
|
|
20
|
+
message: chalk.cyan('Select tracks to add to queue (Space to toggle, Enter to confirm):'),
|
|
21
|
+
choices,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function promptQueueAction(queue) {
|
|
26
|
+
const choices = [
|
|
27
|
+
{
|
|
28
|
+
name: '➕ Add more songs to queue (search again)',
|
|
29
|
+
value: 'search',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: `⬇️ Download queue (${queue.length} track${queue.length === 1 ? '' : 's'})`,
|
|
33
|
+
value: 'download',
|
|
34
|
+
disabled: queue.length === 0 ? '(Queue is empty)' : false,
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
if (queue.length > 0) {
|
|
39
|
+
choices.push({
|
|
40
|
+
name: '🗑️ Clear queue',
|
|
41
|
+
value: 'clear',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
choices.push({
|
|
46
|
+
name: '❌ Exit',
|
|
47
|
+
value: 'exit',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return await select({
|
|
51
|
+
message: chalk.cyan('Queue Menu - choose action:'),
|
|
52
|
+
choices,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function displayQueueSummary(queue) {
|
|
57
|
+
if (!queue || queue.length === 0) return;
|
|
58
|
+
console.log(chalk.bold.blue(`\n📋 Current Download Queue (${queue.length}):`));
|
|
59
|
+
queue.forEach((track, idx) => {
|
|
60
|
+
console.log(
|
|
61
|
+
chalk.gray(` ${idx + 1}. `) +
|
|
62
|
+
chalk.white(track.title) +
|
|
63
|
+
chalk.gray(` [${track.channel} - ${track.getFormattedDuration()}]`)
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
console.log();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function promptContinue() {
|
|
70
|
+
return await confirm({
|
|
71
|
+
message: chalk.cyan('Would you like to start another search session?'),
|
|
72
|
+
default: true,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createSpinner(text) {
|
|
77
|
+
return ora(text);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function displayBanner() {
|
|
81
|
+
console.log(chalk.bold.magenta('\n🎵 Music Fetcher CLI 🎵\n'));
|
|
82
|
+
}
|