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 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 &amp; errors</b></summary>
756
794
 
757
- `getJson` · `getText` · `getBuffer` · `getStream` · `HttpClient` · `httpAgent` ·
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;
@@ -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…)
@@ -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
- /** Single-flight + LRU around a fetcher, keyed per session. */
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>;
@@ -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
- /** Single-flight + LRU around a fetcher, keyed per session. */
221
- coalesce(key, fetcher) {
222
- const cached = this.cache.get(key);
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 = this.inFlight.get(key);
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
- return await fetcher();
263
+ const value = await fetcher();
264
+ cache.set(key, value);
265
+ return value;
233
266
  }
234
267
  finally {
235
- this.inFlight.delete(key);
268
+ inFlight.delete(key);
236
269
  }
237
270
  })();
238
- this.inFlight.set(key, promise);
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
- const key = `gw:${method}:${Object.entries(body).join(':')}`;
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.13.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"