@involvex/youtube-music-cli 0.0.9 → 0.0.12

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,21 @@
1
+ ## [0.0.12](https://github.com/involvex/youtube-music-cli/compare/v0.0.11...v0.0.12) (2026-02-18)
2
+
3
+ ## [0.0.11](https://github.com/involvex/youtube-music-cli/compare/v0.0.10...v0.0.11) (2026-02-18)
4
+
5
+ ### Features
6
+
7
+ - **download:** add track, artist, and playlist downloads ([42298d2](https://github.com/involvex/youtube-music-cli/commit/42298d2fd35ba71ad841f58e140f4c53b3222ed7))
8
+
9
+ ## [0.0.10](https://github.com/involvex/youtube-music-cli/compare/v0.0.9...v0.0.10) (2026-02-18)
10
+
11
+ ### Bug Fixes
12
+
13
+ - mpv resume not working and double-process on track change ([fd04bda](https://github.com/involvex/youtube-music-cli/commit/fd04bdaa852f366eb92d265415ec18794ea5dfc3))
14
+
15
+ ### Features
16
+
17
+ - dynamic mix playlist creation from search results ([d019048](https://github.com/involvex/youtube-music-cli/commit/d019048c4450d633003c8026ac43e92a743d87a0))
18
+
1
19
  ## [0.0.9](https://github.com/involvex/youtube-music-cli/compare/v0.0.8...v0.0.9) (2026-02-18)
2
20
 
3
21
  ### Features
@@ -6,5 +6,5 @@ import { useNavigation } from "../../hooks/useNavigation.js";
6
6
  export default function Help() {
7
7
  const { theme } = useTheme();
8
8
  const { dispatch: _dispatch } = useNavigation();
9
- 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 / Esc" }), " - 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, { 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: "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: "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: "D" }), " - Delete Playlist"] }) }), _jsxs(Text, { color: theme.colors.dim, children: ["Press ", _jsx(Text, { color: theme.colors.text, children: "Esc" }), " or", ' ', _jsx(Text, { color: theme.colors.text, children: "?" }), " to close"] })] })] }));
9
+ 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 / Esc" }), " - 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, { 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: "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" }), " or", ' ', _jsx(Text, { color: theme.colors.text, children: "?" }), " to close"] })] })] }));
10
10
  }
@@ -1,15 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Shortcuts bar component
3
3
  import { Box, Text } from 'ink';
4
- import { useCallback } from 'react';
5
4
  import { usePlayer } from "../../hooks/usePlayer.js";
6
- import { useNavigation } from "../../hooks/useNavigation.js";
7
5
  import { useTheme } from "../../hooks/useTheme.js";
8
6
  import { useKeyBinding } from "../../hooks/useKeyboard.js";
9
7
  import { KEYBINDINGS } from "../../utils/constants.js";
10
8
  export default function ShortcutsBar() {
11
9
  const { theme } = useTheme();
12
- const { dispatch: navDispatch } = useNavigation();
13
10
  const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, volumeFineUp, volumeFineDown, } = usePlayer();
14
11
  // Register key bindings globally
15
12
  const handlePlayPause = () => {
@@ -20,9 +17,6 @@ export default function ShortcutsBar() {
20
17
  resume();
21
18
  }
22
19
  };
23
- const goConfig = useCallback(() => {
24
- navDispatch({ category: 'NAVIGATE', view: 'config' });
25
- }, [navDispatch]);
26
20
  useKeyBinding(KEYBINDINGS.PLAY_PAUSE, handlePlayPause);
27
21
  useKeyBinding(KEYBINDINGS.NEXT, next);
28
22
  useKeyBinding(KEYBINDINGS.PREVIOUS, previous);
