gerdur-core 2.6.0 → 2.8.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,55 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.8.0 - 2026-08-31
4
+
5
+ Phase 3.2 (cont.) — a `Session` is now usable per-account. Additive; free
6
+ functions unchanged.
7
+
8
+ ### Added
9
+
10
+ - **`Session` query methods**: `getUser`, `getTrackInfo`, `getLyrics`,
11
+ `getAlbumInfo`, `getAlbumTracks`, `getPlaylistInfo`, `getPlaylistTracks`,
12
+ `getArtistInfo`, `getDiscography`, `getProfile`, `searchMusic` — plus the raw
13
+ channels `gw` / `gwLight` / `gwGet`. So `createSession(arl)` gives you an
14
+ isolated client you can actually query, not just inspect.
15
+ - Each `Session` has **its own response cache** (`session.cache`) — gateway
16
+ responses carry account-specific `TRACK_TOKEN`s, so sharing one cache across
17
+ accounts was wrong.
18
+
19
+ ### Changed
20
+
21
+ - `request` / `requestLight` / `requestGet` are now thin wrappers over
22
+ `defaultSession()`. **`initDeezerApi(newArl)` now clears the default session's
23
+ cache** — previously a new account could be served the old account's cached
24
+ track tokens.
25
+
26
+ ## 2.7.0 - 2026-08-31
27
+
28
+ Phase 3.2 — session lifecycle. The scattered module-level state
29
+ (`request.ts`: `arl` / `sid` / `api_token`; `get-url.ts`: `license_token` /
30
+ `country` / streaming rights) is consolidated into one **`Session`** object.
31
+ Backwards compatible — `initDeezerApi` and every free function are unchanged.
32
+
33
+ ### Added
34
+
35
+ - **`Session`** — owns one account's `arl`, HTTP client (`sid` / `api_token`),
36
+ `license_token` / `country` / `canStreamLossless` / `canStreamHq`, the
37
+ bounded-retry request loop, and token refresh (`init`, `refreshApiToken`,
38
+ `loadUserData`).
39
+ - **`createSession(arl?)`** — an isolated session you hold and inspect, so
40
+ multiple accounts can be used concurrently.
41
+ - **`defaultSession()`** — the process-wide session `initDeezerApi` and the free
42
+ functions run against. `setDefaultSession(session)` swaps it.
43
+ - `SessionUserData` type; `DEFAULT_ARL` constant.
44
+
45
+ ### Changed
46
+
47
+ - **Proactive `license_token` refresh** — `loadUserData()` caches the account's
48
+ media-API credentials for 25 min and force-refreshes on a media 403, instead of
49
+ only re-fetching after a failed download. Fewer opaque CDN 403s on long
50
+ playlists.
51
+ - `RETRY_POLICY` now lives in `lib/session.ts` (still exported, same shape).
52
+
3
53
  ## 2.6.0 - 2026-08-31
4
54
 
5
55
  Phase 2.4 round 2 — Flow, radios, and a user's library. All public REST, all
package/README.md CHANGED
@@ -97,6 +97,49 @@ Retries are bounded — `RETRY_POLICY` (exported) sets per-class attempt caps an
97
97
  `DeezerError` instead of spinning. `GeoBlocked`, `WrongLicense` and
98
98
  `ExpiredTrackToken` are still thrown as their own types from the download path.
99
99
 
100
+ ### Sessions
101
+
102
+ A **`Session`** owns one account's state — the `arl`, the HTTP client (`sid` /
103
+ `api_token`), and the account's `license_token` / `country` / streaming rights,
104
+ plus the bounded-retry loop and token refresh. This state used to be
105
+ module-level globals; bundling it means multiple accounts can coexist.
106
+
107
+ - **`initDeezerApi(arl)`** — (re)authenticate the **default** session. Every free
108
+ function (`getTrackInfo`, `searchMusic`, `getTrackDownloadUrl`, …) runs against
109
+ it. Unchanged: same signature, returns the gateway `SESSION` id.
110
+ - **`createSession(arl?)`** — an **isolated** session you hold and inspect:
111
+ `session.arl`, `session.sid`, and — after `await session.loadUserData()` —
112
+ `session.country`, `session.licenseToken`, `session.canStreamLossless`,
113
+ `session.canStreamHq`. `loadUserData()` caches for 25 min and refreshes on a
114
+ media-API 403.
115
+ - **`defaultSession()`** — the `Session` the free functions use.
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
+ | `gw(body, method)` / `gwLight(body, method)` / `gwGet(method, params?)` | the raw coalesced request channels |
131
+ | `init(arl?)` / `refreshApiToken()` / `loadUserData(force?)` / `invalidateUserData()` | lifecycle |
132
+
133
+ ```js
134
+ await initDeezerApi(arl); // default session, as before
135
+ const track = await getTrackInfo('3135556');
136
+
137
+ const s = await createSession(otherArl); // a second account, fully isolated
138
+ await s.loadUserData();
139
+ console.log(s.country, s.canStreamLossless);
140
+ const mine = await s.getTrackInfo('3135556'); // runs against `otherArl`
141
+ ```
142
+
100
143
  ### `.initDeezerApi(arl_cookie);`
