@involvex/youtube-music-cli 0.0.22 → 0.0.24

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,11 @@
1
+ ## [0.0.24](https://github.com/involvex/youtube-music-cli/compare/v0.0.23...v0.0.24) (2026-02-20)
2
+
3
+ ### Features
4
+
5
+ - add YouTube URL support for play command ([c09e411](https://github.com/involvex/youtube-music-cli/commit/c09e411dd36e5670727d3914203c5c66de08457b))
6
+
7
+ ## [0.0.23](https://github.com/involvex/youtube-music-cli/compare/v0.0.22...v0.0.23) (2026-02-20)
8
+
1
9
  ## [0.0.22](https://github.com/involvex/youtube-music-cli/compare/v0.0.21...v0.0.22) (2026-02-20)
2
10
 
3
11
  ### Features
@@ -15,7 +15,7 @@ import { APP_VERSION } from "./utils/constants.js";
15
15
  const cli = meow(`
16
16
  Usage
17
17
  $ youtube-music-cli
18
- $ youtube-music-cli play <track-id>
18
+ $ youtube-music-cli play <track-id|youtube-url>
19
19
  $ youtube-music-cli search <query>
20
20
  $ youtube-music-cli playlist <playlist-id>
21
21
  $ youtube-music-cli suggestions
@@ -38,7 +38,7 @@ function buildLastfmSignature(params, secret) {
38
38
  .sort()
39
39
  .map(k => `${k}${params[k]}`)
40
40
  .join('');
41
- return createHash('md5')
41
+ return createHash('sha256')
42
42
  .update(sorted + secret)
43
43
  .digest('hex');
44
44
  }
@@ -7,6 +7,7 @@ declare class StaticFileService {
7
7
  * Get MIME type for a file extension
8
8
  */
9
9
  private getMimeType;
10
+ private resolveSafeFilePath;
10
11
  /**
11
12
  * Load index.html into memory
12
13
  */
@@ -1,7 +1,7 @@
1
1
  // Static file serving service for web UI
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
- import { extname, join, dirname } from 'node:path';
4
+ import { extname, join, dirname, normalize, resolve, sep } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { logger } from "../logger/logger.service.js";
7
7
  const MIME_TYPES = {
@@ -55,6 +55,23 @@ class StaticFileService {
55
55
  const ext = extname(filePath).toLowerCase();
56
56
  return MIME_TYPES[ext] || 'application/octet-stream';
57
57
  }
58
+ resolveSafeFilePath(urlPath) {
59
+ let decodedPath;
60
+ try {
61
+ decodedPath = decodeURIComponent(urlPath);
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ const relativePath = normalize(decodedPath).replace(/^[\\/]+/, '');
67
+ const rootPath = resolve(this.webDistDir);
68
+ const resolvedPath = resolve(rootPath, relativePath);
69
+ const rootPrefix = rootPath.endsWith(sep) ? rootPath : `${rootPath}${sep}`;
70
+ if (resolvedPath !== rootPath && !resolvedPath.startsWith(rootPrefix)) {
71
+ return null;
72
+ }
73
+ return resolvedPath;
74
+ }
58
75
  /**
59
76
  * Load index.html into memory
60
77
  */
@@ -111,7 +128,12 @@ class StaticFileService {
111
128
  return;
112
129
  }
113
130
  // Serve static files
114
- const filePath = join(this.webDistDir, urlPath);
131
+ const filePath = this.resolveSafeFilePath(urlPath);
132
+ if (!filePath) {
133
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
134
+ res.end('Bad Request');
135
+ return;
136
+ }
115
137
  try {
116
138
  // Check if file exists
117
139
  if (!existsSync(filePath)) {
@@ -1,10 +1,105 @@
1
- import { Innertube } from 'youtubei.js';
1
+ import { Innertube, Log } from 'youtubei.js';
2
2
  import { logger } from "../logger/logger.service.js";
3
3
  import { getSearchCache } from "../cache/cache.service.js";
4
4
  // Initialize YouTube client
5
5
  let ytClient = null;
6
+ function toMusicSearchType(searchType) {
7
+ switch (searchType) {
8
+ case 'songs': {
9
+ return 'song';
10
+ }
11
+ case 'albums': {
12
+ return 'album';
13
+ }
14
+ case 'artists': {
15
+ return 'artist';
16
+ }
17
+ case 'playlists': {
18
+ return 'playlist';
19
+ }
20
+ default: {
21
+ return 'all';
22
+ }
23
+ }
24
+ }
25
+ function getMusicShelfItems(shelf) {
26
+ if (!shelf || typeof shelf !== 'object') {
27
+ return [];
28
+ }
29
+ const contents = shelf.contents;
30
+ if (!Array.isArray(contents)) {
31
+ return [];
32
+ }
33
+ return contents.filter((item) => !!item && typeof item === 'object');
34
+ }
35
+ function parseVideoId(value) {
36
+ const trimmedValue = value.trim();
37
+ if (!trimmedValue) {
38
+ return null;
39
+ }
40
+ if (!trimmedValue.includes('://') && !trimmedValue.includes('/')) {
41
+ return trimmedValue;
42
+ }
43
+ try {
44
+ const parsedUrl = new URL(trimmedValue);
45
+ const vParam = parsedUrl.searchParams.get('v');
46
+ if (vParam) {
47
+ return vParam;
48
+ }
49
+ const host = parsedUrl.hostname.toLowerCase();
50
+ const isYouTubeHost = host === 'youtu.be' ||
51
+ host === 'youtube.com' ||
52
+ host.endsWith('.youtube.com') ||
53
+ host === 'music.youtube.com';
54
+ if (!isYouTubeHost) {
55
+ return null;
56
+ }
57
+ if (host === 'youtu.be') {
58
+ const pathId = parsedUrl.pathname.split('/').filter(Boolean)[0];
59
+ if (pathId) {
60
+ return pathId;
61
+ }
62
+ }
63
+ const pathId = parsedUrl.pathname
64
+ .split('/')
65
+ .filter(Boolean)
66
+ .find(part => part.length >= 8);
67
+ return pathId ?? null;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ function toTrack(item) {
74
+ const rawId = item.id?.trim() ?? '';
75
+ const videoId = rawId ? parseVideoId(rawId) : null;
76
+ if (!videoId) {
77
+ return null;
78
+ }
79
+ const artists = item.artists && item.artists.length > 0
80
+ ? item.artists.map(artist => ({
81
+ artistId: artist.channel_id || artist.id || '',
82
+ name: artist.name ?? 'Unknown',
83
+ }))
84
+ : [
85
+ {
86
+ artistId: item.author?.channel_id || item.author?.id || '',
87
+ name: item.author?.name ?? 'Unknown',
88
+ },
89
+ ];
90
+ return {
91
+ videoId,
92
+ title: item.title || item.name || 'Unknown',
93
+ artists,
94
+ duration: typeof item.duration === 'number'
95
+ ? item.duration
96
+ : (item.duration?.seconds ?? 0),
97
+ };
98
+ }
6
99
  async function getClient() {
7
100
  if (!ytClient) {
101
+ // Suppress noisy youtubei.js parser warnings in TUI output.
102
+ Log.setLevel(Log.Level.ERROR);
8
103
  ytClient = await Innertube.create();
9
104
  }
10
105
  return ytClient;
@@ -13,7 +108,8 @@ class MusicService {
13
108
  searchCache = getSearchCache();
14
109
  async search(query, options = {}) {
15
110
  const searchType = options.type || 'all';
16
- const cacheKey = `search:${searchType}:${options.limit ?? 20}:${query}`;
111
+ const resultLimit = options.limit ?? 20;
112
+ const cacheKey = `search:${searchType}:${resultLimit}:${query}`;
17
113
  // Return cached result if available
18
114
  const cached = this.searchCache.get(cacheKey);
19
115
  if (cached) {
@@ -26,17 +122,97 @@ class MusicService {
26
122
  const results = [];
27
123
  try {
28
124
  const yt = await getClient();
29
- const search = (await yt.search(query));
30
- // Process search results based on type
125
+ const musicSearch = (await yt.music.search(query, {
126
+ type: toMusicSearchType(searchType),
127
+ }));
31
128
  if (searchType === 'all' || searchType === 'songs') {
32
- const videos = search.videos;
33
- if (videos) {
34
- for (const video of videos) {
35
- if (video.type === 'Video' || video.id) {
129
+ const songItems = [
130
+ ...getMusicShelfItems(musicSearch.songs),
131
+ ...getMusicShelfItems(musicSearch.videos),
132
+ ];
133
+ for (const item of songItems) {
134
+ const track = toTrack(item);
135
+ if (!track) {
136
+ continue;
137
+ }
138
+ results.push({
139
+ type: 'song',
140
+ data: track,
141
+ });
142
+ }
143
+ }
144
+ if (searchType === 'all' || searchType === 'playlists') {
145
+ for (const playlist of getMusicShelfItems(musicSearch.playlists)) {
146
+ const playlistId = playlist.id?.trim();
147
+ if (!playlistId) {
148
+ continue;
149
+ }
150
+ results.push({
151
+ type: 'playlist',
152
+ data: {
153
+ playlistId,
154
+ name: playlist.title || playlist.name || 'Unknown Playlist',
155
+ tracks: [],
156
+ },
157
+ });
158
+ }
159
+ }
160
+ if (searchType === 'all' || searchType === 'artists') {
161
+ for (const artist of getMusicShelfItems(musicSearch.artists)) {
162
+ const artistId = artist.id?.trim() ||
163
+ artist.author?.channel_id ||
164
+ artist.author?.id ||
165
+ '';
166
+ if (!artistId) {
167
+ continue;
168
+ }
169
+ results.push({
170
+ type: 'artist',
171
+ data: {
172
+ artistId,
173
+ name: artist.name ||
174
+ artist.title ||
175
+ artist.author?.name ||
176
+ 'Unknown Artist',
177
+ },
178
+ });
179
+ }
180
+ }
181
+ if (searchType === 'all' || searchType === 'albums') {
182
+ for (const album of getMusicShelfItems(musicSearch.albums)) {
183
+ const albumId = album.id?.trim();
184
+ if (!albumId) {
185
+ continue;
186
+ }
187
+ results.push({
188
+ type: 'album',
189
+ data: {
190
+ albumId,
191
+ name: album.title || album.name || 'Unknown Album',
192
+ artists: (album.artists ?? []).map(artist => ({
193
+ artistId: artist.channel_id || artist.id || '',
194
+ name: artist.name ?? 'Unknown',
195
+ })),
196
+ tracks: [],
197
+ },
198
+ });
199
+ }
200
+ }
201
+ if (results.length === 0) {
202
+ const search = (await yt.search(query));
203
+ if (searchType === 'all' || searchType === 'songs') {
204
+ const videos = search.videos;
205
+ if (videos) {
206
+ for (const video of videos) {
207
+ const rawVideoId = video.id || video.video_id || '';
208
+ const videoId = parseVideoId(rawVideoId);
209
+ if ((!video.type && !rawVideoId) || !videoId) {
210
+ continue;
211
+ }
36
212
  results.push({
37
213
  type: 'song',
38
214
  data: {
39
- videoId: video.id || video.video_id || '',
215
+ videoId,
40
216
  title: (typeof video.title === 'string'
41
217
  ? video.title
42
218
  : video.title?.text) || 'Unknown',
@@ -56,46 +232,50 @@ class MusicService {
56
232
  }
57
233
  }
58
234
  }
59
- }
60
- if (searchType === 'all' || searchType === 'playlists') {
61
- const playlists = search.playlists;
62
- if (playlists) {
63
- for (const playlist of playlists) {
64
- results.push({
65
- type: 'playlist',
66
- data: {
67
- playlistId: playlist.id || '',
68
- name: (typeof playlist.title === 'string'
69
- ? playlist.title
70
- : playlist.title?.text) || 'Unknown Playlist',
71
- tracks: [],
72
- },
73
- });
235
+ if (searchType === 'all' || searchType === 'playlists') {
236
+ const playlists = search.playlists;
237
+ if (playlists) {
238
+ for (const playlist of playlists) {
239
+ results.push({
240
+ type: 'playlist',
241
+ data: {
242
+ playlistId: playlist.id || '',
243
+ name: (typeof playlist.title === 'string'
244
+ ? playlist.title
245
+ : playlist.title?.text) || 'Unknown Playlist',
246
+ tracks: [],
247
+ },
248
+ });
249
+ }
74
250
  }
75
251
  }
76
- }
77
- if (searchType === 'all' || searchType === 'artists') {
78
- const channels = search.channels;
79
- if (channels) {
80
- for (const channel of channels) {
81
- results.push({
82
- type: 'artist',
83
- data: {
84
- artistId: channel.id || channel.channelId || '',
85
- name: (typeof channel.author === 'string'
86
- ? channel.author
87
- : channel.author?.name) || 'Unknown Artist',
88
- },
89
- });
252
+ if (searchType === 'all' || searchType === 'artists') {
253
+ const channels = search.channels;
254
+ if (channels) {
255
+ for (const channel of channels) {
256
+ results.push({
257
+ type: 'artist',
258
+ data: {
259
+ artistId: channel.id || channel.channelId || '',
260
+ name: (typeof channel.author === 'string'
261
+ ? channel.author
262
+ : channel.author?.name) || 'Unknown Artist',
263
+ },
264
+ });
265
+ }
90
266
  }
91
267
  }
92
268
  }
93
269
  }
94
270
  catch (error) {
95
- console.error('Search failed:', error);
271
+ logger.error('MusicService', 'Search failed', {
272
+ query,
273
+ searchType,
274
+ error: error instanceof Error ? error.message : String(error),
275
+ });
96
276
  }
97
277
  const response = {
98
- results,
278
+ results: results.slice(0, resultLimit),
99
279
  hasMore: false,
100
280
  };
101
281
  // Cache the result
@@ -103,8 +283,13 @@ class MusicService {
103
283
  return response;
104
284
  }
105
285
  async getTrack(videoId) {
286
+ const normalizedVideoId = parseVideoId(videoId);
287
+ if (!normalizedVideoId) {
288
+ logger.warn('MusicService', 'Invalid track id/url provided', { videoId });
289
+ return null;
290
+ }
106
291
  return {
107
- videoId,
292
+ videoId: normalizedVideoId,
108
293
  title: 'Unknown Track',
109
294
  artists: [],
110
295
  };
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@involvex/youtube-music-cli",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "description": "- A Commandline music player for youtube-music",
5
5
  "repository": {
6
6
  "type": "git",
package/readme.md CHANGED
@@ -105,7 +105,7 @@ brew install involvex/youtube-music-cli/youtube-music-cli
105
105
  winget install Involvex.YoutubeMusicCLI
106
106
  ```
107
107
 
108
- > Maintainers: tag pushes trigger `.github/workflows/homebrew-publish.yml` and `.github/workflows/winget-publish.yml`. Set `WINGETCREATE_TOKEN` and make sure `Involvex.YoutubeMusicCLI` exists in winget-pkgs for automated updates.
108
+ > Maintainers: tag pushes trigger `.github/workflows/homebrew-publish.yml` and `.github/workflows/winget-publish.yml`. Homebrew uses the tap format `involvex/youtube-music-cli/youtube-music-cli`, so ensure the formula file exists on the default branch at `Formula/youtube-music-cli.rb` for the tap installation to work. Set `WINGETCREATE_TOKEN` and make sure `Involvex.YoutubeMusicCLI` exists in winget-pkgs for automated updates.
109
109
 
110
110
  ### From Source
111
111
 
@@ -131,7 +131,7 @@ youtube-music-cli
131
131
 
132
132
  ```bash
133
133
  # Play a specific track
134
- youtube-music-cli play <video-id>
134
+ youtube-music-cli play <video-id|youtube-url>
135
135
 
136
136
  # Search for music
137
137
  youtube-music-cli search "artist or song name"