@involvex/youtube-music-cli 0.0.8 → 0.0.11

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,25 @@
1
+ ## [0.0.11](https://github.com/involvex/youtube-music-cli/compare/v0.0.10...v0.0.11) (2026-02-18)
2
+
3
+ ### Features
4
+
5
+ - **download:** add track, artist, and playlist downloads ([42298d2](https://github.com/involvex/youtube-music-cli/commit/42298d2fd35ba71ad841f58e140f4c53b3222ed7))
6
+
7
+ ## [0.0.10](https://github.com/involvex/youtube-music-cli/compare/v0.0.9...v0.0.10) (2026-02-18)
8
+
9
+ ### Bug Fixes
10
+
11
+ - mpv resume not working and double-process on track change ([fd04bda](https://github.com/involvex/youtube-music-cli/commit/fd04bdaa852f366eb92d265415ec18794ea5dfc3))
12
+
13
+ ### Features
14
+
15
+ - dynamic mix playlist creation from search results ([d019048](https://github.com/involvex/youtube-music-cli/commit/d019048c4450d633003c8026ac43e92a743d87a0))
16
+
17
+ ## [0.0.9](https://github.com/involvex/youtube-music-cli/compare/v0.0.8...v0.0.9) (2026-02-18)
18
+
19
+ ### Features
20
+
21
+ - **search:** add dynamic mix creation from search results ([0d50231](https://github.com/involvex/youtube-music-cli/commit/0d5023168c73cec9d22dab9808e0fb2f23b5c1cc))
22
+
1
23
  ## [0.0.8](https://github.com/involvex/youtube-music-cli/compare/v0.0.7...v0.0.8) (2026-02-18)
2
24
 
3
25
  ### 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);
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useNavigation } from "../../hooks/useNavigation.js";
4
4
  import { useYouTubeMusic } from "../../hooks/useYouTubeMusic.js";
5
5
  import SearchResults from "../search/SearchResults.js";
6
- import { useState, useCallback, useEffect } from 'react';
6
+ import { useState, useCallback, useEffect, useRef } from 'react';
7
7
  import React from 'react';
8
8
  import { useTheme } from "../../hooks/useTheme.js";
9
9
  import SearchBar from "../search/SearchBar.js";
@@ -17,6 +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 [actionMessage, setActionMessage] = useState(null);
21
+ const actionTimeoutRef = useRef(null);
20
22
  // Handle search action
21
23
  const performSearch = useCallback(async (query) => {
22
24
  if (!query || isSearching)
@@ -71,6 +73,33 @@ function SearchLayout() {
71
73
  }
72
74
  }, [isTyping, dispatch]);
73
75
  useKeyBinding(KEYBINDINGS.BACK, goBack);
76
+ const handleMixCreated = useCallback((message) => {
77
+ setActionMessage(message);
78
+ if (actionTimeoutRef.current) {
79
+ clearTimeout(actionTimeoutRef.current);
80
+ }
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;
94
+ }, 4000);
95
+ }, []);
96
+ useEffect(() => {
97
+ return () => {
98
+ if (actionTimeoutRef.current) {
99
+ clearTimeout(actionTimeoutRef.current);
100
+ }
101
+ };
102
+ }, []);
74
103
  // Reset search state when leaving view
75
104
  useEffect(() => {
76
105
  return () => {
@@ -81,8 +110,8 @@ function SearchLayout() {
81
110
  }, [dispatch]);
82
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 => {
83
112
  void performSearch(input);
84
- } }), (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 })), !isLoading && navState.hasSearched && results.length === 0 && !error && (_jsx(Text, { color: theme.colors.dim, children: "No results found" })), _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
85
114
  ? 'Type to search, Enter to start, Esc to clear'
86
- : `Arrows to navigate, Enter to play, ]/[ 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` })] }));
87
116
  }
88
117
  export default React.memo(SearchLayout);
@@ -10,20 +10,23 @@ 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);
22
25
  useKeyboardBlocker(renamingPlaylistId !== null);
23
26
  const handleCreate = useCallback(() => {
24
27
  const name = `Playlist ${playlists.length + 1}`;
25
- createPlaylist(name);
26
- setLastCreated(name);
28
+ const playlist = createPlaylist(name);
29
+ setLastCreated(playlist.name);
27
30
  setSelectedIndex(playlists.length);
28
31
  }, [createPlaylist, playlists.length]);
29
32
  const navigateUp = useCallback(() => {
@@ -67,16 +70,54 @@ export default function PlaylistList() {
67
70
  }
68
71
  dispatch({ category: 'GO_BACK' });
69
72
  }, [dispatch, renamingPlaylistId]);
