gerdur-core 2.13.2 → 2.14.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 +46 -0
- package/MIGRATING.md +249 -0
- package/README.md +51 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -1
- package/dist/lib/caches.d.ts +59 -0
- package/dist/lib/caches.js +100 -0
- package/dist/lib/decrypt.js +3 -0
- package/dist/lib/session.d.ts +8 -1
- package/dist/lib/session.js +46 -12
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.14.0 - 2026-08-31
|
|
4
|
+
|
|
5
|
+
Multi-tenant caching — for backends where many accounts share one process.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Shared gateway metadata cache.** Most gw payloads embed a per-account
|
|
10
|
+
`TRACK_TOKEN`, so each `Session` keeps its own cache. Five methods carry
|
|
11
|
+
nothing account-scoped — `album.getData`, `artist.getData`, `song.getLyrics`,
|
|
12
|
+
`album.getDiscography`, `playlist.getData` — and now go to a process-wide cache
|
|
13
|
+
**partitioned by country**, with a shared in-flight map so a concurrent burst
|
|
14
|
+
collapses into one request. Measured with 500 sessions reading the same album:
|
|
15
|
+
**500 gateway requests → 1**, and 1.8 MB of duplicated payload → one copy.
|
|
16
|
+
Track-bearing methods (`song.getData`, `playlist.getSongs`,
|
|
17
|
+
`song.getListByAlbum`, `song.getListData`, `episode.getData`, `mobile.*`,
|
|
18
|
+
`deezer.pageSearch`, `user_getInfo`, …) are never shared.
|
|
19
|
+
- **`configureCache({shared: {maxSize, ttl}})`** — size the shared cache to your
|
|
20
|
+
catalogue. Default `{maxSize: 2000, ttl: 3_600_000}`.
|
|
21
|
+
- **`cacheStats()`** → `{shared: {size, maxSize, hits, misses, inFlight}}` — for
|
|
22
|
+
a `/metrics` or health endpoint.
|
|
23
|
+
- **`clearSharedCaches()`** — drops the shared cache; per-session caches are
|
|
24
|
+
untouched.
|
|
25
|
+
- `__tests__/caches.ts` — offline tests pinning the isolation guarantees: no
|
|
26
|
+
cross-account token reuse, no cross-country bleed, in-flight entries released.
|
|
27
|
+
- README: a **Running this on a server** section (stream don't buffer, decrypt is
|
|
28
|
+
on the event loop, size the cache, evict idle sessions).
|
|
29
|
+
|
|
30
|
+
### Not done, deliberately
|
|
31
|
+
|
|
32
|
+
- Memoising the initialised Blowfish key schedule per track was implemented,
|
|
33
|
+
measured and **reverted**: key setup is 39.7 µs against 33 ms to decrypt an
|
|
34
|
+
8 MiB file — **0.12%**, with break-even at ~10 KiB decrypted per key. Not worth
|
|
35
|
+
a cache or the public knob it would need.
|
|
36
|
+
|
|
37
|
+
## 2.13.3 - 2026-08-31
|
|
38
|
+
|
|
39
|
+
### Docs
|
|
40
|
+
|
|
41
|
+
- **`MIGRATING.md`** — a migration guide from `@soulwax/d-fi-core`: dependency
|
|
42
|
+
swap, import rename, the one breaking `addTrackTags` change, the local
|
|
43
|
+
workarounds that can now be deleted (OpenSSL-3 Blowfish fallback, per-track
|
|
44
|
+
URL-retry loops, manual album-info caching, `code === 4` spin guards), and the
|
|
45
|
+
faster primitives to adopt (`downloadTrackBuffer`, batch `resolveDownloadUrls`,
|
|
46
|
+
`streamTrackDownload`, `Session` / `createSession`, `DeezerError`). Linked from
|
|
47
|
+
the README and shipped in the package.
|
|
48
|
+
|
|
3
49
|
## 2.13.2 - 2026-08-31
|
|
4
50
|
|
|
5
51
|
### Changed
|
package/MIGRATING.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# Migrating from `@soulwax/d-fi-core`
|
|
2
|
+
|
|
3
|
+
`gerdur-core` is the continuation of `@soulwax/d-fi-core`. It was relaunched
|
|
4
|
+
clean-slate on 2026-08-30 — new repo, squashed history, published under a new
|
|
5
|
+
name — so **`gerdur-core@1.0.1` is the last state of `@soulwax/d-fi-core` with
|
|
6
|
+
every identifier renamed**. Everything since (`1.0.1 → 2.13.x`) is additive
|
|
7
|
+
except for one call.
|
|
8
|
+
|
|
9
|
+
**TL;DR:** rename the dependency and the imports, fix one `addTrackTags` call,
|
|
10
|
+
and you're done. Then delete the workarounds you no longer need and adopt the
|
|
11
|
+
faster primitives below.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Swap the dependency
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm rm @soulwax/d-fi-core
|
|
19
|
+
npm i gerdur-core
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
// package.json
|
|
24
|
+
- "@soulwax/d-fi-core": "^x.y.z"
|
|
25
|
+
+ "gerdur-core": "^2.13.2"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 2. Rename imports
|
|
29
|
+
|
|
30
|
+
Mechanical, whole codebase:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
- import { … } from '@soulwax/d-fi-core';
|
|
34
|
+
+ import { … } from 'gerdur-core';
|
|
35
|
+
|
|
36
|
+
- import type { trackType } from '@soulwax/d-fi-core/types';
|
|
37
|
+
+ import type { trackType } from 'gerdur-core/types'; // the /types subpath name is unchanged
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 3. Fix the one breaking change — `addTrackTags`
|
|
41
|
+
|
|
42
|
+
The third argument changed from a cover-size `number` to an options object, and
|
|
43
|
+
the return changed from `Buffer` to `{buffer, model}`.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// before
|
|
47
|
+
const out: Buffer = await addTrackTags(audio, track, 500);
|
|
48
|
+
|
|
49
|
+
// after
|
|
50
|
+
const {buffer: out, model} = await addTrackTags(audio, track, {coverSize: 500});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- `coverSize` now defaults to **1000** (was 1000 in code but often passed
|
|
54
|
+
explicitly) and is clamped to Deezer's real ceiling of **1800**.
|
|
55
|
+
- `model` is the full [`TrackTagModel`](README.md#types) — every resolved field,
|
|
56
|
+
plus `model.lyricsSynced` (an LRC document, ready for a `.lrc` sidecar).
|
|
57
|
+
|
|
58
|
+
If you called the lower-level writers directly, they now take a `TrackTagModel`
|
|
59
|
+
instead of `(buffer, track, album, cover)`:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// before
|
|
63
|
+
writeMetadataMp3(buffer, track, album, cover);
|
|
64
|
+
writeMetadataFlac(buffer, track, album, size, cover);
|
|
65
|
+
|
|
66
|
+
// after — build the model, or just call addTrackTags
|
|
67
|
+
import {buildTagModel} from 'gerdur-core';
|
|
68
|
+
const model = buildTagModel({track, album, publicTrack, lyrics, cover, coverSize: 1000, deezerIds: true, includeRank: true});
|
|
69
|
+
writeMetadataMp3(buffer, model);
|
|
70
|
+
writeMetadataFlac(buffer, model, {embedSyncedLyrics: true});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 4. Everything else compiles unchanged
|
|
74
|
+
|
|
75
|
+
Same names, same signatures, same return shapes:
|
|
76
|
+
|
|
77
|
+
- `initDeezerApi(arl)` — still `Promise<string>` (the gateway `SESSION` id); the
|
|
78
|
+
`arl` must still be exactly 192 characters.
|
|
79
|
+
- `getTrackInfo` · `getTrackInfoPublicApi` · `getLyrics` · `getAlbumInfo` ·
|
|
80
|
+
`getAlbumInfoPublicApi` · `getAlbumTracks` · `getPlaylistInfo` ·
|
|
81
|
+
`getPlaylistTracks` · `getArtistInfo` · `getDiscography` · `getProfile` ·
|
|
82
|
+
`getUser` · `getChannelList` · `getShowInfo` · `getPlaylistChannel`
|
|
83
|
+
- `searchMusic` · `searchAlternative`
|
|
84
|
+
- `getTrackDownloadUrl(track, 1 | 3 | 9)` → `{trackUrl, isEncrypted, fileSize} | null`
|
|
85
|
+
- `decryptDownload(buffer, sngId)` · `getSongFileName(track, quality)`
|
|
86
|
+
- `WrongLicense` · `GeoBlocked`
|
|
87
|
+
- `parseInfo` · `getUrlParts` · `isrc2deezer` · `upc2deezer`
|
|
88
|
+
- the `spotify` · `tidal` · `youtube` converter namespaces
|
|
89
|
+
|
|
90
|
+
## 5. Delete your workarounds
|
|
91
|
+
|
|
92
|
+
If your integration carried any of these, remove them — `gerdur-core` handles it
|
|
93
|
+
now:
|
|
94
|
+
|
|
95
|
+
| Workaround you probably wrote | Now handled by |
|
|
96
|
+
| :--- | :--- |
|
|
97
|
+
| `egoroof-blowfish` / manual `bf-cbc` fallback for `ERR_OSSL_EVP_UNSUPPORTED` on Node 17+ / OpenSSL 3 | native, dependency-free Blowfish since `1.0.2` (~290 MiB/s), verified against the canonical test vectors |
|
|
98
|
+
| per-track `for (const q of [9,3,1]) try { getTrackDownloadUrl(track, q) }` retry loops | `getTrackDownloadUrl` / `resolveDownloadUrls` — media-API 403/429/5xx triggers re-auth + backoff, then a token-free legacy-CDN fallback instead of throwing |
|
|
99
|
+
| a guard against `requestWithRetry` spinning forever on `error.code === 4` | `RETRY_POLICY` — per-error-class attempt caps + a 30 s wall-clock deadline; throws `DeezerError` on exhaustion |
|
|
100
|
+
| a `Map` / object cache of album info while tagging a whole album | LRU + in-flight coalescing in the API layer — each metadata endpoint is hit once per album automatically |
|
|
101
|
+
| `isEncrypted = url.includes('/mobile/')` heuristics | `resolved.isEncrypted` now comes from the media API's `cipher` field — authoritative |
|
|
102
|
+
| clearing a cache yourself when switching `arl` | `initDeezerApi(newArl)` clears the default session cache |
|
|
103
|
+
| buffering the whole file twice (download, then decrypt) | `streamTrackDownload` — constant memory, ~one 2048-byte stripe |
|
|
104
|
+
|
|
105
|
+
## 6. Adopt the faster primitives
|
|
106
|
+
|
|
107
|
+
### One-call download
|
|
108
|
+
|
|
109
|
+
The `getTrackDownloadUrl` → fetch → `decryptDownload` sequence collapses to:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import {downloadTrackBuffer} from 'gerdur-core';
|
|
113
|
+
const audio = await downloadTrackBuffer(track, 3); // Buffer | null — URL resolve, retries and decrypt inside
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Batch URL resolution — one request per album, not N
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import {refreshTrackTokens, resolveDownloadUrls} from 'gerdur-core';
|
|
120
|
+
|
|
121
|
+
const fresh = await refreshTrackTokens(tracks); // one request; refreshes tokens older than ~1 h
|
|
122
|
+
const urls = await resolveDownloadUrls(fresh, ['FLAC', 'MP3_320', 'MP3_128']);
|
|
123
|
+
// urls[i] = {trackUrl, isEncrypted, fileSize, format, cipher} | null — Deezer returns the best each is licensed for
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`resolveDownloadUrls` is a single `media.deezer.com/v1/get_url` POST for the
|
|
127
|
+
whole list — roughly **19× faster** than the old per-track-per-quality loop for a
|
|
128
|
+
14-track album. `refreshTrackTokens` first prevents the classic "long playlist
|
|
129
|
+
starts 403-ing partway through" (tokens expire after ~1 h).
|
|
130
|
+
|
|
131
|
+
### Streaming — constant memory + resume
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import {pipeline} from 'stream/promises';
|
|
135
|
+
import {createWriteStream, existsSync, statSync} from 'fs';
|
|
136
|
+
import {streamTrackDownload} from 'gerdur-core';
|
|
137
|
+
|
|
138
|
+
const {stream, startedAt} = await streamTrackDownload(track, 9, {
|
|
139
|
+
resumeFrom: existsSync(f) ? statSync(f).size : 0, // snapped to a 2048-byte stripe boundary
|
|
140
|
+
onProgress: (received, total) => …,
|
|
141
|
+
});
|
|
142
|
+
await pipeline(stream, createWriteStream(f, {flags: startedAt ? 'a' : 'w'}));
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Or drop `createDecryptStream(sngId, startChunk)` into a pipeline you already own.
|
|
146
|
+
|
|
147
|
+
### Multi-account without global state
|
|
148
|
+
|
|
149
|
+
The module-level `arl` / session / `license_token` are gone into a `Session`:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import {createSession} from 'gerdur-core';
|
|
153
|
+
|
|
154
|
+
const a = await createSession(arlOne);
|
|
155
|
+
const b = await createSession(arlTwo);
|
|
156
|
+
const track = await a.getTrackInfo('3135556');
|
|
157
|
+
const audio = await a.getTrackBuffer(track, 9); // isolated arl, tokens, and response cache
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`initDeezerApi` still works — it is now a thin shim over a process-wide default
|
|
161
|
+
session.
|
|
162
|
+
|
|
163
|
+
### Typed errors
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import {DeezerError, GeoBlocked, WrongLicense, ExpiredTrackToken} from 'gerdur-core';
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
await downloadTrackBuffer(track, 9);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err instanceof DeezerError) console.error(err.code, err.keys, err.retryable);
|
|
172
|
+
if (err instanceof ExpiredTrackToken) { /* re-fetch the track and retry */ }
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Richer tags, fed once
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
const {buffer, model} = await addTrackTags(audio, track, {album, lyrics, cover}); // skip the refetch
|
|
180
|
+
if (model.lyricsSynced) writeFileSync(f.replace(/\.\w+$/, '.lrc'), model.lyricsSynced);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`addTrackTags` now also writes ReplayGain, BPM, real `©` / `℗` lines, the
|
|
184
|
+
original (vs reissue) release date, full engineer / producer / performer credits,
|
|
185
|
+
featured artists, ISRC / UPC, a proper explicit-status enum, Deezer ids, and the
|
|
186
|
+
artist photo as a second embedded image.
|
|
187
|
+
|
|
188
|
+
### New read surfaces
|
|
189
|
+
|
|
190
|
+
Mostly no auth required:
|
|
191
|
+
|
|
192
|
+
- **Search** — `searchPublicApi` / `searchTracks` / `searchAlbums` / … ,
|
|
193
|
+
`buildAdvancedQuery`, `suggest`, `searchFacets`
|
|
194
|
+
- **Browse** — `getChart`, `getGenres`, `getRelatedArtists`, `getArtistTopTracks`,
|
|
195
|
+
`getEditorialReleases`, …
|
|
196
|
+
- **Resolve codes** — `getTrackByISRC`, `getAlbumByUPC`
|
|
197
|
+
- **Flow / library / radios** — `getUserFlow`, `getUserFavoriteTracks`,
|
|
198
|
+
`getRadioTracks`, …
|
|
199
|
+
- **Podcasts** — `getEpisode`, `getShowEpisodes`
|
|
200
|
+
- **Previews** — `getTrackPreview`, `downloadPreview` (licence-free 30 s MP3, no
|
|
201
|
+
`arl`, no decryption — good for tests / CI)
|
|
202
|
+
|
|
203
|
+
### Enrichment — covers larger than Deezer's 1800 px cap
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import {configureMusicBrainz, getCoverArtByISRC, getBuffer, addTrackTags} from 'gerdur-core';
|
|
207
|
+
|
|
208
|
+
configureMusicBrainz({userAgent: 'my-app/1.0 ( me@example.com )'});
|
|
209
|
+
const coverUrl = await getCoverArtByISRC(track.ISRC, {minSize: 1200});
|
|
210
|
+
if (coverUrl) await addTrackTags(audio, track, {cover: await getBuffer(coverUrl)});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Shared socket pool
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
import {httpAgent, httpsAgent, getBuffer, getJson, getStream} from 'gerdur-core';
|
|
217
|
+
// hand httpAgent / httpsAgent to your own got/undici calls to reuse the same keep-alive sockets
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Before / after
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
// ── before: @soulwax/d-fi-core ───────────────────────────────────────────────
|
|
226
|
+
import got from 'got';
|
|
227
|
+
import {initDeezerApi, getTrackInfo, getTrackDownloadUrl, decryptDownload, addTrackTags} from '@soulwax/d-fi-core';
|
|
228
|
+
|
|
229
|
+
await initDeezerApi(arl);
|
|
230
|
+
const track = await getTrackInfo('3135556');
|
|
231
|
+
const {trackUrl, isEncrypted} = (await getTrackDownloadUrl(track, 3))!;
|
|
232
|
+
const body = (await got(trackUrl, {responseType: 'buffer'})).body;
|
|
233
|
+
const audio = isEncrypted ? decryptDownload(body, track.SNG_ID) : body;
|
|
234
|
+
const tagged = await addTrackTags(audio, track, 500); // Buffer
|
|
235
|
+
fs.writeFileSync('out.mp3', tagged);
|
|
236
|
+
|
|
237
|
+
// ── after: gerdur-core ──────────────────────────────────────────────────────
|
|
238
|
+
import {initDeezerApi, getTrackInfo, downloadTrackBuffer, addTrackTags} from 'gerdur-core';
|
|
239
|
+
|
|
240
|
+
await initDeezerApi(arl);
|
|
241
|
+
const track = await getTrackInfo('3135556');
|
|
242
|
+
const audio = await downloadTrackBuffer(track, 3); // fetch + decrypt, retries handled
|
|
243
|
+
const {buffer, model} = await addTrackTags(audio!, track, {coverSize: 500});
|
|
244
|
+
fs.writeFileSync('out.mp3', buffer);
|
|
245
|
+
if (model.lyricsSynced) fs.writeFileSync('out.lrc', model.lyricsSynced);
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
See [README.md](README.md) for the full guide and the
|
|
249
|
+
[CHANGELOG](CHANGELOG.md) for the version-by-version history.
|
package/README.md
CHANGED
|
@@ -27,6 +27,10 @@ It has **no CLI and does no disk I/O** — every function returns data or a
|
|
|
27
27
|
`Buffer`/stream. The [`gerdur`](https://www.npmjs.com/package/gerdur) package is
|
|
28
28
|
the CLI and the file-writing layer on top.
|
|
29
29
|
|
|
30
|
+
> **Coming from `@soulwax/d-fi-core`?** `gerdur-core` is its continuation — a
|
|
31
|
+
> near drop-in rename plus one `addTrackTags` change. See
|
|
32
|
+
> **[MIGRATING.md](MIGRATING.md)**.
|
|
33
|
+
|
|
30
34
|
---
|
|
31
35
|
|
|
32
36
|
## Contents
|
|
@@ -51,10 +55,12 @@ the CLI and the file-writing layer on top.
|
|
|
51
55
|
- [Tag MP3 / FLAC](#tag-mp3--flac)
|
|
52
56
|
- [Enrichment (MusicBrainz + Cover Art Archive)](#enrichment)
|
|
53
57
|
- [Use multiple accounts](#use-multiple-accounts)
|
|
58
|
+
- [Running this on a server](#running-this-on-a-server)
|
|
54
59
|
- [HTTP helpers](#http-helpers)
|
|
55
60
|
- [Errors](#errors)
|
|
56
61
|
- [Types](#types)
|
|
57
62
|
- [API reference](#api-reference)
|
|
63
|
+
- [Migrating from @soulwax/d-fi-core](#migrating)
|
|
58
64
|
- [The name](#the-name)
|
|
59
65
|
- [Legal](#legal)
|
|
60
66
|
|
|
@@ -599,6 +605,38 @@ and the raw channels (`gw`, `gwLight`, `gwGet`).
|
|
|
599
605
|
in tests). The free `getTrackDownloadUrl` / `resolveDownloadUrls` /
|
|
600
606
|
`streamTrackDownload` / `refreshTrackTokens` all take an optional `session`.
|
|
601
607
|
|
|
608
|
+
**Caching across sessions.** Most gw payloads embed a per-account `TRACK_TOKEN`,
|
|
609
|
+
so each `Session` has its own response cache. Five methods carry nothing
|
|
610
|
+
account-scoped — `album.getData`, `artist.getData`, `song.getLyrics`,
|
|
611
|
+
`album.getDiscography`, `playlist.getData` — and those go to a **process-wide
|
|
612
|
+
cache partitioned by country**, so one copy serves every session and a
|
|
613
|
+
concurrent burst collapses into one request. Measured with 500 sessions reading
|
|
614
|
+
the same album: **500 gateway requests → 1**, and 1.8 MB of duplicated payload →
|
|
615
|
+
one copy. Track lists (`song.getData`, `playlist.getSongs`, `song.getListByAlbum`,
|
|
616
|
+
`episode.getData`, …) are never shared.
|
|
617
|
+
|
|
618
|
+
```ts
|
|
619
|
+
import {configureCache, cacheStats, clearSharedCaches} from 'gerdur-core';
|
|
620
|
+
|
|
621
|
+
configureCache({shared: {maxSize: 20_000, ttl: 30 * 60_000}}); // once, at startup
|
|
622
|
+
cacheStats(); // {shared: {size, maxSize, hits, misses, inFlight}} — for /metrics
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
### Running this on a server
|
|
626
|
+
|
|
627
|
+
- **Stream, don't buffer.** `downloadTrackBuffer` / `getTrackBuffer` hold the
|
|
628
|
+
whole file (and `Buffer.concat` doubles it briefly) — fine for a script, a
|
|
629
|
+
memory bomb at concurrency. Use `streamTrackDownload` in a request path.
|
|
630
|
+
- **Decryption runs on the event loop.** Blowfish costs ~33 ms per 8 MiB
|
|
631
|
+
(~243 MiB/s), so heavy concurrent traffic will compete with everything else in
|
|
632
|
+
the process. Put the decrypt in a worker if you saturate a core.
|
|
633
|
+
- **Size the shared cache** to your catalogue with `configureCache`, and export
|
|
634
|
+
`cacheStats()` so you can see the hit rate.
|
|
635
|
+
- **Evict idle sessions yourself.** `createSession` has no lifecycle — a session
|
|
636
|
+
per user, kept forever, keeps its cache forever.
|
|
637
|
+
- `httpAgent` / `httpsAgent` are process-global (`maxSockets: 64`) and shared
|
|
638
|
+
between API calls and CDN downloads.
|
|
639
|
+
|
|
602
640
|
### HTTP helpers
|
|
603
641
|
|
|
604
642
|
The zero-dependency HTTP client (`get`/`post`/`head`, redirects, gzip/br/deflate,
|
|
@@ -754,7 +792,8 @@ import type {
|
|
|
754
792
|
<details>
|
|
755
793
|
<summary><b>HTTP & errors</b></summary>
|
|
756
794
|
|
|
757
|
-
`
|
|
795
|
+
`configureCache` · `cacheStats` · `clearSharedCaches` · `getJson` · `getText` ·
|
|
796
|
+
`getBuffer` · `getStream` · `HttpClient` · `httpAgent` ·
|
|
758
797
|
`httpsAgent` · `HttpStatusError` · `DeezerError` · `GeoBlocked` · `WrongLicense`
|
|
759
798
|
· `ExpiredTrackToken`
|
|
760
799
|
</details>
|
|
@@ -762,6 +801,17 @@ import type {
|
|
|
762
801
|
See the [FAQ](docs/faq.md) and the [`gerdur` CLI](https://www.npmjs.com/package/gerdur)
|
|
763
802
|
for end-to-end usage.
|
|
764
803
|
|
|
804
|
+
<a id="migrating"></a>
|
|
805
|
+
|
|
806
|
+
## Migrating from @soulwax/d-fi-core
|
|
807
|
+
|
|
808
|
+
`gerdur-core` is the continuation of `@soulwax/d-fi-core`. `gerdur-core@1.0.1` is
|
|
809
|
+
that codebase renamed; everything since is additive except a single `addTrackTags`
|
|
810
|
+
signature change. **[MIGRATING.md](MIGRATING.md)** has the exact steps —
|
|
811
|
+
dependency swap, import rename, the one fix, the workarounds you can now delete,
|
|
812
|
+
and the faster primitives (`downloadTrackBuffer`, batch `resolveDownloadUrls`,
|
|
813
|
+
`streamTrackDownload`, `Session`, `DeezerError`) to adopt.
|
|
814
|
+
|
|
765
815
|
## The name
|
|
766
816
|
|
|
767
817
|
**Gerðr** is the jötunn Freyr sends Skírnir riding through a wall of fire to
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export { initDeezerApi, createSession, defaultSession, Session, RETRY_POLICY, DE
|
|
|
3
3
|
export type { SessionUserData } from './lib/session';
|
|
4
4
|
export { DeezerError } from './lib/errors';
|
|
5
5
|
export type { DeezerErrorPayload } from './lib/errors';
|
|
6
|
+
export { configureCache, cacheStats, clearSharedCaches } from './lib/caches';
|
|
7
|
+
export type { CacheOptions, CacheStats } from './lib/caches';
|
|
6
8
|
export * from './api';
|
|
7
9
|
export * from './converter';
|
|
8
10
|
export * from './lib/decrypt';
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.getStream = exports.getText = exports.getJson = exports.getBuffer = exports.httpsAgent = exports.httpAgent = exports.DeezerError = exports.DEFAULT_ARL = exports.RETRY_POLICY = exports.Session = exports.defaultSession = exports.createSession = exports.initDeezerApi = void 0;
|
|
17
|
+
exports.getStream = exports.getText = exports.getJson = exports.getBuffer = exports.httpsAgent = exports.httpAgent = exports.clearSharedCaches = exports.cacheStats = exports.configureCache = exports.DeezerError = exports.DEFAULT_ARL = exports.RETRY_POLICY = exports.Session = exports.defaultSession = exports.createSession = exports.initDeezerApi = void 0;
|
|
18
18
|
require("./lib/session-augment"); // wires the download methods onto Session.prototype
|
|
19
19
|
var session_1 = require("./lib/session");
|
|
20
20
|
Object.defineProperty(exports, "initDeezerApi", { enumerable: true, get: function () { return session_1.initDeezerApi; } });
|
|
@@ -25,6 +25,10 @@ Object.defineProperty(exports, "RETRY_POLICY", { enumerable: true, get: function
|
|
|
25
25
|
Object.defineProperty(exports, "DEFAULT_ARL", { enumerable: true, get: function () { return session_1.DEFAULT_ARL; } });
|
|
26
26
|
var errors_1 = require("./lib/errors");
|
|
27
27
|
Object.defineProperty(exports, "DeezerError", { enumerable: true, get: function () { return errors_1.DeezerError; } });
|
|
28
|
+
var caches_1 = require("./lib/caches");
|
|
29
|
+
Object.defineProperty(exports, "configureCache", { enumerable: true, get: function () { return caches_1.configureCache; } });
|
|
30
|
+
Object.defineProperty(exports, "cacheStats", { enumerable: true, get: function () { return caches_1.cacheStats; } });
|
|
31
|
+
Object.defineProperty(exports, "clearSharedCaches", { enumerable: true, get: function () { return caches_1.clearSharedCaches; } });
|
|
28
32
|
__exportStar(require("./api"), exports);
|
|
29
33
|
__exportStar(require("./converter"), exports);
|
|
30
34
|
__exportStar(require("./lib/decrypt"), exports);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** An LRU that counts hits and misses and can be resized at runtime. */
|
|
2
|
+
declare class CountingCache {
|
|
3
|
+
private lru;
|
|
4
|
+
private opts;
|
|
5
|
+
hits: number;
|
|
6
|
+
misses: number;
|
|
7
|
+
constructor(maxSize: number, ttl?: number);
|
|
8
|
+
get(key: string): any;
|
|
9
|
+
set(key: string, value: any): void;
|
|
10
|
+
clear(): void;
|
|
11
|
+
/** Resize / re-TTL. Drops the current contents (a new LRU is built). */
|
|
12
|
+
reconfigure(opts: {
|
|
13
|
+
maxSize?: number;
|
|
14
|
+
ttl?: number;
|
|
15
|
+
}): void;
|
|
16
|
+
get size(): number;
|
|
17
|
+
get maxSize(): number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Account-independent gateway payloads, shared across every {@link Session}.
|
|
21
|
+
* Keyed by country + method + params — see `Session.gw`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const sharedGatewayCache: CountingCache;
|
|
24
|
+
/** In-flight requests for {@link sharedGatewayCache}, so concurrent sessions coalesce. */
|
|
25
|
+
export declare const sharedInFlight: Map<string, Promise<any>>;
|
|
26
|
+
export interface CacheOptions {
|
|
27
|
+
/**
|
|
28
|
+
* The shared gateway metadata cache. Default `{maxSize: 2000, ttl: 3_600_000}`.
|
|
29
|
+
* Raise `maxSize` for a large catalogue; entries are JSON payloads of a few KB.
|
|
30
|
+
*/
|
|
31
|
+
shared?: {
|
|
32
|
+
maxSize?: number;
|
|
33
|
+
ttl?: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resize the shared cache. Call once at startup — reconfiguring drops its
|
|
38
|
+
* contents.
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* configureCache({shared: {maxSize: 20_000, ttl: 30 * 60_000}});
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare const configureCache: (options: CacheOptions) => void;
|
|
45
|
+
/** Drop the shared cache. Per-session caches are untouched (`session.cache.clear()` for those). */
|
|
46
|
+
export declare const clearSharedCaches: () => void;
|
|
47
|
+
export interface CacheStats {
|
|
48
|
+
/** the shared account-independent gateway metadata cache */
|
|
49
|
+
shared: {
|
|
50
|
+
size: number;
|
|
51
|
+
maxSize: number;
|
|
52
|
+
hits: number;
|
|
53
|
+
misses: number;
|
|
54
|
+
inFlight: number;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** A snapshot of shared-cache occupancy and hit rate — for metrics / health endpoints. */
|
|
58
|
+
export declare const cacheStats: () => CacheStats;
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.cacheStats = exports.clearSharedCaches = exports.configureCache = exports.sharedInFlight = exports.sharedGatewayCache = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* The process-wide cache for account-independent gateway metadata.
|
|
9
|
+
*
|
|
10
|
+
* Each {@link Session} keeps its own response cache because most gw payloads
|
|
11
|
+
* embed a per-account `TRACK_TOKEN`. A handful of methods carry nothing
|
|
12
|
+
* account-scoped, though — album, artist, lyrics, discography and playlist
|
|
13
|
+
* metadata are the same bytes for every account in a country. Caching those per
|
|
14
|
+
* session multiplies one payload by the number of logged-in accounts, which is
|
|
15
|
+
* fine for a script and wrong for a server.
|
|
16
|
+
*
|
|
17
|
+
* Holding them here means they are stored once, and — via {@link sharedInFlight}
|
|
18
|
+
* — a burst of sessions asking for the same album shares a single request
|
|
19
|
+
* instead of issuing one each. Measured at 500 sessions reading one album:
|
|
20
|
+
* **500 gateway requests → 1**, and 1.8 MB of duplicated payload → one copy.
|
|
21
|
+
*
|
|
22
|
+
* Tune with {@link configureCache}, inspect with {@link cacheStats}.
|
|
23
|
+
*/
|
|
24
|
+
const fast_lru_1 = __importDefault(require("./fast-lru"));
|
|
25
|
+
/** An LRU that counts hits and misses and can be resized at runtime. */
|
|
26
|
+
class CountingCache {
|
|
27
|
+
constructor(maxSize, ttl = 0) {
|
|
28
|
+
this.hits = 0;
|
|
29
|
+
this.misses = 0;
|
|
30
|
+
this.opts = { maxSize, ttl };
|
|
31
|
+
this.lru = new fast_lru_1.default(this.opts);
|
|
32
|
+
}
|
|
33
|
+
get(key) {
|
|
34
|
+
const value = this.lru.get(key);
|
|
35
|
+
if (value === undefined) {
|
|
36
|
+
this.misses++;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
this.hits++;
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
set(key, value) {
|
|
44
|
+
this.lru.set(key, value);
|
|
45
|
+
}
|
|
46
|
+
clear() {
|
|
47
|
+
this.lru.clear();
|
|
48
|
+
}
|
|
49
|
+
/** Resize / re-TTL. Drops the current contents (a new LRU is built). */
|
|
50
|
+
reconfigure(opts) {
|
|
51
|
+
var _a, _b;
|
|
52
|
+
this.opts = { maxSize: (_a = opts.maxSize) !== null && _a !== void 0 ? _a : this.opts.maxSize, ttl: (_b = opts.ttl) !== null && _b !== void 0 ? _b : this.opts.ttl };
|
|
53
|
+
this.lru = new fast_lru_1.default(this.opts);
|
|
54
|
+
}
|
|
55
|
+
get size() {
|
|
56
|
+
return this.lru.size;
|
|
57
|
+
}
|
|
58
|
+
get maxSize() {
|
|
59
|
+
return this.opts.maxSize;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Account-independent gateway payloads, shared across every {@link Session}.
|
|
64
|
+
* Keyed by country + method + params — see `Session.gw`.
|
|
65
|
+
*/
|
|
66
|
+
exports.sharedGatewayCache = new CountingCache(2000, 60 * 60000);
|
|
67
|
+
/** In-flight requests for {@link sharedGatewayCache}, so concurrent sessions coalesce. */
|
|
68
|
+
exports.sharedInFlight = new Map();
|
|
69
|
+
/**
|
|
70
|
+
* Resize the shared cache. Call once at startup — reconfiguring drops its
|
|
71
|
+
* contents.
|
|
72
|
+
*
|
|
73
|
+
* ```ts
|
|
74
|
+
* configureCache({shared: {maxSize: 20_000, ttl: 30 * 60_000}});
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
const configureCache = (options) => {
|
|
78
|
+
if (options.shared) {
|
|
79
|
+
exports.sharedGatewayCache.reconfigure(options.shared);
|
|
80
|
+
exports.sharedInFlight.clear();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
exports.configureCache = configureCache;
|
|
84
|
+
/** Drop the shared cache. Per-session caches are untouched (`session.cache.clear()` for those). */
|
|
85
|
+
const clearSharedCaches = () => {
|
|
86
|
+
exports.sharedGatewayCache.clear();
|
|
87
|
+
exports.sharedInFlight.clear();
|
|
88
|
+
};
|
|
89
|
+
exports.clearSharedCaches = clearSharedCaches;
|
|
90
|
+
/** A snapshot of shared-cache occupancy and hit rate — for metrics / health endpoints. */
|
|
91
|
+
const cacheStats = () => ({
|
|
92
|
+
shared: {
|
|
93
|
+
size: exports.sharedGatewayCache.size,
|
|
94
|
+
maxSize: exports.sharedGatewayCache.maxSize,
|
|
95
|
+
hits: exports.sharedGatewayCache.hits,
|
|
96
|
+
misses: exports.sharedGatewayCache.misses,
|
|
97
|
+
inFlight: exports.sharedInFlight.size,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
exports.cacheStats = cacheStats;
|
package/dist/lib/decrypt.js
CHANGED
|
@@ -34,6 +34,9 @@ const getBlowfishKey = (trackId) => {
|
|
|
34
34
|
return key;
|
|
35
35
|
};
|
|
36
36
|
const CHUNK = 2048;
|
|
37
|
+
// Note: memoising the initialised Blowfish schedule per track was measured and
|
|
38
|
+
// rejected — key setup is 39.7µs against 33ms to decrypt an 8 MiB file (0.12%),
|
|
39
|
+
// break-even at ~10 KiB decrypted per key. Not worth the cache.
|
|
37
40
|
/**
|
|
38
41
|
* Decrypt a downloaded track. Deezer applies Blowfish-CBC "stripe" obfuscation:
|
|
39
42
|
* the file is split into 2048-byte chunks and only every third chunk (0, 3, 6…)
|
package/dist/lib/session.d.ts
CHANGED
|
@@ -102,7 +102,14 @@ export declare class Session {
|
|
|
102
102
|
* backoff, and a wall-clock deadline. Throws `DeezerError` on exhaustion.
|
|
103
103
|
*/
|
|
104
104
|
request<T>(method: 'GET' | 'POST', url: string, body?: unknown, config?: SessionRequestConfig): Promise<HttpResponse<T>>;
|
|
105
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Single-flight + LRU around a fetcher.
|
|
107
|
+
*
|
|
108
|
+
* `shared` routes to the process-wide cache and in-flight map instead of this
|
|
109
|
+
* session's — used for gw methods whose payloads carry nothing account-scoped
|
|
110
|
+
* (see {@link ACCOUNT_INDEPENDENT_GW}). Two sessions asking for the same album
|
|
111
|
+
* concurrently then share one request rather than issuing one each.
|
|
112
|
+
*/
|
|
106
113
|
private coalesce;
|
|
107
114
|
/** POST `gateway.php` — the main gw method channel. Coalesced + cached. */
|
|
108
115
|
gw<T = any>(body: Record<string, unknown>, method: string): Promise<T>;
|
package/dist/lib/session.js
CHANGED
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.setDefaultSession = exports.createSession = exports.initDeezerApi = exports.defaultSession = exports.Session = exports.RETRY_POLICY = exports.DEFAULT_ARL = void 0;
|
|
7
7
|
const delay_1 = __importDefault(require("delay"));
|
|
8
|
+
const caches_1 = require("./caches");
|
|
8
9
|
const errors_1 = require("./errors");
|
|
9
10
|
const fast_lru_1 = __importDefault(require("./fast-lru"));
|
|
10
11
|
const http_1 = require("./http");
|
|
@@ -40,6 +41,27 @@ const backoffDelay = (attempt) => {
|
|
|
40
41
|
};
|
|
41
42
|
/** How long a loaded `deezer.getUserData` payload is trusted before a refresh. */
|
|
42
43
|
const USER_DATA_TTL_MS = 25 * 60 * 1000;
|
|
44
|
+
/**
|
|
45
|
+
* gw methods whose payloads carry **no account-scoped fields**, so one copy can
|
|
46
|
+
* serve every session instead of one copy per session.
|
|
47
|
+
*
|
|
48
|
+
* The account-scoped field that matters is `TRACK_TOKEN`, and it appears on
|
|
49
|
+
* exactly two response shapes — `trackType` and `showEpisodeType`. None of the
|
|
50
|
+
* methods below embed either, so their results are identical for every account
|
|
51
|
+
* in a given country. Everything else (`song.getData`, `playlist.getSongs`,
|
|
52
|
+
* `song.getListByAlbum`, `song.getListData`, `episode.getData`, `mobile.*`,
|
|
53
|
+
* `deezer.pageSearch`, `user_getInfo`, …) stays in the per-session cache.
|
|
54
|
+
*
|
|
55
|
+
* The shared key is prefixed with the session's country, so a multi-region
|
|
56
|
+
* deployment never serves one country's availability view to another.
|
|
57
|
+
*/
|
|
58
|
+
const ACCOUNT_INDEPENDENT_GW = new Set([
|
|
59
|
+
'album.getData',
|
|
60
|
+
'artist.getData',
|
|
61
|
+
'song.getLyrics',
|
|
62
|
+
'album.getDiscography',
|
|
63
|
+
'playlist.getData', // playlistInfo — metadata only (playlist.getSongs is NOT shared)
|
|
64
|
+
]);
|
|
43
65
|
/**
|
|
44
66
|
* One Deezer session — owns the `arl`, the HTTP client (and its `sid` /
|
|
45
67
|
* `api_token`), and the account's `license_token` / `country` / streaming
|
|
@@ -217,38 +239,52 @@ class Session {
|
|
|
217
239
|
}
|
|
218
240
|
}
|
|
219
241
|
// ─── Cached request primitives ──────────────────────────────────────────────
|
|
220
|
-
/**
|
|
221
|
-
|
|
222
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Single-flight + LRU around a fetcher.
|
|
244
|
+
*
|
|
245
|
+
* `shared` routes to the process-wide cache and in-flight map instead of this
|
|
246
|
+
* session's — used for gw methods whose payloads carry nothing account-scoped
|
|
247
|
+
* (see {@link ACCOUNT_INDEPENDENT_GW}). Two sessions asking for the same album
|
|
248
|
+
* concurrently then share one request rather than issuing one each.
|
|
249
|
+
*/
|
|
250
|
+
coalesce(key, fetcher, shared = false) {
|
|
251
|
+
const cache = shared ? caches_1.sharedGatewayCache : this.cache;
|
|
252
|
+
const inFlight = shared ? caches_1.sharedInFlight : this.inFlight;
|
|
253
|
+
const cached = cache.get(key);
|
|
223
254
|
if (cached) {
|
|
224
255
|
return Promise.resolve(cached);
|
|
225
256
|
}
|
|
226
|
-
const pending =
|
|
257
|
+
const pending = inFlight.get(key);
|
|
227
258
|
if (pending) {
|
|
228
259
|
return pending;
|
|
229
260
|
}
|
|
230
261
|
const promise = (async () => {
|
|
231
262
|
try {
|
|
232
|
-
|
|
263
|
+
const value = await fetcher();
|
|
264
|
+
cache.set(key, value);
|
|
265
|
+
return value;
|
|
233
266
|
}
|
|
234
267
|
finally {
|
|
235
|
-
|
|
268
|
+
inFlight.delete(key);
|
|
236
269
|
}
|
|
237
270
|
})();
|
|
238
|
-
|
|
271
|
+
inFlight.set(key, promise);
|
|
239
272
|
return promise;
|
|
240
273
|
}
|
|
241
274
|
/** POST `gateway.php` — the main gw method channel. Coalesced + cached. */
|
|
242
275
|
gw(body, method) {
|
|
243
|
-
|
|
276
|
+
var _a;
|
|
277
|
+
const shared = ACCOUNT_INDEPENDENT_GW.has(method);
|
|
278
|
+
const key = shared
|
|
279
|
+
? `${(_a = this.country) !== null && _a !== void 0 ? _a : 'XX'}:gw:${method}:${Object.entries(body).join(':')}`
|
|
280
|
+
: `gw:${method}:${Object.entries(body).join(':')}`;
|
|
244
281
|
return this.coalesce(key, async () => {
|
|
245
282
|
const { data: { error, results }, } = await this.request('POST', '/gateway.php', body, { params: { method } });
|
|
246
283
|
if (results && Object.keys(results).length > 0) {
|
|
247
|
-
this.cache.set(key, results);
|
|
248
284
|
return results;
|
|
249
285
|
}
|
|
250
286
|
throw new errors_1.DeezerError(error);
|
|
251
|
-
});
|
|
287
|
+
}, shared);
|
|
252
288
|
}
|
|
253
289
|
/** POST `gw-light.php` — the lighter method channel (search, suggest, …). */
|
|
254
290
|
gwLight(body, method) {
|
|
@@ -258,7 +294,6 @@ class Session {
|
|
|
258
294
|
params: { method, api_version: '1.0' },
|
|
259
295
|
});
|
|
260
296
|
if (results && Object.keys(results).length > 0) {
|
|
261
|
-
this.cache.set(key, results);
|
|
262
297
|
return results;
|
|
263
298
|
}
|
|
264
299
|
throw new errors_1.DeezerError(error);
|
|
@@ -272,7 +307,6 @@ class Session {
|
|
|
272
307
|
params: { method, ...params },
|
|
273
308
|
});
|
|
274
309
|
if (results && Object.keys(results).length > 0) {
|
|
275
|
-
this.cache.set(cacheKey, results);
|
|
276
310
|
return results;
|
|
277
311
|
}
|
|
278
312
|
throw new errors_1.DeezerError(error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gerdur-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"description": "Deezer API client, cross-service URL resolution, Blowfish track decryption and MP3/FLAC metadata tagging — the engine behind the gerdur CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deezer",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"files": [
|
|
32
32
|
"dist/**/*",
|
|
33
33
|
"types/**/*",
|
|
34
|
-
"CHANGELOG.md"
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"MIGRATING.md"
|
|
35
36
|
],
|
|
36
37
|
"engines": {
|
|
37
38
|
"node": ">=12"
|