gerdur-core 2.13.0 → 2.13.2

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.
Files changed (4) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +655 -346
  4. package/package.json +20 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.13.2 - 2026-08-31
4
+
5
+ ### Changed
6
+
7
+ - **Licensed under MIT.** The `LICENSE` file was empty and `package.json` said
8
+ `"SEE LICENSE IN LICENSE"`; both now state MIT.
9
+
10
+ ## 2.13.1 - 2026-08-31
11
+
12
+ ### Docs
13
+
14
+ - **README rewritten** — restructured around use cases (a task-oriented guide
15
+ covering auth, lookups, URL/ISRC/UPC resolution, both search APIs, browse,
16
+ Flow/library, podcasts, previews, URL resolution, buffered and streamed
17
+ downloads, decryption, tagging, enrichment, multi-account sessions and the HTTP
18
+ helpers), a full collapsible API reference, an errors table and a types
19
+ summary. npm-facing tone: badges, single-line description, no internal-project
20
+ references.
21
+ - `package.json` `description` rewritten and `keywords` added for npm search.
22
+
23
+ No code changes.
24
+
3
25
  ## 2.13.0 - 2026-08-31
4
26
 
5
27
  ### Added
package/LICENSE CHANGED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 soulwax
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,14 +1,66 @@
1
1
  # gerdur-core
2
2
 