73
+ const handleDelete = useCallback(() => {
74
+ if (renamingPlaylistId)
75
+ return;
76
+ const playlist = playlists[selectedIndex];
77
+ if (!playlist)
78
+ return;
79
+ deletePlaylist(playlist.playlistId);
80
+ setSelectedIndex(prev => Math.max(0, prev - 1));
81
+ }, [deletePlaylist, playlists, renamingPlaylistId, selectedIndex]);
82
+ const handleDownload = useCallback(async () => {
83
+ if (renamingPlaylistId)
84
+ return;
85
+ const playlist = playlists[selectedIndex];
86
+ if (!playlist)
87
+ return;
88
+ const config = downloadService.getConfig();
89
+ if (!config.enabled) {
90
+ setDownloadStatus('Downloads are disabled. Enable Download Feature in Settings.');
91
+ return;
92
+ }
93
+ const target = downloadService.resolvePlaylistTarget(playlist);
94
+ if (target.tracks.length === 0) {
95
+ setDownloadStatus(`No tracks to download in "${playlist.name}".`);
96
+ return;
97
+ }
98
+ setDownloadStatus(`Downloading ${target.tracks.length} track(s) from "${playlist.name}"...`);
99
+ try {
100
+ const summary = await downloadService.downloadTracks(target.tracks);
101
+ setDownloadStatus(`Downloaded ${summary.downloaded}, skipped ${summary.skipped}, failed ${summary.failed}.`);
102
+ }
103
+ catch (error) {
104
+ setDownloadStatus(error instanceof Error ? error.message : 'Failed to download playlist.');
105
+ }
106
+ }, [downloadService, playlists, renamingPlaylistId, selectedIndex]);
70
107
  useKeyBinding(KEYBINDINGS.UP, navigateUp);
71
108
  useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
72
109
  useKeyBinding(KEYBINDINGS.SELECT, startPlaylist);
73
110
  useKeyBinding(['r'], handleRename);
74
111
  useKeyBinding(KEYBINDINGS.CREATE_PLAYLIST, handleCreate);
112
+ useKeyBinding(KEYBINDINGS.DELETE_PLAYLIST, handleDelete);
75
113
  useKeyBinding(KEYBINDINGS.BACK, handleBack);
114
+ useKeyBinding(KEYBINDINGS.DOWNLOAD, () => {
115
+ void handleDownload();
116
+ });
76
117
  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
118
  const isSelected = index === selectedIndex;
78
119
  const isRenaming = renamingPlaylistId === playlist.playlistId && isSelected;
79
120
  const rowBackground = isSelected ? theme.colors.secondary : undefined;
80
121
  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] }))] })] }));
122
+ })), _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
123
  }
@@ -4,7 +4,9 @@ type Props = {
4
4
  results: SearchResult[];
5
5
  selectedIndex: number;
6
6
  isActive?: boolean;
7
+ onMixCreated?: (message: string) => void;
8
+ onDownloadStatus?: (message: string) => void;
7
9
  };
