gerdur-core 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.9.0 - 2026-08-31
4
+
5
+ Phase 3.2 (cont.) — downloads are session-aware. Additive.
6
+
7
+ ### Added
8
+
9
+ - **`Session.getTrackDownloadUrl` / `resolveDownloadUrls` / `streamTrack` /
10
+ `getTrackBuffer`** — resolve and download **as a specific account**, so a
11
+ `createSession(arl)` client is now end-to-end usable.
12
+ - **`downloadTrackBuffer(track, quality, options?)`** — download + decrypt a
13
+ track fully into a `Buffer` (no tagging), free-function form of
14
+ `session.getTrackBuffer`.
15
+ - `getTrackDownloadUrl(track, quality, session?)`, `resolveDownloadUrls(tracks,
16
+ qualities?, session?)` and `streamTrackDownload(track, quality, {session})`
17
+ gained an optional session argument (defaults to the process default).
18
+
19
+ ## 2.8.0 - 2026-08-31
20
+
21
+ Phase 3.2 (cont.) — a `Session` is now usable per-account. Additive; free
22
+ functions unchanged.
23
+
24
+ ### Added
25
+
26
+ - **`Session` query methods**: `getUser`, `getTrackInfo`, `getLyrics`,
27
+ `getAlbumInfo`, `getAlbumTracks`, `getPlaylistInfo`, `getPlaylistTracks`,
28
+ `getArtistInfo`, `getDiscography`, `getProfile`, `searchMusic` — plus the raw
29
+ channels `gw` / `gwLight` / `gwGet`. So `createSession(arl)` gives you an
30
+ isolated client you can actually query, not just inspect.
31
+ - Each `Session` has **its own response cache** (`session.cache`) — gateway
32
+ responses carry account-specific `TRACK_TOKEN`s, so sharing one cache across
33
+ accounts was wrong.
34
+
35
+ ### Changed
36
+
37
+ - `request` / `requestLight` / `requestGet` are now thin wrappers over
38
+ `defaultSession()`. **`initDeezerApi(newArl)` now clears the default session's
39
+ cache** — previously a new account could be served the old account's cached
40
+ track tokens.
41
+
3
42
  ## 2.7.0 - 2026-08-31
4
43
 
5
44
  Phase 3.2 — session lifecycle. The scattered module-level state
package/README.md CHANGED
@@ -114,13 +114,39 @@ module-level globals; bundling it means multiple accounts can coexist.
114
114
  media-API 403.
115
115
  - **`defaultSession()`** — the `Session` the free functions use.
116
116
 
117
+ Each `Session` has **its own response cache** (per-account, since gateway
118
+ responses carry account-specific `TRACK_TOKEN`s) and its own query methods:
119
+
120
+ | Method | |
121
+ | :--- | :--- |
122
+ | `getUser()` | this account's profile |
123
+ | `getTrackInfo(id)` | `song.getData` — the `TRACK_TOKEN` is this session's |
124
+ | `getLyrics(id)` | plain + synced lyrics |
125
+ | `getAlbumInfo(id)` / `getAlbumTracks(id)` | album metadata / full track list |
126
+ | `getPlaylistInfo(id)` / `getPlaylistTracks(id)` | playlist metadata / tracks (with `TRACK_POSITION`) |
127
+ | `getArtistInfo(id)` / `getDiscography(id, nb?)` | artist metadata / discography |
128
+ | `getProfile(userId)` | a public profile |
129
+ | `searchMusic(query, types?, nb?)` | search (`deezer.pageSearch`) |
130
+ | `getTrackDownloadUrl(track, quality)` | resolve a CDN URL **as this account** |
131
+ | `resolveDownloadUrls(tracks, qualities?)` | batch-resolve, one request, as this account |
132
+ | `streamTrack(track, quality, opts?)` | constant-memory stream of decrypted audio |
133
+ | `getTrackBuffer(track, quality, opts?)` | download + decrypt fully into a `Buffer` |
134
+ | `gw(body, method)` / `gwLight(body, method)` / `gwGet(method, params?)` | the raw coalesced request channels |
135
+ | `init(arl?)` / `refreshApiToken()` / `loadUserData(force?)` / `invalidateUserData()` | lifecycle |
136
+
137
+ The free `getTrackDownloadUrl(track, quality, session?)` / `resolveDownloadUrls(…, session?)`
138
+ / `streamTrackDownload(…, {session})` all take an optional session too;
139
+ `downloadTrackBuffer(track, quality, opts?)` is the free-function form of
140
+ `session.getTrackBuffer`.
141
+
117
142
  ```js
118
143
  await initDeezerApi(arl); // default session, as before
119
144
  const track = await getTrackInfo('3135556');
120
145
 
121
- const s = await createSession(otherArl); // a second account, isolated
146
+ const s = await createSession(otherArl); // a second account, fully isolated
122
147
  await s.loadUserData();
123
148
  console.log(s.country, s.canStreamLossless);
149
+ const mine = await s.getTrackInfo('3135556'); // runs against `otherArl`
124
150
  ```
