@involvex/youtube-music-cli 0.0.11 → 0.0.13

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,17 @@
1
+ ## [0.0.13](https://github.com/involvex/youtube-music-cli/compare/v0.0.12...v0.0.13) (2026-02-18)
2
+
3
+ ### Features
4
+
5
+ - **download:** add cover art and improved file organization ([1d48f7d](https://github.com/involvex/youtube-music-cli/commit/1d48f7de3a4787e9341697b4c6f44a50a43e752c))
6
+
7
+ ### BREAKING CHANGES
8
+
9
+ - **download:** The output file structure has changed from flat directory
10
+ to nested artist/album directories. Existing workflows expecting the old
11
+ structure will need to be updated.
12
+
13
+ ## [0.0.12](https://github.com/involvex/youtube-music-cli/compare/v0.0.11...v0.0.12) (2026-02-18)
14
+
1
15
  ## [0.0.11](https://github.com/involvex/youtube-music-cli/compare/v0.0.10...v0.0.11) (2026-02-18)
2
16
 
3
17
  ### Features
@@ -22,6 +22,7 @@ export default function PlaylistList() {
22
22
  const [renamingPlaylistId, setRenamingPlaylistId] = useState(null);
23
23
  const [renameValue, setRenameValue] = useState('');
24
24
  const [downloadStatus, setDownloadStatus] = useState(null);
25
+ const [isDownloading, setIsDownloading] = useState(false);
25
26
  useKeyboardBlocker(renamingPlaylistId !== null);
26
27
  const handleCreate = useCallback(() => {
27
28
  const name = `Playlist ${playlists.length + 1}`;
@@ -82,6 +83,10 @@ export default function PlaylistList() {
82
83
  const handleDownload = useCallback(async () => {
83
84
  if (renamingPlaylistId)
84
85
  return;
86
+ if (isDownloading) {
87
+ setDownloadStatus('Download already in progress. Please wait.');
88
+ return;
89
+ }
85
90
  const playlist = playlists[selectedIndex];
86
91
  if (!playlist)
87
92
  return;
@@ -95,15 +100,25 @@ export default function PlaylistList() {
95
100
  setDownloadStatus(`No tracks to download in "${playlist.name}".`);
96
101
  return;
97
102
  }
98
- setDownloadStatus(`Downloading ${target.tracks.length} track(s) from "${playlist.name}"...`);
103
+ setDownloadStatus(`Downloading ${target.tracks.length} track(s) from "${playlist.name}"... this can take a few minutes.`);
99
104
  try {
105
+ setIsDownloading(true);
100
106
  const summary = await downloadService.downloadTracks(target.tracks);
101
107
  setDownloadStatus(`Downloaded ${summary.downloaded}, skipped ${summary.skipped}, failed ${summary.failed}.`);
102
108
  }
103
109
  catch (error) {
104
110
  setDownloadStatus(error instanceof Error ? error.message : 'Failed to download playlist.');
105
111
  }
106
- }, [downloadService, playlists, renamingPlaylistId, selectedIndex]);
112
+ finally {
113
+ setIsDownloading(false);
114
+ }
115
+ }, [
116
+ downloadService,
117
+ isDownloading,
118
+ playlists,
119
+ renamingPlaylistId,
120
+ selectedIndex,
121
+ ]);
107
122
  useKeyBinding(KEYBINDINGS.UP, navigateUp);
108
123
  useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
109
124
  useKeyBinding(KEYBINDINGS.SELECT, startPlaylist);
@@ -9,7 +9,7 @@ import { usePlayer } from "../../hooks/usePlayer.js";
9
9
  import { usePlaylist } from "../../hooks/usePlaylist.js";
10
10
  import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
11
11
  import { truncate } from "../../utils/format.js";
12
- import { useCallback, useRef, useEffect } from 'react';
12
+ import { useCallback, useRef, useEffect, useState } from 'react';
13
13
  import { logger } from "../../services/logger/logger.service.js";
14
14
  import { useTerminalSize } from "../../hooks/useTerminalSize.js";
15
15
  import { getMusicService } from "../../services/youtube-music/api.js";
@@ -24,6 +24,7 @@ function SearchResults({ results, selectedIndex, isActive = true, onMixCreated,
24
24
  const musicService = getMusicService();
25
25
  const downloadService = getDownloadService();
26
26
  const { createPlaylist } = usePlaylist();
27
+ const [isDownloading, setIsDownloading] = useState(false);
27
28
  const mixCreatedRef = useRef(onMixCreated);
28
29
  mixCreatedRef.current = onMixCreated;
29
30
  const downloadStatusRef = useRef(onDownloadStatus);
@@ -225,6 +226,10 @@ function SearchResults({ results, selectedIndex, isActive = true, onMixCreated,
225
226
  const downloadSelected = useCallback(async () => {
226
227
  if (!isActive)
227
228
  return;
229
+ if (isDownloading) {
230
+ downloadStatusRef.current?.('Download already in progress. Please wait.');
231
+ return;
232
+ }
228
233
  const selected = results[selectedIndex];
229
234
  if (!selected)
230
235
  return;
@@ -234,19 +239,23 @@ function SearchResults({ results, selectedIndex, isActive = true, onMixCreated,
234
239
  return;
235
240
  }
236
241
  try {
242
+ setIsDownloading(true);
237
243
  const target = await downloadService.resolveSearchTarget(selected);
238
244
  if (target.tracks.length === 0) {
239
245
  downloadStatusRef.current?.(`No tracks found for "${target.name}".`);
240
246
  return;
241
247
  }
242
- downloadStatusRef.current?.(`Downloading ${target.tracks.length} track(s) from "${target.name}"...`);
248
+ downloadStatusRef.current?.(`Downloading ${target.tracks.length} track(s) from "${target.name}"... this can take a few minutes.`);
243
249
  const summary = await downloadService.downloadTracks(target.tracks);
244
250
  downloadStatusRef.current?.(`Downloaded ${summary.downloaded}, skipped ${summary.skipped}, failed ${summary.failed}.`);
245
251
  }
246
252
  catch (error) {
247
253
  downloadStatusRef.current?.(error instanceof Error ? error.message : 'Download failed.');
248
254
  }
249
- }, [downloadService, isActive, results, selectedIndex]);
255
+ finally {
256
+ setIsDownloading(false);
257
+ }
258
+ }, [downloadService, isActive, isDownloading, results, selectedIndex]);
250
259
  useKeyBinding(KEYBINDINGS.UP, navigateUp);
251
260
  useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
252
261
  useKeyBinding(KEYBINDINGS.SELECT, handleSelect);
@@ -13,6 +13,7 @@ type DownloadTarget = {
13
13
  declare class DownloadService {
14
14
  private ffmpegChecked;
15
15
  private ffmpegAvailable;
16
+ private activeDownload;
16
17
  private readonly config;
17
18
  private readonly musicService;
18
19
  getConfig(): {
@@ -29,7 +30,11 @@ declare class DownloadService {
29
30
  private fetchAudio;
30
31
  private ensureFfmpeg;
31
32
  private convertAudio;
33
+ private buildMetadataArgs;
32
34
  private runFfmpeg;
35
+ private recordViaYtDlp;
36
+ private downloadCoverArt;
37
+ private recordViaMpv;
33
38
  }
34
39
  export declare function getDownloadService(): DownloadService;
35
40
  export {};
@@ -7,6 +7,7 @@ import { getMusicService } from "../youtube-music/api.js";
7
7
  class DownloadService {
8
8
  ffmpegChecked = false;
9
9
  ffmpegAvailable = false;
10
+ activeDownload = false;
10
11
  config = getConfigService();
11
12
  musicService = getMusicService();
12
13
  getConfig() {
@@ -55,49 +56,97 @@ class DownloadService {
55
56
  };
56
57
  }
57
58
  async downloadTracks(tracks) {
59
+ if (this.activeDownload) {
60
+ throw new Error('A download is already in progress. Please wait for it to finish.');
61
+ }
58
62
  const { directory, format } = this.getConfig();
59
63
  if (!directory) {
60
64
  throw new Error('No download directory configured.');
61
65
  }
62
66
  mkdirSync(directory, { recursive: true });
63
67
  await this.ensureFfmpeg();
68
+ this.activeDownload = true;
64
69
  const result = {
65
70
  downloaded: 0,
66
71
  skipped: 0,
67
72
  failed: 0,
68
73
  errors: [],
69
74
  };
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;
75
+ try {
76
+ for (const track of tracks) {
77
+ const destination = this.getDestinationPath(track, directory, format);
78
+ const tempSource = `${destination}.source`;
79
+ const tempCover = `${destination}.cover.jpg`;
80
+ try {
81
+ logger.info('DownloadService', 'Starting track download', {
82
+ videoId: track.videoId,
83
+ title: track.title,
84
+ });
85
+ mkdirSync(path.dirname(destination), { recursive: true });
86
+ if (existsSync(destination)) {
87
+ result.skipped++;
88
+ logger.debug('DownloadService', 'Skipping existing file', {
89
+ destination,
90
+ });
91
+ continue;
92
+ }
93
+ try {
94
+ const streamUrl = await this.musicService.getStreamUrl(track.videoId);
95
+ const audioBuffer = await this.fetchAudio(streamUrl);
96
+ writeFileSync(tempSource, audioBuffer);
97
+ }
98
+ catch (streamError) {
99
+ logger.warn('DownloadService', 'Stream URL extraction failed, falling back to yt-dlp', {
100
+ videoId: track.videoId,
101
+ error: streamError instanceof Error
102
+ ? streamError.message
103
+ : String(streamError),
104
+ });
105
+ try {
106
+ await this.recordViaYtDlp(track.videoId, tempSource);
107
+ }
108
+ catch (ytdlpError) {
109
+ logger.warn('DownloadService', 'yt-dlp fallback failed, falling back to mpv recording', {
110
+ videoId: track.videoId,
111
+ error: ytdlpError instanceof Error
112
+ ? ytdlpError.message
113
+ : String(ytdlpError),
114
+ });
115
+ await this.recordViaMpv(track.videoId, tempSource);
116
+ }
117
+ }
118
+ const hasCover = await this.downloadCoverArt(track.videoId, tempCover);
119
+ await this.convertAudio(tempSource, destination, format, track, hasCover ? tempCover : undefined);
120
+ result.downloaded++;
121
+ logger.info('DownloadService', 'Track download complete', {
122
+ videoId: track.videoId,
123
+ destination,
124
+ });
77
125
  }
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);
126
+ catch (error) {
127
+ result.failed++;
128
+ const message = error instanceof Error ? error.message : 'Unknown download failure';
129
+ result.errors.push(message);
130
+ logger.error('DownloadService', 'Track download failed', {
131
+ videoId: track.videoId,
132
+ title: track.title,
133
+ error: message,
134
+ });
135
+ }
136
+ finally {
137
+ if (existsSync(tempSource)) {
138
+ unlinkSync(tempSource);
139
+ }
140
+ if (existsSync(tempCover)) {
141
+ unlinkSync(tempCover);
142
+ }
97
143
  }
98
144
  }
145
+ return result;
146
+ }
147
+ finally {
148
+ this.activeDownload = false;
99
149
  }
100
- return result;
101
150
  }
102
151
  uniqueTracks(tracks) {
103
152
  const seen = new Set();
@@ -112,8 +161,11 @@ class DownloadService {
112
161
  }
113
162
  getDestinationPath(track, directory, format) {
114
163
  const artist = track.artists[0]?.name ?? 'Unknown Artist';
115
- const baseName = this.sanitizeFilename(`${artist} - ${track.title}`);
116
- return path.join(directory, `${baseName}.${format}`);
164
+ const album = track.album?.name ?? 'Singles';
165
+ const artistDir = this.sanitizeFilename(artist) || 'Unknown Artist';
166
+ const albumDir = this.sanitizeFilename(album) || 'Singles';
167
+ const fileName = this.sanitizeFilename(track.title) || track.videoId;
168
+ return path.join(directory, artistDir, albumDir, `${fileName}.${format}`);
117
169
  }
118
170
  sanitizeFilename(value) {
119
171
  return value.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_').trim();
@@ -143,32 +195,52 @@ class DownloadService {
143
195
  throw new Error('ffmpeg is required for downloads. Install ffmpeg and ensure it is available in PATH.');
144
196
  }
145
197
  }
146
- async convertAudio(sourcePath, destinationPath, format) {
198
+ async convertAudio(sourcePath, destinationPath, format, track, coverPath) {
199
+ const metadataArgs = this.buildMetadataArgs(track);
147
200
  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
- ]);
201
+ const args = ['-y', '-i', sourcePath];
202
+ if (coverPath) {
203
+ args.push('-i', coverPath, '-map', '0:a:0', '-map', '1:v:0');
204
+ }
205
+ else {
206
+ args.push('-map', '0:a:0', '-vn');
207
+ }
208
+ args.push('-codec:a', 'libmp3lame', '-q:a', '2', ...metadataArgs);
209
+ if (coverPath) {
210
+ args.push('-codec:v', 'mjpeg', '-disposition:v:0', 'attached_pic', '-metadata:s:v', 'title=Album cover', '-metadata:s:v', 'comment=Cover (front)');
211
+ }
212
+ args.push(destinationPath);
213
+ await this.runFfmpeg(args);
159
214
  return;
160
215
  }
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
- ]);
216
+ const args = ['-y', '-i', sourcePath];
217
+ if (coverPath) {
218
+ args.push('-i', coverPath, '-map', '0:a:0', '-map', '1:v:0');
219
+ }
220
+ else {
221
+ args.push('-map', '0:a:0', '-vn');
222
+ }
223
+ args.push('-codec:a', 'aac', '-b:a', '192k', ...metadataArgs);
224
+ if (coverPath) {
225
+ args.push('-codec:v', 'mjpeg', '-disposition:v:0', 'attached_pic');
226
+ }
227
+ args.push(destinationPath);
228
+ await this.runFfmpeg(args);
229
+ }
230
+ buildMetadataArgs(track) {
231
+ const artist = track.artists
232
+ .map(row => row.name)
233
+ .filter(Boolean)
234
+ .join(', ') || 'Unknown Artist';
235
+ const album = track.album?.name || 'Singles';
236
+ return [
237
+ '-metadata',
238
+ `title=${track.title}`,
239
+ '-metadata',
240
+ `artist=${artist}`,
241
+ '-metadata',
242
+ `album=${album}`,
243
+ ];
172
244
  }
173
245
  async runFfmpeg(args) {
174
246
  await new Promise((resolve, reject) => {
@@ -187,6 +259,87 @@ class DownloadService {
187
259
  });
188
260
  });
189
261
  }
262
+ async recordViaYtDlp(videoId, outputPath) {
263
+ const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
264
+ await new Promise((resolve, reject) => {
265
+ const process = spawn('yt-dlp', [
266
+ '--no-playlist',
267
+ '--quiet',
268
+ '--no-warnings',
269
+ '--js-runtimes',
270
+ 'node',
271
+ '-f',
272
+ 'bestaudio',
273
+ '--output',
274
+ outputPath,
275
+ watchUrl,
276
+ ], { windowsHide: true });
277
+ let stderr = '';
278
+ let stdout = '';
279
+ process.stderr.on('data', chunk => {
280
+ stderr += String(chunk);
281
+ });
282
+ process.stdout.on('data', chunk => {
283
+ stdout += String(chunk);
284
+ });
285
+ process.on('error', reject);
286
+ process.on('exit', code => {
287
+ if (code === 0 && existsSync(outputPath)) {
288
+ resolve();
289
+ return;
290
+ }
291
+ reject(new Error((stderr || stdout).trim() ||
292
+ `yt-dlp exited with code ${code} and no output file`));
293
+ });
294
+ });
295
+ }
296
+ async downloadCoverArt(videoId, outputPath) {
297
+ const candidates = [
298
+ `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
299
+ `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
300
+ ];
301
+ for (const url of candidates) {
302
+ try {
303
+ const response = await fetch(url);
304
+ if (!response.ok)
305
+ continue;
306
+ const image = Buffer.from(await response.arrayBuffer());
307
+ if (image.length === 0)
308
+ continue;
309
+ writeFileSync(outputPath, image);
310
+ return true;
311
+ }
312
+ catch {
313
+ continue;
314
+ }
315
+ }
316
+ return false;
317
+ }
318
+ async recordViaMpv(videoId, outputPath) {
319
+ const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
320
+ await new Promise((resolve, reject) => {
321
+ const process = spawn('mpv', [
322
+ watchUrl,
323
+ '--no-video',
324
+ '--ao=null',
325
+ '--ytdl=yes',
326
+ '--really-quiet',
327
+ `--stream-record=${outputPath}`,
328
+ ], { windowsHide: true });
329
+ let stderr = '';
330
+ process.stderr.on('data', chunk => {
331
+ stderr += String(chunk);
332
+ });
333
+ process.on('error', reject);
334
+ process.on('exit', code => {
335
+ if (code === 0 && existsSync(outputPath)) {
336
+ resolve();
337
+ return;
338
+ }
339
+ reject(new Error(stderr.trim() || `mpv exited with code ${code} and no output file`));
340
+ });
341
+ });
342
+ }
190
343
  }
191
344
  let downloadServiceInstance = null;
192
345
  export function getDownloadService() {
@@ -295,119 +295,52 @@ class MusicService {
295
295
  }
296
296
  async getStreamUrl(videoId) {
297
297
  logger.info('MusicService', 'Starting stream extraction', { videoId });
298
- // Try Method 1: @distube/ytdl-core (most reliable, actively maintained)
299
- try {
300
- logger.debug('MusicService', 'Attempting ytdl-core extraction', {
301
- videoId,
302
- });
303
- const ytdl = await import('@distube/ytdl-core');
304
- const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
305
- const info = await ytdl.default.getInfo(videoUrl);
306
- logger.debug('MusicService', 'ytdl-core getInfo succeeded', {
307
- formatCount: info.formats.length,
308
- });
309
- const audioFormats = ytdl.default.filterFormats(info.formats, 'audioonly');
310
- logger.debug('MusicService', 'ytdl-core audio formats filtered', {
311
- audioFormatCount: audioFormats.length,
312
- });
313
- if (audioFormats.length > 0) {
314
- // Get highest quality audio
315
- const bestAudio = audioFormats.sort((a, b) => {
316
- const aBitrate = Number.parseInt(String(a.audioBitrate || 0));
317
- const bBitrate = Number.parseInt(String(b.audioBitrate || 0));
318
- return bBitrate - aBitrate;
319
- })[0];
320
- if (bestAudio?.url) {
321
- logger.info('MusicService', 'Using ytdl-core stream', {
322
- bitrate: bestAudio.audioBitrate,
323
- urlLength: bestAudio.url.length,
324
- mimeType: bestAudio.mimeType,
325
- });
326
- return bestAudio.url;
327
- }
328
- }
329
- logger.warn('MusicService', 'ytdl-core: No audio formats with URL found');
330
- }
331
- catch (error) {
332
- logger.error('MusicService', 'ytdl-core extraction failed', {
333
- error: error instanceof Error ? error.message : String(error),
334
- stack: error instanceof Error ? error.stack : undefined,
335
- });
298
+ const isBunRuntime = typeof globalThis.Bun !== 'undefined';
299
+ // Try Method 1: @distube/ytdl-core (skip under Bun due undici incompatibility)
300
+ if (isBunRuntime) {
301
+ logger.warn('MusicService', 'Skipping ytdl-core extraction on Bun runtime', { videoId });
336
302
  }
337
- // Try Method 2: youtubei.js (may fail with ParsingError)
338
- try {
339
- logger.debug('MusicService', 'Attempting youtubei.js extraction', {
340
- videoId,
341
- });
342
- const yt = await getClient();
343
- const video = (await yt.getInfo(videoId));
344
- logger.debug('MusicService', 'youtubei.js getInfo succeeded');
345
- // Get the download URL for the video
346
- const streamData = video.chooseFormat?.({
347
- type: 'audio',
348
- quality: 'best',
349
- });
350
- if (streamData?.url) {
351
- logger.info('MusicService', 'Using youtubei.js stream (chooseFormat)', {
352
- urlLength: streamData.url.length,
303
+ else {
304
+ try {
305
+ logger.debug('MusicService', 'Attempting ytdl-core extraction', {
306
+ videoId,
353
307
  });
354
- return streamData.url;
355
- }
356
- // Fallback: Manually select from streaming_data.adaptive_formats
357
- logger.debug('MusicService', 'chooseFormat returned nothing, trying manual selection');
358
- const streamingData = video.streaming_data;
359
- if (streamingData?.adaptive_formats) {
360
- logger.debug('MusicService', 'Found adaptive_formats', {
361
- count: streamingData.adaptive_formats.length,
308
+ const ytdl = await import('@distube/ytdl-core');
309
+ const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
310
+ const info = await ytdl.default.getInfo(videoUrl);
311
+ logger.debug('MusicService', 'ytdl-core getInfo succeeded', {
312
+ formatCount: info.formats.length,
362
313
  });
363
- // Filter for audio-only formats
364
- const audioFormats = streamingData.adaptive_formats.filter((f) => f.mime_type?.includes('audio') || f.type?.includes('audio'));
365
- logger.debug('MusicService', 'Audio formats found', {
366
- count: audioFormats.length,
314
+ const audioFormats = ytdl.default.filterFormats(info.formats, 'audioonly');
315
+ logger.debug('MusicService', 'ytdl-core audio formats filtered', {
316
+ audioFormatCount: audioFormats.length,
367
317
  });
368
318
  if (audioFormats.length > 0) {
369
- // Sort by bitrate (higher is better)
370
- const sorted = audioFormats.sort((a, b) => {
371
- const aBitrate = Number.parseInt(String(a.bitrate || 0));
372
- const bBitrate = Number.parseInt(String(b.bitrate || 0));
319
+ // Get highest quality audio
320
+ const bestAudio = audioFormats.sort((a, b) => {
321
+ const aBitrate = Number.parseInt(String(a.audioBitrate || 0));
322
+ const bBitrate = Number.parseInt(String(b.audioBitrate || 0));
373
323
  return bBitrate - aBitrate;
374
- });
375
- const bestAudio = sorted[0];
376
- if (bestAudio) {
377
- // Check for direct URL
378
- if (bestAudio.url) {
379
- logger.info('MusicService', 'Using youtubei.js stream (manual)', {
380
- bitrate: bestAudio.bitrate,
381
- mimeType: bestAudio.mime_type || bestAudio.type,
382
- urlLength: bestAudio.url.length,
383
- });
384
- return bestAudio.url;
385
- }
386
- // Check for signatureCipher (needs decoding)
387
- if (bestAudio.signature_cipher || bestAudio.signatureCipher) {
388
- logger.warn('MusicService', 'Format has signature cipher (not supported)', {
389
- hasCipher: true,
390
- });
391
- }
324
+ })[0];
325
+ if (bestAudio?.url) {
326
+ logger.info('MusicService', 'Using ytdl-core stream', {
327
+ bitrate: bestAudio.audioBitrate,
328
+ urlLength: bestAudio.url.length,
329
+ mimeType: bestAudio.mimeType,
330
+ });
331
+ return bestAudio.url;
392
332
  }
393
333
  }
334
+ logger.warn('MusicService', 'ytdl-core: No audio formats with URL found');
394
335
  }
395
- logger.warn('MusicService', 'youtubei.js: No usable stream URL found');
396
- }
397
- catch (error) {
398
- if (error instanceof Error && error.message.includes('ParsingError')) {
399
- logger.warn('MusicService', 'youtubei.js parsing error (expected)', {
400
- error: error.message,
401
- });
402
- }
403
- else {
404
- logger.error('MusicService', 'youtubei.js extraction failed', {
336
+ catch (error) {
337
+ logger.error('MusicService', 'ytdl-core extraction failed', {
405
338
  error: error instanceof Error ? error.message : String(error),
406
339
  stack: error instanceof Error ? error.stack : undefined,
407
340
  });
408
341
  }
409
342
  }
410
- // Try Method 3: youtube-ext (lightweight, no parsing needed)
343
+ // Try Method 2: youtube-ext (lightweight, no parser path)
411
344
  try {
412
345
  logger.debug('MusicService', 'Attempting youtube-ext extraction', {
413
346
  videoId,
@@ -445,7 +378,7 @@ class MusicService {
445
378
  stack: error instanceof Error ? error.stack : undefined,
446
379
  });
447
380
  }
448
- // Try Method 4: Invidious API (last resort)
381
+ // Try Method 3: Invidious API (last resort)
449
382
  try {
450
383
  logger.debug('MusicService', 'Attempting Invidious extraction', {
451
384
  videoId,
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@involvex/youtube-music-cli",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "- A Commandline music player for youtube-music",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,20 +47,12 @@
47
47
  "lint": "eslint . --ext .js,.jsx,.ts,.tsx --ignore-pattern dist",
48
48
  "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix --ignore-pattern dist",
49
49
  "start": "bun run dist/source/cli.js",
50
- "test": "prettier --check . && bun run lint && ava",
50
+ "test": "bun run build && ava",
51
51
  "typecheck": "tsc --noEmit",
52
52
  "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
53
53
  "clean": "rimraf dist",
54
54
  "release": "powershell -File scripts/release.ps1"
55
55
  },
56
- "xo": {
57
- "react": true,
58
- "prettier": true,
59
- "rules": {
60
- "react/prop-types": "off"
61
- },
62
- "semicolon": true
63
- },
64
56
  "prettier": "@vdemedes/prettier-config",
65
57
  "ava": {
66
58
  "files": [
@@ -93,7 +85,6 @@
93
85
  "chalk": "^5.6.2",
94
86
  "conventional-changelog-cli": "^5.0.0",
95
87
  "eslint": "^10.0.0",
96
- "eslint-config-xo-react": "^0.29.0",
97
88
  "eslint-plugin-react": "^7.37.5",
98
89
  "eslint-plugin-react-hooks": "^7.0.1",
99
90
  "globals": "^17.3.0",
@@ -106,8 +97,7 @@
106
97
  "rimraf": "^6.1.3",
107
98
  "ts-node": "^10.9.2",
108
99
  "typescript": "^5.9.3",
109
- "typescript-eslint": "^8.56.0",
110
- "xo": "^1.2.3"
100
+ "typescript-eslint": "^8.56.0"
111
101
  },
112
102
  "engines": {
113
103
  "node": ">=16"