@@ -30,6 +24,6 @@ export default function ShortcutsBar() {
30
24
  useKeyBinding(KEYBINDINGS.VOLUME_DOWN, volumeDown);
31
25
  useKeyBinding(KEYBINDINGS.VOLUME_FINE_UP, volumeFineUp);
32
26
  useKeyBinding(KEYBINDINGS.VOLUME_FINE_DOWN, volumeFineDown);
33
- useKeyBinding(KEYBINDINGS.SETTINGS, goConfig);
34
- return (_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.dim, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.colors.dim, children: ["Shortcuts: ", _jsx(Text, { color: theme.colors.text, children: "Space" }), " Play/Pause |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u2192" }), " Next |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u2190" }), " Previous |", ' ', _jsx(Text, { color: theme.colors.text, children: "Shift+P" }), " Playlists |", ' ', _jsx(Text, { color: theme.colors.text, children: "p" }), " Plugins |", ' ', _jsx(Text, { color: theme.colors.text, children: "/" }), " Search |", ' ', _jsx(Text, { color: theme.colors.text, children: "," }), " Settings |", ' ', _jsx(Text, { color: theme.colors.text, children: "?" }), " Help |", ' ', _jsx(Text, { color: theme.colors.text, children: "q" }), " Quit"] }), _jsxs(Text, { color: theme.colors.text, children: [_jsx(Text, { color: theme.colors.dim, children: "[=/" }), "-", _jsx(Text, { color: theme.colors.dim, children: "]" }), " Vol:", ' ', _jsxs(Text, { color: theme.colors.primary, children: [playerState.volume, "%"] })] })] }));
27
+ // Note: SETTINGS keybinding handled by MainLayout to avoid double-dispatch
28
+ return (_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.dim, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.colors.dim, children: ["Shortcuts: ", _jsx(Text, { color: theme.colors.text, children: "Space" }), " Play/Pause |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u2192" }), " Next |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u2190" }), " Prev |", ' ', _jsx(Text, { color: theme.colors.text, children: "Shift+P" }), " Playlists |", ' ', _jsx(Text, { color: theme.colors.text, children: "Shift+D" }), " Download |", ' ', _jsx(Text, { color: theme.colors.text, children: "m" }), " Mix |", ' ', _jsx(Text, { color: theme.colors.text, children: "M" }), " Mini |", ' ', _jsx(Text, { color: theme.colors.text, children: "/" }), " Search |", ' ', _jsx(Text, { color: theme.colors.text, children: "?" }), " Help |", ' ', _jsx(Text, { color: theme.colors.text, children: "q" }), " Quit"] }), _jsxs(Text, { color: theme.colors.text, children: [_jsx(Text, { color: theme.colors.dim, children: "[=/" }), "-", _jsx(Text, { color: theme.colors.dim, children: "]" }), " Vol:", ' ', _jsxs(Text, { color: theme.colors.primary, children: [playerState.volume, "%"] })] })] }));
35
29
  }