125
151
 
126
152
  ### `.initDeezerApi(arl_cookie);`
@@ -1,23 +1,14 @@
1
1
  /**
2
- * Make POST requests to deezer api
3
- * @param {Object} body post body
4
- * @param {String} method request method
2
+ * The gateway request helpers are now thin wrappers over the **default**
3
+ * {@link Session} its per-session cache + single-flight coalescing does the
4
+ * de-duplication that used to live here. `createSession(arl)` gives you an
5
+ * isolated one whose `session.gw(...)` / `session.getTrackInfo(...)` don't share
6
+ * this cache.
5
7
  */
8
+ /** POST `gateway.php` (main gw channel). */
6
9
  export declare const request: (body: object, method: string) => Promise<any>;
7
- /**
8
- * Make POST requests to deezer api
9
- * @param {Object} body post body
10
- * @param {String} method request method
11
- */
10
+ /** POST `gw-light.php` (search / suggest / lighter methods). */
12
11
  export declare const requestLight: (body: object, method: string) => Promise<any>;
13
- /**
14
- * Make GET requests to deezer public api
15
- * @param {String} method request method
16
- * @param {Object} params request parameters
17
- */
12
+ /** GET `gateway.php` (app_page_get, user_getInfo, …). */
18
13
  export declare const requestGet: (method: string, params?: Record<string, any>, key?: string) => Promise<any>;
19
- /**
20
- * Make GET requests to deezer public api
21
- * @param {String} slug endpoint
22
- */
23
14
  export declare const requestPublicApi: (slug: string) => Promise<any>;
@@ -4,106 +4,54 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.requestPublicApi = exports.requestGet = exports.requestLight = exports.request = void 0;
7
- const request_1 = __importDefault(require("../lib/request"));
8
7
  const errors_1 = require("../lib/errors");
8
+ const request_1 = __importDefault(require("../lib/request"));
9
+ const session_1 = require("../lib/session");
9
10
  const cache_1 = __importDefault(require("./cache"));
10
11
  /**
11
- * In-flight request coalescing (single-flight).
12
- *
13
- * The LRU only helps *after* a response lands. During a batch download the
14
- * pipeline fires many identical metadata calls at once — e.g. tagging 14 tracks
15
- * of one album triggers 14 concurrent `album/<id>` lookups — and every one of
16
- * them misses the still-empty cache and hits the network. Here a second caller
17
- * for a key already in flight awaits the same promise instead, so the wire sees
18
- * exactly one request. Entries are removed as soon as they settle; the LRU takes
19
- * over from there.
12
+ * The gateway request helpers are now thin wrappers over the **default**
13
+ * {@link Session} — its per-session cache + single-flight coalescing does the
14
+ * de-duplication that used to live here. `createSession(arl)` gives you an
15
+ * isolated one whose `session.gw(...)` / `session.getTrackInfo(...)` don't share
16
+ * this cache.
17
+ */
18
+ /** POST `gateway.php` (main gw channel). */
19
+ const request = (body, method) => (0, session_1.defaultSession)().gw(body, method);
20
+ exports.request = request;
21
+ /** POST `gw-light.php` (search / suggest / lighter methods). */
22
+ const requestLight = (body, method) => (0, session_1.defaultSession)().gwLight(body, method);
23
+ exports.requestLight = requestLight;
24
+ /** GET `gateway.php` (app_page_get, user_getInfo, …). */
25
+ const requestGet = (method, params = {}, key = 'get_request') => (0, session_1.defaultSession)().gwGet(method, params, key);
26
+ exports.requestGet = requestGet;
27
+ /**
28
+ * GET the public REST API (`api.deezer.com`). Account-independent, so it keeps
29
+ * its own process-wide cache rather than a per-session one.
20
30
  */