3
- The engine behind [`gerdur`](https://github.com/soulwax/gerdur): the streaming-service API clients, URL resolution, track decryption, and metadata tagging. Also used across the darkfloor / hexmusic services.
4
-
5
- Source: <https://github.com/soulwax/gerdur-core> · npm: <https://www.npmjs.com/package/gerdur-core>
6
-
7
- ## Why *gerdur*?
8
-
9
- **Gerðr** is the jötunn Freyr sends his servant Skírnir riding through a wall of fire to fetch; her name, *garðr*, means "the enclosure, the walled garden" (cf. English *garden*). `gerdur-core` does the crossing — resolving identifiers, getting past the wall, decrypting the stream, and handing back a finished, tagged track. The [`gerdur`](https://github.com/soulwax/gerdur) CLI is a thin shell around it.
10
-
11
- ## Installation
3
+ > Deezer API client, cross-service URL resolution, Blowfish track decryption and
4
+ > MP3/FLAC metadata tagging — the engine behind the [`gerdur`](https://www.npmjs.com/package/gerdur) CLI.
5
+
6
+ [![npm](https://img.shields.io/npm/v/gerdur-core.svg)](https://www.npmjs.com/package/gerdur-core)
7
+ [![npm downloads](https://img.shields.io/npm/dm/gerdur-core.svg)](https://www.npmjs.com/package/gerdur-core)
8
+ [![node](https://img.shields.io/node/v/gerdur-core.svg)](https://www.npmjs.com/package/gerdur-core)
9
+
10
+ `gerdur-core` is a small, dependency-light TypeScript library that does the parts
11
+ of a music downloader that are fiddly to get right:
12
+
13
+ - **Talks to Deezer** — the internal gateway (`gw-light.php` / `gateway.php`), the
14
+ public REST API (`api.deezer.com`) and the media API (`media.deezer.com`), with
15
+ bounded retries, token refresh and per-account sessions.
16
+ - **Resolves any link** — Deezer, Spotify, Tidal and YouTube URLs, plus ISRC and
17
+ UPC codes, all mapped to a downloadable Deezer track.
18
+ - **Decrypts the stream** — Blowfish-CBC "stripe" decryption, buffered or as a
19
+ Node `Transform` (constant memory, resumable).
20
+ - **Writes real tags** — ID3v2.3 for MP3, Vorbis comments for FLAC: cover art,
21
+ full credits, ReplayGain, BPM, ISRC, release dates, plain and time-synced
22
+ lyrics (`.lrc`).
23
+ - **Enriches, optionally** — higher-resolution cover art and canonical
24
+ release/label data from MusicBrainz and the Cover Art Archive.
25
+
26
+ It has **no CLI and does no disk I/O** — every function returns data or a
27
+ `Buffer`/stream. The [`gerdur`](https://www.npmjs.com/package/gerdur) package is
28
+ the CLI and the file-writing layer on top.
29
+
30
+ ---
31
+
32
+ ## Contents
33
+
34
+ - [Install](#install)
35
+ - [Quick start](#quick-start)
36
+ - [Core concepts](#core-concepts)
37
+ - [Guide](#guide)
38
+ - [Authenticate](#authenticate)
39
+ - [Look up tracks, albums, playlists, artists](#look-up-tracks-albums-playlists-artists)
40
+ - [Resolve a share URL (Deezer / Spotify / Tidal / YouTube)](#resolve-a-share-url)
41
+ - [Resolve an ISRC or UPC](#resolve-an-isrc-or-upc)
42
+ - [Search](#search)
43
+ - [Browse and discover](#browse-and-discover)
44
+ - [Flow, radios and a user's library](#flow-radios-and-a-users-library)
45
+ - [Podcasts](#podcasts)
46
+ - [Preview clips](#preview-clips)
47
+ - [Resolve a download URL](#resolve-a-download-url)
48
+ - [Download a track (buffer)](#download-a-track-buffer)
49
+ - [Download a track (stream, constant memory, resume)](#download-a-track-stream)
50
+ - [Decrypt](#decrypt)
51
+ - [Tag MP3 / FLAC](#tag-mp3--flac)
52
+ - [Enrichment (MusicBrainz + Cover Art Archive)](#enrichment)
53
+ - [Use multiple accounts](#use-multiple-accounts)
54
+ - [HTTP helpers](#http-helpers)
55
+ - [Errors](#errors)
56
+ - [Types](#types)
57
+ - [API reference](#api-reference)
58
+ - [The name](#the-name)
59
+ - [Legal](#legal)
60
+
61
+ ---
62
+
63
+ ## Install
12
64
 
13
65
  ```bash
14
66
  npm i gerdur-core
@@ -22,450 +74,707 @@ yarn add gerdur-core
22
74
  pnpm add gerdur-core
23
75
  ```
24
76
 
25
- Type declarations are also published under the `gerdur-core/types` subpath for consumers that want to import the shared library types directly.
77
+ - **Node** 12. Ships CommonJS (`dist/`) with bundled `.d.ts`.
78
+ - **Types subpath**: the hand-written Deezer response types are also published at
79
+ `gerdur-core/types`, so downstream packages can `import type {trackType} from
80
+ 'gerdur-core/types'` without a second dependency.
81
+ - You need a Deezer **`arl`** cookie for anything account-scoped (downloads,
82
+ `getUser`, lyrics, the internal search). Public REST endpoints (charts, public
83
+ search, ISRC/UPC lookups, previews) work with no auth.
26
84
 
27
- ## Usage
85
+ ## Quick start
28
86
 
29
- Here's a simple example to download tracks.
87
+ Download one track, tag it, and write it to disk:
30
88
 
31
89
  ```ts
32
- import fs from 'fs';
33
- import {get} from 'https';
34
- import * as api from 'gerdur-core';
35
-
36
- const downloadBuffer = (url: string) =>
37
- new Promise<Buffer>((resolve, reject) => {
38
- get(url, (response) => {
39
- const chunks: Buffer[] = [];
40
- response.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
41
- response.on('end', () => resolve(Buffer.concat(chunks)));
42
- response.on('error', reject);
43
- }).on('error', reject);
44
- });
45
-
46
- // Init api with arl from cookie
47
- await api.initDeezerApi(arl_cookie);
48
-
49
- // Verify user
50
- try {
51
- const user = await api.getUser();
52
- // Successfully logged in
53
- console.log('Logged in as ' + user.BLOG_NAME);
54
- } catch (err) {
55
- // Invalid arl cookie set
56
- console.error(err.message);
90
+ import {writeFileSync} from 'fs';
91
+ import {initDeezerApi, getTrackInfo, downloadTrackBuffer, addTrackTags} from 'gerdur-core';
92
+
93
+ await initDeezerApi(process.env.ARL!); // 192-char arl cookie
94
+
95
+ const track = await getTrackInfo('3135556'); // Daft Punk — Harder, Better, Faster, Stronger
96
+ const audio = await downloadTrackBuffer(track, 3); // 3 = 320 kbps MP3, downloaded + decrypted
97
+ if (!audio) throw new Error('not available for this account / region');
98
+
99
+ const {buffer, model} = await addTrackTags(audio, track); // cover, credits, lyrics, ReplayGain…
100
+ writeFileSync(`${model.title}.mp3`, buffer);
101
+ if (model.lyricsSynced) writeFileSync(`${model.title}.lrc`, model.lyricsSynced); // synced lyrics
102
+ ```
103
+
104
+ The same pipeline, one step at a time — resolve a URL, then run each track
105
+ through it yourself:
106
+
107
+ ```ts
108
+ import {writeFileSync} from 'fs';
109
+ import {
110
+ initDeezerApi,
111
+ parseInfo,
112
+ resolveDownloadUrls,
113
+ refreshTrackTokens,
114
+ getBuffer,
115
+ decryptDownload,
116
+ addTrackTags,
117
+ } from 'gerdur-core';
118
+
119
+ await initDeezerApi(process.env.ARL!);
120
+
121
+ const {tracks} = await parseInfo('https://www.deezer.com/album/302127');
122
+ const fresh = await refreshTrackTokens(tracks); // long lists: refresh expiring tokens first
123
+ const urls = await resolveDownloadUrls(fresh, ['FLAC', 'MP3_320', 'MP3_128']); // one request
124
+
125
+ for (const [i, track] of fresh.entries()) {
126
+ const url = urls[i];
127
+ if (!url) continue; // geo-blocked / unavailable
128
+ const body = await getBuffer(url.trackUrl);
129
+ const decrypted = url.isEncrypted ? decryptDownload(body, track.SNG_ID) : body;
130
+ const {buffer, model} = await addTrackTags(decrypted, track);
131
+ writeFileSync(`${model.trackNumber}. ${model.title}.${url.format === 'FLAC' ? 'flac' : 'mp3'}`, buffer);
57
132
  }
133
+ ```
134
+
135
+ ## Core concepts
136
+
137
+ **`arl` cookie.** Deezer authenticates the mobile/gateway API with a single
138
+ `arl` cookie (192 characters). `initDeezerApi(arl)` exchanges it for a session
139
+ token. Get one from a logged-in browser: DevTools → Application → Cookies →
140
+ `deezer.com` → `arl` (see the [FAQ](docs/faq.md)).
141
+
142
+ **Sessions.** A [`Session`](#use-multiple-accounts) owns one account's state —
143
+ the `arl`, the HTTP client (session id / API token), the media `license_token`,
144
+ the resolved `country` and streaming rights, plus the retry loop and a response
145
+ cache. The free functions (`getTrackInfo`, `searchMusic`, …) run against a
146
+ process-wide **default** session that `initDeezerApi` configures. Call
147
+ `createSession(arl)` for an isolated one.
58
148
 
59
- // GET Track Object
60
- const track = await api.getTrackInfo(song_id);
149
+ **Quality.** Numeric shorthand: **`1`** = MP3 128 kbps, **`3`** = MP3 320 kbps,
150
+ **`9`** = FLAC (~1411 kbps). The batch resolver also takes format strings from
151
+ [`DEEZER_FORMATS`](#resolve-a-download-url) (`FLAC`, `MP3_320`, `MP3_256`,
152
+ `MP3_128`, `MP3_64`, `AAC_64`, `MP4_RA3/2/1`). What you actually get depends on
153
+ the account's plan and the track's licensing.
61
154
 
62
- // Parse download URL for 128kbps
63
- const trackData = await api.getTrackDownloadUrl(track, 1);
155
+ **Track tokens.** Each track carries a `TRACK_TOKEN` that the media API needs; it
156
+ lives ~1 hour. A token fetched at the start of a long download is stale by track
157
+ 40 and surfaces as an opaque CDN 403 — run the list through
158
+ [`refreshTrackTokens`](#resolve-a-download-url) first.
64
159
 
65
- // Download track
66
- const data = await downloadBuffer(trackData.trackUrl);
160
+ **Encryption.** Most CDN downloads are Blowfish-CBC "stripe"-obfuscated: the file
161
+ is split into 2048-byte chunks and only every third one (0, 3, 6, …) is
162
+ encrypted. `resolved.isEncrypted` (from the media API's `cipher` field) tells you
163
+ whether to [decrypt](#decrypt). Previews and podcast episodes are plain.
67
164
 
68
- // Decrypt track if needed
69
- const outFile = trackData.isEncrypted ? api.decryptDownload(data, track.SNG_ID) : data;
165
+ **Retries.** Gateway calls run through a bounded loop — per-error-class attempt
166
+ caps plus a 30 s wall-clock deadline, full-jittered exponential backoff. A
167
+ persistently failing endpoint throws a [`DeezerError`](#errors) instead of
168
+ spinning. The policy is exported as `RETRY_POLICY`.
70
169
 
71
- // Add metadata — resolves album info, credits, lyrics and cover from Deezer
72
- const {buffer, model} = await api.addTrackTags(outFile, track, {coverSize: 500});
170
+ ---
73
171
 
74
- // Save file to disk
75
- fs.writeFileSync(track.SNG_TITLE + '.mp3', buffer);
172
+ ## Guide
76
173
 
77
- // Time-synced lyrics, when Deezer has them, come back as an LRC document
78
- if (model.lyricsSynced) fs.writeFileSync(track.SNG_TITLE + '.lrc', model.lyricsSynced);
174
+ ### Authenticate
175
+
176
+ ```ts
177
+ import {initDeezerApi, getUser} from 'gerdur-core';
178
+
179
+ await initDeezerApi(arl); // throws if arl length !== 192; returns the gateway SESSION id
180
+
181
+ try {
182
+ const me = await getUser();
183
+ console.log('Logged in as', me.BLOG_NAME, '· id', me.USER_ID, '· country', me.COUNTRY);
184
+ } catch (err) {
185
+ console.error('arl invalid or expired:', (err as Error).message);
186
+ }
79
187
  ```
80
188
 
81
- ### [Read FAQ](docs/faq.md)
189
+ `initDeezerApi` only pings for a session token. The `license_token`, `country`
190
+ and streaming rights are fetched lazily on the first download (or eagerly with
191
+ `createSession(arl)` + `await session.loadUserData()`).
82
192
 
83
- ## Methods
193
+ ### Look up tracks, albums, playlists, artists
84
194
 
85
- Every method returns an `Object` or throws. Gateway / media-API failures throw a
86
- **`DeezerError`** (`extends Error`) with:
195
+ All take string ids and return the raw Deezer gateway objects. Every response is
196
+ memoised (LRU, 1000 entries / 60 min) and in-flight-coalesced.
87
197
 
88
- | Field | |
198
+ | Function | Returns |
89
199
  | :--- | :--- |
90
- | `code` | Deezer's numeric error code, when it sent one (e.g. `4`, `800`) |
91
- | `keys` | the gateway error keys, e.g. `['VALID_TOKEN_REQUIRED']`, `['DATA_ERROR']` |
92
- | `retryable` | whether a retry could plausibly succeed |
93
- | `payload` | the raw error object |
200
+ | `getTrackInfo(id)` | `song.getData` the track, with this session's `TRACK_TOKEN` |
201
+ | `getLyrics(id)` | `song.getLyrics` `LYRICS_TEXT` plus `LYRICS_SYNC_JSON` when synced |
202
+ | `getAlbumInfo(id)` | `album.getData` |
203
+ | `getAlbumTracks(id)` | every track on the album (`song.getListByAlbum`, `nb: -1`) |
204
+ | `getPlaylistInfo(id)` | `playlist.getData` |
205
+ | `getPlaylistTracks(id)` | every track, with `TRACK_POSITION` filled in |
206
+ | `getArtistInfo(id)` | `artist.getData` |
207
+ | `getDiscography(id, nb = 500)` | the artist's discography (`album.getDiscography`) |
208
+ | `getProfile(userId)` | a public profile (`mobile.pageUser`, loved tracks) |
209
+ | `getShowInfo(showId, nb?, start?)` | a podcast show + a page of `EPISODES` |
210
+ | `getChannelList()` | Deezer's browse channels |
211
+ | `getPlaylistChannel(page)` | a channel page (`app_page_get`) — e.g. `"channels/dance"` |
212
+ | `getUser()` | the logged-in account's profile |
213
+ | `getTrackInfoPublicApi(id)` / `getAlbumInfoPublicApi(id)` | the same entities from the **public** REST API (`isrc`, `bpm`, `contributors`) |
94
214
 
95
- Retries are bounded — `RETRY_POLICY` (exported) sets per-class attempt caps and a
96
- 30 s wall-clock deadline, so a persistently failing endpoint surfaces a
97
- `DeezerError` instead of spinning. `GeoBlocked`, `WrongLicense` and
98
- `ExpiredTrackToken` are still thrown as their own types from the download path.
215
+ ```ts
216
+ const album = await getAlbumInfo('302127');
217
+ const {data: tracks} = await getAlbumTracks('302127');
218
+ console.log(album.ALB_TITLE, '—', tracks.length, 'tracks');
219
+ ```
99
220
 
100
- ### Sessions
221
+ ### Resolve a share URL
101
222
 
102
- A **`Session`** owns one account's state the `arl`, the HTTP client (`sid` /
103
- `api_token`), and the account's `license_token` / `country` / streaming rights,
104
- plus the bounded-retry loop and token refresh. This state used to be
105
- module-level globals; bundling it means multiple accounts can coexist.
223
+ `parseInfo(url)` classifies a Deezer / Spotify / Tidal / YouTube URL, fetches it,
224
+ and returns a uniform shape. Spotify/Tidal/YouTube entities are matched to their
225
+ Deezer equivalents (ISRC for tracks, UPC for albums), so everything downstream is
226
+ a Deezer track.
106
227
 
107
- - **`initDeezerApi(arl)`** — (re)authenticate the **default** session. Every free
108
- function (`getTrackInfo`, `searchMusic`, `getTrackDownloadUrl`, …) runs against
109
- it. Unchanged: same signature, returns the gateway `SESSION` id.
110
- - **`createSession(arl?)`** — an **isolated** session you hold and inspect:
111
- `session.arl`, `session.sid`, and — after `await session.loadUserData()` —
112
- `session.country`, `session.licenseToken`, `session.canStreamLossless`,
113
- `session.canStreamHq`. `loadUserData()` caches for 25 min and refreshes on a
114
- media-API 403.
115
- - **`defaultSession()`** — the `Session` the free functions use.
228
+ ```ts
229
+ import {parseInfo, getUrlParts} from 'gerdur-core';
116
230
 
117
- Each `Session` has **its own response cache** (per-account, since gateway
118
- responses carry account-specific `TRACK_TOKEN`s) and its own query methods:
231
+ const {info, linktype, linkinfo, tracks} = await parseInfo(
232
+ 'https://open.spotify.com/album/2noRn2Aes5aoNVsU6iWThc',
233
+ );
234
+ // info → {type: 'spotify-album', id: '2noRn2Aes5aoNVsU6iWThc'}
235
+ // linktype → 'album' | 'playlist' | 'artist' | 'track'
236
+ // linkinfo → the album/playlist/artist object (empty for a single track)
237
+ // tracks → trackType[] ready for resolveDownloadUrls
119
238
 
120
- | Method | |
121
- | :--- | :--- |
122
- | `getUser()` | this account's profile |
123
- | `getTrackInfo(id)` | `song.getData` — the `TRACK_TOKEN` is this session's |
124
- | `getLyrics(id)` | plain + synced lyrics |
125
- | `getAlbumInfo(id)` / `getAlbumTracks(id)` | album metadata / full track list |
126
- | `getPlaylistInfo(id)` / `getPlaylistTracks(id)` | playlist metadata / tracks (with `TRACK_POSITION`) |
127
- | `getArtistInfo(id)` / `getDiscography(id, nb?)` | artist metadata / discography |
128
- | `getProfile(userId)` | a public profile |
129
- | `searchMusic(query, types?, nb?)` | search (`deezer.pageSearch`) |
130
- | `getTrackDownloadUrl(track, quality)` | resolve a CDN URL **as this account** |
131
- | `resolveDownloadUrls(tracks, qualities?)` | batch-resolve, one request, as this account |
132
- | `streamTrack(track, quality, opts?)` | constant-memory stream of decrypted audio |
133
- | `getTrackBuffer(track, quality, opts?)` | download + decrypt fully into a `Buffer` |
134
- | `gw(body, method)` / `gwLight(body, method)` / `gwGet(method, params?)` | the raw coalesced request channels |
135
- | `init(arl?)` / `refreshApiToken()` / `loadUserData(force?)` / `invalidateUserData()` | lifecycle |
136
-
137
- The free `getTrackDownloadUrl(track, quality, session?)` / `resolveDownloadUrls(…, session?)`
138
- / `streamTrackDownload(…, {session})` all take an optional session too;
139
- `downloadTrackBuffer(track, quality, opts?)` is the free-function form of
140
- `session.getTrackBuffer`.
141
-
142
- ```js
143
- await initDeezerApi(arl); // default session, as before
144
- const track = await getTrackInfo('3135556');
145
-
146
- const s = await createSession(otherArl); // a second account, fully isolated
147
- await s.loadUserData();
148
- console.log(s.country, s.canStreamLossless);
149
- const mine = await s.getTrackInfo('3135556'); // runs against `otherArl`
239
+ const parts = await getUrlParts('https://deezer.com/track/3135556'); // just classify: {type, id}
150
240
  ```
151
241
 
152
- ### `.initDeezerApi(arl_cookie);`
242
+ Supported: Deezer `track` / `album` / `audiobook` / `playlist` / `artist` (+
243
+ `page.link` short links), `spotify:` URIs and `open.spotify.com` links, Tidal
244
+ links, and `youtube.com/watch` / `youtu.be` links. Spotify artist resolution is
245
+ capped at ~10 tracks by Spotify's anonymous token.
153
246
 
154
- > It is recommended that you first init the app with this method using your arl cookie.
247
+ The lower-level converters are also exported:
155
248
 
156
- | Parameters | Required | Type |
157
- | ------------ | :------: | -------: |
158
- | `arl_cookie` | Yes | `string` |
249
+ ```ts
250
+ import {isrc2deezer, upc2deezer, spotify, tidal, youtube} from 'gerdur-core';
159
251
 
160
- ### `.getTrackInfo(track_id);`
252
+ const track = await isrc2deezer('Get Lucky', 'USUM71311296'); // hydrated gw track
253
+ const [albumInfo, albumTracks] = await upc2deezer('Discovery', '0724384960650');
254
+ await spotify.setSpotifyAnonymousToken(); // needed before spotify.* calls
255
+ ```
161
256
 
162
- | Parameters | Required | Type |
163
- | ---------- | :------: | -------: |
164
- | `track_id` | Yes | `string` |
257
+ ### Resolve an ISRC or UPC
165
258
 
166
- ### `.getLyrics(track_id);`
259
+ Public REST, no auth. Returns public-API objects (not gw tracks — pass `.id` to
260
+ `getTrackInfo` / `getAlbumTracks` to make them downloadable).
167
261
 
168
- | Parameters | Required | Type |
169
- | ---------- | :------: | -------: |
170
- | `track_id` | Yes | `string` |
262
+ ```ts
263
+ import {getTrackByISRC, getAlbumByUPC, getTrackInfo} from 'gerdur-core';
171
264
 
172
- ### `.getAlbumInfo(album_id);`
265
+ const pub = await getTrackByISRC('USUM71311296'); // {id, title, bpm, gain, isrc, preview, …}
266
+ const track = await getTrackInfo(String(pub.id)); // now downloadable
173
267
 
174
- | Parameters | Required | Type |
175
- | ---------- | :------: | -------: |
176
- | `album_id` | Yes | `string` |
268
+ const album = await getAlbumByUPC('0724384960650'); // {id, title, tracks: {data: [...]}}
269
+ ```
177
270
 
178
- ### `.getAlbumTracks(album_id);`
271
+ ### Search
179
272
 
180
- | Parameters | Required | Type |
181
- | ---------- | :------: | -------: |
182
- | `album_id` | Yes | `string` |
273
+ **Internal search** `deezer.pageSearch`, needs a session. Richest results
274
+ (top-result ranking, artist suggestions), and it returns per-type totals.
183
275
 
184
- ### `.getPlaylistInfo(playlist_id);`
276
+ ```ts
277
+ import {searchMusic, searchFacets, suggest} from 'gerdur-core';
185
278
 
186
- | Parameters | Required | Type |
187
- | ------------- | :------: | -------: |
188
- | `playlist_id` | Yes | `string` |
279
+ const result = await searchMusic('daft punk', ['TRACK', 'ALBUM', 'ARTIST'], 25);
280
+ result.TRACK.data.forEach((t) => console.log(t.SNG_TITLE, '—', t.ART_NAME));
189
281
 
190
- ### `.getPlaylistTracks(playlist_id);`
282
+ searchFacets(result); // {track: 207, album: 99, artist: 17, …, order: ['TOP_RESULT','ARTIST','TRACK',…]}
191
283
 
192
- | Parameters | Required | Type |
193
- | ------------- | :------: | -------: |
194
- | `playlist_id` | Yes | `string` |
284
+ const hints = await suggest('daf'); // fast "as you type" autocomplete, per-type
285
+ ```
195
286
 
196
- ### `.getArtistInfo(artist_id);`
287
+ **Public REST search** — `api.deezer.com/search`, no auth. Clean objects with
288
+ `isrc` / `preview` / `rank`, `order`, and `limit` / `index` paging.
197
289
 
198
- | Parameters | Required | Type |
199
- | ----------- | :------: | -------: |
200
- | `artist_id` | Yes | `string` |
290
+ ```ts
291
+ import {
292
+ searchPublicApi,
293
+ searchTracks,
294
+ searchAlbums,
295
+ searchArtists,
296
+ searchPlaylists,
297
+ buildAdvancedQuery,
298
+ } from 'gerdur-core';
299
+
300
+ const {data} = await searchTracks('one more time', {order: 'RANKING', limit: 25});
301
+
302
+ // advanced operators — reliable only on the track index
303
+ const q = buildAdvancedQuery({artist: 'daft punk', durMin: 200, bpmMax: 130});
304
+ // => 'artist:"daft punk" dur_min:200 bpm_max:130'
305
+ const strict = await searchPublicApi(q, {strict: true, limit: 50});
306
+
307
+ const albums = await searchAlbums('discovery'); // plain string — operators are ignored here
308
+ ```
201
309
 
202
- ### `.getDiscography(artist_id, limit);`
310
+ | `searchPublicApi(query, options)` option | Type | Notes |
311
+ | :--- | :--- | :--- |
312
+ | `type` | `'track'` (default) `'album'` `'artist'` `'playlist'` `'user'` `'radio'` `'podcast'` | |
313
+ | `order` | `RANKING`, `RATING_DESC`, `DURATION_DESC`, `TRACK_ASC`, … | |
314
+ | `strict` | `boolean` | send `strict=on` — disables Deezer's fuzzy fallback |
315
+ | `limit` / `index` | `number` | page size (Deezer caps near 100) / offset |
203
316
 
204
- | Parameters | Required | Type | Default | Description |
205
- | ----------- | :------: | -------: | ------: | ----------------------: |
206
- | `artist_id` | Yes | `string` | - | artist id |
207
- | `limit` | No | `number` | 500 | maximum tracks to fetch |
317
+ `buildAdvancedQuery({query?, artist?, album?, track?, label?, durMin?, durMax?, bpmMin?, bpmMax?})`
318
+ is a pure string builder Deezer treats the operators as ranking hints, not hard
319
+ filters, and only honours them on **track** search.
208
320
 
209
- ### `.getProfile(user_id);`
321
+ ### Browse and discover
210
322
 
211
- | Parameters | Required | Type |
212
- | ---------- | :------: | -------: |
213
- | `user_id` | Yes | `string` |
323
+ Public REST, no auth. Everything returns a `{data, total?, next?}` list unless
324
+ noted.
214
325
 
215
- ### `.searchAlternative(artist_name, song_name);`
326
+ | Function | Returns |
327
+ | :--- | :--- |
328
+ | `getGenres()` | Deezer's genre list (`id` `0` = "All") |
329
+ | `getChart(genreId = 0, limit = 10)` | `{tracks, albums, artists, playlists, podcasts}` for a genre |
330
+ | `getChartTracks(genreId = 0, limit = 100, index = 0)` | just the track chart, each with a `position` |
331
+ | `getGenreArtists(genreId)` | artists filed under a genre |
332
+ | `getEditorialList()` | Deezer's editorial sections |
333
+ | `getEditorialReleases(id = 0, limit = 25, index = 0)` | new releases for a section |
334
+ | `getEditorialSelection(id = 0)` | albums the editors are pushing |
335
+ | `getEditorialCharts(id = 0)` | a section's charts (same 5-list shape as `getChart`) |
336
+ | `getArtistTopTracks(artistId, limit = 50)` | an artist's most popular tracks |
337
+ | `getRelatedArtists(artistId, limit = 20)` | similar artists |
338
+ | `getArtistAlbums(artistId, limit = 50, index = 0)` | the artist's discography (public shape) |
339
+ | `getArtistPlaylists(artistId, limit = 25)` | playlists featuring the artist |
340
+ | `getArtistRadioTracks(artistId)` | a ready-made radio seeded from the artist |
216
341
 
217
- | Parameters | Required | Type |
218
- | ------------- | :------: | -------: |
219
- | `artist_name` | Yes | `string` |
220
- | `song_name` | Yes | `string` |
342
+ ```ts
343
+ import {getGenres, getChart, getRelatedArtists} from 'gerdur-core';
221
344
 
222
- ### `.searchMusic(query, types, limit);`
345
+ const {data: genres} = await getGenres();
346
+ const rock = genres.find((g) => g.name === 'Rock')!;
347
+ const {tracks} = await getChart(rock.id, 20); // this week's rock chart
348
+ const similar = await getRelatedArtists(27); // artists like Daft Punk
349
+ ```
223
350
 
224
- | Parameters | Required | Type | Default | Description |
225
- | ---------- | :------: | -------: | --------: | ------------------------------: |
226
- | `query` | Yes | `string` | - | search query |
227
- | `types` | No | `array` | ['TRACK'] | array of search types |
228
- | `limit` | No | `number` | 15 | maximum item to fetch per types |
351
+ ### Flow, radios and a user's library
229
352
 
230
- ### `.searchPublicApi(query, options?)` and `.searchTracks` / `.searchAlbums` / `.searchArtists` / `.searchPlaylists`
353
+ Public-profile datapass a `userId` (`getUser().USER_ID`, a profile URL, or
354
+ `parseInfo`). A **private** library is only visible to that user's own session.
231
355
 
232
- Hits the **public** REST API (`api.deezer.com/search`) instead of the internal
233
- `pageSearch` gateway. Returns clean public-API objects (`isrc`, `preview`,
234
- `rank`, numeric ids), accepts the advanced query operators, an `order`, and
235
- `limit` / `index` paging. No auth required.
356
+ | Function | Returns |
357
+ | :--- | :--- |
358
+ | `getUserFlow(userId, limit = 40)` | **Flow** — the endless personalised mix, as tracks |
359
+ | `getUserFavoriteTracks(userId, limit?, index?)` | loved tracks, newest first (each with `time_add`) |
360
+ | `getUserFavoriteAlbums(userId, limit?, index?)` | favourite albums |
361
+ | `getUserFavoriteArtists(userId, limit?, index?)` | favourite artists |
362
+ | `getUserPlaylists(userId, limit?, index?)` | the user's own + followed playlists |
363
+ | `getUserRadios(userId)` | radios the user favourited |
364
+ | `getUserChartTracks(userId, limit?)` | the user's personal top tracks |
365
+ | `getRadios()` | Deezer's curated radio list |
366
+ | `getRadioTracks(radioId)` | a radio's current tracklist — a ready-to-play source |
367
+ | `getRadioGenres()` | radios grouped by genre |
236
368
 
237
- | Parameters | Required | Type | Description |
238
- | ---------------- | :------: | --------- | --------------------------------------------------------------------------- |
239
- | `query` | Yes | `string` | plain text, or the output of `buildAdvancedQuery` |
240
- | `options.type` | No | `string` | `'track'` (default), `'album'`, `'artist'`, `'playlist'`, `'user'`, `'radio'` |
241
- | `options.order` | No | `string` | `RANKING`, `TRACK_ASC`, `RATING_DESC`, `DURATION_DESC`, … |
242
- | `options.strict` | No | `boolean` | send Deezer's `strict=on` (disables the fuzzy fallback) |
243
- | `options.limit` | No | `number` | page size (Deezer caps near 100) |
244
- | `options.index` | No | `number` | offset into the result set |
369
+ ```ts
370
+ import {getUser, getUserFlow, getUserFavoriteTracks, getRadioTracks} from 'gerdur-core';
245
371
 
246
- ```js
247
- const {data} = await searchTracks(buildAdvancedQuery({artist: 'daft punk', durMin: 200}), {limit: 25});
372
+ const me = await getUser();
373
+ const {data: flow} = await getUserFlow(me.USER_ID);
374
+ const {data: loved} = await getUserFavoriteTracks(me.USER_ID);
375
+ const {data: eighties} = await getRadioTracks(38305); // "The '80s"
248
376
  ```
249
377
 
250
- ### `.buildAdvancedQuery(filters)`
378
+ ### Podcasts
251
379
 
252
- Pure helper — composes Deezer's advanced operators into one query string.
253
- `{artist, album, track, label}` become `artist:"…"`; `{durMin, durMax, bpmMin, bpmMax}`
254
- become `dur_min:NNN` / `bpm_min:NNN`; a free-text `query` is emitted first.
255
- Reliable only on the **track** index — `/search/album` and `/search/artist`
256
- ignore the operators, so pass a plain string there.
380
+ ```ts
381
+ import {getShowEpisodes, getEpisode} from 'gerdur-core';
257
382
 
258
- ### `.suggest(query, nb?)`
383
+ const {data: episodes} = await getShowEpisodes('1265876', 25); // newest first
384
+ const ep = await getEpisode(episodes[0].EPISODE_ID);
385
+ // ep.EPISODE_DIRECT_STREAM_URL — a plain MP3: no licence, no decryption
386
+ ```
259
387
 
260
- `deezer.suggest` autocomplete — cheaper and faster than `searchMusic`, for
261
- "as you type" UIs. `nb` (default 5) caps items per type. Needs an initialised
262
- session (`initDeezerApi`).
388
+ ### Preview clips
263
389
 
264
- ### `.searchFacets(result)`
390
+ The 30-second preview is a plain MP3 — **no licence, no `arl`, no encryption**.
391
+ Good for "audition before download" and for CI that must not pull full tracks.
265
392
 
266
- Flattens the per-type hit counts + Deezer's relevance `order` out of a
267
- `searchMusic` result for "207 tracks · 99 albums · 17 artists" UIs.
393
+ ```ts
394
+ import {getTrackPreview, downloadPreview} from 'gerdur-core';
268
395
 
269
- ```js
270
- const r = await searchMusic('daft punk', ['TRACK', 'ALBUM', 'ARTIST']);
271
- searchFacets(r); // {track: 207, album: 99, artist: 17, …, order: ['TOP_RESULT','TRACK',…]}
396
+ const {url, duration} = (await getTrackPreview('3135556'))!; // {url, duration: 30}
397
+ const clip = await downloadPreview('3135556'); // Buffer (plain MP3), or null
272
398
  ```
273
399
 
274
- ### Browse & discovery
400
+ Accepts a gw `track` object (reads its `MEDIA`, no extra request), a track id, or
401
+ a number.
275
402
 
276
- Public REST endpoints no `arl` needed, memoised like the rest. All return a
277
- `{data, total?, next?}` list unless noted.
403
+ ### Resolve a download URL
278
404
 
279
- | Method | Returns |
280
- | :--- | :--- |
281
- | `getGenres()` | Deezer's genre list (`id` `0` = "All"). |
282
- | `getChart(genreId = 0, limit = 10)` | `{tracks, albums, artists, playlists, podcasts}` — the ranked lists for a genre. |
283
- | `getChartTracks(genreId = 0, limit = 100, index = 0)` | just the track chart, each with a `position`. |
284
- | `getGenreArtists(genreId)` | artists filed under a genre. |
285
- | `getEditorialList()` | Deezer's editorial sections. |
286
- | `getEditorialReleases(editorialId = 0, limit = 25, index = 0)` | new releases for a section. |
287
- | `getEditorialSelection(editorialId = 0)` | albums the editors are pushing. |
288
- | `getEditorialCharts(editorialId = 0)` | a section's charts (same 5-list shape as `getChart`). |
289
- | `getArtistTopTracks(artistId, limit = 50)` | an artist's most popular tracks. |
290
- | `getRelatedArtists(artistId, limit = 20)` | similar / related artists. |
291
- | `getArtistAlbums(artistId, limit = 50, index = 0)` | the artist's discography. |
292
- | `getArtistPlaylists(artistId, limit = 25)` | playlists featuring the artist. |
293
- | `getArtistRadioTracks(artistId)` | a ready-made radio seeded from the artist. |
294
- | `getTrackByISRC(isrc)` | the public-API track for an ISRC (`bpm`, `gain`, `preview`, …). |
295
- | `getAlbumByUPC(upc)` | the public-API album (with its `tracks`) for a UPC/EAN barcode. |
296
-
297
- ```js
298
- const {data: genres} = await getGenres();
299
- const rock = genres.find((g) => g.name === 'Rock');
300
- const {tracks} = await getChart(rock.id, 20); // this week's rock chart
301
- const similar = await getRelatedArtists(27); // artists like Daft Punk
302
- const track = await getTrackByISRC('USUM71311296'); // "Get Lucky"
405
+ ```ts
406
+ import {getTrackDownloadUrl, resolveDownloadUrls, refreshTrackTokens, DEEZER_FORMATS} from 'gerdur-core';
407
+
408
+ // one track
409
+ const one = await getTrackDownloadUrl(track, 9); // {trackUrl, isEncrypted, fileSize} | null
410
+
411
+ // many tracks, ONE request — Deezer returns the best each is licensed for
412
+ const fresh = await refreshTrackTokens(tracks); // refresh tokens older than ~1h first
413
+ const urls = await resolveDownloadUrls(fresh, ['FLAC', 'MP3_320', 'MP3_128']);
414
+ // urls[i] {trackUrl, isEncrypted, fileSize, format, cipher} | null
303
415
  ```
304
416
 
305
- `getTrackByISRC` / `getAlbumByUPC` return raw public-API objects. To download,
306
- pass the `id` to `getTrackInfo` / `getAlbumTracks` (or use the converter's
307
- `isrc2deezer` / `upc2deezer`, which hydrate a gw track for you).
417
+ - **`DEEZER_FORMATS`** (best worst): `FLAC`, `MP3_320`, `MP3_256`, `MP3_128`,
418
+ `MP3_64`, `AAC_64`, `MP4_RA3`, `MP4_RA2`, `MP4_RA1`. `resolveDownloadUrls`
419
+ accepts either these strings or the `1 | 3 | 9` shorthand.
420
+ - **`formatName(q)` / `toFormat(q)`** — normalise a number or string to the media
421
+ API's format string.
422
+ - **`refreshTrackTokens(tracks, {graceSeconds = 300, session?})`** — one
423
+ `song.getListData` request refreshes every token that has expired or expires
424
+ within `graceSeconds`. Tracks with a valid token are returned untouched.
425
+ - Falls back to the legacy `e-cdns-proxy-*.dzcdn.net` scheme when the media API
426
+ declines. Throws [`WrongLicense`](#errors) / [`GeoBlocked`](#errors) /
427
+ [`ExpiredTrackToken`](#errors).
308
428
 
309
- ### Flow, radios & a user's library
429
+ ### Download a track (buffer)
310
430
 
311
- Public-profile data — pass a `userId` (`getUser().USER_ID`, a profile URL, or
312
- `parseInfo`). A private library is only visible to that user's own session.
431
+ ```ts
432
+ import {downloadTrackBuffer, addTrackTags} from 'gerdur-core';
313
433
 
314
- | Method | Returns |
315
- | :--- | :--- |
316
- | `getUserFlow(userId, limit = 40)` | **Flow** — the endless personalised mix, as tracks. |
317
- | `getUserFavoriteTracks(userId, limit?, index?)` | loved tracks, newest first (each with `time_add`). |
318
- | `getUserFavoriteAlbums(userId, limit?, index?)` | favourite albums. |
319
- | `getUserFavoriteArtists(userId, limit?, index?)` | favourite artists. |
320
- | `getUserPlaylists(userId, limit?, index?)` | the user's own + followed playlists. |
321
- | `getUserRadios(userId)` | radios the user favourited. |
322
- | `getUserChartTracks(userId, limit?)` | the user's personal top tracks. |
323
- | `getRadios()` | Deezer's curated radio list. |
324
- | `getRadioTracks(radioId)` | a radio's current tracklist — a ready-to-play source. |
325
- | `getRadioGenres()` | radios grouped by genre. |
326
-
327
- ```js
328
- const me = await getUser();
329
- const {data: flow} = await getUserFlow(me.USER_ID); // your Flow
330
- const {data: loved} = await getUserFavoriteTracks(me.USER_ID);
331
- const {data: eighties} = await getRadioTracks(38305); // "The '80s"
434
+ const audio = await downloadTrackBuffer(track, 3); // get_url → fetch → decrypt, all in memory
435
+ if (audio) {
436
+ const {buffer} = await addTrackTags(audio, track);
437
+ // buffer is a tagged MP3/FLAC write it wherever
438
+ }
332
439
  ```
333
440
 
334
- ### Podcasts
441
+ `downloadTrackBuffer(track, quality, {onProgress?, session?})` → `Buffer | null`
442
+ (`null` when the track+quality can't be resolved). No resume — use the stream API
443
+ for that.
335
444
 
336
- | Method | |
337
- | :--- | :--- |
338
- | `getShowEpisodes(showId, nb = 25, start = 0)` | a page of a show's episodes, newest first |
339
- | `getEpisode(episodeId)` | one episode — `EPISODE_DIRECT_STREAM_URL` is a plain MP3, no licence / decryption |
445
+ ### Download a track (stream)
340
446
 
341
- ### `.getTrackDownloadUrl(track, quality);`
447
+ Constant memory (~one 2048-byte stripe) regardless of file size or concurrency,
448
+ with progress and resume.
342
449
 
343
- | Parameters | Required | Type | Description |
344
- | ---------- | :------: | ----------: | ---------------------------------: |
345
- | `track` | Yes | `string` | track object |
346
- | `quality` | Yes | `1, 3 or 9` | 1 = 128kbps, 3 = 320kbps, 9 = flac |
450
+ ```ts
451
+ import {pipeline} from 'stream/promises';
452
+ import {createWriteStream, statSync, existsSync} from 'fs';
453
+ import {streamTrackDownload} from 'gerdur-core';
347
454
 
348
- Resolves `{trackUrl, isEncrypted, fileSize}`. `isEncrypted` now comes from the
349
- media API's `cipher` field (authoritative) rather than a URL guess.
455
+ const resumeFrom = existsSync('track.flac') ? statSync('track.flac').size : 0;
350
456
 
351
- ### `.refreshTrackTokens(tracks, options?);`
457
+ const {stream, size, startedAt, isEncrypted} = await streamTrackDownload(track, 9, {
458
+ resumeFrom, // rounded down to a 2048-byte boundary so stripe decryption stays aligned
459
+ onProgress: (received, total) => process.stdout.write(`\r${((received / total) * 100) | 0}%`),
460
+ });
461
+
462
+ await pipeline(stream, createWriteStream('track.flac', {flags: startedAt > 0 ? 'a' : 'w'}));
463
+ ```
464
+
465
+ Lower-level pieces:
466
+
467
+ - **`getStream(url, {rangeStart?})`** → `{stream, headers, status, url}` — a raw,
468
+ content-decoded (gzip/br/deflate) response stream.
469
+ - **`createDecryptStream(sngId, startChunk?)`** → a Node `Transform` for your own
470
+ `pipeline`. `startChunk` = `resumeFromByte / 2048`.
471
+ - **`TrackDecryptStream(sngId, startChunk?)`** — the imperative engine
472
+ (`.write(buf) → Buffer`, `.final() → Buffer`) behind that `Transform`.
352
473
 
353
- `TRACK_TOKEN`s live ~1 hour, so a token fetched at the start of a long playlist
354
- download is dead by track 40 (surfacing as an opaque CDN 403). Run the selection
355
- through this first **one** `song.getListData` request refreshes every token
356
- that has expired (or expires within `options.graceSeconds`, default 300).
357
- Tracks with a still-valid token come back untouched. Also `session.refreshTrackTokens(tracks, graceSeconds?)`.
474
+ > Streaming the **tag write** (rewriting a FLAC metadata block with no full-file
475
+ > buffer) is not implemented yet buffer the stream and call `addTrackTags`, or
476
+ > tag the finished file afterwards.
358
477
 
359
- ```js
360
- const fresh = await refreshTrackTokens(playlist.tracks);
361
- const urls = await resolveDownloadUrls(fresh, [9, 3, 1]);
478
+ ### Decrypt
479
+
480
+ ```ts
481
+ import {decryptDownload} from 'gerdur-core';
482
+
483
+ const plain = resolved.isEncrypted ? decryptDownload(body, track.SNG_ID) : body;
362
484
  ```
363
485
 
364
- ### Formats
486
+ `decryptDownload(buffer, sngId)` decrypts a fully-downloaded body. Format-
487
+ preserving: an encrypted MP3 stays an MP3. The per-track key is
488
+ `md5(sngId)[i] ^ md5(sngId)[i+16] ^ "g4el58wc0zvf9na1"[i]`.
365
489
 
366
- Deezer's `get_url` understands more than `1 / 3 / 9`. `DEEZER_FORMATS` lists them
367
- best worst: `FLAC`, `MP3_320`, `MP3_256`, `MP3_128`, `MP3_64`, `AAC_64`,
368
- `MP4_RA3`, `MP4_RA2`, `MP4_RA1` (the last four are the HE-AAC ladder some
369
- accounts / regions expose).
490
+ `getSongFileName(track, quality)` builds the obfuscated filename for the legacy
491
+ CDN path you rarely need it directly.
370
492
 
371
- - **`resolveDownloadUrls(tracks, qualities)`** `qualities` entries may be the
372
- `1 | 3 | 9` shorthand **or** any format string, e.g.
373
- `resolveDownloadUrls(tracks, ['FLAC', 'MP3_320', 'AAC_64'])`. Deezer returns
374
- the best each track is licensed for. Each result now also carries `format` and
375
- `cipher` (`'BF_CBC_STRIPE'` or `'NONE'`).
376
- - **`formatName(quality)`** / **`toFormat(quality)`** — normalise a number or
377
- format string to the `get_url` format string.
493
+ ### Tag MP3 / FLAC
378
494
 
379
- ### `.getTrackPreview(track)` / `.downloadPreview(track)`
495
+ `addTrackTags(buffer, track, options?)` sniffs `fLaC` vs MP3, gathers everything
496
+ Deezer has for the track (album info, credits, lyrics, cover, artist photo,
497
+ BPM — all coalesced, so tagging a whole album hits each endpoint once), writes
498
+ the tags, and returns `{buffer, model}`.
380
499
 
381
- The 30-second preview clip — a plain MP3, **no licence, no `arl`, no
382
- encryption**. `track` may be a gw track object (reads its `MEDIA`, no extra
383
- request), a track id, or a number.
500
+ ```ts
501
+ const {buffer, model} = await addTrackTags(audio, track, {
502
+ coverSize: 1200, // 56–1800 px, default 1000
503
+ });
384
504
 
385
- ```js
386
- const {url} = await getTrackPreview('3135556'); // {url, duration: 30}
387
- const clip = await downloadPreview('3135556'); // Buffer (ID3-tagged MP3)
505
+ model.title; // "Harder, Better, Faster, Stronger"
506
+ model.isrc; // "GBDUW0000059"
507
+ model.bpm; // 123
508
+ model.replayGainTrackGain; // "-9.24 dB"
509
+ model.lyricsSynced; // an LRC document — write it as a .lrc sidecar
510
+ model.contributors; // normalised producers / engineers / performers / …
388
511
  ```
389
512
 
390
- Useful for "audition before download" and for CI that shouldn't pull full tracks.
513
+ | `AddTrackTagsOptions` | Default | |
514
+ | :--- | :--- | :--- |
515
+ | `coverSize` | `1000` | embedded cover width, 56–1800 px |
516
+ | `cover` / `artistImage` | — | pre-fetched image `Buffer` (`null` = skip); avoids a download |
517
+ | `album` / `lyrics` / `publicTrack` | — | pre-fetched payloads — pass once per album to skip refetching |
518
+ | `embedCover` / `embedArtistImage` | `true` | |
519
+ | `writeLyrics` / `embedSyncedLyrics` | `true` | synced LRC goes to FLAC Vorbis only (no ID3v2.3 `SYLT`) |
520
+ | `richCredits` | `true` | hydrate credits + BPM for album/playlist tracks that omit them |
521
+ | `deezerIds` / `includeRank` | `true` | write `DEEZER_*_ID` / popularity rank |
522
+
523
+ Building blocks, if you want the model without writing tags:
524
+
525
+ - **`getRichAlbum(albId)`** → merged gw + public album metadata (`RichAlbum`).
526
+ - **`buildTagModel(input)`** → the canonical `TrackTagModel` (see [Types](#types)).
527
+ - **`normalizeContributors(SNG_CONTRIBUTORS)`** → cleans Deezer's messy
528
+ contributor keys into `{mainArtists, featuring, composers, producers,
529
+ engineers, …}`.
530
+ - **`toLrc(syncJson, meta)`** → render `LYRICS_SYNC_JSON` as an LRC string.
531
+ - **`downloadAlbumCover(track, size)` / `downloadArtistImage(track)`** →
532
+ image `Buffer`s. `MAX_COVER_SIZE` = 1800.
533
+
534
+ ### Enrichment
535
+
536
+ Optional, **read-only**, off by default, and **never wired into `addTrackTags`**.
537
+ Fills gaps Deezer leaves — canonical release/label data and cover art larger than
538
+ Deezer's 1800 px ceiling.
539
+
540
+ ```ts
541
+ import {configureMusicBrainz, getCoverArtByISRC, lookupRecordingByISRC} from 'gerdur-core';
391
542
 
392
- ### `.decryptDownload(data, song_id);`
543
+ configureMusicBrainz({userAgent: 'my-app/1.0 ( me@example.com )'}); // required — MB wants a real UA
393
544
 
394
- | Parameters | Required | Type | Description |
395
- | ---------- | :------: | -------: | ---------------------: |
396
- | `data` | Yes | `buffer` | downloaded song buffer |
397
- | `song_id` | Yes | `string` | track id |
545
+ // one call: ISRC MusicBrainz recording → best Cover Art Archive front cover
546
+ const coverUrl = await getCoverArtByISRC(track.ISRC, {minSize: 1200}); // string | null
547
+ if (coverUrl) {
548
+ const cover = await getBuffer(coverUrl);
549
+ await addTrackTags(audio, track, {cover}); // hand it your own cover
550
+ }
398
551
 
399
- ### Streaming download
552
+ // or the pieces
553
+ const rec = await lookupRecordingByISRC(track.ISRC); // MBRecording | null
554
+ rec?.isrcs; // every ISRC MB has for this recording
555
+ rec?.releases; // each with releaseGroupMbid, primaryType, status, label, catalogNumber
556
+ ```
400
557
 
401
- For large files / high concurrency — peak memory is ~one 2048-byte stripe
402
- instead of the file.
558
+ | Function | |
559
+ | :--- | :--- |
560
+ | `configureMusicBrainz({userAgent?, minIntervalMs?})` | set the UA and rate limit (default 1100 ms); call once at startup |
561
+ | `lookupRecordingByISRC(isrc)` | canonical `MBRecording` (title, artist credits, length, ISRCs, releases) or `null` |
562
+ | `getMusicBrainzRecording(mbid)` / `getMusicBrainzRelease(mbid, inc?)` | direct MBID lookups; release adds label / catalogue number / barcode |
563
+ | `getCoverArt(mbid, entity = 'release-group')` | Cover Art Archive images (`front` / `approved` / `thumbnails`), or `null` |
564
+ | `getBestCoverArtUrl(mbid, {entity?, minSize = 1200})` | one URL — approved front cover ≥ `minSize` px, else full-res |
565
+ | `getRecordingCoverArt(recording, {minSize?, maxTries = 4})` | walks a recording's release-groups canonical-first (Official → Album → earliest) |
566
+ | `getCoverArtByISRC(isrc, {minSize?, maxTries?})` | the whole chain — **use this**, not `getBestCoverArtUrl` on `releases[0]` |
567
+ | `PoliteJsonClient` | the serialised, rate-limited, `503`/`429`-retrying, `404`→`null` JSON client both use — exported for your own polite clients |
403
568
 
404
- - **`streamTrackDownload(track, quality, options?)`** `{stream, size, startedAt, isEncrypted}`.
405
- `stream` is decrypted audio bytes (`get_url` CDN fetch stripe-decrypt
406
- `Transform` → your sink). `options.onProgress(received, total)`;
407
- `options.resumeFrom` (bytes, rounded down to a 2048 boundary) sends a `Range`
408
- header and resumes stripe-decryption in phase.
409
- - **`createDecryptStream(sngId, startChunk?)`** → a `Transform` for your own `pipeline`.
410
- - **`getStream(url, {rangeStart?})`** → `{stream, headers, status}` — a raw,
411
- content-decoded response stream.
569
+ A persistent MusicBrainz `503` ("server busy") surfaces as `HttpStatusError`
570
+ after 3 backed-off retries catch it and fall back to Deezer's data.
412
571
 
413
- ```js
414
- import {pipeline} from 'stream/promises';
415
- import {createWriteStream} from 'fs';
572
+ ### Use multiple accounts
416
573
 
417
- const {stream} = await streamTrackDownload(track, 9, {
418
- onProgress: (got, total) => process.stdout.write(`\r${((got / total) * 100) | 0}%`),
419
- });
420
- await pipeline(stream, createWriteStream('track.flac'));
574
+ The free functions share one default session. For concurrent accounts, hold
575
+ isolated `Session` objects each with its own `arl`, tokens, `license_token`
576
+ and response cache.
577
+
578
+ ```ts
579
+ import {createSession} from 'gerdur-core';
580
+
581
+ const a = await createSession(arlOne);
582
+ const b = await createSession(arlTwo);
583
+
584
+ await a.loadUserData();
585
+ console.log(a.country, a.canStreamLossless, a.licenseToken);
586
+
587
+ const track = await a.getTrackInfo('3135556'); // TRACK_TOKEN is a's
588
+ const audio = await a.getTrackBuffer(track, 9); // resolved + decrypted as account a
421
589
  ```
422
590
 
423
- Streaming the **tag write** (rewriting the FLAC metadata block in place, no
424
- full-file `Buffer.concat`) is not done yet — buffer the result and call
425
- `addTrackTags`, or tag the file afterward.
591
+ `Session` methods: `getUser`, `getTrackInfo`, `getLyrics`, `getAlbumInfo`,
592
+ `getAlbumTracks`, `getPlaylistInfo`, `getPlaylistTracks`, `getArtistInfo`,
593
+ `getDiscography`, `getProfile`, `searchMusic`, `getTrackDownloadUrl`,
594
+ `resolveDownloadUrls`, `refreshTrackTokens`, `streamTrack`, `getTrackBuffer`,
595
+ plus lifecycle (`init`, `refreshApiToken`, `loadUserData`, `invalidateUserData`)
596
+ and the raw channels (`gw`, `gwLight`, `gwGet`).
597
+
598
+ `defaultSession()` returns the shared one; `setDefaultSession(s)` swaps it (used
599
+ in tests). The free `getTrackDownloadUrl` / `resolveDownloadUrls` /
600
+ `streamTrackDownload` / `refreshTrackTokens` all take an optional `session`.
601
+
602
+ ### HTTP helpers
603
+
604
+ The zero-dependency HTTP client (`get`/`post`/`head`, redirects, gzip/br/deflate,
605
+ keep-alive) is used internally and exported for reuse:
426
606
 
427
- ### `.addTrackTags(data, track, options?)`
607
+ ```ts
608
+ import {getJson, getText, getBuffer, getStream, httpAgent, httpsAgent, HttpClient} from 'gerdur-core';
609
+
610
+ const data = await getJson<{id: number}>('https://api.deezer.com/track/3135556');
611
+ const bytes = await getBuffer(coverUrl);
612
+ const {stream} = await getStream(bigFileUrl, {rangeStart: 1024});
613
+
614
+ const client = new HttpClient({baseURL: 'https://api.deezer.com', timeout: 15000});
615
+ ```
428
616
 
429
- Resolves album info, credits, lyrics and artwork from Deezer and writes them into
430
- the audio (ID3v2.3 for MP3, Vorbis comments for FLAC). Returns
431
- `{buffer, model}` — `model` is the full `TrackTagModel`, including
432
- `model.lyricsSynced` (an LRC document) when the track has time-synced lyrics.
617
+ `httpAgent` / `httpsAgent` are shared keep-alive agents pass them to your own
618
+ `http` calls to reuse connections.
433
619
 
434
- | Parameters | Required | Type | Description |
435
- | ---------- | :------: | -------: | --------------------------------------------- |
436
- | `data` | Yes | `buffer` | downloaded, decrypted song buffer |
437
- | `track` | Yes | `object` | track object from `getTrackInfo` / `parseInfo` |
438
- | `options` | No | `object` | `AddTrackTagsOptions` — `coverSize` (56–1800), pre-fetched `album`/`lyrics`/`cover`, and toggles (`richCredits`, `embedArtistImage`, `deezerIds`, …) |
620
+ ---
439
621
 
440
- ### Enrichment (optional, read-only)
622
+ ## Errors
441
623
 
442
- Fill gaps Deezer leaves, from open databases **off by default, never wired into
443
- `addTrackTags`**. Both services rate-limit and want a descriptive `User-Agent`;
444
- call `configureMusicBrainz({userAgent})` once at startup.
624
+ Gateway and media-API failures throw a **`DeezerError`** (`extends Error`):
445
625
 
446
- | Method | |
626
+ | Field | |
447
627
  | :--- | :--- |
448
- | `lookupRecordingByISRC(isrc)` | canonical MusicBrainz recording — title, artist credits, length, **all** known ISRCs, and the releases it's on (each with a `releaseGroupMbid`). `null` if unknown. |
449
- | `getMusicBrainzRecording(mbid)` / `getMusicBrainzRelease(mbid, inc?)` | direct MBID lookups — the release adds label, catalogue number, barcode. |
450
- | `getCoverArt(mbid, entity = 'release-group')` | Cover Art Archive images (`front` / `approved` / `thumbnails`). `null` when there's no art. |
451
- | `getBestCoverArtUrl(mbid, {entity?, minSize = 1200})` | one URL — the approved front cover at ≥ `minSize` px, else full-res. Deezer caps its own art at 1800 px; this goes bigger. |
452
- | `getCoverArtByISRC(isrc, {minSize?, maxTries?})` | the whole chain — ISRC → recording → **canonical-first** release walk → first real front cover. Use this, not `getBestCoverArtUrl` on `releases[0]` (often a promo comp with no art). |
453
- | `getRecordingCoverArt(recording, …)` | same, from an already-fetched `MBRecording`. |
454
-
455
- ```js
456
- configureMusicBrainz({userAgent: 'myapp/1.0 ( me@example.com )'});
457
- const rec = await lookupRecordingByISRC(track.isrc);
458
- if (rec?.releases[0]?.releaseGroupMbid) {
459
- const cover = await getBestCoverArtUrl(rec.releases[0].releaseGroupMbid, {minSize: 1200});
460
- }
628
+ | `code` | Deezer's numeric code, when it sent one (e.g. `4`, `800`) |
629
+ | `keys` | gateway error keys, e.g. `['VALID_TOKEN_REQUIRED']`, `['DATA_ERROR']` |
630
+ | `retryable` | whether a retry could plausibly help |
631
+ | `payload` | the raw error object |
632
+
633
+ The download path also throws these typed errors:
634
+
635
+ | Error | Meaning | Recovery |
636
+ | :--- | :--- | :--- |
637
+ | `GeoBlocked` | not licensed in the account's country | try another account / region |
638
+ | `WrongLicense` | the account's plan can't stream that format | request a lower quality |
639
+ | `ExpiredTrackToken` | the `TRACK_TOKEN` aged out (~1 h) | re-fetch the track (`getTrackInfo`) or `refreshTrackTokens`, then retry |
640
+ | `HttpStatusError` | a non-2xx HTTP response (`statusCode`, `headers`, `body`) | inspect `statusCode` |
641
+
642
+ `RETRY_POLICY` (exported) is the bounded-retry config: `code4Attempts`,
643
+ `authReinits`, `tokenRefreshes`, `baseMs`, `maxDelayMs`, `deadlineMs`.
644
+
645
+ ## Types
646
+
647
+ Response types are hand-written and shipped both from the main entry and the
648
+ `gerdur-core/types` subpath:
649
+
650
+ ```ts
651
+ import type {trackType, albumType, playlistTracksType, lyricsType} from 'gerdur-core/types';
652
+ import type {
653
+ TrackTagModel, // canonical tag model from addTrackTags / buildTagModel
654
+ RichAlbum,
655
+ ResolvedUrl, // {trackUrl, isEncrypted, fileSize, format, cipher}
656
+ TrackStream, // {stream, size, startedAt, isEncrypted}
657
+ StreamTrackOptions,
658
+ SessionUserData, // {licenseToken, country, canStreamLossless, canStreamHq, offerId?}
659
+ DeezerFormat,
660
+ AddTrackTagsOptions,
661
+ MBRecording,
662
+ MBRelease,
663
+ CoverArt,
664
+ } from 'gerdur-core';
461
665
  ```
462
666
 
463
- Errors: a persistent MusicBrainz `503` ("server busy") surfaces as
464
- `HttpStatusError` after 3 backed-off retries catch and fall back to Deezer's
465
- data.
667
+ `TrackTagModel` is the normalised view every tag writer consumes — `title`,
668
+ `artists` / `mainArtists` / `featuredArtists`, `composers` / `producers` /
669
+ `engineers` / `performers`, `trackNumber` / `discNumber` / totals, `isrc` /
670
+ `barcode` / `bpm` / `durationMs`, `genres` / `label` / `releaseType`, `date` /
671
+ `originalDate`, `copyright` / `producerLine`, `replayGainTrackGain`, `explicit`,
672
+ `lyrics` / `lyricsSynced`, `ids.*` and `rank`.
673
+
674
+ ## API reference
675
+
676
+ <details>
677
+ <summary><b>Auth &amp; sessions</b></summary>
678
+
679
+ `initDeezerApi(arl)` · `createSession(arl?)` · `defaultSession()` ·
680
+ `setDefaultSession(s)` · `Session` · `RETRY_POLICY` · `DEFAULT_ARL`
681
+ </details>
682
+
683
+ <details>
684
+ <summary><b>Lookups</b></summary>
685
+
686
+ `getTrackInfo` · `getLyrics` · `getAlbumInfo` · `getAlbumTracks` ·
687
+ `getPlaylistInfo` · `getPlaylistTracks` · `getArtistInfo` · `getDiscography` ·
688
+ `getProfile` · `getUser` · `getShowInfo` · `getChannelList` ·
689
+ `getPlaylistChannel` · `getTrackInfoPublicApi` · `getAlbumInfoPublicApi`
690
+ </details>
691
+
692
+ <details>
693
+ <summary><b>URL &amp; code resolution</b></summary>
694
+
695
+ `parseInfo` · `getUrlParts` · `isrc2deezer` · `upc2deezer` ·
696
+ `getTrackByISRC` · `getAlbumByUPC` · `spotify.*` · `tidal.*` · `youtube.*`
697
+ </details>
698
+
699
+ <details>
700
+ <summary><b>Search</b></summary>
701
+
702
+ `searchMusic` · `searchAlternative` · `suggest` · `searchFacets` ·
703
+ `searchPublicApi` · `searchTracks` · `searchAlbums` · `searchArtists` ·
704
+ `searchPlaylists` · `buildAdvancedQuery`
705
+ </details>
706
+
707
+ <details>
708
+ <summary><b>Browse &amp; discovery</b></summary>
709
+
710
+ `getGenres` · `getGenreArtists` · `getChart` · `getChartTracks` ·
711
+ `getEditorialList` · `getEditorialReleases` · `getEditorialSelection` ·
712
+ `getEditorialCharts` · `getArtistTopTracks` · `getRelatedArtists` ·
713
+ `getArtistAlbums` · `getArtistPlaylists` · `getArtistRadioTracks`
714
+ </details>
715
+
716
+ <details>
717
+ <summary><b>Flow, radios, library</b></summary>
718
+
719
+ `getUserFlow` · `getUserFavoriteTracks` · `getUserFavoriteAlbums` ·
720
+ `getUserFavoriteArtists` · `getUserPlaylists` · `getUserRadios` ·
721
+ `getUserChartTracks` · `getRadios` · `getRadioTracks` · `getRadioGenres`
722
+ </details>
723
+
724
+ <details>
725
+ <summary><b>Podcasts &amp; previews</b></summary>
726
+
727
+ `getEpisode` · `getShowEpisodes` · `getTrackPreview` · `downloadPreview`
728
+ </details>
729
+
730
+ <details>
731
+ <summary><b>Download, decrypt, stream</b></summary>
732
+
733
+ `getTrackDownloadUrl` · `resolveDownloadUrls` · `refreshTrackTokens` ·
734
+ `downloadTrackBuffer` · `streamTrackDownload` · `getStream` ·
735
+ `createDecryptStream` · `TrackDecryptStream` · `decryptDownload` ·
736
+ `getSongFileName` · `DEEZER_FORMATS` · `formatName` · `toFormat`
737
+ </details>
738
+
739
+ <details>
740
+ <summary><b>Tagging</b></summary>
741
+
742
+ `addTrackTags` · `buildTagModel` · `getRichAlbum` · `normalizeContributors` ·
743
+ `toLrc` · `downloadAlbumCover` · `downloadArtistImage` · `MAX_COVER_SIZE`
744
+ </details>
745
+
746
+ <details>
747
+ <summary><b>Enrichment</b></summary>
748
+
749
+ `configureMusicBrainz` · `lookupRecordingByISRC` · `getMusicBrainzRecording` ·
750
+ `getMusicBrainzRelease` · `getCoverArt` · `getBestCoverArtUrl` ·
751
+ `getRecordingCoverArt` · `getCoverArtByISRC` · `PoliteJsonClient`
752
+ </details>
753
+
754
+ <details>
755
+ <summary><b>HTTP &amp; errors</b></summary>
756
+
757
+ `getJson` · `getText` · `getBuffer` · `getStream` · `HttpClient` · `httpAgent` ·
758
+ `httpsAgent` · `HttpStatusError` · `DeezerError` · `GeoBlocked` · `WrongLicense`
759
+ · `ExpiredTrackToken`
760
+ </details>
761
+
762
+ See the [FAQ](docs/faq.md) and the [`gerdur` CLI](https://www.npmjs.com/package/gerdur)
763
+ for end-to-end usage.
764
+
765
+ ## The name
766
+
767
+ **Gerðr** is the jötunn Freyr sends Skírnir riding through a wall of fire to
768
+ fetch. Her name is *garðr* — "the enclosure, the walled garden" (English
769
+ *garden*). `gerdur-core` does the crossing: resolve the identifier, get past the
770
+ wall, decrypt the stream, hand back a finished track.
466
771
 
467
- ###
772
+ ## Legal
468
773
 
469
- > We are not responsible for any misuse of this library by any third party. Please make sure to respect the artists and the music industry when using this library.
774
+ For personal and archival use with content you are entitled to access. You are
775
+ responsible for complying with the terms of service of any provider and with
776
+ copyright law in your jurisdiction. The authors accept no liability for misuse.
777
+ Respect the artists — buy the music you love.
470
778
 
471
- > Made with :heart: by the Bluesix Team. If you want to contribute, please read the [contributing guidelines](.github/CONTRIBUTING.md) first.
779
+ See [LICENSE](LICENSE) · [Contributing](.github/CONTRIBUTING.md) ·
780
+ [Issues](https://github.com/soulwax/gerdur-core/issues)
package/package.json CHANGED
@@ -1,7 +1,24 @@
1
1
  {
2
2
  "name": "gerdur-core",
3
- "version": "2.13.0",
4
- "description": "Core module for gerdur.",
3
+ "version": "2.13.2",
4
+ "description": "Deezer API client, cross-service URL resolution, Blowfish track decryption and MP3/FLAC metadata tagging — the engine behind the gerdur CLI.",
5
+ "keywords": [
6
+ "deezer",
7
+ "deezer-api",
8
+ "spotify",
9
+ "tidal",
10
+ "isrc",
11
+ "music",
12
+ "metadata",
13
+ "id3",
14
+ "flac",
15
+ "lyrics",
16
+ "lrc",
17
+ "musicbrainz",
18
+ "cover-art",
19
+ "decrypt",
20
+ "blowfish"
21
+ ],
5
22
  "main": "dist/index.js",
6
23
  "types": "dist/index.d.ts",
7
24
  "scripts": {
@@ -31,7 +48,7 @@
31
48
  "access": "public"
32
49
  },
33
50
  "author": "Christian",
34
- "license": "SEE LICENSE IN LICENSE",
51
+ "license": "MIT",
35
52
  "dependencies": {
36
53
  "browser-id3-writer": "^4.4.0",
37
54
  "delay": "^5.0.0",