gerdur-core 2.2.0 → 2.4.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,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.0 - 2026-08-31
4
+
5
+ Phase 3.3 — deterministic retries and typed errors.
6
+
7
+ ### Fixed
8
+
9
+ - **The gateway retry loop had no cap** on `error.code === 4`,
10
+ `NEED_API_AUTH_REQUIRED`, or `GATEWAY_ERROR` — a failing endpoint could spin
11
+ forever. `requestWithRetry` is now a bounded loop: per-class attempt caps
12
+ (`code 4` ×6, re-auth ×3, token refresh ×15), full-jittered exponential
13
+ backoff, and a 30 s wall-clock deadline. On exhaustion it throws.
14
+
15
+ ### Added
16
+
17
+ - **`DeezerError`** (`extends Error`) — thrown for gateway / media-API failures
18
+ instead of `new Error(Object.entries(error).join(', '))`. Carries `code`,
19
+ `keys`, `retryable`, and the raw `payload`. `message` stays human-readable.
20
+ - **`RETRY_POLICY`** — the exported, inspectable retry configuration.
21
+
22
+ ### Changed
23
+
24
+ - Gateway helpers (`request`, `requestLight`, `requestGet`, `requestPublicApi`)
25
+ and `resolveDownloadUrls` now throw `DeezerError`. `message` text changed shape
26
+ (`"KEY: value"` rather than `"KEY,value"`); read `err.code` / `err.keys`
27
+ instead of matching the string.
28
+
29
+ ## 2.3.0 - 2026-08-31
30
+
31
+ Phase 2.1 — all the formats, and previews. Additive; the `1 / 3 / 9` path is
32
+ unchanged.
33
+
34
+ ### Added
35
+
36
+ - **`getTrackPreview(track)` / `downloadPreview(track)`** — the 30-second preview
37
+ clip. A plain MP3: no licence, no `arl`, no Blowfish. Accepts a gw track (reads
38
+ `MEDIA`, no extra request), a track id, or a number.
39
+ - **`DEEZER_FORMATS`** — every format `get_url` understands, best → worst
40
+ (`FLAC`, `MP3_320`, `MP3_256`, `MP3_128`, `MP3_64`, `AAC_64`, `MP4_RA3/2/1`).
41
+ - **`resolveDownloadUrls(tracks, qualities)`** now accepts format strings in the
42
+ preference list (`['FLAC', 'MP3_320', 'AAC_64']`), not just `1 | 3 | 9`. Each
43
+ `ResolvedUrl` gains `cipher` (`'BF_CBC_STRIPE'` | `'NONE'`).
44
+ - **`toFormat(quality)`** — normalise a number or format string to the `get_url`
45
+ format string. `formatName` now also accepts format strings (identity).
46
+ - `Quality` / `DeezerFormat` exported types.
47
+
48
+ ### Changed
49
+
50
+ - `isEncrypted` (from `getTrackDownloadUrl` and `resolveDownloadUrls`) is now
51
+ taken from the media API's `cipher` field instead of guessing from the URL
52
+ path — authoritative, and correct for `cipher: NONE` content.
53
+ - `trackType.FILESIZE_*` fields are typed `string` (several were mistyped as the
54
+ literal `'0'`); added `FILESIZE_MP3_MISC` / `FILESIZE_MHM1_RA*`.
55
+
3
56
  ## 2.2.0 - 2026-08-31
4
57
 
