@involvex/youtube-music-cli 0.0.8 → 0.0.9

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,9 @@
1
+ ## [0.0.9](https://github.com/involvex/youtube-music-cli/compare/v0.0.8...v0.0.9) (2026-02-18)
2
+
3
+ ### Features
4
+
5
+ - **search:** add dynamic mix creation from search results ([0d50231](https://github.com/involvex/youtube-music-cli/commit/0d5023168c73cec9d22dab9808e0fb2f23b5c1cc))
6
+
1
7
  ## [0.0.8](https://github.com/involvex/youtube-music-cli/compare/v0.0.7...v0.0.8) (2026-02-18)
2
8
 
3
9
  ### Features
@@ -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 [mixMessage, setMixMessage] = useState(null);
21
+ const mixTimeoutRef = useRef(null);
20
22
  // Handle search action
21
23
  const performSearch = useCallback(async (query) => {
22
24
  if (!query || isSearching)
@@ -71,6 +73,23 @@ function SearchLayout() {
71
73
  }
72
74
  }, [isTyping, dispatch]);
73
75
  useKeyBinding(KEYBINDINGS.BACK, goBack);
76
+ const handleMixCreated = useCallback((message) => {
77
+ setMixMessage(message);
78
+ if (mixTimeoutRef.current) {
79
+ clearTimeout(mixTimeoutRef.current);
80
+ }
81
+ mixTimeoutRef.current = setTimeout(() => {
82
+ setMixMessage(null);
83
+ mixTimeoutRef.current = null;
84
+ }, 4000);
85
+ }, []);
86
+ useEffect(() => {
87
+ return () => {
88
+ if (mixTimeoutRef.current) {
89
+ clearTimeout(mixTimeoutRef.current);
90
+ }
91
+ };
92
+ }, []);
74
93
  // Reset search state when leaving view
75
94
  useEffect(() => {
76
95
  return () => {
@@ -81,8 +100,8 @@ function SearchLayout() {
81
100
  }, [dispatch]);
82
101
  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
102
  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
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
85
104
  ? '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` })] }));
105
+ : `Arrows to navigate, Enter to play, M to create mix, ]/[ more/fewer results (${navState.searchLimit}), H for history, Esc to type` })] }));
87
106
  }
88
107
  export default React.memo(SearchLayout);
