gerdur-core 1.0.2 → 1.0.4
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 +18 -0
- package/dist/api/request.js +66 -45
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -1
- package/dist/lib/get-url.d.ts +21 -0
- package/dist/lib/get-url.js +109 -41
- package/dist/lib/http.d.ts +5 -1
- package/dist/lib/http.js +24 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.4 - 2026-08-31
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `resolveDownloadUrls(tracks, qualities)` — resolve download URLs for a whole album/playlist in **one** `media.deezer.com/v1/get_url` POST (`track_tokens[]` + an ordered `formats[]` fallback), instead of one request per track re-tried per quality. ~19× faster URL resolution for a 14-track album. Returns one `ResolvedUrl | null` per input track, in order. `formatName(quality)` is also exported.
|
|
8
|
+
- `httpAgent` / `httpsAgent` — the shared keep-alive connection pools are now exported so downstream apps can hand them to `got`/`undici` and reuse the same sockets for downloads. `getBuffer` / `getJson` / `getText` are exported too.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **Shared HTTP agents are tuned and bounded**: `keepAlive`, `maxSockets: 64` (was the Node default of `Infinity`), `maxFreeSockets: 16`, `scheduling: 'lifo'`, 30 s keep-alive. Caps a pathological fan-out from opening hundreds of sockets to `api.deezer.com`, while leaving generous headroom over the converter's concurrency.
|
|
13
|
+
- **In-flight request coalescing** in the API layer: concurrent identical gateway / public-API calls (e.g. every track of one album asking for the same album info while tagging) now share a single request instead of each missing the still-empty cache and hitting the wire.
|
|
14
|
+
|
|
15
|
+
## 1.0.3 - 2026-08-30
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `getTrackDownloadUrl`: when `media.deezer.com/v1/get_url` returns 403/429/5xx (throttling or a stale license token), re-authenticate and retry with exponential backoff (up to 3×); if it still fails, fall through to the token-free legacy CDN instead of throwing.
|
|
20
|
+
|
|
3
21
|
## 1.0.2 - 2026-08-30
|
|
4
22
|
|
|
5
23
|
### Fixed
|
package/dist/api/request.js
CHANGED
|
@@ -6,6 +6,38 @@ 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
8
|
const cache_1 = __importDefault(require("./cache"));
|
|
9
|
+
/**
|
|
10
|
+
* In-flight request coalescing (single-flight).
|
|
11
|
+
*
|
|
12
|
+
* The LRU only helps *after* a response lands. During a batch download the
|
|
13
|
+
* pipeline fires many identical metadata calls at once — e.g. tagging 14 tracks
|
|
14
|
+
* of one album triggers 14 concurrent `album/<id>` lookups — and every one of
|
|
15
|
+
* them misses the still-empty cache and hits the network. Here a second caller
|
|
16
|
+
* for a key already in flight awaits the same promise instead, so the wire sees
|
|
17
|
+
* exactly one request. Entries are removed as soon as they settle; the LRU takes
|
|
18
|
+
* over from there.
|
|
19
|
+
*/
|
|
20
|
+
const inFlight = new Map();
|
|
21
|
+
const coalesce = (cacheKey, fetcher) => {
|
|
22
|
+
const cached = cache_1.default.get(cacheKey);
|
|
23
|
+
if (cached) {
|
|
24
|
+
return Promise.resolve(cached);
|
|
25
|
+
}
|
|
26
|
+
const pending = inFlight.get(cacheKey);
|
|
27
|
+
if (pending) {
|
|
28
|
+
return pending;
|
|
29
|
+
}
|
|
30
|
+
const promise = (async () => {
|
|
31
|
+
try {
|
|
32
|
+
return await fetcher();
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
inFlight.delete(cacheKey);
|
|
36
|
+
}
|
|
37
|
+
})();
|
|
38
|
+
inFlight.set(cacheKey, promise);
|
|
39
|
+
return promise;
|
|
40
|
+
};
|
|
9
41
|
/**
|
|
10
42
|
* Make POST requests to deezer api
|
|
11
43
|
* @param {Object} body post body
|
|
@@ -13,17 +45,14 @@ const cache_1 = __importDefault(require("./cache"));
|
|
|
13
45
|
*/
|
|
14
46
|
const request = async (body, method) => {
|
|
15
47
|
const cacheKey = method + ':' + Object.entries(body).join(':');
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
const errorMessage = Object.entries(error).join(', ');
|
|
26
|
-
throw new Error(errorMessage);
|
|
48
|
+
return coalesce(cacheKey, async () => {
|
|
49
|
+
const { data: { error, results }, } = await request_1.default.post('/gateway.php', body, { params: { method } });
|
|
50
|
+
if (Object.keys(results).length > 0) {
|
|
51
|
+
cache_1.default.set(cacheKey, results);
|
|
52
|
+
return results;
|
|
53
|
+
}
|
|
54
|
+
throw new Error(Object.entries(error).join(', '));
|
|
55
|
+
});
|
|
27
56
|
};
|
|
28
57
|
exports.request = request;
|
|
29
58
|
/**
|
|
@@ -33,19 +62,16 @@ exports.request = request;
|
|
|
33
62
|
*/
|
|
34
63
|
const requestLight = async (body, method) => {
|
|
35
64
|
const cacheKey = method + ':' + Object.entries(body).join(':');
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
return coalesce(cacheKey, async () => {
|
|
66
|
+
const { data: { error, results }, } = await request_1.default.post('https://www.deezer.com/ajax/gw-light.php', body, {
|
|
67
|
+
params: { method, api_version: '1.0' },
|
|
68
|
+
});
|
|
69
|
+
if (Object.keys(results).length > 0) {
|
|
70
|
+
cache_1.default.set(cacheKey, results);
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
73
|
+
throw new Error(Object.entries(error).join(', '));
|
|
42
74
|
});
|
|
43
|
-
if (Object.keys(results).length > 0) {
|
|
44
|
-
cache_1.default.set(cacheKey, results);
|
|
45
|
-
return results;
|
|
46
|
-
}
|
|
47
|
-
const errorMessage = Object.entries(error).join(', ');
|
|
48
|
-
throw new Error(errorMessage);
|
|
49
75
|
};
|
|
50
76
|
exports.requestLight = requestLight;
|
|
51
77
|
/**
|
|
@@ -55,17 +81,14 @@ exports.requestLight = requestLight;
|
|
|
55
81
|
*/
|
|
56
82
|
const requestGet = async (method, params = {}, key = 'get_request') => {
|
|
57
83
|
const cacheKey = method + key;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
const errorMessage = Object.entries(error).join(', ');
|
|
68
|
-
throw new Error(errorMessage);
|
|
84
|
+
return coalesce(cacheKey, async () => {
|
|
85
|
+
const { data: { error, results }, } = await request_1.default.get('/gateway.php', { params: { method, ...params } });
|
|
86
|
+
if (Object.keys(results).length > 0) {
|
|
87
|
+
cache_1.default.set(cacheKey, results);
|
|
88
|
+
return results;
|
|
89
|
+
}
|
|
90
|
+
throw new Error(Object.entries(error).join(', '));
|
|
91
|
+
});
|
|
69
92
|
};
|
|
70
93
|
exports.requestGet = requestGet;
|
|
71
94
|
/**
|
|
@@ -73,16 +96,14 @@ exports.requestGet = requestGet;
|
|
|
73
96
|
* @param {String} slug endpoint
|
|
74
97
|
*/
|
|
75
98
|
const requestPublicApi = async (slug) => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
cache_1.default.set(slug, data);
|
|
86
|
-
return data;
|
|
99
|
+
return coalesce(slug, async () => {
|
|
100
|
+
const { data } = await request_1.default.get('https://api.deezer.com' + slug);
|
|
101
|
+
if (data.error) {
|
|
102
|
+
const errorMessage = Object.entries(data.error).join(', ');
|
|
103
|
+
throw new Error(errorMessage);
|
|
104
|
+
}
|
|
105
|
+
cache_1.default.set(slug, data);
|
|
106
|
+
return data;
|
|
107
|
+
});
|
|
87
108
|
};
|
|
88
109
|
exports.requestPublicApi = requestPublicApi;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -14,11 +14,17 @@ 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.initDeezerApi = void 0;
|
|
17
|
+
exports.getText = exports.getJson = exports.getBuffer = exports.httpsAgent = exports.httpAgent = 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
|
__exportStar(require("./api"), exports);
|
|
21
21
|
__exportStar(require("./converter"), exports);
|
|
22
22
|
__exportStar(require("./lib/decrypt"), exports);
|
|
23
23
|
__exportStar(require("./lib/get-url"), exports);
|
|
24
|
+
var http_1 = require("./lib/http");
|
|
25
|
+
Object.defineProperty(exports, "httpAgent", { enumerable: true, get: function () { return http_1.httpAgent; } });
|
|
26
|
+
Object.defineProperty(exports, "httpsAgent", { enumerable: true, get: function () { return http_1.httpsAgent; } });
|
|
27
|
+
Object.defineProperty(exports, "getBuffer", { enumerable: true, get: function () { return http_1.getBuffer; } });
|
|
28
|
+
Object.defineProperty(exports, "getJson", { enumerable: true, get: function () { return http_1.getJson; } });
|
|
29
|
+
Object.defineProperty(exports, "getText", { enumerable: true, get: function () { return http_1.getText; } });
|
|
24
30
|
__exportStar(require("./metadata-writer"), exports);
|
package/dist/lib/get-url.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ 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
18
|
/**
|
|
17
19
|
* @param track Track info json returned from `getTrackInfo`
|
|
18
20
|
* @param quality 1 = 128kbps, 3 = 320kbps and 9 = flac (around 1411kbps)
|
|
@@ -22,3 +24,22 @@ export declare const getTrackDownloadUrl: (track: trackType, quality: number) =>
|
|
|
22
24
|
isEncrypted: boolean;
|
|
23
25
|
fileSize: number;
|
|
24
26
|
} | null>;
|
|
27
|
+
export interface ResolvedUrl {
|
|
28
|
+
trackUrl: string;
|
|
29
|
+
isEncrypted: boolean;
|
|
30
|
+
fileSize: number;
|
|
31
|
+
/** the format Deezer actually returned, e.g. `'FLAC'` */
|
|
32
|
+
format: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve download URLs for many tracks in a **single** `get_url` request.
|
|
36
|
+
*
|
|
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.
|
|
41
|
+
*
|
|
42
|
+
* @param tracks from `getTrackInfo` / `parseInfo` (needs `TRACK_TOKEN`, `SNG_ID`, `FILESIZE_*`)
|
|
43
|
+
* @param qualities preference order — 9 = FLAC, 3 = MP3 320, 1 = MP3 128
|
|
44
|
+
*/
|
|
45
|
+
export declare const resolveDownloadUrls: (tracks: trackType[], qualities?: number[]) => Promise<(ResolvedUrl | null)[]>;
|
package/dist/lib/get-url.js
CHANGED
|
@@ -3,7 +3,8 @@ 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.getTrackDownloadUrl = exports.ExpiredTrackToken = exports.GeoBlocked = exports.WrongLicense = void 0;
|
|
6
|
+
exports.resolveDownloadUrls = exports.getTrackDownloadUrl = exports.formatName = exports.ExpiredTrackToken = exports.GeoBlocked = exports.WrongLicense = void 0;
|
|
7
|
+
const delay_1 = __importDefault(require("delay"));
|
|
7
8
|
const decrypt_1 = require("../lib/decrypt");
|
|
8
9
|
const http_1 = require("../lib/http");
|
|
9
10
|
const request_1 = __importDefault(require("../lib/request"));
|
|
@@ -64,36 +65,69 @@ const dzAuthenticate = async () => {
|
|
|
64
65
|
};
|
|
65
66
|
return user_data;
|
|
66
67
|
};
|
|
68
|
+
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
|
+
};
|
|
82
|
+
exports.formatName = formatName;
|
|
83
|
+
/** POST media.deezer.com/v1/get_url with re-auth + exponential-backoff retry on 403/429/5xx. */
|
|
84
|
+
const mediaGetUrl = async (track_tokens, formats, attempt = 0) => {
|
|
85
|
+
var _a;
|
|
86
|
+
const user = user_data ? user_data : await dzAuthenticate();
|
|
87
|
+
try {
|
|
88
|
+
const { data } = await request_1.default.post('https://media.deezer.com/v1/get_url', {
|
|
89
|
+
license_token: user.license_token,
|
|
90
|
+
media: [{ type: 'FULL', formats }],
|
|
91
|
+
track_tokens,
|
|
92
|
+
});
|
|
93
|
+
return { data: (_a = data.data) !== null && _a !== void 0 ? _a : [], country: user.country };
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
const status = err instanceof http_1.HttpStatusError ? err.statusCode : 0;
|
|
97
|
+
if ((status === 403 || status === 429 || status >= 500) && attempt < MEDIA_MAX_RETRIES) {
|
|
98
|
+
user_data = null;
|
|
99
|
+
await (0, delay_1.default)(500 * 2 ** attempt + Math.floor(Math.random() * 250));
|
|
100
|
+
return mediaGetUrl(track_tokens, formats, attempt + 1);
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
/** Parse one `data[i]` entry from a get_url response into a source URL (or null / throw). */
|
|
106
|
+
const parseMediaEntry = (entry, token, country) => {
|
|
107
|
+
var _a, _b;
|
|
108
|
+
if (entry === null || entry === void 0 ? void 0 : entry.errors) {
|
|
109
|
+
const { code } = entry.errors[0];
|
|
110
|
+
if (code === 2002)
|
|
111
|
+
throw new GeoBlocked(country);
|
|
112
|
+
if (code === 2000 || code === 2001)
|
|
113
|
+
throw new ExpiredTrackToken(token);
|
|
114
|
+
throw new Error(Object.entries(entry.errors[0]).join(', '));
|
|
115
|
+
}
|
|
116
|
+
const media = (_a = entry === null || entry === void 0 ? void 0 : entry.media) === null || _a === void 0 ? void 0 : _a[0];
|
|
117
|
+
if (!((_b = media === null || media === void 0 ? void 0 : media.sources) === null || _b === void 0 ? void 0 : _b.length))
|
|
118
|
+
return null;
|
|
119
|
+
return { url: media.sources[0].url, format: media.format };
|
|
120
|
+
};
|
|
67
121
|
const getTrackUrlFromServer = async (track_token, format) => {
|
|
122
|
+
var _a, _b;
|
|
68
123
|
const user = user_data ? user_data : await dzAuthenticate();
|
|
69
124
|
if ((format === 'FLAC' && !user.can_stream_lossless) || (format === 'MP3_320' && !user.can_stream_hq)) {
|
|
70
125
|
throw new WrongLicense(format);
|
|
71
126
|
}
|
|
72
|
-
const { data } = await
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
type: 'FULL',
|
|
77
|
-
formats: [{ format, cipher: 'BF_CBC_STRIPE' }],
|
|
78
|
-
},
|
|
79
|
-
],
|
|
80
|
-
track_tokens: [track_token],
|
|
81
|
-
});
|
|
82
|
-
if (data.data.length > 0) {
|
|
83
|
-
if (data.data[0].errors) {
|
|
84
|
-
const { code } = data.data[0].errors[0];
|
|
85
|
-
if (code === 2002) {
|
|
86
|
-
throw new GeoBlocked(user.country);
|
|
87
|
-
}
|
|
88
|
-
// 2000: invalid/expired token · 2001: token has no rights on this song
|
|
89
|
-
if (code === 2000 || code === 2001) {
|
|
90
|
-
throw new ExpiredTrackToken(track_token);
|
|
91
|
-
}
|
|
92
|
-
throw new Error(Object.entries(data.data[0].errors[0]).join(', '));
|
|
93
|
-
}
|
|
94
|
-
return data.data[0].media.length > 0 ? data.data[0].media[0].sources[0].url : null;
|
|
95
|
-
}
|
|
96
|
-
return null;
|
|
127
|
+
const { data, country } = await mediaGetUrl([track_token], [{ format, cipher: 'BF_CBC_STRIPE' }]);
|
|
128
|
+
if (!data.length)
|
|
129
|
+
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;
|
|
97
131
|
};
|
|
98
132
|
/**
|
|
99
133
|
* @param track Track info json returned from `getTrackInfo`
|
|
@@ -103,20 +137,8 @@ const getTrackDownloadUrl = async (track, quality) => {
|
|
|
103
137
|
let wrongLicense = null;
|
|
104
138
|
let geoBlocked = null;
|
|
105
139
|
let expiredToken = null;
|
|
106
|
-
let
|
|
107
|
-
|
|
108
|
-
case 9:
|
|
109
|
-
formatName = 'FLAC';
|
|
110
|
-
break;
|
|
111
|
-
case 3:
|
|
112
|
-
formatName = 'MP3_320';
|
|
113
|
-
break;
|
|
114
|
-
case 1:
|
|
115
|
-
formatName = 'MP3_128';
|
|
116
|
-
break;
|
|
117
|
-
default:
|
|
118
|
-
throw new Error(`Unknown quality ${quality}`);
|
|
119
|
-
}
|
|
140
|
+
let mediaBlocked = null;
|
|
141
|
+
const format = (0, exports.formatName)(quality);
|
|
120
142
|
// Track tokens last ~1 hour. If it's already stale, skip the doomed media API
|
|
121
143
|
// call and go straight to the token-free fallback below.
|
|
122
144
|
const tokenStale = Boolean(track.TRACK_TOKEN_EXPIRE && track.TRACK_TOKEN_EXPIRE * 1000 < Date.now());
|
|
@@ -126,7 +148,7 @@ const getTrackDownloadUrl = async (track, quality) => {
|
|
|
126
148
|
else {
|
|
127
149
|
// Get URL with the official API
|
|
128
150
|
try {
|
|
129
|
-
const url = await getTrackUrlFromServer(track.TRACK_TOKEN,
|
|
151
|
+
const url = await getTrackUrlFromServer(track.TRACK_TOKEN, format);
|
|
130
152
|
if (url) {
|
|
131
153
|
return {
|
|
132
154
|
trackUrl: url,
|
|
@@ -145,6 +167,10 @@ const getTrackDownloadUrl = async (track, quality) => {
|
|
|
145
167
|
else if (err instanceof ExpiredTrackToken) {
|
|
146
168
|
expiredToken = err;
|
|
147
169
|
}
|
|
170
|
+
else if (err instanceof http_1.HttpStatusError && (err.statusCode === 403 || err.statusCode === 429)) {
|
|
171
|
+
// media API is throttling this account — try the token-free legacy CDN below
|
|
172
|
+
mediaBlocked = err;
|
|
173
|
+
}
|
|
148
174
|
else {
|
|
149
175
|
throw err;
|
|
150
176
|
}
|
|
@@ -172,6 +198,9 @@ const getTrackDownloadUrl = async (track, quality) => {
|
|
|
172
198
|
if (expiredToken) {
|
|
173
199
|
throw expiredToken;
|
|
174
200
|
}
|
|
201
|
+
if (mediaBlocked) {
|
|
202
|
+
throw mediaBlocked;
|
|
203
|
+
}
|
|
175
204
|
return null;
|
|
176
205
|
};
|
|
177
206
|
exports.getTrackDownloadUrl = getTrackDownloadUrl;
|
|
@@ -184,3 +213,42 @@ const testUrl = async (url) => {
|
|
|
184
213
|
return 0;
|
|
185
214
|
}
|
|
186
215
|
};
|
|
216
|
+
const QUALITY_OF_FORMAT = { FLAC: 9, MP3_320: 3, MP3_128: 1, MP3_256: 3, MP3_64: 1 };
|
|
217
|
+
/**
|
|
218
|
+
* Resolve download URLs for many tracks in a **single** `get_url` request.
|
|
219
|
+
*
|
|
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.
|
|
224
|
+
*
|
|
225
|
+
* @param tracks from `getTrackInfo` / `parseInfo` (needs `TRACK_TOKEN`, `SNG_ID`, `FILESIZE_*`)
|
|
226
|
+
* @param qualities preference order — 9 = FLAC, 3 = MP3 320, 1 = MP3 128
|
|
227
|
+
*/
|
|
228
|
+
const resolveDownloadUrls = async (tracks, qualities = [9, 3, 1]) => {
|
|
229
|
+
if (!tracks.length)
|
|
230
|
+
return [];
|
|
231
|
+
const formats = qualities.map((q) => ({ format: (0, exports.formatName)(q), cipher: 'BF_CBC_STRIPE' }));
|
|
232
|
+
const { data, country } = await mediaGetUrl(tracks.map((t) => t.TRACK_TOKEN), formats);
|
|
233
|
+
return tracks.map((track, i) => {
|
|
234
|
+
var _a;
|
|
235
|
+
let parsed;
|
|
236
|
+
try {
|
|
237
|
+
parsed = parseMediaEntry(data[i], track.TRACK_TOKEN, country);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// per-track error (geo-block, no rights, expired token) — skip this one
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
if (!parsed)
|
|
244
|
+
return null;
|
|
245
|
+
const q = (_a = QUALITY_OF_FORMAT[parsed.format]) !== null && _a !== void 0 ? _a : qualities[0];
|
|
246
|
+
return {
|
|
247
|
+
trackUrl: parsed.url,
|
|
248
|
+
isEncrypted: parsed.url.includes('/mobile/') || parsed.url.includes('/media/'),
|
|
249
|
+
fileSize: getTrackFileSize(track, q),
|
|
250
|
+
format: parsed.format,
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
exports.resolveDownloadUrls = resolveDownloadUrls;
|
package/dist/lib/http.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/// <reference types="node" />
|
|
3
|
-
|
|
3
|
+
/// <reference types="node" />
|
|
4
|
+
import { Agent as HttpAgent, IncomingHttpHeaders } from 'http';
|
|
5
|
+
import { Agent as HttpsAgent } from 'https';
|
|
4
6
|
type HttpMethod = 'GET' | 'POST' | 'HEAD';
|
|
5
7
|
type ResponseType = 'buffer' | 'json' | 'text';
|
|
6
8
|
type QueryValue = string | number | boolean | null | undefined;
|
|
@@ -22,6 +24,8 @@ interface RequestDescriptor {
|
|
|
22
24
|
timeout: number;
|
|
23
25
|
url: string;
|
|
24
26
|
}
|
|
27
|
+
export declare const httpAgent: HttpAgent;
|
|
28
|
+
export declare const httpsAgent: HttpsAgent;
|
|
25
29
|
export interface HttpResponse<T> {
|
|
26
30
|
config: RequestDescriptor;
|
|
27
31
|
data: T;
|
package/dist/lib/http.js
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
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 = void 0;
|
|
3
|
+
exports.headRequest = exports.getText = exports.getJson = exports.getBuffer = 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");
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Shared keep-alive connection pools. Every request gerdur-core makes — gateway,
|
|
9
|
+
* media API, CDN HEAD probes — reuses these, so a batch of calls to the same host
|
|
10
|
+
* rides one handful of TCP+TLS connections instead of a fresh handshake each time.
|
|
11
|
+
*
|
|
12
|
+
* `maxSockets` is bounded (not the Node default of `Infinity`): the converter fans
|
|
13
|
+
* out at concurrency 10–25, so 64 is generous headroom while still capping a
|
|
14
|
+
* pathological fan-out that would otherwise open hundreds of sockets to
|
|
15
|
+
* `api.deezer.com` and get the account rate-limited. `scheduling: 'lifo'` keeps
|
|
16
|
+
* the hottest socket warm. Downstream apps can import these and hand them to
|
|
17
|
+
* `got`/`undici` so downloads share the same pool.
|
|
18
|
+
*/
|
|
19
|
+
const AGENT_OPTS = {
|
|
20
|
+
keepAlive: true,
|
|
21
|
+
keepAliveMsecs: 30000,
|
|
22
|
+
maxSockets: 64,
|
|
23
|
+
maxFreeSockets: 16,
|
|
24
|
+
scheduling: 'lifo',
|
|
25
|
+
timeout: 60000,
|
|
26
|
+
};
|
|
27
|
+
exports.httpAgent = new http_1.Agent(AGENT_OPTS);
|
|
28
|
+
exports.httpsAgent = new https_1.Agent(AGENT_OPTS);
|
|
9
29
|
class HttpStatusError extends Error {
|
|
10
30
|
constructor(statusCode, headers, body) {
|
|
11
31
|
super(`Request failed with status code ${statusCode}`);
|
|
@@ -86,7 +106,7 @@ const requestRaw = async (config, redirectCount = 0) => {
|
|
|
86
106
|
const isHttps = requestUrl.protocol === 'https:';
|
|
87
107
|
const requestFn = isHttps ? https_1.request : http_1.request;
|
|
88
108
|
const req = requestFn({
|
|
89
|
-
agent: isHttps ? httpsAgent : httpAgent,
|
|
109
|
+
agent: isHttps ? exports.httpsAgent : exports.httpAgent,
|
|
90
110
|
headers,
|
|
91
111
|
hostname: requestUrl.hostname,
|
|
92
112
|
method: config.method,
|