@@ -76,7 +76,7 @@ function MainLayout() {
76
76
  useKeyBinding(KEYBINDINGS.SUGGESTIONS, goToSuggestions);
77
77
  useKeyBinding(KEYBINDINGS.SETTINGS, goToSettings);
78
78
  useKeyBinding(KEYBINDINGS.HELP, goToHelp);
79
- useKeyBinding(['m'], togglePlayerMode);
79
+ useKeyBinding(['M'], togglePlayerMode);
80
80
  useKeyBinding(['l'], goToLyrics);
81
81
  useKeyBinding(['T'], goToTrending);
82
82
  useKeyBinding(['e'], goToExplore);
@@ -17,8 +17,8 @@ function SearchLayout() {
17
17
  const [results, setResults] = useState([]);
18
18
  const [isTyping, setIsTyping] = useState(true);
19
19
  const [isSearching, setIsSearching] = useState(false);
20
- const [mixMessage, setMixMessage] = useState(null);
21
- const mixTimeoutRef = useRef(null);
20
+ const [actionMessage, setActionMessage] = useState(null);
21
+ const actionTimeoutRef = useRef(null);
22
22
  // Handle search action
23
23
  const performSearch = useCallback(async (query) => {
24
24
  if (!query || isSearching)
@@ -74,19 +74,29 @@ function SearchLayout() {
74
74
  }, [isTyping, dispatch]);
75
75
  useKeyBinding(KEYBINDINGS.BACK, goBack);
76
76
  const handleMixCreated = useCallback((message) => {
77
- setMixMessage(message);
78
- if (mixTimeoutRef.current) {
79
- clearTimeout(mixTimeoutRef.current);
77
+ setActionMessage(message);
78
+ if (actionTimeoutRef.current) {
79
+ clearTimeout(actionTimeoutRef.current);
80
80
  }
81
- mixTimeoutRef.current = setTimeout(() => {
82
- setMixMessage(null);
83
- mixTimeoutRef.current = null;
81
+ actionTimeoutRef.current = setTimeout(() => {
82
+ setActionMessage(null);
83
+ actionTimeoutRef.current = null;
84
+ }, 4000);
85
+ }, []);
86
+ const handleDownloadStatus = useCallback((message) => {
87
+ setActionMessage(message);
88
+ if (actionTimeoutRef.current) {
89
+ clearTimeout(actionTimeoutRef.current);
90
+ }
91
+ actionTimeoutRef.current = setTimeout(() => {
92
+ setActionMessage(null);
93
+ actionTimeoutRef.current = null;
84
94
  }, 4000);
85
95
  }, []);
86
96
  useEffect(() => {
87
97
  return () => {
88
- if (mixTimeoutRef.current) {
89
- clearTimeout(mixTimeoutRef.current);
98
+ if (actionTimeoutRef.current) {
99
+ clearTimeout(actionTimeoutRef.current);
90
100
  }
91
101
  };
92
102
  }, []);
@@ -100,8 +110,8 @@ function SearchLayout() {
100
110
  }, [dispatch]);
101
111
  return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.secondary, paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.colors.primary, children: "Search" }), _jsxs(Text, { color: theme.colors.dim, children: [' ', "| Limit: ", navState.searchLimit, " (Use [ or ] to adjust)"] })] }), _jsx(SearchBar, { isActive: isTyping && !isSearching, onInput: input => {
102
112
  void performSearch(input);
103
- } }), (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 })), !isLoading && navState.hasSearched && results.length === 0 && !error && (_jsx(Text, { color: theme.colors.dim, children: "No results found" })), mixMessage && _jsx(Text, { color: theme.colors.accent, children: mixMessage }), _jsx(Text, { color: theme.colors.dim, children: isTyping
113
+ } }), (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
104
114
  ? 'Type to search, Enter to start, Esc to clear'
105
- : `Arrows to navigate, Enter to play, M to create mix, ]/[ more/fewer results (${navState.searchLimit}), H for history, Esc to type` })] }));
115
+ : `Arrows to navigate, Enter to play, M mix, Shift+D download, ]/[ more/fewer results (${navState.searchLimit}), H history, Esc to type` })] }));
106
116
  }
107
117
  export default React.memo(SearchLayout);
@@ -10,15 +10,19 @@ import { usePlaylist } from "../../hooks/usePlaylist.js";
10
10
  import { useTheme } from "../../hooks/useTheme.js";
11
11
  import { useKeyboardBlocker } from "../../hooks/useKeyboardBlocker.js";
12
12
  import { KEYBINDINGS } from "../../utils/constants.js";
