@involvex/youtube-music-cli 0.0.12 → 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,15 @@
|
|
|
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
|
+
|
|
1
13
|
## [0.0.12](https://github.com/involvex/youtube-music-cli/compare/v0.0.11...v0.0.12) (2026-02-18)
|
|
2
14
|
|
|
3
15
|
## [0.0.11](https://github.com/involvex/youtube-music-cli/compare/v0.0.10...v0.0.11) (2026-02-18)
|
|
@@ -30,8 +30,10 @@ declare class DownloadService {
|
|
|
30
30
|
private fetchAudio;
|
|
31
31
|
private ensureFfmpeg;
|
|
32
32
|
private convertAudio;
|
|
33
|
+
private buildMetadataArgs;
|
|
33
34
|
private runFfmpeg;
|
|
34
35
|
private recordViaYtDlp;
|
|
36
|
+
private downloadCoverArt;
|
|
35
37
|
private recordViaMpv;
|
|
36
38
|
}
|
|
37
39
|
export declare function getDownloadService(): DownloadService;
|
|
@@ -76,11 +76,13 @@ class DownloadService {
|
|
|
76
76
|
for (const track of tracks) {
|
|
77
77
|
const destination = this.getDestinationPath(track, directory, format);
|
|
78
78
|
const tempSource = `${destination}.source`;
|
|
79
|
+
const tempCover = `${destination}.cover.jpg`;
|
|
79
80
|
try {
|
|
80
81
|
logger.info('DownloadService', 'Starting track download', {
|
|
81
82
|
videoId: track.videoId,
|
|
82
83
|
title: track.title,
|
|
83
84
|
});
|
|
85
|
+
mkdirSync(path.dirname(destination), { recursive: true });
|
|
84
86
|
if (existsSync(destination)) {
|
|
85
87
|
result.skipped++;
|
|
86
88
|
logger.debug('DownloadService', 'Skipping existing file', {
|
|
@@ -113,7 +115,8 @@ class DownloadService {
|
|
|
113
115
|
await this.recordViaMpv(track.videoId, tempSource);
|
|
114
116
|
}
|
|
115
117
|
}
|
|
116
|
-
await this.
|
|
118
|
+
const hasCover = await this.downloadCoverArt(track.videoId, tempCover);
|
|
119
|
+
await this.convertAudio(tempSource, destination, format, track, hasCover ? tempCover : undefined);
|
|
117
120
|
result.downloaded++;
|
|
118
121
|
logger.info('DownloadService', 'Track download complete', {
|
|
119
122
|
videoId: track.videoId,
|
|
@@ -134,6 +137,9 @@ class DownloadService {
|
|
|
134
137
|
if (existsSync(tempSource)) {
|
|
135
138
|
unlinkSync(tempSource);
|
|
136
139
|
}
|
|
140
|
+
if (existsSync(tempCover)) {
|
|
141
|
+
unlinkSync(tempCover);
|
|
142
|
+
}
|
|
137
143
|
}
|
|
138
144
|
}
|
|
139
145
|
return result;
|
|
@@ -155,8 +161,11 @@ class DownloadService {
|
|
|
155
161
|
}
|
|
156
162
|
getDestinationPath(track, directory, format) {
|
|
157
163
|
const artist = track.artists[0]?.name ?? 'Unknown Artist';
|
|
158
|
-
const
|
|
159
|
-
|
|
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}`);
|
|
160
169
|
}
|
|
161
170
|
sanitizeFilename(value) {
|
|
162
171
|
return value.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_').trim();
|
|
@@ -186,32 +195,52 @@ class DownloadService {
|
|
|
186
195
|
throw new Error('ffmpeg is required for downloads. Install ffmpeg and ensure it is available in PATH.');
|
|
187
196
|
}
|
|
188
197
|
}
|
|
189
|
-
async convertAudio(sourcePath, destinationPath, format) {
|
|
198
|
+
async convertAudio(sourcePath, destinationPath, format, track, coverPath) {
|
|
199
|
+
const metadataArgs = this.buildMetadataArgs(track);
|
|
190
200
|
if (format === 'mp3') {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
'-i',
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
'-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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);
|
|
202
214
|
return;
|
|
203
215
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
'-i',
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
'-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
+
];
|
|
215
244
|
}
|
|
216
245
|
async runFfmpeg(args) {
|
|
217
246
|
await new Promise((resolve, reject) => {
|
|
@@ -264,6 +293,28 @@ class DownloadService {
|
|
|
264
293
|
});
|
|
265
294
|
});
|
|
266
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
|
+
}
|
|
267
318
|
async recordViaMpv(videoId, outputPath) {
|
|
268
319
|
const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
|
269
320
|
await new Promise((resolve, reject) => {
|
|
Binary file
|