npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

gerdur-core

Deezer API client, cross-service URL resolution, Blowfish track decryption and MP3/FLAC metadata tagging — the engine behind the gerdur CLI.

npm npm downloads node

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-core is its continuation — a near drop-in rename plus one addTrackTags change. See MIGRATING.md.


Contents


Install

npm i gerdur-core
yarn add gerdur-core
pnpm 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 can import type {trackType} from 'gerdur-core/types' without a second dependency.
  • You need a Deezer arl cookie 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 lyrics

The 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.comarl (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.getLyricsLYRICS_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.* calls

Resolve 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 searchdeezer.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-type

Public REST searchapi.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 Punk

Flow, 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 lookup

userId 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

createPlaylist is a one-way door. Deezer's gateway exposes no delete — a dozen spellings of playlist.delete all answer GATEWAY_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.ts behind GERDUR_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 decryption

Preview 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 null

Accepts 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} | null
  • DEEZER_FORMATS (best → worst): FLAC, MP3_320, MP3_256, MP3_128, MP3_64, AAC_64, MP4_RA3, MP4_RA2, MP4_RA1. resolveDownloadUrls accepts either these strings or the 1 | 3 | 9 shorthand.
  • formatName(q) / toFormat(q) — normalise a number or string to the media API's format string.
  • refreshTrackTokens(tracks, {graceSeconds = 300, session?}) — one song.getListData request refreshes every token that has expired or expires within graceSeconds. Tracks with a valid token are returned untouched.
  • Falls back to the legacy e-cdns-proxy-*.dzcdn.net scheme when the media API declines. Throws WrongLicense / 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 Node Transform for your own pipeline. startChunk = resumeFromByte / 2048.
  • TrackDecryptStream(sngId, startChunk?) — the imperative engine (.write(buf) → Buffer, .final() → Buffer) behind that Transform.

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 all

Building blocks, if you want the model without writing tags:

  • getRichAlbum(albId) → merged gw + public album metadata (RichAlbum).
  • buildTagModel(input) → the canonical TrackTagModel (see Types).
  • normalizeContributors(SNG_CONTRIBUTORS) → cleans Deezer's messy contributor keys into {mainArtists, featuring, composers, producers, engineers, …}.
  • toLrc(syncJson, meta) → render LYRICS_SYNC_JSON as an LRC string.
  • downloadAlbumCover(track, size) / downloadArtistImage(track) → image Buffers. 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, 404null 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 a

Session 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 /metrics

Running this on a server

  • Stream, don't buffer — including the tags. downloadTrackBuffer / getTrackBuffer hold the whole file, and addTrackTags holds it again (the tag writers allocate a second copy). Use streamTrackDownload + resolveTagModel + createTagStream for 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 export cacheStats() 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. createSession has no lifecycle — a session per user, kept forever, keeps its cache forever.
  • httpAgent / httpsAgent are 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