@involvex/youtube-music-cli 0.0.26 → 0.0.28

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [0.0.28](https://github.com/involvex/youtube-music-cli/compare/v0.0.27...v0.0.28) (2026-02-22)
2
+
3
+ ### Bug Fixes
4
+
5
+ - standardize quote style in snyk_rules.instructions.md ([5906fed](https://github.com/involvex/youtube-music-cli/commit/5906fed587be5f9cd6629281428ef304d8b32749))
6
+
7
+ ## [0.0.27](https://github.com/involvex/youtube-music-cli/compare/v0.0.26...v0.0.27) (2026-02-20)
8
+
1
9
  ## [0.0.26](https://github.com/involvex/youtube-music-cli/compare/v0.0.25...v0.0.26) (2026-02-20)
2
10
 
3
11
  ## [0.0.25](https://github.com/involvex/youtube-music-cli/compare/v0.0.24...v0.0.25) (2026-02-20)
@@ -11,7 +11,10 @@ import { getWebServerManager } from "./services/web/web-server-manager.js";
11
11
  import { getWebStreamingService } from "./services/web/web-streaming.service.js";
12
12
  import { getVersionCheckService } from "./services/version-check/version-check.service.js";
13
13
  import { getConfigService } from "./services/config/config.service.js";
14
+ import { getPlayerService } from "./services/player/player.service.js";
14
15
  import { APP_VERSION } from "./utils/constants.js";
16
+ import { ensurePlaybackDependencies } from "./services/player/dependency-check.service.js";
17
+ import { getMusicService } from "./services/youtube-music/api.js";
15
18
  const cli = meow(`
16
19
  Usage
17
20
  $ youtube-music-cli
@@ -117,6 +120,64 @@ if (cli.flags.help) {
117
120
  // Handle plugin commands
118
121
  const command = cli.input[0];
119
122
  const args = cli.input.slice(1);
123
+ const isInteractiveTerminal = Boolean(process.stdin.isTTY && process.stdout.isTTY);
124
+ function requiresImmediatePlayback(flags) {
125
+ return Boolean(flags.playTrack || flags.searchQuery || flags.playPlaylist);
126
+ }
127
+ function shouldCheckPlaybackDependencies(commandName, flags) {
128
+ if (flags.webOnly) {
129
+ return false;
130
+ }
131
+ if (requiresImmediatePlayback(flags)) {
132
+ return true;
133
+ }
134
+ return (commandName === undefined ||
135
+ commandName === 'suggestions' ||
136
+ Boolean(flags.web));
137
+ }
138
+ async function runDirectPlaybackCommand(flags) {
139
+ const musicService = getMusicService();
140
+ const playerService = getPlayerService();
141
+ const config = getConfigService();
142
+ const playbackOptions = {
143
+ volume: flags.volume ?? config.get('volume'),
144
+ audioNormalization: config.get('audioNormalization'),
145
+ };
146
+ let track;
147
+ if (flags.playTrack) {
148
+ track = await musicService.getTrack(flags.playTrack);
149
+ if (!track) {
150
+ throw new Error(`Track not found: ${flags.playTrack}`);
151
+ }
152
+ }
153
+ else if (flags.searchQuery) {
154
+ const response = await musicService.search(flags.searchQuery, {
155
+ type: 'songs',
156
+ limit: 1,
157
+ });
158
+ const firstSong = response.results.find(result => result.type === 'song');
159
+ if (!firstSong) {
160
+ throw new Error(`No playable tracks found for: "${flags.searchQuery}"`);
161
+ }
162
+ track = firstSong.data;
163
+ }
164
+ else if (flags.playPlaylist) {
165
+ const playlist = await musicService.getPlaylist(flags.playPlaylist);
166
+ track = playlist.tracks[0];
167
+ if (!track) {
168
+ throw new Error(`No playable tracks found in playlist: ${flags.playPlaylist}`);
169
+ }
170
+ }
171
+ if (!track) {
172
+ throw new Error('No track resolved for playback command.');
173
+ }
174
+ const artists = track.artists.length > 0
175
+ ? track.artists.map(artist => artist.name).join(', ')
176
+ : 'Unknown Artist';
177
+ console.log(`Playing: ${track.title} — ${artists}`);
178
+ const youtubeUrl = `https://www.youtube.com/watch?v=${track.videoId}`;
179
+ await playerService.play(youtubeUrl, playbackOptions);
180
+ }
120
181
  if (command === 'plugins') {
121
182
  const subCommand = args[0];
122
183
  const pluginArg = args[1];
@@ -274,6 +335,28 @@ else {
274
335
  else if (command === 'back') {
275
336
  cli.flags.action = 'previous';
276
337
  }
338
+ const flags = cli.flags;
339
+ const shouldRunDirectPlayback = requiresImmediatePlayback(flags) &&
340
+ (flags.headless || !isInteractiveTerminal);
341
+ if (shouldRunDirectPlayback) {
342
+ void (async () => {
343
+ const dependencyCheck = await ensurePlaybackDependencies({
344
+ interactive: isInteractiveTerminal,
345
+ });
346
+ if (!dependencyCheck.ready) {
347
+ process.exit(1);
348
+ return;
349
+ }
350
+ try {
351
+ await runDirectPlaybackCommand(flags);
352
+ process.exit(0);
353
+ }
354
+ catch (error) {
355
+ console.error(`✗ Playback failed: ${error instanceof Error ? error.message : String(error)}`);
356
+ process.exit(1);
357
+ }
358
+ })();
359
+ }
277
360
  else if (command === 'import') {
278
361
  // Handle import commands
279
362
  void (async () => {
@@ -317,6 +400,15 @@ else {
317
400
  void (async () => {
318
401
  const webManager = getWebServerManager();
319
402
  try {
403
+ if (shouldCheckPlaybackDependencies(command, flags)) {
404
+ const dependencyCheck = await ensurePlaybackDependencies({
405
+ interactive: isInteractiveTerminal,
406
+ });
407
+ if (!dependencyCheck.ready && requiresImmediatePlayback(flags)) {
408
+ process.exit(1);
409
+ return;
410
+ }
411
+ }
320
412
  await webManager.start({
321
413
  enabled: true,
322
414
  host: cli.flags.webHost ?? 'localhost',
@@ -343,7 +435,7 @@ else {
343
435
  }
344
436
  else {
345
437
  // Also render the CLI UI
346
- render(_jsx(App, { flags: cli.flags }));
438
+ render(_jsx(App, { flags: flags }));
347
439
  }
348
440
  }
349
441
  catch (error) {
@@ -353,9 +445,18 @@ else {
353
445
  })();
354
446
  }
355
447
  else {
356
- // Check for updates before rendering the app (skip in web-only mode)
357
- if (!cli.flags.webOnly) {
358
- void (async () => {
448
+ void (async () => {
449
+ if (shouldCheckPlaybackDependencies(command, flags)) {
450
+ const dependencyCheck = await ensurePlaybackDependencies({
451
+ interactive: isInteractiveTerminal,
452
+ });
453
+ if (!dependencyCheck.ready && requiresImmediatePlayback(flags)) {
454
+ process.exit(1);
455
+ return;
456
+ }
457
+ }
458
+ // Check for updates before rendering the app (skip in web-only mode)
459
+ if (!cli.flags.webOnly) {
359
460
  const versionCheck = getVersionCheckService();
360
461
  const config = getConfigService();
361
462
  const lastCheck = config.getLastVersionCheck();
@@ -369,9 +470,9 @@ else {
369
470
  console.log('');
370
471
  }
371
472
  }
372
- })();
373
- }
374
- // Render the app
375
- render(_jsx(App, { flags: cli.flags }));
473
+ }
474
+ // Render the app
475
+ render(_jsx(App, { flags: flags }));
476
+ })();
376
477
  }
377
478
  }
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Search view layout
3
3
  import { useNavigation } from "../../hooks/useNavigation.js";
4
4
  import { useYouTubeMusic } from "../../hooks/useYouTubeMusic.js";
@@ -10,15 +10,18 @@ import SearchBar from "../search/SearchBar.js";
10
10
  import { useKeyBinding } from "../../hooks/useKeyboard.js";
11
11
  import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
12
12
  import { Box, Text } from 'ink';
13
+ import { usePlayer } from "../../hooks/usePlayer.js";
13
14
  function SearchLayout() {
14
15
  const { theme } = useTheme();
15
16
  const { state: navState, dispatch } = useNavigation();
17
+ const { state: playerState } = usePlayer();
16
18
  const { isLoading, error, search } = useYouTubeMusic();
17
19
  const [results, setResults] = useState([]);
18
20
  const [isTyping, setIsTyping] = useState(true);
19
21
  const [isSearching, setIsSearching] = useState(false);
20
22
  const [actionMessage, setActionMessage] = useState(null);
21
23
  const actionTimeoutRef = useRef(null);
24
+ const lastAutoSearchedQueryRef = useRef(null);
22
25
  // Handle search action
23
26
  const performSearch = useCallback(async (query) => {
24
27
  if (!query || isSearching)
@@ -56,12 +59,18 @@ function SearchLayout() {
56
59
  useKeyBinding(['h'], goToHistory);
57
60
  // Initial search if query is in state (usually from CLI flags)
58
61
  useEffect(() => {
59
- if (navState.searchQuery && !navState.hasSearched) {
60
- void performSearch(navState.searchQuery);
62
+ const query = navState.searchQuery.trim();
63
+ if (!query || navState.hasSearched) {
64
+ return;
61
65
  }
62
- // We only want this to run once on mount or when searchQuery changes initially
63
- // eslint-disable-next-line react-hooks/exhaustive-deps
64
- }, []);
66
+ if (lastAutoSearchedQueryRef.current === query) {
67
+ return;
68
+ }
69
+ lastAutoSearchedQueryRef.current = query;
70
+ queueMicrotask(() => {
71
+ void performSearch(query);
72
+ });
73
+ }, [navState.searchQuery, navState.hasSearched, performSearch]);
65
74
  // Handle going back
66
75
  const goBack = useCallback(() => {
67
76
  if (!isTyping) {
@@ -106,9 +115,11 @@ function SearchLayout() {
106
115
  setResults([]);
107
116
  dispatch({ category: 'SET_HAS_SEARCHED', hasSearched: false });
108
117
  dispatch({ category: 'SET_SEARCH_QUERY', query: '' });
118
+ lastAutoSearchedQueryRef.current = null;
109
119
  };
110
120
  }, [dispatch]);
111
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.colors.dim, children: ["Limit: ", navState.searchLimit, " (Use [ or ] to adjust)"] }), _jsx(SearchBar, { isActive: isTyping && !isSearching, onInput: input => {
121
+ return (_jsxs(Box, { flexDirection: "column", children: [playerState.currentTrack && (_jsxs(Box, { children: [_jsx(Text, { color: theme.colors.dim, children: playerState.isPlaying ? '▶ ' : '⏸ ' }), _jsx(Text, { color: theme.colors.primary, bold: true, children: playerState.currentTrack.title }), playerState.currentTrack.artists &&
122
+ playerState.currentTrack.artists.length > 0 && (_jsxs(Text, { color: theme.colors.secondary, children: [' • ', playerState.currentTrack.artists.map(a => a.name).join(', ')] }))] })), _jsxs(Text, { color: theme.colors.dim, children: ["Limit: ", navState.searchLimit, " (Use [ or ] to adjust)"] }), _jsx(SearchBar, { isActive: isTyping && !isSearching, onInput: input => {
112
123
  void performSearch(input);
113
124
  } }), (isLoading || isSearching) && (_jsx(Text, { color: theme.colors.accent, children: "Searching..." })), error && _jsx(Text, { color: theme.colors.error, children: error }), !isLoading && navState.hasSearched && (_jsx(SearchResults, { results: results, selectedIndex: navState.selectedResult, isActive: !isTyping, onMixCreated: handleMixCreated, onDownloadStatus: handleDownloadStatus })), !isLoading && navState.hasSearched && results.length === 0 && !error && (_jsx(Text, { color: theme.colors.dim, children: "No results found" })), actionMessage && (_jsx(Text, { color: theme.colors.accent, children: actionMessage })), _jsx(Text, { color: theme.colors.dim, children: isTyping
114
125
  ? 'Type to search, Enter to start, Esc to clear'
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Hook to bind keyboard shortcuts.
3
3
  * This uses a centralized manager to avoid multiple useInput calls and memory leaks.
4
+ * Uses a ref-based approach to always call the latest handler without stale closures.
4
5
  */
5
6
  export declare function useKeyBinding(keys: readonly string[], handler: () => void): void;
6
7
  /**
@@ -1,5 +1,5 @@
1
1
  // Keyboard input handling hook
2
- import { useCallback, useEffect } from 'react';
2
+ import { useEffect, useRef } from 'react';
3
3
  import { useInput } from 'ink';
4
4
  import { logger } from "../services/logger/logger.service.js";
5
5
  import { useKeyboardBlockContext } from "./useKeyboardBlocker.js";
@@ -8,16 +8,18 @@ const registry = new Set();
8
8
  /**
9
9
  * Hook to bind keyboard shortcuts.
10
10
  * This uses a centralized manager to avoid multiple useInput calls and memory leaks.
11
+ * Uses a ref-based approach to always call the latest handler without stale closures.
11
12
  */
12
13
  export function useKeyBinding(keys, handler) {
13
- const memoizedHandler = useCallback(handler, [handler]);
14
+ const handlerRef = useRef(handler);
15
+ handlerRef.current = handler;
14
16
  useEffect(() => {
15
- const entry = { keys, handler: memoizedHandler };
17
+ const entry = { keys, handler: () => handlerRef.current() };
16
18
  registry.add(entry);
17
19
  return () => {
18
20
  registry.delete(entry);
19
21
  };
20
- }, [keys, memoizedHandler]);
22
+ }, [keys]); // keys is the only dep; handlerRef is a stable ref
21
23
  }
22
24
  /**
23
25
  * Global Keyboard Manager Component
@@ -84,7 +86,15 @@ export function KeyboardManager() {
84
86
  return false;
85
87
  if (hasShift && !key.shift && !uppercaseShiftInput)
86
88
  return false;
87
- if (!hasShift && key.shift)
89
+ // Block lowercase-only bindings when shift is active or the input is
90
+ // an uppercase letter (which implies Shift was held).
91
+ // Example: the 'p' (Plugins) binding must not fire when the user
92
+ // presses Shift+P, which should only trigger 'shift+p' (Playlists).
93
+ // Note: `input !== input.toLowerCase()` is true only for uppercase
94
+ // alphabetical characters, avoiding false positives on symbols/digits.
95
+ if (!hasShift &&
96
+ (key.shift ||
97
+ (input.length === 1 && input !== input.toLowerCase())))
88
98
  return false;
89
99
  // Check the actual key
90
100
  if (mainKey === 'up' && key.upArrow)
@@ -5,22 +5,30 @@ import { getConfigService } from "../services/config/config.service.js";
5
5
  export function usePlayer() {
6
6
  const { state, dispatch, ...playerStore } = usePlayerStore();
7
7
  const play = useCallback((track, options) => {
8
- // Clear queue if requested (e.g., playing from search results)
9
8
  if (options?.clearQueue) {
9
+ // When clearing the queue, always dispatch fresh play commands rather
10
+ // than relying on the stale queue state captured in this closure.
11
+ // This fixes a bug where a track already in the queue wouldn't replay
12
+ // after clearQueue because SET_QUEUE_POSITION would be dispatched
13
+ // against the (now-empty) queue.
10
14
  dispatch({ category: 'CLEAR_QUEUE' });
11
- }
12
- // Add to queue if not already there
13
- const isInQueue = state.queue.some(t => t.videoId === track.videoId);
14
- if (!isInQueue) {
15
15
  dispatch({ category: 'ADD_TO_QUEUE', track });
16
- }
17
- // Find position and play
18
- const position = state.queue.findIndex(t => t.videoId === track.videoId);
19
- if (position >= 0) {
20
- dispatch({ category: 'SET_QUEUE_POSITION', position });
16
+ dispatch({ category: 'PLAY', track });
21
17
  }
22
18
  else {
23
- dispatch({ category: 'PLAY', track });
19
+ // Add to queue if not already there
20
+ const isInQueue = state.queue.some(t => t.videoId === track.videoId);
21
+ if (!isInQueue) {
22
+ dispatch({ category: 'ADD_TO_QUEUE', track });
23
+ }
24
+ // Find position and play
25
+ const position = state.queue.findIndex(t => t.videoId === track.videoId);
26
+ if (position >= 0) {
27
+ dispatch({ category: 'SET_QUEUE_POSITION', position });
28
+ }
29
+ else {
30
+ dispatch({ category: 'PLAY', track });
31
+ }
24
32
  }
25
33
  // Add to history
26
34
  const config = getConfigService();
@@ -57,23 +57,68 @@ function Initializer({ flags }) {
57
57
  }
58
58
  function HeadlessLayout({ flags }) {
59
59
  const { play, pause, resume, next, previous } = usePlayer();
60
- const { getTrack } = useYouTubeMusic();
60
+ const { getTrack, getPlaylist, search } = useYouTubeMusic();
61
61
  useEffect(() => {
62
- if (flags?.playTrack) {
63
- void getTrack(flags.playTrack).then(track => {
64
- if (track)
65
- play(track);
66
- });
67
- }
68
- if (flags?.action === 'pause')
69
- pause();
70
- if (flags?.action === 'resume')
71
- resume();
72
- if (flags?.action === 'next')
73
- next();
74
- if (flags?.action === 'previous')
75
- previous();
76
- }, [flags, play, pause, resume, next, previous, getTrack]);
62
+ void (async () => {
63
+ if (flags?.playTrack) {
64
+ const track = await getTrack(flags.playTrack);
65
+ if (!track) {
66
+ console.error(`Track not found: ${flags.playTrack}`);
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+ play(track);
71
+ console.log(`Playing: ${track.title}`);
72
+ return;
73
+ }
74
+ if (flags?.searchQuery) {
75
+ const response = await search(flags.searchQuery, {
76
+ type: 'songs',
77
+ limit: 1,
78
+ });
79
+ const songResult = response?.results.find(result => result.type === 'song');
80
+ if (!songResult) {
81
+ console.error(`No playable tracks found for: "${flags.searchQuery}"`);
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+ const track = songResult.data;
86
+ play(track, { clearQueue: true });
87
+ console.log(`Playing: ${track.title}`);
88
+ return;
89
+ }
90
+ if (flags?.playPlaylist) {
91
+ const playlist = await getPlaylist(flags.playPlaylist);
92
+ const firstTrack = playlist?.tracks[0];
93
+ if (!firstTrack) {
94
+ console.error(`No playable tracks found in playlist: ${flags.playPlaylist}`);
95
+ process.exitCode = 1;
96
+ return;
97
+ }
98
+ play(firstTrack, { clearQueue: true });
99
+ console.log(`Playing playlist "${playlist.name}": ${firstTrack.title}`);
100
+ return;
101
+ }
102
+ if (flags?.action === 'pause')
103
+ pause();
104
+ if (flags?.action === 'resume')
105
+ resume();
106
+ if (flags?.action === 'next')
107
+ next();
108
+ if (flags?.action === 'previous')
109
+ previous();
110
+ })();
111
+ }, [
112
+ flags,
113
+ play,
114
+ pause,
115
+ resume,
116
+ next,
117
+ previous,
118
+ getTrack,
119
+ getPlaylist,
120
+ search,
121
+ ]);
77
122
  return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "green", children: "Headless mode active." }) }));
78
123
  }
79
124
  export default function Main({ flags }) {
@@ -0,0 +1,12 @@
1
+ export type PlaybackDependency = 'mpv' | 'yt-dlp';
2
+ export type InstallPlan = {
3
+ command: string;
4
+ args: string[];
5
+ };
6
+ export declare function buildInstallPlan(platform: NodeJS.Platform, availableManagers: readonly string[], missingDependencies: readonly PlaybackDependency[]): InstallPlan | null;
7
+ export declare function ensurePlaybackDependencies(options: {
8
+ interactive: boolean;
9
+ }): Promise<{
10
+ ready: boolean;
11
+ missing: PlaybackDependency[];
12
+ }>;
@@ -0,0 +1,140 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline/promises';
3
+ const REQUIRED_DEPENDENCIES = ['mpv', 'yt-dlp'];
4
+ function getDependencyExecutable(dependency) {
5
+ if (process.platform === 'win32') {
6
+ return dependency === 'mpv' ? 'mpv.exe' : 'yt-dlp.exe';
7
+ }
8
+ return dependency;
9
+ }
10
+ function renderInstallCommand(plan) {
11
+ return [plan.command, ...plan.args].join(' ');
12
+ }
13
+ function runCommand(command, args, options) {
14
+ return new Promise(resolve => {
15
+ const child = spawn(command, args, {
16
+ stdio: options.stdio,
17
+ });
18
+ child.once('error', () => {
19
+ resolve(false);
20
+ });
21
+ child.once('close', code => {
22
+ resolve(code === 0);
23
+ });
24
+ });
25
+ }
26
+ async function commandExists(command) {
27
+ return runCommand(command, ['--version'], { stdio: 'ignore' });
28
+ }
29
+ async function getMissingDependencies() {
30
+ const missing = [];
31
+ for (const dependency of REQUIRED_DEPENDENCIES) {
32
+ const executable = getDependencyExecutable(dependency);
33
+ const exists = await commandExists(executable);
34
+ if (!exists) {
35
+ missing.push(dependency);
36
+ }
37
+ }
38
+ return missing;
39
+ }
40
+ export function buildInstallPlan(platform, availableManagers, missingDependencies) {
41
+ if (missingDependencies.length === 0) {
42
+ return null;
43
+ }
44
+ const deps = [...missingDependencies];
45
+ const hasManager = (manager) => availableManagers.includes(manager);
46
+ if ((platform === 'darwin' || platform === 'linux') && hasManager('brew')) {
47
+ return { command: 'brew', args: ['install', ...deps] };
48
+ }
49
+ if (platform === 'win32') {
50
+ if (hasManager('scoop')) {
51
+ return { command: 'scoop', args: ['install', ...deps] };
52
+ }
53
+ if (hasManager('choco')) {
54
+ return { command: 'choco', args: ['install', ...deps, '-y'] };
55
+ }
56
+ return null;
57
+ }
58
+ if (platform === 'linux') {
59
+ if (hasManager('apt-get')) {
60
+ return {
61
+ command: 'sudo',
62
+ args: ['apt-get', 'install', '-y', ...deps],
63
+ };
64
+ }
65
+ if (hasManager('pacman')) {
66
+ return {
67
+ command: 'sudo',
68
+ args: ['pacman', '-S', '--needed', ...deps],
69
+ };
70
+ }
71
+ if (hasManager('dnf')) {
72
+ return {
73
+ command: 'sudo',
74
+ args: ['dnf', 'install', '-y', ...deps],
75
+ };
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ async function getAvailablePackageManagers(platform) {
81
+ const candidates = platform === 'win32'
82
+ ? ['scoop', 'choco']
83
+ : platform === 'darwin'
84
+ ? ['brew']
85
+ : ['brew', 'apt-get', 'pacman', 'dnf'];
86
+ const available = [];
87
+ for (const manager of candidates) {
88
+ if (await commandExists(manager)) {
89
+ available.push(manager);
90
+ }
91
+ }
92
+ return available;
93
+ }
94
+ function printManualInstallHelp(missing, plan) {
95
+ console.error(`\nMissing playback dependencies: ${missing.join(', ')}. Install them and re-run the command.`);
96
+ if (plan) {
97
+ console.error(`Suggested install command: ${renderInstallCommand(plan)}\n`);
98
+ return;
99
+ }
100
+ console.error('Suggested install commands:\n macOS: brew install mpv yt-dlp\n Windows: scoop install mpv yt-dlp\n Linux (apt): sudo apt-get install -y mpv yt-dlp\n');
101
+ }
102
+ export async function ensurePlaybackDependencies(options) {
103
+ const missing = await getMissingDependencies();
104
+ if (missing.length === 0) {
105
+ return { ready: true, missing: [] };
106
+ }
107
+ const availableManagers = await getAvailablePackageManagers(process.platform);
108
+ const installPlan = buildInstallPlan(process.platform, availableManagers, missing);
109
+ if (!options.interactive || !installPlan) {
110
+ printManualInstallHelp(missing, installPlan);
111
+ return { ready: false, missing };
112
+ }
113
+ const prompt = `Missing ${missing.join(', ')}. Install now with "${renderInstallCommand(installPlan)}"? [Y/n] `;
114
+ const readline = createInterface({
115
+ input: process.stdin,
116
+ output: process.stdout,
117
+ });
118
+ const response = (await readline.question(prompt)).trim().toLowerCase();
119
+ readline.close();
120
+ if (response === 'n' || response === 'no') {
121
+ printManualInstallHelp(missing, installPlan);
122
+ return { ready: false, missing };
123
+ }
124
+ console.log(`\nInstalling dependencies: ${missing.join(', ')}`);
125
+ const installSuccess = await runCommand(installPlan.command, installPlan.args, {
126
+ stdio: 'inherit',
127
+ });
128
+ if (!installSuccess) {
129
+ console.error('\nAutomatic installation failed.');
130
+ printManualInstallHelp(missing, installPlan);
131
+ return { ready: false, missing };
132
+ }
133
+ const missingAfterInstall = await getMissingDependencies();
134
+ if (missingAfterInstall.length > 0) {
135
+ printManualInstallHelp(missingAfterInstall, installPlan);
136
+ return { ready: false, missing: missingAfterInstall };
137
+ }
138
+ console.log('Playback dependencies installed successfully.\n');
139
+ return { ready: true, missing: [] };
140
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@involvex/youtube-music-cli",
3
- "version": "0.0.26",
3
+ "version": "0.0.28",
4
4
  "description": "- A Commandline music player for youtube-music",
5
5
  "repository": {
6
6
  "type": "git",
package/readme.md CHANGED
@@ -322,6 +322,8 @@ Ensure mpv is installed and in your PATH:
322
322
  mpv --version
323
323
  ```
324
324
 
325
+ On startup, the CLI now checks for `mpv` and `yt-dlp`. In interactive terminals it can prompt to run an install command automatically (with explicit confirmation first).
326
+
325
327
  ### No audio
326
328
 
327
329
  1. Check volume isn't muted (`=` to increase)