gerdur 2.11.2 → 2.13.0
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 +34 -0
- package/README.md +25 -0
- package/dist/package.json +2 -2
- package/dist/src/gerdur.js +3 -1
- package/dist/src/lib/download-track.d.ts +8 -1
- package/dist/src/lib/download-track.js +27 -18
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.13.0 - 2026-08-31
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`--fast`** — skip the expensive per-track metadata: full studio credits, BPM,
|
|
8
|
+
and the Musixmatch fallback for tracks Deezer has no lyrics for. Tagging is
|
|
9
|
+
what actually burns Deezer's rate limit (a 14-track album costs ~54 requests to
|
|
10
|
+
tag against 2 to fetch), and this takes that album to **7 requests — 81% fewer
|
|
11
|
+
against the quota**. Audio, cover, Deezer lyrics and `.lrc` sidecars are
|
|
12
|
+
unchanged; you lose credits, BPM and non-Deezer lyrics.
|
|
13
|
+
- **`gerdur-core@^2.16.0`** for the underlying `lyricsFallback` option.
|
|
14
|
+
|
|
15
|
+
## 2.12.0 - 2026-08-31
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **Downloads no longer hold the track in memory.** The final stage was
|
|
20
|
+
temp file -> `readFileSync` -> `addTrackTags` (which allocates the file again,
|
|
21
|
+
plus another copy to strip Deezer's existing tag) -> `writeFileSync`. It is now
|
|
22
|
+
a single streamed pass — **temp file -> decrypt -> tag -> destination** — using
|
|
23
|
+
`gerdur-core@2.15.0`'s `resolveTagModel` + `createTagStream`. Peak memory per
|
|
24
|
+
concurrent download drops from ~2-3x the track size to roughly a couple of
|
|
25
|
+
2048-byte stripes plus the cover art, which matters most with `-c 8` on FLAC.
|
|
26
|
+
Output is byte-identical.
|
|
27
|
+
- The intermediate `.dec` temp file is gone — decryption is now a stage in that
|
|
28
|
+
same pipeline rather than a separate pass over the whole file.
|
|
29
|
+
- Tracks are written to `<name>.part` and renamed on success, so an interrupted
|
|
30
|
+
or failed run can no longer leave a half-written file where a real track
|
|
31
|
+
should be.
|
|
32
|
+
|
|
33
|
+
Verified end to end against live downloads: MP3 (ID3v2.3, all frames, 324 kbps)
|
|
34
|
+
and FLAC (STREAMINFO / SEEKTABLE / VORBIS_COMMENT / 2x PICTURE / PADDING, 16-bit
|
|
35
|
+
44.1 kHz) both decode with zero errors under ffmpeg.
|
|
36
|
+
|
|
3
37
|
## 2.11.2 - 2026-08-31
|
|
4
38
|
|
|
5
39
|
### Changed
|
package/README.md
CHANGED
|
@@ -166,6 +166,7 @@ All options are optional; the interactive prompt fills in the rest. Provide
|
|
|
166
166
|
| `--search-limit` | | number | How many results to fetch (default 50) |
|
|
167
167
|
| `--preview` | | — | Download 30-second `.preview.mp3` clips — no `--quality`, no `arl` |
|
|
168
168
|
| `--enrich` | | — | Embed a higher-res cover from the Cover Art Archive (by ISRC) |
|
|
169
|
+
| `--fast` | | — | Skip per-track credits, BPM and the Musixmatch lyrics fallback — **~81% fewer Deezer requests** |
|
|
169
170
|
| `--update` | `-U` | — | Self-update (prebuilt binary only) |
|
|
170
171
|
| `--help` | `-h` | — | Full help |
|
|
171
172
|
|
|
@@ -245,6 +246,30 @@ at 1800 px). No match or the services are down? It silently keeps Deezer's cover
|
|
|
245
246
|
gerdur --enrich -q flac -u https://deezer.com/album/302127
|
|
246
247
|
```
|
|
247
248
|
|
|
249
|
+
### Downloading a lot at once (`--fast`)
|
|
250
|
+
|
|
251
|
+
Tagging, not downloading, is what burns through Deezer's rate limit: a 14-track
|
|
252
|
+
album costs about **54 requests to tag** and 2 to fetch. Most of that is per-track
|
|
253
|
+
— full credits and BPM (`song.getData` + a public lookup each), plus a Musixmatch
|
|
254
|
+
scrape for every track Deezer has no lyrics for, which fails outright on many
|
|
255
|
+
networks.
|
|
256
|
+
|
|
257
|
+
`--fast` skips exactly those. Same audio, same cover, same Deezer lyrics, same
|
|
258
|
+
`.lrc` sidecars:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
gerdur --fast -q flac -u https://deezer.com/album/302127
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
| 14-track album | requests | against Deezer's quota |
|
|
265
|
+
| :--- | ---: | ---: |
|
|
266
|
+
| default | 54 | 36 |
|
|
267
|
+
| `--fast` | **7** | **7** |
|
|
268
|
+
|
|
269
|
+
You lose full studio credits, BPM, and lyrics for tracks Deezer doesn't carry.
|
|
270
|
+
Worth it when you're pulling a large library and getting rate-limited; leave it
|
|
271
|
+
off for a handful of tracks you care about.
|
|
272
|
+
|
|
248
273
|
### Output templates
|
|
249
274
|
|
|
250
275
|
`--output` (or `saveLayout` in the config) is a path template. `{TOKEN}`s are
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gerdur",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "Command-line music downloader for Deezer (Spotify/Tidal links resolved via ISRC matching) with automatic MP3/FLAC tagging, synced lyrics and a side-effect-free programmatic API.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deezer",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"adm-zip": "^0.5.16",
|
|
64
64
|
"chalk": "^4.1.2",
|
|
65
65
|
"commander": "^9.5.0",
|
|
66
|
-
"gerdur-core": "^2.
|
|
66
|
+
"gerdur-core": "^2.16.0",
|
|
67
67
|
"dot-prop": "^6.0.1",
|
|
68
68
|
"got": "^11.8.6",
|
|
69
69
|
"gradient-string": "^2.0.2",
|
package/dist/src/gerdur.js
CHANGED
|
@@ -60,7 +60,8 @@ const cmd = new commander_1.Command()
|
|
|
60
60
|
.option('--dur-max <seconds>', 'Search filter: maximum duration')
|
|
61
61
|
.option('--search-limit <n>', 'Max search results to fetch', '50')
|
|
62
62
|
.option('--preview', 'Download the 30-second preview clips instead of full tracks', false)
|
|
63
|
-
.option('--enrich', 'Embed a higher-res cover from the Cover Art Archive (looked up by ISRC)', false)
|
|
63
|
+
.option('--enrich', 'Embed a higher-res cover from the Cover Art Archive (looked up by ISRC)', false)
|
|
64
|
+
.option('--fast', 'Skip per-track credits, BPM and the Musixmatch lyrics fallback (~81% fewer Deezer requests)', false);
|
|
64
65
|
if (process.pkg) {
|
|
65
66
|
cmd.option('-U, --update', 'Update this program to latest version');
|
|
66
67
|
}
|
|
@@ -486,6 +487,7 @@ const startDownload = async (saveLayout, url, skipPrompt) => {
|
|
|
486
487
|
message: `(${index}/${data.tracks.length})`,
|
|
487
488
|
lrc,
|
|
488
489
|
enrich: Boolean(options.enrich),
|
|
490
|
+
fast: Boolean(options.fast),
|
|
489
491
|
prefetched: prefetchedUrls.get(track.SNG_ID) ?? null,
|
|
490
492
|
});
|
|
491
493
|
// Add to saved list
|
|
@@ -27,6 +27,13 @@ interface downloadTrackProps {
|
|
|
27
27
|
* to Deezer's cover if there's no match. Off by default.
|
|
28
28
|
*/
|
|
29
29
|
enrich?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Skip the expensive per-track metadata: full credits, BPM, and the Musixmatch
|
|
32
|
+
* fallback for tracks Deezer has no lyrics for. Measured on a 14-track album
|
|
33
|
+
* that is 54 requests down to 7 — 81% fewer against Deezer's rate limit — at
|
|
34
|
+
* the cost of those fields. Off by default.
|
|
35
|
+
*/
|
|
36
|
+
fast?: boolean;
|
|
30
37
|
/**
|
|
31
38
|
* A download URL already resolved for this track by a batch `resolveDownloadUrls`
|
|
32
39
|
* pass. When set, the per-track `get_url` request and quality-fallback round-trips
|
|
@@ -40,5 +47,5 @@ interface downloadTrackProps {
|
|
|
40
47
|
format: string;
|
|
41
48
|
} | null;
|
|
42
49
|
}
|
|
43
|
-
declare const downloadTrack: ({ track, quality, info, coverSizes, path, totalTracks, trackNumber, fallbackTrack, fallbackQuality, isFallback, isQualityFallback, overwrite, message, lrc, enrich, prefetched, }: downloadTrackProps) => Promise<string | undefined>;
|
|
50
|
+
declare const downloadTrack: ({ track, quality, info, coverSizes, path, totalTracks, trackNumber, fallbackTrack, fallbackQuality, isFallback, isQualityFallback, overwrite, message, lrc, enrich, fast, prefetched, }: downloadTrackProps) => Promise<string | undefined>;
|
|
44
51
|
export default downloadTrack;
|
|
@@ -13,12 +13,11 @@ const log_update_1 = __importDefault(require("log-update"));
|
|
|
13
13
|
const chalk_1 = __importDefault(require("chalk"));
|
|
14
14
|
const signale_1 = __importDefault(require("../lib/signale"));
|
|
15
15
|
const util_2 = require("./util");
|
|
16
|
-
const decrypt_1 = require("./decrypt");
|
|
17
16
|
const pipeline = (0, util_1.promisify)(stream_1.default.pipeline);
|
|
18
17
|
const simulate = process.env.SIMULATE;
|
|
19
18
|
/** media-API format string -> gerdur's numeric quality */
|
|
20
19
|
const FORMAT_TO_QUALITY = { FLAC: 9, MP3_320: 3, MP3_256: 3, MP3_128: 1, MP3_64: 1 };
|
|
21
|
-
const downloadTrack = async ({ track, quality, info, coverSizes, path, totalTracks, trackNumber = true, fallbackTrack = true, fallbackQuality = true, isFallback = false, isQualityFallback = false, overwrite = false, message = '', lrc = true, enrich = false, prefetched = null, }) => {
|
|
20
|
+
const downloadTrack = async ({ track, quality, info, coverSizes, path, totalTracks, trackNumber = true, fallbackTrack = true, fallbackQuality = true, isFallback = false, isQualityFallback = false, overwrite = false, message = '', lrc = true, enrich = false, fast = false, prefetched = null, }) => {
|
|
22
21
|
(0, log_update_1.default)(signale_1.default.pending(track.SNG_TITLE + ' by ' + track.ART_NAME + ' from ' + track.ALB_TITLE));
|
|
23
22
|
try {
|
|
24
23
|
let ext = '.mp3', fileSize = 0, downloaded = 0, coverSize = 500;
|
|
@@ -142,17 +141,6 @@ const downloadTrack = async ({ track, quality, info, coverSizes, path, totalTrac
|
|
|
142
141
|
(0, log_update_1.default)(signale_1.default.info(`Downloading ${track.SNG_TITLE} ${message}\n ${bar(transferred)} | ${humanSizeTotal}MiB`));
|
|
143
142
|
}
|
|
144
143
|
}), (0, fs_1.createWriteStream)(tmpfile, { flags: 'a', autoClose: true }));
|
|
145
|
-
let outFile;
|
|
146
|
-
if (trackData.isEncrypted) {
|
|
147
|
-
(0, log_update_1.default)(signale_1.default.pending('Decrypting ' + track.SNG_TITLE + ' by ' + track.ART_NAME));
|
|
148
|
-
const decfile = tmpfile + '.dec';
|
|
149
|
-
await (0, decrypt_1.decryptFileToFile)(tmpfile, decfile, track.SNG_ID); // streamed — ~one stripe in memory
|
|
150
|
-
outFile = (0, fs_1.readFileSync)(decfile);
|
|
151
|
-
(0, fs_1.unlinkSync)(decfile);
|
|
152
|
-
}
|
|
153
|
-
else {
|
|
154
|
-
outFile = (0, fs_1.readFileSync)(tmpfile);
|
|
155
|
-
}
|
|
156
144
|
let enrichedCover;
|
|
157
145
|
if (enrich && track.ISRC && !simulate) {
|
|
158
146
|
try {
|
|
@@ -167,12 +155,13 @@ const downloadTrack = async ({ track, quality, info, coverSizes, path, totalTrac
|
|
|
167
155
|
}
|
|
168
156
|
}
|
|
169
157
|
(0, log_update_1.default)(signale_1.default.pending('Tagging ' + track.SNG_TITLE + ' by ' + track.ART_NAME));
|
|
170
|
-
|
|
158
|
+
// Resolve the metadata first, without touching the audio — the tags are then
|
|
159
|
+
// written by a stream, so the track is never held in memory.
|
|
160
|
+
const model = await (0, gerdur_core_1.resolveTagModel)(track, {
|
|
171
161
|
coverSize,
|
|
162
|
+
...(fast ? { richCredits: false, lyricsFallback: false } : {}),
|
|
172
163
|
...(enrichedCover ? { cover: enrichedCover } : {}),
|
|
173
164
|
});
|
|
174
|
-
// Delete temporary file now
|
|
175
|
-
(0, fs_1.unlinkSync)(tmpfile);
|
|
176
165
|
(0, log_update_1.default)(signale_1.default.pending('Saving ' + track.SNG_TITLE + ' by ' + track.ART_NAME));
|
|
177
166
|
if (!simulate) {
|
|
178
167
|
// Create directory if not exists
|
|
@@ -180,12 +169,32 @@ const downloadTrack = async ({ track, quality, info, coverSizes, path, totalTrac
|
|
|
180
169
|
if (!(0, fs_1.existsSync)(dir)) {
|
|
181
170
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
182
171
|
}
|
|
183
|
-
//
|
|
184
|
-
|
|
172
|
+
// One pass: temp file -> decrypt -> tag -> destination. Peak memory is a
|
|
173
|
+
// couple of stripes plus the cover, instead of the whole track two or
|
|
174
|
+
// three times over. `.part` then rename so an interrupted run never
|
|
175
|
+
// leaves a half-written track behind.
|
|
176
|
+
const partPath = savePath + '.part';
|
|
177
|
+
const stages = [(0, fs_1.createReadStream)(tmpfile)];
|
|
178
|
+
if (trackData.isEncrypted) {
|
|
179
|
+
stages.push((0, gerdur_core_1.createDecryptStream)(track.SNG_ID));
|
|
180
|
+
}
|
|
181
|
+
stages.push((0, gerdur_core_1.createTagStream)(model), (0, fs_1.createWriteStream)(partPath));
|
|
182
|
+
try {
|
|
183
|
+
await pipeline(...stages);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
if ((0, fs_1.existsSync)(partPath)) {
|
|
187
|
+
(0, fs_1.unlinkSync)(partPath);
|
|
188
|
+
}
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
(0, fs_1.renameSync)(partPath, savePath);
|
|
185
192
|
if (lrc !== false && model.lyricsSynced) {
|
|
186
193
|
(0, fs_1.writeFileSync)(savePath.replace(/\.(mp3|flac)$/i, '.lrc'), model.lyricsSynced);
|
|
187
194
|
}
|
|
188
195
|
}
|
|
196
|
+
// Delete temporary file now
|
|
197
|
+
(0, fs_1.unlinkSync)(tmpfile);
|
|
189
198
|
// Print sucess info
|
|
190
199
|
(0, log_update_1.default)(signale_1.default.success(`${isFallback ? chalk_1.default.yellow('[Fallback] ') : ''}${track.SNG_TITLE} by ${track.ART_NAME}`));
|
|
191
200
|
log_update_1.default.done();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gerdur",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "Command-line music downloader for Deezer (Spotify/Tidal links resolved via ISRC matching) with automatic MP3/FLAC tagging, synced lyrics and a side-effect-free programmatic API.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deezer",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"adm-zip": "^0.5.16",
|
|
64
64
|
"chalk": "^4.1.2",
|
|
65
65
|
"commander": "^9.5.0",
|
|
66
|
-
"gerdur-core": "^2.
|
|
66
|
+
"gerdur-core": "^2.16.0",
|
|
67
67
|
"dot-prop": "^6.0.1",
|
|
68
68
|
"got": "^11.8.6",
|
|
69
69
|
"gradient-string": "^2.0.2",
|