@involvex/youtube-music-cli 0.0.23 → 0.0.25
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 +12 -0
- package/dist/source/cli.js +1 -1
- package/dist/source/components/common/Help.js +10 -2
- package/dist/source/components/common/ShortcutsBar.js +2 -2
- package/dist/source/components/layouts/MainLayout.js +25 -1
- package/dist/source/main.js +1 -10
- package/dist/source/services/player/player.service.js +9 -1
- package/dist/source/services/youtube-music/api.js +226 -41
- package/dist/source/utils/constants.d.ts +1 -0
- package/dist/source/utils/constants.js +1 -0
- package/dist/youtube-music-cli.exe +0 -0
- package/package.json +1 -1
- package/readme.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.0.25](https://github.com/involvex/youtube-music-cli/compare/v0.0.24...v0.0.25) (2026-02-20)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
- add Shift+Q/R shortcuts and improve help view navigation ([0d68ad0](https://github.com/involvex/youtube-music-cli/commit/0d68ad0f6aa2e379b164a4b35a452a592e2ae421))
|
|
6
|
+
|
|
7
|
+
## [0.0.24](https://github.com/involvex/youtube-music-cli/compare/v0.0.23...v0.0.24) (2026-02-20)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- add YouTube URL support for play command ([c09e411](https://github.com/involvex/youtube-music-cli/commit/c09e411dd36e5670727d3914203c5c66de08457b))
|
|
12
|
+
|
|
1
13
|
## [0.0.23](https://github.com/involvex/youtube-music-cli/compare/v0.0.22...v0.0.23) (2026-02-20)
|
|
2
14
|
|
|
3
15
|
## [0.0.22](https://github.com/involvex/youtube-music-cli/compare/v0.0.21...v0.0.22) (2026-02-20)
|
package/dist/source/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { APP_VERSION } from "./utils/constants.js";
|
|
|
15
15
|
const cli = meow(`
|
|
16
16
|
Usage
|
|
17
17
|
$ youtube-music-cli
|
|
18
|
-
$ youtube-music-cli play <track-id>
|
|
18
|
+
$ youtube-music-cli play <track-id|youtube-url>
|
|
19
19
|
$ youtube-music-cli search <query>
|
|
20
20
|
$ youtube-music-cli playlist <playlist-id>
|
|
21
21
|
$ youtube-music-cli suggestions
|
|
@@ -3,8 +3,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
4
|
import { useTheme } from "../../hooks/useTheme.js";
|
|
5
5
|
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
6
|
+
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
7
|
+
import { KEYBINDINGS } from "../../utils/constants.js";
|
|
8
|
+
import { useCallback } from 'react';
|
|
6
9
|
export default function Help() {
|
|
7
10
|
const { theme } = useTheme();
|
|
8
|
-
const { dispatch
|
|
9
|
-
|
|
11
|
+
const { dispatch } = useNavigation();
|
|
12
|
+
const closeHelp = useCallback(() => {
|
|
13
|
+
dispatch({ category: 'GO_BACK' });
|
|
14
|
+
}, [dispatch]);
|
|
15
|
+
useKeyBinding(KEYBINDINGS.BACK, closeHelp);
|
|
16
|
+
useKeyBinding(KEYBINDINGS.SELECT, closeHelp);
|
|
17
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, padding: 1, children: [_jsx(Box, { borderStyle: "single", borderColor: theme.colors.secondary, paddingX: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Keyboard Shortcuts" }) }), _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, color: theme.colors.secondary, children: "Global" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "q" }), " - Quit", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "?" }), " - Help", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "/" }), " - Search", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Shift+P" }), " - Playlists", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "p" }), " - Plugins", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "g" }), " - Suggestions", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "," }), " - Settings", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Shift+Q" }), " - Background Play", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Shift+R" }), " - Resume Control"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Player" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "Space" }), " - Play/Pause", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "n" }), " - Next", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "b" }), " - Previous", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "=" }), " - Volume Up", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "-" }), " - Volume Down", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Shift+S" }), " - Toggle Shuffle", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "r" }), " - Toggle Repeat"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Navigation" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "Up" }), " /", _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.text, children: "k" }), " - Move Up", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Down" }), " /", _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.text, children: "j" }), " - Move Down", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Enter" }), " - Select", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Esc" }), " - Go Back"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Search" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "Tab" }), " - Switch Search Type", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "m" }), " - Create Mix Playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Shift+D" }), " - Download selection", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Esc" }), " - Clear Search", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "[ / ]" }), " - Results Limit"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Playlist" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "a" }), " - Add to Playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "d" }), " - Remove from Playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "c" }), " - Create Playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Shift+D" }), " - Download Playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "D" }), " - Delete Playlist"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "View" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "M" }), " - Toggle Mini Player", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "l" }), " - Lyrics", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "T" }), " - Trending", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "e" }), " - Explore"] }) }), _jsxs(Text, { color: theme.colors.dim, children: ["Press ", _jsx(Text, { color: theme.colors.text, children: "Esc" }), ",", ' ', _jsx(Text, { color: theme.colors.text, children: "Enter" }), ",", ' ', _jsx(Text, { color: theme.colors.text, children: "q" }), ", or", ' ', _jsx(Text, { color: theme.colors.text, children: "?" }), " to close"] })] })] }));
|
|
10
18
|
}
|
|
@@ -27,7 +27,7 @@ export default function ShortcutsBar() {
|
|
|
27
27
|
useKeyBinding(KEYBINDINGS.SHUFFLE, toggleShuffle);
|
|
28
28
|
useKeyBinding(KEYBINDINGS.REPEAT, toggleRepeat);
|
|
29
29
|
// Note: SETTINGS keybinding handled by MainLayout to avoid double-dispatch
|
|
30
|
-
return (_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.dim, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.colors.dim, children: [_jsx(Text, { color: theme.colors.text, children: "\
|
|
30
|
+
return (_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.dim, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.colors.dim, children: [_jsx(Text, { color: theme.colors.text, children: "\u23EF [Space]" }), " |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u23EE [B/\u2190]" }), " |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u23ED [N/\u2192]" }), " |", ' ', _jsx(Text, { color: theme.colors.text, children: "\uD83D\uDD00 [Shift+S]" }), " |", ' ', _jsx(Text, { color: theme.colors.text, children: "\uD83D\uDD01 [R]" }), " |", ' ', _jsx(Text, { color: theme.colors.text, children: "\uD83D\uDCDA [Shift+P]" }), " |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u2B07 [Shift+D]" }), " |", ' ', _jsx(Text, { color: theme.colors.text, children: "\uD83D\uDD0E [/]" }), " |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u2753 [?]" }), " |", ' ', _jsx(Text, { color: theme.colors.text, children: "\uD83D\uDEF0 [Shift+Q]" }), " |", ' ', _jsx(Text, { color: theme.colors.text, children: "\uD83D\uDD0C [Shift+R]" }), " |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u23FB [Q]" })] }), _jsxs(Text, { color: theme.colors.text, children: [_jsx(Text, { color: playerState.shuffle ? theme.colors.primary : theme.colors.dim, children: "\uD83D\uDD00" }), ' ', _jsx(Text, { color: playerState.repeat === 'off'
|
|
31
31
|
? theme.colors.dim
|
|
32
|
-
: theme.colors.secondary, children: playerState.repeat === 'one' ? '🔂' : '🔄' }), ' ', _jsx(Text, { color: theme.colors.dim, children: "\uD83D\uDD0A [
|
|
32
|
+
: theme.colors.secondary, children: playerState.repeat === 'one' ? '🔂' : '🔄' }), ' ', _jsx(Text, { color: theme.colors.dim, children: "\uD83D\uDD0A [=/-]" }), ' ', _jsxs(Text, { color: theme.colors.primary, children: [playerState.volume, "%"] })] })] }));
|
|
33
33
|
}
|
|
@@ -27,9 +27,11 @@ import { Box } from 'ink';
|
|
|
27
27
|
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
28
28
|
import { getPlayerService } from "../../services/player/player.service.js";
|
|
29
29
|
import { getConfigService } from "../../services/config/config.service.js";
|
|
30
|
+
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
30
31
|
function MainLayout() {
|
|
31
32
|
const { theme } = useTheme();
|
|
32
33
|
const { state: navState, dispatch } = useNavigation();
|
|
34
|
+
const { resume } = usePlayer();
|
|
33
35
|
const { columns } = useTerminalSize();
|
|
34
36
|
// Responsive padding based on terminal size
|
|
35
37
|
const getPadding = () => (columns < 100 ? 0 : 1);
|
|
@@ -50,8 +52,12 @@ function MainLayout() {
|
|
|
50
52
|
dispatch({ category: 'NAVIGATE', view: VIEW.SETTINGS });
|
|
51
53
|
}, [dispatch]);
|
|
52
54
|
const goToHelp = useCallback(() => {
|
|
55
|
+
if (navState.currentView === VIEW.HELP) {
|
|
56
|
+
dispatch({ category: 'GO_BACK' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
53
59
|
dispatch({ category: 'NAVIGATE', view: VIEW.HELP });
|
|
54
|
-
}, [dispatch]);
|
|
60
|
+
}, [dispatch, navState.currentView]);
|
|
55
61
|
const handleQuit = useCallback(() => {
|
|
56
62
|
// From player view, quit the app
|
|
57
63
|
if (navState.currentView === VIEW.PLAYER) {
|
|
@@ -91,6 +97,23 @@ function MainLayout() {
|
|
|
91
97
|
// Exit the app
|
|
92
98
|
process.exit(0);
|
|
93
99
|
}, []);
|
|
100
|
+
const handleResumeBackground = useCallback(() => {
|
|
101
|
+
const player = getPlayerService();
|
|
102
|
+
const config = getConfigService();
|
|
103
|
+
const backgroundState = config.getBackgroundPlaybackState();
|
|
104
|
+
if (!backgroundState.enabled || !backgroundState.ipcPath) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
void player
|
|
108
|
+
.reattach(backgroundState.ipcPath)
|
|
109
|
+
.then(() => {
|
|
110
|
+
resume();
|
|
111
|
+
config.clearBackgroundPlaybackState();
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {
|
|
114
|
+
config.clearBackgroundPlaybackState();
|
|
115
|
+
});
|
|
116
|
+
}, [resume]);
|
|
94
117
|
const togglePlayerMode = useCallback(() => {
|
|
95
118
|
dispatch({ category: 'TOGGLE_PLAYER_MODE' });
|
|
96
119
|
}, [dispatch]);
|
|
@@ -108,6 +131,7 @@ function MainLayout() {
|
|
|
108
131
|
useKeyBinding(['e'], goToExplore);
|
|
109
132
|
useKeyBinding(['i'], goToImport);
|
|
110
133
|
useKeyBinding(KEYBINDINGS.DETACH, handleDetach);
|
|
134
|
+
useKeyBinding(KEYBINDINGS.RESUME_BACKGROUND, handleResumeBackground);
|
|
111
135
|
// Memoize the view component to prevent unnecessary remounts
|
|
112
136
|
// Only recreate when currentView actually changes
|
|
113
137
|
const currentView = useMemo(() => {
|
package/dist/source/main.js
CHANGED
|
@@ -15,7 +15,6 @@ import { usePlayer } from "./hooks/usePlayer.js";
|
|
|
15
15
|
import { useYouTubeMusic } from "./hooks/useYouTubeMusic.js";
|
|
16
16
|
import { VIEW } from "./utils/constants.js";
|
|
17
17
|
import { getConfigService } from "./services/config/config.service.js";
|
|
18
|
-
import { getPlayerService } from "./services/player/player.service.js";
|
|
19
18
|
import { getNotificationService } from "./services/notification/notification.service.js";
|
|
20
19
|
function Initializer({ flags }) {
|
|
21
20
|
const { dispatch } = useNavigation();
|
|
@@ -24,20 +23,12 @@ function Initializer({ flags }) {
|
|
|
24
23
|
useEffect(() => {
|
|
25
24
|
// Check for background playback state on startup
|
|
26
25
|
const config = getConfigService();
|
|
27
|
-
const player = getPlayerService();
|
|
28
26
|
const backgroundState = config.getBackgroundPlaybackState();
|
|
29
27
|
if (backgroundState.enabled) {
|
|
30
28
|
// Show notification about background playback
|
|
31
29
|
const notification = getNotificationService();
|
|
32
30
|
notification.setEnabled(true);
|
|
33
|
-
void notification.notify('Background Playback Active', 'Press R to resume control');
|
|
34
|
-
// Try to reattach to the existing mpv process
|
|
35
|
-
if (backgroundState.ipcPath) {
|
|
36
|
-
void player.reattach(backgroundState.ipcPath).catch(() => {
|
|
37
|
-
// Reattach failed, clear the state
|
|
38
|
-
config.clearBackgroundPlaybackState();
|
|
39
|
-
});
|
|
40
|
-
}
|
|
31
|
+
void notification.notify('Background Playback Active', 'Press Shift+R to resume control');
|
|
41
32
|
}
|
|
42
33
|
if (flags?.showSuggestions) {
|
|
43
34
|
dispatch({ category: 'NAVIGATE', view: VIEW.SUGGESTIONS });
|
|
@@ -235,7 +235,11 @@ class PlayerService {
|
|
|
235
235
|
mpvArgs.push(playUrl);
|
|
236
236
|
// Capture process in local var so stale exit handlers from a killed
|
|
237
237
|
// process don't overwrite state belonging to a newly-spawned process.
|
|
238
|
-
const spawnedProcess = spawn(this.getMpvCommand(), mpvArgs
|
|
238
|
+
const spawnedProcess = spawn(this.getMpvCommand(), mpvArgs, {
|
|
239
|
+
detached: true,
|
|
240
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
241
|
+
windowsHide: true,
|
|
242
|
+
});
|
|
239
243
|
this.mpvProcess = spawnedProcess;
|
|
240
244
|
if (!spawnedProcess.stdout || !spawnedProcess.stderr) {
|
|
241
245
|
throw new Error('Failed to create mpv process streams');
|
|
@@ -373,6 +377,10 @@ class PlayerService {
|
|
|
373
377
|
ipcPath: this.ipcPath,
|
|
374
378
|
currentUrl: this.currentUrl,
|
|
375
379
|
};
|
|
380
|
+
if (this.mpvProcess) {
|
|
381
|
+
// Allow detached mpv process to survive after CLI exits.
|
|
382
|
+
this.mpvProcess.unref();
|
|
383
|
+
}
|
|
376
384
|
// Clear references but DON'T kill mpv process - it keeps playing
|
|
377
385
|
this.mpvProcess = null;
|
|
378
386
|
this.ipcSocket = null;
|
|
@@ -1,10 +1,105 @@
|
|
|
1
|
-
import { Innertube } from 'youtubei.js';
|
|
1
|
+
import { Innertube, Log } from 'youtubei.js';
|
|
2
2
|
import { logger } from "../logger/logger.service.js";
|
|
3
3
|
import { getSearchCache } from "../cache/cache.service.js";
|
|
4
4
|
// Initialize YouTube client
|
|
5
5
|
let ytClient = null;
|
|
6
|
+
function toMusicSearchType(searchType) {
|
|
7
|
+
switch (searchType) {
|
|
8
|
+
case 'songs': {
|
|
9
|
+
return 'song';
|
|
10
|
+
}
|
|
11
|
+
case 'albums': {
|
|
12
|
+
return 'album';
|
|
13
|
+
}
|
|
14
|
+
case 'artists': {
|
|
15
|
+
return 'artist';
|
|
16
|
+
}
|
|
17
|
+
case 'playlists': {
|
|
18
|
+
return 'playlist';
|
|
19
|
+
}
|
|
20
|
+
default: {
|
|
21
|
+
return 'all';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function getMusicShelfItems(shelf) {
|
|
26
|
+
if (!shelf || typeof shelf !== 'object') {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const contents = shelf.contents;
|
|
30
|
+
if (!Array.isArray(contents)) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
return contents.filter((item) => !!item && typeof item === 'object');
|
|
34
|
+
}
|
|
35
|
+
function parseVideoId(value) {
|
|
36
|
+
const trimmedValue = value.trim();
|
|
37
|
+
if (!trimmedValue) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (!trimmedValue.includes('://') && !trimmedValue.includes('/')) {
|
|
41
|
+
return trimmedValue;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const parsedUrl = new URL(trimmedValue);
|
|
45
|
+
const vParam = parsedUrl.searchParams.get('v');
|
|
46
|
+
if (vParam) {
|
|
47
|
+
return vParam;
|
|
48
|
+
}
|
|
49
|
+
const host = parsedUrl.hostname.toLowerCase();
|
|
50
|
+
const isYouTubeHost = host === 'youtu.be' ||
|
|
51
|
+
host === 'youtube.com' ||
|
|
52
|
+
host.endsWith('.youtube.com') ||
|
|
53
|
+
host === 'music.youtube.com';
|
|
54
|
+
if (!isYouTubeHost) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (host === 'youtu.be') {
|
|
58
|
+
const pathId = parsedUrl.pathname.split('/').filter(Boolean)[0];
|
|
59
|
+
if (pathId) {
|
|
60
|
+
return pathId;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const pathId = parsedUrl.pathname
|
|
64
|
+
.split('/')
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.find(part => part.length >= 8);
|
|
67
|
+
return pathId ?? null;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function toTrack(item) {
|
|
74
|
+
const rawId = item.id?.trim() ?? '';
|
|
75
|
+
const videoId = rawId ? parseVideoId(rawId) : null;
|
|
76
|
+
if (!videoId) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const artists = item.artists && item.artists.length > 0
|
|
80
|
+
? item.artists.map(artist => ({
|
|
81
|
+
artistId: artist.channel_id || artist.id || '',
|
|
82
|
+
name: artist.name ?? 'Unknown',
|
|
83
|
+
}))
|
|
84
|
+
: [
|
|
85
|
+
{
|
|
86
|
+
artistId: item.author?.channel_id || item.author?.id || '',
|
|
87
|
+
name: item.author?.name ?? 'Unknown',
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
return {
|
|
91
|
+
videoId,
|
|
92
|
+
title: item.title || item.name || 'Unknown',
|
|
93
|
+
artists,
|
|
94
|
+
duration: typeof item.duration === 'number'
|
|
95
|
+
? item.duration
|
|
96
|
+
: (item.duration?.seconds ?? 0),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
6
99
|
async function getClient() {
|
|
7
100
|
if (!ytClient) {
|
|
101
|
+
// Suppress noisy youtubei.js parser warnings in TUI output.
|
|
102
|
+
Log.setLevel(Log.Level.ERROR);
|
|
8
103
|
ytClient = await Innertube.create();
|
|
9
104
|
}
|
|
10
105
|
return ytClient;
|
|
@@ -13,7 +108,8 @@ class MusicService {
|
|
|
13
108
|
searchCache = getSearchCache();
|
|
14
109
|
async search(query, options = {}) {
|
|
15
110
|
const searchType = options.type || 'all';
|
|
16
|
-
const
|
|
111
|
+
const resultLimit = options.limit ?? 20;
|
|
112
|
+
const cacheKey = `search:${searchType}:${resultLimit}:${query}`;
|
|
17
113
|
// Return cached result if available
|
|
18
114
|
const cached = this.searchCache.get(cacheKey);
|
|
19
115
|
if (cached) {
|
|
@@ -26,17 +122,97 @@ class MusicService {
|
|
|
26
122
|
const results = [];
|
|
27
123
|
try {
|
|
28
124
|
const yt = await getClient();
|
|
29
|
-
const
|
|
30
|
-
|
|
125
|
+
const musicSearch = (await yt.music.search(query, {
|
|
126
|
+
type: toMusicSearchType(searchType),
|
|
127
|
+
}));
|
|
31
128
|
if (searchType === 'all' || searchType === 'songs') {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
129
|
+
const songItems = [
|
|
130
|
+
...getMusicShelfItems(musicSearch.songs),
|
|
131
|
+
...getMusicShelfItems(musicSearch.videos),
|
|
132
|
+
];
|
|
133
|
+
for (const item of songItems) {
|
|
134
|
+
const track = toTrack(item);
|
|
135
|
+
if (!track) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
results.push({
|
|
139
|
+
type: 'song',
|
|
140
|
+
data: track,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (searchType === 'all' || searchType === 'playlists') {
|
|
145
|
+
for (const playlist of getMusicShelfItems(musicSearch.playlists)) {
|
|
146
|
+
const playlistId = playlist.id?.trim();
|
|
147
|
+
if (!playlistId) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
results.push({
|
|
151
|
+
type: 'playlist',
|
|
152
|
+
data: {
|
|
153
|
+
playlistId,
|
|
154
|
+
name: playlist.title || playlist.name || 'Unknown Playlist',
|
|
155
|
+
tracks: [],
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (searchType === 'all' || searchType === 'artists') {
|
|
161
|
+
for (const artist of getMusicShelfItems(musicSearch.artists)) {
|
|
162
|
+
const artistId = artist.id?.trim() ||
|
|
163
|
+
artist.author?.channel_id ||
|
|
164
|
+
artist.author?.id ||
|
|
165
|
+
'';
|
|
166
|
+
if (!artistId) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
results.push({
|
|
170
|
+
type: 'artist',
|
|
171
|
+
data: {
|
|
172
|
+
artistId,
|
|
173
|
+
name: artist.name ||
|
|
174
|
+
artist.title ||
|
|
175
|
+
artist.author?.name ||
|
|
176
|
+
'Unknown Artist',
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (searchType === 'all' || searchType === 'albums') {
|
|
182
|
+
for (const album of getMusicShelfItems(musicSearch.albums)) {
|
|
183
|
+
const albumId = album.id?.trim();
|
|
184
|
+
if (!albumId) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
results.push({
|
|
188
|
+
type: 'album',
|
|
189
|
+
data: {
|
|
190
|
+
albumId,
|
|
191
|
+
name: album.title || album.name || 'Unknown Album',
|
|
192
|
+
artists: (album.artists ?? []).map(artist => ({
|
|
193
|
+
artistId: artist.channel_id || artist.id || '',
|
|
194
|
+
name: artist.name ?? 'Unknown',
|
|
195
|
+
})),
|
|
196
|
+
tracks: [],
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (results.length === 0) {
|
|
202
|
+
const search = (await yt.search(query));
|
|
203
|
+
if (searchType === 'all' || searchType === 'songs') {
|
|
204
|
+
const videos = search.videos;
|
|
205
|
+
if (videos) {
|
|
206
|
+
for (const video of videos) {
|
|
207
|
+
const rawVideoId = video.id || video.video_id || '';
|
|
208
|
+
const videoId = parseVideoId(rawVideoId);
|
|
209
|
+
if ((!video.type && !rawVideoId) || !videoId) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
36
212
|
results.push({
|
|
37
213
|
type: 'song',
|
|
38
214
|
data: {
|
|
39
|
-
videoId
|
|
215
|
+
videoId,
|
|
40
216
|
title: (typeof video.title === 'string'
|
|
41
217
|
? video.title
|
|
42
218
|
: video.title?.text) || 'Unknown',
|
|
@@ -56,46 +232,50 @@ class MusicService {
|
|
|
56
232
|
}
|
|
57
233
|
}
|
|
58
234
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
:
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
}
|
|
235
|
+
if (searchType === 'all' || searchType === 'playlists') {
|
|
236
|
+
const playlists = search.playlists;
|
|
237
|
+
if (playlists) {
|
|
238
|
+
for (const playlist of playlists) {
|
|
239
|
+
results.push({
|
|
240
|
+
type: 'playlist',
|
|
241
|
+
data: {
|
|
242
|
+
playlistId: playlist.id || '',
|
|
243
|
+
name: (typeof playlist.title === 'string'
|
|
244
|
+
? playlist.title
|
|
245
|
+
: playlist.title?.text) || 'Unknown Playlist',
|
|
246
|
+
tracks: [],
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
74
250
|
}
|
|
75
251
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
}
|
|
252
|
+
if (searchType === 'all' || searchType === 'artists') {
|
|
253
|
+
const channels = search.channels;
|
|
254
|
+
if (channels) {
|
|
255
|
+
for (const channel of channels) {
|
|
256
|
+
results.push({
|
|
257
|
+
type: 'artist',
|
|
258
|
+
data: {
|
|
259
|
+
artistId: channel.id || channel.channelId || '',
|
|
260
|
+
name: (typeof channel.author === 'string'
|
|
261
|
+
? channel.author
|
|
262
|
+
: channel.author?.name) || 'Unknown Artist',
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
90
266
|
}
|
|
91
267
|
}
|
|
92
268
|
}
|
|
93
269
|
}
|
|
94
270
|
catch (error) {
|
|
95
|
-
|
|
271
|
+
logger.error('MusicService', 'Search failed', {
|
|
272
|
+
query,
|
|
273
|
+
searchType,
|
|
274
|
+
error: error instanceof Error ? error.message : String(error),
|
|
275
|
+
});
|
|
96
276
|
}
|
|
97
277
|
const response = {
|
|
98
|
-
results,
|
|
278
|
+
results: results.slice(0, resultLimit),
|
|
99
279
|
hasMore: false,
|
|
100
280
|
};
|
|
101
281
|
// Cache the result
|
|
@@ -103,8 +283,13 @@ class MusicService {
|
|
|
103
283
|
return response;
|
|
104
284
|
}
|
|
105
285
|
async getTrack(videoId) {
|
|
286
|
+
const normalizedVideoId = parseVideoId(videoId);
|
|
287
|
+
if (!normalizedVideoId) {
|
|
288
|
+
logger.warn('MusicService', 'Invalid track id/url provided', { videoId });
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
106
291
|
return {
|
|
107
|
-
videoId,
|
|
292
|
+
videoId: normalizedVideoId,
|
|
108
293
|
title: 'Unknown Track',
|
|
109
294
|
artists: [],
|
|
110
295
|
};
|
|
@@ -37,6 +37,7 @@ export declare const KEYBINDINGS: {
|
|
|
37
37
|
readonly SETTINGS: readonly [","];
|
|
38
38
|
readonly PLUGINS: readonly ["p"];
|
|
39
39
|
readonly DETACH: readonly ["shift+q"];
|
|
40
|
+
readonly RESUME_BACKGROUND: readonly ["shift+r"];
|
|
40
41
|
readonly PLAY_PAUSE: readonly [" "];
|
|
41
42
|
readonly NEXT: readonly ["n", "right"];
|
|
42
43
|
readonly PREVIOUS: readonly ["b", "left"];
|
|
Binary file
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -105,7 +105,7 @@ brew install involvex/youtube-music-cli/youtube-music-cli
|
|
|
105
105
|
winget install Involvex.YoutubeMusicCLI
|
|
106
106
|
```
|
|
107
107
|
|
|
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.
|
|
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.
|
|
109
109
|
|
|
110
110
|
### From Source
|
|
111
111
|
|
|
@@ -131,7 +131,7 @@ youtube-music-cli
|
|
|
131
131
|
|
|
132
132
|
```bash
|
|
133
133
|
# Play a specific track
|
|
134
|
-
youtube-music-cli play <video-id>
|
|
134
|
+
youtube-music-cli play <video-id|youtube-url>
|
|
135
135
|
|
|
136
136
|
# Search for music
|
|
137
137
|
youtube-music-cli search "artist or song name"
|