8
- declare function SearchResults({ results, selectedIndex, isActive }: 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;
9
11
  declare const _default: React.MemoExoticComponent<typeof SearchResults>;
10
12
  export default _default;
@@ -6,20 +6,28 @@ import { useTheme } from "../../hooks/useTheme.js";
6
6
  import { useNavigation } from "../../hooks/useNavigation.js";
7
7
  import { useKeyBinding } from "../../hooks/useKeyboard.js";
8
8
  import { usePlayer } from "../../hooks/usePlayer.js";
9
- import { KEYBINDINGS } from "../../utils/constants.js";
9
+ import { usePlaylist } from "../../hooks/usePlaylist.js";
10
+ import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
10
11
  import { truncate } from "../../utils/format.js";
11
12
  import { useCallback, useRef, useEffect } from 'react';
12
13
  import { logger } from "../../services/logger/logger.service.js";
13
14
  import { useTerminalSize } from "../../hooks/useTerminalSize.js";
14
15
  import { getMusicService } from "../../services/youtube-music/api.js";
16
+ import { getDownloadService } from "../../services/download/download.service.js";
15
17
  // Generate unique component instance ID
16
18
  let instanceCounter = 0;
17
- function SearchResults({ results, selectedIndex, isActive = true }) {
19
+ function SearchResults({ results, selectedIndex, isActive = true, onMixCreated, onDownloadStatus, }) {
18
20
  const { theme } = useTheme();
19
21
  const { dispatch } = useNavigation();
20
22
  const { play, dispatch: playerDispatch } = usePlayer();
21
23
  const { columns } = useTerminalSize();
22
24
  const musicService = getMusicService();
25
+ const downloadService = getDownloadService();
26
+ const { createPlaylist } = usePlaylist();
27
+ const mixCreatedRef = useRef(onMixCreated);
28
+ mixCreatedRef.current = onMixCreated;
29
+ const downloadStatusRef = useRef(onDownloadStatus);
30
+ downloadStatusRef.current = onDownloadStatus;
23
31
  // Track component instance and last action time for debouncing
24
32
  const instanceIdRef = useRef(++instanceCounter);
25
33
  const lastSelectTime = useRef(0);
@@ -123,9 +131,131 @@ function SearchResults({ results, selectedIndex, isActive = true }) {
123
131
  logger.debug('SearchResults', 'SELECT key pressed', { isActive, instanceId });
124
132
  playSelected();
125
133
  }, [isActive, playSelected]);
134
+ const createMixPlaylist = useCallback(async () => {
135
+ if (!isActive)
136
+ return;
137
+ const selected = results[selectedIndex];
138
+ if (!selected) {
139
+ logger.warn('SearchResults', 'No result selected for mix');
140
+ return;
141
+ }
142
+ let playlistName = 'Dynamic mix';
143
+ const collectedTracks = [];
144
+ if (selected.type === 'song') {
145
+ const selectedTrack = selected.data;
146
+ const title = selectedTrack.title || 'selected track';
147
+ playlistName = `Mix for ${title}`;
148
+ collectedTracks.push(selectedTrack);
149
+ try {
150
+ const suggestions = await musicService.getSuggestions(selectedTrack.videoId);
151
+ collectedTracks.push(...suggestions);
152
+ }
153
+ catch (error) {
154
+ logger.error('SearchResults', 'Failed to fetch song suggestions', {
155
+ error,
156
+ });
157
+ }
158
+ }
159
+ else if (selected.type === 'artist') {
160
+ const artistName = 'name' in selected.data ? selected.data.name : '';
161
+ if (!artistName) {
162
+ logger.warn('SearchResults', 'Artist name missing for mix');
163
+ mixCreatedRef.current?.('Artist information is missing, cannot create mix.');
164
+ return;
165
+ }
166
+ playlistName = `${artistName} mix`;
167
+ try {
168
+ const response = await musicService.search(artistName, {
169
+ type: 'songs',
170
+ limit: 25,
171
+ });
172
+ const artistTracks = response.results
173
+ .filter(result => result.type === 'song')
174
+ .map(result => result.data);
175
+ collectedTracks.push(...artistTracks);
176
+ }
177
+ catch (error) {
178
+ logger.error('SearchResults', 'Failed to fetch artist songs for mix', {
179
+ error,
180
+ });
181
+ }
182
+ }
183
+ else {
184
+ logger.warn('SearchResults', 'Mix creation unsupported result type', {
185
+ type: selected.type,
186
+ });
187
+ mixCreatedRef.current?.('Mix creation is only supported for songs and artists.');
188
+ return;
189
+ }
190
+ const uniqueTracks = [];
191
+ const seenVideoIds = new Set();
192
+ for (const track of collectedTracks) {
193
+ if (!track?.videoId || seenVideoIds.has(track.videoId))
194
+ continue;
195
+ seenVideoIds.add(track.videoId);
196
+ uniqueTracks.push(track);
197
+ }
198
+ if (uniqueTracks.length === 0) {
199
+ mixCreatedRef.current?.('No similar tracks were found to create a mix.');
200
+ return;
201
+ }
202
+ const playlist = createPlaylist(playlistName, uniqueTracks);
203
+ logger.info('SearchResults', 'Mix playlist created', {
204
+ name: playlist.name,
205
+ trackCount: uniqueTracks.length,
206
+ });
207
+ // Queue the mix tracks and start playing the first one
208
+ playerDispatch({ category: 'SET_QUEUE', queue: uniqueTracks });
209
+ const firstTrack = uniqueTracks[0];
210
+ if (firstTrack) {
211
+ playerDispatch({ category: 'PLAY', track: firstTrack });
212
+ }
213
+ // Navigate to player view so the user lands on the queue/player
214
+ dispatch({ category: 'NAVIGATE', view: VIEW.PLAYER });
215
+ mixCreatedRef.current?.(`Created mix "${playlist.name}" with ${uniqueTracks.length} tracks — playing now.`);
216
+ }, [
217
+ createPlaylist,
218
+ dispatch,
219
+ isActive,
220
+ musicService,
221
+ playerDispatch,
222
+ results,
223
+ selectedIndex,
224
+ ]);
225
+ const downloadSelected = useCallback(async () => {
226
+ if (!isActive)
227
+ return;
228
+ const selected = results[selectedIndex];
229
+ if (!selected)
230
+ return;
231
+ const config = downloadService.getConfig();
232
+ if (!config.enabled) {
233
+ downloadStatusRef.current?.('Downloads are disabled. Enable Download Feature in Settings.');
234
+ return;
235
+ }
236
+ try {
237
+ const target = await downloadService.resolveSearchTarget(selected);
238
+ if (target.tracks.length === 0) {
239
+ downloadStatusRef.current?.(`No tracks found for "${target.name}".`);
240
+ return;
241
+ }
242
+ downloadStatusRef.current?.(`Downloading ${target.tracks.length} track(s) from "${target.name}"...`);
243
+ const summary = await downloadService.downloadTracks(target.tracks);
244
+ downloadStatusRef.current?.(`Downloaded ${summary.downloaded}, skipped ${summary.skipped}, failed ${summary.failed}.`);
245
+ }
246
+ catch (error) {
247
+ downloadStatusRef.current?.(error instanceof Error ? error.message : 'Download failed.');
248
+ }
249
+ }, [downloadService, isActive, results, selectedIndex]);
126
250
  useKeyBinding(KEYBINDINGS.UP, navigateUp);
127
251
  useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
128
252
  useKeyBinding(KEYBINDINGS.SELECT, handleSelect);
253
+ useKeyBinding(KEYBINDINGS.CREATE_MIX, () => {
254
+ void createMixPlaylist();
255
+ });
256
+ useKeyBinding(KEYBINDINGS.DOWNLOAD, () => {
257
+ void downloadSelected();
258
+ });
129
259
  // Note: Removed redundant useEffect that was syncing selectedIndex to dispatch
130
260
  // This was causing unnecessary re-renders. The selectedIndex is already managed
131
261
  // 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,7 +2,7 @@ import type { Playlist, Track } from '../types/youtube-music.types.ts';
2
2
  export type AddTrackResult = 'added' | 'duplicate';
3
3
  export declare function usePlaylist(): {
4
4
  playlists: Playlist[];
5
- createPlaylist: (name: string) => void;
5
+ createPlaylist: (name: string, tracks?: Track[]) => Playlist;
6
6
  deletePlaylist: (playlistId: string) => void;
7
7
  renamePlaylist: (playlistId: string, newName: string) => void;
8
8
  addTrackToPlaylist: (playlistId: string, track: Track, force?: boolean) => AddTrackResult;
@@ -7,15 +7,16 @@ export function usePlaylist() {
7
7
  useEffect(() => {
8
8
  setPlaylists(configService.get('playlists'));
9
9
  }, []);
10
- const createPlaylist = useCallback((name) => {
10
+ const createPlaylist = useCallback((name, tracks = []) => {
11
11
  const newPlaylist = {
12
12
  playlistId: Date.now().toString(),
13
13
  name,
14
- tracks: [],
14
+ tracks: tracks.map(track => ({ ...track })),
15
15
  };
16
16
  const updatedPlaylists = [...playlists, newPlaylist];
17
17
  setPlaylists(updatedPlaylists);
18
18
  configService.set('playlists', updatedPlaylists);
19
+ return newPlaylist;
19
20
  }, [playlists, configService]);
20
21
  const deletePlaylist = useCallback((playlistId) => {
21
22
  const updatedPlaylists = playlists.filter(p => p.playlistId !== playlistId);
@@ -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,35 @@
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 readonly config;
17
+ private readonly musicService;
18
+ getConfig(): {
19
+ enabled: boolean;
20
+ directory: string;
21
+ format: DownloadFormat;
22
+ };
23
+ resolveSearchTarget(result: SearchResult): Promise<DownloadTarget>;
24
+ resolvePlaylistTarget(playlist: Playlist): DownloadTarget;
25
+ downloadTracks(tracks: Track[]): Promise<DownloadResult>;
26
+ private uniqueTracks;
27
+ private getDestinationPath;
28
+ private sanitizeFilename;
29
+ private fetchAudio;
30
+ private ensureFfmpeg;
31
+ private convertAudio;
32
+ private runFfmpeg;
33
+ }
34
+ export declare function getDownloadService(): DownloadService;
35
+ export {};
@@ -0,0 +1,197 @@
1
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { getConfigService } from "../config/config.service.js";
5
+ import { logger } from "../logger/logger.service.js";
6
+ import { getMusicService } from "../youtube-music/api.js";
7
+ class DownloadService {
8
+ ffmpegChecked = false;
9
+ ffmpegAvailable = false;
10
+ config = getConfigService();
11
+ musicService = getMusicService();
12
+ getConfig() {
13
+ return {
14
+ enabled: this.config.get('downloadsEnabled') ?? false,
15
+ directory: this.config.get('downloadDirectory') ?? '',
16
+ format: (this.config.get('downloadFormat') ?? 'mp3'),
17
+ };
18
+ }
19
+ async resolveSearchTarget(result) {
20
+ if (result.type === 'song') {
21
+ const track = result.data;
22
+ return { name: track.title, tracks: [track] };
23
+ }
24
+ if (result.type === 'artist') {
25
+ const artistName = 'name' in result.data ? result.data.name : '';
26
+ if (!artistName) {
27
+ throw new Error('Artist name is missing.');
28
+ }
29
+ const response = await this.musicService.search(artistName, {
30
+ type: 'songs',
31
+ limit: 25,
32
+ });
33
+ const tracks = response.results
34
+ .filter(row => row.type === 'song')
35
+ .map(row => row.data);
36
+ return { name: artistName, tracks: this.uniqueTracks(tracks) };
37
+ }
38
+ if (result.type === 'playlist') {
39
+ const playlistInfo = result.data;
40
+ if (!playlistInfo.playlistId) {
41
+ throw new Error('Playlist id is missing.');
42
+ }
43
+ const playlist = await this.musicService.getPlaylist(playlistInfo.playlistId);
44
+ return {
45
+ name: playlist.name || playlistInfo.name || 'playlist',
46
+ tracks: this.uniqueTracks(playlist.tracks),
47
+ };
48
+ }
49
+ throw new Error('Downloads are supported for songs, artists, and playlists.');
50
+ }
51
+ resolvePlaylistTarget(playlist) {
52
+ return {
53
+ name: playlist.name,
54
+ tracks: this.uniqueTracks(playlist.tracks),
55
+ };
56
+ }
57
+ async downloadTracks(tracks) {
58
+ const { directory, format } = this.getConfig();
59
+ if (!directory) {
60
+ throw new Error('No download directory configured.');
61
+ }
62
+ mkdirSync(directory, { recursive: true });
63
+ await this.ensureFfmpeg();
64
+ const result = {
65
+ downloaded: 0,
66
+ skipped: 0,
67
+ failed: 0,
68
+ errors: [],
69
+ };
70
+ for (const track of tracks) {
71
+ const destination = this.getDestinationPath(track, directory, format);
72
+ const tempSource = `${destination}.source`;
73
+ try {
74
+ if (existsSync(destination)) {
75
+ result.skipped++;
76
+ continue;
77
+ }
78
+ const streamUrl = await this.musicService.getStreamUrl(track.videoId);
79
+ const audioBuffer = await this.fetchAudio(streamUrl);
80
+ writeFileSync(tempSource, audioBuffer);
81
+ await this.convertAudio(tempSource, destination, format);
82
+ result.downloaded++;
83
+ }
84
+ catch (error) {
85
+ result.failed++;
86
+ const message = error instanceof Error ? error.message : 'Unknown download failure';
87
+ result.errors.push(message);
88
+ logger.error('DownloadService', 'Track download failed', {
89
+ videoId: track.videoId,
90
+ title: track.title,
91
+ error: message,
92
+ });
93
+ }
94
+ finally {
95
+ if (existsSync(tempSource)) {
96
+ unlinkSync(tempSource);
97
+ }
98
+ }
99
+ }
100
+ return result;
101
+ }
102
+ uniqueTracks(tracks) {
103
+ const seen = new Set();
104
+ const unique = [];
105
+ for (const track of tracks) {
106
+ if (!track?.videoId || seen.has(track.videoId))
107
+ continue;
108
+ seen.add(track.videoId);
109
+ unique.push(track);
110
+ }
111
+ return unique;
112
+ }
113
+ getDestinationPath(track, directory, format) {
114
+ const artist = track.artists[0]?.name ?? 'Unknown Artist';
115
+ const baseName = this.sanitizeFilename(`${artist} - ${track.title}`);
116
+ return path.join(directory, `${baseName}.${format}`);
117
+ }
118
+ sanitizeFilename(value) {
119
+ return value.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_').trim();
120
+ }
121
+ async fetchAudio(url) {
122
+ const response = await fetch(url);
123
+ if (!response.ok) {
124
+ throw new Error(`Failed to fetch audio stream (${response.status}).`);
125
+ }
126
+ const audio = await response.arrayBuffer();
127
+ return Buffer.from(audio);
128
+ }
129
+ async ensureFfmpeg() {
130
+ if (this.ffmpegChecked) {
131
+ if (!this.ffmpegAvailable) {
132
+ throw new Error('ffmpeg is required for downloads. Install ffmpeg and ensure it is available in PATH.');
133
+ }
134
+ return;
135
+ }
136
+ this.ffmpegChecked = true;
137
+ try {
138
+ await this.runFfmpeg(['-version']);
139
+ this.ffmpegAvailable = true;
140
+ }
141
+ catch {
142
+ this.ffmpegAvailable = false;
143
+ throw new Error('ffmpeg is required for downloads. Install ffmpeg and ensure it is available in PATH.');
144
+ }
145
+ }
146
+ async convertAudio(sourcePath, destinationPath, format) {
147
+ if (format === 'mp3') {
148
+ await this.runFfmpeg([
149
+ '-y',
150
+ '-i',
151
+ sourcePath,
152
+ '-vn',
153
+ '-codec:a',
154
+ 'libmp3lame',
155
+ '-q:a',
156
+ '2',
157
+ destinationPath,
158
+ ]);
159
+ return;
160
+ }
161
+ await this.runFfmpeg([
162
+ '-y',
163
+ '-i',
164
+ sourcePath,
165
+ '-vn',
166
+ '-codec:a',
167
+ 'aac',
168
+ '-b:a',
169
+ '192k',
170
+ destinationPath,
171
+ ]);
172
+ }
173
+ async runFfmpeg(args) {
174
+ await new Promise((resolve, reject) => {
175
+ const process = spawn('ffmpeg', args, { windowsHide: true });
176
+ let stderr = '';
177
+ process.stderr.on('data', chunk => {
178
+ stderr += String(chunk);
179
+ });
180
+ process.on('error', reject);
181
+ process.on('exit', code => {
182
+ if (code === 0) {
183
+ resolve();
184
+ return;
185
+ }
186
+ reject(new Error(stderr.trim() || `ffmpeg exited with code ${code}`));
187
+ });
188
+ });
189
+ }
190
+ }
191
+ let downloadServiceInstance = null;
192
+ export function getDownloadService() {
193
+ if (!downloadServiceInstance) {
194
+ downloadServiceInstance = new DownloadService();
195
+ }
196
+ return downloadServiceInstance;
197
+ }
@@ -21,6 +21,7 @@ declare class PlayerService {
21
21
  private ipcConnectRetries;
22
22
  private readonly maxIpcRetries;
23
23
  private currentTrackId;
24
+ private playSessionId;
24
25
  private constructor();
25
26
  static getInstance(): PlayerService;
26
27
  getCurrentTrackId(): string | null;
@@ -29,7 +30,7 @@ declare class PlayerService {
29
30
  */
30
31
  onEvent(callback: PlayerEventCallback): void;
31
32
  /**
32
- * Generate IPC socket path based on platform
33
+ * Generate IPC socket path based on platform, unique per play session
33
34
  */
34
35
  private getIpcPath;
35
36
  /**
@@ -14,6 +14,7 @@ class PlayerService {
14
14
  ipcConnectRetries = 0;
15
15
  maxIpcRetries = 10;
16
16
  currentTrackId = null; // Track currently playing
17
+ playSessionId = 0; // Incremented per play() call for unique IPC paths
17
18
  constructor() { }
18
19
  static getInstance() {
19
20
  if (!PlayerService.instance) {
@@ -31,16 +32,16 @@ class PlayerService {
31
32
  this.eventCallback = callback;
32
33
  }
33
34
  /**
34
- * Generate IPC socket path based on platform
35
+ * Generate IPC socket path based on platform, unique per play session
35
36
  */
36
37
  getIpcPath() {
37
38
  if (process.platform === 'win32') {
38
39
  // Windows named pipe
39
- return `\\\\.\\pipe\\mpvsocket-${process.pid}`;
40
+ return `\\\\.\\pipe\\mpvsocket-${process.pid}-${this.playSessionId}`;
40
41
  }
41
42
  else {
42
43
  // Unix domain socket
43
- return `/tmp/mpvsocket-${process.pid}`;
44
+ return `/tmp/mpvsocket-${process.pid}-${this.playSessionId}`;
44
45
  }
45
46
  }
46
47
  /**
@@ -193,6 +194,8 @@ class PlayerService {
193
194
  if (!url.startsWith('http')) {
194
195
  playUrl = `https://www.youtube.com/watch?v=${url}`;
195
196
  }
197
+ // Increment session ID for a unique IPC socket path per play call
198
+ this.playSessionId++;
196
199
  // Generate IPC socket path
197
200
  this.ipcPath = this.getIpcPath();
198
201
  return new Promise((resolve, reject) => {
@@ -223,8 +226,11 @@ class PlayerService {
223
226
  mpvArgs.push(`--http-proxy=${options.proxy}`);
224
227
  }
225
228
  mpvArgs.push(playUrl);
226
- this.mpvProcess = spawn('mpv', mpvArgs);
227
- if (!this.mpvProcess.stdout || !this.mpvProcess.stderr) {
229
+ // Capture process in local var so stale exit handlers from a killed
230
+ // process don't overwrite state belonging to a newly-spawned process.
231
+ const spawnedProcess = spawn('mpv', mpvArgs);
232
+ this.mpvProcess = spawnedProcess;
233
+ if (!spawnedProcess.stdout || !spawnedProcess.stderr) {
228
234
  throw new Error('Failed to create mpv process streams');
229
235
  }
230
236
  this.isPlaying = true;
@@ -238,27 +244,30 @@ class PlayerService {
238
244
  });
239
245
  }, 200);
240
246
  // Handle stdout (should be minimal with --really-quiet)
241
- this.mpvProcess.stdout.on('data', (data) => {
247
+ spawnedProcess.stdout.on('data', (data) => {
242
248
  logger.debug('PlayerService', 'mpv stdout', {
243
249
  output: data.toString().trim(),
244
250
  });
245
251
  });
246
252
  // Handle stderr (errors)
247
- this.mpvProcess.stderr.on('data', (data) => {
253
+ spawnedProcess.stderr.on('data', (data) => {
248
254
  const error = data.toString().trim();
249
255
  if (error) {
250
256
  logger.error('PlayerService', 'mpv stderr', { error });
251
257
  }
252
258
  });
253
- // Handle process exit
254
- this.mpvProcess.on('exit', (code, signal) => {
259
+ // Handle process exit — guard against stale handlers from killed processes
260
+ spawnedProcess.on('exit', (code, signal) => {
255
261
  logger.info('PlayerService', 'mpv process exited', {
256
262
  code,
257
263
  signal,
258
264
  wasPlaying: this.isPlaying,
259
265
  });
260
- this.isPlaying = false;
261
- this.mpvProcess = null;
266
+ // Only update shared state if this is still the active process
267
+ if (this.mpvProcess === spawnedProcess) {
268
+ this.isPlaying = false;
269
+ this.mpvProcess = null;
270
+ }
262
271
  if (code === 0) {
263
272
  // Normal exit (track finished)
264
273
  resolve();
@@ -269,14 +278,16 @@ class PlayerService {
269
278
  }
270
279
  // If killed by signal, don't reject (user stopped it)
271
280
  });
272
- // Handle errors
273
- this.mpvProcess.on('error', (error) => {
281
+ // Handle errors — same guard
282
+ spawnedProcess.on('error', (error) => {
274
283
  logger.error('PlayerService', 'mpv process error', {
275
284
  error: error.message,
276
285
  stack: error.stack,
277
286
  });
278
- this.isPlaying = false;
279
- this.mpvProcess = null;
287
+ if (this.mpvProcess === spawnedProcess) {
288
+ this.isPlaying = false;
289
+ this.mpvProcess = null;
290
+ }
280
291
  reject(error);
281
292
  });
282
293
  logger.info('PlayerService', 'mpv process started successfully');
@@ -124,11 +124,50 @@ class MusicService {
124
124
  };
125
125
  }
126
126
  async getPlaylist(playlistId) {
127
- return {
128
- playlistId,
129
- name: 'Unknown Playlist',
130
- tracks: [],
131
- };
127
+ try {
128
+ const yt = await getClient();
129
+ const playlistData = (await yt.music.getPlaylist(playlistId));
130
+ const rows = [
131
+ ...(playlistData.contents ?? []),
132
+ ...(playlistData.tracks ?? []),
133
+ ];
134
+ const seen = new Set();
135
+ const tracks = [];
136
+ for (const row of rows) {
137
+ const videoId = row.id || row.video_id;
138
+ if (!videoId || seen.has(videoId))
139
+ continue;
140
+ seen.add(videoId);
141
+ tracks.push({
142
+ videoId,
143
+ title: (typeof row.title === 'string' ? row.title : row.title?.text) ??
144
+ 'Unknown',
145
+ artists: (row.artists ?? []).map(artist => ({
146
+ artistId: artist.channel_id || artist.id || '',
147
+ name: artist.name ?? 'Unknown',
148
+ })),
149
+ duration: typeof row.duration === 'number'
150
+ ? row.duration
151
+ : (row.duration?.seconds ?? 0),
152
+ });
153
+ }
154
+ return {
155
+ playlistId,
156
+ name: playlistData.title || playlistData.name || 'Unknown Playlist',
157
+ tracks,
158
+ };
159
+ }
160
+ catch (error) {
161
+ logger.error('MusicService', 'getPlaylist failed', {
162
+ playlistId,
163
+ error: error instanceof Error ? error.message : String(error),
164
+ });
165
+ return {
166
+ playlistId,
167
+ name: 'Unknown Playlist',
168
+ tracks: [],
169
+ };
170
+ }
132
171
  }
133
172
  async getTrending() {
134
173
  try {
@@ -218,37 +257,39 @@ class MusicService {
218
257
  async getSuggestions(trackId) {
219
258
  try {
220
259
  const yt = await getClient();
221
- let video = null;
222
- try {
223
- video = (await yt.getInfo(trackId));
224
- }
225
- catch (error) {
226
- logger.warn('MusicService', 'getSuggestions getInfo failed', {
227
- error: error instanceof Error ? error.message : String(error),
228
- });
229
- return [];
230
- }
231
- const suggestions = video?.related?.contents ?? [];
260
+ // Use music.getUpNext with automix — avoids the yt.getInfo() ParsingError
261
+ // caused by YouTube "Remove ads" menu items that youtubei.js can't parse.
262
+ const panel = await yt.music.getUpNext(trackId, true);
232
263
  const tracks = [];
233
- for (const item of suggestions) {
234
- const videoId = item?.id || '';
235
- if (!videoId)
264
+ for (const item of panel.contents) {
265
+ const video = item;
266
+ const videoId = video.video_id;
267
+ if (!videoId || videoId === trackId)
236
268
  continue;
237
- const title = typeof item.title === 'string' ? item.title : item.title?.text;
269
+ const title = typeof video.title === 'string'
270
+ ? video.title
271
+ : (video.title?.text ?? '');
238
272
  if (!title)
239
273
  continue;
240
274
  tracks.push({
241
275
  videoId,
242
276
  title,
243
- artists: [],
277
+ artists: (video.artists ?? []).map(a => ({
278
+ artistId: a.channel_id ?? '',
279
+ name: a.name ?? 'Unknown',
280
+ })),
281
+ duration: video.duration?.seconds ?? 0,
244
282
  });
245
283
  }
246
- return tracks.slice(0, 10);
284
+ logger.debug('MusicService', 'getSuggestions success', {
285
+ trackId,
286
+ count: tracks.length,
287
+ });
288
+ return tracks.slice(0, 15);
247
289
  }
248
290
  catch (error) {
249
- logger.error('MusicService', 'getSuggestions failed', {
250
- error: error instanceof Error ? error.message : String(error),
251
- });
291
+ const message = error instanceof Error ? error.message : String(error);
292
+ logger.warn('MusicService', 'getSuggestions failed', { error: message });
252
293
  return [];
253
294
  }
254
295
  }
@@ -407,10 +407,18 @@ function PlayerManager() {
407
407
  }, [state.progress, state.duration, state.currentTrack]);
408
408
  // Handle play/pause state
409
409
  useEffect(() => {
410
- if (!state.isPlaying) {
410
+ if (state.isPlaying) {
411
+ // Resume only if the same track is already loaded in the player service.
412
+ // If the track changed, the "handle track changes" effect will call play().
413
+ const currentTrackId = playerService.getCurrentTrackId?.() ?? '';
414
+ if (currentTrackId && state.currentTrack?.videoId === currentTrackId) {
415
+ playerService.resume();
416
+ }
417
+ }
418
+ else {
411
419
  playerService.pause();
412
420
  }
413
- }, [state.isPlaying, playerService]);
421
+ }, [state.isPlaying, state.currentTrack, playerService]);
414
422
  // Handle volume changes
415
423
  useEffect(() => {
416
424
  const config = getConfigService();
@@ -1,6 +1,7 @@
1
1
  import type { Playlist } from './youtube-music.types.ts';
2
2
  import type { Theme } from './theme.types.ts';
3
3
  export type RepeatMode = 'off' | 'all' | 'one';
4
+ export type DownloadFormat = 'mp3' | 'm4a';
4
5
  export interface KeybindingConfig {
5
6
  keys: string[];
6
7
  description: string;
@@ -30,4 +31,7 @@ export interface Config {
30
31
  };
31
32
  discordRichPresence?: boolean;
32
33
  proxy?: string;
34
+ downloadsEnabled?: boolean;
35
+ downloadDirectory?: string;
36
+ downloadFormat?: DownloadFormat;
33
37
  }
@@ -59,7 +59,9 @@ export declare const KEYBINDINGS: {
59
59
  readonly ADD_TO_PLAYLIST: readonly ["a"];
60
60
  readonly REMOVE_FROM_PLAYLIST: readonly ["d"];
61
61
  readonly CREATE_PLAYLIST: readonly ["c"];
62
+ readonly CREATE_MIX: readonly ["m"];
62
63
  readonly DELETE_PLAYLIST: readonly ["D"];
64
+ readonly DOWNLOAD: readonly ["shift+d"];
63
65
  };
64
66
  export declare const DEFAULT_VOLUME = 70;
65
67
  export declare const MAX_QUEUE_SIZE = 1000;
@@ -71,7 +71,9 @@ export const KEYBINDINGS = {
71
71
  ADD_TO_PLAYLIST: ['a'],
72
72
  REMOVE_FROM_PLAYLIST: ['d'],
73
73
  CREATE_PLAYLIST: ['c'],
74
+ CREATE_MIX: ['m'],
74
75
  DELETE_PLAYLIST: ['D'],
76
+ DOWNLOAD: ['shift+d'],
75
77
  };
76
78
  // Default volume
77
79
  export const DEFAULT_VOLUME = 70;
Binary file
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@involvex/youtube-music-cli",
3
- "version": "0.0.8",
3
+ "version": "0.0.11",
4
4
  "description": "- A Commandline music player for youtube-music",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "https://github.com/involvex/youtube-music-cli"
7
+ "url": "git+https://github.com/involvex/youtube-music-cli.git"
8
8
  },
9
9
  "funding": "https://github.com/sponsors/involvex",
10
10
  "license": "MIT",
@@ -21,6 +21,20 @@
21
21
  "LICENSE",
22
22
  ".github/FUNDING.yml"
23
23
  ],
24
+ "icon": "assets/icon2.PNG",
25
+ "sponsor": {
26
+ "url": "https://github.com/sponsors/involvex"
27
+ },
28
+ "homepage": "https://involvex.github.io/youtube-music-cli/",
29
+ "keywords": [
30
+ "cli",
31
+ "youtube",
32
+ "youtube-music",
33
+ "youtube-cli",
34
+ "youtube-music-cli",
35
+ "ink",
36
+ "cli-music"
37
+ ],
24
38
  "scripts": {
25
39
  "prebuild": "bun run format && bun run lint:fix && bun run typecheck",
26
40
  "build": "tsc",