gerdur-core 2.7.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,28 @@
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
+
3
26
  ## 2.7.0 - 2026-08-31
4
27
 
5
28
  Phase 3.2 — session lifecycle. The scattered module-level state
package/README.md CHANGED
@@ -114,13 +114,30 @@ 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
+ | `gw(body, method)` / `gwLight(body, method)` / `gwGet(method, params?)` | the raw coalesced request channels |
131
+ | `init(arl?)` / `refreshApiToken()` / `loadUserData(force?)` / `invalidateUserData()` | lifecycle |
132
+
117
133
  ```js
118
134
  await initDeezerApi(arl); // default session, as before
119
135
  const track = await getTrackInfo('3135556');
120
136
 
121
- const s = await createSession(otherArl); // a second account, isolated
137
+ const s = await createSession(otherArl); // a second account, fully isolated
122
138
  await s.loadUserData();
123
139
  console.log(s.country, s.canStreamLossless);
140
+ const mine = await s.getTrackInfo('3135556'); // runs against `otherArl`
124
141
  ```
125
142
 
126
143
  ### `.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 {};
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gerdur-core",
3
- "version": "2.7.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",