13
+ import { getDownloadService } from "../../services/download/download.service.js";
13
14
  export default function PlaylistList() {
14
15
  const { theme } = useTheme();
15
16
  const { play, setQueue } = usePlayer();
16
17
  const { dispatch } = useNavigation();
17
- const { playlists, createPlaylist, renamePlaylist } = usePlaylist();
18
+ const downloadService = getDownloadService();
19
+ const { playlists, createPlaylist, renamePlaylist, deletePlaylist } = usePlaylist();
18
20
  const [selectedIndex, setSelectedIndex] = useState(0);
19
21
  const [lastCreated, setLastCreated] = useState(null);
20
22
  const [renamingPlaylistId, setRenamingPlaylistId] = useState(null);
21
23
  const [renameValue, setRenameValue] = useState('');
24
+ const [downloadStatus, setDownloadStatus] = useState(null);
25
+ const [isDownloading, setIsDownloading] = useState(false);
22
26
  useKeyboardBlocker(renamingPlaylistId !== null);
23
27
  const handleCreate = useCallback(() => {
24
28
  const name = `Playlist ${playlists.length + 1}`;
@@ -67,16 +71,68 @@ export default function PlaylistList() {
67
71
  }
68
72
  dispatch({ category: 'GO_BACK' });
69
73
  }, [dispatch, renamingPlaylistId]);
74
+ const handleDelete = useCallback(() => {
75
+ if (renamingPlaylistId)
76
+ return;
77
+ const playlist = playlists[selectedIndex];
78
+ if (!playlist)
79
+ return;
80
+ deletePlaylist(playlist.playlistId);
81
+ setSelectedIndex(prev => Math.max(0, prev - 1));
82
+ }, [deletePlaylist, playlists, renamingPlaylistId, selectedIndex]);
83
+ const handleDownload = useCallback(async () => {
84
+ if (renamingPlaylistId)
85
+ return;
86
+ if (isDownloading) {
87
+ setDownloadStatus('Download already in progress. Please wait.');
88
+ return;
89
+ }
90
+ const playlist = playlists[selectedIndex];
91
+ if (!playlist)
92
+ return;
93
+ const config = downloadService.getConfig();
94
+ if (!config.enabled) {
95
+ setDownloadStatus('Downloads are disabled. Enable Download Feature in Settings.');
96
+ return;
97
+ }
98
+ const target = downloadService.resolvePlaylistTarget(playlist);
99
+ if (target.tracks.length === 0) {
100
+ setDownloadStatus(`No tracks to download in "${playlist.name}".`);
101
+ return;
102
+ }
103
+ setDownloadStatus(`Downloading ${target.tracks.length} track(s) from "${playlist.name}"... this can take a few minutes.`);
104
+ try {
105
+ setIsDownloading(true);
106
+ const summary = await downloadService.downloadTracks(target.tracks);
107
+ setDownloadStatus(`Downloaded ${summary.downloaded}, skipped ${summary.skipped}, failed ${summary.failed}.`);
108
+ }
109
+ catch (error) {
110
+ setDownloadStatus(error instanceof Error ? error.message : 'Failed to download playlist.');
111
+ }
112
+ finally {
113
+ setIsDownloading(false);
114
+ }
115
+ }, [
116
+ downloadService,
117
+ isDownloading,
118
+ playlists,
119
+ renamingPlaylistId,
120
+ selectedIndex,
121
+ ]);
70
122
  useKeyBinding(KEYBINDINGS.UP, navigateUp);
71
123
  useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
72
124
  useKeyBinding(KEYBINDINGS.SELECT, startPlaylist);
73
125
  useKeyBinding(['r'], handleRename);
74
126
  useKeyBinding(KEYBINDINGS.CREATE_PLAYLIST, handleCreate);
127
+ useKeyBinding(KEYBINDINGS.DELETE_PLAYLIST, handleDelete);
75
128
  useKeyBinding(KEYBINDINGS.BACK, handleBack);
129
+ useKeyBinding(KEYBINDINGS.DOWNLOAD, () => {
130
+ void handleDownload();
131
+ });
76
132
  return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Playlists" }) }), playlists.length === 0 ? (_jsx(Text, { color: theme.colors.dim, children: "No playlists yet" })) : (playlists.map((playlist, index) => {
77
133
  const isSelected = index === selectedIndex;
78
134
  const isRenaming = renamingPlaylistId === playlist.playlistId && isSelected;
79
135
  const rowBackground = isSelected ? theme.colors.secondary : undefined;
80
136
  return (_jsxs(Box, { paddingX: 1, backgroundColor: rowBackground, children: [_jsxs(Text, { color: isSelected ? theme.colors.background : theme.colors.primary, bold: isSelected, children: [index + 1, "."] }), _jsx(Text, { children: " " }), _jsxs(Box, { flexDirection: "column", children: [isRenaming ? (_jsx(TextInput, { value: renameValue, onChange: setRenameValue, onSubmit: handleRenameSubmit, placeholder: "Playlist name", focus: true })) : (_jsx(Text, { color: isSelected ? theme.colors.background : theme.colors.text, bold: isSelected, children: playlist.name })), _jsx(Text, { color: theme.colors.dim, children: ` (${playlist.tracks.length} tracks)` })] })] }, playlist.playlistId));
81
- })), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: theme.colors.dim, children: [_jsx(Text, { color: theme.colors.text, children: "Enter" }), " to play playlist |", ' ', _jsx(Text, { color: theme.colors.text, children: "r" }), " to rename |", ' ', _jsx(Text, { color: theme.colors.text, children: "c" }), " to create |", ' ', _jsx(Text, { color: theme.colors.text, children: "Esc" }), " to go back"] }), lastCreated && (_jsxs(Text, { color: theme.colors.accent, children: [" Created ", lastCreated] }))] })] }));
137
+ })), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: theme.colors.dim, children: [_jsx(Text, { color: theme.colors.text, children: "Enter" }), " to play |", ' ', _jsx(Text, { color: theme.colors.text, children: "r" }), " rename |", ' ', _jsx(Text, { color: theme.colors.text, children: "c" }), " create |", ' ', _jsx(Text, { color: theme.colors.text, children: "Shift+D" }), " download |", ' ', _jsx(Text, { color: theme.colors.text, children: "D" }), " delete |", ' ', _jsx(Text, { color: theme.colors.text, children: "Esc" }), " back"] }), lastCreated && (_jsxs(Text, { color: theme.colors.accent, children: [" Created ", lastCreated] })), downloadStatus && (_jsx(Text, { color: theme.colors.accent, children: downloadStatus }))] })] }));
82
138
  }
