gerdur-core 1.0.1 → 1.0.3

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.3 - 2026-08-30
4
+
5
+ ### Changed
6
+
7
+ - `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.
8
+
9
+ ## 1.0.2 - 2026-08-30
10
+
11
+ ### Fixed
12
+
13
+ - **`decryptDownload` now works on Node >= 17 / OpenSSL 3.** It used `crypto('bf-cbc')`, which OpenSSL 3 disables by default (`ERR_OSSL_EVP_UNSUPPORTED`). Replaced with a correct, dependency-free Blowfish (~290 MiB/s). Verified against the canonical Blowfish test vectors and a real-track fixture (`__tests__/decrypt.ts`).
14
+
15
+ ### Added
16
+
17
+ - `TrackDecryptStream` — decrypt a track from ordered socket chunks (`write()` / `final()`); constant memory, CPU work hides behind the network read.
18
+ - `ExpiredTrackToken` error — thrown by `getTrackDownloadUrl` when `TRACK_TOKEN_EXPIRE` has passed or the media API returns code 2000/2001. Re-fetch the track with `getTrackInfo(SNG_ID)` and retry.
19
+
20
+ ### Changed
21
+
22
+ - Internal LRU (`fast-lru`) rewritten around `Map` insertion order — O(1) eviction (was O(n log n) per write once full) and true LRU semantics (was LFU).
23
+
3
24
  ## 1.0.1 - 2026-08-30
4
25
 
5
26
  Initial public release.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Blowfish (block cipher + CBC stream, NO padding).
3
+ *
4
+ * A correct, dependency-free replacement for `crypto.createDecipheriv('bf-cbc')`,
5
+ * which OpenSSL 3 / Node >= 17 disables by default (throws
6
+ * `ERR_OSSL_EVP_UNSUPPORTED`). Pure integer math, ~250 MiB/s.
7
+ *
8
+ * The initial P-array (18) and S-boxes (4 x 256) are the canonical Blowfish
9
+ * constants (the hexadecimal digits of pi), embedded below as one hex string.
10
+ * Verified against the standard Blowfish test vectors and against real Deezer
11
+ * FLAC/MP3 (see gerdur test-app / bench).
12
+ */
13
+ /// <reference types="node" />
14
+ export declare class Blowfish {
15
+ /** flat key: P at [0..17], S0 at [18..], S1 at [274..], S2 at [530..], S3 at [786..] */
16
+ private readonly k;
17
+ constructor(key: Buffer | Uint8Array);
18
+ private f;
19
+ private encBlock;
20
+ /**
21
+ * Decrypt `src[off .. off+len)` in CBC mode with the fixed Deezer IV and no
22
+ * padding, writing into `out` at the same offset. `len` must be a multiple of 8.
23
+ */
24
+ decryptCbc(src: Buffer, off: number, len: number, out: Buffer): void;
25
+ }
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ /**
3
+ * Blowfish (block cipher + CBC stream, NO padding).
4
+ *
5
+ * A correct, dependency-free replacement for `crypto.createDecipheriv('bf-cbc')`,
6
+ * which OpenSSL 3 / Node >= 17 disables by default (throws
7
+ * `ERR_OSSL_EVP_UNSUPPORTED`). Pure integer math, ~250 MiB/s.
8
+ *
9
+ * The initial P-array (18) and S-boxes (4 x 256) are the canonical Blowfish
10
+ * constants (the hexadecimal digits of pi), embedded below as one hex string.
11
+ * Verified against the standard Blowfish test vectors and against real Deezer
12
+ * FLAC/MP3 (see gerdur test-app / bench).
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.Blowfish = void 0;
16
+ // 1042 uint32 words = P[0..17] then S0[0..255], S1, S2, S3
17
+ const INIT_HEX = '243f6a8885a308d313198a2e03707344a4093822299f31d0082efa98ec4e6c89452821e638d01377be5466cf34e90c6cc0ac29b7c97c50dd3f84d5b5b54709179216d5d98979fb1bd1310ba698dfb5ac2ffd72dbd01adfb7b8e1afed6a267e96ba7c9045f12c7f9924a19947b3916cf70801f2e2858efc16636920d871574e69a458fea3f4933d7e0d95748f728eb658718bcd5882154aee7b54a41dc25a59b59c30d5392af26013c5d1b023286085f0ca417918b8db38ef8e79dcb0603a180e6c9e0e8bb01e8a3ed71577c1bd314b2778af2fda55605c60e65525f3aa55ab945748986263e8144055ca396a2aab10b6b4cc5c341141e8cea15486af7c72e993b3ee1411636fbc2a2ba9c55d741831f6ce5c3e169b87931eafd6ba336c24cf5c7a325381289586773b8f48986b4bb9afc4bfe81b6628219361d809ccfb21a991487cac605dec8032ef845d5de98575b1dc262302eb651b8823893e81d396acc50f6d6ff383f442392e0b4482a484200469c8f04a9e1f9b5e21c66842f6e96c9a670c9c61abd388f06a51a0d2d8542f68960fa728ab5133a36eef0b6c137a3be4ba3bf0507efb2a98a1f1651d39af017666ca593e82430e888cee8619456f9fb47d84a5c33b8b5ebee06f75d885c12073401a449f56c16aa64ed3aa62363f77061bfedf72429b023d37d0d724d00a1248db0fead349f1c09b075372c980991b7b25d479d8f6e8def7e3fe501ab6794c3b976ce0bd04c006bac1a94fb6409f60c45e5c9ec2196a246368fb6faf3e6c53b51339b2eb3b52ec6f6dfc511f9b30952ccc814544af5ebd09bee3d004de334afd660f2807192e4bb3c0cba85745c8740fd20b5f39b9d3fbdb5579c0bd1a60320ad6a100c6402c7279679f25fefb1fa3cc8ea5e9f8db3222f83c7516dffd616b152f501ec8ad0552ab323db5fafd23876053317b483e00df829e5c57bbca6f8ca01a87562edf1769dbd542a8f6287effc3ac6732c68c4f5573695b27b0bbca58c8e1ffa35db8f011a010fa3d98fd2183b84afcb56c2dd1d35b9a53e479b6f84565d28e49bc4bfb9790e1ddf2daa4cb7e3362fb1341cee4c6e8ef20cada36774c01d07e9efe2bf11fb495dbda4dae909198eaad8e716b93d5a0d08ed1d0afc725e08e3c5b2f8e7594b78ff6e2fbf2122b648888b812900df01c4fad5ea0688fc31cd1cff191b3a8c1ad2f2f2218be0e1777ea752dfe8b021fa1e5a0cc0fb56f74e818acf3d6ce89e299b4a84fe0fd13e0b77cc43b81d2ada8d9165fa2668095770593cc7314211a1477e6ad206577b5fa86c75442f5fb9d35cfebcdaf0c7b3e89a0d6411bd3ae1e7e4900250e2d2071b35e226800bb57b8e0af2464369bf009b91e5563911d59dfa6aa78c14389d95a537f207d5ba202e5b9c5832603766295cfa911c819684e734a41b3472dca7b14a94a1b5100529a532915d60f573fbc9bc6e42b60a47681e6740008ba6fb5571be91ff296ec6b2a0dd915b6636521e7b9f9b6ff34052ec585566453b02d5da99f8fa108ba47996e85076a4b7a70e9b5b32944db75092ec4192623ad6ea6b049a7df7d9cee60b88fedb266ecaa8c71699a17ff5664526cc2b19ee1193602a575094c29a0591340e4183a3e3f54989a5b429d656b8fe4d699f73fd6a1d29c07efe830f54d2d38e6f0255dc14cdd20868470eb266382e9c6021ecc5e09686b3f3ebaefc93c9718146b6a70a1687f358452a0e286b79c5305aa5007373e07841c7fdeae5c8e7d44ec5716f2b8b03ada37f0500c0df01c1f040200b3ffae0cf51a3cb574b225837a58dc0921bdd19113f97ca92ff69432477322f547013ae5e58137c2dadcc8b576349af3dda7a94461460fd0030eecc8c73ea4751e41e238cd993bea0e2f3280bba1183eb3314e548b384f6db9086f420d03f60a04bf2cb8129024977c795679b072bcaf89afde9a771fd9930810b38bae12dccf3f2e5512721f2e6b7124501adde69f84cd877a5847187408da17bc9f9abce94b7d8cec7aec3adb851dfa63094366c464c3d2ef1c18473215d908dd433b3724c2ba1612a14d432a65c45150940002133ae4dd71dff89e10314e5581ac77d65f11199b043556f1d7a3c76b3c11183b5924a509f28fe6ed97f1fbfa9ebabf2c1e153c6e86e34570eae96fb1860e5e0a5a3e2ab3771fe71c4e3d06fa2965dcb999e71d0f803e89d65266c8252e4cc9789c10b36ac6150eba94e2ea78a5fc3c531e0a2df4f2f74ea7361d2b3d1939260f19c279605223a708f71312b6ebadfe6eeac31f66e3bc4595a67bc883b17f37d1018cff28c332ddefbe6c5aa56558218568ab9802eecea50fdb2f953b2aef7dad5b6e2f841521b62829076170ecdd4775619f151013cca830eb61bd960334fe1eaa0363cfb5735c904c70a239d59e9e0bcbaade14eecc86bc60622ca79cab5cabb2f3846e648b1eaf19bdf0caa02369b9655abb5040685a323c2ab4b3319ee9d5c021b8f79b540b19875fa09995f7997e623d7da8f837889a97e32d7711ed935f166812810e358829c7e61fd696dedfa17858ba9957f584a51b2272639b83c3ff1ac24696cdb30aeb532e30548fd948e46dbc312858ebf2ef34c6ffeafe28ed61ee7c3c735d4a14d9e864b7e342105d14203e13e045eee2b6a3aaabeadb6c4f15facb4fd0c742f442ef6abbb5654f3b1d41cd2105d81e799e86854dc7e44b476a3d816250cf62a1f25b8d2646fc8883a0c1c7b6a37f1524c369cb749247848a0b5692b285095bbf00ad19489d1462b17423820e0058428d2a0c55f5ea1dadf43e233f70613372f0928d937e41d65fecf16c223bdb7cde3759cbee74604085f2a7ce77326ea607808419f8509ee8efd85561d99735a969a7aac50c06c25a04abfc800bcadc9e447a2ec3453484fdd567050e1e9ec9db73dbd3105588cd675fda79e3674340c5c43465713e38d83d28f89ef16dff20153e21e78fb03d4ae6e39f2bdb83adf7e93d5a68948140f7f64c261c94692934411520f77602d4f7bcf46b2ed4a20068d40824713320f46a43b7d4b7500061af1e39f62e9724454614214f74bf8b88404d95fc1d96b591af70f4ddd366a02f45bfbc09ec03bd97857fac6dd031cb850496eb27b355fd3941da2547e6abca0a9a28507825530429f40a2c86dae9b66dfb68dc1462d7486900680ec0a427a18dee4f3ffea2e887ad8cb58ce0067af4d6b6aace1e7cd3375fecce78a399406b2a4220fe9e35d9f385b9ee39d7ab3b124e8b1dc9faf74b6d185626a36631eae397b23a6efa74dd5b43326841e7f7ca7820fbfb0af54ed8feb397454056acba48952755533a3a20838d87fe6ba9b7d096954b55a867bca1159a58cca9296399e1db33a62a4a563f3125f95ef47e1c9029317cfdf8e80204272f7080bb155c05282ce395c11548e4c66d2248c1133fc70f86dc07f9c9ee41041f0f404779a45d886e17325f51ebd59bc0d1f2bcc18f41113564257b7834602a9c60dff8e8a31f636c1b0e12b4c202e1329eaf664fd1cad181156b2395e0333e92e13b240b62eebeb92285b2a20ee6ba0d99de720c8c2da2f728d012784595b794fd647d0862e7ccf5f05449a36f877d48fac39dfd27f33e8d1e0a476341992eff743a6f6eabf4f8fd37a812dc60a1ebddf8991be14cdb6e6b0dc67b55106d672c372765d43bdcd0e804f1290dc7cc00ffa3b5390f92690fed0b667b9ffbcedb7d9ca091cf0bd9155ea3bb132f88515bad247b9479bf763bd6eb37392eb3cc1159798026e297f42e312d6842ada7c66a2b3b12754ccc782ef11c6a124237b79251e706a1bbe64bfb63501a6b101811caedfa3d25bdd8e2e1c3c9444216590a121386d90cec6ed5abea2a64af674eda86a85fbebfe98864e4c3fe9dbc8057f0f7c08660787bf86003604dd1fd8346f6381fb07745ae04d736fccc83426b33f01eab71b08041873c005e5f77a057bebde8ae2455464299bf582e614e58f48ff2ddfda2f474ef388789bdc25366f9c3c8b38e74b475f25546fcd9b97aeb26618b1ddf84846a0e79915f95e2466e598e20b457708cd55591c902de4cb90bace1bb8205d011a862487574a99eb77f19b6e0a9dc09662d09a1c4324633e85a1f0209f0be8c4a99a0251d6efe101ab93d1d0ba5a4dfa186f20f2868f169dcb7da83573906fea1e2ce9b4fcd7f5250115e01a70683faa002b5c40de6d0279af88c27773f8641c3604c0661a806b5f0177a28c0f586e0006058aa30dc7d6211e69ed72338ea6353c2dd94c2c21634bbcbee5690bcb6deebfc7da1ce591d766f05e4094b7c018839720a3d7c927c2486e3725f724d9db91ac15bb4d39eb8fced54557808fca5b5d83d7cd34dad0fc41e50ef5eb161e6f8a28514d96c51133c6fd5c7e756e14ec4362abfceddc6c837d79a323492638212670efa8e406000e03a39ce37d3faf5cfabc277375ac52d1b5cb0679e4fa33742d382274099bc9bbed5118e9dbf0f7315d62d1c7ec700c47bb78c1b6b21a19045b26eb1be6a366eb45748ab2fbc946e79c6a376d26549c2c8530ff8ee468dde7dd5730a1d4cd04dc62939bbdba9ba4650ac9526e8be5ee304a1fad5f06a2d519a63ef8ce29a86ee22c089c2b843242ef6a51e03aa9cf2d0a483c061ba9be96a4d8fe51550ba645bd62826a2f9a73a3ae14ba99586ef5562e9c72fefd3f752f7da3f046f6977fa0a5980e4a91587b086019b09e6ad3b3ee593e990fd5a9e34d7972cf0b7d9022b8b5196d5ac3a017da67dd1cf3ed67c7d2d281f9f25cfadf2b89b5ad6b4725a88f54ce029ac71e019a5e647b0acfded93fa9be8d3c48d283b57ccf8d5662979132e28785f0191ed756055f7960e44e3d35e8c15056dd488f46dba03a161250564f0bdc3eb9e153c9057a297271aeca93a072a1b3f6d9b1e6321f5f59c66fb26dcf3197533d928b155fdf5035634828aba3cbb28517711c20ad9f8abcc5167ccad925f4de817513830dc8e379d58629320f991ea7a90c2fb3e7bce5121ce64774fbe32a8b6e37ec3293d4648de53696413e680a2ae0810dd6db22469852dfd09072166b39a460a6445c0dd586cdecf1c20c8ae5bbef7dd1b588d40ccd2017f6bb4e3bbdda26a7e3a59ff453e350a44bcb4cdd572eacea8fa6484bb8d6612aebf3c6f47d29be463542f5d9eaec2771bf64e6370740e0d8de75b1357f8721671af537d5d4040cb084eb4e2cc34d2466a0115af84e1b0042895983a1d06b89fb4ce6ea0486f3f3b823520ab82011a1d4b277227f8611560b1e7933fdcbb3a792b344525bda08839e151ce794b2f32c9b7a01fbac9e01cc87ebcc7d1f6cf0111c3a1e8aac71a908749d44fbd9ad0dadecbd50ada380339c32ac69136678df9317ce0b12b4ff79e59b743f5bb3af2d519ff27d9459cbf97222c15e6fc2a0f91fc719b941525fae59361ceb69cebc2a8645912baa8d1b6c1075ee3056a0c10d25065cb03a442e0ec6e0e1698db3b4c98a0be3278e9649f1f9532e0d392dfd3a0342b8971f21e1b0a74414ba3348cc5be7120c37632d8df359f8d9b992f2ee60b6f470fe3f11de54cda541edad891ce6279cfcd3e7e6f1618b166fd2c1d05848fd2c5f6fb2299f523f357a632762393a8353156cccd02acf081625a75ebb56e16369788d273ccde96629281b949d04c50901b71c65614e6c6c7bd327a140a45e1d006c3f27b9ac9aa53fd62a80f00bb25bfe235bdd2f671126905b2040222b6cbcf7ccd769c2b53113ec01640e3d338abbd602547adf0ba38209cf746ce7677afa1c52075606085cbfe4e8ae88dd87aaaf9b04cf9aa7e1948c25c02fb8a8c01c36ae4d6ebe1f990d4f869a65cdea03f09252dc208e69fb74e6132ce77e25b578fdfe33ac372e6';
18
+ const INIT = new Uint32Array(1042);
19
+ for (let i = 0; i < 1042; i++) {
20
+ INIT[i] = parseInt(INIT_HEX.slice(i * 8, i * 8 + 8), 16) >>> 0;
21
+ }
22
+ const IV0 = 0x00010203 | 0;
23
+ const IV1 = 0x04050607 | 0;
24
+ class Blowfish {
25
+ constructor(key) {
26
+ /** flat key: P at [0..17], S0 at [18..], S1 at [274..], S2 at [530..], S3 at [786..] */
27
+ this.k = new Uint32Array(1042);
28
+ const k = this.k;
29
+ k.set(INIT);
30
+ let j = 0;
31
+ for (let i = 0; i < 18; i++) {
32
+ let d = 0;
33
+ for (let n = 0; n < 4; n++) {
34
+ d = ((d << 8) | key[j % key.length]) >>> 0;
35
+ j++;
36
+ }
37
+ k[i] = (k[i] ^ d) >>> 0;
38
+ }
39
+ let l = 0;
40
+ let r = 0;
41
+ for (let i = 0; i < 18; i += 2) {
42
+ [l, r] = this.encBlock(l, r);
43
+ k[i] = l >>> 0;
44
+ k[i + 1] = r >>> 0;
45
+ }
46
+ for (let s = 0; s < 4; s++) {
47
+ const base = 18 + s * 256;
48
+ for (let i = 0; i < 256; i += 2) {
49
+ [l, r] = this.encBlock(l, r);
50
+ k[base + i] = l >>> 0;
51
+ k[base + i + 1] = r >>> 0;
52
+ }
53
+ }
54
+ }
55
+ f(x) {
56
+ const k = this.k;
57
+ const a = (k[18 + ((x >>> 24) & 0xff)] + k[274 + ((x >>> 16) & 0xff)]) | 0;
58
+ return (((a ^ k[530 + ((x >>> 8) & 0xff)]) | 0) + k[786 + (x & 0xff)]) | 0;
59
+ }
60
+ encBlock(l, r) {
61
+ const k = this.k;
62
+ for (let i = 0; i < 16; i++) {
63
+ l = (l ^ k[i]) | 0;
64
+ r = (r ^ this.f(l)) | 0;
65
+ const t = l;
66
+ l = r;
67
+ r = t;
68
+ }
69
+ const t = l;
70
+ l = r;
71
+ r = t;
72
+ r = (r ^ k[16]) | 0;
73
+ l = (l ^ k[17]) | 0;
74
+ return [l >>> 0, r >>> 0];
75
+ }
76
+ /**
77
+ * Decrypt `src[off .. off+len)` in CBC mode with the fixed Deezer IV and no
78
+ * padding, writing into `out` at the same offset. `len` must be a multiple of 8.
79
+ */
80
+ decryptCbc(src, off, len, out) {
81
+ const k = this.k;
82
+ let pl = IV0;
83
+ let pr = IV1;
84
+ const end = off + len;
85
+ for (let p = off; p < end; p += 8) {
86
+ const cl = (src[p] << 24) | (src[p + 1] << 16) | (src[p + 2] << 8) | src[p + 3] | 0;
87
+ const cr = (src[p + 4] << 24) | (src[p + 5] << 16) | (src[p + 6] << 8) | src[p + 7] | 0;
88
+ let L = cl;
89
+ let R = cr;
90
+ for (let i = 17; i > 1; i -= 2) {
91
+ L = (L ^ k[i]) | 0;
92
+ R = (R ^ this.f(L)) | 0;
93
+ R = (R ^ k[i - 1]) | 0;
94
+ L = (L ^ this.f(R)) | 0;
95
+ }
96
+ const left = ((R ^ k[0]) >>> 0) ^ (pl >>> 0);
97
+ const right = ((L ^ k[1]) >>> 0) ^ (pr >>> 0);
98
+ out[p] = (left >>> 24) & 0xff;
99
+ out[p + 1] = (left >>> 16) & 0xff;
100
+ out[p + 2] = (left >>> 8) & 0xff;
101
+ out[p + 3] = left & 0xff;
102
+ out[p + 4] = (right >>> 24) & 0xff;
103
+ out[p + 5] = (right >>> 16) & 0xff;
104
+ out[p + 6] = (right >>> 8) & 0xff;
105
+ out[p + 7] = right & 0xff;
106
+ pl = cl;
107
+ pr = cr;
108
+ }
109
+ }
110
+ }
111
+ exports.Blowfish = Blowfish;
@@ -2,8 +2,26 @@
2
2
  import type { trackType } from '../types';
3
3
  export declare const getSongFileName: ({ MD5_ORIGIN, SNG_ID, MEDIA_VERSION }: trackType, quality: number) => string;
4
4
  /**
5
+ * Decrypt a downloaded track. Deezer applies Blowfish-CBC "stripe" obfuscation:
6
+ * the file is split into 2048-byte chunks and only every third chunk (0, 3, 6…)
7
+ * is encrypted; the rest — and any trailing partial chunk — are plaintext.
5
8
  *
6
- * @param source Downloaded song from `getTrackDownloadUrl`
7
- * @param trackId Song ID as string
9
+ * @param source Downloaded body from `getTrackDownloadUrl`
10
+ * @param trackId `SNG_ID` as a string
8
11
  */
9
12
  export declare const decryptDownload: (source: Buffer, trackId: string) => Buffer;
13
+ /**
14
+ * Streaming variant: feed it socket chunks in order via `write()`, then call
15
+ * `final()`. Decrypts each 2048-byte stripe as it completes — constant memory,
16
+ * and the CPU work hides behind the (slower) network read.
17
+ */
18
+ export declare class TrackDecryptStream {
19
+ private readonly bf;
20
+ private carry;
21
+ private chunkIndex;
22
+ constructor(trackId: string);
23
+ /** Returns the decrypted bytes for every complete 2048-byte stripe now available. */
24
+ write(part: Buffer): Buffer;
25
+ /** The trailing partial chunk (always plaintext). */
26
+ final(): Buffer;
27
+ }
@@ -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.decryptDownload = exports.getSongFileName = void 0;
6
+ exports.TrackDecryptStream = exports.decryptDownload = exports.getSongFileName = void 0;
7
7
  const crypto_1 = __importDefault(require("crypto"));
8
+ const blowfish_1 = require("./blowfish");
8
9
  const md5 = (data, type = 'ascii') => {
9
10
  const md5sum = crypto_1.default.createHash('md5');
10
11
  md5sum.update(data.toString(), type);
@@ -21,43 +22,81 @@ const getSongFileName = ({ MD5_ORIGIN, SNG_ID, MEDIA_VERSION }, quality) => {
21
22
  return crypto_1.default.createCipheriv('aes-128-ecb', 'jo6aey6haid2Teih', '').update(step2, 'ascii', 'hex');
22
23
  };
23
24
  exports.getSongFileName = getSongFileName;
25
+ const BLOWFISH_SECRET = 'g4el58wc0zvf9na1';
26
+ /** Per-track Blowfish key: md5(id)[i] ^ md5(id)[i+16] ^ SECRET[i], for i in 0..15. */
24
27
  const getBlowfishKey = (trackId) => {
25
- const SECRET = 'g4el58wc' + '0zvf9na1';
26
28
  const idMd5 = md5(trackId);
27
- let bfKey = '';
29
+ const key = Buffer.allocUnsafe(16);
28
30
  for (let i = 0; i < 16; i++) {
29
- bfKey += String.fromCharCode(idMd5.charCodeAt(i) ^ idMd5.charCodeAt(i + 16) ^ SECRET.charCodeAt(i));
31
+ key[i] = idMd5.charCodeAt(i) ^ idMd5.charCodeAt(i + 16) ^ BLOWFISH_SECRET.charCodeAt(i);
30
32
  }
31
- return bfKey;
32
- };
33
- const decryptChunk = (chunk, blowFishKey) => {
34
- const cipher = crypto_1.default.createDecipheriv('bf-cbc', blowFishKey, Buffer.from([0, 1, 2, 3, 4, 5, 6, 7]));
35
- cipher.setAutoPadding(false);
36
- return Buffer.concat([cipher.update(chunk), cipher.final()]);
33
+ return key;
37
34
  };
35
+ const CHUNK = 2048;
38
36
  /**
37
+ * Decrypt a downloaded track. Deezer applies Blowfish-CBC "stripe" obfuscation:
38
+ * the file is split into 2048-byte chunks and only every third chunk (0, 3, 6…)
39
+ * is encrypted; the rest — and any trailing partial chunk — are plaintext.
39
40
  *
40
- * @param source Downloaded song from `getTrackDownloadUrl`
41
- * @param trackId Song ID as string
41
+ * @param source Downloaded body from `getTrackDownloadUrl`
42
+ * @param trackId `SNG_ID` as a string
42
43
  */
43
44
  const decryptDownload = (source, trackId) => {
44
- const chunkSize = 2048;
45
- const blowFishKey = getBlowfishKey(trackId);
46
- let chunkIndex = 0;
45
+ const bf = new blowfish_1.Blowfish(getBlowfishKey(trackId));
46
+ const dest = Buffer.allocUnsafe(source.length);
47
47
  let position = 0;
48
- const destBuffer = Buffer.alloc(source.length);
48
+ let chunkIndex = 0;
49
49
  while (position < source.length) {
50
- const currentChunkSize = Math.min(chunkSize, source.length - position);
51
- const sourceChunk = source.subarray(position, position + currentChunkSize);
52
- if (chunkIndex % 3 > 0 || currentChunkSize < chunkSize) {
53
- sourceChunk.copy(destBuffer, position);
50
+ const size = Math.min(CHUNK, source.length - position);
51
+ if (chunkIndex % 3 === 0 && size === CHUNK) {
52
+ bf.decryptCbc(source, position, CHUNK, dest);
54
53
  }
55
54
  else {
56
- decryptChunk(sourceChunk, blowFishKey).copy(destBuffer, position);
55
+ source.copy(dest, position, position, position + size);
57
56
  }
58
- position += currentChunkSize;
57
+ position += size;
59
58
  chunkIndex++;
60
59
  }
61
- return destBuffer;
60
+ return dest;
62
61
  };
63
62
  exports.decryptDownload = decryptDownload;
63
+ /**
64
+ * Streaming variant: feed it socket chunks in order via `write()`, then call
65
+ * `final()`. Decrypts each 2048-byte stripe as it completes — constant memory,
66
+ * and the CPU work hides behind the (slower) network read.
67
+ */
68
+ class TrackDecryptStream {
69
+ constructor(trackId) {
70
+ this.carry = Buffer.alloc(0);
71
+ this.chunkIndex = 0;
72
+ this.bf = new blowfish_1.Blowfish(getBlowfishKey(trackId));
73
+ }
74
+ /** Returns the decrypted bytes for every complete 2048-byte stripe now available. */
75
+ write(part) {
76
+ const data = this.carry.length ? Buffer.concat([this.carry, part]) : part;
77
+ const complete = data.length - (data.length % CHUNK);
78
+ if (complete === 0) {
79
+ this.carry = data;
80
+ return Buffer.alloc(0);
81
+ }
82
+ const out = Buffer.allocUnsafe(complete);
83
+ for (let off = 0; off < complete; off += CHUNK) {
84
+ if (this.chunkIndex % 3 === 0) {
85
+ this.bf.decryptCbc(data, off, CHUNK, out);
86
+ }
87
+ else {
88
+ data.copy(out, off, off, off + CHUNK);
89
+ }
90
+ this.chunkIndex++;
91
+ }
92
+ this.carry = data.subarray(complete);
93
+ return out;
94
+ }
95
+ /** The trailing partial chunk (always plaintext). */
96
+ final() {
97
+ const rest = this.carry;
98
+ this.carry = Buffer.alloc(0);
99
+ return rest;
100
+ }
101
+ }
102
+ exports.TrackDecryptStream = TrackDecryptStream;
@@ -1,63 +1,21 @@
1
- /**
2
- * Fast LRU & TTL cache
3
- * @param {Integer} options.max - Max entries in the cache. @default Infinity
4
- * @param {Integer} options.ttl - Timeout before removing entries. @default Infinity
5
- */
6
1
  declare class FastLRU {
7
- _max: number;
8
- _ttl: number;
9
- _cache: Map<any, any>;
10
- _meta: {
11
- [key: string]: any;
12
- };
13
- constructor({ maxSize, ttl }: {
14
- maxSize?: number | undefined;
15
- ttl?: number | undefined;
2
+ private readonly max;
3
+ private readonly ttl;
4
+ private readonly map;
5
+ constructor({ maxSize, ttl }?: {
6
+ maxSize?: number;
7
+ ttl?: number;
16
8
  });
17
- /**
18
- * Add new entry
19
- */
20
- set(key: string, value: any, ttl?: number): void;
21
- /**
22
- * Get entry
23
- */
24
9
  get(key: string): any;
25
- /**
26
- * Get without hitting hits
27
- */
28
10
  peek(key: string): any;
29
- /**
30
- * Remove entry
31
- */
11
+ set(key: string, value: any, ttl?: number): void;
32
12
  delete(key: string): void;
33
- /**
34
- * Remove all entries
35
- */
36
13
  clear(): void;
37
- /**
38
- * Check has entry
39
- */
40
14
  has(key: string): boolean;
41
- /**
42
- * Get all kies
43
- * @returns {Iterator} Iterator on all kies
44
- */
45
- keys(): IterableIterator<any>;
46
- /**
47
- * Iterate over values
48
- */
49
- values(): IterableIterator<any>;
50
- /**
51
- * Iterate over entries
52
- */
53
- entries(): IterableIterator<[any, any]>;
54
- /**
55
- * For each
56
- */
57
- forEach(cb: any): void;
58
- /**
59
- * Entries total size
60
- */
15
+ keys(): IterableIterator<string>;
16
+ values(): IterableIterator<unknown>;
17
+ entries(): IterableIterator<[string, unknown]>;
18
+ forEach(cb: (value: unknown, key: string) => void): void;
61
19
  get size(): number;
62
20
  }
63
21
  export default FastLRU;
@@ -1,121 +1,75 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- /**
4
- * Fast LRU & TTL cache
5
- * @param {Integer} options.max - Max entries in the cache. @default Infinity
6
- * @param {Integer} options.ttl - Timeout before removing entries. @default Infinity
7
- */
8
3
  class FastLRU {
9
- constructor({ maxSize = Infinity, ttl = 0 }) {
10
- // Default options
11
- this._max = maxSize;
12
- this._ttl = ttl;
13
- this._cache = new Map();
14
- // Metadata for entries
15
- this._meta = {};
4
+ constructor({ maxSize = Infinity, ttl = 0 } = {}) {
5
+ this.map = new Map();
6
+ this.max = maxSize;
7
+ this.ttl = ttl;
16
8
  }
17
- /**
18
- * Add new entry
19
- */
20
- set(key, value, ttl = this._ttl) {
21
- // Execution time
22
- const time = Date.now();
23
- // Remvove least recently used elements if exceeds max bytes
24
- if (this._cache.size >= this._max) {
25
- const items = Object.values(this._meta);
26
- if (this._ttl > 0) {
27
- for (const item of items) {
28
- if (item.expire < time) {
29
- this.delete(item.key);
30
- }
31
- }
32
- }
33
- if (this._cache.size >= this._max) {
34
- const least = items.sort((a, b) => a.hits - b.hits)[0];
35
- this.delete(least.key);
36
- }
9
+ get(key) {
10
+ const e = this.map.get(key);
11
+ if (e === undefined)
12
+ return undefined;
13
+ if (e.expire !== 0 && e.expire < Date.now()) {
14
+ this.map.delete(key);
15
+ return undefined;
37
16
  }
38
- // Override if key already set
39
- this._cache.set(key, value);
40
- this._meta[key] = {
41
- key,
42
- hits: 0,
43
- expire: time + ttl,
44
- };
17
+ // bump to most-recently-used
18
+ this.map.delete(key);
19
+ this.map.set(key, e);
20
+ return e.value;
45
21
  }
46
- /**
47
- * Get entry
48
- */
49
- get(key) {
50
- if (this._cache.has(key)) {
51
- const item = this._cache.get(key);
52
- if (this._ttl > 0) {
53
- const time = Date.now();
54
- if (this._meta[key].expire < time) {
55
- this.delete(key);
56
- return undefined;
22
+ peek(key) {
23
+ var _a;
24
+ return (_a = this.map.get(key)) === null || _a === void 0 ? void 0 : _a.value;
25
+ }
26
+ set(key, value, ttl = this.ttl) {
27
+ this.map.delete(key);
28
+ if (this.map.size >= this.max) {
29
+ // drop expired first, then the LRU head if still over
30
+ if (this.ttl > 0) {
31
+ const now = Date.now();
32
+ for (const [k, e] of this.map) {
33
+ if (e.expire !== 0 && e.expire < now)
34
+ this.map.delete(k);
57
35
  }
58
36
  }
59
- this._meta[key].hits++;
60
- return item;
37
+ while (this.map.size >= this.max) {
38
+ const oldest = this.map.keys().next().value;
39
+ if (oldest === undefined)
40
+ break;
41
+ this.map.delete(oldest);
42
+ }
61
43
  }
44
+ this.map.set(key, { value, expire: ttl > 0 ? Date.now() + ttl : 0 });
62
45
  }
63
- /**
64
- * Get without hitting hits
65
- */
66
- peek(key) {
67
- return this._cache.get(key);
68
- }
69
- /**
70
- * Remove entry
71
- */
72
46
  delete(key) {
73
- delete this._meta[key];
74
- this._cache.delete(key);
47
+ this.map.delete(key);
75
48
  }
76
- /**
77
- * Remove all entries
78
- */
79
49
  clear() {
80
- this._cache.clear();
81
- this._meta = {};
50
+ this.map.clear();
82
51
  }
83
- /**
84
- * Check has entry
85
- */
86
52
  has(key) {
87
- return this._cache.has(key);
53
+ const e = this.map.get(key);
54
+ return e !== undefined && (e.expire === 0 || e.expire >= Date.now());
88
55
  }
89
- /**
90
- * Get all kies
91
- * @returns {Iterator} Iterator on all kies
92
- */
93
56
  keys() {
94
- return this._cache.keys();
57
+ return this.map.keys();
95
58
  }
96
- /**
97
- * Iterate over values
98
- */
99
- values() {
100
- return this._cache.values();
59
+ *values() {
60
+ for (const e of this.map.values())
61
+ yield e.value;
101
62
  }
102
- /**
103
- * Iterate over entries
104
- */
105
- entries() {
106
- return this._cache.entries();
63
+ *entries() {
64
+ for (const [k, e] of this.map)
65
+ yield [k, e.value];
107
66
  }
108
- /**
109
- * For each
110
- */
111
67
  forEach(cb) {
112
- return this._cache.forEach(cb);
68
+ for (const [k, e] of this.map)
69
+ cb(e.value, k);
113
70
  }
114
- /**
115
- * Entries total size
116
- */
117
71
  get size() {
118
- return this._cache.size;
72
+ return this.map.size;
119
73
  }
120
74
  }
121
75
  exports.default = FastLRU;
@@ -5,6 +5,14 @@ export declare class WrongLicense extends Error {
5
5
  export declare class GeoBlocked extends Error {
6
6
  constructor(country: string);
7
7
  }
8
+ /**
9
+ * The track's `TRACK_TOKEN` has expired (they last ~1 hour). Re-fetch the track
10
+ * with `getTrackInfo(SNG_ID)` for a fresh token and retry.
11
+ */
12
+ export declare class ExpiredTrackToken extends Error {
13
+ readonly sngId: string;
14
+ constructor(sngId: string);
15
+ }
8
16
  /**
9
17
  * @param track Track info json returned from `getTrackInfo`
10
18
  * @param quality 1 = 128kbps, 3 = 320kbps and 9 = flac (around 1411kbps)
@@ -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.GeoBlocked = exports.WrongLicense = void 0;
6
+ exports.getTrackDownloadUrl = 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"));
@@ -23,6 +24,18 @@ class GeoBlocked extends Error {
23
24
  }
24
25
  }
25
26
  exports.GeoBlocked = GeoBlocked;
27
+ /**
28
+ * The track's `TRACK_TOKEN` has expired (they last ~1 hour). Re-fetch the track
29
+ * with `getTrackInfo(SNG_ID)` for a fresh token and retry.
30
+ */
31
+ class ExpiredTrackToken extends Error {
32
+ constructor(sngId) {
33
+ super(`Track token for ${sngId} has expired — re-fetch the track and retry`);
34
+ this.sngId = sngId;
35
+ this.name = 'ExpiredTrackToken';
36
+ }
37
+ }
38
+ exports.ExpiredTrackToken = ExpiredTrackToken;
26
39
  let user_data = null;
27
40
  const getTrackFileSize = (track, quality) => {
28
41
  switch (quality) {
@@ -52,26 +65,45 @@ const dzAuthenticate = async () => {
52
65
  };
53
66
  return user_data;
54
67
  };
55
- const getTrackUrlFromServer = async (track_token, format) => {
68
+ const MEDIA_MAX_RETRIES = 3;
69
+ const getTrackUrlFromServer = async (track_token, format, attempt = 0) => {
56
70
  const user = user_data ? user_data : await dzAuthenticate();
57
71
  if ((format === 'FLAC' && !user.can_stream_lossless) || (format === 'MP3_320' && !user.can_stream_hq)) {
58
72
  throw new WrongLicense(format);
59
73
  }
60
- const { data } = await request_1.default.post('https://media.deezer.com/v1/get_url', {
61
- license_token: user.license_token,
62
- media: [
63
- {
64
- type: 'FULL',
65
- formats: [{ format, cipher: 'BF_CBC_STRIPE' }],
66
- },
67
- ],
68
- track_tokens: [track_token],
69
- });
74
+ let data;
75
+ try {
76
+ ({ data } = await request_1.default.post('https://media.deezer.com/v1/get_url', {
77
+ license_token: user.license_token,
78
+ media: [
79
+ {
80
+ type: 'FULL',
81
+ formats: [{ format, cipher: 'BF_CBC_STRIPE' }],
82
+ },
83
+ ],
84
+ track_tokens: [track_token],
85
+ }));
86
+ }
87
+ catch (err) {
88
+ // media.deezer.com throttles / 5xx / stale license_token — re-auth and back off
89
+ const status = err instanceof http_1.HttpStatusError ? err.statusCode : 0;
90
+ if ((status === 403 || status === 429 || status >= 500) && attempt < MEDIA_MAX_RETRIES) {
91
+ user_data = null;
92
+ await (0, delay_1.default)(500 * 2 ** attempt + Math.floor(Math.random() * 250));
93
+ return getTrackUrlFromServer(track_token, format, attempt + 1);
94
+ }
95
+ throw err;
96
+ }
70
97
  if (data.data.length > 0) {
71
98
  if (data.data[0].errors) {
72
- if (data.data[0].errors[0].code === 2002) {
99
+ const { code } = data.data[0].errors[0];
100
+ if (code === 2002) {
73
101
  throw new GeoBlocked(user.country);
74
102
  }
103
+ // 2000: invalid/expired token · 2001: token has no rights on this song
104
+ if (code === 2000 || code === 2001) {
105
+ throw new ExpiredTrackToken(track_token);
106
+ }
75
107
  throw new Error(Object.entries(data.data[0].errors[0]).join(', '));
76
108
  }
77
109
  return data.data[0].media.length > 0 ? data.data[0].media[0].sources[0].url : null;
@@ -85,6 +117,8 @@ const getTrackUrlFromServer = async (track_token, format) => {
85
117
  const getTrackDownloadUrl = async (track, quality) => {
86
118
  let wrongLicense = null;
87
119
  let geoBlocked = null;
120
+ let expiredToken = null;
121
+ let mediaBlocked = null;
88
122
  let formatName;
89
123
  switch (quality) {
90
124
  case 9:
@@ -99,26 +133,41 @@ const getTrackDownloadUrl = async (track, quality) => {
99
133
  default:
100
134
  throw new Error(`Unknown quality ${quality}`);
101
135
  }
102
- // Get URL with the official API
103
- try {
104
- const url = await getTrackUrlFromServer(track.TRACK_TOKEN, formatName);
105
- if (url) {
106
- return {
107
- trackUrl: url,
108
- isEncrypted: url.includes('/mobile/') || url.includes('/media/'),
109
- fileSize: getTrackFileSize(track, quality),
110
- };
111
- }
136
+ // Track tokens last ~1 hour. If it's already stale, skip the doomed media API
137
+ // call and go straight to the token-free fallback below.
138
+ const tokenStale = Boolean(track.TRACK_TOKEN_EXPIRE && track.TRACK_TOKEN_EXPIRE * 1000 < Date.now());
139
+ if (tokenStale) {
140
+ expiredToken = new ExpiredTrackToken(track.SNG_ID);
112
141
  }
113
- catch (err) {
114
- if (err instanceof WrongLicense) {
115
- wrongLicense = err;
116
- }
117
- else if (err instanceof GeoBlocked) {
118
- geoBlocked = err;
142
+ else {
143
+ // Get URL with the official API
144
+ try {
145
+ const url = await getTrackUrlFromServer(track.TRACK_TOKEN, formatName);
146
+ if (url) {
147
+ return {
148
+ trackUrl: url,
149
+ isEncrypted: url.includes('/mobile/') || url.includes('/media/'),
150
+ fileSize: getTrackFileSize(track, quality),
151
+ };
152
+ }
119
153
  }
120
- else {
121
- throw err;
154
+ catch (err) {
155
+ if (err instanceof WrongLicense) {
156
+ wrongLicense = err;
157
+ }
158
+ else if (err instanceof GeoBlocked) {
159
+ geoBlocked = err;
160
+ }
161
+ else if (err instanceof ExpiredTrackToken) {
162
+ expiredToken = err;
163
+ }
164
+ else if (err instanceof http_1.HttpStatusError && (err.statusCode === 403 || err.statusCode === 429)) {
165
+ // media API is throttling this account — try the token-free legacy CDN below
166
+ mediaBlocked = err;
167
+ }
168
+ else {
169
+ throw err;
170
+ }
122
171
  }
123
172
  }
124
173
  // Fallback to the old method
@@ -140,6 +189,12 @@ const getTrackDownloadUrl = async (track, quality) => {
140
189
  if (geoBlocked) {
141
190
  throw geoBlocked;
142
191
  }
192
+ if (expiredToken) {
193
+ throw expiredToken;
194
+ }
195
+ if (mediaBlocked) {
196
+ throw mediaBlocked;
197
+ }
143
198
  return null;
144
199
  };
145
200
  exports.getTrackDownloadUrl = getTrackDownloadUrl;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gerdur-core",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Core module for gerdur.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",