101
144
 
102
145
  > It is recommended that you first init the app with this method using your 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,4 +1,5 @@
1
- export { initDeezerApi, RETRY_POLICY } from './lib/request';
1
+ export { initDeezerApi, createSession, defaultSession, Session, RETRY_POLICY, DEFAULT_ARL } from './lib/session';
2
+ export type { SessionUserData } from './lib/session';
2
3
  export { DeezerError } from './lib/errors';
3
4
  export type { DeezerErrorPayload } from './lib/errors';
4
5
  export * from './api';
package/dist/index.js CHANGED
@@ -14,10 +14,14 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.getStream = exports.getText = exports.getJson = exports.getBuffer = exports.httpsAgent = exports.httpAgent = exports.DeezerError = exports.RETRY_POLICY = exports.initDeezerApi = void 0;
18
- var request_1 = require("./lib/request");
19
- Object.defineProperty(exports, "initDeezerApi", { enumerable: true, get: function () { return request_1.initDeezerApi; } });
20
- Object.defineProperty(exports, "RETRY_POLICY", { enumerable: true, get: function () { return request_1.RETRY_POLICY; } });
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
+ var session_1 = require("./lib/session");
19
+ Object.defineProperty(exports, "initDeezerApi", { enumerable: true, get: function () { return session_1.initDeezerApi; } });
20
+ Object.defineProperty(exports, "createSession", { enumerable: true, get: function () { return session_1.createSession; } });
21
+ Object.defineProperty(exports, "defaultSession", { enumerable: true, get: function () { return session_1.defaultSession; } });
22
+ Object.defineProperty(exports, "Session", { enumerable: true, get: function () { return session_1.Session; } });
23
+ Object.defineProperty(exports, "RETRY_POLICY", { enumerable: true, get: function () { return session_1.RETRY_POLICY; } });
24
+ Object.defineProperty(exports, "DEFAULT_ARL", { enumerable: true, get: function () { return session_1.DEFAULT_ARL; } });
21
25
  var errors_1 = require("./lib/errors");
22
26
  Object.defineProperty(exports, "DeezerError", { enumerable: true, get: function () { return errors_1.DeezerError; } });
23
27
  __exportStar(require("./api"), exports);
@@ -9,6 +9,7 @@ const decrypt_1 = require("../lib/decrypt");
9
9
  const errors_1 = require("../lib/errors");
10
10
  const http_1 = require("../lib/http");
11
11
  const request_1 = __importDefault(require("../lib/request"));