5
58
  Phase 2.4 — browse & discovery. All additive, all on the public REST API (no
package/README.md CHANGED
@@ -82,7 +82,20 @@ if (model.lyricsSynced) fs.writeFileSync(track.SNG_TITLE + '.lrc', model.lyricsS
82
82
 
83
83
  ## Methods
84
84
 
85
- All method returns `Object` or throws `Error`. Make sure to catch error on your side.
85
+ Every method returns an `Object` or throws. Gateway / media-API failures throw a
86
+ **`DeezerError`** (`extends Error`) with:
87
+
88
+ | Field | |
89
+ | :--- | :--- |
90
+ | `code` | Deezer's numeric error code, when it sent one (e.g. `4`, `800`) |
91
+ | `keys` | the gateway error keys, e.g. `['VALID_TOKEN_REQUIRED']`, `['DATA_ERROR']` |
92
+ | `retryable` | whether a retry could plausibly succeed |
93
+ | `payload` | the raw error object |
94
+
95
+ Retries are bounded — `RETRY_POLICY` (exported) sets per-class attempt caps and a
96
+ 30 s wall-clock deadline, so a persistently failing endpoint surfaces a
97
+ `DeezerError` instead of spinning. `GeoBlocked`, `WrongLicense` and
98
+ `ExpiredTrackToken` are still thrown as their own types from the download path.
86
99
 
87
100
  ### `.initDeezerApi(arl_cookie);`
88
101
 
@@ -238,6 +251,37 @@ pass the `id` to `getTrackInfo` / `getAlbumTracks` (or use the converter's
238
251
  | `track` | Yes | `string` | track object |
239
252
  | `quality` | Yes | `1, 3 or 9` | 1 = 128kbps, 3 = 320kbps, 9 = flac |
240
253
 
254
+ Resolves `{trackUrl, isEncrypted, fileSize}`. `isEncrypted` now comes from the
255
+ media API's `cipher` field (authoritative) rather than a URL guess.
256
+
257
+ ### Formats
258
+
259
+ Deezer's `get_url` understands more than `1 / 3 / 9`. `DEEZER_FORMATS` lists them
260
+ best → worst: `FLAC`, `MP3_320`, `MP3_256`, `MP3_128`, `MP3_64`, `AAC_64`,
261
+ `MP4_RA3`, `MP4_RA2`, `MP4_RA1` (the last four are the HE-AAC ladder some
262
+ accounts / regions expose).
263
+
264
+ - **`resolveDownloadUrls(tracks, qualities)`** — `qualities` entries may be the
265
+ `1 | 3 | 9` shorthand **or** any format string, e.g.
266
+ `resolveDownloadUrls(tracks, ['FLAC', 'MP3_320', 'AAC_64'])`. Deezer returns
267
+ the best each track is licensed for. Each result now also carries `format` and
268
+ `cipher` (`'BF_CBC_STRIPE'` or `'NONE'`).
269
+ - **`formatName(quality)`** / **`toFormat(quality)`** — normalise a number or
270
+ format string to the `get_url` format string.
271
+
272
+ ### `.getTrackPreview(track)` / `.downloadPreview(track)`
273
+
274
+ The 30-second preview clip — a plain MP3, **no licence, no `arl`, no
275
+ encryption**. `track` may be a gw track object (reads its `MEDIA`, no extra
276
+ request), a track id, or a number.
277
+
278
+ ```js
279
+ const {url} = await getTrackPreview('3135556'); // {url, duration: 30}
280
+ const clip = await downloadPreview('3135556'); // Buffer (ID3-tagged MP3)
281
+ ```
282
+
283
+ Useful for "audition before download" and for CI that shouldn't pull full tracks.
284
+
241
285
  ### `.decryptDownload(data, song_id);`
242
286
 
243
287
  | Parameters | Required | Type | Description |
@@ -2,3 +2,4 @@ export * from './api';
2
2
  export * from './request';
3
3
  export * from './search';
4
4
  export * from './browse';
5
+ export * from './preview';
package/dist/api/index.js CHANGED
@@ -18,3 +18,4 @@ __exportStar(require("./api"), exports);
18
18
  __exportStar(require("./request"), exports);
19
19
  __exportStar(require("./search"), exports);
20
20
  __exportStar(require("./browse"), exports);
21
+ __exportStar(require("./preview"), exports);
@@ -0,0 +1,21 @@
1
+ /// <reference types="node" />
2
+ import type { trackType } from '../types';
3
+ export interface TrackPreview {
4
+ /** direct URL to a ~30 s MP3 clip — no licence, no encryption, no `arl` */
5
+ url: string;
6
+ /** clip length in seconds (Deezer previews are 30 s) */
7
+ duration: number;
8
+ }
9
+ /**
10
+ * The 30-second preview clip for a track. Deezer exposes it two ways:
11
+ * `song.getData` carries it in `MEDIA` (`{TYPE: 'preview', HREF}`), and the
12
+ * public `/track/` endpoint carries it as `preview`. Pass a gw `track` object
13
+ * (uses `MEDIA`, no extra request) or a track id (one public-API lookup).
14
+ *
15
+ * The clip is a plain MP3 — never Blowfish-encrypted — so it needs no
16
+ * `decryptDownload` and is safe to use in tests and "audition before download"
17
+ * flows.
18
+ */
19
+ export declare const getTrackPreview: (track: trackType | string | number) => Promise<TrackPreview | null>;
20
+ /** Fetch the 30-second preview clip as a `Buffer` (plain MP3, no decryption needed). */
21
+ export declare const downloadPreview: (track: trackType | string | number) => Promise<Buffer | null>;
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.downloadPreview = exports.getTrackPreview = void 0;
4
+ const http_1 = require("../lib/http");
5
+ const api_1 = require("./api");
6
+ /**
7
+ * The 30-second preview clip for a track. Deezer exposes it two ways:
8
+ * `song.getData` carries it in `MEDIA` (`{TYPE: 'preview', HREF}`), and the
9
+ * public `/track/` endpoint carries it as `preview`. Pass a gw `track` object
10
+ * (uses `MEDIA`, no extra request) or a track id (one public-API lookup).
11
+ *
12
+ * The clip is a plain MP3 — never Blowfish-encrypted — so it needs no
13
+ * `decryptDownload` and is safe to use in tests and "audition before download"
14
+ * flows.
15
+ */
16
+ const getTrackPreview = async (track) => {
17
+ var _a;
18
+ if (typeof track === 'object') {
19
+ const media = (_a = track.MEDIA) === null || _a === void 0 ? void 0 : _a[0];
20
+ if ((media === null || media === void 0 ? void 0 : media.HREF) && (!media.TYPE || media.TYPE === 'preview')) {
21
+ return { url: media.HREF, duration: 30 };
22
+ }
23
+ const pub = await (0, api_1.getTrackInfoPublicApi)(track.SNG_ID);
24
+ return pub.preview ? { url: pub.preview, duration: 30 } : null;
25
+ }
26
+ const pub = await (0, api_1.getTrackInfoPublicApi)(String(track));
27
+ return pub.preview ? { url: pub.preview, duration: 30 } : null;
28
+ };
29
+ exports.getTrackPreview = getTrackPreview;
30
+ /** Fetch the 30-second preview clip as a `Buffer` (plain MP3, no decryption needed). */
31
+ const downloadPreview = async (track) => {
32
+ const preview = await (0, exports.getTrackPreview)(track);
33
+ if (!preview)
34
+ return null;
35
+ return (0, http_1.getBuffer)(preview.url);
36
+ };
37
+ exports.downloadPreview = downloadPreview;
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.requestPublicApi = exports.requestGet = exports.requestLight = exports.request = void 0;
7
7
  const request_1 = __importDefault(require("../lib/request"));
8
+ const errors_1 = require("../lib/errors");
8
9
  const cache_1 = __importDefault(require("./cache"));
9
10
  /**
10
11
  * In-flight request coalescing (single-flight).
@@ -47,11 +48,11 @@ const request = async (body, method) => {
47
48
  const cacheKey = method + ':' + Object.entries(body).join(':');
48
49
  return coalesce(cacheKey, async () => {
49
50
  const { data: { error, results }, } = await request_1.default.post('/gateway.php', body, { params: { method } });
50
- if (Object.keys(results).length > 0) {
51
+ if (results && Object.keys(results).length > 0) {
51
52
  cache_1.default.set(cacheKey, results);
52
53
  return results;
53
54
  }
54
- throw new Error(Object.entries(error).join(', '));
55
+ throw new errors_1.DeezerError(error);
55
56
  });
56
57
  };
57
58
  exports.request = request;
@@ -66,11 +67,11 @@ const requestLight = async (body, method) => {
66
67
  const { data: { error, results }, } = await request_1.default.post('https://www.deezer.com/ajax/gw-light.php', body, {
67
68
  params: { method, api_version: '1.0' },
68
69
  });
69
- if (Object.keys(results).length > 0) {
70
+ if (results && Object.keys(results).length > 0) {
70
71
  cache_1.default.set(cacheKey, results);
71
72
  return results;
72
73
  }
73
- throw new Error(Object.entries(error).join(', '));
74
+ throw new errors_1.DeezerError(error);
74
75
  });
75
76
  };
76
77
  exports.requestLight = requestLight;
@@ -83,11 +84,11 @@ const requestGet = async (method, params = {}, key = 'get_request') => {
83
84
  const cacheKey = method + key;
84
85
  return coalesce(cacheKey, async () => {
85
86
  const { data: { error, results }, } = await request_1.default.get('/gateway.php', { params: { method, ...params } });
86
- if (Object.keys(results).length > 0) {
87
+ if (results && Object.keys(results).length > 0) {
87
88
  cache_1.default.set(cacheKey, results);
88
89
  return results;
89
90
  }
90
- throw new Error(Object.entries(error).join(', '));
91
+ throw new errors_1.DeezerError(error);
91
92
  });
92
93
  };
93
94
  exports.requestGet = requestGet;
@@ -99,8 +100,7 @@ const requestPublicApi = async (slug) => {
99
100
  return coalesce(slug, async () => {
100
101
  const { data } = await request_1.default.get('https://api.deezer.com' + slug);
101
102
  if (data.error) {
102
- const errorMessage = Object.entries(data.error).join(', ');
103
- throw new Error(errorMessage);
103
+ throw new errors_1.DeezerError(data.error);
104
104
  }
105
105
  cache_1.default.set(slug, data);
106
106
  return data;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
- export { initDeezerApi } from './lib/request';
1
+ export { initDeezerApi, RETRY_POLICY } from './lib/request';
2
+ export { DeezerError } from './lib/errors';
3
+ export type { DeezerErrorPayload } from './lib/errors';
2
4
  export * from './api';
3
5
  export * from './converter';
4
6
  export * from './lib/decrypt';
package/dist/index.js CHANGED
@@ -14,9 +14,12 @@ 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.getText = exports.getJson = exports.getBuffer = exports.httpsAgent = exports.httpAgent = exports.initDeezerApi = void 0;
17
+ exports.getText = exports.getJson = exports.getBuffer = exports.httpsAgent = exports.httpAgent = exports.DeezerError = exports.RETRY_POLICY = exports.initDeezerApi = void 0;
18
18
  var request_1 = require("./lib/request");
19
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; } });
21
+ var errors_1 = require("./lib/errors");
22
+ Object.defineProperty(exports, "DeezerError", { enumerable: true, get: function () { return errors_1.DeezerError; } });
20
23
  __exportStar(require("./api"), exports);
21
24
  __exportStar(require("./converter"), exports);
22
25
  __exportStar(require("./lib/decrypt"), exports);
@@ -0,0 +1,27 @@
1
+ /** The raw `error` object a Deezer gateway response carries. */
2
+ export type DeezerErrorPayload = Record<string, unknown>;
3
+ /**
4
+ * A structured error from the Deezer gateway or media API — replaces the old
5
+ * `new Error(Object.entries(error).join(', '))`. Carries the numeric `code`, the
6
+ * gateway error `keys`, a `retryable` hint, and the raw `payload`.
7
+ *
8
+ * The `message` is still human-readable (`"VALID_TOKEN_REQUIRED: …"`), so code
9
+ * that only reads `err.message` keeps working — but prefer `err.code` /
10
+ * `err.keys` / `err.retryable`.
11
+ */
12
+ export declare class DeezerError extends Error {
13
+ /** `error.code` when Deezer sent a numeric one */
14
+ readonly code?: number;
15
+ /** the gateway error keys, e.g. `['VALID_TOKEN_REQUIRED']`, `['DATA_ERROR']` */
16
+ readonly keys: string[];
17
+ /** whether a retry could plausibly succeed */
18
+ readonly retryable: boolean;
19
+ /** the raw error payload */
20
+ readonly payload: DeezerErrorPayload;
21
+ constructor(payload: DeezerErrorPayload | null | undefined, opts?: {
22
+ retryable?: boolean;
23
+ message?: string;
24
+ });
25
+ /** Whether a `code` / key set is worth retrying. */
26
+ static retryable(code: number | undefined, keys: string[]): boolean;
27
+ }
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DeezerError = void 0;
4
+ /** Gateway error keys where retrying (after a token refresh / re-auth) can help. */
5
+ const RETRYABLE_KEYS = new Set(['NEED_API_AUTH_REQUIRED', 'GATEWAY_ERROR', 'VALID_TOKEN_REQUIRED']);
6
+ /**
7
+ * A structured error from the Deezer gateway or media API — replaces the old
8
+ * `new Error(Object.entries(error).join(', '))`. Carries the numeric `code`, the
9
+ * gateway error `keys`, a `retryable` hint, and the raw `payload`.
10
+ *
11
+ * The `message` is still human-readable (`"VALID_TOKEN_REQUIRED: …"`), so code
12
+ * that only reads `err.message` keeps working — but prefer `err.code` /
13
+ * `err.keys` / `err.retryable`.
14
+ */
15
+ class DeezerError extends Error {
16
+ constructor(payload, opts = {}) {
17
+ var _a, _b;
18
+ const p = payload && typeof payload === 'object' ? payload : {};
19
+ const keys = Object.keys(p);
20
+ const rawCode = p.code;
21
+ const code = typeof rawCode === 'number' ? rawCode : undefined;
22
+ super((_a = opts.message) !== null && _a !== void 0 ? _a : describe(p, keys));
23
+ this.name = 'DeezerError';
24
+ this.code = code;
25
+ this.keys = keys;
26
+ this.payload = p;
27
+ this.retryable = (_b = opts.retryable) !== null && _b !== void 0 ? _b : isRetryable(code, keys);
28
+ }
29
+ /** Whether a `code` / key set is worth retrying. */
30
+ static retryable(code, keys) {
31
+ return isRetryable(code, keys);
32
+ }
33
+ }
34
+ exports.DeezerError = DeezerError;
35
+ const describe = (payload, keys) => {
36
+ if (!keys.length)
37
+ return 'Deezer request failed';
38
+ return keys.map((k) => `${k}: ${String(payload[k])}`).join(', ');
39
+ };
40
+ const isRetryable = (code, keys) => {
41
+ if (code === 4)
42
+ return true; // Deezer's transient "quota exceeded"
43
+ return keys.some((k) => RETRYABLE_KEYS.has(k));
44
+ };
@@ -13,8 +13,24 @@ export declare class ExpiredTrackToken extends Error {
13
13
  readonly sngId: string;
14
14
  constructor(sngId: string);
15
15
  }
16
- /** quality code -> the `format` string the media API expects */
17
- export declare const formatName: (quality: number) => string;
16
+ /**
17
+ * Every audio format Deezer's `get_url` understands, best → worst. `FLAC`,
18
+ * `MP3_320` and `MP3_128` are the classic `9 / 3 / 1` qualities; the rest are
19
+ * additional tiers (`MP3_256`, `MP3_64`, the `AAC_64` / `MP4_RA*` HE-AAC ladder)
20
+ * that some accounts and regions expose.
21
+ */
22
+ export declare const DEEZER_FORMATS: readonly ["FLAC", "MP3_320", "MP3_256", "MP3_128", "MP3_64", "AAC_64", "MP4_RA3", "MP4_RA2", "MP4_RA1"];
23
+ export type DeezerFormat = (typeof DEEZER_FORMATS)[number];
24
+ /** A quality request: the numeric `1 | 3 | 9` shorthand, or any format string. */
25
+ export type Quality = number | DeezerFormat | string;
26
+ /** Normalise a {@link Quality} (number shorthand or format string) to a format string. */
27
+ export declare const toFormat: (quality: Quality) => string;
28
+ /**
29
+ * Quality code (`1 | 3 | 9`) or format string → the `format` string the media
30
+ * API expects. Format strings pass straight through, so this doubles as a
31
+ * validator-free identity for `'AAC_64'`, `'MP3_256'`, …
32
+ */
33
+ export declare const formatName: (quality: Quality) => string;
18
34
  /**
19
35
  * @param track Track info json returned from `getTrackInfo`
20
36
  * @param quality 1 = 128kbps, 3 = 320kbps and 9 = flac (around 1411kbps)
@@ -28,18 +44,21 @@ export interface ResolvedUrl {
28
44
  trackUrl: string;
29
45
  isEncrypted: boolean;
30
46
  fileSize: number;
31
- /** the format Deezer actually returned, e.g. `'FLAC'` */
47
+ /** the format Deezer actually returned, e.g. `'FLAC'`, `'MP3_128'`, `'AAC_64'` */
32
48
  format: string;
49
+ /** the cipher Deezer applied — `'BF_CBC_STRIPE'` (stripe-encrypted) or `'NONE'` */
50
+ cipher: string;
33
51
  }
34
52
  /**
35
53
  * Resolve download URLs for many tracks in a **single** `get_url` request.
36
54
  *
37
- * `qualities` is an ordered preference list (e.g. `[9, 3, 1]`): Deezer returns
38
- * the best each track is licensed for, so there is no per-quality retry. The
39
- * result has one entry per input track, in order; `null` for a track that is
40
- * geo-blocked, unavailable, or errored.
55
+ * `qualities` is an ordered preference list: Deezer returns the best each track
56
+ * is licensed for, so there is no per-quality retry. Entries may be the numeric
57
+ * `1 | 3 | 9` shorthand **or** any format string from {@link DEEZER_FORMATS}
58
+ * (e.g. `['FLAC', 'MP3_320', 'AAC_64']`). The result has one entry per input
59
+ * track, in order; `null` for a track that is geo-blocked, unavailable, or errored.
41
60
  *
42
61
  * @param tracks from `getTrackInfo` / `parseInfo` (needs `TRACK_TOKEN`, `SNG_ID`, `FILESIZE_*`)
43
- * @param qualities preference order — 9 = FLAC, 3 = MP3 320, 1 = MP3 128
62
+ * @param qualities preference order — default `[9, 3, 1]` (FLAC MP3 320 MP3 128)
44
63
  */
45
- export declare const resolveDownloadUrls: (tracks: trackType[], qualities?: number[]) => Promise<(ResolvedUrl | null)[]>;
64
+ export declare const resolveDownloadUrls: (tracks: trackType[], qualities?: Quality[]) => Promise<(ResolvedUrl | null)[]>;
@@ -3,9 +3,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.resolveDownloadUrls = exports.getTrackDownloadUrl = exports.formatName = exports.ExpiredTrackToken = exports.GeoBlocked = exports.WrongLicense = void 0;
6
+ exports.resolveDownloadUrls = exports.getTrackDownloadUrl = exports.formatName = exports.toFormat = exports.DEEZER_FORMATS = exports.ExpiredTrackToken = exports.GeoBlocked = exports.WrongLicense = void 0;
7
7
  const delay_1 = __importDefault(require("delay"));
8
8
  const decrypt_1 = require("../lib/decrypt");
9
+ const errors_1 = require("../lib/errors");
9
10
  const http_1 = require("../lib/http");
10
11
  const request_1 = __importDefault(require("../lib/request"));
11
12
  class WrongLicense extends Error {
@@ -37,17 +38,50 @@ class ExpiredTrackToken extends Error {
37
38
  }
38
39
  exports.ExpiredTrackToken = ExpiredTrackToken;
39
40
  let user_data = null;
40
- const getTrackFileSize = (track, quality) => {
41
- switch (quality) {
42
- case 9:
43
- return Number(track.FILESIZE_FLAC);
44
- case 3:
45
- return Number(track.FILESIZE_MP3_320);
46
- case 1:
47
- return Number(track.FILESIZE_MP3_128);
48
- default:
49
- return 0;
41
+ /**
42
+ * Every audio format Deezer's `get_url` understands, best → worst. `FLAC`,
43
+ * `MP3_320` and `MP3_128` are the classic `9 / 3 / 1` qualities; the rest are
44
+ * additional tiers (`MP3_256`, `MP3_64`, the `AAC_64` / `MP4_RA*` HE-AAC ladder)
45
+ * that some accounts and regions expose.
46
+ */
47
+ exports.DEEZER_FORMATS = [
48
+ 'FLAC',
49
+ 'MP3_320',
50
+ 'MP3_256',
51
+ 'MP3_128',
52
+ 'MP3_64',
53
+ 'AAC_64',
54
+ 'MP4_RA3',
55
+ 'MP4_RA2',
56
+ 'MP4_RA1',
57
+ ];
58
+ const FORMAT_FILESIZE_KEY = {
59
+ FLAC: 'FILESIZE_FLAC',
60
+ MP3_320: 'FILESIZE_MP3_320',
61
+ MP3_256: 'FILESIZE_MP3_256',
62
+ MP3_128: 'FILESIZE_MP3_128',
63
+ MP3_64: 'FILESIZE_MP3_64',
64
+ MP3_MISC: 'FILESIZE_MP3_MISC',
65
+ AAC_64: 'FILESIZE_AAC_64',
66
+ MP4_RA1: 'FILESIZE_MP4_RA1',
67
+ MP4_RA2: 'FILESIZE_MP4_RA2',
68
+ MP4_RA3: 'FILESIZE_MP4_RA3',
69
+ };
70
+ const NUMERIC_FORMAT = { 9: 'FLAC', 3: 'MP3_320', 1: 'MP3_128' };
71
+ /** Normalise a {@link Quality} (number shorthand or format string) to a format string. */
72
+ const toFormat = (quality) => {
73
+ if (typeof quality === 'number') {
74
+ const f = NUMERIC_FORMAT[quality];
75
+ if (!f)
76
+ throw new Error(`Unknown quality ${quality}`);
77
+ return f;
50
78
  }
79
+ return quality;
80
+ };
81
+ exports.toFormat = toFormat;
82
+ const getTrackFileSize = (track, quality) => {
83
+ const key = FORMAT_FILESIZE_KEY[(0, exports.toFormat)(quality)];
84
+ return key ? Number(track[key]) || 0 : 0;
51
85
  };
52
86
  const dzAuthenticate = async () => {
53
87
  const { data } = await request_1.default.get('https://www.deezer.com/ajax/gw-light.php', {
@@ -66,19 +100,12 @@ const dzAuthenticate = async () => {
66
100
  return user_data;
67
101
  };
68
102
  const MEDIA_MAX_RETRIES = 3;
69
- /** quality code -> the `format` string the media API expects */
70
- const formatName = (quality) => {
71
- switch (quality) {
72
- case 9:
73
- return 'FLAC';
74
- case 3:
75
- return 'MP3_320';
76
- case 1:
77
- return 'MP3_128';
78
- default:
79
- throw new Error(`Unknown quality ${quality}`);
80
- }
81
- };
103
+ /**
104
+ * Quality code (`1 | 3 | 9`) or format string → the `format` string the media
105
+ * API expects. Format strings pass straight through, so this doubles as a
106
+ * validator-free identity for `'AAC_64'`, `'MP3_256'`, …
107
+ */
108
+ const formatName = (quality) => (0, exports.toFormat)(quality);
82
109
  exports.formatName = formatName;
83
110
  /** POST media.deezer.com/v1/get_url with re-auth + exponential-backoff retry on 403/429/5xx. */
84
111
  const mediaGetUrl = async (track_tokens, formats, attempt = 0) => {
@@ -104,22 +131,23 @@ const mediaGetUrl = async (track_tokens, formats, attempt = 0) => {
104
131
  };
105
132
  /** Parse one `data[i]` entry from a get_url response into a source URL (or null / throw). */
106
133
  const parseMediaEntry = (entry, token, country) => {
107
- var _a, _b;
134
+ var _a, _b, _c, _d;
108
135
  if (entry === null || entry === void 0 ? void 0 : entry.errors) {
109
136
  const { code } = entry.errors[0];
110
137
  if (code === 2002)
111
138
  throw new GeoBlocked(country);
112
139
  if (code === 2000 || code === 2001)
113
140
  throw new ExpiredTrackToken(token);
114
- throw new Error(Object.entries(entry.errors[0]).join(', '));
141
+ throw new errors_1.DeezerError(entry.errors[0]);
115
142
  }
116
143
  const media = (_a = entry === null || entry === void 0 ? void 0 : entry.media) === null || _a === void 0 ? void 0 : _a[0];
117
144
  if (!((_b = media === null || media === void 0 ? void 0 : media.sources) === null || _b === void 0 ? void 0 : _b.length))
118
145
  return null;
119
- return { url: media.sources[0].url, format: media.format };
146
+ return { url: media.sources[0].url, format: media.format, cipher: (_d = (_c = media.cipher) === null || _c === void 0 ? void 0 : _c.type) !== null && _d !== void 0 ? _d : 'BF_CBC_STRIPE' };
120
147
  };
148
+ /** Whether a track fetched with the given cipher needs Blowfish stripe decryption. */
149
+ const cipherIsEncrypted = (cipher, url) => cipher ? cipher !== 'NONE' : url.includes('/mobile/') || url.includes('/media/');
121
150
  const getTrackUrlFromServer = async (track_token, format) => {
122
- var _a, _b;
123
151
  const user = user_data ? user_data : await dzAuthenticate();
124
152
  if ((format === 'FLAC' && !user.can_stream_lossless) || (format === 'MP3_320' && !user.can_stream_hq)) {
125
153
  throw new WrongLicense(format);
@@ -127,7 +155,7 @@ const getTrackUrlFromServer = async (track_token, format) => {
127
155
  const { data, country } = await mediaGetUrl([track_token], [{ format, cipher: 'BF_CBC_STRIPE' }]);
128
156
  if (!data.length)
129
157
  return null;
130
- return (_b = (_a = parseMediaEntry(data[0], track_token, country)) === null || _a === void 0 ? void 0 : _a.url) !== null && _b !== void 0 ? _b : null;
158
+ return parseMediaEntry(data[0], track_token, country);
131
159
  };
132
160
  /**
133
161
  * @param track Track info json returned from `getTrackInfo`
@@ -148,11 +176,11 @@ const getTrackDownloadUrl = async (track, quality) => {
148
176
  else {
149
177
  // Get URL with the official API
150
178
  try {
151
- const url = await getTrackUrlFromServer(track.TRACK_TOKEN, format);
152
- if (url) {
179
+ const resolved = await getTrackUrlFromServer(track.TRACK_TOKEN, format);
180
+ if (resolved) {
153
181
  return {
154
- trackUrl: url,
155
- isEncrypted: url.includes('/mobile/') || url.includes('/media/'),
182
+ trackUrl: resolved.url,
183
+ isEncrypted: cipherIsEncrypted(resolved.cipher, resolved.url),
156
184
  fileSize: getTrackFileSize(track, quality),
157
185
  };
158
186
  }
@@ -213,17 +241,17 @@ const testUrl = async (url) => {
213
241
  return 0;
214
242
  }
215
243
  };
216
- const QUALITY_OF_FORMAT = { FLAC: 9, MP3_320: 3, MP3_128: 1, MP3_256: 3, MP3_64: 1 };
217
244
  /**
218
245
  * Resolve download URLs for many tracks in a **single** `get_url` request.
219
246
  *
220
- * `qualities` is an ordered preference list (e.g. `[9, 3, 1]`): Deezer returns
221
- * the best each track is licensed for, so there is no per-quality retry. The
222
- * result has one entry per input track, in order; `null` for a track that is
223
- * geo-blocked, unavailable, or errored.
247
+ * `qualities` is an ordered preference list: Deezer returns the best each track
248
+ * is licensed for, so there is no per-quality retry. Entries may be the numeric
249
+ * `1 | 3 | 9` shorthand **or** any format string from {@link DEEZER_FORMATS}
250
+ * (e.g. `['FLAC', 'MP3_320', 'AAC_64']`). The result has one entry per input
251
+ * track, in order; `null` for a track that is geo-blocked, unavailable, or errored.
224
252
  *
225
253
  * @param tracks from `getTrackInfo` / `parseInfo` (needs `TRACK_TOKEN`, `SNG_ID`, `FILESIZE_*`)
226
- * @param qualities preference order — 9 = FLAC, 3 = MP3 320, 1 = MP3 128
254
+ * @param qualities preference order — default `[9, 3, 1]` (FLAC MP3 320 MP3 128)
227
255
  */
228
256
  const resolveDownloadUrls = async (tracks, qualities = [9, 3, 1]) => {
229
257
  if (!tracks.length)
@@ -231,7 +259,6 @@ const resolveDownloadUrls = async (tracks, qualities = [9, 3, 1]) => {
231
259
  const formats = qualities.map((q) => ({ format: (0, exports.formatName)(q), cipher: 'BF_CBC_STRIPE' }));
232
260
  const { data, country } = await mediaGetUrl(tracks.map((t) => t.TRACK_TOKEN), formats);
233
261
  return tracks.map((track, i) => {
234
- var _a;
235
262
  let parsed;
236
263
  try {
237
264
  parsed = parseMediaEntry(data[i], track.TRACK_TOKEN, country);
@@ -242,12 +269,12 @@ const resolveDownloadUrls = async (tracks, qualities = [9, 3, 1]) => {
242
269
  }
243
270
  if (!parsed)
244
271
  return null;
245
- const q = (_a = QUALITY_OF_FORMAT[parsed.format]) !== null && _a !== void 0 ? _a : qualities[0];
246
272
  return {
247
273
  trackUrl: parsed.url,
248
- isEncrypted: parsed.url.includes('/mobile/') || parsed.url.includes('/media/'),
249
- fileSize: getTrackFileSize(track, q),
274
+ isEncrypted: cipherIsEncrypted(parsed.cipher, parsed.url),
275
+ fileSize: getTrackFileSize(track, parsed.format),
250
276
  format: parsed.format,
277
+ cipher: parsed.cipher,
251
278
  };
252
279
  });
253
280
  };
@@ -3,6 +3,26 @@ type DeezerRequestConfig = {
3
3
  headers?: Record<string, string>;
4
4
  params?: HttpQuery;
5
5
  };
6
+ /**
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`).
11
+ */
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
+ };
6
26
  export declare const initDeezerApi: (arl: string) => Promise<string>;
7
27
  declare const _default: {
8
28
  defaults: {
@@ -3,10 +3,36 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.initDeezerApi = void 0;
6
+ exports.initDeezerApi = exports.RETRY_POLICY = void 0;
7
7
  const delay_1 = __importDefault(require("delay"));
8
8
  const http_1 = require("./http");
9
+ const errors_1 = require("./errors");
9
10
  let user_arl = 'c973964816688562722418b5200c1515dffaad15a42643ebf87cc72824a54612ec51c2ad42d566743f9e424c774e98ccae7737770acff59251328e6cd598c7bcac38ca269adf78bfb88ec5bbad6cd800db3c0b88b2af645bb22b99e71de26416';
11
+ /**
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`).
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
+ };
10
36
  const instance = new http_1.HttpClient({
11
37
  baseURL: 'https://api.deezer.com/1.0',
12
38
  timeout: 15000,
@@ -54,30 +80,41 @@ const initDeezerApi = async (arl) => {
54
80
  return data.results.SESSION;
55
81
  };
56
82
  exports.initDeezerApi = initDeezerApi;
57
- let token_retry = 0;
58
83
  const requestWithRetry = async (method, url, body, config = {}) => {
59
84
  var _a;
60
- const response = method === 'POST' ? await instance.post(url, body, config) : await instance.get(url, config);
61
- const error = (_a = response.data) === null || _a === void 0 ? void 0 : _a.error;
62
- if (!error || Object.keys(error).length === 0) {
63
- token_retry = 0;
64
- return response;
65
- }
66
- if (error.NEED_API_AUTH_REQUIRED) {
67
- await (0, exports.initDeezerApi)(user_arl);
68
- return await requestWithRetry(method, url, body, config);
69
- }
70
- if (error.code === 4) {
71
- await delay_1.default.range(1000, 1500);
72
- return await requestWithRetry(method, url, body, config);
73
- }
74
- if (error.GATEWAY_ERROR || (error.VALID_TOKEN_REQUIRED && token_retry < 15)) {
75
- token_retry += 1;
76
- await getApiToken();
77
- return await requestWithRetry(method, url, body, config);
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);
78
117
  }
79
- token_retry = 0;
80
- return response;
81
118
  };
82
119
  exports.default = {
83
120
  defaults: instance.defaults,
@@ -1,6 +1,6 @@
1
1
  import type { artistType } from './artist';
2
2
  interface mediaType {
3
- TYPE: 'preview';
3
+ TYPE: 'preview' | string;
4
4
  HREF: string;
5
5
  }
6
6
  export interface lyricsSync {
@@ -59,15 +59,19 @@ interface songType {
59
59
  URL_REWRITING: string;
60
60
  VERSION?: string;
61
61
  MD5_ORIGIN?: string;
62
- FILESIZE_AAC_64: '0';
62
+ FILESIZE_AAC_64: string;
63
63
  FILESIZE_MP3_64: string;
64
64
  FILESIZE_MP3_128: string;
65
- FILESIZE_MP3_256: '0';
66
- FILESIZE_MP3_320: '0';
67
- FILESIZE_MP4_RA1: '0';
68
- FILESIZE_MP4_RA2: '0';
69
- FILESIZE_MP4_RA3: '0';
70
- FILESIZE_FLAC: '0';
65
+ FILESIZE_MP3_256: string;
66
+ FILESIZE_MP3_320: string;
67
+ FILESIZE_MP3_MISC?: string;
68
+ FILESIZE_MP4_RA1: string;
69
+ FILESIZE_MP4_RA2: string;
70
+ FILESIZE_MP4_RA3: string;
71
+ FILESIZE_MHM1_RA1?: string;
72
+ FILESIZE_MHM1_RA2?: string;
73
+ FILESIZE_MHM1_RA3?: string;
74
+ FILESIZE_FLAC: string;
71
75
  FILESIZE: string;
72
76
  GAIN: string;
73
77
  MEDIA_VERSION: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gerdur-core",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "Core module for gerdur.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",