21
- const inFlight = new Map();
22
- const coalesce = (cacheKey, fetcher) => {
23
- const cached = cache_1.default.get(cacheKey);
31
+ const inFlightPublic = new Map();
32
+ const requestPublicApi = (slug) => {
33
+ const cached = cache_1.default.get(slug);
24
34
  if (cached) {
25
35
  return Promise.resolve(cached);
26
36
  }
27
- const pending = inFlight.get(cacheKey);
37
+ const pending = inFlightPublic.get(slug);
28
38
  if (pending) {
29
39
  return pending;
30
40
  }
31
41
  const promise = (async () => {
32
42
  try {
33
- return await fetcher();
43
+ const { data } = await request_1.default.get('https://api.deezer.com' + slug);
44
+ if (data.error) {
45
+ throw new errors_1.DeezerError(data.error);
46
+ }
47
+ cache_1.default.set(slug, data);
48
+ return data;
34
49
  }
35
50
  finally {
36
- inFlight.delete(cacheKey);
51
+ inFlightPublic.delete(slug);
37
52
  }
38
53
  })();
39
- inFlight.set(cacheKey, promise);
54
+ inFlightPublic.set(slug, promise);
40
55
  return promise;
41
56
  };
42
- /**
43
- * Make POST requests to deezer api
44
- * @param {Object} body post body
45
- * @param {String} method request method
46
- */
47
- const request = async (body, method) => {
48
- const cacheKey = method + ':' + Object.entries(body).join(':');
49
- return coalesce(cacheKey, async () => {
50
- const { data: { error, results }, } = await request_1.default.post('/gateway.php', body, { params: { method } });
51
- if (results && Object.keys(results).length > 0) {
52
- cache_1.default.set(cacheKey, results);
53
- return results;
54
- }
55
- throw new errors_1.DeezerError(error);
56
- });
57
- };
58
- exports.request = request;
59
- /**
60
- * Make POST requests to deezer api
61
- * @param {Object} body post body
62
- * @param {String} method request method
63
- */
64
- const requestLight = async (body, method) => {
65
- const cacheKey = method + ':' + Object.entries(body).join(':');
66
- return coalesce(cacheKey, async () => {
67
- const { data: { error, results }, } = await request_1.default.post('https://www.deezer.com/ajax/gw-light.php', body, {
68
- params: { method, api_version: '1.0' },
69
- });
70
- if (results && Object.keys(results).length > 0) {
71
- cache_1.default.set(cacheKey, results);
72
- return results;
73
- }
74
- throw new errors_1.DeezerError(error);
75
- });
76
- };
77
- exports.requestLight = requestLight;
78
- /**
79
- * Make GET requests to deezer public api
80
- * @param {String} method request method
81
- * @param {Object} params request parameters
82
- */
83
- const requestGet = async (method, params = {}, key = 'get_request') => {
84
- const cacheKey = method + key;
85
- return coalesce(cacheKey, async () => {
86
- const { data: { error, results }, } = await request_1.default.get('/gateway.php', { params: { method, ...params } });
87
- if (results && Object.keys(results).length > 0) {
88
- cache_1.default.set(cacheKey, results);
89
- return results;
90
- }
91
- throw new errors_1.DeezerError(error);
92
- });
93
- };
94
- exports.requestGet = requestGet;
95
- /**
96
- * Make GET requests to deezer public api
97
- * @param {String} slug endpoint
98
- */
99
- const requestPublicApi = async (slug) => {
100
- return coalesce(slug, async () => {
101
- const { data } = await request_1.default.get('https://api.deezer.com' + slug);
102
- if (data.error) {
103
- throw new errors_1.DeezerError(data.error);
104
- }
105
- cache_1.default.set(slug, data);
106
- return data;
107
- });
108
- };
109
57
  exports.requestPublicApi = requestPublicApi;
@@ -12,7 +12,7 @@ export declare const getUrlParts: (url: string, setToken?: boolean) => Promise<u
12
12
  export declare const parseInfo: (url: string) => Promise<{
13
13
  info: urlPartsType;
14
14
  linktype: linkType;
15
- linkinfo: Record<string, any> | artistInfoType | albumType | playlistInfo;
15
+ linkinfo: artistInfoType | albumType | playlistInfo | Record<string, any>;
16
16
  tracks: trackType[];
17
17
  }>;
18
18
  export {};
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import './lib/session-augment';
1
2
  export { initDeezerApi, createSession, defaultSession, Session, RETRY_POLICY, DEFAULT_ARL } from './lib/session';
2
3
  export type { SessionUserData } from './lib/session';
3
4
  export { DeezerError } from './lib/errors';
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.getStream = exports.getText = exports.getJson = exports.getBuffer = exports.httpsAgent = exports.httpAgent = exports.DeezerError = exports.DEFAULT_ARL = exports.RETRY_POLICY = exports.Session = exports.defaultSession = exports.createSession = exports.initDeezerApi = void 0;
18
+ require("./lib/session-augment"); // wires the download methods onto Session.prototype
18
19
  var session_1 = require("./lib/session");
19
20
  Object.defineProperty(exports, "initDeezerApi", { enumerable: true, get: function () { return session_1.initDeezerApi; } });
20
21
  Object.defineProperty(exports, "createSession", { enumerable: true, get: function () { return session_1.createSession; } });
@@ -1,3 +1,4 @@
1
+ import { Session } from '../lib/session';
1
2
  import type { trackType } from '../types';
2
3
  export declare class WrongLicense extends Error {
3
4
  constructor(format: string);
@@ -34,8 +35,9 @@ export declare const formatName: (quality: Quality) => string;
34
35
  /**
35
36
  * @param track Track info json returned from `getTrackInfo`
36
37
  * @param quality 1 = 128kbps, 3 = 320kbps and 9 = flac (around 1411kbps)
38
+ * @param session which account to resolve for — defaults to the process default
37
39
  */
38
- export declare const getTrackDownloadUrl: (track: trackType, quality: number) => Promise<{
40
+ export declare const getTrackDownloadUrl: (track: trackType, quality: number, session?: Session) => Promise<{
39
41
  trackUrl: string;
40
42
  isEncrypted: boolean;
41
43
  fileSize: number;
@@ -60,5 +62,6 @@ export interface ResolvedUrl {
60
62
  *
61
63
  * @param tracks from `getTrackInfo` / `parseInfo` (needs `TRACK_TOKEN`, `SNG_ID`, `FILESIZE_*`)
62
64
  * @param qualities preference order — default `[9, 3, 1]` (FLAC → MP3 320 → MP3 128)
65
+ * @param session which account to resolve for — defaults to the process default
63
66
  */
64
- export declare const resolveDownloadUrls: (tracks: trackType[], qualities?: Quality[]) => Promise<(ResolvedUrl | null)[]>;
67
+ export declare const resolveDownloadUrls: (tracks: trackType[], qualities?: Quality[], session?: Session) => Promise<(ResolvedUrl | null)[]>;
@@ -8,7 +8,6 @@ const delay_1 = __importDefault(require("delay"));
8
8
  const decrypt_1 = require("../lib/decrypt");
9
9
  const errors_1 = require("../lib/errors");
10
10
  const http_1 = require("../lib/http");
11
- const request_1 = __importDefault(require("../lib/request"));
12
11
  const session_1 = require("../lib/session");
13
12
  class WrongLicense extends Error {
14
13
  constructor(format) {
@@ -92,11 +91,11 @@ const MEDIA_MAX_RETRIES = 3;
92
91
  const formatName = (quality) => (0, exports.toFormat)(quality);
93
92
  exports.formatName = formatName;
94
93
  /** POST media.deezer.com/v1/get_url with re-auth + exponential-backoff retry on 403/429/5xx. */
95
- const mediaGetUrl = async (track_tokens, formats, attempt = 0) => {
94
+ const mediaGetUrl = async (session, track_tokens, formats, attempt = 0) => {
96
95
  var _a;
97
- const user = await (0, session_1.defaultSession)().loadUserData();
96
+ const user = await session.loadUserData();
98
97
  try {
99
- const { data } = await request_1.default.post('https://media.deezer.com/v1/get_url', {
98
+ const { data } = await session.request('POST', 'https://media.deezer.com/v1/get_url', {
100
99
  license_token: user.licenseToken,
101
100
  media: [{ type: 'FULL', formats }],
102
101
  track_tokens,
@@ -107,9 +106,9 @@ const mediaGetUrl = async (track_tokens, formats, attempt = 0) => {
107
106
  const status = err instanceof http_1.HttpStatusError ? err.statusCode : 0;
108
107
  if ((status === 403 || status === 429 || status >= 500) && attempt < MEDIA_MAX_RETRIES) {
109
108
  // a stale license token is a common cause — force a refresh before the retry
110
- await (0, session_1.defaultSession)().loadUserData(true);
109
+ await session.loadUserData(true);
111
110
  await (0, delay_1.default)(500 * 2 ** attempt + Math.floor(Math.random() * 250));
112
- return mediaGetUrl(track_tokens, formats, attempt + 1);
111
+ return mediaGetUrl(session, track_tokens, formats, attempt + 1);
113
112
  }
114
113
  throw err;
115
114
  }
@@ -132,12 +131,12 @@ const parseMediaEntry = (entry, token, country) => {
132
131
  };
133
132
  /** Whether a track fetched with the given cipher needs Blowfish stripe decryption. */
134
133
  const cipherIsEncrypted = (cipher, url) => cipher ? cipher !== 'NONE' : url.includes('/mobile/') || url.includes('/media/');
135
- const getTrackUrlFromServer = async (track_token, format) => {
136
- const user = await (0, session_1.defaultSession)().loadUserData();
134
+ const getTrackUrlFromServer = async (session, track_token, format) => {
135
+ const user = await session.loadUserData();
137
136
  if ((format === 'FLAC' && !user.canStreamLossless) || (format === 'MP3_320' && !user.canStreamHq)) {
138
137
  throw new WrongLicense(format);
139
138
  }
140
- const { data, country } = await mediaGetUrl([track_token], [{ format, cipher: 'BF_CBC_STRIPE' }]);
139
+ const { data, country } = await mediaGetUrl(session, [track_token], [{ format, cipher: 'BF_CBC_STRIPE' }]);
141
140
  if (!data.length)
142
141
  return null;
143
142
  return parseMediaEntry(data[0], track_token, country);
@@ -145,8 +144,9 @@ const getTrackUrlFromServer = async (track_token, format) => {
145
144
  /**
146
145
  * @param track Track info json returned from `getTrackInfo`
147
146
  * @param quality 1 = 128kbps, 3 = 320kbps and 9 = flac (around 1411kbps)
147
+ * @param session which account to resolve for — defaults to the process default
148
148
  */
149
- const getTrackDownloadUrl = async (track, quality) => {
149
+ const getTrackDownloadUrl = async (track, quality, session = (0, session_1.defaultSession)()) => {
150
150
  let wrongLicense = null;
151
151
  let geoBlocked = null;
152
152
  let expiredToken = null;
@@ -161,7 +161,7 @@ const getTrackDownloadUrl = async (track, quality) => {
161
161
  else {
162
162
  // Get URL with the official API
163
163
  try {
164
- const resolved = await getTrackUrlFromServer(track.TRACK_TOKEN, format);
164
+ const resolved = await getTrackUrlFromServer(session, track.TRACK_TOKEN, format);
165
165
  if (resolved) {
166
166
  return {
167
167
  trackUrl: resolved.url,
@@ -237,12 +237,13 @@ const testUrl = async (url) => {
237
237
  *
238
238
  * @param tracks from `getTrackInfo` / `parseInfo` (needs `TRACK_TOKEN`, `SNG_ID`, `FILESIZE_*`)
239
239
  * @param qualities preference order — default `[9, 3, 1]` (FLAC → MP3 320 → MP3 128)
240
+ * @param session which account to resolve for — defaults to the process default
240
241
  */
241
- const resolveDownloadUrls = async (tracks, qualities = [9, 3, 1]) => {
242
+ const resolveDownloadUrls = async (tracks, qualities = [9, 3, 1], session = (0, session_1.defaultSession)()) => {
242
243
  if (!tracks.length)
243
244
  return [];
244
245
  const formats = qualities.map((q) => ({ format: (0, exports.formatName)(q), cipher: 'BF_CBC_STRIPE' }));
245
- const { data, country } = await mediaGetUrl(tracks.map((t) => t.TRACK_TOKEN), formats);
246
+ const { data, country } = await mediaGetUrl(session, tracks.map((t) => t.TRACK_TOKEN), formats);
246
247
  return tracks.map((track, i) => {
247
248
  let parsed;
248
249
  try {
@@ -0,0 +1,20 @@
1
+ /// <reference types="node" />
2
+ import type { Quality, ResolvedUrl } from './get-url';
3
+ import type { StreamTrackOptions, TrackStream } from './stream-download';
4
+ import type { trackType } from '../types';
5
+ declare module './session' {
6
+ interface Session {
7
+ /** Resolve a downloadable URL for a track **as this account** (`1 | 3 | 9`). */
8
+ getTrackDownloadUrl(track: trackType, quality: number): Promise<{
9
+ trackUrl: string;
10
+ isEncrypted: boolean;
11
+ fileSize: number;
12
+ } | null>;
13
+ /** Batch-resolve download URLs for many tracks in one request, as this account. */
14
+ resolveDownloadUrls(tracks: trackType[], qualities?: Quality[]): Promise<(ResolvedUrl | null)[]>;
15
+ /** Download a track as a constant-memory stream of decrypted audio, as this account. */
16
+ streamTrack(track: trackType, quality: number, options?: Omit<StreamTrackOptions, 'session'>): Promise<TrackStream>;
17
+ /** Download + decrypt a track fully into a `Buffer`, as this account. */
18
+ getTrackBuffer(track: trackType, quality: number, options?: Omit<StreamTrackOptions, 'session' | 'resumeFrom'>): Promise<Buffer | null>;
19
+ }
20
+ }
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Download methods on {@link Session}, kept in a separate module so `session.ts`
5
+ * doesn't have to import the download stack (which imports `session.ts` back).
6
+ * `import`ing this file for its side effect wires the methods onto the prototype;
7
+ * `src/index.ts` does that.
8
+ */
9
+ const get_url_1 = require("./get-url");
10
+ const session_1 = require("./session");
11
+ const stream_download_1 = require("./stream-download");
12
+ session_1.Session.prototype.getTrackDownloadUrl = function (track, quality) {
13
+ return (0, get_url_1.getTrackDownloadUrl)(track, quality, this);
14
+ };
15
+ session_1.Session.prototype.resolveDownloadUrls = function (tracks, qualities) {
16
+ return (0, get_url_1.resolveDownloadUrls)(tracks, qualities, this);
17
+ };
18
+ session_1.Session.prototype.streamTrack = function (track, quality, options = {}) {
19
+ return (0, stream_download_1.streamTrackDownload)(track, quality, { ...options, session: this });
20
+ };
21
+ session_1.Session.prototype.getTrackBuffer = function (track, quality, options = {}) {
22
+ return (0, stream_download_1.downloadTrackBuffer)(track, quality, { ...options, session: this });
23
+ };
@@ -1,5 +1,8 @@
1
+ import FastLRU from './fast-lru';
1
2
  import { HttpClient } from './http';
2
3
  import type { HttpQuery, HttpResponse } from './http';
4
+ import type { albumTracksType, albumType, artistInfoType, discographyType, lyricsType, playlistInfo, playlistTracksType, profileType, searchType, trackType, userType } from '../types';
5
+ type SearchEntity = 'ALBUM' | 'ARTIST' | 'TRACK' | 'PLAYLIST' | 'RADIO' | 'SHOW' | 'USER' | 'LIVESTREAM' | 'CHANNEL';
3
6
  /**
4
7
  * The bundled default `arl`. It is shared, rate-limited and expiring — good for
5
8
  * unauthenticated endpoints, useless for downloads. `initDeezerApi` /
@@ -56,6 +59,13 @@ export declare class Session {
56
59
  arl: string;
57
60
  /** the underlying HTTP client — its `defaults.params` carry `sid` / `api_token` */
58
61
  readonly http: HttpClient;
62
+ /**
63
+ * This session's response cache (1000 entries / 60 min). Per-session because
64
+ * gateway responses carry account-specific data (e.g. `TRACK_TOKEN`). Cleared
65
+ * when the account changes via `init(arl)`.
66
+ */
67
+ readonly cache: FastLRU;
68
+ private readonly inFlight;
59
69
  private userData;
60
70
  private userDataAt;
61
71
  constructor(arl?: string);
@@ -92,6 +102,36 @@ export declare class Session {
92
102
  * backoff, and a wall-clock deadline. Throws `DeezerError` on exhaustion.
93
103
  */
94
104
  request<T>(method: 'GET' | 'POST', url: string, body?: unknown, config?: SessionRequestConfig): Promise<HttpResponse<T>>;
105
+ /** Single-flight + LRU around a fetcher, keyed per session. */
106
+ private coalesce;
107
+ /** POST `gateway.php` — the main gw method channel. Coalesced + cached. */
108
+ gw<T = any>(body: Record<string, unknown>, method: string): Promise<T>;
109
+ /** POST `gw-light.php` — the lighter method channel (search, suggest, …). */
110
+ gwLight<T = any>(body: Record<string, unknown>, method: string): Promise<T>;
111
+ /** GET `gateway.php` — used for `app_page_get` and the like. */
112
+ gwGet<T = any>(method: string, params?: Record<string, any>, key?: string): Promise<T>;
113
+ /** The logged-in user's profile. */
114
+ getUser(): Promise<userType>;
115
+ /** `song.getData` for a track — includes this session's `TRACK_TOKEN`. */
116
+ getTrackInfo(sngId: string): Promise<trackType>;
117
+ /** `song.getLyrics` — plain + time-synced lyrics. */
118
+ getLyrics(sngId: string): Promise<lyricsType>;
119
+ /** `album.getData`. */
120
+ getAlbumInfo(albId: string): Promise<albumType>;
121
+ /** All of an album's tracks (`song.getListByAlbum`). */
122
+ getAlbumTracks(albId: string): Promise<albumTracksType>;
123
+ /** `playlist.getData`. */
124
+ getPlaylistInfo(playlistId: string): Promise<playlistInfo>;
125
+ /** All of a playlist's tracks, with `TRACK_POSITION` filled in. */
126
+ getPlaylistTracks(playlistId: string): Promise<playlistTracksType>;
127
+ /** `artist.getData`. */
128
+ getArtistInfo(artId: string): Promise<artistInfoType>;
129
+ /** An artist's discography (`album.getDiscography`). */
130
+ getDiscography(artId: string, nb?: number): Promise<discographyType>;
131
+ /** A public profile (`mobile.pageUser`). */
132
+ getProfile(userId: string): Promise<profileType>;
133
+ /** Search (`deezer.pageSearch`). `types` defaults to `['TRACK']`. */
134
+ searchMusic(query: string, types?: SearchEntity[], nb?: number): Promise<searchType>;
95
135
  }
96
136
  /** The process-wide default session every free function runs against. */
97
137
  export declare const defaultSession: () => Session;
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.setDefaultSession = exports.createSession = exports.initDeezerApi = exports.defaultSession = exports.Session = exports.RETRY_POLICY = exports.DEFAULT_ARL = void 0;
7
7
  const delay_1 = __importDefault(require("delay"));
8
8
  const errors_1 = require("./errors");
9
+ const fast_lru_1 = __importDefault(require("./fast-lru"));
9
10
  const http_1 = require("./http");
10
11
  /**
11
12
  * The bundled default `arl`. It is shared, rate-limited and expiring — good for
@@ -51,6 +52,13 @@ const USER_DATA_TTL_MS = 25 * 60 * 1000;
51
52
  */
52
53
  class Session {
53
54
  constructor(arl) {
55
+ /**
56
+ * This session's response cache (1000 entries / 60 min). Per-session because
57
+ * gateway responses carry account-specific data (e.g. `TRACK_TOKEN`). Cleared
58
+ * when the account changes via `init(arl)`.
59
+ */
60
+ this.cache = new fast_lru_1.default({ maxSize: 1000, ttl: 60 * 60000 });
61
+ this.inFlight = new Map();
54
62
  this.userData = null;
55
63
  this.userDataAt = 0;
56
64
  this.arl = arl !== null && arl !== void 0 ? arl : exports.DEFAULT_ARL;
@@ -115,6 +123,7 @@ class Session {
115
123
  }
116
124
  this.arl = arl;
117
125
  this.userData = null;
126
+ this.cache.clear();
118
127
  }
119
128
  const { data } = await this.http.get('https://www.deezer.com/ajax/gw-light.php', {
120
129
  params: { method: 'deezer.ping', api_version: '1.0', api_token: '' },
@@ -207,6 +216,118 @@ class Session {
207
216
  throw new errors_1.DeezerError(error);
208
217
  }
209
218
  }
219
+ // ─── Cached request primitives ──────────────────────────────────────────────
220
+ /** Single-flight + LRU around a fetcher, keyed per session. */
221
+ coalesce(key, fetcher) {
222
+ const cached = this.cache.get(key);
223
+ if (cached) {
224
+ return Promise.resolve(cached);
225
+ }
226
+ const pending = this.inFlight.get(key);
227
+ if (pending) {
228
+ return pending;
229
+ }
230
+ const promise = (async () => {
231
+ try {
232
+ return await fetcher();
233
+ }
234
+ finally {
235
+ this.inFlight.delete(key);
236
+ }
237
+ })();
238
+ this.inFlight.set(key, promise);
239
+ return promise;
240
+ }
241
+ /** POST `gateway.php` — the main gw method channel. Coalesced + cached. */
242
+ gw(body, method) {
243
+ const key = `gw:${method}:${Object.entries(body).join(':')}`;
244
+ return this.coalesce(key, async () => {
245
+ const { data: { error, results }, } = await this.request('POST', '/gateway.php', body, { params: { method } });
246
+ if (results && Object.keys(results).length > 0) {
247
+ this.cache.set(key, results);
248
+ return results;
249
+ }
250
+ throw new errors_1.DeezerError(error);
251
+ });
252
+ }
253
+ /** POST `gw-light.php` — the lighter method channel (search, suggest, …). */
254
+ gwLight(body, method) {
255
+ const key = `gwl:${method}:${Object.entries(body).join(':')}`;
256
+ return this.coalesce(key, async () => {
257
+ const { data: { error, results }, } = await this.request('POST', 'https://www.deezer.com/ajax/gw-light.php', body, {
258
+ params: { method, api_version: '1.0' },
259
+ });
260
+ if (results && Object.keys(results).length > 0) {
261
+ this.cache.set(key, results);
262
+ return results;
263
+ }
264
+ throw new errors_1.DeezerError(error);
265
+ });
266
+ }
267
+ /** GET `gateway.php` — used for `app_page_get` and the like. */
268
+ gwGet(method, params = {}, key = 'get_request') {
269
+ const cacheKey = `gwget:${method}:${key}`;
270
+ return this.coalesce(cacheKey, async () => {
271
+ const { data: { error, results }, } = await this.request('GET', '/gateway.php', undefined, {
272
+ params: { method, ...params },
273
+ });
274
+ if (results && Object.keys(results).length > 0) {
275
+ this.cache.set(cacheKey, results);
276
+ return results;
277
+ }
278
+ throw new errors_1.DeezerError(error);
279
+ });
280
+ }
281
+ // ─── Query methods (this account's view) ────────────────────────────────────
282
+ /** The logged-in user's profile. */
283
+ getUser() {
284
+ return this.gwGet('user_getInfo');
285
+ }
286
+ /** `song.getData` for a track — includes this session's `TRACK_TOKEN`. */
287
+ getTrackInfo(sngId) {
288
+ return this.gw({ sng_id: sngId }, 'song.getData');
289
+ }
290
+ /** `song.getLyrics` — plain + time-synced lyrics. */
291
+ getLyrics(sngId) {
292
+ return this.gw({ sng_id: sngId }, 'song.getLyrics');
293
+ }
294
+ /** `album.getData`. */
295
+ getAlbumInfo(albId) {
296
+ return this.gw({ alb_id: albId }, 'album.getData');
297
+ }
298
+ /** All of an album's tracks (`song.getListByAlbum`). */
299
+ getAlbumTracks(albId) {
300
+ return this.gw({ alb_id: albId, lang: 'us', nb: -1 }, 'song.getListByAlbum');
301
+ }
302
+ /** `playlist.getData`. */
303
+ getPlaylistInfo(playlistId) {
304
+ return this.gw({ playlist_id: playlistId, lang: 'en' }, 'playlist.getData');
305
+ }
306
+ /** All of a playlist's tracks, with `TRACK_POSITION` filled in. */
307
+ async getPlaylistTracks(playlistId) {
308
+ const res = await this.gw({ playlist_id: playlistId, lang: 'en', nb: -1, start: 0, tab: 0, tags: true, header: true }, 'playlist.getSongs');
309
+ res.data = res.data.map((t, i) => {
310
+ t.TRACK_POSITION = i + 1;
311
+ return t;
312
+ });
313
+ return res;
314
+ }
315
+ /** `artist.getData`. */
316
+ getArtistInfo(artId) {
317
+ return this.gw({ art_id: artId, filter_role_id: [0], lang: 'en', tab: 0, nb: -1, start: 0 }, 'artist.getData');
318
+ }
319
+ /** An artist's discography (`album.getDiscography`). */
320
+ getDiscography(artId, nb = 500) {
321
+ return this.gw({ art_id: artId, filter_role_id: [0], lang: 'en', nb, nb_songs: -1, start: 0 }, 'album.getDiscography');
322
+ }
323
+ /** A public profile (`mobile.pageUser`). */
324
+ getProfile(userId) {
325
+ return this.gw({ user_id: userId, tab: 'loved', nb: -1 }, 'mobile.pageUser');
326
+ }
327
+ /** Search (`deezer.pageSearch`). `types` defaults to `['TRACK']`. */
328
+ searchMusic(query, types = ['TRACK'], nb = 15) {
329
+ return this.gwLight({ query, start: 0, nb, suggest: true, artist_suggest: true, top_tracks: true, types }, 'deezer.pageSearch');
330
+ }
210
331
  }
211
332
  exports.Session = Session;
212
333
  let _default = new Session();
@@ -1,5 +1,7 @@
1
1
  /// <reference types="node" />
2
+ /// <reference types="node" />
2
3
  import { Readable } from 'stream';
4
+ import type { Session } from './session';
3
5
  import type { trackType } from '../types';
4
6
  export interface StreamTrackOptions {
5
7
  /**
@@ -10,6 +12,8 @@ export interface StreamTrackOptions {
10
12
  resumeFrom?: number;
11
13
  /** progress callback — `(bytesReceived, totalBytes)`; `total` is 0 when unknown */
12
14
  onProgress?: (received: number, total: number) => void;
15
+ /** which account to download as — defaults to the process default session */
16
+ session?: Session;
13
17
  }
14
18
  export interface TrackStream {
15
19
  /** decrypted audio bytes, ready to pipe to a file or a tag muxer */
@@ -35,3 +39,10 @@ export interface TrackStream {
35
39
  * track+quality can't be resolved at all.
36
40
  */
37
41
  export declare const streamTrackDownload: (track: trackType, quality: number, options?: StreamTrackOptions) => Promise<TrackStream>;
42
+ /**
43
+ * Download + decrypt a track fully into memory. Convenience over
44
+ * {@link streamTrackDownload} for callers that just want the bytes (no tagging —
45
+ * pipe through `addTrackTags` yourself). `null` when the track+quality can't be
46
+ * resolved. Does **not** support resume.
47
+ */
48
+ export declare const downloadTrackBuffer: (track: trackType, quality: number, options?: Omit<StreamTrackOptions, 'resumeFrom'>) => Promise<Buffer | null>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.streamTrackDownload = void 0;
3
+ exports.downloadTrackBuffer = exports.streamTrackDownload = void 0;
4
4
  const stream_1 = require("stream");
5
5
  const decrypt_1 = require("./decrypt");
6
6
  const http_1 = require("./http");
@@ -20,7 +20,7 @@ const CHUNK = 2048;
20
20
  * track+quality can't be resolved at all.
21
21
  */
22
22
  const streamTrackDownload = async (track, quality, options = {}) => {
23
- const resolved = await (0, get_url_1.getTrackDownloadUrl)(track, quality);
23
+ const resolved = await (0, get_url_1.getTrackDownloadUrl)(track, quality, options.session);
24
24
  if (!resolved) {
25
25
  throw new Error(`Track ${track.SNG_ID} is unavailable at quality ${quality}`);
26
26
  }
@@ -52,3 +52,27 @@ const streamTrackDownload = async (track, quality, options = {}) => {
52
52
  };
53
53
  };
54
54
  exports.streamTrackDownload = streamTrackDownload;
55
+ /**
56
+ * Download + decrypt a track fully into memory. Convenience over
57
+ * {@link streamTrackDownload} for callers that just want the bytes (no tagging —
58
+ * pipe through `addTrackTags` yourself). `null` when the track+quality can't be
59
+ * resolved. Does **not** support resume.
60
+ */
61
+ const downloadTrackBuffer = async (track, quality, options = {}) => {
62
+ let ts;
63
+ try {
64
+ ts = await (0, exports.streamTrackDownload)(track, quality, { onProgress: options.onProgress, session: options.session });
65
+ }
66
+ catch (err) {
67
+ if (err instanceof Error && /unavailable at quality/.test(err.message)) {
68
+ return null;
69
+ }
70
+ throw err;
71
+ }
72
+ const chunks = [];
73
+ for await (const chunk of ts.stream) {
74
+ chunks.push(chunk);
75
+ }
76
+ return Buffer.concat(chunks);
77
+ };
78
+ exports.downloadTrackBuffer = downloadTrackBuffer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gerdur-core",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "Core module for gerdur.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",