@@ -5,7 +5,8 @@ type Props = {
5
5
  selectedIndex: number;
6
6
  isActive?: boolean;
7
7
  onMixCreated?: (message: string) => void;
8
+ onDownloadStatus?: (message: string) => void;
8
9
  };
9
- declare function SearchResults({ results, selectedIndex, isActive, onMixCreated, }: Props): import("react/jsx-runtime").JSX.Element | null;
10
+ declare function SearchResults({ results, selectedIndex, isActive, onMixCreated, onDownloadStatus, }: Props): import("react/jsx-runtime").JSX.Element | null;
10
11
  declare const _default: React.MemoExoticComponent<typeof SearchResults>;
11
12
  export default _default;
@@ -7,23 +7,28 @@ import { useNavigation } from "../../hooks/useNavigation.js";
7
7
  import { useKeyBinding } from "../../hooks/useKeyboard.js";
8
8
  import { usePlayer } from "../../hooks/usePlayer.js";
9
9
  import { usePlaylist } from "../../hooks/usePlaylist.js";
10
- import { KEYBINDINGS } from "../../utils/constants.js";
10
+ import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
11
11
  import { truncate } from "../../utils/format.js";
12
- import { useCallback, useRef, useEffect } from 'react';
12
+ import { useCallback, useRef, useEffect, useState } from 'react';
13
13
  import { logger } from "../../services/logger/logger.service.js";
14
14
  import { useTerminalSize } from "../../hooks/useTerminalSize.js";
15
15
  import { getMusicService } from "../../services/youtube-music/api.js";
16
+ import { getDownloadService } from "../../services/download/download.service.js";
16
17
  // Generate unique component instance ID
17
18
  let instanceCounter = 0;
