gerdur 0.0.0-probe → 1.0.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 +10 -0
- package/LICENSE +0 -0
- package/README.md +231 -1
- package/dist/package.json +90 -0
- package/dist/src/gerdur.d.ts +2 -0
- package/dist/src/gerdur.js +345 -0
- package/dist/src/hoenir.d.ts +2 -0
- package/dist/src/hoenir.js +345 -0
- package/dist/src/index.d.ts +62 -0
- package/dist/src/index.js +101 -0
- package/dist/src/kyrie.d.ts +2 -0
- package/dist/src/kyrie.js +345 -0
- package/dist/src/lib/api-download.d.ts +54 -0
- package/dist/src/lib/api-download.js +89 -0
- package/dist/src/lib/arl-setup.d.ts +82 -0
- package/dist/src/lib/arl-setup.js +274 -0
- package/dist/src/lib/auto-updater.d.ts +2 -0
- package/dist/src/lib/auto-updater.js +70 -0
- package/dist/src/lib/config.d.ts +53 -0
- package/dist/src/lib/config.js +164 -0
- package/dist/src/lib/decrypt-pool.d.ts +2 -0
- package/dist/src/lib/decrypt-pool.js +122 -0
- package/dist/src/lib/decrypt-worker.d.ts +1 -0
- package/dist/src/lib/decrypt-worker.js +17 -0
- package/dist/src/lib/decrypt.d.ts +3 -0
- package/dist/src/lib/decrypt.js +114 -0
- package/dist/src/lib/download-track.d.ts +24 -0
- package/dist/src/lib/download-track.js +163 -0
- package/dist/src/lib/email-login.d.ts +16 -0
- package/dist/src/lib/email-login.js +111 -0
- package/dist/src/lib/session.d.ts +84 -0
- package/dist/src/lib/session.js +118 -0
- package/dist/src/lib/signale.d.ts +15 -0
- package/dist/src/lib/signale.js +20 -0
- package/dist/src/lib/update-check.d.ts +2 -0
- package/dist/src/lib/update-check.js +30 -0
- package/dist/src/lib/util.d.ts +17 -0
- package/dist/src/lib/util.js +94 -0
- package/dist/src/q-fi.d.ts +2 -0
- package/dist/src/q-fi.js +345 -0
- package/package.json +88 -5
- package/setup-termux.js +41 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.decryptDownloadFile = void 0;
|
|
4
|
+
const os_1 = require("os");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const worker_threads_1 = require("worker_threads");
|
|
7
|
+
const idleTimeoutMs = 5000;
|
|
8
|
+
const maxWorkers = Math.max(1, Math.min(4, (0, os_1.cpus)().length));
|
|
9
|
+
const workers = new Set();
|
|
10
|
+
const queue = [];
|
|
11
|
+
let nextJobId = 0;
|
|
12
|
+
const getWorkerOptions = () => {
|
|
13
|
+
if (__filename.endsWith('.ts')) {
|
|
14
|
+
return {
|
|
15
|
+
path: (0, path_1.join)(__dirname, 'decrypt-worker.ts'),
|
|
16
|
+
execArgv: ['-r', 'ts-node/register'],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
path: (0, path_1.join)(__dirname, 'decrypt-worker.js'),
|
|
21
|
+
execArgv: process.execArgv,
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
const toBuffer = (outFile) => {
|
|
25
|
+
if (Buffer.isBuffer(outFile)) {
|
|
26
|
+
return outFile;
|
|
27
|
+
}
|
|
28
|
+
return Buffer.from(outFile.buffer, outFile.byteOffset, outFile.byteLength);
|
|
29
|
+
};
|
|
30
|
+
const disposeWorker = (state) => {
|
|
31
|
+
if (state.idleTimer) {
|
|
32
|
+
clearTimeout(state.idleTimer);
|
|
33
|
+
}
|
|
34
|
+
workers.delete(state);
|
|
35
|
+
void state.worker.terminate();
|
|
36
|
+
};
|
|
37
|
+
const rejectCurrent = (state, err) => {
|
|
38
|
+
if (state.current) {
|
|
39
|
+
state.current.reject(err);
|
|
40
|
+
state.current = undefined;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const scheduleIdleShutdown = (state) => {
|
|
44
|
+
if (state.idleTimer) {
|
|
45
|
+
clearTimeout(state.idleTimer);
|
|
46
|
+
}
|
|
47
|
+
state.idleTimer = setTimeout(() => disposeWorker(state), idleTimeoutMs);
|
|
48
|
+
};
|
|
49
|
+
const createWorker = () => {
|
|
50
|
+
const { path, execArgv } = getWorkerOptions();
|
|
51
|
+
const state = { worker: new worker_threads_1.Worker(path, { execArgv }) };
|
|
52
|
+
state.worker.on('message', ({ outFile, error, stack }) => {
|
|
53
|
+
const job = state.current;
|
|
54
|
+
state.current = undefined;
|
|
55
|
+
if (!job) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (error) {
|
|
59
|
+
const err = new Error(error);
|
|
60
|
+
err.stack = stack;
|
|
61
|
+
job.reject(err);
|
|
62
|
+
}
|
|
63
|
+
else if (outFile) {
|
|
64
|
+
job.resolve(toBuffer(outFile));
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
job.reject(new Error('Decrypt worker returned no output'));
|
|
68
|
+
}
|
|
69
|
+
runNextJob();
|
|
70
|
+
});
|
|
71
|
+
state.worker.on('error', (err) => {
|
|
72
|
+
rejectCurrent(state, err);
|
|
73
|
+
workers.delete(state);
|
|
74
|
+
runNextJob();
|
|
75
|
+
});
|
|
76
|
+
state.worker.on('exit', (code) => {
|
|
77
|
+
workers.delete(state);
|
|
78
|
+
if (code !== 0) {
|
|
79
|
+
rejectCurrent(state, new Error(`Decrypt worker exited with code ${code}`));
|
|
80
|
+
runNextJob();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
workers.add(state);
|
|
84
|
+
return state;
|
|
85
|
+
};
|
|
86
|
+
const getAvailableWorker = () => {
|
|
87
|
+
for (const state of workers) {
|
|
88
|
+
if (!state.current) {
|
|
89
|
+
return state;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (workers.size < maxWorkers) {
|
|
93
|
+
return createWorker();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
function runNextJob() {
|
|
97
|
+
const job = queue.shift();
|
|
98
|
+
if (!job) {
|
|
99
|
+
for (const state of workers) {
|
|
100
|
+
if (!state.current) {
|
|
101
|
+
scheduleIdleShutdown(state);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const state = getAvailableWorker();
|
|
107
|
+
if (!state) {
|
|
108
|
+
queue.unshift(job);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (state.idleTimer) {
|
|
112
|
+
clearTimeout(state.idleTimer);
|
|
113
|
+
state.idleTimer = undefined;
|
|
114
|
+
}
|
|
115
|
+
state.current = job;
|
|
116
|
+
state.worker.postMessage({ id: job.id, tmpfile: job.tmpfile, trackId: job.trackId });
|
|
117
|
+
}
|
|
118
|
+
const decryptDownloadFile = (tmpfile, trackId) => new Promise((resolve, reject) => {
|
|
119
|
+
queue.push({ id: nextJobId++, tmpfile, trackId, resolve, reject });
|
|
120
|
+
runNextJob();
|
|
121
|
+
});
|
|
122
|
+
exports.decryptDownloadFile = decryptDownloadFile;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const worker_threads_1 = require("worker_threads");
|
|
4
|
+
const decrypt_1 = require("./decrypt");
|
|
5
|
+
if (!worker_threads_1.parentPort) {
|
|
6
|
+
throw new Error('decrypt-worker must run inside a worker thread');
|
|
7
|
+
}
|
|
8
|
+
worker_threads_1.parentPort.on('message', ({ id, tmpfile, trackId }) => {
|
|
9
|
+
try {
|
|
10
|
+
const outFile = (0, decrypt_1.decryptDownloadFile)(tmpfile, trackId);
|
|
11
|
+
worker_threads_1.parentPort?.postMessage({ id, outFile }, [outFile.buffer]);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
15
|
+
worker_threads_1.parentPort?.postMessage({ id, error: error.message, stack: error.stack });
|
|
16
|
+
}
|
|
17
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.decryptDownloadFile = exports.decryptDownload = void 0;
|
|
7
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const gerdur_core_1 = require("gerdur-core");
|
|
10
|
+
const egoroof_blowfish_1 = require("egoroof-blowfish");
|
|
11
|
+
const md5 = (data, type = 'ascii') => {
|
|
12
|
+
const md5sum = crypto_1.default.createHash('md5');
|
|
13
|
+
md5sum.update(data.toString(), type);
|
|
14
|
+
return md5sum.digest('hex');
|
|
15
|
+
};
|
|
16
|
+
const getBlowfishKey = (trackId) => {
|
|
17
|
+
const SECRET = 'g4el58wc0zvf9na1';
|
|
18
|
+
const idMd5 = md5(trackId);
|
|
19
|
+
let bfKey = '';
|
|
20
|
+
for (let i = 0; i < 16; i++) {
|
|
21
|
+
bfKey += String.fromCharCode(idMd5.charCodeAt(i) ^ idMd5.charCodeAt(i + 16) ^ SECRET.charCodeAt(i));
|
|
22
|
+
}
|
|
23
|
+
return bfKey;
|
|
24
|
+
};
|
|
25
|
+
const decryptChunkFallback = (chunk, blowFishKey) => {
|
|
26
|
+
const bf = new egoroof_blowfish_1.Blowfish(blowFishKey, egoroof_blowfish_1.Blowfish.MODE.CBC, egoroof_blowfish_1.Blowfish.PADDING.NULL);
|
|
27
|
+
bf.setIv(new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]));
|
|
28
|
+
return Buffer.from(bf.decode(new Uint8Array(chunk)));
|
|
29
|
+
};
|
|
30
|
+
const decryptChunkNative = (chunk, blowFishKey) => {
|
|
31
|
+
const cipher = crypto_1.default.createDecipheriv('bf-cbc', blowFishKey, Buffer.from([0, 1, 2, 3, 4, 5, 6, 7]));
|
|
32
|
+
cipher.setAutoPadding(false);
|
|
33
|
+
return Buffer.concat([cipher.update(chunk), cipher.final()]);
|
|
34
|
+
};
|
|
35
|
+
const decryptChunk = (chunk, blowFishKey) => {
|
|
36
|
+
try {
|
|
37
|
+
return decryptChunkNative(chunk, blowFishKey);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (!canFallback(err)) {
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
return decryptChunkFallback(chunk, blowFishKey);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const decryptDownloadFallback = (source, trackId) => {
|
|
47
|
+
const chunkSize = 2048;
|
|
48
|
+
const blowFishKey = getBlowfishKey(trackId);
|
|
49
|
+
const destBuffer = Buffer.alloc(source.length);
|
|
50
|
+
let chunkIndex = 0;
|
|
51
|
+
let position = 0;
|
|
52
|
+
while (position < source.length) {
|
|
53
|
+
const currentChunkSize = Math.min(chunkSize, source.length - position);
|
|
54
|
+
const sourceChunk = source.subarray(position, position + currentChunkSize);
|
|
55
|
+
if (chunkIndex % 3 > 0 || currentChunkSize < chunkSize) {
|
|
56
|
+
sourceChunk.copy(destBuffer, position);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
decryptChunk(sourceChunk, blowFishKey).copy(destBuffer, position);
|
|
60
|
+
}
|
|
61
|
+
position += currentChunkSize;
|
|
62
|
+
chunkIndex++;
|
|
63
|
+
}
|
|
64
|
+
return destBuffer;
|
|
65
|
+
};
|
|
66
|
+
const canFallback = (err) => {
|
|
67
|
+
if (!(err instanceof Error)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const code = err.code;
|
|
71
|
+
return code === 'ERR_OSSL_EVP_UNSUPPORTED' || err.message.includes('digital envelope routines::unsupported');
|
|
72
|
+
};
|
|
73
|
+
const decryptDownload = (source, trackId) => {
|
|
74
|
+
try {
|
|
75
|
+
return (0, gerdur_core_1.decryptDownload)(source, trackId);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
if (!canFallback(err)) {
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
return decryptDownloadFallback(source, trackId);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
exports.decryptDownload = decryptDownload;
|
|
85
|
+
const decryptDownloadFile = (tmpfile, trackId) => {
|
|
86
|
+
const chunkSize = 2048;
|
|
87
|
+
const blowFishKey = getBlowfishKey(trackId);
|
|
88
|
+
const sourceLength = (0, fs_1.statSync)(tmpfile).size;
|
|
89
|
+
const destBuffer = Buffer.alloc(sourceLength);
|
|
90
|
+
const chunk = Buffer.alloc(chunkSize);
|
|
91
|
+
const fd = (0, fs_1.openSync)(tmpfile, 'r');
|
|
92
|
+
let chunkIndex = 0;
|
|
93
|
+
let position = 0;
|
|
94
|
+
try {
|
|
95
|
+
while (position < sourceLength) {
|
|
96
|
+
const currentChunkSize = Math.min(chunkSize, sourceLength - position);
|
|
97
|
+
const bytesRead = (0, fs_1.readSync)(fd, chunk, 0, currentChunkSize, position);
|
|
98
|
+
const sourceChunk = chunk.subarray(0, bytesRead);
|
|
99
|
+
if (chunkIndex % 3 > 0 || bytesRead < chunkSize) {
|
|
100
|
+
sourceChunk.copy(destBuffer, position);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
decryptChunk(sourceChunk, blowFishKey).copy(destBuffer, position);
|
|
104
|
+
}
|
|
105
|
+
position += bytesRead;
|
|
106
|
+
chunkIndex++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
(0, fs_1.closeSync)(fd);
|
|
111
|
+
}
|
|
112
|
+
return destBuffer;
|
|
113
|
+
};
|
|
114
|
+
exports.decryptDownloadFile = decryptDownloadFile;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { trackType } from 'gerdur-core/types';
|
|
2
|
+
interface downloadTrackProps {
|
|
3
|
+
track: trackType;
|
|
4
|
+
quality: string | number;
|
|
5
|
+
info: {
|
|
6
|
+
[key: string]: any;
|
|
7
|
+
};
|
|
8
|
+
coverSizes: {
|
|
9
|
+
'128': number;
|
|
10
|
+
'320': number;
|
|
11
|
+
flac: number;
|
|
12
|
+
};
|
|
13
|
+
path: string;
|
|
14
|
+
totalTracks: number;
|
|
15
|
+
trackNumber?: boolean;
|
|
16
|
+
fallbackTrack?: boolean;
|
|
17
|
+
fallbackQuality?: boolean;
|
|
18
|
+
isFallback?: boolean;
|
|
19
|
+
isQualityFallback?: boolean;
|
|
20
|
+
overwrite?: boolean;
|
|
21
|
+
message?: string;
|
|
22
|
+
}
|
|
23
|
+
declare const downloadTrack: ({ track, quality, info, coverSizes, path, totalTracks, trackNumber, fallbackTrack, fallbackQuality, isFallback, isQualityFallback, overwrite, message, }: downloadTrackProps) => Promise<string | undefined>;
|
|
24
|
+
export default downloadTrack;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const got_1 = __importDefault(require("got"));
|
|
7
|
+
const stream_1 = __importDefault(require("stream"));
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const util_1 = require("util");
|
|
10
|
+
const path_1 = require("path");
|
|
11
|
+
const gerdur_core_1 = require("gerdur-core");
|
|
12
|
+
const log_update_1 = __importDefault(require("log-update"));
|
|
13
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
14
|
+
const signale_1 = __importDefault(require("../lib/signale"));
|
|
15
|
+
const util_2 = require("./util");
|
|
16
|
+
const decrypt_pool_1 = require("./decrypt-pool");
|
|
17
|
+
const pipeline = (0, util_1.promisify)(stream_1.default.pipeline);
|
|
18
|
+
const simulate = process.env.SIMULATE;
|
|
19
|
+
const downloadTrack = async ({ track, quality, info, coverSizes, path, totalTracks, trackNumber = true, fallbackTrack = true, fallbackQuality = true, isFallback = false, isQualityFallback = false, overwrite = false, message = '', }) => {
|
|
20
|
+
(0, log_update_1.default)(signale_1.default.pending(track.SNG_TITLE + ' by ' + track.ART_NAME + ' from ' + track.ALB_TITLE));
|
|
21
|
+
try {
|
|
22
|
+
let ext = '.mp3', fileSize = 0, downloaded = 0, coverSize = 500;
|
|
23
|
+
switch (quality) {
|
|
24
|
+
case 1:
|
|
25
|
+
case '1':
|
|
26
|
+
case '128':
|
|
27
|
+
case 'MP3_128':
|
|
28
|
+
case '128kbps':
|
|
29
|
+
quality = 1;
|
|
30
|
+
fileSize = Number(track.FILESIZE_MP3_128);
|
|
31
|
+
coverSize = coverSizes['128'];
|
|
32
|
+
break;
|
|
33
|
+
case 9:
|
|
34
|
+
case '9':
|
|
35
|
+
case 'flac':
|
|
36
|
+
case 'Flac':
|
|
37
|
+
case 'FLAC':
|
|
38
|
+
quality = 9;
|
|
39
|
+
ext = '.flac';
|
|
40
|
+
fileSize = Number(track.FILESIZE_FLAC);
|
|
41
|
+
coverSize = coverSizes['flac'];
|
|
42
|
+
break;
|
|
43
|
+
default:
|
|
44
|
+
quality = 3;
|
|
45
|
+
fileSize = Number(track.FILESIZE_MP3_320);
|
|
46
|
+
coverSize = coverSizes['320'];
|
|
47
|
+
}
|
|
48
|
+
const savePath = (0, util_2.saveLayout)({ track, album: info, path, trackNumber, minimumIntegerDigits: totalTracks >= 100 ? 3 : 2 }) + ext;
|
|
49
|
+
if ((0, fs_1.existsSync)(savePath) && !overwrite) {
|
|
50
|
+
(0, log_update_1.default)(signale_1.default.info(`Skipped "${track.SNG_TITLE}", track already exists.`));
|
|
51
|
+
log_update_1.default.done();
|
|
52
|
+
(0, log_update_1.default)(signale_1.default.note(savePath));
|
|
53
|
+
log_update_1.default.done();
|
|
54
|
+
return savePath;
|
|
55
|
+
}
|
|
56
|
+
let trackData;
|
|
57
|
+
try {
|
|
58
|
+
trackData = await (0, gerdur_core_1.getTrackDownloadUrl)(track, quality);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (!(err instanceof gerdur_core_1.GeoBlocked) || !track.FALLBACK) {
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!trackData) {
|
|
66
|
+
if (fallbackTrack && track.FALLBACK && !isFallback && track.ART_ID === track.FALLBACK.ART_ID) {
|
|
67
|
+
const { FALLBACK, ...CURRENT_TRACK } = track;
|
|
68
|
+
return await downloadTrack({
|
|
69
|
+
track: { ...CURRENT_TRACK, ...FALLBACK },
|
|
70
|
+
quality,
|
|
71
|
+
info,
|
|
72
|
+
coverSizes,
|
|
73
|
+
path,
|
|
74
|
+
totalTracks,
|
|
75
|
+
trackNumber,
|
|
76
|
+
fallbackTrack: false,
|
|
77
|
+
isFallback: true,
|
|
78
|
+
overwrite,
|
|
79
|
+
message,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
else if (fallbackQuality && quality !== 1) {
|
|
83
|
+
return await downloadTrack({
|
|
84
|
+
track,
|
|
85
|
+
quality: quality === 9 ? 3 : 1,
|
|
86
|
+
info,
|
|
87
|
+
coverSizes,
|
|
88
|
+
path,
|
|
89
|
+
totalTracks,
|
|
90
|
+
trackNumber,
|
|
91
|
+
fallbackTrack,
|
|
92
|
+
isFallback,
|
|
93
|
+
isQualityFallback: true,
|
|
94
|
+
overwrite,
|
|
95
|
+
message,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
(0, log_update_1.default)(signale_1.default.warn(`Skipped "${track.SNG_TITLE}", track not available.`));
|
|
99
|
+
log_update_1.default.done();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const headers = {};
|
|
103
|
+
const tmpfile = `gerdur_${quality}_${track.SNG_ID}_${simulate ? 'simulate' : track.MD5_ORIGIN}`;
|
|
104
|
+
if (simulate) {
|
|
105
|
+
coverSize = 56;
|
|
106
|
+
headers.range = 'bytes=0-1023';
|
|
107
|
+
}
|
|
108
|
+
else if ((0, fs_1.existsSync)(tmpfile)) {
|
|
109
|
+
const tmpfilestat = (0, fs_1.statSync)(tmpfile);
|
|
110
|
+
downloaded = tmpfilestat.size;
|
|
111
|
+
headers.range = 'bytes=' + tmpfilestat.size + '-';
|
|
112
|
+
}
|
|
113
|
+
fileSize = trackData.fileSize;
|
|
114
|
+
const bar = (0, util_2.progressBar)(fileSize, 40);
|
|
115
|
+
const humanSizeTotal = (fileSize / 1024 / 1024).toFixed(2);
|
|
116
|
+
let transferredLast = downloaded;
|
|
117
|
+
await pipeline(got_1.default.stream(trackData.trackUrl, { responseType: 'buffer', headers }).on('downloadProgress', ({ transferred }) => {
|
|
118
|
+
// Report download progress
|
|
119
|
+
transferred += downloaded;
|
|
120
|
+
if (transferred - transferredLast > 50000) {
|
|
121
|
+
transferredLast = transferred;
|
|
122
|
+
(0, log_update_1.default)(signale_1.default.info(`Downloading ${track.SNG_TITLE} ${message}\n ${bar(transferred)} | ${humanSizeTotal}MiB`));
|
|
123
|
+
}
|
|
124
|
+
}), (0, fs_1.createWriteStream)(tmpfile, { flags: 'a', autoClose: true }));
|
|
125
|
+
let outFile;
|
|
126
|
+
if (trackData.isEncrypted) {
|
|
127
|
+
(0, log_update_1.default)(signale_1.default.pending('Decrypting ' + track.SNG_TITLE + ' by ' + track.ART_NAME));
|
|
128
|
+
outFile = await (0, decrypt_pool_1.decryptDownloadFile)(tmpfile, track.SNG_ID);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
outFile = (0, fs_1.readFileSync)(tmpfile);
|
|
132
|
+
}
|
|
133
|
+
(0, log_update_1.default)(signale_1.default.pending('Tagging ' + track.SNG_TITLE + ' by ' + track.ART_NAME));
|
|
134
|
+
const trackWithMetadata = await (0, gerdur_core_1.addTrackTags)(outFile, track, coverSize);
|
|
135
|
+
// Delete temporary file now
|
|
136
|
+
(0, fs_1.unlinkSync)(tmpfile);
|
|
137
|
+
(0, log_update_1.default)(signale_1.default.pending('Saving ' + track.SNG_TITLE + ' by ' + track.ART_NAME));
|
|
138
|
+
if (!simulate) {
|
|
139
|
+
// Create directory if not exists
|
|
140
|
+
const dir = (0, path_1.dirname)(savePath);
|
|
141
|
+
if (!(0, fs_1.existsSync)(dir)) {
|
|
142
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
// Save file to disk
|
|
145
|
+
(0, fs_1.writeFileSync)(savePath, trackWithMetadata);
|
|
146
|
+
}
|
|
147
|
+
// Print sucess info
|
|
148
|
+
(0, log_update_1.default)(signale_1.default.success(`${isFallback ? chalk_1.default.yellow('[Fallback] ') : ''}${track.SNG_TITLE} by ${track.ART_NAME}`));
|
|
149
|
+
log_update_1.default.done();
|
|
150
|
+
if (isQualityFallback) {
|
|
151
|
+
(0, log_update_1.default)(signale_1.default.note(`Used ${quality === 3 ? '320kbps' : '128kbps'} as other formats were unavailable`));
|
|
152
|
+
log_update_1.default.done();
|
|
153
|
+
}
|
|
154
|
+
return savePath;
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
(0, log_update_1.default)(signale_1.default.error(track.SNG_TITLE));
|
|
158
|
+
log_update_1.default.done();
|
|
159
|
+
(0, log_update_1.default)(signale_1.default.note(err.message));
|
|
160
|
+
log_update_1.default.done();
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
exports.default = downloadTrack;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Discriminated result so the caller can branch on the failure reason. */
|
|
2
|
+
export type LoginResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
arl: string;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
reason: 'wrong-credentials' | 'no-arl' | 'network' | 'unknown';
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Attempt to obtain an arl for the given credentials.
|
|
12
|
+
*
|
|
13
|
+
* The password is MD5-hashed and folded into the app hash before transmission;
|
|
14
|
+
* it is never written anywhere by this function.
|
|
15
|
+
*/
|
|
16
|
+
export declare const loginWithEmail: (email: string, password: string) => Promise<LoginResult>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.loginWithEmail = void 0;
|
|
7
|
+
const crypto_1 = require("crypto");
|
|
8
|
+
const got_1 = __importDefault(require("got"));
|
|
9
|
+
/**
|
|
10
|
+
* Email/password -> arl login for Deezer.
|
|
11
|
+
*
|
|
12
|
+
* Uses Deezer's mobile OAuth endpoint (connect.deezer.com/oauth/user_auth.php)
|
|
13
|
+
* rather than the web login form, which is bot-protected. The approach and the
|
|
14
|
+
* public app credentials/hash scheme mirror the well-known deemix flow.
|
|
15
|
+
*
|
|
16
|
+
* This is still best-effort: Deezer may reject the login (bad credentials,
|
|
17
|
+
* account protections, or changes on their side), in which case the caller
|
|
18
|
+
* should fall back to pasting an arl. Passwords handled here are never persisted
|
|
19
|
+
* or logged; only the resulting arl is returned.
|
|
20
|
+
*/
|
|
21
|
+
// Public Deezer app credentials used by the mobile OAuth flow (same values used
|
|
22
|
+
// by deemix and similar tools). Not secret in any meaningful sense.
|
|
23
|
+
const CLIENT_ID = '447462';
|
|
24
|
+
const CLIENT_SECRET = 'a83bf7f38ad2f137e444727cfc3775cf';
|
|
25
|
+
const UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0';
|
|
26
|
+
const md5 = (data) => (0, crypto_1.createHash)('md5').update(Buffer.from(data, 'utf8')).digest('hex');
|
|
27
|
+
/**
|
|
28
|
+
* Merge `set-cookie` response headers into a cookie map (last value wins),
|
|
29
|
+
* emulating a cookie jar shared across the login requests.
|
|
30
|
+
*/
|
|
31
|
+
const collectCookies = (jar, setCookie) => {
|
|
32
|
+
if (!setCookie) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const raw of setCookie) {
|
|
36
|
+
const [pair] = raw.split(';');
|
|
37
|
+
const eq = pair.indexOf('=');
|
|
38
|
+
if (eq > 0) {
|
|
39
|
+
const name = pair.slice(0, eq).trim();
|
|
40
|
+
const value = pair.slice(eq + 1).trim();
|
|
41
|
+
if (name) {
|
|
42
|
+
jar.set(name, value);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const cookieHeader = (jar) => [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
|
48
|
+
/**
|
|
49
|
+
* Attempt to obtain an arl for the given credentials.
|
|
50
|
+
*
|
|
51
|
+
* The password is MD5-hashed and folded into the app hash before transmission;
|
|
52
|
+
* it is never written anywhere by this function.
|
|
53
|
+
*/
|
|
54
|
+
const loginWithEmail = async (email, password) => {
|
|
55
|
+
const jar = new Map();
|
|
56
|
+
try {
|
|
57
|
+
// 1. Bootstrap a session so Deezer sets an initial sid cookie.
|
|
58
|
+
const boot = await (0, got_1.default)('https://www.deezer.com', {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'User-Agent': UA },
|
|
61
|
+
timeout: { request: 15000 },
|
|
62
|
+
throwHttpErrors: false,
|
|
63
|
+
});
|
|
64
|
+
collectCookies(jar, boot.headers['set-cookie']);
|
|
65
|
+
// 2. Authenticate via the mobile OAuth endpoint. The password is hashed,
|
|
66
|
+
// then combined with the public app id/secret into the request hash.
|
|
67
|
+
const passwordHash = md5(password);
|
|
68
|
+
const hash = md5([CLIENT_ID, email, passwordHash, CLIENT_SECRET].join(''));
|
|
69
|
+
let accessToken = null;
|
|
70
|
+
const auth = await (0, got_1.default)('https://connect.deezer.com/oauth/user_auth.php', {
|
|
71
|
+
searchParams: { app_id: CLIENT_ID, login: email, password: passwordHash, hash },
|
|
72
|
+
headers: { 'User-Agent': UA, cookie: cookieHeader(jar) },
|
|
73
|
+
responseType: 'json',
|
|
74
|
+
timeout: { request: 15000 },
|
|
75
|
+
throwHttpErrors: false,
|
|
76
|
+
});
|
|
77
|
+
collectCookies(jar, auth.headers['set-cookie']);
|
|
78
|
+
const authBody = auth.body ?? {};
|
|
79
|
+
if (authBody.access_token && authBody.access_token !== 'undefined') {
|
|
80
|
+
accessToken = authBody.access_token;
|
|
81
|
+
}
|
|
82
|
+
if (!accessToken && !jar.has('arl') && (authBody.error || auth.statusCode >= 400)) {
|
|
83
|
+
return { ok: false, reason: 'wrong-credentials', message: 'Login failed — check your email and password.' };
|
|
84
|
+
}
|
|
85
|
+
// 3. Read the arl for the now-authenticated session.
|
|
86
|
+
const arlResp = await (0, got_1.default)('https://www.deezer.com/ajax/gw-light.php', {
|
|
87
|
+
searchParams: { method: 'user.getArl', input: 3, api_version: '1.0', api_token: 'null' },
|
|
88
|
+
headers: { 'User-Agent': UA, cookie: cookieHeader(jar) },
|
|
89
|
+
responseType: 'json',
|
|
90
|
+
timeout: { request: 15000 },
|
|
91
|
+
throwHttpErrors: false,
|
|
92
|
+
});
|
|
93
|
+
collectCookies(jar, arlResp.headers['set-cookie']);
|
|
94
|
+
const arl = arlResp.body?.results;
|
|
95
|
+
if (typeof arl === 'string' && arl.length === 192) {
|
|
96
|
+
return { ok: true, arl };
|
|
97
|
+
}
|
|
98
|
+
if (jar.has('arl') && jar.get('arl').length === 192) {
|
|
99
|
+
return { ok: true, arl: jar.get('arl') };
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
reason: 'no-arl',
|
|
104
|
+
message: 'Logged in but could not retrieve an arl. Please paste your arl instead.',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
return { ok: false, reason: 'network', message: err?.message || 'Network error during login.' };
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
exports.loginWithEmail = loginWithEmail;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { parseInfo, searchMusic } from 'gerdur-core';
|
|
3
|
+
import type { Quality, DownloadTrackOptions, DownloadResult } from './api-download';
|
|
4
|
+
import type { trackType, userType } from 'gerdur-core/types';
|
|
5
|
+
/** Search result categories accepted by {@link Session.search}. */
|
|
6
|
+
export type SearchType = 'ALBUM' | 'ARTIST' | 'TRACK' | 'PLAYLIST' | 'RADIO' | 'SHOW' | 'USER' | 'LIVESTREAM' | 'CHANNEL';
|
|
7
|
+
export interface SessionOptions {
|
|
8
|
+
/** Deezer arl cookie. Provide this OR email+password. */
|
|
9
|
+
arl?: string;
|
|
10
|
+
/** Deezer account email (used with `password` to fetch an arl). */
|
|
11
|
+
email?: string;
|
|
12
|
+
/** Deezer account password. */
|
|
13
|
+
password?: string;
|
|
14
|
+
/** Default download concurrency for `downloadTracks`. Default 4. */
|
|
15
|
+
concurrency?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface DownloadTracksOptions extends DownloadTrackOptions {
|
|
18
|
+
/** Concurrent downloads. Defaults to the session concurrency. */
|
|
19
|
+
concurrency?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Called after each track settles, with the running progress. `result` is the
|
|
22
|
+
* download result (or `null` if the track was unavailable / errored).
|
|
23
|
+
*/
|
|
24
|
+
onProgress?: (progress: {
|
|
25
|
+
index: number;
|
|
26
|
+
total: number;
|
|
27
|
+
track: trackType;
|
|
28
|
+
result: DownloadResult | null;
|
|
29
|
+
}) => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* An authenticated Deezer session exposing high-level query and download
|
|
33
|
+
* helpers. Create one with {@link createSession}. All methods are silent (no
|
|
34
|
+
* console output); use `onProgress` callbacks for progress.
|
|
35
|
+
*/
|
|
36
|
+
export declare class Session {
|
|
37
|
+
/** The arl in use for this session. */
|
|
38
|
+
readonly arl: string;
|
|
39
|
+
private concurrency;
|
|
40
|
+
private constructor();
|
|
41
|
+
/**
|
|
42
|
+
* Log in and return a ready-to-use session. Accepts either an `arl` or
|
|
43
|
+
* `email`+`password` (which are exchanged for an arl via Deezer's OAuth flow).
|
|
44
|
+
*/
|
|
45
|
+
static create(options: SessionOptions): Promise<Session>;
|
|
46
|
+
/** Fetch the logged-in user profile. */
|
|
47
|
+
getUser(): Promise<userType>;
|
|
48
|
+
/**
|
|
49
|
+
* Resolve any supported URL (Deezer/Spotify/Tidal track, album, playlist, or
|
|
50
|
+
* artist) into its info + track list.
|
|
51
|
+
*/
|
|
52
|
+
parseUrl(url: string): ReturnType<typeof parseInfo>;
|
|
53
|
+
/** Search Deezer. Defaults to track results. */
|
|
54
|
+
search(query: string, types?: SearchType[], limit?: number): ReturnType<typeof searchMusic>;
|
|
55
|
+
/** Return a fully tagged audio Buffer for a track (nothing written to disk). */
|
|
56
|
+
getTrackBuffer(track: trackType, quality?: Quality, options?: {}): Promise<Buffer | null>;
|
|
57
|
+
/** Download a single track to disk. */
|
|
58
|
+
downloadTrack(track: trackType, quality?: Quality, options?: DownloadTrackOptions): Promise<DownloadResult | null>;
|
|
59
|
+
/**
|
|
60
|
+
* Download many tracks with bounded concurrency. Resolves to one result per
|
|
61
|
+
* input track (in the original order); entries are `null` for tracks that
|
|
62
|
+
* were unavailable or errored. Progress is reported via `onProgress`.
|
|
63
|
+
*/
|
|
64
|
+
downloadTracks(tracks: trackType[], quality?: Quality, options?: DownloadTracksOptions): Promise<(DownloadResult | null)[]>;
|
|
65
|
+
/**
|
|
66
|
+
* Convenience: resolve a URL and download all of its tracks in one call.
|
|
67
|
+
* Uses the linked album/playlist info for filename templating.
|
|
68
|
+
*/
|
|
69
|
+
downloadUrl(url: string, quality?: Quality, options?: DownloadTracksOptions): Promise<(DownloadResult | null)[]>;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Create and log in an authenticated {@link Session}.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```ts
|
|
76
|
+
* const session = await createSession({arl: '...'}); // or {email, password}
|
|
77
|
+
* const {tracks, linkinfo} = await session.parseUrl(url);
|
|
78
|
+
* await session.downloadTracks(tracks, 'flac', {
|
|
79
|
+
* output: 'Music/{ALB_TITLE}/{SNG_TITLE}',
|
|
80
|
+
* onProgress: ({index, total}) => console.log(`${index + 1}/${total}`),
|
|
81
|
+
* });
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
export declare const createSession: (options: SessionOptions) => Promise<Session>;
|