@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 +8 -0
- package/dist/source/cli.js +109 -8
- package/dist/source/components/layouts/SearchLayout.js +18 -7
- package/dist/source/hooks/useKeyboard.d.ts +1 -0
- package/dist/source/hooks/useKeyboard.js +15 -5
- package/dist/source/hooks/usePlayer.js +19 -11
- package/dist/source/main.js +61 -16
- package/dist/source/services/player/dependency-check.service.d.ts +12 -0
- package/dist/source/services/player/dependency-check.service.js +140 -0
- package/dist/youtube-music-cli.exe +0 -0
- package/package.json +1 -1
- package/readme.md +2 -0
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)
|
package/dist/source/cli.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
375
|
-
|
|
473
|
+
}
|
|
474
|
+
// Render the app
|
|
475
|
+
render(_jsx(App, { flags: flags }));
|
|
476
|
+
})();
|
|
376
477
|
}
|
|
377
478
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
60
|
-
|
|
62
|
+
const query = navState.searchQuery.trim();
|
|
63
|
+
if (!query || navState.hasSearched) {
|
|
64
|
+
return;
|
|
61
65
|
}
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
14
|
+
const handlerRef = useRef(handler);
|
|
15
|
+
handlerRef.current = handler;
|
|
14
16
|
useEffect(() => {
|
|
15
|
-
const entry = { keys, handler:
|
|
17
|
+
const entry = { keys, handler: () => handlerRef.current() };
|
|
16
18
|
registry.add(entry);
|
|
17
19
|
return () => {
|
|
18
20
|
registry.delete(entry);
|
|
19
21
|
};
|
|
20
|
-
}, [keys
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/dist/source/main.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
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)
|