@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 +8 -0
- package/dist/source/cli.js +1 -1
- package/dist/source/services/scrobbling/scrobbling.service.js +1 -1
- package/dist/source/services/web/static-file.service.d.ts +1 -0
- package/dist/source/services/web/static-file.service.js +24 -2
- package/dist/source/services/youtube-music/api.js +226 -41
- package/dist/youtube-music-cli.exe +0 -0
- package/package.json +1 -1
- package/readme.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,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
|
package/dist/source/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { APP_VERSION } from "./utils/constants.js";
|
|
|
15
15
|
const cli = meow(`
|
|
16
16
|
Usage
|
|
17
17
|
$ youtube-music-cli
|
|
18
|
-
$ youtube-music-cli play <track-id>
|
|
18
|
+
$ youtube-music-cli play <track-id|youtube-url>
|
|
19
19
|
$ youtube-music-cli search <query>
|
|
20
20
|
$ youtube-music-cli playlist <playlist-id>
|
|
21
21
|
$ youtube-music-cli suggestions
|
|
@@ -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 =
|
|
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
|
|
111
|
+
const resultLimit = options.limit ?? 20;
|
|
112
|
+
const cacheKey = `search:${searchType}:${resultLimit}:${query}`;
|
|
17
113
|
// Return cached result if available
|
|
18
114
|
const cached = this.searchCache.get(cacheKey);
|
|
19
115
|
if (cached) {
|
|
@@ -26,17 +122,97 @@ class MusicService {
|
|
|
26
122
|
const results = [];
|
|
27
123
|
try {
|
|
28
124
|
const yt = await getClient();
|
|
29
|
-
const
|
|
30
|
-
|
|
125
|
+
const musicSearch = (await yt.music.search(query, {
|
|
126
|
+
type: toMusicSearchType(searchType),
|
|
127
|
+
}));
|
|
31
128
|
if (searchType === 'all' || searchType === 'songs') {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
129
|
+
const songItems = [
|
|
130
|
+
...getMusicShelfItems(musicSearch.songs),
|
|
131
|
+
...getMusicShelfItems(musicSearch.videos),
|
|
132
|
+
];
|
|
133
|
+
for (const item of songItems) {
|
|
134
|
+
const track = toTrack(item);
|
|
135
|
+
if (!track) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
results.push({
|
|
139
|
+
type: 'song',
|
|
140
|
+
data: track,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (searchType === 'all' || searchType === 'playlists') {
|
|
145
|
+
for (const playlist of getMusicShelfItems(musicSearch.playlists)) {
|
|
146
|
+
const playlistId = playlist.id?.trim();
|
|
147
|
+
if (!playlistId) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
results.push({
|
|
151
|
+
type: 'playlist',
|
|
152
|
+
data: {
|
|
153
|
+
playlistId,
|
|
154
|
+
name: playlist.title || playlist.name || 'Unknown Playlist',
|
|
155
|
+
tracks: [],
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (searchType === 'all' || searchType === 'artists') {
|
|
161
|
+
for (const artist of getMusicShelfItems(musicSearch.artists)) {
|
|
162
|
+
const artistId = artist.id?.trim() ||
|
|
163
|
+
artist.author?.channel_id ||
|
|
164
|
+
artist.author?.id ||
|
|
165
|
+
'';
|
|
166
|
+
if (!artistId) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
results.push({
|
|
170
|
+
type: 'artist',
|
|
171
|
+
data: {
|
|
172
|
+
artistId,
|
|
173
|
+
name: artist.name ||
|
|
174
|
+
artist.title ||
|
|
175
|
+
artist.author?.name ||
|
|
176
|
+
'Unknown Artist',
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (searchType === 'all' || searchType === 'albums') {
|
|
182
|
+
for (const album of getMusicShelfItems(musicSearch.albums)) {
|
|
183
|
+
const albumId = album.id?.trim();
|
|
184
|
+
if (!albumId) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
results.push({
|
|
188
|
+
type: 'album',
|
|
189
|
+
data: {
|
|
190
|
+
albumId,
|
|
191
|
+
name: album.title || album.name || 'Unknown Album',
|
|
192
|
+
artists: (album.artists ?? []).map(artist => ({
|
|
193
|
+
artistId: artist.channel_id || artist.id || '',
|
|
194
|
+
name: artist.name ?? 'Unknown',
|
|
195
|
+
})),
|
|
196
|
+
tracks: [],
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (results.length === 0) {
|
|
202
|
+
const search = (await yt.search(query));
|
|
203
|
+
if (searchType === 'all' || searchType === 'songs') {
|
|
204
|
+
const videos = search.videos;
|
|
205
|
+
if (videos) {
|
|
206
|
+
for (const video of videos) {
|
|
207
|
+
const rawVideoId = video.id || video.video_id || '';
|
|
208
|
+
const videoId = parseVideoId(rawVideoId);
|
|
209
|
+
if ((!video.type && !rawVideoId) || !videoId) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
36
212
|
results.push({
|
|
37
213
|
type: 'song',
|
|
38
214
|
data: {
|
|
39
|
-
videoId
|
|
215
|
+
videoId,
|
|
40
216
|
title: (typeof video.title === 'string'
|
|
41
217
|
? video.title
|
|
42
218
|
: video.title?.text) || 'Unknown',
|
|
@@ -56,46 +232,50 @@ class MusicService {
|
|
|
56
232
|
}
|
|
57
233
|
}
|
|
58
234
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
:
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
}
|
|
235
|
+
if (searchType === 'all' || searchType === 'playlists') {
|
|
236
|
+
const playlists = search.playlists;
|
|
237
|
+
if (playlists) {
|
|
238
|
+
for (const playlist of playlists) {
|
|
239
|
+
results.push({
|
|
240
|
+
type: 'playlist',
|
|
241
|
+
data: {
|
|
242
|
+
playlistId: playlist.id || '',
|
|
243
|
+
name: (typeof playlist.title === 'string'
|
|
244
|
+
? playlist.title
|
|
245
|
+
: playlist.title?.text) || 'Unknown Playlist',
|
|
246
|
+
tracks: [],
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
74
250
|
}
|
|
75
251
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
}
|
|
252
|
+
if (searchType === 'all' || searchType === 'artists') {
|
|
253
|
+
const channels = search.channels;
|
|
254
|
+
if (channels) {
|
|
255
|
+
for (const channel of channels) {
|
|
256
|
+
results.push({
|
|
257
|
+
type: 'artist',
|
|
258
|
+
data: {
|
|
259
|
+
artistId: channel.id || channel.channelId || '',
|
|
260
|
+
name: (typeof channel.author === 'string'
|
|
261
|
+
? channel.author
|
|
262
|
+
: channel.author?.name) || 'Unknown Artist',
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
90
266
|
}
|
|
91
267
|
}
|
|
92
268
|
}
|
|
93
269
|
}
|
|
94
270
|
catch (error) {
|
|
95
|
-
|
|
271
|
+
logger.error('MusicService', 'Search failed', {
|
|
272
|
+
query,
|
|
273
|
+
searchType,
|
|
274
|
+
error: error instanceof Error ? error.message : String(error),
|
|
275
|
+
});
|
|
96
276
|
}
|
|
97
277
|
const response = {
|
|
98
|
-
results,
|
|
278
|
+
results: results.slice(0, resultLimit),
|
|
99
279
|
hasMore: false,
|
|
100
280
|
};
|
|
101
281
|
// Cache the result
|
|
@@ -103,8 +283,13 @@ class MusicService {
|
|
|
103
283
|
return response;
|
|
104
284
|
}
|
|
105
285
|
async getTrack(videoId) {
|
|
286
|
+
const normalizedVideoId = parseVideoId(videoId);
|
|
287
|
+
if (!normalizedVideoId) {
|
|
288
|
+
logger.warn('MusicService', 'Invalid track id/url provided', { videoId });
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
106
291
|
return {
|
|
107
|
-
videoId,
|
|
292
|
+
videoId: normalizedVideoId,
|
|
108
293
|
title: 'Unknown Track',
|
|
109
294
|
artists: [],
|
|
110
295
|
};
|
|
Binary file
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -105,7 +105,7 @@ brew install involvex/youtube-music-cli/youtube-music-cli
|
|
|
105
105
|
winget install Involvex.YoutubeMusicCLI
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
> Maintainers: tag pushes trigger `.github/workflows/homebrew-publish.yml` and `.github/workflows/winget-publish.yml`. 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"
|