@involvex/youtube-music-cli 0.0.46 → 0.0.48
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 +4 -0
- package/dist/cli.js.map +1004 -0
- package/dist/source/hooks/usePlayer.d.ts +1 -0
- package/dist/source/services/player-state/player-state.service.d.ts +1 -0
- package/dist/source/stores/player.store.d.ts +1 -0
- package/dist/source/types/actions.d.ts +4 -0
- package/dist/source/types/player.types.d.ts +3 -2
- package/dist/source/utils/constants.d.ts +1 -0
- package/dist/source/utils/icons.d.ts +1 -0
- package/dist/youtube-music-cli +0 -0
- package/package.json +1 -1
- package/dist/eslint.config.js +0 -55
- package/dist/package.json +0 -120
- package/dist/scripts/build-cli.js +0 -46
- package/dist/source/app.js +0 -17
- package/dist/source/cli.js +0 -504
- package/dist/source/components/common/ErrorBoundary.js +0 -22
- package/dist/source/components/common/Help.js +0 -18
- package/dist/source/components/common/ShortcutsBar.js +0 -80
- package/dist/source/components/config/ConfigLayout.js +0 -84
- package/dist/source/components/config/KeybindingsLayout.js +0 -107
- package/dist/source/components/export/ExportLayout.js +0 -111
- package/dist/source/components/import/ImportLayout.js +0 -119
- package/dist/source/components/import/ImportProgress.js +0 -73
- package/dist/source/components/layouts/ExploreLayout.js +0 -72
- package/dist/source/components/layouts/HistoryLayout.js +0 -37
- package/dist/source/components/layouts/LyricsLayout.js +0 -89
- package/dist/source/components/layouts/MainLayout.js +0 -190
- package/dist/source/components/layouts/MiniPlayerLayout.js +0 -20
- package/dist/source/components/layouts/PlayerLayout.js +0 -9
- package/dist/source/components/layouts/PluginsLayout.js +0 -77
- package/dist/source/components/layouts/SearchLayout.js +0 -193
- package/dist/source/components/layouts/TrendingLayout.js +0 -59
- package/dist/source/components/player/NowPlaying.js +0 -45
- package/dist/source/components/player/PlayerControls.js +0 -83
- package/dist/source/components/player/ProgressBar.js +0 -19
- package/dist/source/components/player/QueueList.js +0 -36
- package/dist/source/components/player/Suggestions.js +0 -50
- package/dist/source/components/playlist/PlaylistList.js +0 -138
- package/dist/source/components/plugins/PluginInstallDialog.js +0 -41
- package/dist/source/components/plugins/PluginsAvailable.js +0 -55
- package/dist/source/components/plugins/PluginsList.js +0 -18
- package/dist/source/components/search/SearchBar.js +0 -55
- package/dist/source/components/search/SearchHistory.js +0 -35
- package/dist/source/components/search/SearchResults.js +0 -280
- package/dist/source/components/settings/Settings.js +0 -211
- package/dist/source/components/theme/ThemeSwitcher.js +0 -11
- package/dist/source/config/themes.config.js +0 -123
- package/dist/source/contexts/theme.context.js +0 -29
- package/dist/source/hooks/useKeyboard.js +0 -188
- package/dist/source/hooks/useKeyboardBlocker.js +0 -45
- package/dist/source/hooks/useNavigation.js +0 -5
- package/dist/source/hooks/usePlayer.js +0 -43
- package/dist/source/hooks/usePlaylist.js +0 -65
- package/dist/source/hooks/useSearch.js +0 -76
- package/dist/source/hooks/useSleepTimer.js +0 -48
- package/dist/source/hooks/useTerminalSize.js +0 -24
- package/dist/source/hooks/useTheme.js +0 -5
- package/dist/source/hooks/useYouTubeMusic.js +0 -112
- package/dist/source/main.js +0 -127
- package/dist/source/services/cache/cache.service.js +0 -67
- package/dist/source/services/completions/completions.service.js +0 -313
- package/dist/source/services/config/config.service.js +0 -191
- package/dist/source/services/discord/discord-rpc.service.js +0 -95
- package/dist/source/services/download/download.service.js +0 -350
- package/dist/source/services/export/export.service.js +0 -131
- package/dist/source/services/history/history.service.js +0 -83
- package/dist/source/services/import/import.service.js +0 -272
- package/dist/source/services/import/spotify.service.js +0 -171
- package/dist/source/services/import/track-matcher.service.js +0 -271
- package/dist/source/services/import/youtube-import.service.js +0 -84
- package/dist/source/services/logger/logger.service.js +0 -52
- package/dist/source/services/lyrics/lyrics.service.js +0 -93
- package/dist/source/services/mpris/mpris.service.js +0 -78
- package/dist/source/services/notification/notification.service.js +0 -57
- package/dist/source/services/player/dependency-check.service.js +0 -140
- package/dist/source/services/player/player.service.js +0 -478
- package/dist/source/services/player-state/player-state.service.js +0 -122
- package/dist/source/services/plugin/plugin-audio-api.js +0 -36
- package/dist/source/services/plugin/plugin-context.js +0 -256
- package/dist/source/services/plugin/plugin-hooks.service.js +0 -135
- package/dist/source/services/plugin/plugin-installer.service.js +0 -248
- package/dist/source/services/plugin/plugin-loader.service.js +0 -161
- package/dist/source/services/plugin/plugin-permissions.service.js +0 -194
- package/dist/source/services/plugin/plugin-registry.service.js +0 -215
- package/dist/source/services/plugin/plugin-ui-api.js +0 -46
- package/dist/source/services/plugin/plugin-updater.service.js +0 -206
- package/dist/source/services/scrobbling/scrobbling.service.js +0 -115
- package/dist/source/services/sleep-timer/sleep-timer.service.js +0 -45
- package/dist/source/services/version-check/version-check.service.js +0 -121
- package/dist/source/services/web/static-file.service.js +0 -185
- package/dist/source/services/web/web-server-manager.js +0 -506
- package/dist/source/services/web/web-streaming.service.js +0 -290
- package/dist/source/services/web/websocket.server.js +0 -267
- package/dist/source/services/youtube-music/api.js +0 -649
- package/dist/source/services/youtube-music/search.service.js +0 -38
- package/dist/source/stores/history.store.js +0 -64
- package/dist/source/stores/navigation.store.js +0 -90
- package/dist/source/stores/player.store.js +0 -724
- package/dist/source/stores/plugins.store.js +0 -177
- package/dist/source/types/actions.js +0 -1
- package/dist/source/types/cli.types.js +0 -1
- package/dist/source/types/config.types.js +0 -1
- package/dist/source/types/history.types.js +0 -1
- package/dist/source/types/import.types.js +0 -2
- package/dist/source/types/keyboard.types.js +0 -1
- package/dist/source/types/navigation.types.js +0 -1
- package/dist/source/types/player.types.js +0 -1
- package/dist/source/types/playlist.types.js +0 -1
- package/dist/source/types/plugin.types.js +0 -1
- package/dist/source/types/theme.types.js +0 -1
- package/dist/source/types/web.types.js +0 -2
- package/dist/source/types/youtube-music.types.js +0 -1
- package/dist/source/types/youtubei.types.js +0 -3
- package/dist/source/utils/constants.js +0 -134
- package/dist/source/utils/format.js +0 -24
- package/dist/source/utils/icons.js +0 -26
- package/dist/source/utils/search-filters.js +0 -100
|
@@ -1,649 +0,0 @@
|
|
|
1
|
-
import { Innertube, Log } from 'youtubei.js';
|
|
2
|
-
import { logger } from "../logger/logger.service.js";
|
|
3
|
-
import { getSearchCache } from "../cache/cache.service.js";
|
|
4
|
-
// Initialize YouTube client
|
|
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
|
-
}
|
|
99
|
-
async function getClient() {
|
|
100
|
-
if (!ytClient) {
|
|
101
|
-
// Suppress noisy youtubei.js parser warnings in TUI output.
|
|
102
|
-
Log.setLevel(Log.Level.ERROR);
|
|
103
|
-
ytClient = await Innertube.create();
|
|
104
|
-
}
|
|
105
|
-
return ytClient;
|
|
106
|
-
}
|
|
107
|
-
class MusicService {
|
|
108
|
-
searchCache = getSearchCache();
|
|
109
|
-
async search(query, options = {}) {
|
|
110
|
-
const searchType = options.type || 'all';
|
|
111
|
-
const resultLimit = options.limit ?? 20;
|
|
112
|
-
const cacheKey = `search:${searchType}:${resultLimit}:${query}`;
|
|
113
|
-
// Return cached result if available
|
|
114
|
-
const cached = this.searchCache.get(cacheKey);
|
|
115
|
-
if (cached) {
|
|
116
|
-
logger.debug('MusicService', 'Returning cached search results', {
|
|
117
|
-
query,
|
|
118
|
-
resultCount: cached.results.length,
|
|
119
|
-
});
|
|
120
|
-
return cached;
|
|
121
|
-
}
|
|
122
|
-
const results = [];
|
|
123
|
-
try {
|
|
124
|
-
const yt = await getClient();
|
|
125
|
-
const musicSearch = (await yt.music.search(query, {
|
|
126
|
-
type: toMusicSearchType(searchType),
|
|
127
|
-
}));
|
|
128
|
-
if (searchType === 'all' || searchType === 'songs') {
|
|
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
|
-
}
|
|
212
|
-
results.push({
|
|
213
|
-
type: 'song',
|
|
214
|
-
data: {
|
|
215
|
-
videoId,
|
|
216
|
-
title: (typeof video.title === 'string'
|
|
217
|
-
? video.title
|
|
218
|
-
: video.title?.text) || 'Unknown',
|
|
219
|
-
artists: [
|
|
220
|
-
{
|
|
221
|
-
artistId: video.channel_id || video.channel?.id || '',
|
|
222
|
-
name: (typeof video.author === 'string'
|
|
223
|
-
? video.author
|
|
224
|
-
: video.author?.name) || 'Unknown',
|
|
225
|
-
},
|
|
226
|
-
],
|
|
227
|
-
duration: (typeof video.duration === 'number'
|
|
228
|
-
? video.duration
|
|
229
|
-
: video.duration?.seconds) || 0,
|
|
230
|
-
},
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
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
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
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
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
catch (error) {
|
|
271
|
-
logger.error('MusicService', 'Search failed', {
|
|
272
|
-
query,
|
|
273
|
-
searchType,
|
|
274
|
-
error: error instanceof Error ? error.message : String(error),
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
const response = {
|
|
278
|
-
results: results.slice(0, resultLimit),
|
|
279
|
-
hasMore: false,
|
|
280
|
-
};
|
|
281
|
-
// Cache the result
|
|
282
|
-
this.searchCache.set(cacheKey, response);
|
|
283
|
-
return response;
|
|
284
|
-
}
|
|
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
|
-
}
|
|
291
|
-
return {
|
|
292
|
-
videoId: normalizedVideoId,
|
|
293
|
-
title: 'Unknown Track',
|
|
294
|
-
artists: [],
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
async getAlbum(albumId) {
|
|
298
|
-
return {
|
|
299
|
-
albumId,
|
|
300
|
-
name: 'Unknown Album',
|
|
301
|
-
artists: [],
|
|
302
|
-
tracks: [],
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
async getArtist(artistId) {
|
|
306
|
-
return {
|
|
307
|
-
artistId,
|
|
308
|
-
name: 'Unknown Artist',
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
async getPlaylist(playlistId) {
|
|
312
|
-
try {
|
|
313
|
-
const yt = await getClient();
|
|
314
|
-
const playlistData = (await yt.music.getPlaylist(playlistId));
|
|
315
|
-
const rows = [
|
|
316
|
-
...(playlistData.contents ?? []),
|
|
317
|
-
...(playlistData.tracks ?? []),
|
|
318
|
-
];
|
|
319
|
-
const seen = new Set();
|
|
320
|
-
const tracks = [];
|
|
321
|
-
for (const row of rows) {
|
|
322
|
-
const videoId = row.id || row.video_id;
|
|
323
|
-
if (!videoId || seen.has(videoId))
|
|
324
|
-
continue;
|
|
325
|
-
seen.add(videoId);
|
|
326
|
-
tracks.push({
|
|
327
|
-
videoId,
|
|
328
|
-
title: (typeof row.title === 'string' ? row.title : row.title?.text) ??
|
|
329
|
-
'Unknown',
|
|
330
|
-
artists: (row.artists ?? []).map(artist => ({
|
|
331
|
-
artistId: artist.channel_id || artist.id || '',
|
|
332
|
-
name: artist.name ?? 'Unknown',
|
|
333
|
-
})),
|
|
334
|
-
duration: typeof row.duration === 'number'
|
|
335
|
-
? row.duration
|
|
336
|
-
: (row.duration?.seconds ?? 0),
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
return {
|
|
340
|
-
playlistId,
|
|
341
|
-
name: playlistData.title || playlistData.name || 'Unknown Playlist',
|
|
342
|
-
tracks,
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
catch (error) {
|
|
346
|
-
logger.error('MusicService', 'getPlaylist failed', {
|
|
347
|
-
playlistId,
|
|
348
|
-
error: error instanceof Error ? error.message : String(error),
|
|
349
|
-
});
|
|
350
|
-
return {
|
|
351
|
-
playlistId,
|
|
352
|
-
name: 'Unknown Playlist',
|
|
353
|
-
tracks: [],
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
async getTrending() {
|
|
358
|
-
try {
|
|
359
|
-
const yt = await getClient();
|
|
360
|
-
const trending = (await yt.getTrending());
|
|
361
|
-
const tracks = [];
|
|
362
|
-
const sections = trending.sections ?? [];
|
|
363
|
-
for (const section of sections) {
|
|
364
|
-
for (const item of section.items ?? []) {
|
|
365
|
-
const videoId = item.id || item.video_id;
|
|
366
|
-
if (!videoId)
|
|
367
|
-
continue;
|
|
368
|
-
tracks.push({
|
|
369
|
-
videoId,
|
|
370
|
-
title: (typeof item.title === 'string'
|
|
371
|
-
? item.title
|
|
372
|
-
: item.title?.text) ?? 'Unknown',
|
|
373
|
-
artists: [
|
|
374
|
-
{
|
|
375
|
-
artistId: '',
|
|
376
|
-
name: (typeof item.author === 'string'
|
|
377
|
-
? item.author
|
|
378
|
-
: item.author?.name) ?? 'Unknown',
|
|
379
|
-
},
|
|
380
|
-
],
|
|
381
|
-
duration: (typeof item.duration === 'number'
|
|
382
|
-
? item.duration
|
|
383
|
-
: item.duration?.seconds) ?? 0,
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return tracks.slice(0, 25);
|
|
388
|
-
}
|
|
389
|
-
catch (error) {
|
|
390
|
-
logger.error('MusicService', 'getTrending failed', {
|
|
391
|
-
error: error instanceof Error ? error.message : String(error),
|
|
392
|
-
});
|
|
393
|
-
return [];
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
async getExploreSections() {
|
|
397
|
-
try {
|
|
398
|
-
const yt = await getClient();
|
|
399
|
-
const music = yt.music;
|
|
400
|
-
const explore = (await music.getExplore());
|
|
401
|
-
const result = [];
|
|
402
|
-
for (const section of explore.sections ?? []) {
|
|
403
|
-
const title = (typeof section.header?.title === 'string'
|
|
404
|
-
? section.header.title
|
|
405
|
-
: section.header?.title?.text) ?? 'Featured';
|
|
406
|
-
const tracks = [];
|
|
407
|
-
for (const item of section.contents ?? []) {
|
|
408
|
-
const videoId = item.id || item.video_id;
|
|
409
|
-
if (!videoId)
|
|
410
|
-
continue;
|
|
411
|
-
tracks.push({
|
|
412
|
-
videoId,
|
|
413
|
-
title: (typeof item.title === 'string'
|
|
414
|
-
? item.title
|
|
415
|
-
: item.title?.text) ?? 'Unknown',
|
|
416
|
-
artists: [
|
|
417
|
-
{
|
|
418
|
-
artistId: '',
|
|
419
|
-
name: (typeof item.author === 'string'
|
|
420
|
-
? item.author
|
|
421
|
-
: item.author?.name) ?? 'Unknown',
|
|
422
|
-
},
|
|
423
|
-
],
|
|
424
|
-
duration: (typeof item.duration === 'number'
|
|
425
|
-
? item.duration
|
|
426
|
-
: item.duration?.seconds) ?? 0,
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
if (tracks.length > 0) {
|
|
430
|
-
result.push({ title, tracks: tracks.slice(0, 10) });
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
return result;
|
|
434
|
-
}
|
|
435
|
-
catch (error) {
|
|
436
|
-
logger.error('MusicService', 'getExploreSections failed', {
|
|
437
|
-
error: error instanceof Error ? error.message : String(error),
|
|
438
|
-
});
|
|
439
|
-
return [];
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
async getSuggestions(trackId) {
|
|
443
|
-
try {
|
|
444
|
-
const yt = await getClient();
|
|
445
|
-
// Use music.getUpNext with automix — avoids the yt.getInfo() ParsingError
|
|
446
|
-
// caused by YouTube "Remove ads" menu items that youtubei.js can't parse.
|
|
447
|
-
const panel = await yt.music.getUpNext(trackId, true);
|
|
448
|
-
const tracks = [];
|
|
449
|
-
for (const item of panel.contents) {
|
|
450
|
-
const video = item;
|
|
451
|
-
const videoId = video.video_id;
|
|
452
|
-
if (!videoId || videoId === trackId)
|
|
453
|
-
continue;
|
|
454
|
-
const title = typeof video.title === 'string'
|
|
455
|
-
? video.title
|
|
456
|
-
: (video.title?.text ?? '');
|
|
457
|
-
if (!title)
|
|
458
|
-
continue;
|
|
459
|
-
tracks.push({
|
|
460
|
-
videoId,
|
|
461
|
-
title,
|
|
462
|
-
artists: (video.artists ?? []).map(a => ({
|
|
463
|
-
artistId: a.channel_id ?? '',
|
|
464
|
-
name: a.name ?? 'Unknown',
|
|
465
|
-
})),
|
|
466
|
-
duration: video.duration?.seconds ?? 0,
|
|
467
|
-
});
|
|
468
|
-
}
|
|
469
|
-
logger.debug('MusicService', 'getSuggestions success', {
|
|
470
|
-
trackId,
|
|
471
|
-
count: tracks.length,
|
|
472
|
-
});
|
|
473
|
-
return tracks.slice(0, 15);
|
|
474
|
-
}
|
|
475
|
-
catch (error) {
|
|
476
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
477
|
-
logger.warn('MusicService', 'getSuggestions failed', { error: message });
|
|
478
|
-
return [];
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
async getStreamUrl(videoId) {
|
|
482
|
-
logger.info('MusicService', 'Starting stream extraction', { videoId });
|
|
483
|
-
const isBunRuntime = typeof globalThis.Bun !== 'undefined';
|
|
484
|
-
// Try Method 1: @distube/ytdl-core (skip under Bun due undici incompatibility)
|
|
485
|
-
if (isBunRuntime) {
|
|
486
|
-
logger.warn('MusicService', 'Skipping ytdl-core extraction on Bun runtime', { videoId });
|
|
487
|
-
}
|
|
488
|
-
else {
|
|
489
|
-
try {
|
|
490
|
-
logger.debug('MusicService', 'Attempting ytdl-core extraction', {
|
|
491
|
-
videoId,
|
|
492
|
-
});
|
|
493
|
-
const ytdl = await import('@distube/ytdl-core');
|
|
494
|
-
const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
|
495
|
-
const info = await ytdl.default.getInfo(videoUrl);
|
|
496
|
-
logger.debug('MusicService', 'ytdl-core getInfo succeeded', {
|
|
497
|
-
formatCount: info.formats.length,
|
|
498
|
-
});
|
|
499
|
-
const audioFormats = ytdl.default.filterFormats(info.formats, 'audioonly');
|
|
500
|
-
logger.debug('MusicService', 'ytdl-core audio formats filtered', {
|
|
501
|
-
audioFormatCount: audioFormats.length,
|
|
502
|
-
});
|
|
503
|
-
if (audioFormats.length > 0) {
|
|
504
|
-
// Get highest quality audio
|
|
505
|
-
const bestAudio = audioFormats.sort((a, b) => {
|
|
506
|
-
const aBitrate = Number.parseInt(String(a.audioBitrate || 0));
|
|
507
|
-
const bBitrate = Number.parseInt(String(b.audioBitrate || 0));
|
|
508
|
-
return bBitrate - aBitrate;
|
|
509
|
-
})[0];
|
|
510
|
-
if (bestAudio?.url) {
|
|
511
|
-
logger.info('MusicService', 'Using ytdl-core stream', {
|
|
512
|
-
bitrate: bestAudio.audioBitrate,
|
|
513
|
-
urlLength: bestAudio.url.length,
|
|
514
|
-
mimeType: bestAudio.mimeType,
|
|
515
|
-
});
|
|
516
|
-
return bestAudio.url;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
logger.warn('MusicService', 'ytdl-core: No audio formats with URL found');
|
|
520
|
-
}
|
|
521
|
-
catch (error) {
|
|
522
|
-
logger.error('MusicService', 'ytdl-core extraction failed', {
|
|
523
|
-
error: error instanceof Error ? error.message : String(error),
|
|
524
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
// Try Method 2: youtube-ext (lightweight, no parser path)
|
|
529
|
-
try {
|
|
530
|
-
logger.debug('MusicService', 'Attempting youtube-ext extraction', {
|
|
531
|
-
videoId,
|
|
532
|
-
});
|
|
533
|
-
const { videoInfo, getFormats } = await import('youtube-ext');
|
|
534
|
-
const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
|
535
|
-
const info = await videoInfo(videoUrl);
|
|
536
|
-
logger.debug('MusicService', 'youtube-ext videoInfo succeeded');
|
|
537
|
-
// Decode stream URLs first
|
|
538
|
-
const decodedFormats = await getFormats(info.stream);
|
|
539
|
-
logger.debug('MusicService', 'youtube-ext formats decoded', {
|
|
540
|
-
formatCount: decodedFormats.length,
|
|
541
|
-
});
|
|
542
|
-
// Get best audio format from decoded adaptive formats
|
|
543
|
-
const audioFormats = decodedFormats.filter(f => f.mimeType?.includes('audio') && f.url);
|
|
544
|
-
logger.debug('MusicService', 'youtube-ext audio formats filtered', {
|
|
545
|
-
audioFormatCount: audioFormats.length,
|
|
546
|
-
});
|
|
547
|
-
if (audioFormats.length > 0) {
|
|
548
|
-
// Sort by bitrate descending and get best quality
|
|
549
|
-
const bestAudio = audioFormats.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0];
|
|
550
|
-
if (bestAudio?.url) {
|
|
551
|
-
logger.info('MusicService', 'Using youtube-ext stream', {
|
|
552
|
-
bitrate: bestAudio.bitrate,
|
|
553
|
-
urlLength: bestAudio.url.length,
|
|
554
|
-
});
|
|
555
|
-
return bestAudio.url;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
logger.warn('MusicService', 'youtube-ext: No audio formats with URL found');
|
|
559
|
-
}
|
|
560
|
-
catch (error) {
|
|
561
|
-
logger.error('MusicService', 'youtube-ext extraction failed', {
|
|
562
|
-
error: error instanceof Error ? error.message : String(error),
|
|
563
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
564
|
-
});
|
|
565
|
-
}
|
|
566
|
-
// Try Method 3: Invidious API (last resort)
|
|
567
|
-
try {
|
|
568
|
-
logger.debug('MusicService', 'Attempting Invidious extraction', {
|
|
569
|
-
videoId,
|
|
570
|
-
});
|
|
571
|
-
const url = await this.getInvidiousStreamUrl(videoId);
|
|
572
|
-
logger.info('MusicService', 'Using Invidious stream', {
|
|
573
|
-
urlLength: url.length,
|
|
574
|
-
});
|
|
575
|
-
return url;
|
|
576
|
-
}
|
|
577
|
-
catch (error) {
|
|
578
|
-
logger.error('MusicService', 'Invidious extraction failed', {
|
|
579
|
-
error: error instanceof Error ? error.message : String(error),
|
|
580
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
581
|
-
});
|
|
582
|
-
}
|
|
583
|
-
// All methods failed
|
|
584
|
-
logger.error('MusicService', 'All stream extraction methods failed', {
|
|
585
|
-
videoId,
|
|
586
|
-
});
|
|
587
|
-
throw new Error('All stream extraction methods failed');
|
|
588
|
-
}
|
|
589
|
-
async getInvidiousStreamUrl(videoId) {
|
|
590
|
-
// Try multiple Invidious instances as fallback
|
|
591
|
-
const instances = [
|
|
592
|
-
'https://vid.puffyan.us',
|
|
593
|
-
'https://invidious.perennialte.ch',
|
|
594
|
-
'https://yewtu.be',
|
|
595
|
-
];
|
|
596
|
-
for (const instance of instances) {
|
|
597
|
-
try {
|
|
598
|
-
logger.debug('MusicService', 'Trying Invidious instance', { instance });
|
|
599
|
-
const response = await fetch(`${instance}/api/v1/videos/${videoId}`);
|
|
600
|
-
if (!response.ok) {
|
|
601
|
-
logger.debug('MusicService', 'Invidious instance returned non-OK', {
|
|
602
|
-
instance,
|
|
603
|
-
status: response.status,
|
|
604
|
-
});
|
|
605
|
-
continue;
|
|
606
|
-
}
|
|
607
|
-
const videoData = (await response.json());
|
|
608
|
-
// Look for audio-only streams
|
|
609
|
-
const audioFormats = [
|
|
610
|
-
...(videoData.adaptiveFormats || []),
|
|
611
|
-
...(videoData.formatStreams || []),
|
|
612
|
-
].filter(f => f.type?.toLowerCase().includes('audio'));
|
|
613
|
-
logger.debug('MusicService', 'Invidious audio formats found', {
|
|
614
|
-
instance,
|
|
615
|
-
count: audioFormats.length,
|
|
616
|
-
});
|
|
617
|
-
if (audioFormats.length > 0) {
|
|
618
|
-
const firstAudio = audioFormats[0];
|
|
619
|
-
if (firstAudio?.url) {
|
|
620
|
-
logger.debug('MusicService', 'Invidious stream URL obtained', {
|
|
621
|
-
instance,
|
|
622
|
-
urlLength: firstAudio.url.length,
|
|
623
|
-
type: firstAudio.type,
|
|
624
|
-
});
|
|
625
|
-
return firstAudio.url;
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
catch (error) {
|
|
630
|
-
logger.debug('MusicService', 'Invidious instance error', {
|
|
631
|
-
instance,
|
|
632
|
-
error: error instanceof Error ? error.message : String(error),
|
|
633
|
-
});
|
|
634
|
-
// Try next instance
|
|
635
|
-
continue;
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
// If all Invidious instances fail, throw error instead of returning watch URL
|
|
639
|
-
throw new Error('No Invidious instance returned a valid stream URL');
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
// Singleton instance
|
|
643
|
-
let musicServiceInstance = null;
|
|
644
|
-
export function getMusicService() {
|
|
645
|
-
if (!musicServiceInstance) {
|
|
646
|
-
musicServiceInstance = new MusicService();
|
|
647
|
-
}
|
|
648
|
-
return musicServiceInstance;
|
|
649
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { getMusicService } from "./api.js";
|
|
2
|
-
class SearchService {
|
|
3
|
-
musicService = getMusicService();
|
|
4
|
-
async search(query, options = {}) {
|
|
5
|
-
return this.musicService.search(query, options);
|
|
6
|
-
}
|
|
7
|
-
async searchSongs(query, limit = 20) {
|
|
8
|
-
const response = await this.search(query, { type: 'songs', limit });
|
|
9
|
-
return response.results
|
|
10
|
-
.filter(r => r.type === 'song')
|
|
11
|
-
.map(r => r.data);
|
|
12
|
-
}
|
|
13
|
-
async searchAlbums(query, limit = 10) {
|
|
14
|
-
const response = await this.search(query, { type: 'albums', limit });
|
|
15
|
-
return response.results
|
|
16
|
-
.filter(r => r.type === 'album')
|
|
17
|
-
.map(r => r.data);
|
|
18
|
-
}
|
|
19
|
-
async searchArtists(query, limit = 10) {
|
|
20
|
-
const response = await this.search(query, { type: 'artists', limit });
|
|
21
|
-
return response.results
|
|
22
|
-
.filter(r => r.type === 'artist')
|
|
23
|
-
.map(r => r.data);
|
|
24
|
-
}
|
|
25
|
-
async searchPlaylists(query, limit = 10) {
|
|
26
|
-
const response = await this.search(query, { type: 'playlists', limit });
|
|
27
|
-
return response.results
|
|
28
|
-
.filter(r => r.type === 'playlist')
|
|
29
|
-
.map(r => r.data);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
let searchServiceInstance = null;
|
|
33
|
-
export function getSearchService() {
|
|
34
|
-
if (!searchServiceInstance) {
|
|
35
|
-
searchServiceInstance = new SearchService();
|
|
36
|
-
}
|
|
37
|
-
return searchServiceInstance;
|
|
38
|
-
}
|