12
+ const session_1 = require("../lib/session");
12
13
  class WrongLicense extends Error {
13
14
  constructor(format) {
14
15
  super();
@@ -37,7 +38,6 @@ class ExpiredTrackToken extends Error {
37
38
  }
38
39
  }
39
40
  exports.ExpiredTrackToken = ExpiredTrackToken;
40
- let user_data = null;
41
41
  /**
42
42
  * Every audio format Deezer's `get_url` understands, best → worst. `FLAC`,
43
43
  * `MP3_320` and `MP3_128` are the classic `9 / 3 / 1` qualities; the rest are
@@ -83,22 +83,6 @@ const getTrackFileSize = (track, quality) => {
83
83
  const key = FORMAT_FILESIZE_KEY[(0, exports.toFormat)(quality)];
84
84
  return key ? Number(track[key]) || 0 : 0;
85
85
  };
86
- const dzAuthenticate = async () => {
87
- const { data } = await request_1.default.get('https://www.deezer.com/ajax/gw-light.php', {
88
- params: {
89
- method: 'deezer.getUserData',
90
- api_version: '1.0',
91
- api_token: 'null',
92
- },
93
- });
94
- user_data = {
95
- license_token: data.results.USER.OPTIONS.license_token,
96
- can_stream_lossless: data.results.USER.OPTIONS.web_lossless || data.results.USER.OPTIONS.mobile_loseless,
97
- can_stream_hq: data.results.USER.OPTIONS.web_hq || data.results.USER.OPTIONS.mobile_hq,
98
- country: data.results.COUNTRY,
99
- };
100
- return user_data;
101
- };
102
86
  const MEDIA_MAX_RETRIES = 3;
103
87
  /**
104
88
  * Quality code (`1 | 3 | 9`) or format string → the `format` string the media
@@ -110,10 +94,10 @@ exports.formatName = formatName;
110
94
  /** POST media.deezer.com/v1/get_url with re-auth + exponential-backoff retry on 403/429/5xx. */
111
95
  const mediaGetUrl = async (track_tokens, formats, attempt = 0) => {
112
96
  var _a;
113
- const user = user_data ? user_data : await dzAuthenticate();
97
+ const user = await (0, session_1.defaultSession)().loadUserData();
114
98
  try {
115
99
  const { data } = await request_1.default.post('https://media.deezer.com/v1/get_url', {
116
- license_token: user.license_token,
100
+ license_token: user.licenseToken,
117
101
  media: [{ type: 'FULL', formats }],
118
102
  track_tokens,
119
103
  });
@@ -122,7 +106,8 @@ const mediaGetUrl = async (track_tokens, formats, attempt = 0) => {
122
106
  catch (err) {
123
107
  const status = err instanceof http_1.HttpStatusError ? err.statusCode : 0;
124
108
  if ((status === 403 || status === 429 || status >= 500) && attempt < MEDIA_MAX_RETRIES) {
125
- user_data = null;
109
+ // a stale license token is a common cause — force a refresh before the retry
110
+ await (0, session_1.defaultSession)().loadUserData(true);
126
111
  await (0, delay_1.default)(500 * 2 ** attempt + Math.floor(Math.random() * 250));
127
112
  return mediaGetUrl(track_tokens, formats, attempt + 1);
128
113
  }
@@ -148,8 +133,8 @@ const parseMediaEntry = (entry, token, country) => {
148
133
  /** Whether a track fetched with the given cipher needs Blowfish stripe decryption. */
149
134
  const cipherIsEncrypted = (cipher, url) => cipher ? cipher !== 'NONE' : url.includes('/mobile/') || url.includes('/media/');
150
135
  const getTrackUrlFromServer = async (track_token, format) => {
151
- const user = user_data ? user_data : await dzAuthenticate();
152
- if ((format === 'FLAC' && !user.can_stream_lossless) || (format === 'MP3_320' && !user.can_stream_hq)) {
136
+ const user = await (0, session_1.defaultSession)().loadUserData();
137
+ if ((format === 'FLAC' && !user.canStreamLossless) || (format === 'MP3_320' && !user.canStreamHq)) {
153
138
  throw new WrongLicense(format);
154
139
  }
155
140
  const { data, country } = await mediaGetUrl([track_token], [{ format, cipher: 'BF_CBC_STRIPE' }]);
@@ -1,31 +1,17 @@
1
1
  import type { HttpQuery, HttpResponse } from './http';
2
+ export { initDeezerApi, createSession, defaultSession, setDefaultSession, Session, RETRY_POLICY } from './session';
3
+ export type { SessionUserData } from './session';
2
4
  type DeezerRequestConfig = {
3
5
  headers?: Record<string, string>;
4
6
  params?: HttpQuery;
5
7
  };
6
8
  /**
7
- * Bounded-retry policy for the gateway. Every retry class has its own attempt
8
- * cap **and** there is a wall-clock deadline, so a persistently failing endpoint
9
- * can no longer spin forever (the old code had no cap on `code === 4`,
10
- * `NEED_API_AUTH_REQUIRED` or `GATEWAY_ERROR`).
9
+ * A thin proxy over the **default** {@link Session} kept so the api / get-url
10
+ * layers can keep calling `client.get` / `client.post` unchanged. Each call runs
11
+ * through the session's bounded-retry loop.
11
12
  */
12
- export declare const RETRY_POLICY: {
13
- /** total attempts for the transient `code === 4` class */
14
- code4Attempts: number;
15
- /** re-inits allowed for `NEED_API_AUTH_REQUIRED` */
16
- authReinits: number;
17
- /** token refreshes allowed for `GATEWAY_ERROR` / `VALID_TOKEN_REQUIRED` */
18
- tokenRefreshes: number;
19
- /** base backoff (ms) — grows exponentially, full-jittered */
20
- baseMs: number;
21
- /** cap on a single backoff wait */
22
- maxDelayMs: number;
23
- /** overall wall-clock budget from the first attempt */
24
- deadlineMs: number;
25
- };
26
- export declare const initDeezerApi: (arl: string) => Promise<string>;
27
13
  declare const _default: {
28
- defaults: {
14
+ readonly defaults: {
29
15
  baseURL?: string | undefined;
30
16
  headers: import("./http").HttpHeaders;
31
17
  maxRedirects: number;
@@ -1,123 +1,23 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.initDeezerApi = exports.RETRY_POLICY = void 0;
7
- const delay_1 = __importDefault(require("delay"));
8
- const http_1 = require("./http");
9
- const errors_1 = require("./errors");
10
- let user_arl = 'c973964816688562722418b5200c1515dffaad15a42643ebf87cc72824a54612ec51c2ad42d566743f9e424c774e98ccae7737770acff59251328e6cd598c7bcac38ca269adf78bfb88ec5bbad6cd800db3c0b88b2af645bb22b99e71de26416';
3
+ exports.RETRY_POLICY = exports.Session = exports.setDefaultSession = exports.defaultSession = exports.createSession = exports.initDeezerApi = void 0;
4
+ const session_1 = require("./session");
5
+ var session_2 = require("./session");
6
+ Object.defineProperty(exports, "initDeezerApi", { enumerable: true, get: function () { return session_2.initDeezerApi; } });
7
+ Object.defineProperty(exports, "createSession", { enumerable: true, get: function () { return session_2.createSession; } });
8
+ Object.defineProperty(exports, "defaultSession", { enumerable: true, get: function () { return session_2.defaultSession; } });
9
+ Object.defineProperty(exports, "setDefaultSession", { enumerable: true, get: function () { return session_2.setDefaultSession; } });
10
+ Object.defineProperty(exports, "Session", { enumerable: true, get: function () { return session_2.Session; } });
11
+ Object.defineProperty(exports, "RETRY_POLICY", { enumerable: true, get: function () { return session_2.RETRY_POLICY; } });
11
12
  /**
12
- * Bounded-retry policy for the gateway. Every retry class has its own attempt
13
- * cap **and** there is a wall-clock deadline, so a persistently failing endpoint
14
- * can no longer spin forever (the old code had no cap on `code === 4`,
15
- * `NEED_API_AUTH_REQUIRED` or `GATEWAY_ERROR`).
13
+ * A thin proxy over the **default** {@link Session} kept so the api / get-url
14
+ * layers can keep calling `client.get` / `client.post` unchanged. Each call runs
15
+ * through the session's bounded-retry loop.
16
16
  */
17
- exports.RETRY_POLICY = {
18
- /** total attempts for the transient `code === 4` class */
19
- code4Attempts: 6,
20
- /** re-inits allowed for `NEED_API_AUTH_REQUIRED` */
21
- authReinits: 3,
22
- /** token refreshes allowed for `GATEWAY_ERROR` / `VALID_TOKEN_REQUIRED` */
23
- tokenRefreshes: 15,
24
- /** base backoff (ms) — grows exponentially, full-jittered */
25
- baseMs: 800,
26
- /** cap on a single backoff wait */
27
- maxDelayMs: 8000,
28
- /** overall wall-clock budget from the first attempt */
29
- deadlineMs: 30000,
30
- };
31
- /** Exponential backoff (ms) with full jitter on the top half of the window. */
32
- const backoffDelay = (attempt) => {
33
- const windowMs = Math.min(exports.RETRY_POLICY.baseMs * 2 ** attempt, exports.RETRY_POLICY.maxDelayMs);
34
- return windowMs / 2 + Math.random() * (windowMs / 2);
35
- };
36
- const instance = new http_1.HttpClient({
37
- baseURL: 'https://api.deezer.com/1.0',
38
- timeout: 15000,
39
- headers: {
40
- Accept: '*/*',
41
- 'Accept-Encoding': 'gzip, deflate',
42
- 'Accept-Language': 'en-US',
43
- 'Cache-Control': 'no-cache',
44
- 'Content-Type': 'application/json; charset=UTF-8',
45
- 'User-Agent': 'Deezer/8.32.0.2 (iOS; 14.4; Mobile; en; iPhone10_5)',
46
- },
47
- params: {
48
- version: '8.32.0',
49
- api_key: 'ZAIVAHCEISOHWAICUQUEXAEPICENGUAFAEZAIPHAELEEVAHPHUCUFONGUAPASUAY',
50
- output: 3,
51
- input: 3,
52
- buildId: 'ios12_universal',
53
- screenHeight: '480',
54
- screenWidth: '320',
55
- lang: 'en',
56
- },
57
- });
58
- const getApiToken = async () => {
59
- const { data } = await instance.get('https://www.deezer.com/ajax/gw-light.php', {
60
- params: {
61
- method: 'deezer.getUserData',
62
- api_version: '1.0',
63
- api_token: 'null',
64
- },
65
- });
66
- instance.defaults.params.sid = data.results.SESSION_ID;
67
- instance.defaults.params.api_token = data.results.checkForm;
68
- return data.results.checkForm;
69
- };
70
- const initDeezerApi = async (arl) => {
71
- if (arl.length !== 192) {
72
- throw new Error(`Invalid arl. Length should be 192 characters. You have provided ${arl.length} characters.`);
73
- }
74
- user_arl = arl;
75
- const { data } = await instance.get('https://www.deezer.com/ajax/gw-light.php', {
76
- params: { method: 'deezer.ping', api_version: '1.0', api_token: '' },
77
- headers: { cookie: 'arl=' + arl },
78
- });
79
- instance.defaults.params.sid = data.results.SESSION;
80
- return data.results.SESSION;
81
- };
82
- exports.initDeezerApi = initDeezerApi;
83
- const requestWithRetry = async (method, url, body, config = {}) => {
84
- var _a;
85
- const startedAt = Date.now();
86
- let authReinits = 0;
87
- let tokenRefreshes = 0;
88
- let code4Attempts = 0;
89
- // eslint-disable-next-line no-constant-condition
90
- while (true) {
91
- const response = method === 'POST' ? await instance.post(url, body, config) : await instance.get(url, config);
92
- const error = (_a = response.data) === null || _a === void 0 ? void 0 : _a.error;
93
- if (!error || Object.keys(error).length === 0) {
94
- return response;
95
- }
96
- const overDeadline = Date.now() - startedAt > exports.RETRY_POLICY.deadlineMs;
97
- if (error.NEED_API_AUTH_REQUIRED && authReinits < exports.RETRY_POLICY.authReinits && !overDeadline) {
98
- authReinits += 1;
99
- await (0, exports.initDeezerApi)(user_arl);
100
- continue;
101
- }
102
- if ((error.GATEWAY_ERROR || error.VALID_TOKEN_REQUIRED) &&
103
- tokenRefreshes < exports.RETRY_POLICY.tokenRefreshes &&
104
- !overDeadline) {
105
- tokenRefreshes += 1;
106
- await getApiToken();
107
- await (0, delay_1.default)(backoffDelay(tokenRefreshes - 1));
108
- continue;
109
- }
110
- if (error.code === 4 && code4Attempts < exports.RETRY_POLICY.code4Attempts && !overDeadline) {
111
- await (0, delay_1.default)(backoffDelay(code4Attempts));
112
- code4Attempts += 1;
113
- continue;
114
- }
115
- // Unhandled error, or a retry class ran out of attempts / hit the deadline.
116
- throw new errors_1.DeezerError(error);
117
- }
118
- };
119
17
  exports.default = {
120
- defaults: instance.defaults,
121
- get: async (url, config = {}) => await requestWithRetry('GET', url, undefined, config),
122
- post: async (url, body, config = {}) => await requestWithRetry('POST', url, body, config),
18
+ get defaults() {
19
+ return (0, session_1.defaultSession)().http.defaults;
20
+ },
21
+ get: (url, config = {}) => (0, session_1.defaultSession)().request('GET', url, undefined, config),
22
+ post: (url, body, config = {}) => (0, session_1.defaultSession)().request('POST', url, body, config),
123
23
  };
@@ -0,0 +1,152 @@
1
+ import FastLRU from './fast-lru';
2
+ import { HttpClient } from './http';
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';
6
+ /**
7
+ * The bundled default `arl`. It is shared, rate-limited and expiring — good for
8
+ * unauthenticated endpoints, useless for downloads. `initDeezerApi` /
9
+ * `createSession` replace it.
10
+ */
11
+ export declare const DEFAULT_ARL = "c973964816688562722418b5200c1515dffaad15a42643ebf87cc72824a54612ec51c2ad42d566743f9e424c774e98ccae7737770acff59251328e6cd598c7bcac38ca269adf78bfb88ec5bbad6cd800db3c0b88b2af645bb22b99e71de26416";
12
+ /**
13
+ * Bounded-retry policy for the gateway. Every retry class has its own attempt
14
+ * cap **and** there is a wall-clock deadline, so a persistently failing endpoint
15
+ * can no longer spin forever.
16
+ */
17
+ export declare const RETRY_POLICY: {
18
+ /** total attempts for the transient `code === 4` class */
19
+ code4Attempts: number;
20
+ /** re-inits allowed for `NEED_API_AUTH_REQUIRED` */
21
+ authReinits: number;
22
+ /** token refreshes allowed for `GATEWAY_ERROR` / `VALID_TOKEN_REQUIRED` */
23
+ tokenRefreshes: number;
24
+ /** base backoff (ms) — grows exponentially, full-jittered */
25
+ baseMs: number;
26
+ /** cap on a single backoff wait */
27
+ maxDelayMs: number;
28
+ /** overall wall-clock budget from the first attempt */
29
+ deadlineMs: number;
30
+ };
31
+ type SessionRequestConfig = {
32
+ headers?: Record<string, string>;
33
+ params?: HttpQuery;
34
+ };
35
+ /** The account-scoped bits of `deezer.getUserData` a download needs. */
36
+ export interface SessionUserData {
37
+ /** the `license_token` the media API's `get_url` requires */
38
+ licenseToken: string;
39
+ /** ISO country the account resolves to (drives geo-blocking) */
40
+ country: string;
41
+ /** account may stream FLAC */
42
+ canStreamLossless: boolean;
43
+ /** account may stream 320 kbps */
44
+ canStreamHq: boolean;
45
+ offerId?: number;
46
+ }
47
+ /**
48
+ * One Deezer session — owns the `arl`, the HTTP client (and its `sid` /
49
+ * `api_token`), and the account's `license_token` / `country` / streaming
50
+ * rights, plus the bounded-retry request loop and token refresh.
51
+ *
52
+ * The state used to be spread across module-level variables in `request.ts` and
53
+ * `get-url.ts`; a `Session` bundles it so multiple accounts can coexist. The
54
+ * free functions (`getTrackInfo`, `request`, …) run against a process-wide
55
+ * default session; `createSession(arl)` gives you an isolated one.
56
+ */
57
+ export declare class Session {
58
+ /** the arl cookie in use */
59
+ arl: string;
60
+ /** the underlying HTTP client — its `defaults.params` carry `sid` / `api_token` */
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;
69
+ private userData;
70
+ private userDataAt;
71
+ constructor(arl?: string);
72
+ /** current gateway session id */
73
+ get sid(): string | undefined;
74
+ /** current CSRF-ish api token */
75
+ get apiToken(): string | undefined;
76
+ /** ISO country the account resolves to, once `loadUserData` has run */
77
+ get country(): string | undefined;
78
+ /** the media-API `license_token`, once `loadUserData` has run */
79
+ get licenseToken(): string | undefined;
80
+ get canStreamLossless(): boolean;
81
+ get canStreamHq(): boolean;
82
+ /**
83
+ * Ping `gw-light.php` for a fresh `SESSION` id. Pass an `arl` to (re)authenticate
84
+ * this session as that account — it must be exactly 192 characters.
85
+ * Returns the new `SESSION`.
86
+ */
87
+ init(arl?: string): Promise<string>;
88
+ /** Refresh `sid` + `api_token` from `deezer.getUserData`. Returns the api token. */
89
+ refreshApiToken(): Promise<string>;
90
+ /**
91
+ * The account's `license_token` / `country` / streaming rights, from
92
+ * `deezer.getUserData`. Cached for {@link USER_DATA_TTL_MS}; pass `force` (or
93
+ * see a media-API 403) to refresh. Also refreshes `sid` / `api_token`.
94
+ */
95
+ loadUserData(force?: boolean): Promise<SessionUserData>;
96
+ /** Drop the cached user data so the next `loadUserData` re-fetches. */
97
+ invalidateUserData(): void;
98
+ /**
99
+ * The bounded-retry gateway request loop. Handles `NEED_API_AUTH_REQUIRED`
100
+ * (re-init), `GATEWAY_ERROR` / `VALID_TOKEN_REQUIRED` (token refresh) and
101
+ * `code === 4` (transient), each with its own attempt cap, full-jittered
102
+ * backoff, and a wall-clock deadline. Throws `DeezerError` on exhaustion.
103
+ */
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>;
135
+ }
136
+ /** The process-wide default session every free function runs against. */
137
+ export declare const defaultSession: () => Session;
138
+ /**
139
+ * (Re)authenticate the **default** session. Kept for backwards compatibility —
140
+ * `createSession` is the way to hold an isolated session. `arl` must be exactly
141
+ * 192 characters. Returns the gateway `SESSION` id.
142
+ */
143
+ export declare const initDeezerApi: (arl: string) => Promise<string>;
144
+ /**
145
+ * Create an **isolated** Deezer session — its own `arl`, `sid`, tokens and
146
+ * `license_token`, so multiple accounts can be used concurrently. Without an
147
+ * `arl` it runs on the bundled (shared, rate-limited) default.
148
+ */
149
+ export declare const createSession: (arl?: string) => Promise<Session>;
150
+ /** Replace the default session (used by tests). */
151
+ export declare const setDefaultSession: (session: Session) => void;
152
+ export {};
@@ -0,0 +1,359 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.setDefaultSession = exports.createSession = exports.initDeezerApi = exports.defaultSession = exports.Session = exports.RETRY_POLICY = exports.DEFAULT_ARL = void 0;
7
+ const delay_1 = __importDefault(require("delay"));
8
+ const errors_1 = require("./errors");
9
+ const fast_lru_1 = __importDefault(require("./fast-lru"));
10
+ const http_1 = require("./http");
11
+ /**
12
+ * The bundled default `arl`. It is shared, rate-limited and expiring — good for
13
+ * unauthenticated endpoints, useless for downloads. `initDeezerApi` /
14
+ * `createSession` replace it.
15
+ */
16
+ exports.DEFAULT_ARL = 'c973964816688562722418b5200c1515dffaad15a42643ebf87cc72824a54612ec51c2ad42d566743f9e424c774e98ccae7737770acff59251328e6cd598c7bcac38ca269adf78bfb88ec5bbad6cd800db3c0b88b2af645bb22b99e71de26416';
17
+ /**
18
+ * Bounded-retry policy for the gateway. Every retry class has its own attempt
19
+ * cap **and** there is a wall-clock deadline, so a persistently failing endpoint
20
+ * can no longer spin forever.
21
+ */
22
+ exports.RETRY_POLICY = {
23
+ /** total attempts for the transient `code === 4` class */
24
+ code4Attempts: 6,
25
+ /** re-inits allowed for `NEED_API_AUTH_REQUIRED` */
26
+ authReinits: 3,
27
+ /** token refreshes allowed for `GATEWAY_ERROR` / `VALID_TOKEN_REQUIRED` */
28
+ tokenRefreshes: 15,
29
+ /** base backoff (ms) — grows exponentially, full-jittered */
30
+ baseMs: 800,
31
+ /** cap on a single backoff wait */
32
+ maxDelayMs: 8000,
33
+ /** overall wall-clock budget from the first attempt */
34
+ deadlineMs: 30000,
35
+ };
36
+ /** Exponential backoff (ms) with full jitter on the top half of the window. */
37
+ const backoffDelay = (attempt) => {
38
+ const windowMs = Math.min(exports.RETRY_POLICY.baseMs * 2 ** attempt, exports.RETRY_POLICY.maxDelayMs);
39
+ return windowMs / 2 + Math.random() * (windowMs / 2);
40
+ };
41
+ /** How long a loaded `deezer.getUserData` payload is trusted before a refresh. */
42
+ const USER_DATA_TTL_MS = 25 * 60 * 1000;
43
+ /**
44
+ * One Deezer session — owns the `arl`, the HTTP client (and its `sid` /
45
+ * `api_token`), and the account's `license_token` / `country` / streaming
46
+ * rights, plus the bounded-retry request loop and token refresh.
47
+ *
48
+ * The state used to be spread across module-level variables in `request.ts` and
49
+ * `get-url.ts`; a `Session` bundles it so multiple accounts can coexist. The
50
+ * free functions (`getTrackInfo`, `request`, …) run against a process-wide
51
+ * default session; `createSession(arl)` gives you an isolated one.
52
+ */
53
+ class Session {
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();
62
+ this.userData = null;
63
+ this.userDataAt = 0;
64
+ this.arl = arl !== null && arl !== void 0 ? arl : exports.DEFAULT_ARL;
65
+ this.http = new http_1.HttpClient({
66
+ baseURL: 'https://api.deezer.com/1.0',
67
+ timeout: 15000,
68
+ headers: {
69
+ Accept: '*/*',
70
+ 'Accept-Encoding': 'gzip, deflate',
71
+ 'Accept-Language': 'en-US',
72
+ 'Cache-Control': 'no-cache',
73
+ 'Content-Type': 'application/json; charset=UTF-8',
74
+ 'User-Agent': 'Deezer/8.32.0.2 (iOS; 14.4; Mobile; en; iPhone10_5)',
75
+ },
76
+ params: {
77
+ version: '8.32.0',
78
+ api_key: 'ZAIVAHCEISOHWAICUQUEXAEPICENGUAFAEZAIPHAELEEVAHPHUCUFONGUAPASUAY',
79
+ output: 3,
80
+ input: 3,
81
+ buildId: 'ios12_universal',
82
+ screenHeight: '480',
83
+ screenWidth: '320',
84
+ lang: 'en',
85
+ },
86
+ });
87
+ }
88
+ /** current gateway session id */
89
+ get sid() {
90
+ return this.http.defaults.params.sid;
91
+ }
92
+ /** current CSRF-ish api token */
93
+ get apiToken() {
94
+ return this.http.defaults.params.api_token;
95
+ }
96
+ /** ISO country the account resolves to, once `loadUserData` has run */
97
+ get country() {
98
+ var _a;
99
+ return (_a = this.userData) === null || _a === void 0 ? void 0 : _a.country;
100
+ }
101
+ /** the media-API `license_token`, once `loadUserData` has run */
102
+ get licenseToken() {
103
+ var _a;
104
+ return (_a = this.userData) === null || _a === void 0 ? void 0 : _a.licenseToken;
105
+ }
106
+ get canStreamLossless() {
107
+ var _a, _b;
108
+ return (_b = (_a = this.userData) === null || _a === void 0 ? void 0 : _a.canStreamLossless) !== null && _b !== void 0 ? _b : false;
109
+ }
110
+ get canStreamHq() {
111
+ var _a, _b;
112
+ return (_b = (_a = this.userData) === null || _a === void 0 ? void 0 : _a.canStreamHq) !== null && _b !== void 0 ? _b : false;
113
+ }
114
+ /**
115
+ * Ping `gw-light.php` for a fresh `SESSION` id. Pass an `arl` to (re)authenticate
116
+ * this session as that account — it must be exactly 192 characters.
117
+ * Returns the new `SESSION`.
118
+ */
119
+ async init(arl) {
120
+ if (arl !== undefined) {
121
+ if (arl.length !== 192) {
122
+ throw new Error(`Invalid arl. Length should be 192 characters. You have provided ${arl.length} characters.`);
123
+ }
124
+ this.arl = arl;
125
+ this.userData = null;
126
+ this.cache.clear();
127
+ }
128
+ const { data } = await this.http.get('https://www.deezer.com/ajax/gw-light.php', {
129
+ params: { method: 'deezer.ping', api_version: '1.0', api_token: '' },
130
+ headers: { cookie: 'arl=' + this.arl },
131
+ });
132
+ this.http.defaults.params.sid = data.results.SESSION;
133
+ return data.results.SESSION;
134
+ }
135
+ /** Refresh `sid` + `api_token` from `deezer.getUserData`. Returns the api token. */
136
+ async refreshApiToken() {
137
+ const { data } = await this.http.get('https://www.deezer.com/ajax/gw-light.php', {
138
+ params: { method: 'deezer.getUserData', api_version: '1.0', api_token: 'null' },
139
+ });
140
+ this.http.defaults.params.sid = data.results.SESSION_ID;
141
+ this.http.defaults.params.api_token = data.results.checkForm;
142
+ return data.results.checkForm;
143
+ }
144
+ /**
145
+ * The account's `license_token` / `country` / streaming rights, from
146
+ * `deezer.getUserData`. Cached for {@link USER_DATA_TTL_MS}; pass `force` (or
147
+ * see a media-API 403) to refresh. Also refreshes `sid` / `api_token`.
148
+ */
149
+ async loadUserData(force = false) {
150
+ var _a, _b, _c, _d, _e, _f, _g;
151
+ if (this.userData && !force && Date.now() - this.userDataAt < USER_DATA_TTL_MS) {
152
+ return this.userData;
153
+ }
154
+ const { data } = await this.http.get('https://www.deezer.com/ajax/gw-light.php', {
155
+ params: { method: 'deezer.getUserData', api_version: '1.0', api_token: 'null' },
156
+ });
157
+ const options = (_c = (_b = (_a = data.results) === null || _a === void 0 ? void 0 : _a.USER) === null || _b === void 0 ? void 0 : _b.OPTIONS) !== null && _c !== void 0 ? _c : {};
158
+ this.userData = {
159
+ licenseToken: options.license_token,
160
+ country: (_d = data.results) === null || _d === void 0 ? void 0 : _d.COUNTRY,
161
+ canStreamLossless: Boolean(options.web_lossless || options.mobile_loseless),
162
+ canStreamHq: Boolean(options.web_hq || options.mobile_hq),
163
+ offerId: (_e = data.results) === null || _e === void 0 ? void 0 : _e.OFFER_ID,
164
+ };
165
+ this.userDataAt = Date.now();
166
+ if ((_f = data.results) === null || _f === void 0 ? void 0 : _f.checkForm) {
167
+ this.http.defaults.params.api_token = data.results.checkForm;
168
+ }
169
+ if ((_g = data.results) === null || _g === void 0 ? void 0 : _g.SESSION_ID) {
170
+ this.http.defaults.params.sid = data.results.SESSION_ID;
171
+ }
172
+ return this.userData;
173
+ }
174
+ /** Drop the cached user data so the next `loadUserData` re-fetches. */
175
+ invalidateUserData() {
176
+ this.userData = null;
177
+ }
178
+ /**
179
+ * The bounded-retry gateway request loop. Handles `NEED_API_AUTH_REQUIRED`
180
+ * (re-init), `GATEWAY_ERROR` / `VALID_TOKEN_REQUIRED` (token refresh) and
181
+ * `code === 4` (transient), each with its own attempt cap, full-jittered
182
+ * backoff, and a wall-clock deadline. Throws `DeezerError` on exhaustion.
183
+ */
184
+ async request(method, url, body, config = {}) {
185
+ var _a;
186
+ const startedAt = Date.now();
187
+ let authReinits = 0;
188
+ let tokenRefreshes = 0;
189
+ let code4Attempts = 0;
190
+ // eslint-disable-next-line no-constant-condition
191
+ while (true) {
192
+ const response = method === 'POST' ? await this.http.post(url, body, config) : await this.http.get(url, config);
193
+ const error = (_a = response.data) === null || _a === void 0 ? void 0 : _a.error;
194
+ if (!error || Object.keys(error).length === 0) {
195
+ return response;
196
+ }
197
+ const overDeadline = Date.now() - startedAt > exports.RETRY_POLICY.deadlineMs;
198
+ if (error.NEED_API_AUTH_REQUIRED && authReinits < exports.RETRY_POLICY.authReinits && !overDeadline) {
199
+ authReinits += 1;
200
+ await this.init();
201
+ continue;
202
+ }
203
+ if ((error.GATEWAY_ERROR || error.VALID_TOKEN_REQUIRED) &&
204
+ tokenRefreshes < exports.RETRY_POLICY.tokenRefreshes &&
205
+ !overDeadline) {
206
+ tokenRefreshes += 1;
207
+ await this.refreshApiToken();
208
+ await (0, delay_1.default)(backoffDelay(tokenRefreshes - 1));
209
+ continue;
210
+ }
211
+ if (error.code === 4 && code4Attempts < exports.RETRY_POLICY.code4Attempts && !overDeadline) {
212
+ await (0, delay_1.default)(backoffDelay(code4Attempts));
213
+ code4Attempts += 1;
214
+ continue;
215
+ }
216
+ throw new errors_1.DeezerError(error);
217
+ }
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
+ }
331
+ }
332
+ exports.Session = Session;
333
+ let _default = new Session();
334
+ /** The process-wide default session every free function runs against. */
335
+ const defaultSession = () => _default;
336
+ exports.defaultSession = defaultSession;
337
+ /**
338
+ * (Re)authenticate the **default** session. Kept for backwards compatibility —
339
+ * `createSession` is the way to hold an isolated session. `arl` must be exactly
340
+ * 192 characters. Returns the gateway `SESSION` id.
341
+ */
342
+ const initDeezerApi = (arl) => _default.init(arl);
343
+ exports.initDeezerApi = initDeezerApi;
344
+ /**
345
+ * Create an **isolated** Deezer session — its own `arl`, `sid`, tokens and
346
+ * `license_token`, so multiple accounts can be used concurrently. Without an
347
+ * `arl` it runs on the bundled (shared, rate-limited) default.
348
+ */
349
+ const createSession = async (arl) => {
350
+ const session = new Session(arl);
351
+ await session.init();
352
+ return session;
353
+ };
354
+ exports.createSession = createSession;
355
+ /** Replace the default session (used by tests). */
356
+ const setDefaultSession = (session) => {
357
+ _default = session;
358
+ };
359
+ exports.setDefaultSession = setDefaultSession;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gerdur-core",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Core module for gerdur.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",