├── .github
└── ISSUE_TEMPLATE
│ └── bug_report.md
├── .gitignore
├── captcha
├── icon.png
├── main.js
├── package-lock.json
├── package.json
└── public
│ ├── html
│ ├── captcha.html
│ └── index.html
│ └── js
│ └── captcha.js
├── config
├── __init__.py
└── settings.example.py
├── deezer
├── __init__.py
└── deezer.py
├── readme.md
├── redsea.py
├── redsea
├── __init__.py
├── cli.py
├── decryption.py
├── mediadownloader.py
├── sessions.py
├── tagger.py
├── tidal_api.py
└── videodownloader.py
└── requirements.txt
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: Dniel97
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Use command '...'
16 | 2. Choose '....' (only needed for explore/search)
17 | 3. See error
18 |
19 | **Expected behavior**
20 | A clear and concise description of what you expected to happen.
21 |
22 | **Screenshots**
23 | If applicable, add screenshots to help explain your problem.
24 |
25 | **Desktop (please complete the following information):**
26 | - OS: [e.g. Android]
27 | - Python version [e.g. 3.6.9, 3.9.3]
28 |
29 | **Additional context**
30 | Add any other context about the problem here.
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | .venv
3 | venv/
4 | .vscode
5 | sessions.pk
6 | downloads/
7 | .DS_Store
8 | .idea/
9 | settings.py
10 | node_modules/
11 | failed_tracks.txt
12 | .python-version
13 |
--------------------------------------------------------------------------------
/captcha/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dniel97/RedSea/173b695b66e3ef9aa9aa3320680c9c001e7d9aff/captcha/icon.png
--------------------------------------------------------------------------------
/captcha/main.js:
--------------------------------------------------------------------------------
1 | const {app, BrowserWindow, Menu, Tray} = require('electron');
2 |
3 | const captcha = require('./public/js/captcha');
4 | const remote = require('electron').remote;
5 | captcha.registerScheme();
6 |
7 | let tray = null;
8 | let mainWindow;
9 | let trayIcon = __dirname + "/icon.png";
10 |
11 | const isMac = process.platform === 'darwin'
12 |
13 | function createTray() {
14 | tray = new Tray(trayIcon);
15 | const contextMenu = Menu.buildFromTemplate([
16 | {
17 | label: 'Show App',
18 | click: function () {
19 | if (mainWindow) mainWindow.show();
20 | }
21 | },
22 | {
23 | label: 'Quit',
24 | click: function () {
25 | app.isQuiting = true;
26 | if (mainWindow) {
27 | mainWindow.close()
28 | } else {
29 | app.quit()
30 | }
31 | }
32 | }
33 | ]);
34 |
35 | tray.setToolTip('Tidal Recaptcha');
36 | if (isMac) {
37 | app.dock.setIcon(trayIcon);
38 | }
39 | tray.setContextMenu(contextMenu);
40 |
41 | tray.on('click', function (e) {
42 | if (mainWindow) {
43 | if (mainWindow.isVisible()) {
44 | mainWindow.hide()
45 | } else {
46 | mainWindow.show()
47 | }
48 | }
49 | });
50 | }
51 |
52 | function createWindow() {
53 | // Create browser window
54 | mainWindow = new BrowserWindow({
55 | width: 350,
56 | height: 650,
57 | icon: trayIcon,
58 | webPreferences: {
59 | contextIsolation: true,
60 | enableRemoteModule: true
61 | }
62 | });
63 |
64 | mainWindow.setMenu(null);
65 |
66 | let template = [
67 | isMac ? {
68 | label: 'Tidal reCAPTCHA',
69 | submenu: [
70 | {label: "About Tidal reCAPTCHA", role: 'about'},
71 | {type: 'separator'},
72 | {
73 | label: "Quit", accelerator: "Command+Q", click: function () {
74 | app.quit();
75 | }
76 | }
77 | ]
78 | } : {
79 | label: 'Tidal reCAPTCHA',
80 | submenu: [
81 | {label: 'Close', role: 'quit'}
82 | ]
83 | },
84 | {
85 | label: 'View',
86 | submenu: [
87 | {label: 'Reload', role: 'reload'},
88 | {label: 'Force Reload', role: 'forceReload'},
89 | {label: 'Toggle Developer Tools', role: 'toggleDevTools'},
90 | {type: 'separator'},
91 | {label: 'Actual Size', role: 'resetZoom'},
92 | {label: 'Zoom in', role: 'zoomIn'},
93 | {label: 'Zoom out', role: 'zoomOut'},
94 | {type: 'separator'},
95 | {label: 'Toogle Full Screen', role: 'togglefullscreen'}
96 | ]
97 | },
98 | {
99 | label: "Edit",
100 | submenu: [
101 | {label: "Undo", accelerator: "CmdOrCtrl+Z", selector: "undo:"},
102 | {label: "Redo", accelerator: "Shift+CmdOrCtrl+Z", selector: "redo:"},
103 | {type: "separator"},
104 | {label: "Cut", accelerator: "CmdOrCtrl+X", selector: "cut:"},
105 | {label: "Copy", accelerator: "CmdOrCtrl+C", selector: "copy:"},
106 | {label: "Paste", accelerator: "CmdOrCtrl+V", selector: "paste:"},
107 | {label: "Select All", accelerator: "CmdOrCtrl+A", selector: "selectAll:"}
108 | ]
109 | }
110 | ];
111 |
112 | Menu.setApplicationMenu(Menu.buildFromTemplate(template));
113 |
114 | mainWindow.loadFile('public/html/index.html');
115 |
116 | // mainWindow.openDevTools();
117 | }
118 |
119 | // This method will be called when Electron has finished
120 | // initialization and is ready to create browser windows.
121 | app.on('ready', function () {
122 | createTray();
123 | createWindow();
124 | captcha.registerProtocol();
125 | });
126 |
127 | // Quit when all windows are closed, except on macOS. There, it's common
128 | // for applications and their menu bar to stay active until the user quits
129 | // explicitly with Cmd + Q.
130 | app.on('window-all-closed', () => {
131 | app.quit()
132 | });
133 |
--------------------------------------------------------------------------------
/captcha/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tidal_recaptcha",
3 | "version": "0.1.0",
4 | "lockfileVersion": 2,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "tidal_recaptcha",
9 | "version": "0.1.0",
10 | "devDependencies": {
11 | "electron": "^15.5.5"
12 | }
13 | },
14 | "node_modules/@electron/get": {
15 | "version": "1.14.1",
16 | "resolved": "https://registry.npmjs.org/@electron/get/-/get-1.14.1.tgz",
17 | "integrity": "sha512-BrZYyL/6m0ZXz/lDxy/nlVhQz+WF+iPS6qXolEU8atw7h6v1aYkjwJZ63m+bJMBTxDE66X+r2tPS4a/8C82sZw==",
18 | "dev": true,
19 | "dependencies": {
20 | "debug": "^4.1.1",
21 | "env-paths": "^2.2.0",
22 | "fs-extra": "^8.1.0",
23 | "got": "^9.6.0",
24 | "progress": "^2.0.3",
25 | "semver": "^6.2.0",
26 | "sumchecker": "^3.0.1"
27 | },
28 | "engines": {
29 | "node": ">=8.6"
30 | },
31 | "optionalDependencies": {
32 | "global-agent": "^3.0.0",
33 | "global-tunnel-ng": "^2.7.1"
34 | }
35 | },
36 | "node_modules/@sindresorhus/is": {
37 | "version": "0.14.0",
38 | "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz",
39 | "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==",
40 | "dev": true,
41 | "engines": {
42 | "node": ">=6"
43 | }
44 | },
45 | "node_modules/@szmarczak/http-timer": {
46 | "version": "1.1.2",
47 | "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",
48 | "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==",
49 | "dev": true,
50 | "dependencies": {
51 | "defer-to-connect": "^1.0.1"
52 | },
53 | "engines": {
54 | "node": ">=6"
55 | }
56 | },
57 | "node_modules/@types/node": {
58 | "version": "14.17.0",
59 | "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.0.tgz",
60 | "integrity": "sha512-w8VZUN/f7SSbvVReb9SWp6cJFevxb4/nkG65yLAya//98WgocKm5PLDAtSs5CtJJJM+kHmJjO/6mmYW4MHShZA==",
61 | "dev": true
62 | },
63 | "node_modules/boolean": {
64 | "version": "3.2.0",
65 | "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
66 | "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
67 | "dev": true,
68 | "optional": true
69 | },
70 | "node_modules/buffer-crc32": {
71 | "version": "0.2.13",
72 | "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
73 | "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=",
74 | "dev": true,
75 | "engines": {
76 | "node": "*"
77 | }
78 | },
79 | "node_modules/buffer-from": {
80 | "version": "1.1.1",
81 | "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
82 | "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
83 | "dev": true
84 | },
85 | "node_modules/cacheable-request": {
86 | "version": "6.1.0",
87 | "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
88 | "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==",
89 | "dev": true,
90 | "dependencies": {
91 | "clone-response": "^1.0.2",
92 | "get-stream": "^5.1.0",
93 | "http-cache-semantics": "^4.0.0",
94 | "keyv": "^3.0.0",
95 | "lowercase-keys": "^2.0.0",
96 | "normalize-url": "^4.1.0",
97 | "responselike": "^1.0.2"
98 | },
99 | "engines": {
100 | "node": ">=8"
101 | }
102 | },
103 | "node_modules/cacheable-request/node_modules/get-stream": {
104 | "version": "5.2.0",
105 | "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
106 | "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
107 | "dev": true,
108 | "dependencies": {
109 | "pump": "^3.0.0"
110 | },
111 | "engines": {
112 | "node": ">=8"
113 | },
114 | "funding": {
115 | "url": "https://github.com/sponsors/sindresorhus"
116 | }
117 | },
118 | "node_modules/cacheable-request/node_modules/lowercase-keys": {
119 | "version": "2.0.0",
120 | "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
121 | "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
122 | "dev": true,
123 | "engines": {
124 | "node": ">=8"
125 | }
126 | },
127 | "node_modules/clone-response": {
128 | "version": "1.0.2",
129 | "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz",
130 | "integrity": "sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q==",
131 | "dev": true,
132 | "dependencies": {
133 | "mimic-response": "^1.0.0"
134 | }
135 | },
136 | "node_modules/concat-stream": {
137 | "version": "1.6.2",
138 | "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
139 | "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
140 | "dev": true,
141 | "engines": [
142 | "node >= 0.8"
143 | ],
144 | "dependencies": {
145 | "buffer-from": "^1.0.0",
146 | "inherits": "^2.0.3",
147 | "readable-stream": "^2.2.2",
148 | "typedarray": "^0.0.6"
149 | }
150 | },
151 | "node_modules/config-chain": {
152 | "version": "1.1.13",
153 | "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
154 | "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
155 | "dev": true,
156 | "optional": true,
157 | "dependencies": {
158 | "ini": "^1.3.4",
159 | "proto-list": "~1.2.1"
160 | }
161 | },
162 | "node_modules/core-util-is": {
163 | "version": "1.0.2",
164 | "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
165 | "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
166 | "dev": true
167 | },
168 | "node_modules/debug": {
169 | "version": "4.3.4",
170 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
171 | "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
172 | "dev": true,
173 | "dependencies": {
174 | "ms": "2.1.2"
175 | },
176 | "engines": {
177 | "node": ">=6.0"
178 | },
179 | "peerDependenciesMeta": {
180 | "supports-color": {
181 | "optional": true
182 | }
183 | }
184 | },
185 | "node_modules/decompress-response": {
186 | "version": "3.3.0",
187 | "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
188 | "integrity": "sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==",
189 | "dev": true,
190 | "dependencies": {
191 | "mimic-response": "^1.0.0"
192 | },
193 | "engines": {
194 | "node": ">=4"
195 | }
196 | },
197 | "node_modules/defer-to-connect": {
198 | "version": "1.1.3",
199 | "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz",
200 | "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==",
201 | "dev": true
202 | },
203 | "node_modules/define-properties": {
204 | "version": "1.1.4",
205 | "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz",
206 | "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==",
207 | "dev": true,
208 | "optional": true,
209 | "dependencies": {
210 | "has-property-descriptors": "^1.0.0",
211 | "object-keys": "^1.1.1"
212 | },
213 | "engines": {
214 | "node": ">= 0.4"
215 | },
216 | "funding": {
217 | "url": "https://github.com/sponsors/ljharb"
218 | }
219 | },
220 | "node_modules/detect-node": {
221 | "version": "2.1.0",
222 | "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
223 | "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
224 | "dev": true,
225 | "optional": true
226 | },
227 | "node_modules/duplexer3": {
228 | "version": "0.1.4",
229 | "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz",
230 | "integrity": "sha512-CEj8FwwNA4cVH2uFCoHUrmojhYh1vmCdOaneKJXwkeY1i9jnlslVo9dx+hQ5Hl9GnH/Bwy/IjxAyOePyPKYnzA==",
231 | "dev": true
232 | },
233 | "node_modules/electron": {
234 | "version": "15.5.5",
235 | "resolved": "https://registry.npmjs.org/electron/-/electron-15.5.5.tgz",
236 | "integrity": "sha512-cGS1ueek14WLvLJlJbId3fmqJLvkr7VuBI0hHt6gpKaj8m2iv/NMteRg0deLgwlxjEF6ZGGNerUJW6a96rNq/Q==",
237 | "dev": true,
238 | "hasInstallScript": true,
239 | "dependencies": {
240 | "@electron/get": "^1.13.0",
241 | "@types/node": "^14.6.2",
242 | "extract-zip": "^1.0.3"
243 | },
244 | "bin": {
245 | "electron": "cli.js"
246 | },
247 | "engines": {
248 | "node": ">= 8.6"
249 | }
250 | },
251 | "node_modules/encodeurl": {
252 | "version": "1.0.2",
253 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
254 | "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
255 | "dev": true,
256 | "optional": true,
257 | "engines": {
258 | "node": ">= 0.8"
259 | }
260 | },
261 | "node_modules/end-of-stream": {
262 | "version": "1.4.4",
263 | "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
264 | "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
265 | "dev": true,
266 | "dependencies": {
267 | "once": "^1.4.0"
268 | }
269 | },
270 | "node_modules/env-paths": {
271 | "version": "2.2.1",
272 | "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
273 | "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
274 | "dev": true,
275 | "engines": {
276 | "node": ">=6"
277 | }
278 | },
279 | "node_modules/es6-error": {
280 | "version": "4.1.1",
281 | "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
282 | "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
283 | "dev": true,
284 | "optional": true
285 | },
286 | "node_modules/escape-string-regexp": {
287 | "version": "4.0.0",
288 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
289 | "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
290 | "dev": true,
291 | "optional": true,
292 | "engines": {
293 | "node": ">=10"
294 | },
295 | "funding": {
296 | "url": "https://github.com/sponsors/sindresorhus"
297 | }
298 | },
299 | "node_modules/extract-zip": {
300 | "version": "1.7.0",
301 | "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz",
302 | "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==",
303 | "dev": true,
304 | "dependencies": {
305 | "concat-stream": "^1.6.2",
306 | "debug": "^2.6.9",
307 | "mkdirp": "^0.5.4",
308 | "yauzl": "^2.10.0"
309 | },
310 | "bin": {
311 | "extract-zip": "cli.js"
312 | }
313 | },
314 | "node_modules/extract-zip/node_modules/debug": {
315 | "version": "2.6.9",
316 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
317 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
318 | "dev": true,
319 | "dependencies": {
320 | "ms": "2.0.0"
321 | }
322 | },
323 | "node_modules/extract-zip/node_modules/ms": {
324 | "version": "2.0.0",
325 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
326 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
327 | "dev": true
328 | },
329 | "node_modules/fd-slicer": {
330 | "version": "1.1.0",
331 | "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
332 | "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=",
333 | "dev": true,
334 | "dependencies": {
335 | "pend": "~1.2.0"
336 | }
337 | },
338 | "node_modules/fs-extra": {
339 | "version": "8.1.0",
340 | "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
341 | "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
342 | "dev": true,
343 | "dependencies": {
344 | "graceful-fs": "^4.2.0",
345 | "jsonfile": "^4.0.0",
346 | "universalify": "^0.1.0"
347 | },
348 | "engines": {
349 | "node": ">=6 <7 || >=8"
350 | }
351 | },
352 | "node_modules/function-bind": {
353 | "version": "1.1.1",
354 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
355 | "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
356 | "dev": true,
357 | "optional": true
358 | },
359 | "node_modules/get-intrinsic": {
360 | "version": "1.1.2",
361 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.2.tgz",
362 | "integrity": "sha512-Jfm3OyCxHh9DJyc28qGk+JmfkpO41A4XkneDSujN9MDXrm4oDKdHvndhZ2dN94+ERNfkYJWDclW6k2L/ZGHjXA==",
363 | "dev": true,
364 | "optional": true,
365 | "dependencies": {
366 | "function-bind": "^1.1.1",
367 | "has": "^1.0.3",
368 | "has-symbols": "^1.0.3"
369 | },
370 | "funding": {
371 | "url": "https://github.com/sponsors/ljharb"
372 | }
373 | },
374 | "node_modules/get-stream": {
375 | "version": "4.1.0",
376 | "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
377 | "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
378 | "dev": true,
379 | "dependencies": {
380 | "pump": "^3.0.0"
381 | },
382 | "engines": {
383 | "node": ">=6"
384 | }
385 | },
386 | "node_modules/global-agent": {
387 | "version": "3.0.0",
388 | "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
389 | "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
390 | "dev": true,
391 | "optional": true,
392 | "dependencies": {
393 | "boolean": "^3.0.1",
394 | "es6-error": "^4.1.1",
395 | "matcher": "^3.0.0",
396 | "roarr": "^2.15.3",
397 | "semver": "^7.3.2",
398 | "serialize-error": "^7.0.1"
399 | },
400 | "engines": {
401 | "node": ">=10.0"
402 | }
403 | },
404 | "node_modules/global-agent/node_modules/semver": {
405 | "version": "7.3.7",
406 | "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
407 | "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
408 | "dev": true,
409 | "optional": true,
410 | "dependencies": {
411 | "lru-cache": "^6.0.0"
412 | },
413 | "bin": {
414 | "semver": "bin/semver.js"
415 | },
416 | "engines": {
417 | "node": ">=10"
418 | }
419 | },
420 | "node_modules/global-tunnel-ng": {
421 | "version": "2.7.1",
422 | "resolved": "https://registry.npmjs.org/global-tunnel-ng/-/global-tunnel-ng-2.7.1.tgz",
423 | "integrity": "sha512-4s+DyciWBV0eK148wqXxcmVAbFVPqtc3sEtUE/GTQfuU80rySLcMhUmHKSHI7/LDj8q0gDYI1lIhRRB7ieRAqg==",
424 | "dev": true,
425 | "optional": true,
426 | "dependencies": {
427 | "encodeurl": "^1.0.2",
428 | "lodash": "^4.17.10",
429 | "npm-conf": "^1.1.3",
430 | "tunnel": "^0.0.6"
431 | },
432 | "engines": {
433 | "node": ">=0.10"
434 | }
435 | },
436 | "node_modules/globalthis": {
437 | "version": "1.0.3",
438 | "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz",
439 | "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==",
440 | "dev": true,
441 | "optional": true,
442 | "dependencies": {
443 | "define-properties": "^1.1.3"
444 | },
445 | "engines": {
446 | "node": ">= 0.4"
447 | },
448 | "funding": {
449 | "url": "https://github.com/sponsors/ljharb"
450 | }
451 | },
452 | "node_modules/got": {
453 | "version": "9.6.0",
454 | "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz",
455 | "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==",
456 | "dev": true,
457 | "dependencies": {
458 | "@sindresorhus/is": "^0.14.0",
459 | "@szmarczak/http-timer": "^1.1.2",
460 | "cacheable-request": "^6.0.0",
461 | "decompress-response": "^3.3.0",
462 | "duplexer3": "^0.1.4",
463 | "get-stream": "^4.1.0",
464 | "lowercase-keys": "^1.0.1",
465 | "mimic-response": "^1.0.1",
466 | "p-cancelable": "^1.0.0",
467 | "to-readable-stream": "^1.0.0",
468 | "url-parse-lax": "^3.0.0"
469 | },
470 | "engines": {
471 | "node": ">=8.6"
472 | }
473 | },
474 | "node_modules/graceful-fs": {
475 | "version": "4.2.10",
476 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
477 | "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
478 | "dev": true
479 | },
480 | "node_modules/has": {
481 | "version": "1.0.3",
482 | "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
483 | "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
484 | "dev": true,
485 | "optional": true,
486 | "dependencies": {
487 | "function-bind": "^1.1.1"
488 | },
489 | "engines": {
490 | "node": ">= 0.4.0"
491 | }
492 | },
493 | "node_modules/has-property-descriptors": {
494 | "version": "1.0.0",
495 | "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz",
496 | "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==",
497 | "dev": true,
498 | "optional": true,
499 | "dependencies": {
500 | "get-intrinsic": "^1.1.1"
501 | },
502 | "funding": {
503 | "url": "https://github.com/sponsors/ljharb"
504 | }
505 | },
506 | "node_modules/has-symbols": {
507 | "version": "1.0.3",
508 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
509 | "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
510 | "dev": true,
511 | "optional": true,
512 | "engines": {
513 | "node": ">= 0.4"
514 | },
515 | "funding": {
516 | "url": "https://github.com/sponsors/ljharb"
517 | }
518 | },
519 | "node_modules/http-cache-semantics": {
520 | "version": "4.1.0",
521 | "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
522 | "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==",
523 | "dev": true
524 | },
525 | "node_modules/inherits": {
526 | "version": "2.0.4",
527 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
528 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
529 | "dev": true
530 | },
531 | "node_modules/ini": {
532 | "version": "1.3.8",
533 | "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
534 | "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
535 | "dev": true,
536 | "optional": true
537 | },
538 | "node_modules/isarray": {
539 | "version": "1.0.0",
540 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
541 | "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
542 | "dev": true
543 | },
544 | "node_modules/json-buffer": {
545 | "version": "3.0.0",
546 | "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz",
547 | "integrity": "sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==",
548 | "dev": true
549 | },
550 | "node_modules/json-stringify-safe": {
551 | "version": "5.0.1",
552 | "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
553 | "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
554 | "dev": true,
555 | "optional": true
556 | },
557 | "node_modules/jsonfile": {
558 | "version": "4.0.0",
559 | "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
560 | "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
561 | "dev": true,
562 | "optionalDependencies": {
563 | "graceful-fs": "^4.1.6"
564 | }
565 | },
566 | "node_modules/keyv": {
567 | "version": "3.1.0",
568 | "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz",
569 | "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==",
570 | "dev": true,
571 | "dependencies": {
572 | "json-buffer": "3.0.0"
573 | }
574 | },
575 | "node_modules/lodash": {
576 | "version": "4.17.21",
577 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
578 | "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
579 | "dev": true,
580 | "optional": true
581 | },
582 | "node_modules/lowercase-keys": {
583 | "version": "1.0.1",
584 | "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
585 | "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==",
586 | "dev": true,
587 | "engines": {
588 | "node": ">=0.10.0"
589 | }
590 | },
591 | "node_modules/lru-cache": {
592 | "version": "6.0.0",
593 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
594 | "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
595 | "dev": true,
596 | "optional": true,
597 | "dependencies": {
598 | "yallist": "^4.0.0"
599 | },
600 | "engines": {
601 | "node": ">=10"
602 | }
603 | },
604 | "node_modules/matcher": {
605 | "version": "3.0.0",
606 | "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
607 | "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
608 | "dev": true,
609 | "optional": true,
610 | "dependencies": {
611 | "escape-string-regexp": "^4.0.0"
612 | },
613 | "engines": {
614 | "node": ">=10"
615 | }
616 | },
617 | "node_modules/mimic-response": {
618 | "version": "1.0.1",
619 | "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
620 | "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
621 | "dev": true,
622 | "engines": {
623 | "node": ">=4"
624 | }
625 | },
626 | "node_modules/minimist": {
627 | "version": "1.2.6",
628 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
629 | "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
630 | "dev": true
631 | },
632 | "node_modules/mkdirp": {
633 | "version": "0.5.5",
634 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
635 | "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
636 | "dev": true,
637 | "dependencies": {
638 | "minimist": "^1.2.5"
639 | },
640 | "bin": {
641 | "mkdirp": "bin/cmd.js"
642 | }
643 | },
644 | "node_modules/ms": {
645 | "version": "2.1.2",
646 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
647 | "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
648 | "dev": true
649 | },
650 | "node_modules/normalize-url": {
651 | "version": "4.5.1",
652 | "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz",
653 | "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==",
654 | "dev": true,
655 | "engines": {
656 | "node": ">=8"
657 | }
658 | },
659 | "node_modules/npm-conf": {
660 | "version": "1.1.3",
661 | "resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz",
662 | "integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==",
663 | "dev": true,
664 | "optional": true,
665 | "dependencies": {
666 | "config-chain": "^1.1.11",
667 | "pify": "^3.0.0"
668 | },
669 | "engines": {
670 | "node": ">=4"
671 | }
672 | },
673 | "node_modules/object-keys": {
674 | "version": "1.1.1",
675 | "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
676 | "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
677 | "dev": true,
678 | "optional": true,
679 | "engines": {
680 | "node": ">= 0.4"
681 | }
682 | },
683 | "node_modules/once": {
684 | "version": "1.4.0",
685 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
686 | "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
687 | "dev": true,
688 | "dependencies": {
689 | "wrappy": "1"
690 | }
691 | },
692 | "node_modules/p-cancelable": {
693 | "version": "1.1.0",
694 | "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz",
695 | "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==",
696 | "dev": true,
697 | "engines": {
698 | "node": ">=6"
699 | }
700 | },
701 | "node_modules/pend": {
702 | "version": "1.2.0",
703 | "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
704 | "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
705 | "dev": true
706 | },
707 | "node_modules/pify": {
708 | "version": "3.0.0",
709 | "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
710 | "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==",
711 | "dev": true,
712 | "optional": true,
713 | "engines": {
714 | "node": ">=4"
715 | }
716 | },
717 | "node_modules/prepend-http": {
718 | "version": "2.0.0",
719 | "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz",
720 | "integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==",
721 | "dev": true,
722 | "engines": {
723 | "node": ">=4"
724 | }
725 | },
726 | "node_modules/process-nextick-args": {
727 | "version": "2.0.1",
728 | "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
729 | "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
730 | "dev": true
731 | },
732 | "node_modules/progress": {
733 | "version": "2.0.3",
734 | "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
735 | "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
736 | "dev": true,
737 | "engines": {
738 | "node": ">=0.4.0"
739 | }
740 | },
741 | "node_modules/proto-list": {
742 | "version": "1.2.4",
743 | "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
744 | "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
745 | "dev": true,
746 | "optional": true
747 | },
748 | "node_modules/pump": {
749 | "version": "3.0.0",
750 | "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
751 | "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
752 | "dev": true,
753 | "dependencies": {
754 | "end-of-stream": "^1.1.0",
755 | "once": "^1.3.1"
756 | }
757 | },
758 | "node_modules/readable-stream": {
759 | "version": "2.3.7",
760 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
761 | "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
762 | "dev": true,
763 | "dependencies": {
764 | "core-util-is": "~1.0.0",
765 | "inherits": "~2.0.3",
766 | "isarray": "~1.0.0",
767 | "process-nextick-args": "~2.0.0",
768 | "safe-buffer": "~5.1.1",
769 | "string_decoder": "~1.1.1",
770 | "util-deprecate": "~1.0.1"
771 | }
772 | },
773 | "node_modules/responselike": {
774 | "version": "1.0.2",
775 | "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz",
776 | "integrity": "sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==",
777 | "dev": true,
778 | "dependencies": {
779 | "lowercase-keys": "^1.0.0"
780 | }
781 | },
782 | "node_modules/roarr": {
783 | "version": "2.15.4",
784 | "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
785 | "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
786 | "dev": true,
787 | "optional": true,
788 | "dependencies": {
789 | "boolean": "^3.0.1",
790 | "detect-node": "^2.0.4",
791 | "globalthis": "^1.0.1",
792 | "json-stringify-safe": "^5.0.1",
793 | "semver-compare": "^1.0.0",
794 | "sprintf-js": "^1.1.2"
795 | },
796 | "engines": {
797 | "node": ">=8.0"
798 | }
799 | },
800 | "node_modules/safe-buffer": {
801 | "version": "5.1.2",
802 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
803 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
804 | "dev": true
805 | },
806 | "node_modules/semver": {
807 | "version": "6.3.0",
808 | "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
809 | "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
810 | "dev": true,
811 | "bin": {
812 | "semver": "bin/semver.js"
813 | }
814 | },
815 | "node_modules/semver-compare": {
816 | "version": "1.0.0",
817 | "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
818 | "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
819 | "dev": true,
820 | "optional": true
821 | },
822 | "node_modules/serialize-error": {
823 | "version": "7.0.1",
824 | "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
825 | "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
826 | "dev": true,
827 | "optional": true,
828 | "dependencies": {
829 | "type-fest": "^0.13.1"
830 | },
831 | "engines": {
832 | "node": ">=10"
833 | },
834 | "funding": {
835 | "url": "https://github.com/sponsors/sindresorhus"
836 | }
837 | },
838 | "node_modules/sprintf-js": {
839 | "version": "1.1.2",
840 | "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
841 | "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==",
842 | "dev": true,
843 | "optional": true
844 | },
845 | "node_modules/string_decoder": {
846 | "version": "1.1.1",
847 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
848 | "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
849 | "dev": true,
850 | "dependencies": {
851 | "safe-buffer": "~5.1.0"
852 | }
853 | },
854 | "node_modules/sumchecker": {
855 | "version": "3.0.1",
856 | "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
857 | "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
858 | "dev": true,
859 | "dependencies": {
860 | "debug": "^4.1.0"
861 | },
862 | "engines": {
863 | "node": ">= 8.0"
864 | }
865 | },
866 | "node_modules/to-readable-stream": {
867 | "version": "1.0.0",
868 | "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz",
869 | "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==",
870 | "dev": true,
871 | "engines": {
872 | "node": ">=6"
873 | }
874 | },
875 | "node_modules/tunnel": {
876 | "version": "0.0.6",
877 | "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
878 | "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
879 | "dev": true,
880 | "optional": true,
881 | "engines": {
882 | "node": ">=0.6.11 <=0.7.0 || >=0.7.3"
883 | }
884 | },
885 | "node_modules/type-fest": {
886 | "version": "0.13.1",
887 | "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
888 | "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
889 | "dev": true,
890 | "optional": true,
891 | "engines": {
892 | "node": ">=10"
893 | },
894 | "funding": {
895 | "url": "https://github.com/sponsors/sindresorhus"
896 | }
897 | },
898 | "node_modules/typedarray": {
899 | "version": "0.0.6",
900 | "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
901 | "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=",
902 | "dev": true
903 | },
904 | "node_modules/universalify": {
905 | "version": "0.1.2",
906 | "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
907 | "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
908 | "dev": true,
909 | "engines": {
910 | "node": ">= 4.0.0"
911 | }
912 | },
913 | "node_modules/url-parse-lax": {
914 | "version": "3.0.0",
915 | "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",
916 | "integrity": "sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==",
917 | "dev": true,
918 | "dependencies": {
919 | "prepend-http": "^2.0.0"
920 | },
921 | "engines": {
922 | "node": ">=4"
923 | }
924 | },
925 | "node_modules/util-deprecate": {
926 | "version": "1.0.2",
927 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
928 | "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
929 | "dev": true
930 | },
931 | "node_modules/wrappy": {
932 | "version": "1.0.2",
933 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
934 | "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
935 | "dev": true
936 | },
937 | "node_modules/yallist": {
938 | "version": "4.0.0",
939 | "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
940 | "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
941 | "dev": true,
942 | "optional": true
943 | },
944 | "node_modules/yauzl": {
945 | "version": "2.10.0",
946 | "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
947 | "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=",
948 | "dev": true,
949 | "dependencies": {
950 | "buffer-crc32": "~0.2.3",
951 | "fd-slicer": "~1.1.0"
952 | }
953 | }
954 | },
955 | "dependencies": {
956 | "@electron/get": {
957 | "version": "1.14.1",
958 | "resolved": "https://registry.npmjs.org/@electron/get/-/get-1.14.1.tgz",
959 | "integrity": "sha512-BrZYyL/6m0ZXz/lDxy/nlVhQz+WF+iPS6qXolEU8atw7h6v1aYkjwJZ63m+bJMBTxDE66X+r2tPS4a/8C82sZw==",
960 | "dev": true,
961 | "requires": {
962 | "debug": "^4.1.1",
963 | "env-paths": "^2.2.0",
964 | "fs-extra": "^8.1.0",
965 | "global-agent": "^3.0.0",
966 | "global-tunnel-ng": "^2.7.1",
967 | "got": "^9.6.0",
968 | "progress": "^2.0.3",
969 | "semver": "^6.2.0",
970 | "sumchecker": "^3.0.1"
971 | }
972 | },
973 | "@sindresorhus/is": {
974 | "version": "0.14.0",
975 | "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz",
976 | "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==",
977 | "dev": true
978 | },
979 | "@szmarczak/http-timer": {
980 | "version": "1.1.2",
981 | "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",
982 | "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==",
983 | "dev": true,
984 | "requires": {
985 | "defer-to-connect": "^1.0.1"
986 | }
987 | },
988 | "@types/node": {
989 | "version": "14.17.0",
990 | "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.0.tgz",
991 | "integrity": "sha512-w8VZUN/f7SSbvVReb9SWp6cJFevxb4/nkG65yLAya//98WgocKm5PLDAtSs5CtJJJM+kHmJjO/6mmYW4MHShZA==",
992 | "dev": true
993 | },
994 | "boolean": {
995 | "version": "3.2.0",
996 | "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
997 | "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
998 | "dev": true,
999 | "optional": true
1000 | },
1001 | "buffer-crc32": {
1002 | "version": "0.2.13",
1003 | "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
1004 | "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=",
1005 | "dev": true
1006 | },
1007 | "buffer-from": {
1008 | "version": "1.1.1",
1009 | "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
1010 | "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
1011 | "dev": true
1012 | },
1013 | "cacheable-request": {
1014 | "version": "6.1.0",
1015 | "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
1016 | "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==",
1017 | "dev": true,
1018 | "requires": {
1019 | "clone-response": "^1.0.2",
1020 | "get-stream": "^5.1.0",
1021 | "http-cache-semantics": "^4.0.0",
1022 | "keyv": "^3.0.0",
1023 | "lowercase-keys": "^2.0.0",
1024 | "normalize-url": "^4.1.0",
1025 | "responselike": "^1.0.2"
1026 | },
1027 | "dependencies": {
1028 | "get-stream": {
1029 | "version": "5.2.0",
1030 | "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
1031 | "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
1032 | "dev": true,
1033 | "requires": {
1034 | "pump": "^3.0.0"
1035 | }
1036 | },
1037 | "lowercase-keys": {
1038 | "version": "2.0.0",
1039 | "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
1040 | "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
1041 | "dev": true
1042 | }
1043 | }
1044 | },
1045 | "clone-response": {
1046 | "version": "1.0.2",
1047 | "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz",
1048 | "integrity": "sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q==",
1049 | "dev": true,
1050 | "requires": {
1051 | "mimic-response": "^1.0.0"
1052 | }
1053 | },
1054 | "concat-stream": {
1055 | "version": "1.6.2",
1056 | "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
1057 | "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
1058 | "dev": true,
1059 | "requires": {
1060 | "buffer-from": "^1.0.0",
1061 | "inherits": "^2.0.3",
1062 | "readable-stream": "^2.2.2",
1063 | "typedarray": "^0.0.6"
1064 | }
1065 | },
1066 | "config-chain": {
1067 | "version": "1.1.13",
1068 | "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
1069 | "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
1070 | "dev": true,
1071 | "optional": true,
1072 | "requires": {
1073 | "ini": "^1.3.4",
1074 | "proto-list": "~1.2.1"
1075 | }
1076 | },
1077 | "core-util-is": {
1078 | "version": "1.0.2",
1079 | "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
1080 | "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
1081 | "dev": true
1082 | },
1083 | "debug": {
1084 | "version": "4.3.4",
1085 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
1086 | "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
1087 | "dev": true,
1088 | "requires": {
1089 | "ms": "2.1.2"
1090 | }
1091 | },
1092 | "decompress-response": {
1093 | "version": "3.3.0",
1094 | "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
1095 | "integrity": "sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==",
1096 | "dev": true,
1097 | "requires": {
1098 | "mimic-response": "^1.0.0"
1099 | }
1100 | },
1101 | "defer-to-connect": {
1102 | "version": "1.1.3",
1103 | "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz",
1104 | "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==",
1105 | "dev": true
1106 | },
1107 | "define-properties": {
1108 | "version": "1.1.4",
1109 | "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz",
1110 | "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==",
1111 | "dev": true,
1112 | "optional": true,
1113 | "requires": {
1114 | "has-property-descriptors": "^1.0.0",
1115 | "object-keys": "^1.1.1"
1116 | }
1117 | },
1118 | "detect-node": {
1119 | "version": "2.1.0",
1120 | "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
1121 | "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
1122 | "dev": true,
1123 | "optional": true
1124 | },
1125 | "duplexer3": {
1126 | "version": "0.1.4",
1127 | "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz",
1128 | "integrity": "sha512-CEj8FwwNA4cVH2uFCoHUrmojhYh1vmCdOaneKJXwkeY1i9jnlslVo9dx+hQ5Hl9GnH/Bwy/IjxAyOePyPKYnzA==",
1129 | "dev": true
1130 | },
1131 | "electron": {
1132 | "version": "15.5.5",
1133 | "resolved": "https://registry.npmjs.org/electron/-/electron-15.5.5.tgz",
1134 | "integrity": "sha512-cGS1ueek14WLvLJlJbId3fmqJLvkr7VuBI0hHt6gpKaj8m2iv/NMteRg0deLgwlxjEF6ZGGNerUJW6a96rNq/Q==",
1135 | "dev": true,
1136 | "requires": {
1137 | "@electron/get": "^1.13.0",
1138 | "@types/node": "^14.6.2",
1139 | "extract-zip": "^1.0.3"
1140 | }
1141 | },
1142 | "encodeurl": {
1143 | "version": "1.0.2",
1144 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
1145 | "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
1146 | "dev": true,
1147 | "optional": true
1148 | },
1149 | "end-of-stream": {
1150 | "version": "1.4.4",
1151 | "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
1152 | "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
1153 | "dev": true,
1154 | "requires": {
1155 | "once": "^1.4.0"
1156 | }
1157 | },
1158 | "env-paths": {
1159 | "version": "2.2.1",
1160 | "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
1161 | "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
1162 | "dev": true
1163 | },
1164 | "es6-error": {
1165 | "version": "4.1.1",
1166 | "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
1167 | "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
1168 | "dev": true,
1169 | "optional": true
1170 | },
1171 | "escape-string-regexp": {
1172 | "version": "4.0.0",
1173 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
1174 | "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
1175 | "dev": true,
1176 | "optional": true
1177 | },
1178 | "extract-zip": {
1179 | "version": "1.7.0",
1180 | "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz",
1181 | "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==",
1182 | "dev": true,
1183 | "requires": {
1184 | "concat-stream": "^1.6.2",
1185 | "debug": "^2.6.9",
1186 | "mkdirp": "^0.5.4",
1187 | "yauzl": "^2.10.0"
1188 | },
1189 | "dependencies": {
1190 | "debug": {
1191 | "version": "2.6.9",
1192 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
1193 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
1194 | "dev": true,
1195 | "requires": {
1196 | "ms": "2.0.0"
1197 | }
1198 | },
1199 | "ms": {
1200 | "version": "2.0.0",
1201 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
1202 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
1203 | "dev": true
1204 | }
1205 | }
1206 | },
1207 | "fd-slicer": {
1208 | "version": "1.1.0",
1209 | "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
1210 | "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=",
1211 | "dev": true,
1212 | "requires": {
1213 | "pend": "~1.2.0"
1214 | }
1215 | },
1216 | "fs-extra": {
1217 | "version": "8.1.0",
1218 | "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
1219 | "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
1220 | "dev": true,
1221 | "requires": {
1222 | "graceful-fs": "^4.2.0",
1223 | "jsonfile": "^4.0.0",
1224 | "universalify": "^0.1.0"
1225 | }
1226 | },
1227 | "function-bind": {
1228 | "version": "1.1.1",
1229 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
1230 | "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
1231 | "dev": true,
1232 | "optional": true
1233 | },
1234 | "get-intrinsic": {
1235 | "version": "1.1.2",
1236 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.2.tgz",
1237 | "integrity": "sha512-Jfm3OyCxHh9DJyc28qGk+JmfkpO41A4XkneDSujN9MDXrm4oDKdHvndhZ2dN94+ERNfkYJWDclW6k2L/ZGHjXA==",
1238 | "dev": true,
1239 | "optional": true,
1240 | "requires": {
1241 | "function-bind": "^1.1.1",
1242 | "has": "^1.0.3",
1243 | "has-symbols": "^1.0.3"
1244 | }
1245 | },
1246 | "get-stream": {
1247 | "version": "4.1.0",
1248 | "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
1249 | "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
1250 | "dev": true,
1251 | "requires": {
1252 | "pump": "^3.0.0"
1253 | }
1254 | },
1255 | "global-agent": {
1256 | "version": "3.0.0",
1257 | "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
1258 | "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
1259 | "dev": true,
1260 | "optional": true,
1261 | "requires": {
1262 | "boolean": "^3.0.1",
1263 | "es6-error": "^4.1.1",
1264 | "matcher": "^3.0.0",
1265 | "roarr": "^2.15.3",
1266 | "semver": "^7.3.2",
1267 | "serialize-error": "^7.0.1"
1268 | },
1269 | "dependencies": {
1270 | "semver": {
1271 | "version": "7.3.7",
1272 | "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
1273 | "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
1274 | "dev": true,
1275 | "optional": true,
1276 | "requires": {
1277 | "lru-cache": "^6.0.0"
1278 | }
1279 | }
1280 | }
1281 | },
1282 | "global-tunnel-ng": {
1283 | "version": "2.7.1",
1284 | "resolved": "https://registry.npmjs.org/global-tunnel-ng/-/global-tunnel-ng-2.7.1.tgz",
1285 | "integrity": "sha512-4s+DyciWBV0eK148wqXxcmVAbFVPqtc3sEtUE/GTQfuU80rySLcMhUmHKSHI7/LDj8q0gDYI1lIhRRB7ieRAqg==",
1286 | "dev": true,
1287 | "optional": true,
1288 | "requires": {
1289 | "encodeurl": "^1.0.2",
1290 | "lodash": "^4.17.10",
1291 | "npm-conf": "^1.1.3",
1292 | "tunnel": "^0.0.6"
1293 | }
1294 | },
1295 | "globalthis": {
1296 | "version": "1.0.3",
1297 | "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz",
1298 | "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==",
1299 | "dev": true,
1300 | "optional": true,
1301 | "requires": {
1302 | "define-properties": "^1.1.3"
1303 | }
1304 | },
1305 | "got": {
1306 | "version": "9.6.0",
1307 | "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz",
1308 | "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==",
1309 | "dev": true,
1310 | "requires": {
1311 | "@sindresorhus/is": "^0.14.0",
1312 | "@szmarczak/http-timer": "^1.1.2",
1313 | "cacheable-request": "^6.0.0",
1314 | "decompress-response": "^3.3.0",
1315 | "duplexer3": "^0.1.4",
1316 | "get-stream": "^4.1.0",
1317 | "lowercase-keys": "^1.0.1",
1318 | "mimic-response": "^1.0.1",
1319 | "p-cancelable": "^1.0.0",
1320 | "to-readable-stream": "^1.0.0",
1321 | "url-parse-lax": "^3.0.0"
1322 | }
1323 | },
1324 | "graceful-fs": {
1325 | "version": "4.2.10",
1326 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
1327 | "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
1328 | "dev": true
1329 | },
1330 | "has": {
1331 | "version": "1.0.3",
1332 | "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
1333 | "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
1334 | "dev": true,
1335 | "optional": true,
1336 | "requires": {
1337 | "function-bind": "^1.1.1"
1338 | }
1339 | },
1340 | "has-property-descriptors": {
1341 | "version": "1.0.0",
1342 | "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz",
1343 | "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==",
1344 | "dev": true,
1345 | "optional": true,
1346 | "requires": {
1347 | "get-intrinsic": "^1.1.1"
1348 | }
1349 | },
1350 | "has-symbols": {
1351 | "version": "1.0.3",
1352 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
1353 | "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
1354 | "dev": true,
1355 | "optional": true
1356 | },
1357 | "http-cache-semantics": {
1358 | "version": "4.1.0",
1359 | "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
1360 | "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==",
1361 | "dev": true
1362 | },
1363 | "inherits": {
1364 | "version": "2.0.4",
1365 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
1366 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
1367 | "dev": true
1368 | },
1369 | "ini": {
1370 | "version": "1.3.8",
1371 | "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
1372 | "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
1373 | "dev": true,
1374 | "optional": true
1375 | },
1376 | "isarray": {
1377 | "version": "1.0.0",
1378 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
1379 | "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
1380 | "dev": true
1381 | },
1382 | "json-buffer": {
1383 | "version": "3.0.0",
1384 | "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz",
1385 | "integrity": "sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==",
1386 | "dev": true
1387 | },
1388 | "json-stringify-safe": {
1389 | "version": "5.0.1",
1390 | "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
1391 | "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
1392 | "dev": true,
1393 | "optional": true
1394 | },
1395 | "jsonfile": {
1396 | "version": "4.0.0",
1397 | "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
1398 | "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
1399 | "dev": true,
1400 | "requires": {
1401 | "graceful-fs": "^4.1.6"
1402 | }
1403 | },
1404 | "keyv": {
1405 | "version": "3.1.0",
1406 | "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz",
1407 | "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==",
1408 | "dev": true,
1409 | "requires": {
1410 | "json-buffer": "3.0.0"
1411 | }
1412 | },
1413 | "lodash": {
1414 | "version": "4.17.21",
1415 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
1416 | "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
1417 | "dev": true,
1418 | "optional": true
1419 | },
1420 | "lowercase-keys": {
1421 | "version": "1.0.1",
1422 | "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
1423 | "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==",
1424 | "dev": true
1425 | },
1426 | "lru-cache": {
1427 | "version": "6.0.0",
1428 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
1429 | "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
1430 | "dev": true,
1431 | "optional": true,
1432 | "requires": {
1433 | "yallist": "^4.0.0"
1434 | }
1435 | },
1436 | "matcher": {
1437 | "version": "3.0.0",
1438 | "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
1439 | "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
1440 | "dev": true,
1441 | "optional": true,
1442 | "requires": {
1443 | "escape-string-regexp": "^4.0.0"
1444 | }
1445 | },
1446 | "mimic-response": {
1447 | "version": "1.0.1",
1448 | "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
1449 | "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
1450 | "dev": true
1451 | },
1452 | "minimist": {
1453 | "version": "1.2.6",
1454 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
1455 | "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
1456 | "dev": true
1457 | },
1458 | "mkdirp": {
1459 | "version": "0.5.5",
1460 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
1461 | "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
1462 | "dev": true,
1463 | "requires": {
1464 | "minimist": "^1.2.5"
1465 | }
1466 | },
1467 | "ms": {
1468 | "version": "2.1.2",
1469 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
1470 | "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
1471 | "dev": true
1472 | },
1473 | "normalize-url": {
1474 | "version": "4.5.1",
1475 | "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz",
1476 | "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==",
1477 | "dev": true
1478 | },
1479 | "npm-conf": {
1480 | "version": "1.1.3",
1481 | "resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz",
1482 | "integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==",
1483 | "dev": true,
1484 | "optional": true,
1485 | "requires": {
1486 | "config-chain": "^1.1.11",
1487 | "pify": "^3.0.0"
1488 | }
1489 | },
1490 | "object-keys": {
1491 | "version": "1.1.1",
1492 | "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
1493 | "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
1494 | "dev": true,
1495 | "optional": true
1496 | },
1497 | "once": {
1498 | "version": "1.4.0",
1499 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
1500 | "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
1501 | "dev": true,
1502 | "requires": {
1503 | "wrappy": "1"
1504 | }
1505 | },
1506 | "p-cancelable": {
1507 | "version": "1.1.0",
1508 | "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz",
1509 | "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==",
1510 | "dev": true
1511 | },
1512 | "pend": {
1513 | "version": "1.2.0",
1514 | "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
1515 | "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
1516 | "dev": true
1517 | },
1518 | "pify": {
1519 | "version": "3.0.0",
1520 | "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
1521 | "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==",
1522 | "dev": true,
1523 | "optional": true
1524 | },
1525 | "prepend-http": {
1526 | "version": "2.0.0",
1527 | "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz",
1528 | "integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==",
1529 | "dev": true
1530 | },
1531 | "process-nextick-args": {
1532 | "version": "2.0.1",
1533 | "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
1534 | "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
1535 | "dev": true
1536 | },
1537 | "progress": {
1538 | "version": "2.0.3",
1539 | "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
1540 | "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
1541 | "dev": true
1542 | },
1543 | "proto-list": {
1544 | "version": "1.2.4",
1545 | "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
1546 | "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
1547 | "dev": true,
1548 | "optional": true
1549 | },
1550 | "pump": {
1551 | "version": "3.0.0",
1552 | "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
1553 | "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
1554 | "dev": true,
1555 | "requires": {
1556 | "end-of-stream": "^1.1.0",
1557 | "once": "^1.3.1"
1558 | }
1559 | },
1560 | "readable-stream": {
1561 | "version": "2.3.7",
1562 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
1563 | "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
1564 | "dev": true,
1565 | "requires": {
1566 | "core-util-is": "~1.0.0",
1567 | "inherits": "~2.0.3",
1568 | "isarray": "~1.0.0",
1569 | "process-nextick-args": "~2.0.0",
1570 | "safe-buffer": "~5.1.1",
1571 | "string_decoder": "~1.1.1",
1572 | "util-deprecate": "~1.0.1"
1573 | }
1574 | },
1575 | "responselike": {
1576 | "version": "1.0.2",
1577 | "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz",
1578 | "integrity": "sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==",
1579 | "dev": true,
1580 | "requires": {
1581 | "lowercase-keys": "^1.0.0"
1582 | }
1583 | },
1584 | "roarr": {
1585 | "version": "2.15.4",
1586 | "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
1587 | "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
1588 | "dev": true,
1589 | "optional": true,
1590 | "requires": {
1591 | "boolean": "^3.0.1",
1592 | "detect-node": "^2.0.4",
1593 | "globalthis": "^1.0.1",
1594 | "json-stringify-safe": "^5.0.1",
1595 | "semver-compare": "^1.0.0",
1596 | "sprintf-js": "^1.1.2"
1597 | }
1598 | },
1599 | "safe-buffer": {
1600 | "version": "5.1.2",
1601 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
1602 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
1603 | "dev": true
1604 | },
1605 | "semver": {
1606 | "version": "6.3.0",
1607 | "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
1608 | "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
1609 | "dev": true
1610 | },
1611 | "semver-compare": {
1612 | "version": "1.0.0",
1613 | "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
1614 | "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
1615 | "dev": true,
1616 | "optional": true
1617 | },
1618 | "serialize-error": {
1619 | "version": "7.0.1",
1620 | "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
1621 | "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
1622 | "dev": true,
1623 | "optional": true,
1624 | "requires": {
1625 | "type-fest": "^0.13.1"
1626 | }
1627 | },
1628 | "sprintf-js": {
1629 | "version": "1.1.2",
1630 | "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
1631 | "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==",
1632 | "dev": true,
1633 | "optional": true
1634 | },
1635 | "string_decoder": {
1636 | "version": "1.1.1",
1637 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
1638 | "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
1639 | "dev": true,
1640 | "requires": {
1641 | "safe-buffer": "~5.1.0"
1642 | }
1643 | },
1644 | "sumchecker": {
1645 | "version": "3.0.1",
1646 | "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
1647 | "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
1648 | "dev": true,
1649 | "requires": {
1650 | "debug": "^4.1.0"
1651 | }
1652 | },
1653 | "to-readable-stream": {
1654 | "version": "1.0.0",
1655 | "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz",
1656 | "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==",
1657 | "dev": true
1658 | },
1659 | "tunnel": {
1660 | "version": "0.0.6",
1661 | "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
1662 | "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
1663 | "dev": true,
1664 | "optional": true
1665 | },
1666 | "type-fest": {
1667 | "version": "0.13.1",
1668 | "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
1669 | "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
1670 | "dev": true,
1671 | "optional": true
1672 | },
1673 | "typedarray": {
1674 | "version": "0.0.6",
1675 | "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
1676 | "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=",
1677 | "dev": true
1678 | },
1679 | "universalify": {
1680 | "version": "0.1.2",
1681 | "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
1682 | "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
1683 | "dev": true
1684 | },
1685 | "url-parse-lax": {
1686 | "version": "3.0.0",
1687 | "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",
1688 | "integrity": "sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==",
1689 | "dev": true,
1690 | "requires": {
1691 | "prepend-http": "^2.0.0"
1692 | }
1693 | },
1694 | "util-deprecate": {
1695 | "version": "1.0.2",
1696 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
1697 | "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
1698 | "dev": true
1699 | },
1700 | "wrappy": {
1701 | "version": "1.0.2",
1702 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
1703 | "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
1704 | "dev": true
1705 | },
1706 | "yallist": {
1707 | "version": "4.0.0",
1708 | "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
1709 | "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
1710 | "dev": true,
1711 | "optional": true
1712 | },
1713 | "yauzl": {
1714 | "version": "2.10.0",
1715 | "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
1716 | "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=",
1717 | "dev": true,
1718 | "requires": {
1719 | "buffer-crc32": "~0.2.3",
1720 | "fd-slicer": "~1.1.0"
1721 | }
1722 | }
1723 | }
1724 | }
1725 |
--------------------------------------------------------------------------------
/captcha/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tidal_recaptcha",
3 | "version": "0.1.0",
4 | "description": "Get the reCaptcha response from Tidal",
5 | "main": "main.js",
6 | "scripts": {
7 | "start": "electron ."
8 | },
9 | "author": "Dniel97",
10 | "devDependencies": {
11 | "electron": "^15.5.5"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/captcha/public/html/captcha.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | captcha
6 |
7 |
8 |
9 |
10 |
14 |
19 |
20 |
21 |
22 |
24 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/captcha/public/html/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Solve reCAPTCHA
6 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/captcha/public/js/captcha.js:
--------------------------------------------------------------------------------
1 | const {app, protocol} = require('electron');
2 | const url = require('url');
3 | const fs = require('fs');
4 | const path = require('path');
5 |
6 |
7 | let captchaPage = fs.readFileSync(path.join(__dirname, '..', 'html', 'captcha.html'), 'utf8');
8 | let count = 0;
9 |
10 |
11 | module.exports = {
12 | callbackResponse: function (data) {
13 | // registerProtocol must be called before callback can set
14 | // so this is just a placeholder for the real callback function
15 | console.log("'response': '" + data + "'");
16 | // if it recieves the second response, close the app
17 | if (count === 1)
18 | app.exit(0);
19 | count += 1;
20 | },
21 | registerScheme: function () {
22 | protocol.registerSchemesAsPrivileged([{ scheme: 'cap', privileges: { standard: true, secure: true, supportFetchAPI: true } }])
23 | // protocol.registerStandardSchemes(['cap']);
24 | },
25 | registerProtocol: function () {
26 | protocol.registerBufferProtocol('cap', (request, callback) => {
27 | let ReUrl = url.parse(request.url, true);
28 | if(ReUrl.query["g-recaptcha-response"])
29 | {
30 | let response = ReUrl.query["g-recaptcha-response"];
31 | this.callbackResponse(response);
32 | }
33 | callback({
34 | mimeType: 'text/html',
35 | data: Buffer.from(captchaPage)
36 | })
37 | })
38 | }
39 | };
40 |
--------------------------------------------------------------------------------
/config/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dniel97/RedSea/173b695b66e3ef9aa9aa3320680c9c001e7d9aff/config/__init__.py
--------------------------------------------------------------------------------
/config/settings.example.py:
--------------------------------------------------------------------------------
1 | '''
2 | Store your redsea download presets here
3 |
4 | You may modify/add/remove as you wish. The only preset which must exist is "default"
5 | and you may change the default as needed.
6 |
7 | === Stock Presets ===
8 | (use these with the -p flag)
9 | default: FLAC 44.1k / 16bit only
10 | best_available: Download the highest available quality (MQA > FLAC > 320 > 96)
11 | mqa_flac: Accept both MQA 24bit and FLAC 16bit
12 | MQA: Only allow FLAC 44.1k / 24bit (includes 'folded' 96k content)
13 | FLAC: FLAC 44.1k / 16bit only
14 | 320: AAC ~320 VBR only
15 | 96: AAC ~96 VBR only
16 |
17 | === Options ===
18 | keep_cover_jpg: Whether to keep the cover.jpg file in the album directory
19 | embed_album_art: Whether to embed album art or not into the file.
20 | save_album_json: save the album metadata as a json file
21 | tries: How many times to attempt to get a valid stream URL.
22 | path: Base download directory
23 | convert_to_alac: Converts a .flac file to an ALAC .m4a file (requires ffmpeg)
24 | save_credits_txt: Saves a {track_format}.txt file with the file containing all the credits of a specific song
25 | embed_credits: Embeds all the credits tags inside a FLAC/MP4 file
26 | save_lyrics_lrc: Saves synced lyrics as .lrc using the official Tidal provider: musixmatch
27 | embed_lyrics: Embed the unsynced lyrics inside a FLAC/MP4 file
28 | genre_language: Select the language of the genres from Deezer to "en-US", "de", "fr", ...
29 | artwork_size: Downloads (artwork_size)x(artwork_size) album covers from iTunes, set it to 0 to disable iTunes cover
30 | resolution: Which resolution you want to download the videos
31 |
32 | Format variables are {title}, {artist}, {album}, {tracknumber}, {discnumber}, {date}, {quality}, {explicit}.
33 | quality: has a whitespace in front, so it will look like this " [Dolby Atmos]", " [360]" or " [M]" according to the downloaded quality
34 | explicit: has a whitespace in front, so it will look like this " [E]"
35 | track_format: How tracks are formatted. The relevant extension is appended to the end.
36 | album_format: Base album directory - tracks and cover art are stored here. May have slashes in it, for instance {artist}/{album}.
37 | playlist_format: How playlist tracks are formatted, same as track_format just with {playlistnumber} added
38 |
39 | Format variables are {title}, {artist}, {tracknumber}, {discnumber}, {date}, {quality}, {explicit}.
40 | quality has a whitespace in front, so it will look like this " [1080P]" according to the highest available resolution returned by the API
41 | {explicit} has a whitespace in front, so it will look like this " [E]"
42 | video_file_format: How video filenames are formatted. The '.mp4' extension is appended to the end.
43 | video_folder_format: The video directory - tmp files and cover art are stored here. May have slashes in it, for instance {artist}/{title}.
44 |
45 |
46 | === Formats ===
47 | MQA_FLAC_24: MQA Format / 24bit FLAC with high-frequency "folded" data
48 | FLAC_16: 16bit FLAC
49 | AAC_320: 320Kbps AAC
50 | AAC_96: 96Kbps AAC
51 |
52 | '''
53 |
54 | # BRUTEFORCEREGION: Attempts to download the track/album with all available accounts if dl fails
55 | BRUTEFORCEREGION = True
56 |
57 | # Shows the Access JWT after every refresh and creation
58 | SHOWAUTH = False
59 |
60 | # The Desktop token
61 | TOKEN = 'c7RLy4RJ3OCNeZki' # MQA Token, unused
62 |
63 | # The mobile token which usually comes along with the authorization header
64 | # MOBILE_TOKEN = "WAU9gXp3tHhK4Nns" # MQA Token
65 | MOBILE_TOKEN = "dN2N95wCyEBTllu4" # Dolby Atmos AC-4 + MQA + FLAC + AAC
66 |
67 | # The TV_TOKEN and the line below (TV_SECRET) are tied together, so un-/comment both.
68 | TV_TOKEN = "7m7Ap0JC9j1cOM3n" # FireTV Dolby Atmos E-AC-3 + MQA
69 | TV_SECRET = "vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY="
70 |
71 | path = "./downloads/"
72 |
73 | PRESETS = {
74 |
75 | # Default settings / only download FLAC_16
76 | "default": {
77 | "keep_cover_jpg": True,
78 | "embed_album_art": True,
79 | "save_album_json": False,
80 | "tries": 5,
81 | "path": path,
82 | "track_format": "{tracknumber} - {title}",
83 | "playlist_format": "{playlistnumber} - {title}",
84 | "album_format": "{albumartist} - {album}{quality}{explicit}",
85 | "video_folder_format": "{artist} - {title}{quality}",
86 | "video_file_format": "{title}",
87 | "convert_to_alac": False,
88 | "save_credits_txt": False,
89 | "embed_credits": True,
90 | "save_lyrics_lrc": True,
91 | "embed_lyrics": True,
92 | "genre_language": "en-US",
93 | "artwork_size": 3000,
94 | "uncompressed_artwork": True,
95 | "resolution": 1080,
96 | "MQA_FLAC_24": True,
97 | "FLAC_16": True,
98 | "AAC_320": False,
99 | "AAC_96": False
100 | },
101 |
102 | # This will download the highest available quality including MQA
103 | "best_available": {
104 | "keep_cover_jpg": False,
105 | "embed_album_art": True,
106 | "save_album_json": False,
107 | "aggressive_remix_filtering": True,
108 | "skip_singles_when_possible": True,
109 | "skip_360ra": True,
110 | "tries": 5,
111 | "path": path,
112 | "track_format": "{tracknumber} - {title}",
113 | "playlist_format": "{playlistnumber} - {title}",
114 | "album_format": "{albumartist} - {album}{quality}{explicit}",
115 | "video_folder_format": "{artist} - {title}{quality}",
116 | "video_file_format": "{title}",
117 | "convert_to_alac": True,
118 | "save_credits_txt": False,
119 | "embed_credits": True,
120 | "save_lyrics_lrc": True,
121 | "embed_lyrics": True,
122 | "genre_language": "en-US",
123 | "artwork_size": 3000,
124 | "uncompressed_artwork": True,
125 | "resolution": 1080,
126 | "MQA_FLAC_24": True,
127 | "FLAC_16": True,
128 | "AAC_320": True,
129 | "AAC_96": True
130 | },
131 |
132 | # This preset will download every song from playlist inside a playlist folder
133 | "playlist": {
134 | "keep_cover_jpg": False,
135 | "embed_album_art": True,
136 | "save_album_json": False,
137 | "tries": 5,
138 | "path": path,
139 | "track_format": "{albumartist} - {title}",
140 | "playlist_format": "{playlistnumber} - {title}",
141 | "video_folder_format": "{artist} - {title}{quality}",
142 | "video_file_format": "{title}",
143 | "album_format": "",
144 | "convert_to_alac": False,
145 | "save_credits_txt": False,
146 | "embed_credits": True,
147 | "save_lyrics_lrc": True,
148 | "embed_lyrics": True,
149 | "genre_language": "en-US",
150 | "artwork_size": 3000,
151 | "uncompressed_artwork": False,
152 | "resolution": 1080,
153 | "MQA_FLAC_24": True,
154 | "FLAC_16": True,
155 | "AAC_320": False,
156 | "AAC_96": False
157 | },
158 |
159 | # This preset will only download FLAC 16
160 | "FLAC": {
161 | "keep_cover_jpg": True,
162 | "embed_album_art": True,
163 | "save_album_json": False,
164 | "tries": 5,
165 | "path": path,
166 | "track_format": "{tracknumber} - {title}",
167 | "playlist_format": "{playlistnumber} - {title}",
168 | "album_format": "{albumartist} - {album}{quality}{explicit}",
169 | "video_folder_format": "{artist} - {title}{quality}",
170 | "video_file_format": "{title}",
171 | "convert_to_alac": False,
172 | "save_credits_txt": False,
173 | "embed_credits": True,
174 | "save_lyrics_lrc": True,
175 | "embed_lyrics": True,
176 | "genre_language": "en-US",
177 | "artwork_size": 3000,
178 | "uncompressed_artwork": True,
179 | "resolution": 1080,
180 | "MQA_FLAC_24": False,
181 | "FLAC_16": True,
182 | "AAC_320": False,
183 | "AAC_96": False
184 | },
185 | }
186 |
--------------------------------------------------------------------------------
/deezer/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dniel97/RedSea/173b695b66e3ef9aa9aa3320680c9c001e7d9aff/deezer/__init__.py
--------------------------------------------------------------------------------
/deezer/deezer.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import time
3 |
4 | import requests
5 | import re
6 | import json
7 |
8 | USER_AGENT_HEADER = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " \
9 | "Chrome/79.0.3945.130 Safari/537.36"
10 |
11 |
12 | class Deezer:
13 | def __init__(self, language='en'):
14 | requests.packages.urllib3.disable_warnings(requests.packages.urllib3.exceptions.InsecureRequestWarning)
15 | self.api_url = "http://www.deezer.com/ajax/gw-light.php"
16 | self.legacy_api_url = "https://api.deezer.com/"
17 | self.http_headers = {
18 | "User-Agent": USER_AGENT_HEADER,
19 | "Accept-Language": language
20 | }
21 | self.session = requests.Session()
22 | self.session.post("https://www.deezer.com/", headers=self.http_headers, verify=False)
23 |
24 | def get_token(self):
25 | token_data = self.gw_api_call('deezer.getUserData')
26 | return token_data["results"]["checkForm"]
27 |
28 | def gw_api_call(self, method, args=None):
29 | if args is None:
30 | args = {}
31 | try:
32 | result = self.session.post(
33 | self.api_url,
34 | params={
35 | 'api_version': "1.0",
36 | 'api_token': 'null' if method == 'deezer.getUserData' else self.get_token(),
37 | 'input': '3',
38 | 'method': method
39 | },
40 | timeout=30,
41 | json=args,
42 | headers=self.http_headers,
43 | verify=False
44 | )
45 | result_json = result.json()
46 | except:
47 | time.sleep(2)
48 | return self.gw_api_call(method, args)
49 | if len(result_json['error']):
50 | raise APIError(json.dumps(result_json['error']))
51 | return result.json()
52 |
53 | def api_call(self, method, args=None):
54 | if args is None:
55 | args = {}
56 | try:
57 | result = self.session.get(
58 | self.legacy_api_url + method,
59 | params=args,
60 | headers=self.http_headers,
61 | timeout=30,
62 | verify=False
63 | )
64 | result_json = result.json()
65 | except:
66 | time.sleep(2)
67 | return self.api_call(method, args)
68 | if 'error' in result_json.keys():
69 | if 'code' in result_json['error'] and result_json['error']['code'] == 4:
70 | time.sleep(5)
71 | return self.api_call(method, args)
72 | raise APIError(json.dumps(result_json['error']))
73 | return result_json
74 |
75 | def get_track_gw(self, sng_id):
76 | if int(sng_id) < 0:
77 | body = self.gw_api_call('song.getData', {'sng_id': sng_id})
78 | else:
79 | body = self.gw_api_call('deezer.pageTrack', {'sng_id': sng_id})
80 | if 'LYRICS' in body['results']:
81 | body['results']['DATA']['LYRICS'] = body['results']['LYRICS']
82 | body['results'] = body['results']['DATA']
83 | return body['results']
84 |
85 | def get_tracks_gw(self, ids):
86 | tracks_array = []
87 | body = self.gw_api_call('song.getListData', {'sng_ids': ids})
88 | errors = 0
89 | for i in range(len(ids)):
90 | if ids[i] != 0:
91 | tracks_array.append(body['results']['data'][i - errors])
92 | else:
93 | errors += 1
94 | tracks_array.append({
95 | 'SNG_ID': 0,
96 | 'SNG_TITLE': '',
97 | 'DURATION': 0,
98 | 'MD5_ORIGIN': 0,
99 | 'MEDIA_VERSION': 0,
100 | 'FILESIZE': 0,
101 | 'ALB_TITLE': "",
102 | 'ALB_PICTURE': "",
103 | 'ART_ID': 0,
104 | 'ART_NAME': ""
105 | })
106 | return tracks_array
107 |
108 | def get_album_gw(self, alb_id):
109 | return self.gw_api_call('album.getData', {'alb_id': alb_id})['results']
110 |
111 | def get_album_tracks_gw(self, alb_id):
112 | tracks_array = []
113 | body = self.gw_api_call('song.getListByAlbum', {'alb_id': alb_id, 'nb': -1})
114 | for track in body['results']['data']:
115 | _track = track
116 | _track['position'] = body['results']['data'].index(track)
117 | tracks_array.append(_track)
118 | return tracks_array
119 |
120 | def get_artist_gw(self, art_id):
121 | return self.gw_api_call('deezer.pageArtist', {'art_id': art_id})
122 |
123 | def search_gw(self, term, type, start, nb=20):
124 | return \
125 | self.gw_api_call('search.music',
126 | {"query": clean_search_query(term), "filter": "ALL", "output": type, "start": start, "nb": nb})[
127 | 'results']
128 |
129 | def get_lyrics_gw(self, sng_id):
130 | return self.gw_api_call('song.getLyrics', {'sng_id': sng_id})["results"]
131 |
132 | def get_track(self, sng_id):
133 | return self.api_call('track/' + str(sng_id))
134 |
135 | def get_track_by_ISRC(self, isrc):
136 | return self.api_call('track/isrc:' + isrc)
137 |
138 | def get_album(self, album_id):
139 | return self.api_call('album/' + str(album_id))
140 |
141 | def get_album_by_UPC(self, upc):
142 | return self.api_call('album/upc:' + str(upc))
143 |
144 | def get_album_tracks(self, album_id):
145 | return self.api_call('album/' + str(album_id) + '/tracks', {'limit': -1})
146 |
147 | def get_artist(self, artist_id):
148 | return self.api_call('artist/' + str(artist_id))
149 |
150 | def get_artist_albums(self, artist_id):
151 | return self.api_call('artist/' + str(artist_id) + '/albums', {'limit': -1})
152 |
153 | def search(self, term, search_type, limit=30, index=0):
154 | return self.api_call('search/' + search_type, {'q': clean_search_query(term), 'limit': limit, 'index': index})
155 |
156 | def get_track_from_metadata(self, artist, track, album):
157 | artist = artist.replace("–", "-").replace("’", "'")
158 | track = track.replace("–", "-").replace("’", "'")
159 | album = album.replace("–", "-").replace("’", "'")
160 |
161 | resp = self.search(f'artist:"{artist}" track:"{track}" album:"{album}"', "track", 1)
162 | if len(resp['data']) > 0:
163 | return resp['data'][0]['id']
164 | resp = self.search(f'artist:"{artist}" track:"{track}"', "track", 1)
165 | if len(resp['data']) > 0:
166 | return resp['data'][0]['id']
167 | if "(" in track and ")" in track and track.find("(") < track.find(")"):
168 | resp = self.search(f'artist:"{artist}" track:"{track[:track.find("(")]}"', "track", 1)
169 | if len(resp['data']) > 0:
170 | return resp['data'][0]['id']
171 | elif " - " in track:
172 | resp = self.search(f'artist:"{artist}" track:"{track[:track.find(" - ")]}"', "track", 1)
173 | if len(resp['data']) > 0:
174 | return resp['data'][0]['id']
175 | else:
176 | return 0
177 | return 0
178 |
179 |
180 | def clean_search_query(term):
181 | term = str(term)
182 | term = re.sub(r' feat[\.]? ', " ", term)
183 | term = re.sub(r' ft[\.]? ', " ", term)
184 | term = re.sub(r'\(feat[\.]? ', " ", term)
185 | term = re.sub(r'\(ft[\.]? ', " ", term)
186 | term = term.replace('&', " ").replace('–', "-").replace('—', "-")
187 | return term
188 |
189 |
190 | class APIError(Exception):
191 | pass
192 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | RedSea
2 | ======
3 | Music downloader and tagger for Tidal. For educational use only, and will break in the future.
4 |
5 | Current state
6 | -------------
7 | **This fork will only get bug/hotfixes by me ([Dniel97](https://github.com/Dniel97)). Currently, Tidal changes/removes old
8 | tokens which supported single .flac/.m4a files and the newer tokens only receives MPEG-DASH which would require a lot of
9 | rewrite! For now is deprecated in favor of a newer [OrpheusDL](https://github.com/yarrm80s/orpheusdl) module:
10 | [Orpheus Tidal module](https://github.com/Dniel97/orpheusdl-tidal).**
11 |
12 | Telegram
13 | --------
14 | Join the telegram group [RedSea Community](https://t.me/RedSea_Community) if you have questions, want to get help,
15 | submit bugs or want to talk to the developer.
16 |
17 | Introduction
18 | ------------
19 | RedSea is a music downloader and tagger for the Tidal music streaming service. It is designed partially as a Tidal API example. This repository also hosts a wildly incomplete Python Tidal
20 | API implementation - it is contained in `redsea/tidal_api.py` and only requires `requests` to be installed.
21 |
22 | Choosing login types and client IDs
23 | -----------------------------------
24 | * To get the E-AC-3 codec version of Dolby Atmos Music, the TV sign in must be used with the client ID and secret of one of the supported Android TVs (full list below) (now included)
25 | * To get the AC-4 codec version of Dolby Atmos Music, the Mobile sign in must be used with the client ID of one of the supported phones (default mobile works)
26 | * To get MQA, use literally anything that is not the browser, nearly all client IDs work. (In this case change the client ID of the desktop login) (bring your own anything (TV, mobile, desktop))
27 | * To get ALAC without conversion, use the client ID of an iOS device, or the optional desktop token included from macOS (comment out the default FLAC supporting one, and uncomment the ALAC one) (secondary desktop works, or bring your own mobile)
28 | * To get 360, use the client ID of a supported Android or iOS device (nearly all support it anyway, so that's easy) (default mobile works)
29 |
30 | Client IDs provided by default:
31 | * TV: FireTV with E-AC-3 (Dolby Atmos) and MQA support
32 | * Mobile: Default has AC-4 support (which also supports MQA by extension). There is also another one which only supports MQA without AC-4 optionally (commented out)
33 | * Desktop: Neither of the included ones support MQA! You must replace it with your own if you want MQA support! Default token can get FLACs only, whereas the optional one can get ALACs only (both are also able to get AAC)
34 | * Browser: Is completely unsupported for now, though why would you want it anyway?
35 |
36 | Further Reading has moved to the wiki: [https://github.com/Dniel97/RedSea/wiki/Technical-info](https://github.com/Dniel97/RedSea/wiki/Technical-info)
37 |
38 | Requirements
39 | ------------
40 | * Python (3.6 or higher)
41 | * requests (2.22.0 or higher)
42 | * mutagen (1.37 or higher)
43 | * pycryptodomex
44 | * ffmpeg-python (0.2.0 or higher)
45 | * prettytable (1.0.0 or higher)
46 | * tqdm (4.56.0 or higher)
47 | * deezerapi (already included from [deemix](https://codeberg.org/RemixDev/deemix))
48 |
49 |
50 | Installation
51 | ------------
52 | The new more detailed Installation Guide has been moved to the wiki: [https://github.com/Dniel97/RedSea/wiki/Installation-Guide](https://github.com/Dniel97/RedSea/wiki/Installation-Guide)
53 |
54 | How to add accounts/sessions
55 | ----------------------------
56 | usage: redsea.py auth list
57 | redsea.py auth add
58 | redsea.py auth remove
59 | redsea.py auth default
60 | redsea.py auth reauth
61 |
62 | positional arguments:
63 |
64 | list Lists stored sessions if any exist
65 |
66 | add Prompts for a TV, Mobile or Desktop session. The TV option
67 | displays a 6 digit key which should be entered inside
68 | link.tidal.com where the user can login. The Mobile/Desktop
69 | option prompts for a Tidal username and password. Both options
70 | authorize a session which then gets stored in
71 | the sessions file
72 |
73 | remove Removes a stored session from the sessions file
74 | by name
75 |
76 | default Set a default account for redsea to use when the
77 | -a flag has not been passed
78 |
79 | reauth Reauthenticates with server to get new sessionId
80 |
81 | Further reading on which session to choose and which prompts to choose in the wiki: [https://github.com/Dniel97/RedSea/wiki/Adding-a-session](https://github.com/Dniel97/RedSea/wiki/Adding-a-session)
82 |
83 | How to use
84 | ----------
85 | usage: redsea.py [-h] [-p PRESET] [-a ACCOUNT] [-s] [--file FILE] urls [urls ...]
86 |
87 | A music downloader for Tidal.
88 |
89 | positional arguments:
90 | urls The URLs to download. You may need to wrap the URLs in
91 | double quotes if you have issues downloading.
92 |
93 | optional arguments:
94 | -h, --help show this help message and exit
95 | -p PRESET, --preset PRESET
96 | Select a download preset. Defaults to Lossless only.
97 | See /config/settings.py for presets
98 | -a ACCOUNT, --account ACCOUNT
99 | Select a session/account to use. Defaults to
100 | the "default" session. If it does not exist, you
101 | will be prompted to create one
102 | -s, --skip Pass this flag to skip track and continue when a track
103 | does not meet the requested quality
104 | -f, --file The URLs to download inside a .txt file with a single
105 | track/album/artist each line.
106 |
107 | #### Searching
108 |
109 | Searching for tracks, albums and videos is now supported.
110 |
111 | Usage: `python redsea.py search [track/album/video] [name of song/video, spaces are allowed]`
112 |
113 | Example: `python redsea.py search video Darkside Alan Walker`
114 |
115 | #### ID downloading
116 |
117 | Download an album/track/artist/video/playlist with just the ID instead of an URL
118 |
119 | Usage: `python redsea.py id [album/track/artist/video/playlist ID]`
120 |
121 | Example: `python redsea.py id id 92265335`
122 |
123 | #### Exploring
124 |
125 | Exploring new Dolby Atmos or 360 Reality Audio releases is now supported
126 |
127 | Usage: `python redsea.py explore (atmos|360) (albums|tracks)`
128 |
129 | Example: `python redsea.py explore atmos tracks`
130 |
131 | Example: `python redsea.py explore 360 albums`
132 |
133 | Lyrics Support
134 | --------------
135 | Redsea supports retrieving synchronized lyrics from the services LyricFind via Deezer, and Musixmatch, automatically falling back if one doesn't have lyrics, depending on the configuration
136 |
137 | Tidal issues
138 | ------------
139 | * Sometimes, tracks will be tagged with a useless version (for instance, "(album version)"), or have the same version twice "(album version)(album version)". This is because tracks in
140 | Tidal are not consistent in terms of metadata - sometimes a version may be included in the track title, included in the version field, or both.
141 |
142 | * Tracks may be tagged with an inaccurate release year; this may be because of Tidal only having the "rerelease" or "remastered" version but showing it as the original.
143 |
144 | To do/Whishlist
145 | ---------------
146 | * ~~ID based downloading (check if ID is a track, album, video, ...)~~
147 | * Complete `mediadownloader.py` rewrite
148 | * Move lyrics support to tagger.py
149 | * Support for being used as a python module (maybe pip?)
150 | * Maybe Spotify playlist support
151 | * Artist album/video download (which downloads all albums/videos from a given artist)
152 |
153 | Config reference
154 | ----------------
155 |
156 | `BRUTEFORCEREGION`: When True, redsea will iterate through every available account and attempt to download when the default or specified session fails to download the release
157 |
158 | ### `Stock Presets`
159 |
160 | `default`: FLAC 44.1k / 16bit only
161 |
162 | `best_available`: Download the highest available quality (MQA > FLAC > 320 > 96)
163 |
164 | `mqa_flac`: Accept both MQA 24bit and FLAC 16bit
165 |
166 | `MQA`: Only allow FLAC 44.1k / 24bit (includes 'folded' 96k content)
167 |
168 | `FLAC`: FLAC 44.1k / 16bit only
169 |
170 | `320`: AAC ~320 VBR only
171 |
172 | `96`: AAC ~96 VBR only
173 |
174 |
175 | ### `Preset Configuration Variables`
176 |
177 | `keep_cover_jpg`: Whether to keep the cover.jpg file in the album directory
178 |
179 | `embed_album_art`: Whether to embed album art or not into the file.
180 |
181 | `save_album_json`: save the album metadata as a json file
182 |
183 | `tries`: How many times to attempt to get a valid stream URL.
184 |
185 | `path`: Base download directory
186 |
187 | `convert_to_alac`: Converts a .flac file to an ALAC .m4a file (requires ffmpeg)
188 |
189 | `save_credits_txt`: Saves a `{track_format}.txt` file with the file containing all the credits of a specific song
190 |
191 | `embed_credits`: Embeds all the credits tags inside a FLAC/MP4 file
192 |
193 | `save_lyrics_lrc`: Saves synced lyrics as .lrc using the Deezer API (from [deemix](https://codeberg.org/RemixDev/deemix)) or musiXmatch
194 |
195 | `embed_lyrics`: Embed the unsynced lyrics inside a FLAC/MP4 file
196 |
197 | `lyrics_provider_order`: Defines the order (from left to right) you want to get the lyrics from
198 |
199 | `genre_language`: Select the language of the genres from Deezer to `en-US, de, fr, ...`
200 |
201 | `artwork_size`: Downloads (artwork_size)x(artwork_size) album covers from iTunes, set it to `0` to disable iTunes cover
202 |
203 | `resolution`: Which resolution you want to download the videos
204 |
205 | ### Album/track format
206 |
207 | Format variables are `{title}`, `{artist}`, `{album}`, `{tracknumber}`, `{discnumber}`, `{date}`, `{quality}`, `{explicit}`.
208 |
209 | * `{quality}` has a whitespace in front, so it will look like this " [Dolby Atmos]", " [360]" or " [M]" according to the downloaded quality
210 |
211 | * `{explicit}` has a whitespace in front, so it will look like this " [E]"
212 |
213 | `track_format`: How tracks are formatted. The relevant extension is appended to the end.
214 |
215 | `album_format`: Base album directory - tracks and cover art are stored here. May have slashes in it, for instance {artist}/{album}.
216 |
217 | `playlist_format`: How playlist tracks are formatted, same as track_format just with `{playlistnumber}` added
218 |
219 | ### Video format
220 |
221 | Format variables are `{title}`, `{artist}`, `{tracknumber}`, `{discnumber}`, `{date}`, `{quality}`, `{explicit}`.
222 |
223 | * `{quality}` has a whitespace in front, so it will look like this " [1080P]" according to the highest available resolution returned by the API
224 |
225 | * `{explicit}` has a whitespace in front, so it will look like this " [E]"
226 |
227 | `video_file_format`: How video filenames are formatted. The '.mp4' extension is appended to the end.
228 |
229 | `video_folder_format`: The video directory - tmp files and cover art are stored here. May have slashes in it, for instance {artist}/{title}.
230 |
--------------------------------------------------------------------------------
/redsea.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import traceback
4 | import sys
5 | import os
6 | import re
7 | import urllib3
8 |
9 | import redsea.cli as cli
10 |
11 | from redsea.mediadownloader import MediaDownloader
12 | from redsea.tagger import Tagger
13 | from redsea.tidal_api import TidalApi, TidalError
14 | from redsea.sessions import RedseaSessionFile
15 |
16 | from config.settings import PRESETS, BRUTEFORCEREGION
17 |
18 |
19 | LOGO = """
20 | /$$$$$$$ /$$ /$$$$$$
21 | | $$__ $$ | $$ /$$__ $$
22 | | $$ \ $$ /$$$$$$ /$$$$$$$| $$ \__/ /$$$$$$ /$$$$$$
23 | | $$$$$$$/ /$$__ $$ /$$__ $$| $$$$$$ /$$__ $$ |____ $$
24 | | $$__ $$| $$$$$$$$| $$ | $$ \____ $$| $$$$$$$$ /$$$$$$$
25 | | $$ \ $$| $$_____/| $$ | $$ /$$ \ $$| $$_____/ /$$__ $$
26 | | $$ | $$| $$$$$$$| $$$$$$$| $$$$$$/| $$$$$$$| $$$$$$$
27 | |__/ |__/ \_______/ \_______/ \______/ \_______/ \_______/
28 |
29 | (c) 2016 Joe Thatcher
30 | https://github.com/svbnet/RedSea
31 | \n"""
32 |
33 | MEDIA_TYPES = {'t': 'track', 'p': 'playlist', 'a': 'album', 'r': 'artist', 'v': 'video'}
34 |
35 | def main():
36 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
37 | os.chdir(sys.path[0])
38 | # Get args
39 | args = cli.get_args()
40 |
41 | # Load config
42 | BRUTEFORCE = args.bruteforce or BRUTEFORCEREGION
43 | preset = PRESETS[args.preset]
44 |
45 | # Parse options
46 | preset['quality'] = []
47 | preset['quality'].append('HI_RES') if preset['MQA_FLAC_24'] else None
48 | preset['quality'].append('LOSSLESS') if preset['FLAC_16'] else None
49 | preset['quality'].append('HIGH') if preset['AAC_320'] else None
50 | preset['quality'].append('LOW') if preset['AAC_96'] else None
51 |
52 | # Check for auth flag / session settings
53 | RSF = RedseaSessionFile('./config/sessions.pk')
54 | if args.urls[0] == 'auth' and len(args.urls) == 1:
55 | print('\nThe "auth" command provides the following methods:')
56 | print('\n list: Lists stored sessions if any exist')
57 | print(' add: Prompts for a TV or Mobile session. The TV option displays a 6 digit key which should be '
58 | 'entered inside link.tidal.com where the user can login. The Mobile option prompts for a Tidal username '
59 | 'and password. Both options authorize a session which then gets stored in the sessions file')
60 | print(' remove: Removes a stored session from the sessions file by name')
61 | print(' default: Set a default account for redsea to use when the -a flag has not been passed')
62 | print(' reauth: Reauthenticates with server to get new sessionId')
63 | print('\nUsage: redsea.py auth add\n')
64 | exit()
65 | elif args.urls[0] == 'auth' and len(args.urls) > 1:
66 | if args.urls[1] == 'list':
67 | RSF.list_sessions()
68 | exit()
69 | elif args.urls[1] == 'add':
70 | if len(args.urls) == 5:
71 | RSF.create_session(args.urls[2], args.urls[3], args.urls[4])
72 | else:
73 | RSF.new_session()
74 | exit()
75 | elif args.urls[1] == 'remove':
76 | RSF.remove_session()
77 | exit()
78 | elif args.urls[1] == 'default':
79 | RSF.set_default()
80 | exit()
81 | elif args.urls[1] == 'reauth':
82 | RSF.reauth()
83 | exit()
84 |
85 | elif args.urls[0] == 'id':
86 | type = None
87 | md = MediaDownloader(TidalApi(RSF.load_session(args.account)), preset, Tagger(preset))
88 |
89 | if len(args.urls) == 2:
90 | id = args.urls[1]
91 | if not id.isdigit():
92 | # Check if id is playlist (UUIDv4)
93 | pattern = re.compile('^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$')
94 | if pattern.match(id):
95 | try:
96 | result = md.playlist_from_id(id)
97 | type = 'p'
98 | except TidalError:
99 | print("The playlist id " + str(id) + " could not be found!")
100 | exit()
101 |
102 | else:
103 | print('The id ' + str(id) + ' is not valid.')
104 | exit()
105 | else:
106 | print('Example usage: python redsea.py id 92265335')
107 | exit()
108 |
109 | if type is None:
110 | type = md.type_from_id(id)
111 |
112 | if type:
113 | media_to_download = [{'id': id, 'type': type}]
114 |
115 | else:
116 | print("The id " + str(id) + " could not be found!")
117 | exit()
118 |
119 | elif args.urls[0] == 'explore':
120 | try:
121 | if args.urls[1] == 'atmos':
122 | page = 'dolby_atmos'
123 | if args.urls[2] == 'tracks':
124 | title = 'Tracks'
125 | elif args.urls[2] == 'albums':
126 | title = 'New Albums'
127 | elif args.urls[1] == '360':
128 | page = '360'
129 | if args.urls[2] == 'tracks':
130 | title = 'New Tracks'
131 | elif args.urls[2] == 'albums':
132 | title = 'Now Available'
133 | except IndexError:
134 | print("Example usage of explore: python redsea.py explore (atmos|360) (albums|tracks)")
135 | exit()
136 |
137 | print(f'Selected: {page.replace("_", " ").title()} - {title}')
138 |
139 | md = MediaDownloader(TidalApi(RSF.load_session(args.account)), preset, Tagger(preset))
140 | page_content = md.page(page)
141 |
142 | # Iterate though all the page and find the module with the title: "Now Available" or "Tracks"
143 | show_more_link = [module['modules'][0]['showMore']['apiPath'] for module in page_content['rows'] if
144 | module['modules'][0]['title'] == title]
145 |
146 | singe_page_content = md.page(show_more_link[0][6:])
147 | # Get the number of all items for offset and the dataApiPath
148 | page_list = singe_page_content['rows'][0]['modules'][0]['pagedList']
149 |
150 | total_items = page_list['totalNumberOfItems']
151 | more_items_link = page_list['dataApiPath'][6:]
152 |
153 | # Now fetch all the found total_items
154 | items = []
155 | for offset in range(0, total_items//50 + 1):
156 | print(f'Fetching {offset * 50}/{total_items}', end='\r')
157 | items += md.page(more_items_link, offset * 50)['items']
158 |
159 | print()
160 | total_items = len(items)
161 |
162 | # Beauty print all found items
163 | for i in range(total_items):
164 | item = items[i]
165 |
166 | if item['audioModes'] == ['DOLBY_ATMOS']:
167 | specialtag = " [Dolby Atmos]"
168 | elif item['audioModes'] == ['SONY_360RA']:
169 | specialtag = " [360 Reality Audio]"
170 | else:
171 | specialtag = ""
172 |
173 | if item['explicit']:
174 | explicittag = " [E]"
175 | else:
176 | explicittag = ""
177 |
178 | date = " (" + item['streamStartDate'].split('T')[0] + ")"
179 |
180 | print(str(i + 1) + ") " + str(item['title']) + " - " + str(
181 | item['artists'][0]['name']) + explicittag + specialtag + date)
182 |
183 | print(str(total_items + 1) + ") Download all items listed above")
184 | print(str(total_items + 2) + ") Exit")
185 |
186 | while True:
187 | chosen = int(input("Selection: ")) - 1
188 | if chosen == total_items + 1:
189 | exit()
190 | elif chosen > total_items + 1 or chosen < 0:
191 | print("Enter an existing number")
192 | else:
193 | break
194 | print()
195 |
196 | # if 'album' in item['url'] is a really ugly way but well should be fine for now
197 | if chosen == total_items:
198 | print('Downloading all albums')
199 | media_to_download = [{'id': str(item['id']), 'type': 'a' if 'album' in item['url'] else 't'} for item in items]
200 | else:
201 | media_to_download = [{'id': str(items[chosen]['id']), 'type': 'a' if 'album' in items[chosen]['url'] else 't'}]
202 |
203 | elif args.urls[0] == 'search':
204 | md = MediaDownloader(TidalApi(RSF.load_session(args.account)), preset, Tagger(preset))
205 | while True:
206 | searchresult = md.search_for_id(args.urls[2:])
207 | if args.urls[1] == 'track':
208 | searchtype = 'tracks'
209 | elif args.urls[1] == 'album':
210 | searchtype = 'albums'
211 | elif args.urls[1] == 'video':
212 | searchtype = 'videos'
213 | else:
214 | print("Example usage of search: python redsea.py search [track/album/video] Darkside Alan Walker")
215 | exit()
216 | # elif args.urls[1] == 'playlist':
217 | # searchtype = 'playlists'
218 |
219 | numberofsongs = int(searchresult[searchtype]['totalNumberOfItems'])
220 | if numberofsongs > 20:
221 | numberofsongs = 20
222 | for i in range(numberofsongs):
223 | song = searchresult[searchtype]['items'][i]
224 |
225 | if searchtype != 'videos':
226 | if song['audioModes'] == ['DOLBY_ATMOS']:
227 | specialtag = " [Dolby Atmos]"
228 | elif song['audioModes'] == ['SONY_360RA']:
229 | specialtag = " [360 Reality Audio]"
230 | elif song['audioQuality'] == 'HI_RES':
231 | specialtag = " [MQA]"
232 | else:
233 | specialtag = ""
234 | else:
235 | specialtag = " [" + song['quality'].replace('MP4_', '') + "]"
236 |
237 | if song['explicit']:
238 | explicittag = " [E]"
239 | else:
240 | explicittag = ""
241 |
242 | print(str(i + 1) + ") " + str(song['title']) + " - " + str(
243 | song['artists'][0]['name']) + explicittag + specialtag)
244 |
245 | query = None
246 |
247 | if numberofsongs > 0:
248 | print(str(numberofsongs + 1) + ") Not found? Try a new search")
249 | while True:
250 | chosen = int(input("Song Selection: ")) - 1
251 | if chosen == numberofsongs:
252 | query = input("Enter new search query: [track/album/video] Darkside Alan Walker: ")
253 | break
254 | elif chosen > numberofsongs:
255 | print("Enter an existing number")
256 | else:
257 | break
258 | print()
259 | if query:
260 | args.urls = ("search " + query).split()
261 | continue
262 | else:
263 | print("No results found for '" + ' '.join(args.urls[2:]))
264 | print("1) Not found? Try a new search")
265 | print("2) Quit")
266 | while True:
267 | chosen = int(input("Selection: "))
268 | if chosen == 1:
269 | query = input("Enter new search query: [track/album/video] Darkside Alan Walker: ")
270 | break
271 | else:
272 | exit()
273 | print()
274 | if query:
275 | args.urls = ("search " + query).split()
276 | continue
277 |
278 | if searchtype == 'tracks':
279 | media_to_download = [{'id': str(searchresult[searchtype]['items'][chosen]['id']), 'type': 't'}]
280 | elif searchtype == 'albums':
281 | media_to_download = [{'id': str(searchresult[searchtype]['items'][chosen]['id']), 'type': 'a'}]
282 | elif searchtype == 'videos':
283 | media_to_download = [{'id': str(searchresult[searchtype]['items'][chosen]['id']), 'type': 'v'}]
284 | # elif searchtype == 'playlists':
285 | # media_to_download = [{'id': str(searchresult[searchtype]['items'][chosen]['id']), 'type': 'p'}]
286 | break
287 |
288 | else:
289 | media_to_download = cli.parse_media_option(args.urls, args.file)
290 |
291 | print(LOGO)
292 |
293 | # Loop through media and download if possible
294 | cm = 0
295 | for mt in media_to_download:
296 |
297 | # Is it an acceptable media type? (skip if not)
298 | if not mt['type'] in MEDIA_TYPES:
299 | print('Unknown media type - ' + mt['type'])
300 | continue
301 |
302 | cm += 1
303 | print('<<< Getting {0} info... >>>'.format(MEDIA_TYPES[mt['type']]))
304 |
305 | # Create a new TidalApi and pass it to a new MediaDownloader
306 | md = MediaDownloader(TidalApi(RSF.load_session(args.account)), preset.copy(), Tagger(preset))
307 |
308 | # Create a new session generator in case we need to switch sessions
309 | session_gen = RSF.get_session()
310 |
311 | # Get media info
312 | def get_tracks(media):
313 | media_name = None
314 | tracks = []
315 | media_info = None
316 | track_info = []
317 |
318 | while True:
319 | try:
320 | if media['type'] == 'f':
321 | lines = media['content'].split('\n')
322 | for i, l in enumerate(lines):
323 | print('Getting info for track {}/{}'.format(i, len(lines)), end='\r')
324 | tracks.append(md.api.get_track(l))
325 | print()
326 |
327 | # Track
328 | elif media['type'] == 't':
329 | tracks.append(md.api.get_track(media['id']))
330 |
331 | # Playlist
332 | elif media['type'] == 'p':
333 | # Stupid mess to get the preset path rather than the modified path when > 2 playlist links added
334 | # md = MediaDownloader(TidalApi(RSF.load_session(args.account)), preset, Tagger(preset))
335 |
336 | # Get playlist title to create path
337 | playlist = md.api.get_playlist(media['id'])
338 |
339 | # Ugly way to get the playlist creator
340 | creator = None
341 | if playlist['creator']['id'] == 0:
342 | creator = 'Tidal'
343 | elif 'name' in playlist['creator']:
344 | creator = md._sanitise_name(playlist["creator"]["name"])
345 |
346 | if creator:
347 | md.opts['path'] = os.path.join(md.opts['path'], f'{creator} - {md._sanitise_name(playlist["title"])}')
348 | else:
349 | md.opts['path'] = os.path.join(md.opts['path'], md._sanitise_name(playlist["title"]))
350 |
351 | # Make sure only tracks are in playlist items
352 | playlist_items = md.api.get_playlist_items(media['id'])['items']
353 | for item_ in playlist_items:
354 | tracks.append(item_['item'])
355 |
356 | # Album
357 | elif media['type'] == 'a':
358 | # Get album information
359 | media_info = md.api.get_album(media['id'])
360 |
361 | # Get a list of the tracks from the album
362 | tracks = md.api.get_album_tracks(media['id'])['items']
363 |
364 | # Video
365 | elif media['type'] == 'v':
366 | # Get video information
367 | tracks.append(md.api.get_video(media['id']))
368 |
369 | # Artist
370 | else:
371 | # Get the name of the artist for display to user
372 | media_name = md.api.get_artist(media['id'])['name']
373 |
374 | # Collect all of the tracks from all of the artist's albums
375 | albums = md.api.get_artist_albums(media['id'])['items'] + md.api.get_artist_albums_ep_singles(media['id'])['items']
376 | eps_info = []
377 | singles_info = []
378 | for album in albums:
379 | if 'aggressive_remix_filtering' in preset and preset['aggressive_remix_filtering']:
380 | title = album['title'].lower()
381 | if 'remix' in title or 'commentary' in title or 'karaoke' in title:
382 | print('\tSkipping ' + album['title'])
383 | continue
384 |
385 | # remove sony 360 reality audio albums if there's another (duplicate) album that isn't 360 reality audio
386 | if 'skip_360ra' in preset and preset['skip_360ra']:
387 | if 'SONY_360RA' in album['audioModes']:
388 | is_duplicate = False
389 | for a2 in albums:
390 | if album['title'] == a2['title'] and album['numberOfTracks'] == a2['numberOfTracks']:
391 | is_duplicate = True
392 | break
393 | if is_duplicate:
394 | print('\tSkipping duplicate Sony 360 Reality Audio album - ' + album['title'])
395 | continue
396 |
397 | # Get album information
398 | media_info = md.api.get_album(album['id'])
399 |
400 | # Get a list of the tracks from the album
401 | tracks = md.api.get_album_tracks(album['id'])['items']
402 |
403 | if 'type' in media_info and str(media_info['type']).lower() == 'single':
404 | singles_info.append((tracks, media_info))
405 | else:
406 | eps_info.append((tracks, media_info))
407 |
408 | if 'skip_singles_when_possible' in preset and preset['skip_singles_when_possible']:
409 | # Filter singles that also appear in albums (EPs)
410 | def track_in_ep(title):
411 | for tracks, _ in eps_info:
412 | for t in tracks:
413 | if t['title'] == title:
414 | return True
415 | return False
416 | for track_info in singles_info[:]:
417 | for t in track_info[0][:]:
418 | if track_in_ep(t['title']):
419 | print('\tSkipping ' + t['title'])
420 | track_info[0].remove(t)
421 | if len(track_info[0]) == 0:
422 | singles_info.remove(track_info)
423 |
424 | track_info = eps_info + singles_info
425 |
426 | if not track_info:
427 | track_info = [(tracks, media_info)]
428 | return media_name, track_info
429 |
430 | # Catch region error
431 | except TidalError as e:
432 | if 'not found. This might be region-locked.' in str(e) and BRUTEFORCE:
433 | # Try again with a different session
434 | try:
435 | session, name = next(session_gen)
436 | md.api = TidalApi(session)
437 | print('Checking info fetch with session "{}" in region {}'.format(name, session.country_code))
438 | continue
439 |
440 | # Ran out of sessions
441 | except StopIteration as s:
442 | print(e)
443 | raise s
444 |
445 | # Skip or halt
446 | else:
447 | raise(e)
448 |
449 | try:
450 | media_name, track_info = get_tracks(media=mt)
451 | except StopIteration:
452 | # Let the user know we cannot download this release and skip it
453 | print('None of the available accounts were able to get info for release {}. Skipping..'.format(mt['id']))
454 | continue
455 |
456 | total = sum([len(t[0]) for t in track_info])
457 |
458 | # Single
459 | if total == 1:
460 | print('<<< Downloading single track... >>>')
461 |
462 | # Playlist or album
463 | else:
464 | if mt['type'] == 'p':
465 | name = md.playlist_from_id(mt['id'])['title']
466 | else:
467 | name = track_info[0][1]['title']
468 |
469 | print('<<< Downloading {0} "{1}": {2} track(s) in total >>>'.format(
470 | MEDIA_TYPES[mt['type']] + (' ' + media_name if media_name else ''), name, total))
471 |
472 | if args.resumeon and len(media_to_download) == 1 and mt['type'] == 'p':
473 | print('<<< Resuming on track {} >>>'.format(args.resumeon))
474 | args.resumeon -= 1
475 | else:
476 | args.resumeon = 0
477 |
478 | cur = args.resumeon
479 | for tracks, media_info in track_info:
480 | for track in tracks[args.resumeon:]:
481 | first = True
482 |
483 | # Actually download the track (finally)
484 | while True:
485 | try:
486 | md.download_media(track, media_info, overwrite=args.overwrite,
487 | track_num=cur+1 if mt['type'] == 'p' else None)
488 | break
489 |
490 | # Catch quality error
491 | except ValueError as e:
492 | print("\t" + str(e))
493 | traceback.print_exc()
494 | if args.skip is True:
495 | print('Skipping track "{} - {}" due to insufficient quality'.format(
496 | track['artist']['name'], track['title']))
497 | break
498 | else:
499 | print('Halting on track "{} - {}" due to insufficient quality'.format(
500 | track['artist']['name'], track['title']))
501 | break
502 |
503 | # Catch file name errors
504 | except OSError as e:
505 | print(e)
506 | print("\tFile name too long or contains apostrophes")
507 | file = open('failed_tracks.txt', 'a')
508 | file.write(str(track['url']) + "\n")
509 | file.close()
510 | break
511 |
512 | # Catch session audio stream privilege error
513 | except AssertionError as e:
514 | if 'Unable to download track' in str(e) and BRUTEFORCE:
515 |
516 | # Try again with a different session
517 | try:
518 | # Reset generator if this is the first attempt
519 | if first:
520 | session_gen = RSF.get_session()
521 | first = False
522 | session, name = next(session_gen)
523 | md.api = TidalApi(session)
524 | print('Attempting audio stream with session "{}" in region {}'.format(name, session.country_code))
525 | continue
526 |
527 | # Ran out of sessions, skip track
528 | except StopIteration:
529 | # Let the user know we cannot download this release and skip it
530 | print('None of the available accounts were able to download track {}. Skipping..'.format(track['id']))
531 | break
532 |
533 | elif 'Please use a mobile session' in str(e):
534 | print(e)
535 | print('Choose one of the following mobile sessions: ')
536 | RSF.list_sessions(True)
537 | break
538 |
539 | # Skip
540 | else:
541 | print(str(e) + '. Skipping..')
542 |
543 | # Progress of current track
544 | cur += 1
545 | print('=== {0}/{1} complete ({2:.0f}% done) ===\n'.format(
546 | cur, total, (cur / total) * 100))
547 |
548 | # Progress of queue
549 | print('> Download queue: {0}/{1} items complete ({2:.0f}% done) <\n'.
550 | format(cm, len(media_to_download), (cm / len(media_to_download)) * 100))
551 |
552 | print('> All downloads completed. <')
553 |
554 | # since oauth sessions can change while downloads are happening if the token gets refreshed
555 | RSF._save()
556 |
557 |
558 | # Run from CLI - catch Ctrl-C and handle it gracefully
559 | if __name__ == '__main__':
560 | try:
561 | main()
562 | except KeyboardInterrupt:
563 | print('\n^C pressed - abort')
564 | exit()
565 |
--------------------------------------------------------------------------------
/redsea/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dniel97/RedSea/173b695b66e3ef9aa9aa3320680c9c001e7d9aff/redsea/__init__.py
--------------------------------------------------------------------------------
/redsea/cli.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | import re
3 | from urllib.parse import urlparse
4 | from os import path
5 |
6 |
7 | def get_args():
8 | #
9 | # argparse setup
10 | #
11 | parser = argparse.ArgumentParser(
12 | description='A music downloader for Tidal.')
13 |
14 | parser.add_argument(
15 | '-p',
16 | '--preset',
17 | default='default',
18 | help='Select a download preset. Defaults to Lossless only. See /config/settings.py for presets')
19 |
20 | parser.add_argument(
21 | '-b',
22 | '--bruteforce',
23 | action='store_true',
24 | default=False,
25 | help='Brute force the download with all available accounts')
26 |
27 | parser.add_argument(
28 | '-a',
29 | '--account',
30 | default='',
31 | help='Select a session/account to use. Defaults to the "default" session.')
32 |
33 | parser.add_argument(
34 | '-s',
35 | '--skip',
36 | action='store_true',
37 | default=False,
38 | help='Pass this flag to skip track and continue when a track does not meet the requested quality')
39 |
40 | parser.add_argument(
41 | '-o',
42 | '--overwrite',
43 | action='store_true',
44 | default=False,
45 | help='Overwrite existing files [Default=skip]'
46 | )
47 |
48 | parser.add_argument(
49 | '--resumeon',
50 | type=int,
51 | help='If ripping a single playlist, resume on the given track number.'
52 | )
53 |
54 | parser.add_argument(
55 | 'urls',
56 | nargs='+',
57 | help='The URLs to download. You may need to wrap the URLs in double quotes if you have issues downloading.'
58 | )
59 |
60 | parser.add_argument(
61 | '-f',
62 | '--file',
63 | action='store_const',
64 | const=True,
65 | default=False,
66 | help='The URLs to download inside a .txt file with a single track/album/artist each line.'
67 | )
68 |
69 | args = parser.parse_args()
70 | if args.resumeon and args.resumeon <= 0:
71 | parser.error('--resumeon must be a positive integer')
72 |
73 | # Check if only URLs or a file exists
74 | if len(args.urls) > 1 and args.file:
75 | parser.error('URLs and -f (--file) cannot be used at the same time')
76 |
77 | return args
78 |
79 |
80 | def parse_media_option(mo, is_file):
81 | opts = []
82 | if is_file:
83 | file_name = str(mo[0])
84 | mo = []
85 | if path.exists(file_name):
86 | file = open(file_name, 'r')
87 | lines = file.readlines()
88 | for line in lines:
89 | mo.append(line.strip())
90 | else:
91 | print("\t File " + file_name + " doesn't exist")
92 | for m in mo:
93 | if m.startswith('http'):
94 | m = re.sub(r'tidal.com\/.{2}\/store\/', 'tidal.com/', m)
95 | m = re.sub(r'tidal.com\/store\/', 'tidal.com/', m)
96 | m = re.sub(r'tidal.com\/browse\/', 'tidal.com/', m)
97 | url = urlparse(m)
98 | components = url.path.split('/')
99 | if not components or len(components) <= 2:
100 | print('Invalid URL: ' + m)
101 | exit()
102 | if len(components) == 5:
103 | type_ = components[3]
104 | id_ = components[4]
105 | else:
106 | type_ = components[1]
107 | id_ = components[2]
108 | if type_ == 'album':
109 | type_ = 'a'
110 | elif type_ == 'track':
111 | type_ = 't'
112 | elif type_ == 'playlist':
113 | type_ = 'p'
114 | elif type_ == 'artist':
115 | type_ = 'r'
116 | elif type_ == 'video':
117 | type_ = 'v'
118 | opts.append({'type': type_, 'id': id_})
119 | continue
120 | elif ':' in m and '#' in m:
121 | ci = m.index(':')
122 | hi = m.find('#')
123 | hi = len(m) if hi == -1 else hi
124 | o = {'type': m[:ci], 'id': m[ci + 1:hi], 'index': m[hi + 1:]}
125 | opts.append(o)
126 | else:
127 | print('Input "{}" does not appear to be a valid url.'.format(m))
128 | return opts
129 |
--------------------------------------------------------------------------------
/redsea/decryption.py:
--------------------------------------------------------------------------------
1 | import base64
2 |
3 | from Cryptodome.Cipher import AES
4 | from Cryptodome.Util import Counter
5 |
6 |
7 | def decrypt_security_token(security_token):
8 | '''
9 | Decrypts security token into key and nonce pair
10 |
11 | security_token should match the securityToken value from the web response
12 | '''
13 |
14 | # Do not change this
15 | master_key = 'UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754='
16 |
17 | # Decode the base64 strings to ascii strings
18 | master_key = base64.b64decode(master_key)
19 | security_token = base64.b64decode(security_token)
20 |
21 | # Get the IV from the first 16 bytes of the securityToken
22 | iv = security_token[:16]
23 | encrypted_st = security_token[16:]
24 |
25 | # Initialize decryptor
26 | decryptor = AES.new(master_key, AES.MODE_CBC, iv)
27 |
28 | # Decrypt the security token
29 | decrypted_st = decryptor.decrypt(encrypted_st)
30 |
31 | # Get the audio stream decryption key and nonce from the decrypted security token
32 | key = decrypted_st[:16]
33 | nonce = decrypted_st[16:24]
34 |
35 | return key, nonce
36 |
37 |
38 | def decrypt_file(file, key, nonce):
39 | '''
40 | Decrypts an encrypted MQA file given the file, key and nonce
41 | '''
42 |
43 | # Initialize counter and file decryptor
44 | counter = Counter.new(64, prefix=nonce, initial_value=0)
45 | decryptor = AES.new(key, AES.MODE_CTR, counter=counter)
46 |
47 | # Open and decrypt
48 | with open(file, 'rb') as eflac:
49 | flac = decryptor.decrypt(eflac.read())
50 |
51 | # Replace with decrypted file
52 | with open(file, 'wb') as dflac:
53 | dflac.write(flac)
54 |
--------------------------------------------------------------------------------
/redsea/mediadownloader.py:
--------------------------------------------------------------------------------
1 | import errno
2 | import json
3 | import os
4 | import os.path as path
5 | import re
6 | import base64
7 | import ffmpeg
8 | import shutil
9 |
10 | import requests
11 | from tqdm import tqdm
12 | from urllib3.util.retry import Retry
13 | from requests.adapters import HTTPAdapter
14 |
15 | from .decryption import decrypt_file, decrypt_security_token
16 | from .tagger import FeaturingFormat
17 | from .tidal_api import TidalApi, TidalRequestError, technical_names
18 | from deezer.deezer import Deezer, APIError
19 | from .videodownloader import download_stream, download_file, tags
20 |
21 |
22 | def _mkdir_p(path):
23 | try:
24 | if not os.path.isdir(path):
25 | os.makedirs(path)
26 | except OSError as exc:
27 | if exc.errno == errno.EEXIST and os.path.isdir(path):
28 | pass
29 | else:
30 | raise
31 |
32 |
33 | class MediaDownloader(object):
34 |
35 | def __init__(self, api, options, tagger=None):
36 | self.api = api
37 | self.opts = options
38 | self.tm = tagger
39 |
40 | # Deezer API
41 | if 'genre_language' in self.opts:
42 | self.dz = Deezer(language=self.opts['genre_language'])
43 | else:
44 | self.dz = Deezer()
45 |
46 | self.session = requests.Session()
47 | retries = Retry(total=10,
48 | backoff_factor=0.4,
49 | status_forcelist=[429, 500, 502, 503, 504])
50 |
51 | self.session.mount('http://', HTTPAdapter(max_retries=retries))
52 | self.session.mount('https://', HTTPAdapter(max_retries=retries))
53 |
54 | def _dl_url(self, url, where):
55 | r = self.session.get(url, stream=True, verify=False)
56 | try:
57 | total = int(r.headers['content-length'])
58 | except KeyError:
59 | return False
60 | with open(where, 'wb') as f:
61 | with tqdm(total=total, unit='B', unit_scale=True, unit_divisor=1024, miniters=1,
62 | bar_format=' {l_bar}{bar}{r_bar}') as bar:
63 | for chunk in r.iter_content(chunk_size=1024):
64 | if chunk: # filter out keep-alive new chunks
65 | f.write(chunk)
66 | bar.update(len(chunk))
67 | print()
68 | return where
69 |
70 | def _dl_picture(self, album_id, where):
71 | if album_id is not None:
72 | rc = self._dl_url(TidalApi.get_album_artwork_url(album_id), where)
73 | if not rc:
74 | return False
75 | else:
76 | return rc
77 | else:
78 | return False
79 |
80 | @staticmethod
81 | def _sanitise_name(name):
82 | name = re.sub(r'[\\\/*?"\'’<>|]', '', str(name))
83 |
84 | # Check file length
85 | if len(name) > 230:
86 | name = name[:230]
87 |
88 | # Check last character is space
89 | if len(name) > 0:
90 | if name[len(name) - 1] == ' ':
91 | name = name[:len(name) - 1]
92 |
93 | return re.sub(r'[:]', ' - ', name)
94 |
95 | def _normalise_info(self, track_info, album_info, use_album_artists=False):
96 | info = {
97 | k: self._sanitise_name(v)
98 | for k, v in self.tm.tags(track_info, None, album_info).items()
99 | }
100 | if len(album_info['artists']) > 1 and use_album_artists:
101 | self.featform = FeaturingFormat()
102 |
103 | artists = []
104 | for a in album_info['artists']:
105 | if a['type'] == 'MAIN':
106 | artists.append(a['name'])
107 |
108 | info['artist'] = self._sanitise_name(self.featform.get_artist_format(artists))
109 | return info
110 |
111 | def _normalise_video(self, video_info):
112 | info = {
113 | k: self._sanitise_name(v) for k, v in tags(video_info).items()
114 | }
115 |
116 | return info
117 |
118 | def get_stream_url(self, track_id, quality):
119 | stream_data = None
120 | print('\tGrabbing stream URL...')
121 | try:
122 | stream_data = self.api.get_stream_url(track_id, quality)
123 | except TidalRequestError as te:
124 | if te.payload['status'] == 404:
125 | print('\tTrack does not exist.')
126 | # in this case, we need to use this workaround discovered by reverse engineering the mobile app, idk why
127 | elif te.payload['subStatus'] == 4005:
128 | try:
129 | print('\tStatus 4005 when getting stream URL, trying workaround...')
130 | playback_info = self.api.get_stream_url(track_id, quality)
131 | manifest = json.loads(base64.b64decode(playback_info['manifest']))
132 | stream_data = {
133 | 'soundQuality': playback_info['audioQuality'],
134 | 'codec': manifest['codecs'],
135 | 'url': manifest['urls'][0],
136 | 'encryptionKey': manifest['keyId'] if 'encryptionType' in manifest and manifest[
137 | 'encryptionType'] != 'NONE' else ''
138 | }
139 | except TidalRequestError as te:
140 | print('\t' + str(te))
141 | else:
142 | print('\t' + str(te))
143 |
144 | if stream_data is None:
145 | raise ValueError('Stream could not be acquired')
146 |
147 | def print_track_info(self, track_info, album_info):
148 | line = '\tTrack: {tracknumber}\n\tTitle: {title}\n\tArtist: {artist}\n\tAlbum: {album}'.format(
149 | **self.tm.tags(track_info, album_info))
150 | try:
151 | print(line)
152 | except UnicodeEncodeError:
153 | line = line.encode('ascii', 'replace').decode('ascii')
154 | print(line)
155 | print('\t----')
156 |
157 | def search_for_id(self, term):
158 | return self.api.get_search_data(term)
159 |
160 | def page(self, page_url, offset=None):
161 | return self.api.get_page(page_url, offset)
162 |
163 | def type_from_id(self, id):
164 | return self.api.get_type_from_id(id)
165 |
166 | def credits_from_album(self, album_id):
167 | return self.api.get_credits(album_id)
168 |
169 | def credits_from_video(self, video_id):
170 | return self.api.get_video_credits(video_id)
171 |
172 | def lyrics_from_track(self, track_id):
173 | return self.api.get_lyrics(track_id)
174 |
175 | def playlist_from_id(self, id):
176 | return self.api.get_playlist(id)
177 |
178 | def download_media(self, track_info, album_info=None, overwrite=False, track_num=None):
179 | track_id = track_info['id']
180 | assert track_info['allowStreaming'], 'Unable to download track {0}: not allowed to stream/download'.format(
181 | track_id)
182 |
183 | print('=== Downloading track ID {0} ==='.format(track_id))
184 |
185 | # Check if track is video
186 | if 'type' in track_info:
187 | playback_info = self.api.get_video_stream_url(track_id)
188 | url = playback_info['url']
189 |
190 | # Fallback if settings doesn't exist
191 | if 'resolution' not in self.opts:
192 | self.opts['resolution'] = 1080
193 |
194 | if 'video_folder_format' not in self.opts:
195 | self.opts['video_folder_format'] = '{artist} - {title}{quality}'
196 | if 'video_file_format' not in self.opts:
197 | self.opts['video_file_format'] = '{title}'
198 |
199 | # Make video locations
200 | video_location = path.join(
201 | self.opts['path'], self.opts['video_folder_format'].format(**self._normalise_video(track_info))).strip()
202 | video_file = self.opts['video_file_format'].format(**self._normalise_video(track_info))
203 | _mkdir_p(video_location)
204 |
205 | file_location = os.path.join(video_location, video_file + '.mp4')
206 | if path.isfile(file_location) and not overwrite:
207 | print('\tFile {} already exists, skipping.'.format(file_location))
208 | return None
209 |
210 | # Get video credits
211 | video_credits = self.credits_from_video(str(track_info['id']))
212 | credits_dict = {}
213 | if video_credits['totalNumberOfItems'] > 0:
214 | for contributor in video_credits['items']:
215 | if contributor['role'] not in credits_dict:
216 | credits_dict[contributor['role']] = []
217 | credits_dict[contributor['role']].append(contributor['name'])
218 |
219 | if credits_dict != {}:
220 | '''
221 | if 'save_credits_txt' in self.opts:
222 | if self.opts['save_credits_txt']:
223 | data = ''
224 | for key, value in credits_dict.items():
225 | data += key + ': '
226 | data += value + '\n'
227 | with open((os.path.splitext(track_path)[0] + '.txt'), 'w') as f:
228 | f.write(data)
229 | '''
230 | # Janky way to set the dict to None to tell the tagger not to include it
231 | if 'embed_credits' in self.opts:
232 | if not self.opts['embed_credits']:
233 | credits_dict = None
234 |
235 | download_stream(video_location, video_file, url, self.opts['resolution'], track_info, credits_dict)
236 |
237 | else:
238 | if album_info is None:
239 | print('\tGrabbing album info...')
240 | tries = self.opts['tries']
241 | for i in range(tries):
242 | try:
243 | album_info = self.api.get_album(track_info['album']['id'])
244 | break
245 | except Exception as e:
246 | print(e)
247 | print('\tGrabbing album info failed, retrying... ({}/{})'.format(i + 1, tries))
248 | if i + 1 == tries:
249 | raise
250 |
251 | # create correct playlist numbering if track_num is present
252 | if track_num:
253 | if 'playlist_format' not in self.opts:
254 | self.opts['playlist_format'] = "{playlistnumber} - {title}"
255 |
256 | # ugly replace operation
257 | playlist_format = self.opts['playlist_format'].replace('{playlistnumber}', str(track_num).zfill(2))
258 | # Make locations
259 | # path already includes the playlist name in this case
260 | album_location = self.opts['path']
261 | track_file = playlist_format.format(**self._normalise_info(track_info, album_info))
262 | else:
263 | # Make locations
264 | album_location = path.join(
265 | self.opts['path'], self.opts['album_format'].format(
266 | **self._normalise_info(track_info, album_info, True))).strip()
267 | track_file = self.opts['track_format'].format(**self._normalise_info(track_info, album_info))
268 |
269 | # Make multi disc directories
270 | if album_info['numberOfVolumes'] > 1:
271 | disc_location = path.join(
272 | album_location,
273 | 'CD{num}'.format(num=track_info['volumeNumber']))
274 | disc_location = re.sub(r'\.+$', '', disc_location)
275 | _mkdir_p(disc_location)
276 |
277 | album_location = re.sub(r'\.+$', '', album_location)
278 | if len(track_file) > 255: # trim filename to be under OS limit (and account for file extension)
279 | track_file = track_file[:250 - len(track_file)]
280 | track_file = re.sub(r'\.+$', '', track_file)
281 | _mkdir_p(album_location)
282 |
283 | # Attempt to get stream URL
284 | # stream_data = self.get_stream_url(track_id, quality)
285 |
286 | DRM = False
287 | playback_info = self.api.get_stream_url(track_id, self.opts['quality'])
288 |
289 | manifest_unparsed = base64.b64decode(playback_info['manifest']).decode('UTF-8')
290 | if 'ContentProtection' in manifest_unparsed:
291 | DRM = True
292 | print("\tWarning: DRM has been detected. If you do not have the decryption key, do not use web login.")
293 | elif 'manifestMimeType' in playback_info:
294 | if playback_info['manifestMimeType'] == 'application/dash+xml':
295 | raise AssertionError(f'\tUnable to download track {playback_info["trackId"]} in '
296 | f'{playback_info["audioQuality"]}!\n')
297 |
298 | if not DRM:
299 | manifest = json.loads(manifest_unparsed)
300 | # Detect codec
301 | print('\tCodec: ', end='')
302 | print(technical_names[manifest['codecs']])
303 |
304 | url = manifest['urls'][0]
305 | if url.find('.flac?') == -1:
306 | if url.find('.m4a?') == -1:
307 | if url.find('.mp4?') == -1:
308 | ftype = ''
309 | else:
310 | ftype = 'm4a'
311 | else:
312 | ftype = 'm4a'
313 | else:
314 | ftype = 'flac'
315 | # ftype needs to be changed to work with audio codecs instead when with web auth
316 | else:
317 | ftype = 'flac'
318 |
319 | if album_info['numberOfVolumes'] > 1 and not track_num:
320 | track_path = path.join(disc_location, track_file + '.' + ftype)
321 | else:
322 | track_path = path.join(album_location, track_file + '.' + ftype)
323 |
324 | if path.isfile(track_path) and not overwrite:
325 | print('\tFile {} already exists, skipping.'.format(track_path))
326 | return None
327 |
328 | self.print_track_info(track_info, album_info)
329 |
330 | if DRM:
331 | manifest = manifest_unparsed
332 | # Get playback link
333 | pattern = re.compile(r'(?<=media=")[^"]+')
334 | playback_link = pattern.findall(manifest)[0].replace("amp;", "")
335 |
336 | # Create album tmp folder
337 | tmp_folder = os.path.join(album_location, 'tmp/')
338 |
339 | if not os.path.isdir(tmp_folder):
340 | os.makedirs(tmp_folder)
341 |
342 | pattern = re.compile(r'(?<= r=")[^"]+')
343 | # Add 2?
344 | length = int(pattern.findall(manifest)[0]) + 3
345 |
346 | # Download all chunk files from MPD
347 | with open(album_location + '/encrypted.mp4', 'wb') as encrypted_file:
348 | for i in range(length):
349 | link = playback_link.replace("$Number$", str(i))
350 | filename = os.path.join(tmp_folder, str(i).zfill(3) + '.mp4')
351 | download_file([link], 0, filename)
352 | with open(filename, 'rb') as fd:
353 | shutil.copyfileobj(fd, encrypted_file)
354 | print('\tDownload progress: {0:.0f}%'.format(((i + 1) / length) * 100), end='\r')
355 | print()
356 | os.chdir(album_location)
357 |
358 | decryption_key = input("\tInput key (ID:key): ")
359 | print("\tDecrypting m4a")
360 | try:
361 | os.system('mp4decrypt --key {} encrypted.mp4 "{}"'.format(decryption_key, track_file + '.m4a'))
362 | except Exception as e:
363 | print(e)
364 | print('mp4decrypt not found!')
365 |
366 | temp_file = track_path
367 | print("\tRemuxing m4a to FLAC")
368 | (
369 | ffmpeg
370 | .input(track_file + '.m4a')
371 | .output(track_file + '.flac', acodec="copy", loglevel='warning')
372 | .overwrite_output()
373 | .run()
374 | )
375 | shutil.rmtree("tmp")
376 | os.remove('encrypted.mp4')
377 | os.remove(track_file + '.m4a')
378 | os.chdir('../../')
379 |
380 | try:
381 | if not DRM:
382 | temp_file = self._dl_url(url, track_path)
383 |
384 | if 'encryptionType' in manifest and manifest['encryptionType'] != 'NONE':
385 | if not manifest['keyId'] == '':
386 | print('\tLooks like file is encrypted. Decrypting...')
387 | key, nonce = decrypt_security_token(manifest['keyId'])
388 | decrypt_file(temp_file, key, nonce)
389 |
390 | aa_location = path.join(album_location, 'Cover.jpg')
391 | if not path.isfile(aa_location):
392 | try:
393 | artwork_size = 1200
394 | if 'artwork_size' in self.opts:
395 | if self.opts['artwork_size'] == 0:
396 | raise Exception
397 | artwork_size = self.opts['artwork_size']
398 |
399 | print('\tDownloading album art from iTunes...')
400 | s = requests.Session()
401 |
402 | params = {
403 | 'country': 'US',
404 | 'entity': 'album',
405 | 'term': track_info['artist']['name'] + ' ' + track_info['album']['title']
406 | }
407 |
408 | r = s.get('https://itunes.apple.com/search', params=params)
409 | r = r.json()
410 | album_cover = None
411 |
412 | for i in range(len(r['results'])):
413 | if album_info['title'] == r['results'][i]['collectionName']:
414 | # Get high resolution album cover
415 | album_cover = r['results'][i]['artworkUrl100']
416 | break
417 |
418 | if album_cover is None:
419 | raise Exception
420 |
421 | compressed = 'bb'
422 | if 'uncompressed_artwork' in self.opts:
423 | if self.opts['uncompressed_artwork']:
424 | compressed = '-999'
425 | album_cover = album_cover.replace('100x100bb.jpg',
426 | '{}x{}{}.jpg'.format(artwork_size, artwork_size, compressed))
427 | self._dl_url(album_cover, aa_location)
428 |
429 | if ftype == 'flac':
430 | # Open cover.jpg to check size
431 | with open(aa_location, 'rb') as f:
432 | data = f.read()
433 |
434 | # Check if cover is smaller than 16MB
435 | max_size = 16777215
436 | if len(data) > max_size:
437 | print('\tCover file size is too large, only {0:.2f}MB are allowed.'.format(
438 | max_size / 1024 ** 2))
439 | print('\tFallback to compressed iTunes cover')
440 |
441 | album_cover = album_cover.replace('-999', 'bb')
442 | self._dl_url(album_cover, aa_location)
443 | except:
444 | print('\tDownloading album art from Tidal...')
445 | if not self._dl_picture(track_info['album']['cover'], aa_location):
446 | aa_location = None
447 |
448 | # Converting FLAC to ALAC
449 | if self.opts['convert_to_alac'] and ftype == 'flac':
450 | print("\tConverting FLAC to ALAC...")
451 | conv_file = temp_file[:-5] + ".m4a"
452 | # command = 'ffmpeg -i "{0}" -vn -c:a alac "{1}"'.format(temp_file, conv_file)
453 | (
454 | ffmpeg
455 | .input(temp_file)
456 | .output(conv_file, acodec='alac', loglevel='warning')
457 | .overwrite_output()
458 | .run()
459 | )
460 |
461 | if path.isfile(conv_file) and not overwrite:
462 | print("\tConversion successful")
463 | os.remove(temp_file)
464 | temp_file = conv_file
465 | ftype = "m4a"
466 |
467 | # Get credits from album id
468 | print('\tSaving credits to file')
469 | album_credits = self.credits_from_album(str(album_info['id']))
470 | credits_dict = {}
471 | try:
472 | track_credits = album_credits['items'][track_info['trackNumber'] - 1]['credits']
473 | for i in range(len(track_credits)):
474 | credits_dict[track_credits[i]['type']] = ''
475 | contributors = track_credits[i]['contributors']
476 | for j in range(len(contributors)):
477 | if j != len(contributors) - 1:
478 | credits_dict[track_credits[i]['type']] += contributors[j]['name'] + ', '
479 | else:
480 | credits_dict[track_credits[i]['type']] += contributors[j]['name']
481 |
482 | if credits_dict != {}:
483 | if 'save_credits_txt' in self.opts:
484 | if self.opts['save_credits_txt']:
485 | data = ''
486 | for key, value in credits_dict.items():
487 | data += key + ': '
488 | data += value + '\n'
489 | with open((os.path.splitext(track_path)[0] + '.txt'), 'w') as f:
490 | f.write(data)
491 | # Janky way to set the dict to None to tell the tagger not to include it
492 | if 'embed_credits' in self.opts:
493 | if not self.opts['embed_credits']:
494 | credits_dict = None
495 | except IndexError:
496 | credits_dict = None
497 |
498 | lyrics = None
499 | if 'save_lyrics_lrc' in self.opts and 'embed_lyrics' in self.opts:
500 | if self.opts['save_lyrics_lrc'] or self.opts['embed_lyrics']:
501 | # New API lyrics call with hacky 404 fix, pls never do it that way
502 | lyrics_data = self.lyrics_from_track(track_id)
503 |
504 | # Get unsynced lyrics
505 | if self.opts['embed_lyrics']:
506 | if 'lyrics' in lyrics_data and lyrics_data['lyrics']:
507 | lyrics = lyrics_data['lyrics']
508 | else:
509 | print('\tNo unsynced lyrics could be found!')
510 |
511 | # Get synced lyrics
512 | if self.opts['save_lyrics_lrc']:
513 | if 'subtitles' in lyrics_data and lyrics_data['subtitles']:
514 | if not os.path.isfile(os.path.splitext(track_path)[0] + '.lrc'):
515 | with open((os.path.splitext(track_path)[0] + '.lrc'), 'wb') as f:
516 | f.write(lyrics_data['subtitles'].encode('utf-8'))
517 | else:
518 | print('\tNo synced lyrics could be found!')
519 |
520 | # Tagging
521 | print('\tTagging media file...')
522 |
523 | if ftype == 'flac':
524 | self.tm.tag_flac(temp_file, track_info, album_info, lyrics, credits_dict=credits_dict,
525 | album_art_path=aa_location)
526 | elif ftype == 'm4a' or ftype == 'mp4':
527 | self.tm.tag_m4a(temp_file, track_info, album_info, lyrics, credits_dict=credits_dict,
528 | album_art_path=aa_location)
529 | else:
530 | print('\tUnknown file type to tag!')
531 |
532 | # Cleanup
533 | if not self.opts['keep_cover_jpg'] and aa_location:
534 | os.remove(aa_location)
535 |
536 | return album_location, temp_file
537 |
538 | # Delete partially downloaded file on keyboard interrupt
539 | except KeyboardInterrupt:
540 | if path.isfile(track_path):
541 | print('Deleting partially downloaded file ' + str(track_path))
542 | os.remove(track_path)
543 | raise
544 |
--------------------------------------------------------------------------------
/redsea/sessions.py:
--------------------------------------------------------------------------------
1 | import getpass
2 |
3 | from redsea.tidal_api import TidalSessionFile, TidalRequestError, TidalMobileSession, TidalTvSession, SessionFormats
4 |
5 |
6 | class RedseaSessionFile(TidalSessionFile):
7 | '''
8 | Redsea - TidalSession interpreter
9 |
10 | Provides more user-friendly cli feedback for the
11 | TidalSessionFile class
12 | '''
13 |
14 | def create_session(self, name, username, password):
15 | super().new_session(name, username, password)
16 |
17 | def new_session(self):
18 | '''
19 | Authenticates with Tidal service
20 |
21 | Returns True if successful
22 | '''
23 | # confirm = input('Do you want to use the new TV authorization (needed for E-AC-3 JOC)? [y/N]? ')
24 | confirm = input('Which login method do you want to use: TV (needed for MQA, E-AC-3), '
25 | 'Mobile (needed for MQA, AC-4, 360) [t/m]? ')
26 |
27 | token_confirm = 'N'
28 |
29 | device = 'mobile'
30 | if confirm.upper() == 'T':
31 | device = 'tv'
32 |
33 | while True:
34 | if device != 'tv' and token_confirm.upper() == 'N':
35 | print('LOGIN: Enter your Tidal username and password:\n')
36 | username = input('Username: ')
37 | password = getpass.getpass('Password: ')
38 | else:
39 | username = ''
40 | password = ''
41 |
42 | name = ''
43 | while name == '':
44 | name = input('What would you like to call this new session? ')
45 | if not name == '':
46 | if name in self.sessions:
47 | confirm = input('A session with name "{}" already exists. Overwrite [y/N]? '.format(name))
48 | if confirm.upper() == 'Y':
49 | super().remove(name)
50 | else:
51 | name = ''
52 | continue
53 | else:
54 | confirm = input('Invalid entry! Would you like to cancel [y/N]? ')
55 | if confirm.upper() == 'Y':
56 | print('Operation cancelled.')
57 | return False
58 |
59 | try:
60 | super().new_session(name, username, password, device)
61 | break
62 | except TidalRequestError as e:
63 | if str(e).startswith('3001'):
64 | print('\nUSERNAME OR PASSWORD INCORRECT. Please try again.\n\n')
65 | continue
66 | elif str(e).startswith('6004'):
67 | print('\nINVALID TOKEN. (HTTP 401)')
68 | continue
69 | except AssertionError as e:
70 | print(e)
71 | confirm = input('Would you like to try again [Y/n]? ')
72 | if not confirm.upper() == 'N':
73 | continue
74 |
75 | SessionFormats(self.sessions[name]).print_fomats()
76 | print('Session saved!')
77 | if not self.default == name:
78 | print('Session named "{}". Use the "-a {}" flag when running redsea to choose session'.format(name, name))
79 |
80 | return True
81 |
82 | def load_session(self, session_name=None):
83 | '''
84 | Loads session from session store by name
85 | '''
86 |
87 | if session_name == '':
88 | session_name = None
89 |
90 | try:
91 | return super().load(session_name=session_name)
92 |
93 | except ValueError as e:
94 | print(e)
95 | if session_name is None:
96 | confirm = input('No sessions found. Would you like to add one [Y/n]? ')
97 | else:
98 | confirm = input('No session "{}" found. Would you like to create it [Y/n]? '.format(session_name))
99 |
100 | if confirm.upper() == 'Y':
101 | if self.new_session():
102 | if len(self.sessions) == 1:
103 | return self.sessions[self.default]
104 | else:
105 | return self.sessions[session_name]
106 | else:
107 | print('No session was created!')
108 | exit(0)
109 |
110 | def get_session(self):
111 | '''
112 | Generator which iterates through available sessions
113 | '''
114 |
115 | for session in self.sessions:
116 | yield self.sessions[session], session
117 |
118 | def remove_session(self):
119 | '''
120 | Removes a session from the session store
121 | '''
122 |
123 | self.list_sessions(formats=False)
124 |
125 | name = ''
126 | while name == '':
127 | name = input('Type the full name of the session you would like to remove: ')
128 | if not name == '':
129 | super().remove(name)
130 | print('Session "{}" has been removed.'.format(name))
131 | else:
132 | confirm = input('Invalid entry! Would you like to cancel [y/N]? ')
133 | if confirm.upper() == 'Y':
134 | return False
135 |
136 | def list_sessions(self, mobile_only=False, formats=True):
137 | '''
138 | List all available sessions
139 | '''
140 |
141 | mobile_sessions = [isinstance(self.sessions[s], TidalMobileSession) for s in self.sessions]
142 | if len(self.sessions) == 0 or (mobile_sessions.count(True) == 0 and mobile_only):
143 | confirm = input('No (mobile) sessions found. Would you like to add one [Y/n]? ')
144 | if confirm.upper() == 'Y':
145 | self.new_session()
146 | else:
147 | exit()
148 |
149 | print('\nSESSIONS:')
150 | for s in self.sessions:
151 | if isinstance(self.sessions[s], TidalMobileSession):
152 | device = '[MOBILE]'
153 | elif isinstance(self.sessions[s], TidalTvSession) and not mobile_only:
154 | device = '[TV]'
155 | else:
156 | device = '[DESKTOP]'
157 |
158 | if mobile_only and isinstance(self.sessions[s], TidalTvSession):
159 | continue
160 |
161 | print(' [{}]{} {} | {}'.format(self.sessions[s].country_code, device, self.sessions[s].username, s))
162 | if formats:
163 | SessionFormats(self.sessions[s]).print_fomats()
164 |
165 | print('')
166 | if self.default is not None:
167 | print('Default session is currently set to: {}'.format(self.default))
168 | print('')
169 |
170 | def set_default(self):
171 | '''
172 | Sets a session as the default
173 | '''
174 |
175 | self.list_sessions(formats=False)
176 |
177 | while True:
178 | name = input('Please provide the name of the session you would like to set as default: ')
179 | if name != '' and name in self.sessions:
180 | super().set_default(name)
181 | print('Default session has successfully been set to "{}"'.format(name))
182 | return
183 | else:
184 | print('ERROR: Session "{}" not found in sessions store!'.format(name))
185 |
186 | def reauth(self):
187 | '''
188 | Requests password from the user and then re-auths
189 | with the Tidal server to get a new (valid) sessionId
190 | '''
191 |
192 | self.list_sessions(formats=False)
193 |
194 | while True:
195 | name = input('Please provide the name of the session you would like to reauthenticate: ')
196 | if name != '' and name in self.sessions:
197 | try:
198 | session = self.sessions[name]
199 |
200 | if isinstance(session, TidalTvSession):
201 | print('You cannot reauthenticate a TV session!')
202 | exit()
203 |
204 | print('LOGIN: Enter your Tidal password for account {}:\n'.format(session.username))
205 | password = getpass.getpass('Password: ')
206 | session.auth(password)
207 | self._save()
208 |
209 | print('Session "{}" has been successfully reauthed.'.format(name))
210 | return
211 |
212 | except TidalRequestError as e:
213 | if 'Username or password is wrong' in str(e):
214 | print('Error ' + str(e) + '. Please try again..')
215 | continue
216 | else:
217 | raise(e)
218 |
219 | except AssertionError as e:
220 | if 'invalid sessionId' in str(e):
221 | print('Reauthentication failed. SessionID is still invalid. Please try again.')
222 | print('Note: If this fails more than once, please check your account and subscription status.')
223 | continue
224 | else:
225 | raise(e)
226 | else:
227 | print('ERROR: Session "{}" not found in sessions store!'.format(name))
228 |
--------------------------------------------------------------------------------
/redsea/tagger.py:
--------------------------------------------------------------------------------
1 | import unicodedata
2 |
3 | from mutagen.easymp4 import EasyMP4
4 | from mutagen.flac import FLAC, Picture
5 | from mutagen.mp4 import MP4Cover
6 | from mutagen.mp4 import MP4Tags
7 | from mutagen.id3 import PictureType
8 |
9 | # Needed for Windows tagging support
10 | MP4Tags._padding = 0
11 |
12 |
13 | def normalize_key(s):
14 | # Remove accents from a given string
15 | return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
16 |
17 |
18 | class FeaturingFormat():
19 | '''
20 | Formatter for featuring artist tags
21 | '''
22 |
23 | def _format(self, featuredArtists, andStr):
24 | artists = ''
25 | if len(featuredArtists) == 1:
26 | artists = featuredArtists[0]
27 | elif len(featuredArtists) == 2:
28 | artists = featuredArtists[0] + ' {} '.format(andStr) + featuredArtists[1]
29 | else:
30 | for i in range(0, len(featuredArtists)):
31 | name = featuredArtists[i]
32 | artists += name
33 | if i < len(featuredArtists) - 1:
34 | artists += ', '
35 | if i == len(featuredArtists) - 2:
36 | artists += andStr + ' '
37 | return artists
38 |
39 | def get_artist_format(self, mainArtists):
40 | return self._format(mainArtists, '&')
41 |
42 | def get_feature_format(self, featuredArtists):
43 | return '(feat. {})'.format(self._format(featuredArtists, 'and'))
44 |
45 |
46 | class Tagger(object):
47 |
48 | def __init__(self, format_options):
49 | self.fmtopts = format_options
50 |
51 | def tags(self, track_info, track_type, album_info=None, tagger=None):
52 | if tagger is None:
53 | tagger = {}
54 | title = track_info['title']
55 | if len(track_info['artists']) == 1:
56 | tagger['artist'] = track_info['artist']['name']
57 | else:
58 | self.featform = FeaturingFormat()
59 | mainArtists = []
60 | featuredArtists = []
61 | for artist in track_info['artists']:
62 | if artist['type'] == 'MAIN':
63 | mainArtists.append(artist['name'])
64 | elif artist['type'] == 'FEATURED':
65 | featuredArtists.append(artist['name'])
66 | if len(featuredArtists) > 0 and '(feat.' not in title:
67 | title += ' ' + self.featform.get_feature_format(
68 | featuredArtists)
69 | tagger['artist'] = self.featform.get_artist_format(mainArtists)
70 |
71 | if album_info is not None:
72 | tagger['albumartist'] = album_info['artist']['name']
73 | tagger['tracknumber'] = str(track_info['trackNumber']).zfill(2)
74 | tagger['album'] = track_info['album']['title']
75 | if album_info is not None:
76 | # TODO: find a way to get numberOfTracks relative to the volume
77 | if track_type == 'm4a':
78 | tagger['tracknumber'] = str(track_info['trackNumber']).zfill(2) + '/' + str(
79 | album_info['numberOfTracks'])
80 | tagger['discnumber'] = str(
81 | track_info['volumeNumber']) + '/' + str(
82 | album_info['numberOfVolumes'])
83 | if track_type == 'flac':
84 | tagger['discnumber'] = str(track_info['volumeNumber'])
85 | tagger['totaldiscs'] = str(album_info['numberOfVolumes'])
86 | tagger['tracknumber'] = str(track_info['trackNumber'])
87 | tagger['totaltracks'] = str(album_info['numberOfTracks'])
88 | else:
89 | tagger['discnumber'] = str(track_info['volumeNumber'])
90 | if album_info['releaseDate']:
91 | tagger['date'] = str(album_info['releaseDate'][:4])
92 | if album_info['upc']:
93 | if track_type == 'm4a':
94 | tagger['upc'] = album_info['upc'].encode()
95 | elif track_type == 'flac':
96 | tagger['UPC'] = album_info['upc']
97 |
98 | if track_info['version'] is not None and track_info['version'] != '':
99 | fmt = ' ({})'.format(track_info['version'])
100 | title += fmt
101 |
102 | tagger['title'] = title
103 |
104 | if track_info['copyright'] is not None:
105 | tagger['copyright'] = track_info['copyright']
106 |
107 | if track_info['isrc'] is not None:
108 | if track_type == 'm4a':
109 | tagger['isrc'] = track_info['isrc'].encode()
110 | elif track_type == 'flac':
111 | tagger['isrc'] = track_info['isrc']
112 |
113 | # Stupid library won't accept int so it is needed to cast it to a byte with hex value 01
114 | if track_info['explicit'] is not None:
115 | if track_type == 'm4a':
116 | tagger['explicit'] = b'\x01' if track_info['explicit'] else b'\x02'
117 | elif track_type == 'flac':
118 | tagger['Rating'] = 'Explicit' if track_info['explicit'] else 'Clean'
119 |
120 | # Set genre from Deezer
121 | if 'genre' in track_info:
122 | tagger['genre'] = track_info['genre']
123 |
124 | if 'replayGain' in track_info and 'peak' in track_info:
125 | if track_type == 'flac':
126 | tagger['REPLAYGAIN_TRACK_GAIN'] = str(track_info['replayGain'])
127 | tagger['REPLAYGAIN_TRACK_PEAK'] = str(track_info['peak'])
128 |
129 | if track_type is None:
130 | if track_info['audioModes'] == ['DOLBY_ATMOS']:
131 | tagger['quality'] = ' [Dolby Atmos]'
132 | elif track_info['audioModes'] == ['SONY_360RA']:
133 | tagger['quality'] = ' [360]'
134 | elif track_info['audioQuality'] == 'HI_RES':
135 | tagger['quality'] = ' [M]'
136 | else:
137 | tagger['quality'] = ''
138 |
139 | if 'explicit' in album_info:
140 | tagger['explicit'] = ' [E]' if album_info['explicit'] else ''
141 |
142 | return tagger
143 |
144 | def _meta_tag(self, tagger, track_info, album_info, track_type):
145 | self.tags(track_info, track_type, album_info, tagger)
146 |
147 | def tag_flac(self, file_path, track_info, album_info, lyrics, credits_dict=None, album_art_path=None):
148 | tagger = FLAC(file_path)
149 |
150 | self._meta_tag(tagger, track_info, album_info, 'flac')
151 | if self.fmtopts['embed_album_art'] and album_art_path is not None:
152 | pic = Picture()
153 | with open(album_art_path, 'rb') as f:
154 | pic.data = f.read()
155 |
156 | # Check if cover is smaller than 16MB
157 | if len(pic.data) < pic._MAX_SIZE:
158 | pic.type = PictureType.COVER_FRONT
159 | pic.mime = u'image/jpeg'
160 | tagger.add_picture(pic)
161 | else:
162 | print('\tCover file size is too large, only {0:.2f}MB are allowed.'.format(pic._MAX_SIZE / 1024 ** 2))
163 | print('\tSet "artwork_size" to a lower value in config/settings.py')
164 |
165 | # Set lyrics from Deezer
166 | if lyrics:
167 | tagger['lyrics'] = lyrics
168 |
169 | if credits_dict:
170 | for key, value in credits_dict.items():
171 | contributors = value.split(', ')
172 | for con in contributors:
173 | tagger.tags.append((normalize_key(key), con))
174 |
175 | tagger.save(file_path)
176 |
177 | def tag_m4a(self, file_path, track_info, album_info, lyrics, credits_dict=None, album_art_path=None):
178 | tagger = EasyMP4(file_path)
179 |
180 | # Register ISRC, UPC, lyrics and explicit tags
181 | tagger.RegisterTextKey('isrc', '----:com.apple.itunes:ISRC')
182 | tagger.RegisterTextKey('upc', '----:com.apple.itunes:UPC')
183 | tagger.RegisterTextKey('explicit', 'rtng')
184 | tagger.RegisterTextKey('lyrics', '\xa9lyr')
185 |
186 | self._meta_tag(tagger, track_info, album_info, 'm4a')
187 | if self.fmtopts['embed_album_art'] and album_art_path is not None:
188 | pic = None
189 | with open(album_art_path, 'rb') as f:
190 | pic = MP4Cover(f.read())
191 | tagger.RegisterTextKey('covr', 'covr')
192 | tagger['covr'] = [pic]
193 |
194 | # Set lyrics from Deezer
195 | if lyrics:
196 | tagger['lyrics'] = lyrics
197 |
198 | if credits_dict:
199 | for key, value in credits_dict.items():
200 | contributors = value.split(', ')
201 | key = normalize_key(key)
202 | # Create a new freeform atom and set the contributors in bytes
203 | tagger.RegisterTextKey(key, '----:com.apple.itunes:' + key)
204 | tagger[key] = [con.encode() for con in contributors]
205 |
206 | tagger.save(file_path)
207 |
--------------------------------------------------------------------------------
/redsea/tidal_api.py:
--------------------------------------------------------------------------------
1 | import pickle
2 | import uuid
3 | import os
4 | import re
5 | import json
6 | import urllib.parse as urlparse
7 | import webbrowser
8 | from urllib.parse import parse_qs
9 | import hashlib
10 | import base64
11 | import secrets
12 | from datetime import datetime, timedelta
13 | import urllib3
14 | import time
15 | import sys
16 | import prettytable
17 |
18 | import requests
19 | from urllib3.util.retry import Retry
20 | from requests.adapters import HTTPAdapter
21 | from subprocess import Popen, PIPE
22 |
23 | from config.settings import TOKEN, MOBILE_TOKEN, TV_TOKEN, TV_SECRET, SHOWAUTH
24 |
25 | technical_names = {
26 | 'eac3': 'E-AC-3 JOC (Dolby Digital Plus with Dolby Atmos, with 5.1 bed)',
27 | 'mha1': 'MPEG-H 3D Audio (Sony 360 Reality Audio)',
28 | 'ac4': 'AC-4 IMS (Dolby AC-4 with Dolby Atmos immersive stereo)',
29 | 'mqa': 'MQA (Master Quality Authenticated) in FLAC container',
30 | 'flac': 'FLAC (Free Lossless Audio Codec)',
31 | 'alac': 'ALAC (Apple Lossless Audio Codec)',
32 | 'mp4a.40.2': 'AAC 320 (Advanced Audio Coding) with a bitrate of 320kb/s',
33 | 'mp4a.40.5': 'AAC 96 (Advanced Audio Coding) with a bitrate of 96kb/s'
34 | }
35 |
36 |
37 | class TidalRequestError(Exception):
38 | def __init__(self, payload):
39 | sf = '{subStatus}: {userMessage} (HTTP {status})'.format(**payload)
40 | self.payload = payload
41 | super(TidalRequestError, self).__init__(sf)
42 |
43 |
44 | class TidalAuthError(Exception):
45 | def __init__(self, message):
46 | super(TidalAuthError, self).__init__(message)
47 |
48 |
49 | class TidalError(Exception):
50 | def __init__(self, message):
51 | self.message = message
52 | super(TidalError, self).__init__(message)
53 |
54 |
55 | class TidalApi:
56 | TIDAL_API_BASE = 'https://api.tidal.com/v1/'
57 | TIDAL_VIDEO_BASE = 'https://api.tidalhifi.com/v1/'
58 | TIDAL_CLIENT_VERSION = '2.26.1'
59 |
60 | def __init__(self, session):
61 | self.session = session
62 | self.s = requests.Session()
63 | retries = Retry(total=10,
64 | backoff_factor=0.4,
65 | status_forcelist=[429, 500, 502, 503, 504])
66 |
67 | self.s.mount('http://', HTTPAdapter(max_retries=retries))
68 | self.s.mount('https://', HTTPAdapter(max_retries=retries))
69 |
70 | def _get(self, url, params=None, refresh=False):
71 | if params is None:
72 | params = {}
73 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
74 | params['countryCode'] = self.session.country_code
75 | if 'limit' not in params:
76 | params['limit'] = '9999'
77 |
78 | # Catch video for different base
79 | if url[:5] == 'video':
80 | resp = self.s.get(
81 | self.TIDAL_VIDEO_BASE + url,
82 | headers=self.session.auth_headers(),
83 | params=params,
84 | verify=False)
85 | else:
86 | resp = self.s.get(
87 | self.TIDAL_API_BASE + url,
88 | headers=self.session.auth_headers(),
89 | params=params,
90 | verify=False)
91 |
92 | # if the request 401s or 403s, try refreshing the TV/Mobile session in case that helps
93 | if not refresh and (resp.status_code == 401 or resp.status_code == 403):
94 | if isinstance(self.session, TidalMobileSession) or isinstance(self.session, TidalTvSession):
95 | self.session.refresh()
96 | return self._get(url, params, True)
97 |
98 | resp_json = None
99 | try:
100 | resp_json = resp.json()
101 | except: # some tracks seem to return a JSON with leading whitespace
102 | try:
103 | resp_json = json.loads(resp.text.strip())
104 | except: # if this doesn't work, the HTTP status probably isn't 200. Are we rate limited?
105 | pass
106 |
107 | if not resp_json:
108 | raise TidalError('Response was not valid JSON. HTTP status {}. {}'.format(resp.status_code, resp.text))
109 |
110 | if 'status' in resp_json and resp_json['status'] == 404 and \
111 | 'subStatus' in resp_json and resp_json['subStatus'] == 2001:
112 | raise TidalError('Error: {}. This might be region-locked.'.format(resp_json['userMessage']))
113 |
114 | # Really hacky way
115 | if 'status' in resp_json and resp_json['status'] == 404 and \
116 | 'error' in resp_json and resp_json['error'] == 'Not Found':
117 | return resp_json
118 |
119 | if 'status' in resp_json and not resp_json['status'] == 200:
120 | raise TidalRequestError(resp_json)
121 |
122 | return resp_json
123 |
124 | def get_stream_url(self, track_id, quality):
125 |
126 | return self._get('tracks/' + str(track_id) + '/playbackinfopostpaywall', {
127 | 'playbackmode': 'STREAM',
128 | 'assetpresentation': 'FULL',
129 | 'audioquality': quality[0],
130 | 'prefetch': 'false'
131 | })
132 |
133 | def get_search_data(self, searchterm):
134 | return self._get('search', params={
135 | 'query': str(searchterm),
136 | 'offset': 0,
137 | 'limit': 20,
138 | 'includeContributors': 'true'
139 | })
140 |
141 | def get_page(self, page_url, offset=None):
142 | return self._get('pages/' + page_url, params={
143 | 'deviceType': 'TV',
144 | 'locale': 'en_US',
145 | 'limit': 50,
146 | 'offset': offset if offset else None
147 | })
148 |
149 | def get_credits(self, album_id):
150 | return self._get('albums/' + album_id + '/items/credits', params={
151 | 'replace': True,
152 | 'offset': 0,
153 | 'limit': 50,
154 | 'includeContributors': True
155 | })
156 |
157 | def get_video_credits(self, video_id):
158 | return self._get('videos/' + video_id + '/contributors', params={
159 | 'limit': 50
160 | })
161 |
162 | def get_lyrics(self, track_id):
163 | return self._get('tracks/' + str(track_id) + '/lyrics', params={
164 | 'deviceType': 'PHONE',
165 | 'locale': 'en_US'
166 | })
167 |
168 | def get_playlist_items(self, playlist_id):
169 | result = self._get('playlists/' + playlist_id + '/items', {
170 | 'offset': 0,
171 | 'limit': 100
172 | })
173 |
174 | if result['totalNumberOfItems'] <= 100:
175 | return result
176 |
177 | offset = len(result['items'])
178 | while True:
179 | buf = self._get('playlists/' + playlist_id + '/items', {
180 | 'offset': offset,
181 | 'limit': 100
182 | })
183 | offset += len(buf['items'])
184 | result['items'] += buf['items']
185 |
186 | if offset >= result['totalNumberOfItems']:
187 | break
188 |
189 | return result
190 |
191 | def get_playlist(self, playlist_id):
192 | return self._get('playlists/' + str(playlist_id))
193 |
194 | def get_album_tracks(self, album_id):
195 | return self._get('albums/' + str(album_id) + '/tracks')
196 |
197 | def get_track(self, track_id):
198 | return self._get('tracks/' + str(track_id))
199 |
200 | def get_album(self, album_id):
201 | return self._get('albums/' + str(album_id))
202 |
203 | def get_video(self, video_id):
204 | return self._get('videos/' + str(video_id))
205 |
206 | def get_favorite_tracks(self, user_id):
207 | return self._get('users/' + str(user_id) + '/favorites/tracks')
208 |
209 | def get_track_contributors(self, track_id):
210 | return self._get('tracks/' + str(track_id) + '/contributors')
211 |
212 | def get_video_stream_url(self, video_id):
213 | return self._get('videos/' + str(video_id) + '/streamurl')
214 |
215 | def get_artist(self, artist_id):
216 | return self._get('artists/' + str(artist_id))
217 |
218 | def get_artist_albums(self, artist_id):
219 | return self._get('artists/' + str(artist_id) + '/albums')
220 |
221 | def get_artist_albums_ep_singles(self, artist_id):
222 | return self._get('artists/' + str(artist_id) + '/albums', params={'filter': 'EPSANDSINGLES'})
223 |
224 | def get_type_from_id(self, id_):
225 | result = None
226 | try:
227 | result = self.get_album(id_)
228 | return 'a'
229 | except TidalError:
230 | pass
231 | try:
232 | result = self.get_artist(id_)
233 | return 'r'
234 | except TidalError:
235 | pass
236 | try:
237 | result = self.get_track(id_)
238 | return 't'
239 | except TidalError:
240 | pass
241 | try:
242 | result = self.get_video(id_)
243 | return 'v'
244 | except TidalError:
245 | pass
246 |
247 | return result
248 |
249 | @classmethod
250 | def get_album_artwork_url(cls, album_id, size=1280):
251 | return 'https://resources.tidal.com/images/{0}/{1}x{1}.jpg'.format(
252 | album_id.replace('-', '/'), size)
253 |
254 |
255 | class SessionFormats:
256 | def __init__(self, session):
257 | self.mqa_trackid = '91950969'
258 | self.dolby_trackid = '131069353'
259 | self.sony_trackid = '142292058'
260 |
261 | self.quality = ['HI_RES', 'LOSSLESS', 'HIGH', 'LOW']
262 |
263 | self.formats = {
264 | 'eac3': False,
265 | 'mha1': False,
266 | 'ac4': False,
267 | 'mqa': False,
268 | 'flac': False,
269 | 'alac': False,
270 | 'mp4a.40.2': False,
271 | 'mp4a.40.5': False
272 | }
273 |
274 | try:
275 | self.check_formats(session)
276 | except TidalRequestError:
277 | print('\tERROR: No (HiFi) subscription found!')
278 |
279 | def check_formats(self, session):
280 | api = TidalApi(session)
281 |
282 | for id in [self.dolby_trackid, self.sony_trackid]:
283 | playback_info = api.get_stream_url(id, ['LOW'])
284 | if playback_info['manifestMimeType'] == 'application/dash+xml':
285 | continue
286 | manifest_unparsed = base64.b64decode(playback_info['manifest']).decode('UTF-8')
287 | if 'ContentProtection' not in manifest_unparsed:
288 | self.formats[json.loads(manifest_unparsed)['codecs']] = True
289 |
290 | for i in range(len(self.quality)):
291 | playback_info = api.get_stream_url(self.mqa_trackid, [self.quality[i]])
292 | if playback_info['manifestMimeType'] == 'application/dash+xml':
293 | continue
294 |
295 | manifest_unparsed = base64.b64decode(playback_info['manifest']).decode('UTF-8')
296 | if 'ContentProtection' not in manifest_unparsed:
297 | self.formats[json.loads(manifest_unparsed)['codecs']] = True
298 |
299 | def print_fomats(self):
300 | table = prettytable.PrettyTable()
301 | table.field_names = ['Codec', 'Technical name', 'Supported']
302 | table.align = 'l'
303 | for format in self.formats:
304 | table.add_row([format, technical_names[format], self.formats[format]])
305 |
306 | string_table = '\t' + table.__str__().replace('\n', '\n\t')
307 | print(string_table)
308 | print('')
309 |
310 |
311 | class ReCaptcha(object):
312 | def __init__(self):
313 | self.captcha_path = 'captcha/'
314 |
315 | self.response_v3 = None
316 | self.response_v2 = None
317 |
318 | self.get_response()
319 |
320 | @staticmethod
321 | def check_npm():
322 | pipe = Popen('npm -version', shell=True, stdout=PIPE).stdout
323 | output = pipe.read().decode('UTF-8')
324 | found = re.search(r'[0-9].[0-9]+.', output)
325 | if not found:
326 | print("NPM could not be found.")
327 | return False
328 | return True
329 |
330 | def get_response(self):
331 | if self.check_npm():
332 | print("Opening reCAPTCHA check...")
333 | command = 'npm start --prefix '
334 | pipe = Popen(command + self.captcha_path, shell=True, stdout=PIPE)
335 | pipe.wait()
336 | output = pipe.stdout.read().decode('UTF-8')
337 | pattern = re.compile(r"(?<='response': ')[0-9A-Za-z-_]+")
338 | response = pattern.findall(output)
339 | if len(response) > 2:
340 | print('You only need to complete the captcha once.')
341 | return False
342 | elif len(response) == 1:
343 | self.response_v3 = response[0]
344 | return True
345 | elif len(response) == 2:
346 | self.response_v3 = response[0]
347 | self.response_v2 = response[1]
348 | return True
349 |
350 | print('Please complete the reCAPTCHA check.')
351 | return False
352 |
353 |
354 | class TidalSession:
355 | '''
356 | Tidal session object which can be used to communicate with Tidal servers
357 | '''
358 |
359 | def __init__(self, username, password):
360 | '''
361 | Initiate a new session
362 | '''
363 | self.TIDAL_CLIENT_VERSION = '2.26.1'
364 | self.TIDAL_API_BASE = 'https://api.tidal.com/v1/'
365 |
366 | self.username = username
367 | self.token = TOKEN
368 | self.unique_id = str(uuid.uuid4()).replace('-', '')[16:]
369 |
370 | self.session_id = None
371 | self.user_id = None
372 | self.country_code = None
373 |
374 | # simple fix for OOB
375 | if username != '' and password != '':
376 | self.auth(password)
377 |
378 | password = None
379 |
380 | def auth(self, password):
381 | '''
382 | Attempts to authorize and create a new valid session
383 | '''
384 |
385 | params = {
386 | 'username': self.username,
387 | 'password': password,
388 | 'token': self.token,
389 | 'clientUniqueKey': self.unique_id,
390 | 'clientVersion': self.TIDAL_CLIENT_VERSION
391 | }
392 |
393 | r = requests.post(self.TIDAL_API_BASE + 'login/username', data=params, verify=False)
394 |
395 | password = None
396 |
397 | if not r.status_code == 200:
398 | raise TidalRequestError(r)
399 |
400 | self.session_id = r.json()['sessionId']
401 | self.user_id = r.json()['userId']
402 | self.country_code = r.json()['countryCode']
403 |
404 | assert self.valid(), 'This session has an invalid sessionId. Please re-authenticate'
405 | self.check_subscription()
406 |
407 | @staticmethod
408 | def session_type():
409 | '''
410 | Returns the type of token used to create the session
411 | '''
412 | return 'Desktop'
413 |
414 | def check_subscription(self):
415 | '''
416 | Checks if subscription is either HiFi or Premium Plus
417 | '''
418 | r = requests.get(f'{self.TIDAL_API_BASE}users/{self.user_id}/subscription',
419 | headers=self.auth_headers(), verify=False)
420 | assert (r.status_code == 200)
421 | if r.json()['subscription']['type'] not in ['HIFI', 'PREMIUM_PLUS']:
422 | raise TidalAuthError('You need a HiFi subscription')
423 |
424 | def valid(self):
425 | '''
426 | Checks if session is still valid and returns True/False
427 | '''
428 | if not isinstance(self, TidalSession):
429 | if self.access_token is None or datetime.now() > self.expires:
430 | return False
431 |
432 | r = requests.get(f'{self.TIDAL_API_BASE}sessions', headers=self.auth_headers(), verify=False)
433 | return r.status_code == 200
434 |
435 | def auth_headers(self):
436 | return {
437 | 'Host': 'api.tidal.com',
438 | 'User-Agent': 'okhttp/3.12.3',
439 | 'X-Tidal-Token': self.token,
440 | 'X-Tidal-SessionId': self.session_id,
441 | 'Connection': 'Keep-Alive',
442 | 'Accept-Encoding': 'gzip',
443 | }
444 |
445 |
446 | class TidalMobileSession(TidalSession):
447 | '''
448 | Tidal session object based on the mobile Android oauth flow
449 | '''
450 |
451 | def __init__(self, username, password):
452 | # init the TidalSession class first
453 | super(TidalMobileSession, self).__init__('', '')
454 |
455 | self.TIDAL_LOGIN_BASE = 'https://login.tidal.com/api/'
456 | self.TIDAL_AUTH_BASE = 'https://auth.tidal.com/v1/'
457 |
458 | self.username = username
459 | self.client_id = MOBILE_TOKEN
460 | self.redirect_uri = 'https://tidal.com/android/login/auth'
461 | self.code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=')
462 | self.code_challenge = base64.urlsafe_b64encode(hashlib.sha256(self.code_verifier).digest()).rstrip(b'=')
463 | self.client_unique_key = secrets.token_hex(16)
464 | self.user_agent = 'Mozilla/5.0 (Linux; Android 12; Pixel 6 Build/RKQ1.200826.002; wv) AppleWebKit/537.36 ' \
465 | '(KHTML, like Gecko) Version/4.0 Chrome/105.0.5195.136 Mobile Safari/537.36'
466 |
467 | self.access_token = None
468 | self.refresh_token = None
469 | self.expires = None
470 | self.user_id = None
471 | self.cid = None
472 | self.country_code = None
473 |
474 | self.auth(password)
475 |
476 | def auth(self, password):
477 | s = requests.Session()
478 |
479 | params = {
480 | 'response_type': 'code',
481 | 'redirect_uri': self.redirect_uri,
482 | 'lang': 'en_US',
483 | 'appMode': 'android',
484 | 'client_id': self.client_id,
485 | 'client_unique_key': self.client_unique_key,
486 | 'code_challenge': self.code_challenge,
487 | 'code_challenge_method': 'S256',
488 | 'restrict_signup': 'true'
489 | }
490 |
491 | # retrieve csrf token for subsequent request
492 | r = s.get('https://login.tidal.com/authorize', params=params, headers={
493 | 'user-agent': self.user_agent,
494 | 'accept-language': 'en-US',
495 | 'x-requested-with': 'com.aspiro.tidal'
496 | })
497 |
498 | if r.status_code == 400:
499 | raise TidalAuthError("Authorization failed! Is the clientid/token up to date?")
500 | elif r.status_code == 403:
501 | raise TidalAuthError("TIDAL BOT protection, try again later!")
502 |
503 | # try Tidal DataDome cookie request
504 | r = s.post('https://dd.tidal.com/js/', data={
505 | 'ddk': '1F633CDD8EF22541BD6D9B1B8EF13A', # API Key (required)
506 | 'Referer': r.url, # Referer authorize link (required)
507 | 'responsePage': 'origin', # useless?
508 | 'ddv': '4.4.7' # useless?
509 | }, headers={
510 | 'user-agent': self.user_agent,
511 | 'content-type': 'application/x-www-form-urlencoded'
512 | })
513 |
514 | if r.status_code != 200 or not r.json().get('cookie'):
515 | raise TidalAuthError("TIDAL BOT protection, could not get DataDome cookie!")
516 |
517 | # get the cookie from the json request and save it in the session
518 | dd_cookie = r.json().get('cookie').split(';')[0]
519 | s.cookies[dd_cookie.split('=')[0]] = dd_cookie.split('=')[1]
520 |
521 | # enter email, verify email is valid
522 | r = s.post(self.TIDAL_LOGIN_BASE + 'email', params=params, json={
523 | 'email': self.username
524 | }, headers={
525 | 'user-agent': self.user_agent,
526 | 'x-csrf-token': s.cookies['_csrf-token'],
527 | 'accept': 'application/json, text/plain, */*',
528 | 'content-type': 'application/json',
529 | 'accept-language': 'en-US',
530 | 'x-requested-with': 'com.aspiro.tidal'
531 | })
532 |
533 | if r.status_code != 200:
534 | raise TidalAuthError(r.text)
535 |
536 | if not r.json()['isValidEmail']:
537 | raise TidalAuthError('Invalid email')
538 | if r.json()['newUser']:
539 | raise TidalAuthError('User does not exist')
540 |
541 | # login with user credentials
542 | r = s.post(self.TIDAL_LOGIN_BASE + 'email/user/existing', params=params, json={
543 | 'email': self.username,
544 | 'password': password
545 | }, headers={
546 | 'User-Agent': self.user_agent,
547 | 'x-csrf-token': s.cookies['_csrf-token'],
548 | 'accept': 'application/json, text/plain, */*',
549 | 'content-type': 'application/json',
550 | 'accept-language': 'en-US',
551 | 'x-requested-with': 'com.aspiro.tidal'
552 | })
553 |
554 | if r.status_code != 200:
555 | raise TidalAuthError(r.text)
556 |
557 | # retrieve access code
558 | r = s.get('https://login.tidal.com/success?lang=en', allow_redirects=False, headers={
559 | 'user-agent': self.user_agent,
560 | 'accept-language': 'en-US',
561 | 'x-requested-with': 'com.aspiro.tidal'
562 | })
563 |
564 | if r.status_code == 401:
565 | raise TidalAuthError('Incorrect password')
566 | assert (r.status_code == 302)
567 | url = urlparse.urlparse(r.headers['location'])
568 | oauth_code = parse_qs(url.query)['code'][0]
569 |
570 | # exchange access code for oauth token
571 | r = requests.post(self.TIDAL_AUTH_BASE + 'oauth2/token', data={
572 | 'code': oauth_code,
573 | 'client_id': self.client_id,
574 | 'grant_type': 'authorization_code',
575 | 'redirect_uri': self.redirect_uri,
576 | 'scope': 'r_usr w_usr w_sub',
577 | 'code_verifier': self.code_verifier,
578 | 'client_unique_key': self.client_unique_key
579 | }, headers={
580 | 'User-Agent': self.user_agent
581 | })
582 |
583 | if r.status_code != 200:
584 | raise TidalAuthError(r.text)
585 |
586 | self.access_token = r.json()['access_token']
587 | self.refresh_token = r.json()['refresh_token']
588 | self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in'])
589 |
590 | if SHOWAUTH:
591 | print('Your Authorization token: ' + self.access_token)
592 |
593 | r = requests.get(f'{self.TIDAL_API_BASE}sessions', headers=self.auth_headers(), verify=False)
594 | assert (r.status_code == 200)
595 | self.user_id = r.json()['userId']
596 | self.country_code = r.json()['countryCode']
597 |
598 | self.check_subscription()
599 |
600 | def refresh(self):
601 | assert (self.refresh_token is not None)
602 | r = requests.post(self.TIDAL_AUTH_BASE + 'oauth2/token', data={
603 | 'refresh_token': self.refresh_token,
604 | 'client_id': self.client_id,
605 | 'grant_type': 'refresh_token'
606 | }, verify=False)
607 |
608 | if r.status_code == 200:
609 | print('\tRefreshing token successful')
610 | self.access_token = r.json()['access_token']
611 | self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in'])
612 |
613 | if SHOWAUTH:
614 | print('Your Authorization token: ' + self.access_token)
615 |
616 | if 'refresh_token' in r.json():
617 | self.refresh_token = r.json()['refresh_token']
618 |
619 | elif r.status_code == 401:
620 | print('\tERROR: ' + r.json()['userMessage'])
621 |
622 | return r.status_code == 200
623 |
624 | def session_type(self):
625 | return 'Mobile'
626 |
627 | def auth_headers(self):
628 | return {
629 | 'Host': 'api.tidal.com',
630 | 'X-Tidal-Token': self.client_id,
631 | 'Authorization': 'Bearer {}'.format(self.access_token),
632 | 'Connection': 'Keep-Alive',
633 | 'Accept-Encoding': 'gzip',
634 | 'User-Agent': 'TIDAL_ANDROID/1039 okhttp/3.14.9'
635 | }
636 |
637 |
638 | class TidalTvSession(TidalSession):
639 | '''
640 | Tidal session object based on the mobile Android oauth flow
641 | '''
642 |
643 | def __init__(self):
644 | # init the TidalSession class first
645 | super(TidalTvSession, self).__init__('', '')
646 |
647 | self.TIDAL_AUTH_BASE = 'https://auth.tidal.com/v1/'
648 |
649 | self.username = None
650 | self.client_id = TV_TOKEN
651 | self.client_secret = TV_SECRET
652 |
653 | self.device_code = None
654 | self.user_code = None
655 |
656 | self.access_token = None
657 | self.refresh_token = None
658 | self.expires = None
659 | self.user_id = None
660 | self.country_code = None
661 |
662 | self.auth()
663 |
664 | def auth(self, password=''):
665 | s = requests.Session()
666 |
667 | # retrieve csrf token for subsequent request
668 | r = s.post(self.TIDAL_AUTH_BASE + 'oauth2/device_authorization', data={
669 | 'client_id': self.client_id,
670 | 'scope': 'r_usr w_usr'
671 | }, verify=False)
672 |
673 | if r.status_code == 400:
674 | raise TidalAuthError("Authorization failed! Is the clientid/token up to date?")
675 | elif r.status_code == 403:
676 | raise TidalAuthError("Tidal BOT Protection, try again later!")
677 |
678 | self.device_code = r.json()['deviceCode']
679 | self.user_code = r.json()['userCode']
680 | print('Go to https://link.tidal.com/{} and log in or sign up to TIDAL.'.format(self.user_code))
681 | webbrowser.open('https://link.tidal.com/' + self.user_code, new=2)
682 |
683 | data = {
684 | 'client_id': self.client_id,
685 | 'device_code': self.device_code,
686 | 'client_secret': self.client_secret,
687 | 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
688 | 'scope': 'r_usr w_usr'
689 | }
690 |
691 | status_code = 400
692 | print('Checking link ', end='')
693 |
694 | while status_code == 400:
695 | for index, char in enumerate("." * 5):
696 | sys.stdout.write(char)
697 | sys.stdout.flush()
698 | # exchange access code for oauth token
699 | time.sleep(0.2)
700 | r = requests.post(self.TIDAL_AUTH_BASE + 'oauth2/token', data=data, verify=False)
701 | status_code = r.status_code
702 | index += 1 # lists are zero indexed, we need to increase by one for the accurate count
703 | # backtrack the written characters, overwrite them with space, backtrack again:
704 | sys.stdout.write("\b" * index + " " * index + "\b" * index)
705 | sys.stdout.flush()
706 |
707 | if r.status_code == 200:
708 | print('\nSuccessfully linked!')
709 | elif r.status_code == 401:
710 | raise TidalAuthError('Auth Error: ' + r.json()['error'])
711 |
712 | self.access_token = r.json()['access_token']
713 | self.refresh_token = r.json()['refresh_token']
714 | self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in'])
715 |
716 | if SHOWAUTH:
717 | print('Your Authorization token: ' + self.access_token)
718 |
719 | r = requests.get('https://api.tidal.com/v1/sessions', headers=self.auth_headers(), verify=False)
720 | assert (r.status_code == 200)
721 | self.user_id = r.json()['userId']
722 | self.country_code = r.json()['countryCode']
723 |
724 | r = requests.get('https://api.tidal.com/v1/users/{}?countryCode={}'.format(self.user_id, self.country_code),
725 | headers=self.auth_headers(), verify=False)
726 | assert (r.status_code == 200)
727 | self.username = r.json()['username']
728 |
729 | self.check_subscription()
730 |
731 | def refresh(self):
732 | assert (self.refresh_token is not None)
733 | r = requests.post(self.TIDAL_AUTH_BASE + 'oauth2/token', data={
734 | 'refresh_token': self.refresh_token,
735 | 'client_id': self.client_id,
736 | 'client_secret': self.client_secret,
737 | 'grant_type': 'refresh_token'
738 | }, verify=False)
739 |
740 | if r.status_code == 200:
741 | print('\tRefreshing token successful')
742 | self.access_token = r.json()['access_token']
743 | self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in'])
744 |
745 | if SHOWAUTH:
746 | print('Your Authorization token: ' + self.access_token)
747 |
748 | if 'refresh_token' in r.json():
749 | self.refresh_token = r.json()['refresh_token']
750 |
751 | return r.status_code == 200
752 |
753 | def session_type(self):
754 | return 'Tv'
755 |
756 | def auth_headers(self):
757 | return {
758 | 'Host': 'api.tidal.com',
759 | 'X-Tidal-Token': self.client_id,
760 | 'Authorization': 'Bearer {}'.format(self.access_token),
761 | 'Connection': 'Keep-Alive',
762 | 'Accept-Encoding': 'gzip',
763 | 'User-Agent': 'TIDAL_ANDROID/1039 okhttp/3.14.9'
764 | }
765 |
766 |
767 | class TidalSessionFile(object):
768 | '''
769 | Tidal session storage file which can save/load
770 | '''
771 |
772 | def __init__(self, session_file):
773 | self.VERSION = '1.0'
774 | self.session_file = session_file # Session file path
775 | self.session_store = {} # Will contain data from session file
776 | self.sessions = {} # Will contain sessions from session_store['sessions']
777 | self.default = None # Specifies the name of the default session to use
778 |
779 | if os.path.isfile(self.session_file):
780 | with open(self.session_file, 'rb') as f:
781 | self.session_store = pickle.load(f)
782 | if 'version' in self.session_store and self.session_store['version'] == self.VERSION:
783 | self.sessions = self.session_store['sessions']
784 | self.default = self.session_store['default']
785 | elif 'version' in self.session_store:
786 | raise ValueError(
787 | 'Session file is version {} while redsea expects version {}'.
788 | format(self.session_store['version'], self.VERSION))
789 | else:
790 | raise ValueError('Existing session file is malformed. Please delete/rebuild session file.')
791 | f.close()
792 | else:
793 | self._save()
794 | self = TidalSessionFile(session_file=self.session_file)
795 |
796 | def _save(self):
797 | '''
798 | Attempts to write current session store to file
799 | '''
800 |
801 | self.session_store['version'] = self.VERSION
802 | self.session_store['sessions'] = self.sessions
803 | self.session_store['default'] = self.default
804 |
805 | with open(self.session_file, 'wb') as f:
806 | pickle.dump(self.session_store, f)
807 |
808 | def new_session(self, session_name, username, password, device):
809 | '''
810 | Create a new TidalSession object and auth with Tidal server
811 | '''
812 |
813 | if session_name not in self.sessions:
814 | if device == 'mobile':
815 | session = TidalMobileSession(username, password)
816 | elif device == 'tv':
817 | session = TidalTvSession()
818 | else:
819 | session = TidalSession(username, password)
820 | self.sessions[session_name] = session
821 | password = None
822 |
823 | if len(self.sessions) == 1:
824 | self.default = session_name
825 | else:
826 | password = None
827 | raise ValueError('Session "{}" already exists in sessions file!'.format(session_name))
828 |
829 | self._save()
830 |
831 | def remove(self, session_name):
832 | '''
833 | Removes a session from the session store and saves the session file
834 | '''
835 |
836 | if session_name not in self.sessions:
837 | raise ValueError('Session "{}" does not exist in session store.'.format(session_name))
838 |
839 | self.sessions.pop(session_name)
840 | self._save()
841 |
842 | def load(self, session_name=None):
843 | '''
844 | Returns a session from the session store
845 | '''
846 |
847 | if len(self.sessions) == 0:
848 | raise ValueError('There are no sessions in session file and no valid AUTHHEADER was provided!')
849 |
850 | if session_name is None:
851 | session_name = self.default
852 |
853 | if session_name in self.sessions:
854 | # TODO: Only required for old sessions, remove if possible
855 | if not hasattr(self.sessions[session_name], 'TIDAL_API_BASE'):
856 | self.sessions[session_name].TIDAL_API_BASE = 'https://api.tidal.com/v1/'
857 |
858 | if not self.sessions[session_name].valid() and isinstance(self.sessions[session_name], TidalMobileSession):
859 | self.sessions[session_name].refresh()
860 | if not self.sessions[session_name].valid() and isinstance(self.sessions[session_name], TidalTvSession):
861 | self.sessions[session_name].refresh()
862 | assert self.sessions[session_name].valid(), '{} has an invalid sessionId. Please re-authenticate'.format(
863 | session_name)
864 |
865 | self._save()
866 |
867 | return self.sessions[session_name]
868 |
869 | raise ValueError('Session "{}" could not be found.'.format(session_name))
870 |
871 | def set_default(self, session_name):
872 | '''
873 | Set a default session to return when
874 | load() is called without a session name
875 | '''
876 |
877 | if session_name in self.sessions:
878 | # TODO: Only required for old sessions, remove if possible
879 | if not hasattr(self.sessions[session_name], 'TIDAL_API_BASE'):
880 | self.sessions[session_name].TIDAL_API_BASE = 'https://api.tidal.com/v1/'
881 |
882 | if not self.sessions[session_name].valid() and isinstance(self.sessions[session_name], TidalMobileSession):
883 | self.sessions[session_name].refresh()
884 | if not self.sessions[session_name].valid() and isinstance(self.sessions[session_name], TidalTvSession):
885 | self.sessions[session_name].refresh()
886 | assert self.sessions[session_name].valid(), '{} has an invalid sessionId. Please re-authenticate'.format(
887 | session_name)
888 | self.default = session_name
889 | self._save()
890 |
--------------------------------------------------------------------------------
/redsea/videodownloader.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 | import shutil
4 | import unicodedata
5 |
6 | import ffmpeg
7 | import requests
8 | from mutagen.easymp4 import EasyMP4
9 | from mutagen.mp4 import MP4Cover
10 | from mutagen.mp4 import MP4Tags
11 |
12 | # Needed for Windows tagging support
13 | MP4Tags._padding = 0
14 |
15 |
16 | def normalize_key(s):
17 | # Remove accents from a given string
18 | return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
19 |
20 |
21 | def parse_master_playlist(masterurl: str):
22 | content = str(requests.get(masterurl, verify=False).content)
23 | pattern = re.compile(r"(?<=RESOLUTION=)[0-9]+x[0-9]+")
24 | resolution_list = pattern.findall(content)
25 | pattern = re.compile(r"(?<=http).+?(?=\\n)")
26 | plist = pattern.findall(content)
27 | playlists = [{'height': int(resolution_list[i].split('x')[1]),
28 | 'url': "http" + plist[i]} for i in range(len(plist))]
29 |
30 | return sorted(playlists, key=lambda k: k['height'], reverse=True)
31 |
32 |
33 | def parse_playlist(url: str):
34 | content = requests.get(url, verify=False).content
35 | pattern = re.compile(r"(?<=http).+?(?=\\n)")
36 | plist = pattern.findall(str(content))
37 | urllist = []
38 | for item in plist:
39 | urllist.append("http" + item)
40 |
41 | return urllist
42 |
43 |
44 | def download_file(urllist: list, part: int, filename: str):
45 | if os.path.isfile(filename):
46 | # print('\tFile {} already exists, skipping.'.format(filename))
47 | return None
48 |
49 | r = requests.get(urllist[part], stream=True, verify=False)
50 | try:
51 | total = int(r.headers['content-length'])
52 | except KeyError:
53 | return False
54 |
55 | with open(filename, 'wb') as f:
56 | cc = 0
57 | for chunk in r.iter_content(chunk_size=1024):
58 | cc += 1024
59 | if chunk: # filter out keep-alive new chunks
60 | f.write(chunk)
61 | f.close()
62 |
63 |
64 | def print_video_info(track_info: dict):
65 | line = '\tTitle: {0}\n\tArtist: {1}\n\tType: {2}\n\tResolution: {3}'.format(track_info['title'],
66 | track_info['artist']['name'],
67 | track_info['type'],
68 | track_info['resolution'])
69 | try:
70 | print(line)
71 | except UnicodeEncodeError:
72 | line = line.encode('ascii', 'replace').decode('ascii')
73 | print(line)
74 | print('\t----')
75 |
76 |
77 | def download_video_artwork(image_id: str, where: str):
78 | url = 'https://resources.tidal.com/images/{0}/{1}x{2}.jpg'.format(
79 | image_id.replace('-', '/'), 1280, 720)
80 |
81 | r = requests.get(url, stream=True, verify=False)
82 |
83 | try:
84 | total = int(r.headers['content-length'])
85 | except KeyError:
86 | return False
87 | with open(where, 'wb') as f:
88 | cc = 0
89 | for chunk in r.iter_content(chunk_size=1024):
90 | cc += 1024
91 | print(
92 | "\tDownload progress: {0:.0f}%".format((cc / total) * 100),
93 | end='\r')
94 | if chunk: # filter out keep-alive new chunks
95 | f.write(chunk)
96 | print()
97 | return True
98 |
99 |
100 | def tags(video_info: dict, tagger=None, ftype=None):
101 | if tagger is None:
102 | tagger = {'id': video_info['id'], 'quality': ' [' + video_info['quality'][4:] + ']'}
103 |
104 | tagger['title'] = video_info['title']
105 | tagger['artist'] = video_info['artist']['name']
106 | if ftype:
107 | tagger['tracknumber'] = str(video_info['trackNumber']).zfill(2) + '/' + str(video_info['volumeNumber'])
108 | else:
109 | tagger['tracknumber'] = str(video_info['trackNumber']).zfill(2)
110 | tagger['discnumber'] = str(video_info['volumeNumber'])
111 |
112 | if 'explicit' in video_info:
113 | if ftype:
114 | tagger['explicit'] = b'\x01' if video_info['explicit'] else b'\x02'
115 | else:
116 | tagger['explicit'] = ' [E]' if video_info['explicit'] else ''
117 |
118 | if video_info['releaseDate']:
119 | # TODO: less hacky way of getting the year?
120 | tagger['date'] = str(video_info['releaseDate'][:4])
121 |
122 | return tagger
123 |
124 |
125 | def tag_video(file_path: str, track_info: dict, credits_dict: dict, album_art_path: str):
126 | tagger = EasyMP4(file_path)
127 | tagger.RegisterTextKey('explicit', 'rtng')
128 |
129 | # Add tags to the EasyMP4 tagger
130 | tags(track_info, tagger, ftype='mp4')
131 |
132 | pic = None
133 | with open(album_art_path, 'rb') as f:
134 | pic = MP4Cover(f.read())
135 | tagger.RegisterTextKey('covr', 'covr')
136 | tagger['covr'] = [pic]
137 |
138 | if credits_dict:
139 | for key, value in credits_dict.items():
140 | key = normalize_key(key)
141 | # Create a new freeform atom and set the contributors in bytes
142 | tagger.RegisterTextKey(key, '----:com.apple.itunes:' + key)
143 | tagger[key] = [bytes(con, encoding='utf-8') for con in value]
144 |
145 | tagger.save(file_path)
146 |
147 |
148 | def download_stream(folder_path: str, file_name: str, url: str, resolution: int, video_info: dict, credits_dict: dict):
149 | tmp_folder = os.path.join(folder_path, 'tmp')
150 | playlists = parse_master_playlist(url)
151 | urllist = []
152 |
153 | for playlist in playlists:
154 | if resolution >= playlist['height']:
155 | video_info['resolution'] = playlist['height']
156 | urllist = parse_playlist(playlist['url'])
157 | break
158 |
159 | if len(urllist) <= 0:
160 | print('Error: list of URLs is empty!')
161 | return False
162 |
163 | print_video_info(video_info)
164 |
165 | if not os.path.isdir(tmp_folder):
166 | os.makedirs(tmp_folder)
167 |
168 | filelist_loc = os.path.join(tmp_folder, 'filelist.txt')
169 |
170 | if os.path.exists(filelist_loc):
171 | os.remove(filelist_loc)
172 |
173 | filename = ""
174 | for i in range(len(urllist)):
175 | try:
176 | filename = os.path.join(tmp_folder, str(i).zfill(3) + '.ts')
177 | download_file(urllist, i, filename)
178 | with open(filelist_loc, 'a') as f:
179 | f.write("file '" + str(i).zfill(3) + '.ts' + "'\n")
180 | percent = i / (len(urllist) - 1) * 100
181 | print("\tDownload progress: {0:.0f}%".format(percent), end='\r')
182 | # print(percent)
183 |
184 | # Delete partially downloaded file on keyboard interrupt
185 | except KeyboardInterrupt:
186 | if os.path.isfile(filename):
187 | print('\tDeleting partially downloaded file ' + str(filename))
188 | os.remove(filename)
189 | raise
190 | # print("\tDownload progress: {0:.0f}%".format(percent), end='\r')
191 | print("\n\tDownload succeeded!")
192 |
193 | file_path = os.path.join(folder_path, file_name + '.mp4')
194 |
195 | (
196 | ffmpeg
197 | .input(filelist_loc, format='concat', safe=0)
198 | .output(file_path, vcodec='copy', acodec='copy', loglevel='warning')
199 | .overwrite_output()
200 | .run()
201 | )
202 | print('\tConcatenation succeeded!')
203 | shutil.rmtree(tmp_folder)
204 |
205 | print('\tDownloading album art ...')
206 | aa_location = os.path.join(folder_path, 'Cover.jpg')
207 | if not os.path.isfile(aa_location):
208 | if not download_video_artwork(video_info['imageId'], aa_location):
209 | aa_location = None
210 |
211 | print('\tTagging video file...')
212 | tag_video(file_path, video_info, credits_dict, aa_location)
213 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | mutagen>=1.37
2 | pycryptodomex>=3.6.1
3 | requests>=2.22.0
4 | urllib3>=1.25.3
5 | ffmpeg-python>=0.2.0
6 | prettytable>1.0.0
7 | tqdm>=4.56.0
8 |
--------------------------------------------------------------------------------