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 CHANGED
@@ -1,3 +1,37 @@
1
- # Temporary Holding Version
1
+ # mscfetch
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runController } from '../src/controllers/appController.js';
4
+
5
+ runController().catch((error) => {
6
+ console.error('Fatal error:', error);
7
+ process.exit(1);
8
+ });
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }