gerdur-core 2.4.0 → 2.6.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,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.6.0 - 2026-08-31
4
+
5
+ Phase 2.4 round 2 — Flow, radios, and a user's library. All public REST, all
6
+ memoised, all additive.
7
+
8
+ ### Added
9
+
10
+ - **Flow / library** (take a `userId`): `getUserFlow`, `getUserFavoriteTracks`
11
+ (with `time_add`), `getUserFavoriteAlbums`, `getUserFavoriteArtists`,
12
+ `getUserPlaylists`, `getUserRadios`, `getUserChartTracks`.
13
+ - **Radios**: `getRadios`, `getRadioTracks(radioId)`, `getRadioGenres`.
14
+ - New types: `userFavoriteTrack`, `userFavoriteAlbum`, `userFavoriteArtist`,
15
+ `userPlaylistResult`, `radioResult`, `radioGenre`.
16
+
17
+ ### Internal
18
+
19
+ - Tests run under `ts-node/register/transpile-only` (build still type-checks) —
20
+ the growing live-API suite was hitting the ts-node worker heap limit.
21
+ - Live-API tests skip cleanly on Deezer's `code: 4` "Quota limit exceeded"
22
+ instead of failing when the suite bursts the public API.
23
+
24
+ ## 2.5.0 - 2026-08-31
25
+
26
+ Phase 3.1 — streaming download primitives. Additive.
27
+
28
+ ### Added
29
+
30
+ - **`streamTrackDownload(track, quality, options?)`** — download a track as a
31
+ stream of decrypted audio (`get_url` → CDN fetch → stripe-decrypt `Transform`
32
+ → your sink). Peak memory is ~one 2048-byte stripe regardless of file size or
33
+ concurrency. `options.onProgress(received, total)`; `options.resumeFrom`
34
+ (bytes, 2048-aligned) sends a `Range` header and resumes stripe decryption
35
+ in phase. Verified byte-identical to the buffered `decryptDownload` path,
36
+ resume included.
37
+ - **`createDecryptStream(sngId, startChunk?)`** — a Node `Transform` wrapping the
38
+ stripe cipher, for composing your own pipelines. `TrackDecryptStream` gained a
39
+ `startChunk` constructor arg.
40
+ - **`getStream(url, {rangeStart?})`** — a content-decoded response stream from
41
+ the internal HTTP client (`gunzip` / `brotli` / `inflate` handled), following
42
+ redirects, rejecting `HttpStatusError` on non-2xx.
43
+
44
+ Streaming the tag write (in-place FLAC metadata-block rewrite) is still open.
45
+
3
46
  ## 2.4.0 - 2026-08-31
4
47
 
5
48
  Phase 3.3 — deterministic retries and typed errors.
package/README.md CHANGED
@@ -244,6 +244,31 @@ const track = await getTrackByISRC('USUM71311296'); // "Get Lucky"
244
244
  pass the `id` to `getTrackInfo` / `getAlbumTracks` (or use the converter's
245
245
  `isrc2deezer` / `upc2deezer`, which hydrate a gw track for you).
246
246
 
247
+ ### Flow, radios & a user's library
248
+
249
+ Public-profile data — pass a `userId` (`getUser().USER_ID`, a profile URL, or
250
+ `parseInfo`). A private library is only visible to that user's own session.
251
+
252
+ | Method | Returns |
253
+ | :--- | :--- |
254
+ | `getUserFlow(userId, limit = 40)` | **Flow** — the endless personalised mix, as tracks. |
255
+ | `getUserFavoriteTracks(userId, limit?, index?)` | loved tracks, newest first (each with `time_add`). |
256
+ | `getUserFavoriteAlbums(userId, limit?, index?)` | favourite albums. |
257
+ | `getUserFavoriteArtists(userId, limit?, index?)` | favourite artists. |
258
+ | `getUserPlaylists(userId, limit?, index?)` | the user's own + followed playlists. |
259
+ | `getUserRadios(userId)` | radios the user favourited. |
260
+ | `getUserChartTracks(userId, limit?)` | the user's personal top tracks. |
261
+ | `getRadios()` | Deezer's curated radio list. |
262
+ | `getRadioTracks(radioId)` | a radio's current tracklist — a ready-to-play source. |
263
+ | `getRadioGenres()` | radios grouped by genre. |
264
+
265
+ ```js
266
+ const me = await getUser();
267
+ const {data: flow} = await getUserFlow(me.USER_ID); // your Flow
268
+ const {data: loved} = await getUserFavoriteTracks(me.USER_ID);
269
+ const {data: eighties} = await getRadioTracks(38305); // "The '80s"
270
+ ```
271
+
247
272
  ### `.getTrackDownloadUrl(track, quality);`
248
273
 
249
274
  | Parameters | Required | Type | Description |
@@ -289,6 +314,34 @@ Useful for "audition before download" and for CI that shouldn't pull full tracks
289
314
  | `data` | Yes | `buffer` | downloaded song buffer |
290
315
  | `song_id` | Yes | `string` | track id |
291
316
 
317
+ ### Streaming download
318
+
319
+ For large files / high concurrency — peak memory is ~one 2048-byte stripe
320
+ instead of 2× the file.
321
+
322
+ - **`streamTrackDownload(track, quality, options?)`** → `{stream, size, startedAt, isEncrypted}`.
323
+ `stream` is decrypted audio bytes (`get_url` → CDN fetch → stripe-decrypt
324
+ `Transform` → your sink). `options.onProgress(received, total)`;
325
+ `options.resumeFrom` (bytes, rounded down to a 2048 boundary) sends a `Range`
326
+ header and resumes stripe-decryption in phase.
327
+ - **`createDecryptStream(sngId, startChunk?)`** → a `Transform` for your own `pipeline`.
328
+ - **`getStream(url, {rangeStart?})`** → `{stream, headers, status}` — a raw,
329
+ content-decoded response stream.
330
+
331
+ ```js
332
+ import {pipeline} from 'stream/promises';
333
+ import {createWriteStream} from 'fs';
334
+
335
+ const {stream} = await streamTrackDownload(track, 9, {
336
+ onProgress: (got, total) => process.stdout.write(`\r${((got / total) * 100) | 0}%`),
337
+ });
338
+ await pipeline(stream, createWriteStream('track.flac'));
339
+ ```
340
+
341
+ Streaming the **tag write** (rewriting the FLAC metadata block in place, no
342
+ full-file `Buffer.concat`) is not done yet — buffer the result and call
343
+ `addTrackTags`, or tag the file afterward.
344
+
292
345
  ### `.addTrackTags(data, track, options?)`
293
346
 
294
347
  Resolves album info, credits, lyrics and artwork from Deezer and writes them into
@@ -3,3 +3,4 @@ export * from './request';
3
3
  export * from './search';
4
4
  export * from './browse';
5
5
  export * from './preview';
6
+ export * from './user';
package/dist/api/index.js CHANGED
@@ -19,3 +19,4 @@ __exportStar(require("./request"), exports);
19
19
  __exportStar(require("./search"), exports);
20
20
  __exportStar(require("./browse"), exports);
21
21
  __exportStar(require("./preview"), exports);
22
+ __exportStar(require("./user"), exports);
@@ -0,0 +1,24 @@
1
+ import type { publicApiList, radioGenre, radioResult, searchResultTrack, userFavoriteAlbum, userFavoriteArtist, userFavoriteTrack, userPlaylistResult } from '../types';
2
+ /**
3
+ * Deezer **Flow** — the endless personalised mix — for a user. Returns
4
+ * public-API track objects; `getTrackInfo(id)` to make one downloadable.
5
+ */
6
+ export declare const getUserFlow: (userId: number | string, limit?: number) => Promise<publicApiList<searchResultTrack>>;
7
+ /** A user's favourite (loved) tracks, newest first; each carries `time_add`. */
8
+ export declare const getUserFavoriteTracks: (userId: number | string, limit?: number, index?: number) => Promise<publicApiList<userFavoriteTrack>>;
9
+ /** A user's favourite albums. */
10
+ export declare const getUserFavoriteAlbums: (userId: number | string, limit?: number, index?: number) => Promise<publicApiList<userFavoriteAlbum>>;
11
+ /** A user's favourite artists. */
12
+ export declare const getUserFavoriteArtists: (userId: number | string, limit?: number, index?: number) => Promise<publicApiList<userFavoriteArtist>>;
13
+ /** A user's own + followed playlists. */
14
+ export declare const getUserPlaylists: (userId: number | string, limit?: number, index?: number) => Promise<publicApiList<userPlaylistResult>>;
15
+ /** The radios a user has favourited. */
16
+ export declare const getUserRadios: (userId: number | string) => Promise<publicApiList<radioResult>>;
17
+ /** A user's personal track chart (their most-played). */
18
+ export declare const getUserChartTracks: (userId: number | string, limit?: number) => Promise<publicApiList<searchResultTrack>>;
19
+ /** Deezer's curated radio list. */
20
+ export declare const getRadios: () => Promise<publicApiList<radioResult>>;
21
+ /** A radio's current track list — a ready-to-play (public-API) source. */
22
+ export declare const getRadioTracks: (radioId: number | string) => Promise<publicApiList<searchResultTrack>>;
23
+ /** Radios grouped by genre. */
24
+ export declare const getRadioGenres: () => Promise<publicApiList<radioGenre>>;
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getRadioGenres = exports.getRadioTracks = exports.getRadios = exports.getUserChartTracks = exports.getUserRadios = exports.getUserPlaylists = exports.getUserFavoriteArtists = exports.getUserFavoriteAlbums = exports.getUserFavoriteTracks = exports.getUserFlow = void 0;
4
+ const request_1 = require("./request");
5
+ const withParams = (slug, params) => {
6
+ const search = new URLSearchParams();
7
+ for (const [key, value] of Object.entries(params)) {
8
+ if (value !== undefined && value !== '') {
9
+ search.set(key, String(value));
10
+ }
11
+ }
12
+ const qs = search.toString();
13
+ return qs ? `${slug}?${qs}` : slug;
14
+ };
15
+ // ─── Flow & a user's library (public REST) ───────────────────────────────────
16
+ //
17
+ // These take an explicit `userId` (from `getUser().USER_ID`, a profile URL, or
18
+ // `parseInfo`). They read **public** profile data — a user whose library is
19
+ // private only exposes it to their own authenticated session. All memoised.
20
+ /**
21
+ * Deezer **Flow** — the endless personalised mix — for a user. Returns
22
+ * public-API track objects; `getTrackInfo(id)` to make one downloadable.
23
+ */
24
+ const getUserFlow = (userId, limit = 40) => (0, request_1.requestPublicApi)(withParams(`/user/${userId}/flow`, { limit }));
25
+ exports.getUserFlow = getUserFlow;
26
+ /** A user's favourite (loved) tracks, newest first; each carries `time_add`. */
27
+ const getUserFavoriteTracks = (userId, limit = 100, index = 0) => (0, request_1.requestPublicApi)(withParams(`/user/${userId}/tracks`, { limit, index }));
28
+ exports.getUserFavoriteTracks = getUserFavoriteTracks;
29
+ /** A user's favourite albums. */
30
+ const getUserFavoriteAlbums = (userId, limit = 50, index = 0) => (0, request_1.requestPublicApi)(withParams(`/user/${userId}/albums`, { limit, index }));
31
+ exports.getUserFavoriteAlbums = getUserFavoriteAlbums;
32
+ /** A user's favourite artists. */
33
+ const getUserFavoriteArtists = (userId, limit = 50, index = 0) => (0, request_1.requestPublicApi)(withParams(`/user/${userId}/artists`, { limit, index }));
34
+ exports.getUserFavoriteArtists = getUserFavoriteArtists;
35
+ /** A user's own + followed playlists. */
36
+ const getUserPlaylists = (userId, limit = 50, index = 0) => (0, request_1.requestPublicApi)(withParams(`/user/${userId}/playlists`, { limit, index }));
37
+ exports.getUserPlaylists = getUserPlaylists;
38
+ /** The radios a user has favourited. */
39
+ const getUserRadios = (userId) => (0, request_1.requestPublicApi)(`/user/${userId}/radios`);
40
+ exports.getUserRadios = getUserRadios;
41
+ /** A user's personal track chart (their most-played). */
42
+ const getUserChartTracks = (userId, limit = 50) => (0, request_1.requestPublicApi)(withParams(`/user/${userId}/charts/tracks`, { limit }));
43
+ exports.getUserChartTracks = getUserChartTracks;
44
+ // ─── Radios (public REST) ────────────────────────────────────────────────────
45
+ /** Deezer's curated radio list. */
46
+ const getRadios = () => (0, request_1.requestPublicApi)('/radio');
47
+ exports.getRadios = getRadios;
48
+ /** A radio's current track list — a ready-to-play (public-API) source. */
49
+ const getRadioTracks = (radioId) => (0, request_1.requestPublicApi)(`/radio/${radioId}/tracks`);
50
+ exports.getRadioTracks = getRadioTracks;
51
+ /** Radios grouped by genre. */
52
+ const getRadioGenres = () => (0, request_1.requestPublicApi)('/radio/genres');
53
+ exports.getRadioGenres = getRadioGenres;
package/dist/index.d.ts CHANGED
@@ -5,5 +5,7 @@ export * from './api';
5
5
  export * from './converter';
6
6
  export * from './lib/decrypt';
7
7
  export * from './lib/get-url';
8
- export { httpAgent, httpsAgent, getBuffer, getJson, getText } from './lib/http';
8
+ export * from './lib/stream-download';
9
+ export { httpAgent, httpsAgent, getBuffer, getJson, getText, getStream } from './lib/http';
10
+ export type { StreamResponse } from './lib/http';
9
11
  export * from './metadata-writer';
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ 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.DeezerError = exports.RETRY_POLICY = exports.initDeezerApi = void 0;
17
+ exports.getStream = 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
20
  Object.defineProperty(exports, "RETRY_POLICY", { enumerable: true, get: function () { return request_1.RETRY_POLICY; } });
@@ -24,10 +24,12 @@ __exportStar(require("./api"), exports);
24
24
  __exportStar(require("./converter"), exports);
25
25
  __exportStar(require("./lib/decrypt"), exports);
26
26
  __exportStar(require("./lib/get-url"), exports);
27
+ __exportStar(require("./lib/stream-download"), exports);
27
28
  var http_1 = require("./lib/http");
28
29
  Object.defineProperty(exports, "httpAgent", { enumerable: true, get: function () { return http_1.httpAgent; } });
29
30
  Object.defineProperty(exports, "httpsAgent", { enumerable: true, get: function () { return http_1.httpsAgent; } });
30
31
  Object.defineProperty(exports, "getBuffer", { enumerable: true, get: function () { return http_1.getBuffer; } });
31
32
  Object.defineProperty(exports, "getJson", { enumerable: true, get: function () { return http_1.getJson; } });
32
33
  Object.defineProperty(exports, "getText", { enumerable: true, get: function () { return http_1.getText; } });
34
+ Object.defineProperty(exports, "getStream", { enumerable: true, get: function () { return http_1.getStream; } });
33
35
  __exportStar(require("./metadata-writer"), exports);
@@ -1,4 +1,5 @@
1
1
  /// <reference types="node" />
2
+ import { Transform } from 'stream';
2
3
  import type { trackType } from '../types';
3
4
  export declare const getSongFileName: ({ MD5_ORIGIN, SNG_ID, MEDIA_VERSION }: trackType, quality: number) => string;
4
5
  /**
@@ -19,9 +20,21 @@ export declare class TrackDecryptStream {
19
20
  private readonly bf;
20
21
  private carry;
21
22
  private chunkIndex;
22
- constructor(trackId: string);
23
+ /**
24
+ * @param trackId `SNG_ID`
25
+ * @param startChunk the 2048-byte chunk index the first byte you'll `write()`
26
+ * corresponds to — non-zero when resuming a `Range` download
27
+ * (`resumeFromByte / 2048`). Per-chunk IVs make this exact.
28
+ */
29
+ constructor(trackId: string, startChunk?: number);
23
30
  /** Returns the decrypted bytes for every complete 2048-byte stripe now available. */
24
31
  write(part: Buffer): Buffer;
25
32
  /** The trailing partial chunk (always plaintext). */
26
33
  final(): Buffer;
27
34
  }
35
+ /**
36
+ * A Node `Transform` that decrypts a Deezer download stripe-by-stripe as bytes
37
+ * flow through it — `fetch(url) → createDecryptStream(sngId) → sink`, constant
38
+ * memory. `startChunk` (`resumeFromByte / 2048`) supports resumed `Range` fetches.
39
+ */
40
+ export declare const createDecryptStream: (trackId: string, startChunk?: number) => Transform;
@@ -3,8 +3,9 @@ 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.TrackDecryptStream = exports.decryptDownload = exports.getSongFileName = void 0;
6
+ exports.createDecryptStream = exports.TrackDecryptStream = exports.decryptDownload = exports.getSongFileName = void 0;
7
7
  const crypto_1 = __importDefault(require("crypto"));
