├── .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 | --------------------------------------------------------------------------------