@involvex/youtube-music-cli 0.0.25 → 0.0.27

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,7 @@
1
+ ## [0.0.27](https://github.com/involvex/youtube-music-cli/compare/v0.0.26...v0.0.27) (2026-02-20)
2
+
3
+ ## [0.0.26](https://github.com/involvex/youtube-music-cli/compare/v0.0.25...v0.0.26) (2026-02-20)
4
+
1
5
  ## [0.0.25](https://github.com/involvex/youtube-music-cli/compare/v0.0.24...v0.0.25) (2026-02-20)
2
6
 
3
7
  ### Features
@@ -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
  }
@@ -19,6 +19,7 @@ function SearchLayout() {
19
19
  const [isSearching, setIsSearching] = useState(false);
20
20
  const [actionMessage, setActionMessage] = useState(null);
21
21
  const actionTimeoutRef = useRef(null);
22
+ const lastAutoSearchedQueryRef = useRef(null);
22
23
  // Handle search action
23
24
  const performSearch = useCallback(async (query) => {
24
25
  if (!query || isSearching)
@@ -56,12 +57,18 @@ function SearchLayout() {
56
57
  useKeyBinding(['h'], goToHistory);
57
58
  // Initial search if query is in state (usually from CLI flags)
58
59
  useEffect(() => {
59
- if (navState.searchQuery && !navState.hasSearched) {
60
- void performSearch(navState.searchQuery);
60
+ const query = navState.searchQuery.trim();
61
+ if (!query || navState.hasSearched) {
62
+ return;
61
63
  }
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
- }, []);
64
+ if (lastAutoSearchedQueryRef.current === query) {
65
+ return;
66
+ }
67
+ lastAutoSearchedQueryRef.current = query;
68
+ queueMicrotask(() => {
69
+ void performSearch(query);
70
+ });
71
+ }, [navState.searchQuery, navState.hasSearched, performSearch]);
65
72
  // Handle going back
66
73
  const goBack = useCallback(() => {
67
74
  if (!isTyping) {
@@ -106,6 +113,7 @@ function SearchLayout() {
106
113
  setResults([]);
107
114
  dispatch({ category: 'SET_HAS_SEARCHED', hasSearched: false });
108
115
  dispatch({ category: 'SET_SEARCH_QUERY', query: '' });
116
+ lastAutoSearchedQueryRef.current = null;
109
117
  };
110
118
  }, [dispatch]);
111
119
  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 => {
@@ -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.25",
3
+ "version": "0.0.27",
4
4
  "description": "- A Commandline music player for youtube-music",
5
5
  "repository": {
6
6
  "type": "git",
package/readme.md CHANGED
@@ -46,9 +46,6 @@ scoop install mpv yt-dlp
46
46
 
47
47
  # With Chocolatey
48
48
  choco install mpv yt-dlp
49
-
50
- # With winget
51
- winget install mpv yt-dlp
52
49
  ```
53
50
 
54
51
  </details>
@@ -99,13 +96,23 @@ bun install -g @involvex/youtube-music-cli
99
96
  brew install involvex/youtube-music-cli/youtube-music-cli
100
97
  ```
101
98
 
102
- ### Winget
99
+ ### GitHub Releases
100
+
101
+ ```bash
102
+ https://github.com/involvex/youtube-music-cli/releases
103
+ ```
104
+
105
+ ### Install Script (bash)
103
106
 
104
107
  ```bash
105
- winget install Involvex.YoutubeMusicCLI
108
+ curl -fssl https://raw.githubusercontent.com/involvex/youtube-music-cli/main/scripts/install.sh | bash
106
109
  ```
107
110
 
108
- > Maintainers: tag pushes trigger `.github/workflows/homebrew-publish.yml` and `.github/workflows/winget-publish.yml`. Homebrew uses the tap format `involvex/youtube-music-cli/youtube-music-cli`, so ensure the formula file exists on the default branch at `Formula/youtube-music-cli.rb` for the tap installation to work. Winget needs `WINGETCREATE_TOKEN` (GitHub PAT with `public_repo`) and a one-time initial submission to winget-pkgs before automated updates can run.
111
+ ### Install Script (PowerShell)
112
+
113
+ ```powershell
114
+ iwr https://raw.githubusercontent.com/involvex/youtube-music-cli/main/scripts/install.ps1 | iex
115
+ ```
109
116
 
110
117
  ### From Source
111
118
 
@@ -315,6 +322,8 @@ Ensure mpv is installed and in your PATH:
315
322
  mpv --version
316
323
  ```
317
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
+
318
327
  ### No audio
319
328
 
320
329
  1. Check volume isn't muted (`=` to increase)