8
+ const stream_1 = require("stream");
8
9
  const blowfish_1 = require("./blowfish");
9
10
  const md5 = (data, type = 'ascii') => {
10
11
  const md5sum = crypto_1.default.createHash('md5');
@@ -66,10 +67,16 @@ exports.decryptDownload = decryptDownload;
66
67
  * and the CPU work hides behind the (slower) network read.
67
68
  */
68
69
  class TrackDecryptStream {
69
- constructor(trackId) {
70
+ /**
71
+ * @param trackId `SNG_ID`
72
+ * @param startChunk the 2048-byte chunk index the first byte you'll `write()`
73
+ * corresponds to — non-zero when resuming a `Range` download
74
+ * (`resumeFromByte / 2048`). Per-chunk IVs make this exact.
75
+ */
76
+ constructor(trackId, startChunk = 0) {
70
77
  this.carry = Buffer.alloc(0);
71
- this.chunkIndex = 0;
72
78
  this.bf = new blowfish_1.Blowfish(getBlowfishKey(trackId));
79
+ this.chunkIndex = startChunk;
73
80
  }
74
81
  /** Returns the decrypted bytes for every complete 2048-byte stripe now available. */
75
82
  write(part) {
@@ -100,3 +107,25 @@ class TrackDecryptStream {
100
107
  }
101
108
  }
102
109
  exports.TrackDecryptStream = TrackDecryptStream;
110
+ /**
111
+ * A Node `Transform` that decrypts a Deezer download stripe-by-stripe as bytes
112
+ * flow through it — `fetch(url) → createDecryptStream(sngId) → sink`, constant
113
+ * memory. `startChunk` (`resumeFromByte / 2048`) supports resumed `Range` fetches.
114
+ */
115
+ const createDecryptStream = (trackId, startChunk = 0) => {
116
+ const engine = new TrackDecryptStream(trackId, startChunk);
117
+ return new stream_1.Transform({
118
+ transform(chunk, _enc, cb) {
119
+ try {
120
+ cb(null, engine.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
121
+ }
122
+ catch (err) {
123
+ cb(err);
124
+ }
125
+ },
126
+ flush(cb) {
127
+ cb(null, engine.final());
128
+ },
129
+ });
130
+ };
131
+ exports.createDecryptStream = createDecryptStream;
@@ -1,8 +1,10 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
3
  /// <reference types="node" />
4
+ /// <reference types="node" />
4
5
  import { Agent as HttpAgent, IncomingHttpHeaders } from 'http';
5
6
  import { Agent as HttpsAgent } from 'https';
7
+ import { Readable } from 'stream';
6
8
  type HttpMethod = 'GET' | 'POST' | 'HEAD';
7
9
  type ResponseType = 'buffer' | 'json' | 'text';
8
10
  type QueryValue = string | number | boolean | null | undefined;
@@ -63,6 +65,21 @@ export declare class HttpClient {
63
65
  head(url: string, config?: Omit<HttpRequestConfig, 'responseType'>): Promise<HttpResponse<Buffer>>;
64
66
  private request;
65
67
  }
68
+ export interface StreamResponse {
69
+ /** the response body as a Readable — content-decoded (gzip/br/deflate unwrapped) */
70
+ stream: Readable;
71
+ headers: IncomingHttpHeaders;
72
+ status: number;
73
+ /** the final URL after redirects */
74
+ url: string;
75
+ }
76
+ /**
77
+ * Stream a URL's body (for large downloads — constant memory). Optionally send a
78
+ * `Range` header to resume from `rangeStart` bytes.
79
+ */
80
+ export declare const getStream: (url: string, config?: HttpRequestConfig & {
81
+ rangeStart?: number;
82
+ }) => Promise<StreamResponse>;
66
83
  export declare const getBuffer: (url: string, config?: HttpRequestConfig) => Promise<Buffer>;
67
84
  export declare const getJson: <T = unknown>(url: string, config?: HttpRequestConfig) => Promise<T>;
68
85
  export declare const getText: (url: string, config?: HttpRequestConfig) => Promise<string>;
package/dist/lib/http.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.headRequest = exports.getText = exports.getJson = exports.getBuffer = exports.HttpClient = exports.HttpStatusError = exports.httpsAgent = exports.httpAgent = void 0;
3
+ exports.headRequest = exports.getText = exports.getJson = exports.getBuffer = exports.getStream = exports.HttpClient = exports.HttpStatusError = exports.httpsAgent = exports.httpAgent = void 0;
4
4
  const http_1 = require("http");
5
5
  const https_1 = require("https");
6
6
  const zlib_1 = require("zlib");
@@ -172,6 +172,80 @@ const requestRaw = async (config, redirectCount = 0) => {
172
172
  req.end();
173
173
  });
174
174
  };
175
+ /**
176
+ * Like a GET, but resolves with the response **stream** instead of a buffered
177
+ * body — for large downloads that should not sit in memory. Follows redirects;
178
+ * rejects with `HttpStatusError` on a non-2xx (buffering only the small error
179
+ * body). The caller owns the returned stream and must consume or destroy it.
180
+ */
181
+ const streamRaw = async (config, redirectCount = 0) => {
182
+ const requestUrl = buildUrl(config.url, config.baseURL, config.params);
183
+ const headers = normalizeHeaders(config.headers);
184
+ return await new Promise((resolve, reject) => {
185
+ var _a;
186
+ const isHttps = requestUrl.protocol === 'https:';
187
+ const requestFn = isHttps ? https_1.request : http_1.request;
188
+ const req = requestFn({
189
+ agent: isHttps ? exports.httpsAgent : exports.httpAgent,
190
+ headers,
191
+ hostname: requestUrl.hostname,
192
+ method: 'GET',
193
+ path: requestUrl.pathname + requestUrl.search,
194
+ port: requestUrl.port,
195
+ protocol: requestUrl.protocol,
196
+ }, (response) => {
197
+ var _a, _b, _c;
198
+ const status = (_a = response.statusCode) !== null && _a !== void 0 ? _a : 0;
199
+ const locationHeader = response.headers.location;
200
+ const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader;
201
+ if (location && isRedirectStatus(status) && redirectCount < ((_b = config.maxRedirects) !== null && _b !== void 0 ? _b : 5)) {
202
+ response.resume();
203
+ streamRaw({ ...config, baseURL: undefined, url: new URL(location, requestUrl).toString() }, redirectCount + 1)
204
+ .then(resolve)
205
+ .catch(reject);
206
+ return;
207
+ }
208
+ if (status < 200 || status >= 300) {
209
+ const chunks = [];
210
+ response.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)));
211
+ response.on('end', () => reject(new HttpStatusError(status, response.headers, Buffer.concat(chunks))));
212
+ response.on('error', reject);
213
+ return;
214
+ }
215
+ const encoding = String((_c = response.headers['content-encoding']) !== null && _c !== void 0 ? _c : '').toLowerCase();
216
+ let stream = response;
217
+ if (encoding === 'gzip') {
218
+ stream = response.pipe((0, zlib_1.createGunzip)());
219
+ }
220
+ else if (encoding === 'br') {
221
+ stream = response.pipe((0, zlib_1.createBrotliDecompress)());
222
+ }
223
+ else if (encoding === 'deflate') {
224
+ stream = response.pipe((0, zlib_1.createInflate)());
225
+ }
226
+ response.on('error', (err) => stream.destroy(err));
227
+ resolve({ stream, headers: response.headers, status, url: requestUrl.toString() });
228
+ });
229
+ req.on('error', reject);
230
+ req.setTimeout((_a = config.timeout) !== null && _a !== void 0 ? _a : 30000, () => {
231
+ var _a;
232
+ req.destroy(new Error(`Request timed out after ${(_a = config.timeout) !== null && _a !== void 0 ? _a : 30000}ms`));
233
+ });
234
+ req.end();
235
+ });
236
+ };
237
+ /**
238
+ * Stream a URL's body (for large downloads — constant memory). Optionally send a
239
+ * `Range` header to resume from `rangeStart` bytes.
240
+ */
241
+ const getStream = async (url, config = {}) => {
242
+ const headers = { ...config.headers };
243
+ if (config.rangeStart && config.rangeStart > 0) {
244
+ headers.Range = `bytes=${config.rangeStart}-`;
245
+ }
246
+ return streamRaw({ url, headers, params: config.params, timeout: config.timeout });
247
+ };
248
+ exports.getStream = getStream;
175
249
  const buildUrl = (url, baseURL, params) => {
176
250
  const parsedUrl = new URL(resolveUrl(url, baseURL));
177
251
  if (params) {
@@ -0,0 +1,37 @@
1
+ /// <reference types="node" />
2
+ import { Readable } from 'stream';
3
+ import type { trackType } from '../types';
4
+ export interface StreamTrackOptions {
5
+ /**
6
+ * Resume from this many bytes already on disk. Rounded **down** to a 2048-byte
7
+ * boundary so stripe decryption stays aligned; the returned `startedAt` tells
8
+ * you where the stream actually begins.
9
+ */
10
+ resumeFrom?: number;
11
+ /** progress callback — `(bytesReceived, totalBytes)`; `total` is 0 when unknown */
12
+ onProgress?: (received: number, total: number) => void;
13
+ }
14
+ export interface TrackStream {
15
+ /** decrypted audio bytes, ready to pipe to a file or a tag muxer */
16
+ stream: Readable;
17
+ /** total size of the (decrypted) file in bytes, or 0 if Deezer didn't say */
18
+ size: number;
19
+ /** byte offset the stream starts at (0, or the aligned `resumeFrom`) */
20
+ startedAt: number;
21
+ /** `false` for `cipher: NONE` content — the stream is the raw file */
22
+ isEncrypted: boolean;
23
+ }
24
+ /**
25
+ * Download a track as a **stream** of decrypted audio — `get_url` → CDN fetch →
26
+ * stripe-decrypt transform → your sink. Peak memory is ~one 2048-byte stripe
27
+ * regardless of file size or how many run concurrently.
28
+ *
29
+ * Buffer-and-tag still works: `pipeline(ts.stream, fs.createWriteStream(tmp))`,
30
+ * then read the temp file back for `addTrackTags`. Streaming the tag write
31
+ * itself (especially FLAC) is not done yet.
32
+ *
33
+ * @throws the same `WrongLicense` / `GeoBlocked` / `ExpiredTrackToken` /
34
+ * `DeezerError` as `getTrackDownloadUrl`, plus `Error('unavailable')` when the
35
+ * track+quality can't be resolved at all.
36
+ */
37
+ export declare const streamTrackDownload: (track: trackType, quality: number, options?: StreamTrackOptions) => Promise<TrackStream>;
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.streamTrackDownload = void 0;
4
+ const stream_1 = require("stream");
5
+ const decrypt_1 = require("./decrypt");
6
+ const http_1 = require("./http");
7
+ const get_url_1 = require("./get-url");
8
+ const CHUNK = 2048;
9
+ /**
10
+ * Download a track as a **stream** of decrypted audio — `get_url` → CDN fetch →
11
+ * stripe-decrypt transform → your sink. Peak memory is ~one 2048-byte stripe
12
+ * regardless of file size or how many run concurrently.
13
+ *
14
+ * Buffer-and-tag still works: `pipeline(ts.stream, fs.createWriteStream(tmp))`,
15
+ * then read the temp file back for `addTrackTags`. Streaming the tag write
16
+ * itself (especially FLAC) is not done yet.
17
+ *
18
+ * @throws the same `WrongLicense` / `GeoBlocked` / `ExpiredTrackToken` /
19
+ * `DeezerError` as `getTrackDownloadUrl`, plus `Error('unavailable')` when the
20
+ * track+quality can't be resolved at all.
21
+ */
22
+ const streamTrackDownload = async (track, quality, options = {}) => {
23
+ const resolved = await (0, get_url_1.getTrackDownloadUrl)(track, quality);
24
+ if (!resolved) {
25
+ throw new Error(`Track ${track.SNG_ID} is unavailable at quality ${quality}`);
26
+ }
27
+ const startedAt = options.resumeFrom ? Math.floor(options.resumeFrom / CHUNK) * CHUNK : 0;
28
+ const { stream: raw, headers } = await (0, http_1.getStream)(resolved.trackUrl, { rangeStart: startedAt });
29
+ const contentLength = Number(headers['content-length']) || 0;
30
+ const size = resolved.fileSize || (contentLength ? contentLength + startedAt : 0);
31
+ let received = startedAt;
32
+ if (options.onProgress) {
33
+ const meter = new stream_1.PassThrough();
34
+ raw.on('data', (c) => {
35
+ var _a;
36
+ received += c.length;
37
+ (_a = options.onProgress) === null || _a === void 0 ? void 0 : _a.call(options, received, size);
38
+ });
39
+ raw.pipe(meter);
40
+ return {
41
+ stream: resolved.isEncrypted ? meter.pipe((0, decrypt_1.createDecryptStream)(track.SNG_ID, startedAt / CHUNK)) : meter,
42
+ size,
43
+ startedAt,
44
+ isEncrypted: resolved.isEncrypted,
45
+ };
46
+ }
47
+ return {
48
+ stream: resolved.isEncrypted ? raw.pipe((0, decrypt_1.createDecryptStream)(track.SNG_ID, startedAt / CHUNK)) : raw,
49
+ size,
50
+ startedAt,
51
+ isEncrypted: resolved.isEncrypted,
52
+ };
53
+ };
54
+ exports.streamTrackDownload = streamTrackDownload;
@@ -1,3 +1,4 @@
1
+ import type { searchResultTrack, searchResultArtist } from './search';
1
2
  export interface userType {
2
3
  USER_ID: string;
3
4
  EMAIL: string;
@@ -14,3 +15,82 @@ export interface userType {
14
15
  PHONE?: string;
15
16
  __TYPE__: 'user';
16
17
  }
18
+ /** A favourite track from `/user/{id}/tracks` — public-API shape, plus `time_add`. */
19
+ export interface userFavoriteTrack extends searchResultTrack {
20
+ /** unix timestamp the track was added to favourites */
21
+ time_add?: number;
22
+ }
23
+ export interface userFavoriteAlbum {
24
+ id: number;
25
+ title: string;
26
+ link: string;
27
+ cover: string;
28
+ cover_small?: string;
29
+ cover_medium?: string;
30
+ cover_big?: string;
31
+ cover_xl?: string;
32
+ md5_image: string;
33
+ nb_tracks: number;
34
+ release_date: string;
35
+ record_type: string;
36
+ available: boolean;
37
+ tracklist: string;
38
+ explicit_lyrics: boolean;
39
+ time_add?: number;
40
+ artist: searchResultArtist | {
41
+ id: number;
42
+ name: string;
43
+ tracklist?: string;
44
+ type: 'artist';
45
+ };
46
+ type: 'album';
47
+ }
48
+ export interface userFavoriteArtist extends searchResultArtist {
49
+ time_add?: number;
50
+ }
51
+ export interface userPlaylistResult {
52
+ id: number;
53
+ title: string;
54
+ duration: number;
55
+ public: boolean;
56
+ is_loved_track: boolean;
57
+ collaborative: boolean;
58
+ nb_tracks: number;
59
+ fans: number;
60
+ link: string;
61
+ picture: string;
62
+ picture_small?: string;
63
+ picture_medium?: string;
64
+ picture_big?: string;
65
+ picture_xl?: string;
66
+ checksum: string;
67
+ tracklist: string;
68
+ creation_date?: string;
69
+ time_add?: number;
70
+ time_mod?: number;
71
+ creator?: {
72
+ id: number;
73
+ name: string;
74
+ tracklist?: string;
75
+ type: 'user';
76
+ };
77
+ type: 'playlist';
78
+ }
79
+ export interface radioResult {
80
+ id: number;
81
+ title: string;
82
+ description?: string;
83
+ picture: string;
84
+ picture_small?: string;
85
+ picture_medium?: string;
86
+ picture_big?: string;
87
+ picture_xl?: string;
88
+ md5_image?: string;
89
+ tracklist: string;
90
+ type: 'radio';
91
+ }
92
+ export interface radioGenre {
93
+ id: number;
94
+ title: string;
95
+ radios: radioResult[];
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gerdur-core",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Core module for gerdur.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -61,7 +61,7 @@
61
61
  "!__tests__/helpers.ts"
62
62
  ],
63
63
  "require": [
64
- "ts-node/register"
64
+ "ts-node/register/transpile-only"
65
65
  ],
66
66
  "timeout": "2m",
67
67
  "verbose": true