mscfetch 1.0.0 → 1.1.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,37 +1,134 @@
1
- # mscfetch
1
+ # 🎵 mscfetch
2
2
 
3
- Interactive terminal music search and batch MP3 downloader.
3
+ [![npm version](https://img.shields.io/npm/v/mscfetch.svg?color=cb3837)](https://www.npmjs.com/package/mscfetch)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js Version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg)](https://nodejs.org/)
4
6
 
5
- ## Prerequisites
7
+ An interactive terminal CLI that searches YouTube for audio tracks, accumulates songs into a batch queue across multiple searches, extracts high-quality audio as MP3 (VBR 0, ~320kbps), and embeds ID3 metadata automatically.
6
8
 
7
- - [Node.js](https://nodejs.org/) (v18+)
8
- - [FFmpeg](https://ffmpeg.org/download.html) in your system PATH (required for audio conversion)
9
+ ---
9
10
 
10
- ## Usage
11
+ ## ⚡ Quick Start
11
12
 
12
- Run directly via `npx` without prior installation:
13
+ Run directly with zero installation via `npx`:
13
14
 
14
15
  ```bash
15
16
  npx mscfetch
16
17
  ```
17
18
 
18
- Or install globally:
19
+ Or install globally on your system:
19
20
 
20
21
  ```bash
21
22
  npm install -g mscfetch
22
23
  mscfetch
23
24
  ```
24
25
 
25
- ## Features
26
+ ---
26
27
 
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`.
28
+ ## 📋 Prerequisites
34
29
 
35
- ## License
30
+ 1. **[Node.js](https://nodejs.org/)** (v18 or higher)
31
+ 2. **[FFmpeg](https://ffmpeg.org/download.html)** in your system PATH (required for audio conversion and metadata embedding)
36
32
 
37
- MIT
33
+ ### Installing FFmpeg
34
+
35
+ If FFmpeg is not already installed, `mscfetch` will automatically detect your OS and offer to install it for you (with an explicit option to decline). You can also install it manually:
36
+
37
+ - **Windows**:
38
+ ```powershell
39
+ winget install Gyan.FFmpeg
40
+ ```
41
+ *(or via Chocolatey: `choco install ffmpeg`)*
42
+
43
+ - **macOS**:
44
+ ```bash
45
+ brew install ffmpeg
46
+ ```
47
+
48
+ - **Linux (Ubuntu/Debian)**:
49
+ ```bash
50
+ sudo apt-get update && sudo apt-get install -y ffmpeg
51
+ ```
52
+
53
+ ---
54
+
55
+ ## 🚀 Features
56
+
57
+ - **🔍 Metadata-Only Search**: Rapidly searches YouTube using `yt-dlp` without preloading or buffering audio stream data.
58
+ - **📋 Multi-Search Queue**: Search for track 1 (e.g. *When September Ends*), add it to your queue, search for track 2 (e.g. *All I Want*), and download everything together.
59
+ - **🎧 High Quality Audio (VBR 0)**: Converts best audio streams to MP3 using VBR 0 (~320kbps) via FFmpeg.
60
+ - **🏷️ Metadata Tags Embedded**: Embeds artist, track title, and media tags directly into the generated MP3 files.
61
+ - **⚡ 2-Worker Concurrency Pool**: Processes downloads in parallel using an internal concurrency queue limited to 2 workers to prevent throttling.
62
+ - **🛡️ Windows/Unix Filename Sanitizer**: Automatically cleans illegal characters (`<>:"/\|?*`) and protects against path traversal.
63
+ - **📂 Cross-Platform Downloads**: Automatically resolves and saves output directly to `~/Downloads/MusicDownloads`.
64
+
65
+ ---
66
+
67
+ ## 💻 Workflow Example
68
+
69
+ ```
70
+ 🎵 Music Fetcher CLI 🎵
71
+
72
+ ? Enter search query (artist or song): Green Day Wake Me Up When September Ends
73
+ ✔ Found tracks
74
+
75
+ ? Select tracks to add to queue ('d' to toggle, Enter to confirm, Esc to go back):
76
+ [ ] Green Day - Wake Me Up When September Ends (Green Day - 4:45)
77
+ [x] Green Day - Wake Me Up When September Ends [Official Video] (4:45)
78
+
79
+ ✔ Added 1 track(s) to queue. Total in queue: 1
80
+
81
+ ? Queue Menu - choose action:
82
+ ❯ ➕ Add more songs to queue (search again)
83
+ ⬇️ Download queue (1 track)
84
+ 🗑️ Clear queue
85
+ ❌ Exit
86
+
87
+ ? Enter search query (artist or song): Paramore All I Want
88
+ ...
89
+ ✔ Added 1 track(s) to queue. Total in queue: 2
90
+
91
+ ? Queue Menu - choose action:
92
+ ➕ Add more songs to queue (search again)
93
+ ❯ ⬇️ Download queue (2 tracks)
94
+ 🗑️ Clear queue
95
+ ❌ Exit
96
+
97
+ Starting batch download of 2 track(s)...
98
+ ✔ Downloaded: Green Day - Wake Me Up When September Ends [Official Video]
99
+ ✔ Downloaded: Paramore - All I Want
100
+ ✔ All downloads saved to: C:\Users\...\Downloads\MusicDownloads
101
+ ```
102
+
103
+ ---
104
+
105
+ ## 🏗️ Architecture
106
+
107
+ Built using native ES Modules (`"type": "module"`) following a strict Model-View-Controller (MVC) pattern:
108
+
109
+ ```
110
+ ├── bin/
111
+ │ └── index.js # Executable entrypoint (#!/usr/bin/env node)
112
+ ├── src/
113
+ │ ├── config/
114
+ │ │ └── paths.js # Cross-platform ~/Downloads/MusicDownloads resolution
115
+ │ ├── controllers/
116
+ │ │ └── appController.js # Main application event & queue loop
117
+ │ ├── models/
118
+ │ │ └── track.js # Track data structure & duration formatting
119
+ │ ├── services/
120
+ │ │ ├── searchService.js # yt-dlp metadata query
121
+ │ │ └── downloadService.js# yt-dlp audio extraction & concurrency queue
122
+ │ ├── utils/
123
+ │ │ └── envCheck.js # FFmpeg presence verification & auto-installer
124
+ │ └── views/
125
+ │ └── terminalView.js # Terminal prompts, spinners, & menus
126
+ ├── package.json
127
+ └── README.md
128
+ ```
129
+
130
+ ---
131
+
132
+ ## 📄 License
133
+
134
+ MIT © [EaRebusa](https://github.com/EaRebusa)
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "mscfetch",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Interactive terminal music search and batch MP3 downloader",
5
5
  "main": "./bin/index.js",
6
6
  "bin": {
7
7
  "mscfetch": "bin/index.js",
8
8
  "music-fetch": "bin/index.js"
9
9
  },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/EaRebusa/mscfetch.git"
13
+ },
10
14
  "files": [
11
15
  "bin",
12
16
  "src",
@@ -25,7 +29,7 @@
25
29
  "yt-dlp",
26
30
  "mscfetch"
27
31
  ],
28
- "author": "",
32
+ "author": "EaRebusa",
29
33
  "license": "MIT",
30
34
  "dependencies": {
31
35
  "@inquirer/prompts": "^8.7.4",
@@ -10,33 +10,54 @@ import {
10
10
  displayQueueSummary,
11
11
  promptContinue,
12
12
  createSpinner,
13
+ BACK_ACTION,
13
14
  } from '../views/terminalView.js';
14
15
  import chalk from 'chalk';
15
16
 
16
17
  export async function runController() {
17
- checkFFmpeg();
18
+ await checkFFmpeg();
18
19
  getDownloadDir();
19
20
 
20
21
  displayBanner();
21
22
 
22
23
  let queue = [];
23
24
  let isRunning = true;
25
+ let currentScreen = 'search';
26
+ let canGoBackFromSearch = false;
24
27
 
25
28
  while (isRunning) {
26
29
  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
- }
30
+ if (currentScreen === 'search') {
31
+ const query = await promptSearchQuery(canGoBackFromSearch);
32
+ if (query === BACK_ACTION) {
33
+ if (canGoBackFromSearch) {
34
+ currentScreen = 'queue_menu';
35
+ continue;
36
+ } else {
37
+ isRunning = false;
38
+ break;
39
+ }
40
+ }
41
+
42
+ const spinner = createSpinner('Searching YouTube...').start();
43
+ let tracks;
44
+ try {
45
+ tracks = await searchTracks(query);
46
+ } finally {
47
+ spinner.stop();
48
+ }
49
+
50
+ if (!tracks || tracks.length === 0) {
51
+ console.log(chalk.yellow('No tracks found for your query.'));
52
+ continue;
53
+ }
35
54
 
36
- if (!tracks || tracks.length === 0) {
37
- console.log(chalk.yellow('No tracks found for your query.'));
38
- } else {
39
55
  const selected = await promptTrackSelection(tracks);
56
+ if (selected === BACK_ACTION) {
57
+ console.log(chalk.gray('\nTrack selection canceled.'));
58
+ continue;
59
+ }
60
+
40
61
  if (selected.length > 0) {
41
62
  const newTracks = selected.filter(
42
63
  (item) => !queue.some((q) => q.id === item.id || q.url === item.url)
@@ -51,16 +72,20 @@ export async function runController() {
51
72
  } else {
52
73
  console.log(chalk.yellow('\nNo tracks selected from this search.'));
53
74
  }
54
- }
55
75
 
56
- // Queue decision loop: allow searching again, downloading, clearing, or exiting
57
- let deciding = true;
58
- while (deciding) {
76
+ currentScreen = 'queue_menu';
77
+ } else if (currentScreen === 'queue_menu') {
59
78
  displayQueueSummary(queue);
60
79
  const action = await promptQueueAction(queue);
61
80
 
81
+ if (action === BACK_ACTION || action === 'exit') {
82
+ isRunning = false;
83
+ break;
84
+ }
85
+
62
86
  if (action === 'search') {
63
- deciding = false; // loops back to promptSearchQuery
87
+ currentScreen = 'search';
88
+ canGoBackFromSearch = true;
64
89
  } else if (action === 'download') {
65
90
  if (queue.length === 0) {
66
91
  console.log(chalk.yellow('Queue is empty. Search and select tracks first.'));
@@ -90,16 +115,18 @@ export async function runController() {
90
115
 
91
116
  downloadSpinner.stop();
92
117
  console.log(chalk.green(`\n✔ All downloads saved to: ${getDownloadDir()}`));
93
- queue = []; // Reset queue after download
94
- deciding = false;
118
+ queue = [];
95
119
 
96
- isRunning = await promptContinue();
120
+ const continueSession = await promptContinue();
121
+ if (continueSession) {
122
+ currentScreen = 'search';
123
+ canGoBackFromSearch = false;
124
+ } else {
125
+ isRunning = false;
126
+ }
97
127
  } else if (action === 'clear') {
98
128
  queue = [];
99
129
  console.log(chalk.yellow('\nQueue cleared.'));
100
- } else if (action === 'exit') {
101
- deciding = false;
102
- isRunning = false;
103
130
  }
104
131
  }
105
132
  } catch (error) {
@@ -109,7 +136,13 @@ export async function runController() {
109
136
  }
110
137
  console.error(chalk.red(`Error: ${error.message}`));
111
138
  try {
112
- isRunning = await promptContinue();
139
+ const continueSession = await promptContinue();
140
+ if (continueSession) {
141
+ currentScreen = 'search';
142
+ canGoBackFromSearch = queue.length > 0;
143
+ } else {
144
+ break;
145
+ }
113
146
  } catch (promptError) {
114
147
  if (promptError?.name === 'ExitPromptError') break;
115
148
  throw promptError;
@@ -1,13 +1,68 @@
1
1
  import { execSync } from 'child_process';
2
+ import { confirm } from '@inquirer/prompts';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
2
5
 
3
- export function checkFFmpeg() {
6
+ export function isFFmpegInstalled() {
4
7
  try {
5
8
  execSync('ffmpeg -version', { stdio: 'ignore' });
6
9
  return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ export function getInstallCommand() {
16
+ const platform = process.platform;
17
+ if (platform === 'win32') {
18
+ return 'winget install Gyan.FFmpeg --accept-package-agreements --accept-source-agreements';
19
+ } else if (platform === 'darwin') {
20
+ return 'brew install ffmpeg';
21
+ } else {
22
+ return 'sudo apt-get update && sudo apt-get install -y ffmpeg';
23
+ }
24
+ }
25
+
26
+ export async function checkFFmpeg() {
27
+ if (isFFmpegInstalled()) {
28
+ return true;
29
+ }
30
+
31
+ console.log(
32
+ chalk.yellow('\n⚠️ FFmpeg is required for MP3 audio conversion, but was not found in your system PATH.')
33
+ );
34
+ const installCmd = getInstallCommand();
35
+ console.log(chalk.gray(`Detected install command for your system: ${installCmd}\n`));
36
+
37
+ let shouldInstall = false;
38
+ try {
39
+ shouldInstall = await confirm({
40
+ message: chalk.cyan('Would you like mscfetch to run this install command for you?'),
41
+ default: false, // Safe default: user has full choice to decline
42
+ });
43
+ } catch (err) {
44
+ if (err?.name === 'ExitPromptError') {
45
+ console.log(chalk.gray('\nInstallation aborted.'));
46
+ process.exit(1);
47
+ }
48
+ }
49
+
50
+ if (!shouldInstall) {
51
+ console.log(chalk.gray('\nInstallation declined. You can install FFmpeg manually at any time by running:'));
52
+ console.log(chalk.bold.white(` ${installCmd}`));
53
+ console.log(chalk.gray('Or download directly from https://ffmpeg.org/download.html\n'));
54
+ process.exit(1);
55
+ }
56
+
57
+ const spinner = ora('Installing FFmpeg...').start();
58
+ try {
59
+ execSync(installCmd, { stdio: 'inherit' });
60
+ spinner.succeed(chalk.green('FFmpeg installation completed!'));
61
+ return true;
7
62
  } 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.');
63
+ spinner.fail(chalk.red(`Automatic installation failed: ${error.message}`));
64
+ console.log(chalk.yellow('\nPlease run the command manually in your terminal/admin prompt:'));
65
+ console.log(chalk.bold.white(` ${installCmd}\n`));
11
66
  process.exit(1);
12
67
  }
13
68
  }
@@ -0,0 +1,663 @@
1
+ import {
2
+ createPrompt,
3
+ useState,
4
+ useKeypress,
5
+ usePrefix,
6
+ usePagination,
7
+ useMemo,
8
+ useRef,
9
+ useEffect,
10
+ makeTheme,
11
+ isUpKey,
12
+ isDownKey,
13
+ isNumberKey,
14
+ isEnterKey,
15
+ isBackspaceKey,
16
+ isTabKey,
17
+ ValidationError,
18
+ Separator,
19
+ } from '@inquirer/core';
20
+ import { cursorHide } from '@inquirer/ansi';
21
+ import { styleText } from 'node:util';
22
+ import figures from '@inquirer/figures';
23
+
24
+ export const BACK_ACTION = Symbol('BACK');
25
+
26
+ export function isEscapeKey(key) {
27
+ return (
28
+ key.name === 'escape' ||
29
+ key.name === 'esc' ||
30
+ (key.sequence === '\x1b' && !key.ctrl && !key.meta && !key.shift)
31
+ );
32
+ }
33
+
34
+ export function isToggleKey(key) {
35
+ return key.name === 'd' || key.name === 'D';
36
+ }
37
+
38
+ // ---------------------------------------------------------
39
+ // Custom Checkbox Prompt: 'd' toggles, Esc cancels/goes back
40
+ // ---------------------------------------------------------
41
+ const checkboxTheme = {
42
+ icon: {
43
+ checked: styleText('green', figures.circleFilled),
44
+ unchecked: figures.circle,
45
+ cursor: figures.pointer,
46
+ disabledChecked: styleText('green', figures.circleDouble),
47
+ disabledUnchecked: '-',
48
+ },
49
+ style: {
50
+ disabled: (text) => styleText('dim', text),
51
+ renderSelectedChoices: (selectedChoices) =>
52
+ selectedChoices.map((choice) => choice.short).join(', '),
53
+ description: (text) => styleText('cyan', text),
54
+ keysHelpTip: (keys) =>
55
+ keys
56
+ .map(([key, action]) => `${styleText('bold', key)} ${styleText('dim', action)}`)
57
+ .join(styleText('dim', ' • ')),
58
+ },
59
+ i18n: { disabledError: 'This option is disabled and cannot be toggled.' },
60
+ };
61
+
62
+ function isSelectable(item) {
63
+ return !Separator.isSeparator(item) && !item.disabled;
64
+ }
65
+
66
+ function isNavigable(item) {
67
+ return !Separator.isSeparator(item);
68
+ }
69
+
70
+ function isChecked(item) {
71
+ return !Separator.isSeparator(item) && item.checked;
72
+ }
73
+
74
+ function toggle(item) {
75
+ return isSelectable(item) ? { ...item, checked: !item.checked } : item;
76
+ }
77
+
78
+ function check(checked) {
79
+ return function (item) {
80
+ return isSelectable(item) ? { ...item, checked } : item;
81
+ };
82
+ }
83
+
84
+ function normalizeCheckboxChoices(choices) {
85
+ return choices.map((choice) => {
86
+ if (Separator.isSeparator(choice)) return choice;
87
+ if (typeof choice !== 'object' || choice === null || !('value' in choice)) {
88
+ const name = String(choice);
89
+ return {
90
+ value: choice,
91
+ name,
92
+ short: name,
93
+ checkedName: name,
94
+ disabled: false,
95
+ checked: false,
96
+ };
97
+ }
98
+ const name = choice.name ?? String(choice.value);
99
+ const normalizedChoice = {
100
+ value: choice.value,
101
+ name,
102
+ short: choice.short ?? name,
103
+ checkedName: choice.checkedName ?? name,
104
+ disabled: choice.disabled ?? false,
105
+ checked: choice.checked ?? false,
106
+ };
107
+ if (choice.description) {
108
+ normalizedChoice.description = choice.description;
109
+ }
110
+ return normalizedChoice;
111
+ });
112
+ }
113
+
114
+ export const customCheckbox = createPrompt((config, done) => {
115
+ const { pageSize = 7, loop = true, required, validate = () => true } = config;
116
+ const shortcuts = { all: 'a', invert: 'i', ...config.shortcuts };
117
+ const theme = makeTheme(checkboxTheme, config.theme);
118
+ const { keybindings } = theme;
119
+ const [status, setStatus] = useState('idle');
120
+ const prefix = usePrefix({ status, theme });
121
+ const [items, setItems] = useState(normalizeCheckboxChoices(config.choices));
122
+ const isCancelledRef = useRef(false);
123
+
124
+ const bounds = useMemo(() => {
125
+ const first = items.findIndex(isNavigable);
126
+ const last = items.findLastIndex(isNavigable);
127
+ if (first === -1) {
128
+ throw new ValidationError('[checkbox prompt] No selectable choices. All choices are disabled.');
129
+ }
130
+ return { first, last };
131
+ }, [items]);
132
+
133
+ const [active, setActive] = useState(bounds.first);
134
+ const [errorMsg, setError] = useState();
135
+
136
+ useKeypress(async (key) => {
137
+ if (status !== 'idle') return;
138
+
139
+ if (isEscapeKey(key)) {
140
+ isCancelledRef.current = true;
141
+ setStatus('done');
142
+ done(BACK_ACTION);
143
+ return;
144
+ }
145
+
146
+ if (isEnterKey(key)) {
147
+ setStatus('loading');
148
+ const selection = items.filter(isChecked);
149
+ const isValid = await validate([...selection]);
150
+ setStatus('idle');
151
+ if (required && !selection.length) {
152
+ setError('At least one choice must be selected');
153
+ } else if (isValid === true) {
154
+ setStatus('done');
155
+ done(selection.map((choice) => choice.value));
156
+ } else {
157
+ setError(isValid || 'You must select a valid value');
158
+ }
159
+ } else if (isUpKey(key, keybindings) || isDownKey(key, keybindings)) {
160
+ if (errorMsg) setError(undefined);
161
+ if (
162
+ loop ||
163
+ (isUpKey(key, keybindings) && active !== bounds.first) ||
164
+ (isDownKey(key, keybindings) && active !== bounds.last)
165
+ ) {
166
+ const offset = isUpKey(key, keybindings) ? -1 : 1;
167
+ let next = active;
168
+ do {
169
+ next = (next + offset + items.length) % items.length;
170
+ } while (!isNavigable(items[next]));
171
+ setActive(next);
172
+ }
173
+ } else if (isToggleKey(key)) {
174
+ const activeItem = items[active];
175
+ if (activeItem && !Separator.isSeparator(activeItem)) {
176
+ if (activeItem.disabled) {
177
+ setError(theme.i18n.disabledError);
178
+ } else {
179
+ setError(undefined);
180
+ setItems(items.map((choice, i) => (i === active ? toggle(choice) : choice)));
181
+ }
182
+ }
183
+ } else if (key.name === shortcuts.all) {
184
+ const selectAll = items.some((choice) => isSelectable(choice) && !choice.checked);
185
+ setItems(items.map(check(selectAll)));
186
+ } else if (key.name === shortcuts.invert) {
187
+ setItems(items.map(toggle));
188
+ } else if (isNumberKey(key)) {
189
+ const selectedIndex = Number(key.name) - 1;
190
+ let selectableIndex = -1;
191
+ const position = items.findIndex((item) => {
192
+ if (Separator.isSeparator(item)) return false;
193
+ selectableIndex++;
194
+ return selectableIndex === selectedIndex;
195
+ });
196
+ const selectedItem = items[position];
197
+ if (selectedItem && isSelectable(selectedItem)) {
198
+ setActive(position);
199
+ setItems(items.map((choice, i) => (i === position ? toggle(choice) : choice)));
200
+ }
201
+ }
202
+ });
203
+
204
+ const message = theme.style.message(config.message, status);
205
+ let description;
206
+ const page = usePagination({
207
+ items,
208
+ active,
209
+ renderItem({ item, isActive }) {
210
+ if (Separator.isSeparator(item)) {
211
+ return ` ${item.separator}`;
212
+ }
213
+ const cursor = isActive ? theme.icon.cursor : ' ';
214
+ if (item.disabled) {
215
+ const disabledLabel = typeof item.disabled === 'string' ? item.disabled : '(disabled)';
216
+ const checkbox = item.checked
217
+ ? theme.icon.disabledChecked
218
+ : theme.icon.disabledUnchecked;
219
+ return theme.style.disabled(`${cursor}${checkbox} ${item.name} ${disabledLabel}`);
220
+ }
221
+ if (isActive) {
222
+ description = item.description;
223
+ }
224
+ const checkbox = item.checked ? theme.icon.checked : theme.icon.unchecked;
225
+ const name = item.checked ? item.checkedName : item.name;
226
+ const color = isActive ? theme.style.highlight : (x) => x;
227
+ return color(`${cursor}${checkbox} ${name}`);
228
+ },
229
+ pageSize,
230
+ loop,
231
+ });
232
+
233
+ if (status === 'done') {
234
+ if (isCancelledRef.current) {
235
+ return [prefix, message, theme.style.disabled('(back)')].filter(Boolean).join(' ');
236
+ }
237
+ const selection = items.filter(isChecked);
238
+ const answer = theme.style.answer(theme.style.renderSelectedChoices(selection, items));
239
+ return [prefix, message, answer].filter(Boolean).join(' ');
240
+ }
241
+
242
+ const keys = [
243
+ ['↑↓', 'navigate'],
244
+ ['d', 'toggle'],
245
+ ];
246
+ if (shortcuts.all) keys.push([shortcuts.all, 'all']);
247
+ if (shortcuts.invert) keys.push([shortcuts.invert, 'invert']);
248
+ keys.push(['esc', 'back']);
249
+ keys.push(['⏎', 'submit']);
250
+ const helpLine = theme.style.keysHelpTip(keys);
251
+
252
+ const lines = [
253
+ [prefix, message].filter(Boolean).join(' '),
254
+ page,
255
+ ' ',
256
+ description ? theme.style.description(description) : '',
257
+ errorMsg ? theme.style.error(errorMsg) : '',
258
+ helpLine,
259
+ ]
260
+ .filter(Boolean)
261
+ .join('\n')
262
+ .trimEnd();
263
+
264
+ return `${lines}${cursorHide}`;
265
+ });
266
+
267
+ // ---------------------------------------------------------
268
+ // Custom Input Prompt: Esc cancels / goes back
269
+ // ---------------------------------------------------------
270
+ const inputTheme = {
271
+ validationFailureMode: 'keep',
272
+ };
273
+
274
+ export const customInput = createPrompt((config, done) => {
275
+ const { prefill = 'tab' } = config;
276
+ const theme = makeTheme(inputTheme, config.theme);
277
+ const [status, setStatus] = useState('idle');
278
+ const [defaultValue, setDefaultValue] = useState(String(config.default ?? ''));
279
+ const [errorMsg, setError] = useState();
280
+ const [value, setValue] = useState('');
281
+ const isCancelledRef = useRef(false);
282
+ const prefix = usePrefix({ status, theme });
283
+
284
+ async function validate(val) {
285
+ const { required, pattern, patternError = 'Invalid input' } = config;
286
+ if (required && !val) return 'You must provide a value';
287
+ if (pattern && !pattern.test(val)) return patternError;
288
+ if (typeof config.validate === 'function') {
289
+ return (await config.validate(val)) || 'You must provide a valid value';
290
+ }
291
+ return true;
292
+ }
293
+
294
+ useKeypress(async (key, rl) => {
295
+ if (status !== 'idle') return;
296
+
297
+ if (isEscapeKey(key)) {
298
+ rl.clearLine(0);
299
+ isCancelledRef.current = true;
300
+ setStatus('done');
301
+ done(BACK_ACTION);
302
+ return;
303
+ }
304
+
305
+ if (isEnterKey(key)) {
306
+ const answer = value || defaultValue;
307
+ setStatus('loading');
308
+ const isValid = await validate(answer);
309
+ if (isValid === true) {
310
+ setValue(answer);
311
+ setStatus('done');
312
+ done(answer);
313
+ } else {
314
+ if (theme.validationFailureMode === 'clear') {
315
+ setValue('');
316
+ } else {
317
+ rl.write(value);
318
+ }
319
+ setError(isValid);
320
+ setStatus('idle');
321
+ }
322
+ } else if (isBackspaceKey(key) && !value) {
323
+ setDefaultValue('');
324
+ } else if (isTabKey(key) && !value) {
325
+ setDefaultValue('');
326
+ rl.clearLine(0);
327
+ rl.write(defaultValue);
328
+ setValue(defaultValue);
329
+ } else {
330
+ setDefaultValue('');
331
+ setValue(rl.line);
332
+ setError(undefined);
333
+ }
334
+ });
335
+
336
+ useEffect((rl) => {
337
+ if (prefill === 'editable' && defaultValue) {
338
+ rl.write(defaultValue);
339
+ setValue(defaultValue);
340
+ }
341
+ }, []);
342
+
343
+ const message = theme.style.message(config.message, status);
344
+ let formattedValue = value;
345
+ if (status === 'done' && isCancelledRef.current) {
346
+ formattedValue = theme.style.disabled ? theme.style.disabled('(back)') : '(back)';
347
+ } else if (typeof config.transformer === 'function') {
348
+ formattedValue = config.transformer(value, { isFinal: status === 'done' });
349
+ } else if (status === 'done') {
350
+ formattedValue = theme.style.answer(value);
351
+ }
352
+
353
+ let defaultStr;
354
+ if (defaultValue && status !== 'done' && !value) {
355
+ defaultStr = theme.style.defaultAnswer(defaultValue);
356
+ }
357
+
358
+ let error = '';
359
+ if (errorMsg) {
360
+ error = theme.style.error(errorMsg);
361
+ }
362
+
363
+ return [
364
+ [prefix, message, defaultStr, formattedValue]
365
+ .filter((v) => v !== undefined)
366
+ .join(' '),
367
+ error,
368
+ ];
369
+ });
370
+
371
+ // ---------------------------------------------------------
372
+ // Custom Select Prompt: Esc cancels / goes back
373
+ // ---------------------------------------------------------
374
+ const selectTheme = {
375
+ icon: { cursor: figures.pointer },
376
+ style: {
377
+ disabled: (text) => styleText('dim', text),
378
+ description: (text) => styleText('cyan', text),
379
+ keysHelpTip: (keys) =>
380
+ keys
381
+ .map(([key, action]) => `${styleText('bold', key)} ${styleText('dim', action)}`)
382
+ .join(styleText('dim', ' • ')),
383
+ },
384
+ i18n: { disabledError: 'This option is disabled and cannot be selected.' },
385
+ indexMode: 'hidden',
386
+ };
387
+
388
+ function normalizeSelectChoices(choices) {
389
+ return choices.map((choice) => {
390
+ if (Separator.isSeparator(choice)) return choice;
391
+ if (typeof choice !== 'object' || choice === null || !('value' in choice)) {
392
+ const name = String(choice);
393
+ return {
394
+ value: choice,
395
+ name,
396
+ short: name,
397
+ disabled: false,
398
+ };
399
+ }
400
+ const name = choice.name ?? String(choice.value);
401
+ const normalizedChoice = {
402
+ value: choice.value,
403
+ name,
404
+ short: choice.short ?? name,
405
+ disabled: choice.disabled ?? false,
406
+ };
407
+ if (choice.description) {
408
+ normalizedChoice.description = choice.description;
409
+ }
410
+ return normalizedChoice;
411
+ });
412
+ }
413
+
414
+ export const customSelect = createPrompt((config, done) => {
415
+ const { loop = true, pageSize = 7 } = config;
416
+ const theme = makeTheme(selectTheme, config.theme);
417
+ const { keybindings } = theme;
418
+ const [status, setStatus] = useState('idle');
419
+ const prefix = usePrefix({ status, theme });
420
+ const searchTimeoutRef = useRef();
421
+ const isCancelledRef = useRef(false);
422
+ const searchEnabled = !keybindings.includes('vim');
423
+ const items = useMemo(() => normalizeSelectChoices(config.choices), [config.choices]);
424
+ const bounds = useMemo(() => {
425
+ const first = items.findIndex(isNavigable);
426
+ const last = items.findLastIndex(isNavigable);
427
+ if (first === -1) {
428
+ throw new ValidationError('[select prompt] No selectable choices. All choices are disabled.');
429
+ }
430
+ return { first, last };
431
+ }, [items]);
432
+
433
+ const [active, setActive] = useState(bounds.first);
434
+ const [errorMsg, setError] = useState();
435
+ const selectedChoice = items[active];
436
+
437
+ useKeypress((key, rl) => {
438
+ clearTimeout(searchTimeoutRef.current);
439
+ if (errorMsg) setError(undefined);
440
+
441
+ if (isEscapeKey(key)) {
442
+ isCancelledRef.current = true;
443
+ setStatus('done');
444
+ done(BACK_ACTION);
445
+ return;
446
+ }
447
+
448
+ if (isEnterKey(key)) {
449
+ if (selectedChoice.disabled) {
450
+ setError(theme.i18n.disabledError);
451
+ } else {
452
+ setStatus('done');
453
+ done(selectedChoice.value);
454
+ }
455
+ } else if (isUpKey(key, keybindings) || isDownKey(key, keybindings)) {
456
+ rl.clearLine(0);
457
+ if (
458
+ loop ||
459
+ (isUpKey(key, keybindings) && active !== bounds.first) ||
460
+ (isDownKey(key, keybindings) && active !== bounds.last)
461
+ ) {
462
+ const offset = isUpKey(key, keybindings) ? -1 : 1;
463
+ let next = active;
464
+ do {
465
+ next = (next + offset + items.length) % items.length;
466
+ } while (!isNavigable(items[next]));
467
+ setActive(next);
468
+ }
469
+ } else if (isNumberKey(key) && !Number.isNaN(Number(rl.line))) {
470
+ const selectedIndex = Number(rl.line) - 1;
471
+ let selectableIndex = -1;
472
+ const position = items.findIndex((item) => {
473
+ if (Separator.isSeparator(item)) return false;
474
+ selectableIndex++;
475
+ return selectableIndex === selectedIndex;
476
+ });
477
+ const item = items[position];
478
+ if (item != null && isSelectable(item)) {
479
+ setActive(position);
480
+ }
481
+ searchTimeoutRef.current = setTimeout(() => {
482
+ rl.clearLine(0);
483
+ }, 700);
484
+ } else if (isBackspaceKey(key)) {
485
+ rl.clearLine(0);
486
+ } else if (searchEnabled) {
487
+ const searchTerm = rl.line.toLowerCase();
488
+ const matchIndex = items.findIndex((item) => {
489
+ if (Separator.isSeparator(item) || !isSelectable(item)) return false;
490
+ return item.name.toLowerCase().startsWith(searchTerm);
491
+ });
492
+ if (matchIndex !== -1) {
493
+ setActive(matchIndex);
494
+ }
495
+ searchTimeoutRef.current = setTimeout(() => {
496
+ rl.clearLine(0);
497
+ }, 700);
498
+ }
499
+ });
500
+
501
+ useEffect(() => () => {
502
+ clearTimeout(searchTimeoutRef.current);
503
+ }, []);
504
+
505
+ const message = theme.style.message(config.message, status);
506
+ const helpLine = theme.style.keysHelpTip([
507
+ ['↑↓', 'navigate'],
508
+ ['esc', 'back'],
509
+ ['⏎', 'select'],
510
+ ]);
511
+
512
+ let separatorCount = 0;
513
+ const page = usePagination({
514
+ items,
515
+ active,
516
+ renderItem({ item, isActive, index }) {
517
+ if (Separator.isSeparator(item)) {
518
+ separatorCount++;
519
+ return ` ${item.separator}`;
520
+ }
521
+ const cursor = isActive ? theme.icon.cursor : ' ';
522
+ const indexLabel = theme.indexMode === 'number' ? `${index + 1 - separatorCount}. ` : '';
523
+ if (item.disabled) {
524
+ const disabledLabel = typeof item.disabled === 'string' ? item.disabled : '(disabled)';
525
+ const disabledCursor = isActive ? theme.icon.cursor : '-';
526
+ return theme.style.disabled(`${disabledCursor} ${indexLabel}${item.name} ${disabledLabel}`);
527
+ }
528
+ const color = isActive ? theme.style.highlight : (x) => x;
529
+ return color(`${cursor} ${indexLabel}${item.name}`);
530
+ },
531
+ pageSize,
532
+ loop,
533
+ });
534
+
535
+ if (status === 'done') {
536
+ if (isCancelledRef.current) {
537
+ return [prefix, message, theme.style.disabled ? theme.style.disabled('(back)') : '(back)']
538
+ .filter(Boolean)
539
+ .join(' ');
540
+ }
541
+ return [prefix, message, theme.style.answer(selectedChoice.short)]
542
+ .filter(Boolean)
543
+ .join(' ');
544
+ }
545
+
546
+ const { description } = selectedChoice;
547
+ const lines = [
548
+ [prefix, message].filter(Boolean).join(' '),
549
+ page,
550
+ ' ',
551
+ description ? theme.style.description(description) : '',
552
+ errorMsg ? theme.style.error(errorMsg) : '',
553
+ helpLine,
554
+ ]
555
+ .filter(Boolean)
556
+ .join('\n')
557
+ .trimEnd();
558
+
559
+ return `${lines}${cursorHide}`;
560
+ });
561
+
562
+ // ---------------------------------------------------------
563
+ // Custom Confirm Prompt: Esc acts as 'no'
564
+ // ---------------------------------------------------------
565
+ const confirmTheme = {
566
+ keywords: {
567
+ yes: 'Yes',
568
+ no: 'No',
569
+ error: ({ yes, no }) => `You must answer with "${yes}" or "${no}"`,
570
+ },
571
+ style: {
572
+ confirmDefault: (text) => {
573
+ const first = text[0] ?? '';
574
+ if (first.toLowerCase() === first.toUpperCase()) {
575
+ return styleText('cyan', text);
576
+ }
577
+ return first.toUpperCase() + text.slice(1);
578
+ },
579
+ },
580
+ };
581
+
582
+ export const customConfirm = createPrompt((config, done) => {
583
+ const [status, setStatus] = useState('idle');
584
+ const [value, setValue] = useState('');
585
+ const [errorMsg, setError] = useState();
586
+ const theme = makeTheme(confirmTheme, config.theme);
587
+ const prefix = usePrefix({ status, theme });
588
+ const { yes, no } = theme.keywords;
589
+ const isCancelledRef = useRef(false);
590
+
591
+ const yesHint = (yes[0] ?? '').toLowerCase();
592
+ const noHint = (no[0] ?? '').toLowerCase();
593
+ const hint =
594
+ config.default === false
595
+ ? `${yesHint}/${theme.style.confirmDefault(noHint)}`
596
+ : `${theme.style.confirmDefault(yesHint)}/${noHint}`;
597
+
598
+ function boolToString(val) {
599
+ return val ? yes : no;
600
+ }
601
+ const { transformer = boolToString } = config;
602
+
603
+ function getBooleanValue(val) {
604
+ const v = val.trim().toLowerCase();
605
+ if (v === '') return config.default !== false;
606
+ if (yes.toLowerCase().startsWith(v)) return true;
607
+ if (no.toLowerCase().startsWith(v)) return false;
608
+ if ('yes'.startsWith(v)) return true;
609
+ if ('no'.startsWith(v)) return false;
610
+ return undefined;
611
+ }
612
+
613
+ useKeypress((key, rl) => {
614
+ if (status !== 'idle') return;
615
+
616
+ if (isEscapeKey(key)) {
617
+ isCancelledRef.current = true;
618
+ setStatus('done');
619
+ done(false);
620
+ return;
621
+ }
622
+
623
+ if (isEnterKey(key)) {
624
+ const answer = getBooleanValue(value);
625
+ if (typeof answer === 'boolean') {
626
+ setStatus('done');
627
+ done(answer);
628
+ } else {
629
+ setError(theme.keywords.error({ yes, no }));
630
+ }
631
+ } else if (isBackspaceKey(key)) {
632
+ rl.clearLine(0);
633
+ setValue('');
634
+ setError(undefined);
635
+ } else {
636
+ setValue(rl.line);
637
+ setError(undefined);
638
+ }
639
+ });
640
+
641
+ const message = theme.style.message(config.message, status);
642
+ let formattedValue = value;
643
+ let defaultValue = '';
644
+ if (status === 'done') {
645
+ if (isCancelledRef.current) {
646
+ formattedValue = theme.style.answer(no);
647
+ } else {
648
+ formattedValue = theme.style.answer(value || transformer(config.default !== false));
649
+ }
650
+ } else {
651
+ defaultValue = ` ${theme.style.defaultAnswer(hint)}`;
652
+ }
653
+
654
+ let error = '';
655
+ if (errorMsg) {
656
+ error = theme.style.error(errorMsg);
657
+ }
658
+
659
+ return [
660
+ `${prefix} ${message}${defaultValue} ${formattedValue}`,
661
+ error,
662
+ ];
663
+ });
@@ -1,10 +1,19 @@
1
- import { input, checkbox, confirm, select } from '@inquirer/prompts';
2
1
  import chalk from 'chalk';
3
2
  import ora from 'ora';
3
+ import {
4
+ customCheckbox,
5
+ customInput,
6
+ customSelect,
7
+ customConfirm,
8
+ BACK_ACTION,
9
+ } from './customPrompts.js';
4
10
 
5
- export async function promptSearchQuery() {
6
- return await input({
7
- message: chalk.cyan('Enter search query (artist or song):'),
11
+ export { BACK_ACTION };
12
+
13
+ export async function promptSearchQuery(canGoBack = false) {
14
+ const escHint = canGoBack ? 'Esc to go back' : 'Esc to exit';
15
+ return await customInput({
16
+ message: chalk.cyan(`Enter search query (artist or song) [${escHint}]:`),
8
17
  validate: (value) => value.trim().length > 0 || 'Query cannot be empty',
9
18
  });
10
19
  }
@@ -16,8 +25,8 @@ export async function promptTrackSelection(tracks) {
16
25
  checked: false, // Default to unchecked per user preference
17
26
  }));
18
27
 
19
- return await checkbox({
20
- message: chalk.cyan('Select tracks to add to queue (Space to toggle, Enter to confirm):'),
28
+ return await customCheckbox({
29
+ message: chalk.cyan("Select tracks to add to queue ('d' to toggle, Enter to confirm, Esc to go back):"),
21
30
  choices,
22
31
  });
23
32
  }
@@ -47,8 +56,8 @@ export async function promptQueueAction(queue) {
47
56
  value: 'exit',
48
57
  });
49
58
 
50
- return await select({
51
- message: chalk.cyan('Queue Menu - choose action:'),
59
+ return await customSelect({
60
+ message: chalk.cyan('Queue Menu - choose action (Esc to exit):'),
52
61
  choices,
53
62
  });
54
63
  }
@@ -67,7 +76,7 @@ export function displayQueueSummary(queue) {
67
76
  }
68
77
 
69
78
  export async function promptContinue() {
70
- return await confirm({
79
+ return await customConfirm({
71
80
  message: chalk.cyan('Would you like to start another search session?'),
72
81
  default: true,
73
82
  });