@@ -22,8 +22,8 @@ export default function PlaylistList() {
22
22
  useKeyboardBlocker(renamingPlaylistId !== null);
23
23
  const handleCreate = useCallback(() => {
24
24
  const name = `Playlist ${playlists.length + 1}`;
25
- createPlaylist(name);
26
- setLastCreated(name);
25
+ const playlist = createPlaylist(name);
26
+ setLastCreated(playlist.name);
27
27
  setSelectedIndex(playlists.length);
28
28
  }, [createPlaylist, playlists.length]);
29
29
  const navigateUp = useCallback(() => {
@@ -4,7 +4,8 @@ type Props = {
4
4
  results: SearchResult[];
5
5
  selectedIndex: number;
6
6
  isActive?: boolean;
7
+ onMixCreated?: (message: string) => void;
7
8
  };
8
- declare function SearchResults({ results, selectedIndex, isActive }: Props): import("react/jsx-runtime").JSX.Element | null;
9
+ declare function SearchResults({ results, selectedIndex, isActive, onMixCreated, }: Props): import("react/jsx-runtime").JSX.Element | null;
9
10
  declare const _default: React.MemoExoticComponent<typeof SearchResults>;
10
11
  export default _default;
@@ -6,6 +6,7 @@ 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 { usePlaylist } from "../../hooks/usePlaylist.js";
9
10
  import { KEYBINDINGS } from "../../utils/constants.js";
10
11
  import { truncate } from "../../utils/format.js";
11
12
  import { useCallback, useRef, useEffect } from 'react';
@@ -14,12 +15,15 @@ import { useTerminalSize } from "../../hooks/useTerminalSize.js";
14
15
  import { getMusicService } from "../../services/youtube-music/api.js";
15
16
  // Generate unique component instance ID
16
17
  let instanceCounter = 0;
17
- function SearchResults({ results, selectedIndex, isActive = true }) {
18
+ function SearchResults({ results, selectedIndex, isActive = true, onMixCreated, }) {
18
19
  const { theme } = useTheme();
19
20
  const { dispatch } = useNavigation();
20
21
  const { play, dispatch: playerDispatch } = usePlayer();
21
22
  const { columns } = useTerminalSize();
22
23
  const musicService = getMusicService();
24
+ const { createPlaylist } = usePlaylist();
25
+ const mixCreatedRef = useRef(onMixCreated);
26
+ mixCreatedRef.current = onMixCreated;
23
27
  // Track component instance and last action time for debouncing
24
28
  const instanceIdRef = useRef(++instanceCounter);
25
29
  const lastSelectTime = useRef(0);
@@ -123,9 +127,100 @@ function SearchResults({ results, selectedIndex, isActive = true }) {
123
127
  logger.debug('SearchResults', 'SELECT key pressed', { isActive, instanceId });
124
128
  playSelected();
125
129
  }, [isActive, playSelected]);
130
+ const createMixPlaylist = useCallback(async () => {
131
+ if (!isActive)
132
+ return;
133
+ const selected = results[selectedIndex];
134
+ if (!selected) {
135
+ logger.warn('SearchResults', 'No result selected for mix');
136
+ return;
137
+ }
138
+ let playlistName = 'Dynamic mix';
139
+ const collectedTracks = [];
140
+ if (selected.type === 'song') {
141
+ const selectedTrack = selected.data;
142
+ const title = selectedTrack.title || 'selected track';
143
+ playlistName = `Mix for ${title}`;
144
+ collectedTracks.push(selectedTrack);
145
+ try {
146
+ const suggestions = await musicService.getSuggestions(selectedTrack.videoId);
147
+ collectedTracks.push(...suggestions);
148
+ }
149
+ catch (error) {
150
+ logger.error('SearchResults', 'Failed to fetch song suggestions', {
151
+ error,
152
+ });
153
+ }
154
+ }
155
+ else if (selected.type === 'artist') {
156
+ const artistName = 'name' in selected.data ? selected.data.name : '';
157
+ if (!artistName) {
158
+ logger.warn('SearchResults', 'Artist name missing for mix');
159
+ mixCreatedRef.current?.('Artist information is missing, cannot create mix.');
160
+ return;
161
+ }
162
+ playlistName = `${artistName} mix`;
163
+ try {
164
+ const response = await musicService.search(artistName, {
165
+ type: 'songs',
166
+ limit: 25,
167
+ });
168
+ const artistTracks = response.results
169
+ .filter(result => result.type === 'song')
170
+ .map(result => result.data);
171
+ collectedTracks.push(...artistTracks);
172
+ }
173
+ catch (error) {
174
+ logger.error('SearchResults', 'Failed to fetch artist songs for mix', {
175
+ error,
176
+ });
177
+ }
178
+ }
179
+ else {
180
+ logger.warn('SearchResults', 'Mix creation unsupported result type', {
181
+ type: selected.type,
182
+ });
183
+ mixCreatedRef.current?.('Mix creation is only supported for songs and artists.');
184
+ return;
185
+ }
186
+ const uniqueTracks = [];
187
+ const seenVideoIds = new Set();
188
+ for (const track of collectedTracks) {
189
+ if (!track?.videoId || seenVideoIds.has(track.videoId))
190
+ continue;
191
+ seenVideoIds.add(track.videoId);
192
+ uniqueTracks.push(track);
193
+ }
194
+ if (uniqueTracks.length === 0) {
195
+ mixCreatedRef.current?.('No similar tracks were found to create a mix.');
196
+ return;
197
+ }
198
+ const playlist = createPlaylist(playlistName, uniqueTracks);
199
+ logger.info('SearchResults', 'Mix playlist created', {
200
+ name: playlist.name,
201
+ trackCount: uniqueTracks.length,
202
+ });
203
+ // Queue the mix tracks and start playing the first one
204
+ playerDispatch({ category: 'SET_QUEUE', queue: uniqueTracks });
205
+ const firstTrack = uniqueTracks[0];
206
+ if (firstTrack) {
207
+ playerDispatch({ category: 'PLAY', track: firstTrack });
208
+ }
209
+ mixCreatedRef.current?.(`Created mix "${playlist.name}" with ${uniqueTracks.length} tracks — playing now.`);
210
+ }, [
211
+ createPlaylist,
212
+ isActive,
213
+ musicService,
214
+ playerDispatch,
215
+ results,
216
+ selectedIndex,
217
+ ]);
126
218
  useKeyBinding(KEYBINDINGS.UP, navigateUp);
127
219
  useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
128
220
  useKeyBinding(KEYBINDINGS.SELECT, handleSelect);
221
+ useKeyBinding(KEYBINDINGS.CREATE_MIX, () => {
222
+ void createMixPlaylist();
223
+ });
129
224
  // Note: Removed redundant useEffect that was syncing selectedIndex to dispatch
130
225
  // This was causing unnecessary re-renders. The selectedIndex is already managed
131
226
  // by the parent component (SearchLayout) and passed down as a prop.
@@ -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);
@@ -218,37 +218,39 @@ class MusicService {
218
218
  async getSuggestions(trackId) {
219
219
  try {
220
220
  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 ?? [];
221
+ // Use music.getUpNext with automix — avoids the yt.getInfo() ParsingError
222
+ // caused by YouTube "Remove ads" menu items that youtubei.js can't parse.
223
+ const panel = await yt.music.getUpNext(trackId, true);
232
224
  const tracks = [];
233
- for (const item of suggestions) {
234
- const videoId = item?.id || '';
235
- if (!videoId)
225
+ for (const item of panel.contents) {
226
+ const video = item;
227
+ const videoId = video.video_id;
228
+ if (!videoId || videoId === trackId)
236
229
  continue;
237
- const title = typeof item.title === 'string' ? item.title : item.title?.text;
230
+ const title = typeof video.title === 'string'
231
+ ? video.title
232
+ : (video.title?.text ?? '');
238
233
  if (!title)
239
234
  continue;
240
235
  tracks.push({
241
236
  videoId,
242
237
  title,
243
- artists: [],
238
+ artists: (video.artists ?? []).map(a => ({
239
+ artistId: a.channel_id ?? '',
240
+ name: a.name ?? 'Unknown',
241
+ })),
242
+ duration: video.duration?.seconds ?? 0,
244
243
  });
245
244
  }
246
- return tracks.slice(0, 10);
245
+ logger.debug('MusicService', 'getSuggestions success', {
246
+ trackId,
247
+ count: tracks.length,
248
+ });
249
+ return tracks.slice(0, 15);
247
250
  }
248
251
  catch (error) {
249
- logger.error('MusicService', 'getSuggestions failed', {
250
- error: error instanceof Error ? error.message : String(error),
251
- });
252
+ const message = error instanceof Error ? error.message : String(error);
253
+ logger.warn('MusicService', 'getSuggestions failed', { error: message });
252
254
  return [];
253
255
  }
254
256
  }
@@ -59,6 +59,7 @@ 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"];
63
64
  };
64
65
  export declare const DEFAULT_VOLUME = 70;
@@ -71,6 +71,7 @@ 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'],
75
76
  };
76
77
  // Default volume
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.9",
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",