18
- function SearchResults({ results, selectedIndex, isActive = true, onMixCreated, }) {
19
+ function SearchResults({ results, selectedIndex, isActive = true, onMixCreated, onDownloadStatus, }) {
19
20
  const { theme } = useTheme();
20
21
  const { dispatch } = useNavigation();
21
22
  const { play, dispatch: playerDispatch } = usePlayer();
22
23
  const { columns } = useTerminalSize();
23
24
  const musicService = getMusicService();
25
+ const downloadService = getDownloadService();
24
26
  const { createPlaylist } = usePlaylist();
27
+ const [isDownloading, setIsDownloading] = useState(false);
25
28
  const mixCreatedRef = useRef(onMixCreated);
26
29
  mixCreatedRef.current = onMixCreated;
30
+ const downloadStatusRef = useRef(onDownloadStatus);
31
+ downloadStatusRef.current = onDownloadStatus;
27
32
  // Track component instance and last action time for debouncing
28
33
  const instanceIdRef = useRef(++instanceCounter);
29
34
  const lastSelectTime = useRef(0);
@@ -206,21 +211,60 @@ function SearchResults({ results, selectedIndex, isActive = true, onMixCreated,
206
211
  if (firstTrack) {
207
212
  playerDispatch({ category: 'PLAY', track: firstTrack });
208
213
  }
214
+ // Navigate to player view so the user lands on the queue/player
215
+ dispatch({ category: 'NAVIGATE', view: VIEW.PLAYER });
209
216
  mixCreatedRef.current?.(`Created mix "${playlist.name}" with ${uniqueTracks.length} tracks — playing now.`);
210
217
  }, [
211
218
  createPlaylist,
219
+ dispatch,
212
220
  isActive,
213
221
  musicService,
214
222
  playerDispatch,
215
223
  results,
216
224
  selectedIndex,
217
225
  ]);
226
+ const downloadSelected = useCallback(async () => {
227
+ if (!isActive)
228
+ return;
229
+ if (isDownloading) {
230
+ downloadStatusRef.current?.('Download already in progress. Please wait.');
231
+ return;
232
+ }
233
+ const selected = results[selectedIndex];
234
+ if (!selected)
235
+ return;
236
+ const config = downloadService.getConfig();
237
+ if (!config.enabled) {
238
+ downloadStatusRef.current?.('Downloads are disabled. Enable Download Feature in Settings.');
239
+ return;
240
+ }
241
+ try {
242
+ setIsDownloading(true);
243
+ const target = await downloadService.resolveSearchTarget(selected);
244
+ if (target.tracks.length === 0) {
245
+ downloadStatusRef.current?.(`No tracks found for "${target.name}".`);
246
+ return;
247
+ }
248
+ downloadStatusRef.current?.(`Downloading ${target.tracks.length} track(s) from "${target.name}"... this can take a few minutes.`);
249
+ const summary = await downloadService.downloadTracks(target.tracks);
250
+ downloadStatusRef.current?.(`Downloaded ${summary.downloaded}, skipped ${summary.skipped}, failed ${summary.failed}.`);
251
+ }
252
+ catch (error) {
253
+ downloadStatusRef.current?.(error instanceof Error ? error.message : 'Download failed.');
254
+ }
255
+ finally {
256
+ setIsDownloading(false);
257
+ }
258
+ }, [downloadService, isActive, isDownloading, results, selectedIndex]);
218
259
  useKeyBinding(KEYBINDINGS.UP, navigateUp);
219
260
  useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
220
261
  useKeyBinding(KEYBINDINGS.SELECT, handleSelect);
221
262
  useKeyBinding(KEYBINDINGS.CREATE_MIX, () => {
222
263
  void createMixPlaylist();
223
264
  });
265
+ useKeyBinding(KEYBINDINGS.DOWNLOAD, () => {
266
+ void downloadSelected();
267
+ });
224
268
  // Note: Removed redundant useEffect that was syncing selectedIndex to dispatch
225
269
  // This was causing unnecessary re-renders. The selectedIndex is already managed
226
270
  // by the parent component (SearchLayout) and passed down as a prop.
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Settings component
3
3
  import { useState } from 'react';
4
4
  import { Box, Text } from 'ink';
5
+ import TextInput from 'ink-text-input';
5
6
  import { useTheme } from "../../hooks/useTheme.js";
6
7
  import { useNavigation } from "../../hooks/useNavigation.js";
7
8
  import { getConfigService } from "../../services/config/config.service.js";
@@ -9,12 +10,17 @@ import { useKeyBinding } from "../../hooks/useKeyboard.js";
9
10
  import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
10
11
  import { useSleepTimer } from "../../hooks/useSleepTimer.js";
11
12
  import { formatTime } from "../../utils/format.js";
13
+ import { useKeyboardBlocker } from "../../hooks/useKeyboardBlocker.js";
12
14
  const QUALITIES = ['low', 'medium', 'high'];
15
+ const DOWNLOAD_FORMATS = ['mp3', 'm4a'];
13
16
  const SETTINGS_ITEMS = [
14
17
  'Stream Quality',
15
18
  'Audio Normalization',
16
19
  'Notifications',
17
20
  'Discord Rich Presence',
21
+ 'Downloads Enabled',
22
+ 'Download Folder',
23
+ 'Download Format',
18
24
  'Sleep Timer',
19
25
  'Custom Keybindings',
20
26
  'Manage Plugins',
@@ -28,7 +34,12 @@ export default function Settings() {
28
34
  const [audioNormalization, setAudioNormalization] = useState(config.get('audioNormalization') ?? false);
29
35
  const [notifications, setNotifications] = useState(config.get('notifications') ?? false);
30
36
  const [discordRpc, setDiscordRpc] = useState(config.get('discordRichPresence') ?? false);
37
+ const [downloadsEnabled, setDownloadsEnabled] = useState(config.get('downloadsEnabled') ?? false);
38
+ const [downloadDirectory, setDownloadDirectory] = useState(config.get('downloadDirectory') ?? '');
39
+ const [downloadFormat, setDownloadFormat] = useState(config.get('downloadFormat') ?? 'mp3');
40
+ const [isEditingDownloadDirectory, setIsEditingDownloadDirectory] = useState(false);
31
41
  const { isActive, activeMinutes, remainingSeconds, startTimer, cancelTimer, presets, } = useSleepTimer();
42
+ useKeyboardBlocker(isEditingDownloadDirectory);
32
43
  const navigateUp = () => {
33
44
  setSelectedIndex(prev => Math.max(0, prev - 1));
34
45
  };
@@ -56,6 +67,17 @@ export default function Settings() {
56
67
  setDiscordRpc(next);
57
68
  config.set('discordRichPresence', next);
58
69
  };
70
+ const toggleDownloadsEnabled = () => {
71
+ const next = !downloadsEnabled;
72
+ setDownloadsEnabled(next);
73
+ config.set('downloadsEnabled', next);
74
+ };
75
+ const cycleDownloadFormat = () => {
76
+ const currentIndex = DOWNLOAD_FORMATS.indexOf(downloadFormat);
77
+ const nextFormat = DOWNLOAD_FORMATS[(currentIndex + 1) % DOWNLOAD_FORMATS.length];
78
+ setDownloadFormat(nextFormat);
79
+ config.set('downloadFormat', nextFormat);
80
+ };
59
81
  const cycleSleepTimer = () => {
60
82
  if (isActive) {
61
83
  cancelTimer();
@@ -82,12 +104,21 @@ export default function Settings() {
82
104
  toggleDiscordRpc();
83
105
  }
84
106
  else if (selectedIndex === 4) {
85
- cycleSleepTimer();
107
+ toggleDownloadsEnabled();
86
108
  }
87
109
  else if (selectedIndex === 5) {
88
- dispatch({ category: 'NAVIGATE', view: VIEW.KEYBINDINGS });
110
+ setIsEditingDownloadDirectory(true);
89
111
  }
90
112
  else if (selectedIndex === 6) {
113
+ cycleDownloadFormat();
114
+ }
115
+ else if (selectedIndex === 7) {
116
+ cycleSleepTimer();
117
+ }
118
+ else if (selectedIndex === 8) {
119
+ dispatch({ category: 'NAVIGATE', view: VIEW.KEYBINDINGS });
120
+ }
121
+ else if (selectedIndex === 9) {
91
122
  dispatch({ category: 'NAVIGATE', view: VIEW.PLUGINS });
92
123
  }
93
124
  };
@@ -97,9 +128,19 @@ export default function Settings() {
97
128
  const sleepTimerLabel = isActive && remainingSeconds !== null
98
129
  ? `Sleep Timer: ${formatTime(remainingSeconds)} remaining (Enter to cancel)`
99
130
  : 'Sleep Timer: Off (Enter to set)';
100
- return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Settings" }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 0 ? theme.colors.primary : undefined, color: selectedIndex === 0 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 0, children: ["Stream Quality: ", quality.toUpperCase()] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 1 ? theme.colors.primary : undefined, color: selectedIndex === 1 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 1, children: ["Audio Normalization: ", audioNormalization ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 2 ? theme.colors.primary : undefined, color: selectedIndex === 2 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 2, children: ["Desktop Notifications: ", notifications ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 3 ? theme.colors.primary : undefined, color: selectedIndex === 3 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 3, children: ["Discord Rich Presence: ", discordRpc ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 4 ? theme.colors.primary : undefined, color: selectedIndex === 4
131
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Settings" }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 0 ? theme.colors.primary : undefined, color: selectedIndex === 0 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 0, children: ["Stream Quality: ", quality.toUpperCase()] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 1 ? theme.colors.primary : undefined, color: selectedIndex === 1 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 1, children: ["Audio Normalization: ", audioNormalization ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 2 ? theme.colors.primary : undefined, color: selectedIndex === 2 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 2, children: ["Desktop Notifications: ", notifications ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 3 ? theme.colors.primary : undefined, color: selectedIndex === 3 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 3, children: ["Discord Rich Presence: ", discordRpc ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 4 ? theme.colors.primary : undefined, color: selectedIndex === 4 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 4, children: ["Download Feature: ", downloadsEnabled ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: isEditingDownloadDirectory && selectedIndex === 5 ? (_jsx(TextInput, { value: downloadDirectory, onChange: setDownloadDirectory, onSubmit: value => {
132
+ const normalized = value.trim();
133
+ if (!normalized) {
134
+ setDownloadDirectory(config.get('downloadDirectory') ?? '');
135
+ setIsEditingDownloadDirectory(false);
136
+ return;
137
+ }
138
+ setDownloadDirectory(normalized);
139
+ config.set('downloadDirectory', normalized);
140
+ setIsEditingDownloadDirectory(false);
141
+ }, placeholder: "Download directory", focus: true })) : (_jsxs(Text, { backgroundColor: selectedIndex === 5 ? theme.colors.primary : undefined, color: selectedIndex === 5 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 5, children: ["Download Folder: ", downloadDirectory] })) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 6 ? theme.colors.primary : undefined, color: selectedIndex === 6 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 6, children: ["Download Format: ", downloadFormat.toUpperCase()] }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 7 ? theme.colors.primary : undefined, color: selectedIndex === 7
101
142
  ? theme.colors.background
102
143
  : isActive
103
144
  ? theme.colors.accent
104
- : theme.colors.text, bold: selectedIndex === 4, children: sleepTimerLabel }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 5 ? theme.colors.primary : undefined, color: selectedIndex === 5 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 5, children: "Custom Keybindings \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 6 ? theme.colors.primary : undefined, color: selectedIndex === 6 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 6, children: "Manage Plugins" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Arrows to navigate, Enter to select, Esc/q to go back" }) })] }));
145
+ : theme.colors.text, bold: selectedIndex === 7, children: sleepTimerLabel }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 8 ? theme.colors.primary : undefined, color: selectedIndex === 8 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 8, children: "Custom Keybindings \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 9 ? theme.colors.primary : undefined, color: selectedIndex === 9 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 9, children: "Manage Plugins" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Arrows to navigate, Enter to select, Esc/q to go back" }) })] }));
105
146
  }
