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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +655 -346
- 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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/gerdur-core)
|
|
7
|
+
[](https://www.npmjs.com/package/gerdur-core)
|
|
8
|
+
[](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
|
-
|
|
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
|
-
##
|
|
85
|
+
## Quick start
|
|
28
86
|
|
|
29
|
-
|
|
87
|
+
Download one track, tag it, and write it to disk:
|
|
30
88
|
|
|
31
89
|
```ts
|
|
32
|
-
import
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
const {buffer, model} = await api.addTrackTags(outFile, track, {coverSize: 500});
|
|
170
|
+
---
|
|
73
171
|
|
|
74
|
-
|
|
75
|
-
fs.writeFileSync(track.SNG_TITLE + '.mp3', buffer);
|
|
172
|
+
## Guide
|
|
76
173
|
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
+
### Look up tracks, albums, playlists, artists
|
|
84
194
|
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
|
198
|
+
| Function | Returns |
|
|
89
199
|
| :--- | :--- |
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
###
|
|
221
|
+
### Resolve a share URL
|
|
101
222
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
+
The lower-level converters are also exported:
|
|
155
248
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
| `arl_cookie` | Yes | `string` |
|
|
249
|
+
```ts
|
|
250
|
+
import {isrc2deezer, upc2deezer, spotify, tidal, youtube} from 'gerdur-core';
|
|
159
251
|
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
| ---------- | :------: | -------: |
|
|
164
|
-
| `track_id` | Yes | `string` |
|
|
257
|
+
### Resolve an ISRC or UPC
|
|
165
258
|
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
| `track_id` | Yes | `string` |
|
|
262
|
+
```ts
|
|
263
|
+
import {getTrackByISRC, getAlbumByUPC, getTrackInfo} from 'gerdur-core';
|
|
171
264
|
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
| `album_id` | Yes | `string` |
|
|
268
|
+
const album = await getAlbumByUPC('0724384960650'); // {id, title, tracks: {data: [...]}}
|
|
269
|
+
```
|
|
177
270
|
|
|
178
|
-
###
|
|
271
|
+
### Search
|
|
179
272
|
|
|
180
|
-
|
|
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
|
-
|
|
276
|
+
```ts
|
|
277
|
+
import {searchMusic, searchFacets, suggest} from 'gerdur-core';
|
|
185
278
|
|
|
186
|
-
|
|
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
|
-
|
|
282
|
+
searchFacets(result); // {track: 207, album: 99, artist: 17, …, order: ['TOP_RESULT','ARTIST','TRACK',…]}
|
|
191
283
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
| `playlist_id` | Yes | `string` |
|
|
284
|
+
const hints = await suggest('daf'); // fast "as you type" autocomplete, per-type
|
|
285
|
+
```
|
|
195
286
|
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
###
|
|
321
|
+
### Browse and discover
|
|
210
322
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
| `user_id` | Yes | `string` |
|
|
323
|
+
Public REST, no auth. Everything returns a `{data, total?, next?}` list unless
|
|
324
|
+
noted.
|
|
214
325
|
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
| `artist_name` | Yes | `string` |
|
|
220
|
-
| `song_name` | Yes | `string` |
|
|
342
|
+
```ts
|
|
343
|
+
import {getGenres, getChart, getRelatedArtists} from 'gerdur-core';
|
|
221
344
|
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
+
Public-profile data — pass 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
|
-
|
|
233
|
-
|
|
234
|
-
`
|
|
235
|
-
`limit
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
const {data} = await
|
|
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
|
-
###
|
|
378
|
+
### Podcasts
|
|
251
379
|
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
"as you type" UIs. `nb` (default 5) caps items per type. Needs an initialised
|
|
262
|
-
session (`initDeezerApi`).
|
|
388
|
+
### Preview clips
|
|
263
389
|
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
393
|
+
```ts
|
|
394
|
+
import {getTrackPreview, downloadPreview} from 'gerdur-core';
|
|
268
395
|
|
|
269
|
-
|
|
270
|
-
const
|
|
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
|
-
|
|
400
|
+
Accepts a gw `track` object (reads its `MEDIA`, no extra request), a track id, or
|
|
401
|
+
a number.
|
|
275
402
|
|
|
276
|
-
|
|
277
|
-
`{data, total?, next?}` list unless noted.
|
|
403
|
+
### Resolve a download URL
|
|
278
404
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
###
|
|
429
|
+
### Download a track (buffer)
|
|
310
430
|
|
|
311
|
-
|
|
312
|
-
|
|
431
|
+
```ts
|
|
432
|
+
import {downloadTrackBuffer, addTrackTags} from 'gerdur-core';
|
|
313
433
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
447
|
+
Constant memory (~one 2048-byte stripe) regardless of file size or concurrency,
|
|
448
|
+
with progress and resume.
|
|
342
449
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
450
|
+
```ts
|
|
451
|
+
import {pipeline} from 'stream/promises';
|
|
452
|
+
import {createWriteStream, statSync, existsSync} from 'fs';
|
|
453
|
+
import {streamTrackDownload} from 'gerdur-core';
|
|
347
454
|
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
500
|
+
```ts
|
|
501
|
+
const {buffer, model} = await addTrackTags(audio, track, {
|
|
502
|
+
coverSize: 1200, // 56–1800 px, default 1000
|
|
503
|
+
});
|
|
384
504
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
543
|
+
configureMusicBrainz({userAgent: 'my-app/1.0 ( me@example.com )'}); // required — MB wants a real UA
|
|
393
544
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
414
|
-
import {pipeline} from 'stream/promises';
|
|
415
|
-
import {createWriteStream} from 'fs';
|
|
572
|
+
### Use multiple accounts
|
|
416
573
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
622
|
+
## Errors
|
|
441
623
|
|
|
442
|
-
|
|
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
|
-
|
|
|
626
|
+
| Field | |
|
|
447
627
|
| :--- | :--- |
|
|
448
|
-
| `
|
|
449
|
-
| `
|
|
450
|
-
| `
|
|
451
|
-
| `
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
464
|
-
`
|
|
465
|
-
|
|
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 & 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 & 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 & 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 & 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 & 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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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": "
|
|
51
|
+
"license": "MIT",
|
|
35
52
|
"dependencies": {
|
|
36
53
|
"browser-id3-writer": "^4.4.0",
|
|
37
54
|
"delay": "^5.0.0",
|