gerdur-core
v2.20.0
Published
Deezer API client, cross-service URL resolution, Blowfish track decryption and MP3/FLAC metadata tagging — the engine behind the gerdur CLI.
Maintainers
Readme
gerdur-core
Deezer API client, cross-service URL resolution, Blowfish track decryption and MP3/FLAC metadata tagging — the engine behind the
gerdurCLI.
gerdur-core is a small, dependency-light TypeScript library that does the parts
of a music downloader that are fiddly to get right:
- Talks to Deezer — the internal gateway (
gw-light.php/gateway.php), the public REST API (api.deezer.com) and the media API (media.deezer.com), with bounded retries, token refresh and per-account sessions. - Resolves any link — Deezer, Spotify, Tidal and YouTube URLs, plus ISRC and UPC codes, all mapped to a downloadable Deezer track.
- Decrypts the stream — Blowfish-CBC "stripe" decryption, buffered or as a
Node
Transform(constant memory, resumable). - Writes real tags — ID3v2.3 for MP3, Vorbis comments for FLAC: cover art,
full credits, ReplayGain, BPM, ISRC, release dates, plain and time-synced
lyrics (
.lrc). - Enriches, optionally — higher-resolution cover art and canonical release/label data from MusicBrainz and the Cover Art Archive.
It has no CLI and does no disk I/O — every function returns data or a
Buffer/stream. The gerdur package is
the CLI and the file-writing layer on top.
Coming from
@soulwax/d-fi-core?gerdur-coreis its continuation — a near drop-in rename plus oneaddTrackTagschange. See MIGRATING.md.
Contents
- Install
- Quick start
- Core concepts
- Guide
- Authenticate
- Look up tracks, albums, playlists, artists
- Resolve a share URL (Deezer / Spotify / Tidal / YouTube)
- Resolve an ISRC or UPC
- Search
- Browse and discover
- Flow, radios and a user's library
- Your own library
- Changing the account (write operations)
- Podcasts
- Preview clips
- Resolve a download URL
- Download a track (buffer)
- Download a track (stream, constant memory, resume)
- Decrypt
- Tag MP3 / FLAC
- Enrichment (MusicBrainz + Cover Art Archive)
- Use multiple accounts
- Running this on a server
- HTTP helpers
- Errors
- Types
- API reference
- Migrating from @soulwax/d-fi-core
- The name
- Legal
Install
npm i gerdur-coreyarn add gerdur-corepnpm add gerdur-core- Node ≥ 12. Ships CommonJS (
dist/) with bundled.d.ts. - Cheap to import — ~43 ms. The Spotify SDK and the HTML parser load only if you actually resolve a Spotify/YouTube link, so a Deezer-only run (or a cold start) never pays for them.
- Types subpath: the hand-written Deezer response types are also published at
gerdur-core/types, so downstream packages canimport type {trackType} from 'gerdur-core/types'without a second dependency. - You need a Deezer
arlcookie for anything account-scoped (downloads,getUser, lyrics, the internal search). Public REST endpoints (charts, public search, ISRC/UPC lookups, previews) work with no auth.
Quick start
Download one track, tag it, and write it to disk:
import {writeFileSync} from 'fs';
import {initDeezerApi, getTrackInfo, downloadTrackBuffer, addTrackTags} from 'gerdur-core';
await initDeezerApi(process.env.ARL!); // 192-char arl cookie
const track = await getTrackInfo('3135556'); // Daft Punk — Harder, Better, Faster, Stronger
const audio = await downloadTrackBuffer(track, 3); // 3 = 320 kbps MP3, downloaded + decrypted
if (!audio) throw new Error('not available for this account / region');
const {buffer, model} = await addTrackTags(audio, track); // cover, credits, lyrics, ReplayGain…
writeFileSync(`${model.title}.mp3`, buffer);
if (model.lyricsSynced) writeFileSync(`${model.title}.lrc`, model.lyricsSynced); // synced lyricsThe same pipeline, one step at a time — resolve a URL, then run each track through it yourself:
import {writeFileSync} from 'fs';
import {
initDeezerApi,
parseInfo,
resolveDownloadUrls,
refreshTrackTokens,
getBuffer,
decryptDownload,
addTrackTags,
} from 'gerdur-core';
await initDeezerApi(process.env.ARL!);
const {tracks} = await parseInfo('https://www.deezer.com/album/302127');
const fresh = await refreshTrackTokens(tracks); // long lists: refresh expiring tokens first
const urls = await resolveDownloadUrls(fresh, ['FLAC', 'MP3_320', 'MP3_128']); // one request
for (const [i, track] of fresh.entries()) {
const url = urls[i];
if (!url) continue; // geo-blocked / unavailable
const body = await getBuffer(url.trackUrl);
const decrypted = url.isEncrypted ? decryptDownload(body, track.SNG_ID) : body;
const {buffer, model} = await addTrackTags(decrypted, track);
writeFileSync(`${model.trackNumber}. ${model.title}.${url.format === 'FLAC' ? 'flac' : 'mp3'}`, buffer);
}Core concepts
arl cookie. Deezer authenticates the mobile/gateway API with a single
arl cookie (192 characters). initDeezerApi(arl) exchanges it for a session
token. Get one from a logged-in browser: DevTools → Application → Cookies →
deezer.com → arl (see the FAQ).
Sessions. A Session owns one account's state —
the arl, the HTTP client (session id / API token), the media license_token,
the resolved country and streaming rights, plus the retry loop and a response
cache. The free functions (getTrackInfo, searchMusic, …) run against a
process-wide default session that initDeezerApi configures. Call
createSession(arl) for an isolated one.
Quality. Numeric shorthand: 1 = MP3 128 kbps, 3 = MP3 320 kbps,
9 = FLAC (~1411 kbps). The batch resolver also takes format strings from
DEEZER_FORMATS (FLAC, MP3_320, MP3_256,
MP3_128, MP3_64, AAC_64, MP4_RA3/2/1). What you actually get depends on
the account's plan and the track's licensing.
Track tokens. Each track carries a TRACK_TOKEN that the media API needs; it
lives ~1 hour. A token fetched at the start of a long download is stale by track
40 and surfaces as an opaque CDN 403 — run the list through
refreshTrackTokens first.
Encryption. Most CDN downloads are Blowfish-CBC "stripe"-obfuscated: the file
is split into 2048-byte chunks and only every third one (0, 3, 6, …) is
encrypted. resolved.isEncrypted (from the media API's cipher field) tells you
whether to decrypt. Previews and podcast episodes are plain.
Retries. Gateway calls run through a bounded loop — per-error-class attempt
caps plus a 30 s wall-clock deadline, full-jittered exponential backoff. A
persistently failing endpoint throws a DeezerError instead of
spinning. The policy is exported as RETRY_POLICY.
Guide
Authenticate
import {initDeezerApi, getUser} from 'gerdur-core';
await initDeezerApi(arl); // throws if arl length !== 192; returns the gateway SESSION id
try {
const me = await getUser();
console.log('Logged in as', me.BLOG_NAME, '· id', me.USER_ID, '· country', me.COUNTRY);
} catch (err) {
console.error('arl invalid or expired:', (err as Error).message);
}initDeezerApi only pings for a session token. The license_token, country
and streaming rights are fetched lazily on the first download (or eagerly with
createSession(arl) + await session.loadUserData()).
Look up tracks, albums, playlists, artists
All take string ids and return the raw Deezer gateway objects. Every response is memoised (LRU, 1000 entries / 60 min) and in-flight-coalesced.
| Function | Returns |
| :--- | :--- |
| getTrackInfo(id) | song.getData — the track, with this session's TRACK_TOKEN |
| getLyrics(id) | song.getLyrics — LYRICS_TEXT plus LYRICS_SYNC_JSON when synced |
| getAlbumInfo(id) | album.getData |
| getAlbumTracks(id) | every track on the album (song.getListByAlbum, nb: -1) |
| getPlaylistInfo(id) | playlist.getData |
| getPlaylistTracks(id) | every track, with TRACK_POSITION filled in |
| getArtistInfo(id) | artist.getData |
| getDiscography(id, nb = 500) | the artist's discography (album.getDiscography) |
| getProfile(userId) | a public profile (mobile.pageUser, loved tracks) |
| getShowInfo(showId, nb?, start?) | a podcast show + a page of EPISODES |
| getChannelList() | Deezer's browse channels |
| getPlaylistChannel(page) | a channel page (app_page_get) — e.g. "channels/dance" |
| getUser() | the logged-in account's profile |
| getTrackInfoPublicApi(id) / getAlbumInfoPublicApi(id) | the same entities from the public REST API (isrc, bpm, contributors) |
const album = await getAlbumInfo('302127');
const {data: tracks} = await getAlbumTracks('302127');
console.log(album.ALB_TITLE, '—', tracks.length, 'tracks');Resolve a share URL
parseInfo(url) classifies a Deezer / Spotify / Tidal / YouTube URL, fetches it,
and returns a uniform shape. Spotify/Tidal/YouTube entities are matched to their
Deezer equivalents (ISRC for tracks, UPC for albums), so everything downstream is
a Deezer track.
import {parseInfo, getUrlParts} from 'gerdur-core';
const {info, linktype, linkinfo, tracks} = await parseInfo(
'https://open.spotify.com/album/2noRn2Aes5aoNVsU6iWThc',
);
// info → {type: 'spotify-album', id: '2noRn2Aes5aoNVsU6iWThc'}
// linktype → 'album' | 'playlist' | 'artist' | 'track'
// linkinfo → the album/playlist/artist object (empty for a single track)
// tracks → trackType[] ready for resolveDownloadUrls
const parts = await getUrlParts('https://deezer.com/track/3135556'); // just classify: {type, id}Supported: Deezer track / album / audiobook / playlist / artist (+
page.link short links), spotify: URIs and open.spotify.com links, Tidal
links, and youtube.com/watch / youtu.be links. Spotify artist resolution is
capped at ~10 tracks by Spotify's anonymous token.
The lower-level converters are also exported:
import {isrc2deezer, upc2deezer, spotify, tidal, youtube} from 'gerdur-core';
const track = await isrc2deezer('Get Lucky', 'USUM71311296'); // hydrated gw track
const [albumInfo, albumTracks] = await upc2deezer('Discovery', '0724384960650');
await spotify.setSpotifyAnonymousToken(); // needed before spotify.* callsResolve an ISRC or UPC
Public REST, no auth. Returns public-API objects (not gw tracks — pass .id to
getTrackInfo / getAlbumTracks to make them downloadable).
import {getTrackByISRC, getAlbumByUPC, getTrackInfo} from 'gerdur-core';
const pub = await getTrackByISRC('USUM71311296'); // {id, title, bpm, gain, isrc, preview, …}
const track = await getTrackInfo(String(pub.id)); // now downloadable
const album = await getAlbumByUPC('0724384960650'); // {id, title, tracks: {data: [...]}}Search
Internal search — deezer.pageSearch, needs a session. Richest results
(top-result ranking, artist suggestions), and it returns per-type totals.
import {searchMusic, searchFacets, suggest} from 'gerdur-core';
const result = await searchMusic('daft punk', ['TRACK', 'ALBUM', 'ARTIST'], 25);
result.TRACK.data.forEach((t) => console.log(t.SNG_TITLE, '—', t.ART_NAME));
searchFacets(result); // {track: 207, album: 99, artist: 17, …, order: ['TOP_RESULT','ARTIST','TRACK',…]}
const hints = await suggest('daf'); // fast "as you type" autocomplete, per-typePublic REST search — api.deezer.com/search, no auth. Clean objects with
isrc / preview / rank, order, and limit / index paging.
import {
searchPublicApi,
searchTracks,
searchAlbums,
searchArtists,
searchPlaylists,
buildAdvancedQuery,
} from 'gerdur-core';
const {data} = await searchTracks('one more time', {order: 'RANKING', limit: 25});
// advanced operators — reliable only on the track index
const q = buildAdvancedQuery({artist: 'daft punk', durMin: 200, bpmMax: 130});
// => 'artist:"daft punk" dur_min:200 bpm_max:130'
const strict = await searchPublicApi(q, {strict: true, limit: 50});
const albums = await searchAlbums('discovery'); // plain string — operators are ignored here| searchPublicApi(query, options) option | Type | Notes |
| :--- | :--- | :--- |
| type | 'track' (default) 'album' 'artist' 'playlist' 'user' 'radio' 'podcast' | |
| order | RANKING, RATING_DESC, DURATION_DESC, TRACK_ASC, … | |
| strict | boolean | send strict=on — disables Deezer's fuzzy fallback |
| limit / index | number | page size (Deezer caps near 100) / offset |
buildAdvancedQuery({query?, artist?, album?, track?, label?, durMin?, durMax?, bpmMin?, bpmMax?})
is a pure string builder — Deezer treats the operators as ranking hints, not hard
filters, and only honours them on track search.
Browse and discover
Public REST, no auth. Everything returns a {data, total?, next?} list unless
noted.
| Function | Returns |
| :--- | :--- |
| getGenres() | Deezer's genre list (id 0 = "All") |
| getChart(genreId = 0, limit = 10) | {tracks, albums, artists, playlists, podcasts} for a genre |
| getChartTracks(genreId = 0, limit = 100, index = 0) | just the track chart, each with a position |
| getGenreArtists(genreId) | artists filed under a genre |
| getEditorialList() | Deezer's editorial sections |
| getEditorialReleases(id = 0, limit = 25, index = 0) | new releases for a section |
| getEditorialSelection(id = 0) | albums the editors are pushing |
| getEditorialCharts(id = 0) | a section's charts (same 5-list shape as getChart) |
| getArtistTopTracks(artistId, limit = 50) | an artist's most popular tracks |
| getRelatedArtists(artistId, limit = 20) | similar artists |
| getArtistAlbums(artistId, limit = 50, index = 0) | the artist's discography (public shape) |
| getArtistPlaylists(artistId, limit = 25) | playlists featuring the artist |
| getArtistRadioTracks(artistId) | a ready-made radio seeded from the artist |
import {getGenres, getChart, getRelatedArtists} from 'gerdur-core';
const {data: genres} = await getGenres();
const rock = genres.find((g) => g.name === 'Rock')!;
const {tracks} = await getChart(rock.id, 20); // this week's rock chart
const similar = await getRelatedArtists(27); // artists like Daft PunkFlow, radios and a user's library
Public-profile data — pass a userId (getUser().USER_ID, a profile URL, or
parseInfo). A private library is only visible to that user's own session.
| Function | Returns |
| :--- | :--- |
| getUserFlow(userId, limit = 40) | Flow — the endless personalised mix, as tracks |
| getUserFavoriteTracks(userId, limit?, index?) | loved tracks, newest first (each with time_add) |
| getUserFavoriteAlbums(userId, limit?, index?) | favourite albums |
| getUserFavoriteArtists(userId, limit?, index?) | favourite artists |
| getUserPlaylists(userId, limit?, index?) | the user's own + followed playlists |
| getUserRadios(userId) | radios the user favourited |
| getUserChartTracks(userId, limit?) | the user's personal top tracks |
| getRadios() | Deezer's curated radio list |
| getRadioTracks(radioId) | a radio's current tracklist — a ready-to-play source |
| getRadioGenres() | radios grouped by genre |
import {getUser, getUserFlow, getUserFavoriteTracks, getRadioTracks} from 'gerdur-core';
const me = await getUser();
const {data: flow} = await getUserFlow(me.USER_ID);
const {data: loved} = await getUserFavoriteTracks(me.USER_ID);
const {data: eighties} = await getRadioTracks(38305); // "The '80s"Your own library
api/user.ts above reads a public profile. These read what the account
can see — private playlists included — over the authenticated gateway, and
return gateway shapes, so tracks come with a TRACK_TOKEN and are immediately
downloadable.
| Function | Returns |
| :--- | :--- |
| getMyPlaylists(userId?, nb?, start?) | the account's own playlists, including private ones |
| getMyFavoriteTracks(userId?, nb?, start?) | loved tracks, as downloadable gw tracks |
| getMyFavoriteTrackIds() | every loved track id in one request — for diffing a local library |
| getMyFavoriteAlbums / getMyFavoriteArtists | favourited albums / artists |
| getMyFavoritePlaylists / getMyFavoriteRadios / getMyFavoriteShows | followed playlists / radios / shows |
| getTrackMix(sngId, nb?, start?) | a "more like this" mix seeded from a track — tokens already attached |
import {getMyPlaylists, getTrackMix, resolveDownloadUrls} from 'gerdur-core';
const {data: playlists} = await getMyPlaylists(); // yours, private included
const {data: mix} = await getTrackMix('3135556', 20); // 20 tracks like this one
const urls = await resolveDownloadUrls(mix, [9, 3, 1]); // straight to download — no per-track lookupuserId defaults to the logged-in account. These are account-scoped, so they
never enter the shared cross-session cache.
Changing the account (write operations)
Everything above reads. These change the logged-in account's library for real — they are in their own module, wired into no download path, and only run when you call them.
| Function | Inverse |
| :--- | :--- |
| addFavoriteTracks(ids) | removeFavoriteTracks(ids) |
| addFavoriteAlbum(id) | removeFavoriteAlbum(id) |
| addFavoriteArtist(id) | removeFavoriteArtist(id) |
| followPlaylist(id) | unfollowPlaylist(id) |
| addFavoriteShow(id) | — none found |
| createPlaylist(title, opts?) | — none exists, see below |
| addTracksToPlaylist(id, sngIds) | removeTracksFromPlaylist(id, sngIds) |
import {addFavoriteTracks, removeFavoriteTracks} from 'gerdur-core';
await addFavoriteTracks(['3135556']); // love it
await removeFavoriteTracks(['3135556']); // and back
createPlaylistis a one-way door. Deezer's gateway exposes no delete — a dozen spellings ofplaylist.deleteall answerGATEWAY_ERROR— so a playlist made here has to be removed from a Deezer client. It is also the one function in this module that has not been exercised against a live account, for exactly that reason.Method names and parameters were established by probing with incomplete arguments (an existing method answers
MISSING_PARAMETER_*), so the surface is real; the round trips live in__tests__/favorites.tsbehindGERDUR_ALLOW_WRITE_TESTS=1.
Scrobbling (log.listen) is deliberately absent: it never answered the probe and
it writes history that cannot be undone.
Podcasts
import {getShowEpisodes, getEpisode} from 'gerdur-core';
const {data: episodes} = await getShowEpisodes('1265876', 25); // newest first
const ep = await getEpisode(episodes[0].EPISODE_ID);
// ep.EPISODE_DIRECT_STREAM_URL — a plain MP3: no licence, no decryptionPreview clips
The 30-second preview is a plain MP3 — no licence, no arl, no encryption.
Good for "audition before download" and for CI that must not pull full tracks.
import {getTrackPreview, downloadPreview} from 'gerdur-core';
const {url, duration} = (await getTrackPreview('3135556'))!; // {url, duration: 30}
const clip = await downloadPreview('3135556'); // Buffer (plain MP3), or nullAccepts a gw track object (reads its MEDIA, no extra request), a track id, or
a number.
Resolve a download URL
import {getTrackDownloadUrl, resolveDownloadUrls, refreshTrackTokens, DEEZER_FORMATS} from 'gerdur-core';
// one track
const one = await getTrackDownloadUrl(track, 9); // {trackUrl, isEncrypted, fileSize} | null
// many tracks, ONE request — Deezer returns the best each is licensed for
const fresh = await refreshTrackTokens(tracks); // refresh tokens older than ~1h first
const urls = await resolveDownloadUrls(fresh, ['FLAC', 'MP3_320', 'MP3_128']);
// urls[i] → {trackUrl, isEncrypted, fileSize, format, cipher} | nullDEEZER_FORMATS(best → worst):FLAC,MP3_320,MP3_256,MP3_128,MP3_64,AAC_64,MP4_RA3,MP4_RA2,MP4_RA1.resolveDownloadUrlsaccepts either these strings or the1 | 3 | 9shorthand.formatName(q)/toFormat(q)— normalise a number or string to the media API's format string.refreshTrackTokens(tracks, {graceSeconds = 300, session?})— onesong.getListDatarequest refreshes every token that has expired or expires withingraceSeconds. Tracks with a valid token are returned untouched.- Falls back to the legacy
e-cdns-proxy-*.dzcdn.netscheme when the media API declines. ThrowsWrongLicense/GeoBlocked/ExpiredTrackToken.
Download a track (buffer)
import {downloadTrackBuffer, addTrackTags} from 'gerdur-core';
const audio = await downloadTrackBuffer(track, 3); // get_url → fetch → decrypt, all in memory
if (audio) {
const {buffer} = await addTrackTags(audio, track);
// buffer is a tagged MP3/FLAC — write it wherever
}downloadTrackBuffer(track, quality, {onProgress?, session?}) → Buffer | null
(null when the track+quality can't be resolved). No resume — use the stream API
for that.
Download a track (stream)
Constant memory (~one 2048-byte stripe) regardless of file size or concurrency, with progress and resume.
import {pipeline} from 'stream/promises';
import {createWriteStream, statSync, existsSync} from 'fs';
import {streamTrackDownload} from 'gerdur-core';
const resumeFrom = existsSync('track.flac') ? statSync('track.flac').size : 0;
const {stream, size, startedAt, isEncrypted} = await streamTrackDownload(track, 9, {
resumeFrom, // rounded down to a 2048-byte boundary so stripe decryption stays aligned
onProgress: (received, total) => process.stdout.write(`\r${((received / total) * 100) | 0}%`),
});
await pipeline(stream, createWriteStream('track.flac', {flags: startedAt > 0 ? 'a' : 'w'}));Lower-level pieces:
getStream(url, {rangeStart?})→{stream, headers, status, url}— a raw, content-decoded (gzip/br/deflate) response stream.createDecryptStream(sngId, startChunk?)→ a NodeTransformfor your ownpipeline.startChunk=resumeFromByte / 2048.TrackDecryptStream(sngId, startChunk?)— the imperative engine (.write(buf) → Buffer,.final() → Buffer) behind thatTransform.
Streaming the tag write (rewriting a FLAC metadata block with no full-file buffer) is not implemented yet — buffer the stream and call
addTrackTags, or tag the finished file afterwards.
Decrypt
import {decryptDownload} from 'gerdur-core';
const plain = resolved.isEncrypted ? decryptDownload(body, track.SNG_ID) : body;decryptDownload(buffer, sngId) decrypts a fully-downloaded body. Format-
preserving: an encrypted MP3 stays an MP3. The per-track key is
md5(sngId)[i] ^ md5(sngId)[i+16] ^ "g4el58wc0zvf9na1"[i].
getSongFileName(track, quality) builds the obfuscated filename for the legacy
CDN path — you rarely need it directly.
Tag MP3 / FLAC
addTrackTags(buffer, track, options?) sniffs fLaC vs MP3, gathers everything
Deezer has for the track (album info, credits, lyrics, cover, artist photo,
BPM — all coalesced, so tagging a whole album hits each endpoint once), writes
the tags, and returns {buffer, model}.
const {buffer, model} = await addTrackTags(audio, track, {
coverSize: 1200, // 56–1800 px, default 1000
});
model.title; // "Harder, Better, Faster, Stronger"
model.isrc; // "GBDUW0000059"
model.bpm; // 123
model.replayGainTrackGain; // "-9.24 dB"
model.lyricsSynced; // an LRC document — write it as a .lrc sidecar
model.contributors; // normalised producers / engineers / performers / …| AddTrackTagsOptions | Default | |
| :--- | :--- | :--- |
| coverSize | 1000 | embedded cover width, 56–1800 px |
| cover / artistImage | — | pre-fetched image Buffer (null = skip); avoids a download |
| album / lyrics / publicTrack | — | pre-fetched payloads — pass once per album to skip refetching |
| embedCover / embedArtistImage | true | |
| writeLyrics / embedSyncedLyrics | true | synced LRC goes to FLAC Vorbis only (no ID3v2.3 SYLT) |
| lyricsFallback | true | scrape Musixmatch when Deezer has no lyrics — 2 requests per such track. Latches off automatically after 3 consecutive transport failures (see below) |
| richCredits | true | hydrate credits + BPM for album/playlist tracks that omit them |
| deezerIds / includeRank | true | write DEEZER_*_ID / popularity rank |
On a server, tag as a stream instead. addTrackTags must materialise the
whole file, which is what caps concurrency. createTagStream produces
byte-identical output without ever holding the audio — both containers keep
their metadata at the front, so only the header is buffered (a few KB for MP3;
the source's own metadata region for FLAC):
import {pipeline} from 'stream/promises';
import {streamTrackDownload, resolveTagModel, createTagStream} from 'gerdur-core';
const model = await resolveTagModel(track); // the fetches, no audio
const {stream} = await streamTrackDownload(track, 9);
await pipeline(stream, createTagStream(model), createWriteStream('track.flac'));| 40 MB tracks, concurrent | addTrackTags | createTagStream |
| :--- | ---: | ---: |
| 4 | +239 MB | +0 MB |
| 16 | +965 MB, 986 ms | +0 MB, 150 ms |
resolveTagModel(track, options?) does exactly what addTrackTags does minus
the writing — same fetches, same coalescing, same AddTrackTagsOptions.
The Musixmatch fallback looks after itself. Where Musixmatch blocks you — it 403s from many networks and most datacentres — those scrapes cost a round trip each and return nothing: 2021 ms on a 14-track album, 70% of tagging time. After three consecutive transport failures it stops being attempted for the rest of the process, taking that album to 653 ms. A track simply not being on Musixmatch doesn't count toward the latch, so it can't disable itself where it actually works, and any success resets it.
import {configureMusixmatch, musixmatchStatus} from 'gerdur-core';
musixmatchStatus(); // {available, consecutiveFailures, maxFailures}
configureMusixmatch({maxFailures: 5}); // more patient
configureMusixmatch({enabled: false}); // never scrape at allBuilding blocks, if you want the model without writing tags:
getRichAlbum(albId)→ merged gw + public album metadata (RichAlbum).buildTagModel(input)→ the canonicalTrackTagModel(see Types).normalizeContributors(SNG_CONTRIBUTORS)→ cleans Deezer's messy contributor keys into{mainArtists, featuring, composers, producers, engineers, …}.toLrc(syncJson, meta)→ renderLYRICS_SYNC_JSONas an LRC string.downloadAlbumCover(track, size)/downloadArtistImage(track)→ imageBuffers.MAX_COVER_SIZE= 1800.
Enrichment
Optional, read-only, off by default, and never wired into addTrackTags.
Fills gaps Deezer leaves — canonical release/label data and cover art larger than
Deezer's 1800 px ceiling.
import {configureMusicBrainz, getCoverArtByISRC, lookupRecordingByISRC} from 'gerdur-core';
configureMusicBrainz({userAgent: 'my-app/1.0 ( [email protected] )'}); // required — MB wants a real UA
// one call: ISRC → MusicBrainz recording → best Cover Art Archive front cover
const coverUrl = await getCoverArtByISRC(track.ISRC, {minSize: 1200}); // string | null
if (coverUrl) {
const cover = await getBuffer(coverUrl);
await addTrackTags(audio, track, {cover}); // hand it your own cover
}
// or the pieces
const rec = await lookupRecordingByISRC(track.ISRC); // MBRecording | null
rec?.isrcs; // every ISRC MB has for this recording
rec?.releases; // each with releaseGroupMbid, primaryType, status, label, catalogNumber| Function | |
| :--- | :--- |
| configureMusicBrainz({userAgent?, minIntervalMs?}) | set the UA and rate limit (default 1100 ms); call once at startup |
| lookupRecordingByISRC(isrc) | canonical MBRecording (title, artist credits, length, ISRCs, releases) or null |
| getMusicBrainzRecording(mbid) / getMusicBrainzRelease(mbid, inc?) | direct MBID lookups; release adds label / catalogue number / barcode |
| getCoverArt(mbid, entity = 'release-group') | Cover Art Archive images (front / approved / thumbnails), or null |
| getBestCoverArtUrl(mbid, {entity?, minSize = 1200}) | one URL — approved front cover ≥ minSize px, else full-res |
| getRecordingCoverArt(recording, {minSize?, maxTries = 4}) | walks a recording's release-groups canonical-first (Official → Album → earliest) |
| getCoverArtByISRC(isrc, {minSize?, maxTries?}) | the whole chain — use this, not getBestCoverArtUrl on releases[0] |
| PoliteJsonClient | the serialised, rate-limited, 503/429-retrying, 404→null JSON client both use — exported for your own polite clients |
A persistent MusicBrainz 503 ("server busy") surfaces as HttpStatusError
after 3 backed-off retries — catch it and fall back to Deezer's data.
Use multiple accounts
The free functions share one default session. For concurrent accounts, hold
isolated Session objects — each with its own arl, tokens, license_token
and response cache.
import {createSession} from 'gerdur-core';
const a = await createSession(arlOne);
const b = await createSession(arlTwo);
await a.loadUserData();
console.log(a.country, a.canStreamLossless, a.licenseToken);
const track = await a.getTrackInfo('3135556'); // TRACK_TOKEN is a's
const audio = await a.getTrackBuffer(track, 9); // resolved + decrypted as account aSession methods: getUser, getTrackInfo, getLyrics, getAlbumInfo,
getAlbumTracks, getPlaylistInfo, getPlaylistTracks, getArtistInfo,
getDiscography, getProfile, searchMusic, getTrackDownloadUrl,
resolveDownloadUrls, refreshTrackTokens, streamTrack, getTrackBuffer,
plus lifecycle (init, refreshApiToken, loadUserData, invalidateUserData)
and the raw channels (gw, gwLight, gwGet).
defaultSession() returns the shared one; setDefaultSession(s) swaps it (used
in tests). The free getTrackDownloadUrl / resolveDownloadUrls /
streamTrackDownload / refreshTrackTokens all take an optional session.
Caching across sessions. Most gw payloads embed a per-account TRACK_TOKEN,
so each Session has its own response cache. Five methods carry nothing
account-scoped — album.getData, artist.getData, song.getLyrics,
album.getDiscography, playlist.getData — and those go to a process-wide
cache partitioned by country, so one copy serves every session and a
concurrent burst collapses into one request. Measured with 500 sessions reading
the same album: 500 gateway requests → 1, and 1.8 MB of duplicated payload →
one copy. Track lists (song.getData, playlist.getSongs, song.getListByAlbum,
episode.getData, …) are never shared.
import {configureCache, cacheStats, clearSharedCaches} from 'gerdur-core';
configureCache({shared: {maxSize: 20_000, ttl: 30 * 60_000}}); // once, at startup
cacheStats(); // {shared: {size, maxSize, hits, misses, inFlight}} — for /metricsRunning this on a server
- Stream, don't buffer — including the tags.
downloadTrackBuffer/getTrackBufferhold the whole file, andaddTrackTagsholds it again (the tag writers allocate a second copy). UsestreamTrackDownload+resolveTagModel+createTagStreamfor an end-to-end constant-memory path: measured at 16 concurrent 40 MB tracks, 965 MB → ~0 MB, and 6.5x faster. - Decryption runs on the event loop. Blowfish costs ~33 ms per 8 MiB (~243 MiB/s), so heavy concurrent traffic will compete with everything else in the process. Put the decrypt in a worker if you saturate a core.
- Size the shared cache to your catalogue with
configureCache, and exportcacheStats()so you can see the hit rate. - Tagging is where the quota goes, not downloading. A 14-track album costs
~54 requests to tag and 2 to fetch.
{richCredits: false, lyricsFallback: false}takes that to 7 — 81% fewer against Deezer's quota — at the cost of full credits, BPM and non-Deezer lyrics. Pre-feed{album, lyrics, cover}for anything you already hold. - Evict idle sessions yourself.
createSessionhas no lifecycle — a session per user, kept forever, keeps its cache forever. httpAgent/httpsAgentare process-global (maxSockets: 64) and shared between API calls and CDN downloads.
HTTP helpers
The zero-dependency HTTP client (get/post/head, redirects, gzip/br/deflate,
keep-alive) is used internally and exported for reuse:
import {getJson, getText, getBuffer, getStream, httpAgent, httpsAgent, HttpClient} from 'gerdur-core';
const data = await getJson<{id: number}>('https://api.deezer.com/track/3135556');
const bytes = await getBuffer(coverUrl);
const {stream} = await getStream(bigFileUrl, {rangeStart: 1024});
const client = new HttpClient({baseURL: 'https://api.deezer.com', timeout: 15000});httpAgent / httpsAgent are shared keep-alive agents — pass them to your own
http calls to reuse connections.
Errors
Gateway and media-API failures throw a DeezerError (extends Error):
| Field | |
| :--- | :--- |
| code | Deezer's numeric code, when it sent one (e.g. 4, 800) |
| keys | gateway error keys, e.g. ['VALID_TOKEN_REQUIRED'], ['DATA_ERROR'] |
| retryable | whether a retry could plausibly help |
| payload | the raw error object |
The download path also throws these typed errors:
| Error | Meaning | Recovery |
| :--- | :--- | :--- |
| GeoBlocked | not licensed in the account's country | try another account / region |
| WrongLicense | the account's plan can't stream that format | request a lower quality |
| ExpiredTrackToken | the TRACK_TOKEN aged out (~1 h) | re-fetch the track (getTrackInfo) or refreshTrackTokens, then retry |
| HttpStatusError | a non-2xx HTTP response (statusCode, headers, body) | inspect statusCode |
RETRY_POLICY (exported) is the bounded-retry config: code4Attempts,
authReinits, tokenRefreshes, baseMs, maxDelayMs, deadlineMs.
Types
Response types are hand-written and shipped both from the main entry and the
gerdur-core/types subpath:
import type {trackType, albumType, playlistTracksType, lyricsType} from 'gerdur-core/types';
import type {
TrackTagModel, // canonical tag model from addTrackTags / buildTagModel
RichAlbum,
ResolvedUrl, // {trackUrl, isEncrypted, fileSize, format, cipher}
TrackStream, // {stream, size, startedAt, isEncrypted}
StreamTrackOptions,
SessionUserData, // {licenseToken, country, canStreamLossless, canStreamHq, offerId?}
DeezerFormat,
AddTrackTagsOptions,
MBRecording,
MBRelease,
CoverArt,
} from 'gerdur-core';TrackTagModel is the normalised view every tag writer consumes — title,
artists / mainArtists / featuredArtists, composers / producers /
engineers / performers, trackNumber / discNumber / totals, isrc /
barcode / bpm / durationMs, genres / label / releaseType, date /
originalDate, copyright / producerLine, replayGainTrackGain, explicit,
lyrics / lyricsSynced, ids.* and rank.
API reference
initDeezerApi(arl) · createSession(arl?) · defaultSession() ·
setDefaultSession(s) · Session · RETRY_POLICY · DEFAULT_ARL
getTrackInfo · getLyrics · getAlbumInfo · getAlbumTracks ·
getPlaylistInfo · getPlaylistTracks · getArtistInfo · getDiscography ·
getProfile · getUser · getShowInfo · getChannelList ·
getPlaylistChannel · getTrackInfoPublicApi · getAlbumInfoPublicApi
parseInfo · getUrlParts · isrc2deezer · upc2deezer ·
getTrackByISRC · getAlbumByUPC · spotify.* · tidal.* · youtube.*
searchMusic · searchAlternative · suggest · searchFacets ·
searchPublicApi · searchTracks · searchAlbums · searchArtists ·
searchPlaylists · buildAdvancedQuery
getGenres · getGenreArtists · getChart · getChartTracks ·
getEditorialList · getEditorialReleases · getEditorialSelection ·
getEditorialCharts · getArtistTopTracks · getRelatedArtists ·
getArtistAlbums · getArtistPlaylists · getArtistRadioTracks
getUserFlow · getUserFavoriteTracks · getUserFavoriteAlbums ·
getUserFavoriteArtists · getUserPlaylists · getUserRadios ·
getUserChartTracks · getRadios · getRadioTracks · getRadioGenres ·
getMyPlaylists · getMyFavoriteTracks · getMyFavoriteTrackIds ·
getMyFavoriteAlbums · getMyFavoriteArtists · getMyFavoritePlaylists ·
getMyFavoriteRadios · getMyFavoriteShows · getTrackMix ·
addFavoriteTracks · removeFavoriteTracks · addFavoriteAlbum ·
removeFavoriteAlbum · addFavoriteArtist · removeFavoriteArtist ·
followPlaylist · unfollowPlaylist · addFavoriteShow · createPlaylist ·
addTracksToPlaylist · removeTracksFromPlaylist · configureMusixmatch ·
musixmatchStatus
getEpisode · getShowEpisodes · getTrackPreview · downloadPreview
getTrackDownloadUrl · resolveDownloadUrls · refreshTrackTokens ·
downloadTrackBuffer · streamTrackDownload · getStream ·
createDecryptStream · TrackDecryptStream · decryptDownload ·
getSongFileName · DEEZER_FORMATS · formatName · toFormat
addTrackTags · resolveTagModel · createTagStream · probeAudioOffset ·
buildTagModel · getRichAlbum · normalizeContributors ·
toLrc · downloadAlbumCover · downloadArtistImage · MAX_COVER_SIZE
configureMusicBrainz · lookupRecordingByISRC · getMusicBrainzRecording ·
getMusicBrainzRelease · getCoverArt · getBestCoverArtUrl ·
getRecordingCoverArt · getCoverArtByISRC · PoliteJsonClient
configureCache · cacheStats · clearSharedCaches · getJson · getText ·
getBuffer · getStream · HttpClient · httpAgent ·
httpsAgent · HttpStatusError · DeezerError · GeoBlocked · WrongLicense
· ExpiredTrackToken
See the FAQ and the gerdur CLI
for end-to-end usage.
Migrating from @soulwax/d-fi-core
gerdur-core is the continuation of @soulwax/d-fi-core. [email protected] is
that codebase renamed; everything since is additive except a single addTrackTags
signature change. MIGRATING.md has the exact steps —
dependency swap, import rename, the one fix, the workarounds you can now delete,
and the faster primitives (downloadTrackBuffer, batch resolveDownloadUrls,
streamTrackDownload, Session, DeezerError) to adopt.
The name
Gerðr is the jötunn Freyr sends Skírnir riding through a wall of fire to
fetch. Her name is garðr — "the enclosure, the walled garden" (English
garden). gerdur-core does the crossing: resolve the identifier, get past the
wall, decrypt the stream, hand back a finished track.
Legal
For personal and archival use with content you are entitled to access. You are responsible for complying with the terms of service of any provider and with copyright law in your jurisdiction. The authors accept no liability for misuse. Respect the artists — buy the music you love.
See LICENSE · Contributing · Issues