@@ -2,6 +2,7 @@
2
2
  import { CONFIG_DIR, CONFIG_FILE } from "../../utils/constants.js";
3
3
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
4
4
  import { BUILTIN_THEMES, DEFAULT_THEME } from "../../config/themes.config.js";
5
+ import path from 'node:path';
5
6
  class ConfigService {
6
7
  configPath;
7
8
  configDir;
@@ -27,6 +28,9 @@ class ConfigService {
27
28
  audioNormalization: false,
28
29
  notifications: false,
29
30
  discordRichPresence: false,
31
+ downloadsEnabled: false,
32
+ downloadDirectory: path.join(CONFIG_DIR, 'downloads'),
33
+ downloadFormat: 'mp3',
30
34
  };
31
35
  }
32
36
  load() {
@@ -0,0 +1,38 @@
1
+ import type { DownloadFormat } from '../../types/config.types.ts';
2
+ import type { Playlist, SearchResult, Track } from '../../types/youtube-music.types.ts';
3
+ type DownloadResult = {
4
+ downloaded: number;
5
+ skipped: number;
6
+ failed: number;
7
+ errors: string[];
8
+ };
9
+ type DownloadTarget = {
10
+ name: string;
11
+ tracks: Track[];
12
+ };
13
+ declare class DownloadService {
14
+ private ffmpegChecked;
15
+ private ffmpegAvailable;
16
+ private activeDownload;
17
+ private readonly config;
18
+ private readonly musicService;
19
+ getConfig(): {
20
+ enabled: boolean;
21
+ directory: string;
22
+ format: DownloadFormat;
23
+ };
24
+ resolveSearchTarget(result: SearchResult): Promise<DownloadTarget>;
25
+ resolvePlaylistTarget(playlist: Playlist): DownloadTarget;
26
+ downloadTracks(tracks: Track[]): Promise<DownloadResult>;
27
+ private uniqueTracks;
28
+ private getDestinationPath;
29
+ private sanitizeFilename;
30
+ private fetchAudio;
31
+ private ensureFfmpeg;
32
+ private convertAudio;
33
+ private runFfmpeg;
34
+ private recordViaYtDlp;
35
+ private recordViaMpv;
36
+ }
37
+ export declare function getDownloadService(): DownloadService;
38
+ export {};