├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md └── workflows │ ├── build-release.yaml │ ├── continuous-integration.yaml │ └── update-winget.yaml ├── .gitignore ├── LICENSE ├── README.md ├── binaries └── AtomicParsley.exe ├── build ├── appimage-fix.js ├── appx │ ├── Square150x150Logo.png │ ├── Square44x44Logo.png │ ├── StoreLogo.png │ └── Wide310x150Logo.png ├── elevate.exe ├── vcredist.nsh └── vcredist_x86.exe ├── codecov.yaml ├── main.js ├── modules ├── Analytics.js ├── AppUpdater.js ├── BinaryUpdater.js ├── ClipboardWatcher.js ├── DetectPython.js ├── DoneAction.js ├── Environment.js ├── FfmpegUpdater.js ├── Filepaths.js ├── QueryManager.js ├── Utils.js ├── download │ ├── DownloadQuery.js │ └── DownloadQueryList.js ├── exceptions │ ├── ErrorHandler.js │ └── errorDefinitions.json ├── info │ ├── InfoQuery.js │ └── InfoQueryList.js ├── persistence │ ├── Logger.js │ ├── Settings.js │ └── TaskList.js ├── size │ └── SizeQuery.js └── types │ ├── Format.js │ ├── ProgressBar.js │ ├── Query.js │ └── Video.js ├── package-lock.json ├── package.json ├── preload.js ├── renderer ├── .eslintrc.js ├── img │ ├── card-text-strike-light.svg │ ├── card-text-strike.svg │ ├── done-icon.png │ ├── downloading-icon.png │ ├── icon-titlebar-dark.png │ ├── icon-titlebar-light.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ └── plain-placeholder.png ├── lib │ ├── select2.min.css │ └── select2.min.js ├── renderer.css ├── renderer.html └── renderer.js ├── tests ├── Analytics.test.js ├── BinaryUpdater.test.js ├── ClipboardWatcher.test.js ├── DetectPython.test.js ├── DoneAction.test.js ├── ErrorHandler.test.js ├── FfmpegUpdater.test.js ├── Filepaths.test.js ├── Format.test.js ├── InfoQuery.test.js ├── InfoQueryList.test.js ├── Logger.test.js ├── Query.test.js ├── Settings.test.js ├── TaskList.test.js ├── Utils.test.js ├── Video.test.js ├── iso-test.json └── test-settings.json └── ytdlgui_demo.gif /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "node": true, 4 | "browser": true, 5 | "commonjs": true, 6 | "es2021": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "parserOptions": { 10 | "ecmaVersion": 12 11 | }, 12 | "rules": { 13 | "accessor-pairs": "error", 14 | "array-bracket-newline": "error", 15 | "array-bracket-spacing": [ 16 | "error", 17 | "never" 18 | ], 19 | "array-callback-return": "error", 20 | "array-element-newline": "off", 21 | "arrow-body-style": "off", 22 | "arrow-parens": "off", 23 | "arrow-spacing": "off", 24 | "block-scoped-var": "error", 25 | "block-spacing": [ 26 | "error", 27 | "always" 28 | ], 29 | "brace-style": [ 30 | "error", 31 | "1tbs", 32 | { 33 | "allowSingleLine": true 34 | } 35 | ], 36 | "camelcase": "off", 37 | "capitalized-comments": [ 38 | "error", 39 | "always" 40 | ], 41 | "class-methods-use-this": "off", 42 | "comma-dangle": "off", 43 | "comma-spacing": "off", 44 | "comma-style": [ 45 | "error", 46 | "last" 47 | ], 48 | "complexity": [ 49 | "error", 50 | { "max": 36 } 51 | ], 52 | "computed-property-spacing": [ 53 | "error", 54 | "never" 55 | ], 56 | "consistent-return": "off", 57 | "consistent-this": "error", 58 | "curly": "off", 59 | "default-case": "off", 60 | "default-case-last": "error", 61 | "default-param-last": "error", 62 | "dot-location": [ 63 | "error", 64 | "property" 65 | ], 66 | "dot-notation": [ 67 | "error", 68 | { 69 | "allowKeywords": true 70 | } 71 | ], 72 | "eol-last": "error", 73 | "eqeqeq": "off", 74 | "func-call-spacing": "error", 75 | "func-name-matching": "error", 76 | "func-names": "error", 77 | "func-style": [ 78 | "error", 79 | "declaration", 80 | ], 81 | "function-paren-newline": "error", 82 | "generator-star-spacing": "error", 83 | "grouped-accessor-pairs": "error", 84 | "guard-for-in": "error", 85 | "id-denylist": "error", 86 | "id-length": "off", 87 | "id-match": "error", 88 | "implicit-arrow-linebreak": [ 89 | "error", 90 | "beside" 91 | ], 92 | "indent": "off", 93 | "init-declarations": "off", 94 | "jsx-quotes": "error", 95 | "key-spacing": "off", 96 | "keyword-spacing": "off", 97 | "line-comment-position": "off", 98 | "linebreak-style": "off", 99 | "lines-around-comment": "error", 100 | "lines-between-class-members": [ 101 | "error", 102 | "always" 103 | ], 104 | "max-classes-per-file": "error", 105 | "max-depth": "off", 106 | "max-len": "off", 107 | "max-lines": "off", 108 | "max-lines-per-function": "off", 109 | "max-nested-callbacks": "error", 110 | "max-params": "off", 111 | "max-statements": "off", 112 | "max-statements-per-line": "off", 113 | "multiline-comment-style": [ 114 | "error", 115 | "separate-lines" 116 | ], 117 | "new-cap": "error", 118 | "new-parens": "error", 119 | "newline-per-chained-call": "error", 120 | "no-alert": "error", 121 | "no-array-constructor": "error", 122 | "no-await-in-loop": "off", 123 | "no-bitwise": "error", 124 | "no-caller": "error", 125 | "no-confusing-arrow": "off", 126 | "no-console": "off", 127 | "no-constructor-return": "error", 128 | "no-continue": "off", 129 | "no-div-regex": "error", 130 | "no-duplicate-imports": "error", 131 | "no-else-return": "off", 132 | "no-empty-function": "error", 133 | "no-eq-null": "off", 134 | "no-eval": "error", 135 | "no-extend-native": "error", 136 | "no-extra-bind": "error", 137 | "no-extra-label": "error", 138 | "no-extra-parens": "off", 139 | "no-floating-decimal": "error", 140 | "no-implicit-globals": "error", 141 | "no-implied-eval": "error", 142 | "no-inline-comments": "off", 143 | "no-invalid-this": "error", 144 | "no-iterator": "error", 145 | "no-label-var": "error", 146 | "no-labels": "error", 147 | "no-lone-blocks": "error", 148 | "no-lonely-if": "off", 149 | "no-loop-func": "error", 150 | "no-loss-of-precision": "error", 151 | "no-magic-numbers": "off", 152 | "no-mixed-operators": "error", 153 | "no-multi-assign": "error", 154 | "no-multi-spaces": "off", 155 | "no-multi-str": "error", 156 | "no-multiple-empty-lines": "error", 157 | "no-negated-condition": "off", 158 | "no-nested-ternary": "error", 159 | "no-new": "error", 160 | "no-new-func": "error", 161 | "no-new-object": "error", 162 | "no-new-wrappers": "error", 163 | "no-nonoctal-decimal-escape": "error", 164 | "no-octal-escape": "error", 165 | "no-param-reassign": "error", 166 | "no-plusplus": "off", 167 | "no-promise-executor-return": "error", 168 | "no-proto": "error", 169 | "no-restricted-exports": "error", 170 | "no-restricted-globals": "error", 171 | "no-restricted-imports": "error", 172 | "no-restricted-properties": "error", 173 | "no-restricted-syntax": "error", 174 | "no-return-assign": "off", 175 | "no-return-await": "off", 176 | "no-script-url": "error", 177 | "no-self-compare": "error", 178 | "no-sequences": "error", 179 | "no-shadow": "off", 180 | "no-tabs": "error", 181 | "no-template-curly-in-string": "off", 182 | "no-ternary": "off", 183 | "no-throw-literal": "error", 184 | "no-trailing-spaces": "error", 185 | "no-undef-init": "error", 186 | "no-undefined": "error", 187 | "no-underscore-dangle": "off", 188 | "no-unmodified-loop-condition": "error", 189 | "no-unneeded-ternary": "error", 190 | "no-unreachable-loop": "error", 191 | "no-unsafe-optional-chaining": "error", 192 | "no-unused-expressions": "error", 193 | "no-unused-vars": ["error", { "ignoreRestSiblings": true }], 194 | "no-use-before-define": "error", 195 | "no-useless-backreference": "error", 196 | "no-useless-call": "error", 197 | "no-useless-computed-key": "error", 198 | "no-useless-concat": "error", 199 | "no-useless-constructor": "error", 200 | "no-useless-rename": "error", 201 | "no-useless-return": "error", 202 | "no-var": "error", 203 | "no-void": "error", 204 | "no-warning-comments": "error", 205 | "no-whitespace-before-property": "error", 206 | "nonblock-statement-body-position": "error", 207 | "object-curly-newline": "error", 208 | "object-curly-spacing": "off", 209 | "object-shorthand": "off", 210 | "one-var": "off", 211 | "one-var-declaration-per-line": "off", 212 | "operator-assignment": [ 213 | "error", 214 | "always" 215 | ], 216 | "operator-linebreak": "error", 217 | "padded-blocks": "off", 218 | "padding-line-between-statements": "error", 219 | "prefer-arrow-callback": "error", 220 | "prefer-const": "off", 221 | "prefer-destructuring": "off", 222 | "prefer-exponentiation-operator": "error", 223 | "prefer-named-capture-group": "off", 224 | "prefer-numeric-literals": "error", 225 | "prefer-object-spread": "error", 226 | "prefer-promise-reject-errors": "error", 227 | "prefer-regex-literals": "error", 228 | "prefer-rest-params": "error", 229 | "prefer-spread": "error", 230 | "prefer-template": "off", 231 | "quote-props": "off", 232 | "quotes": "off", 233 | "radix": [ 234 | "error", 235 | "always" 236 | ], 237 | "require-atomic-updates": "off", 238 | "require-await": "off", 239 | "require-unicode-regexp": "off", 240 | "rest-spread-spacing": "error", 241 | "semi": "off", 242 | "semi-spacing": [ 243 | "error", 244 | { 245 | "after": true, 246 | "before": false 247 | } 248 | ], 249 | "semi-style": [ 250 | "error", 251 | "last" 252 | ], 253 | "sort-imports": "error", 254 | "sort-keys": "off", 255 | "sort-vars": "error", 256 | "space-before-blocks": "off", 257 | "space-before-function-paren": "off", 258 | "space-in-parens": [ 259 | "error", 260 | "never" 261 | ], 262 | "space-infix-ops": "off", 263 | "space-unary-ops": "error", 264 | "spaced-comment": [ 265 | "error", 266 | "never" 267 | ], 268 | "switch-colon-spacing": "error", 269 | "symbol-description": "error", 270 | "template-curly-spacing": [ 271 | "error", 272 | "never" 273 | ], 274 | "template-tag-spacing": "error", 275 | "unicode-bom": [ 276 | "error", 277 | "never" 278 | ], 279 | "vars-on-top": "error", 280 | "wrap-iife": "error", 281 | "wrap-regex": "off", 282 | "yield-star-spacing": "error", 283 | "yoda": [ 284 | "error", 285 | "never" 286 | ] 287 | } 288 | }; 289 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | ko_fi: jely2002 3 | github: jely2002 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to improve this app 4 | title: '' 5 | labels: bug 6 | assignees: '' 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. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional info (please complete the following information):** 27 | - OS: [e.g. Windows 10] 28 | - Application version [e.g. 1.8.4] (the version can be found in the settings menu) 29 | - Application type [e.g. portable, installer, Microsoft Store] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for youtube-dl-gui 4 | title: '' 5 | labels: 'feature request' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question about youtube-dl-gui 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your question related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the question you have.** 14 | A clear and concise question about youtube-dl-gui. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the question here. 18 | -------------------------------------------------------------------------------- /.github/workflows/build-release.yaml: -------------------------------------------------------------------------------- 1 | name: Build artifacts to link to release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build-linux: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '12' 16 | - name: Install npm modules 17 | run: npm ci 18 | - name: Make envfile 19 | run: echo ${{ secrets.SENTRY_DSN }} | base64 -d > .env 20 | - name: Build and publish artifact 21 | run: npm_config_yes=true npx electron-builder -p always 22 | env: 23 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | - name: Get version 25 | id: package-version 26 | uses: martinbeentjes/npm-get-version-action@master 27 | - name: Create Sentry release 28 | uses: getsentry/action-release@v1 29 | env: 30 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 31 | SENTRY_ORG: ${{ secrets.SENTRY_ORG }} 32 | SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} 33 | with: 34 | environment: production 35 | version: ${{ steps.package-version.outputs.current-version }} 36 | build-windows: 37 | runs-on: windows-latest 38 | steps: 39 | - uses: actions/checkout@v2 40 | - uses: actions/setup-node@v2 41 | with: 42 | node-version: '12' 43 | - name: Install npm modules 44 | run: npm ci 45 | - name: Make envfile 46 | run: | 47 | echo ${{ secrets.SENTRY_DSN }} > env.b64 48 | certutil -decode env.b64 .env 49 | del env.b64 50 | - name: Build and publish artifact 51 | run: env npm_config_yes=true npx electron-builder -p always 52 | env: 53 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | - name: Build and publish portable artifact 55 | run: env npm_config_yes=true npx electron-builder --win portable -p always 56 | env: 57 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | build-mac: 59 | runs-on: macos-11 60 | steps: 61 | - uses: actions/checkout@v2 62 | - uses: actions/setup-node@v2 63 | with: 64 | node-version: '12' 65 | - name: Install npm modules 66 | run: npm ci 67 | - name: Make envfile 68 | run: echo ${{ secrets.SENTRY_DSN }} | base64 -d > .env 69 | - name: Build and publish artifact 70 | run: npm_config_yes=true npx electron-builder -p always 71 | env: 72 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | -------------------------------------------------------------------------------- /.github/workflows/continuous-integration.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | paths-ignore: 5 | - '**/.github/ISSUE_TEMPLATE/**' 6 | - '**/.github/FUNDING.yml' 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v2 13 | with: 14 | node-version: '12' 15 | - name: Install npm modules 16 | run: npm install 17 | - name: Run ESLint 18 | run: npm run lint 19 | unit-test: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: actions/setup-node@v2 24 | with: 25 | node-version: '12' 26 | - name: Install npm modules 27 | run: npm install 28 | - name: Run Jest 29 | run: npm run test 30 | - name: Upload coverage to codecov 31 | uses: codecov/codecov-action@v1 32 | -------------------------------------------------------------------------------- /.github/workflows/update-winget.yaml: -------------------------------------------------------------------------------- 1 | name: Update winget package 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version number' 8 | required: true 9 | 10 | jobs: 11 | wingetcreate-update: 12 | runs-on: windows-latest 13 | steps: 14 | - uses: actions/setup-dotnet@v1 15 | with: 16 | dotnet-version: '5.0.x' 17 | - name: Run wingetcreate 18 | env: 19 | APP_VERSION: ${{ github.event.inputs.version }} 20 | WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} 21 | shell: cmd 22 | run: | 23 | curl https://aka.ms/wingetcreate/latest -L --output wingetcreate.exe 24 | wingetcreate.exe update jely2002.youtube-dl-gui -s true -u https://github.com/jely2002/youtube-dl-gui/releases/download/v%APP_VERSION%/Open-Video-Downloader-Setup-%APP_VERSION%.exe -v %APP_VERSION% -t %WINGET_TOKEN% 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | logo 2 | 3 | # Open Video Downloader (youtube-dl-gui)
![version badge](https://img.shields.io/github/v/release/jely2002/youtube-dl-gui?label=latest-release) ![GitHub](https://img.shields.io/github/license/jely2002/youtube-dl-gui) ![coverage badge](https://img.shields.io/codecov/c/github/jely2002/youtube-dl-gui) ![downloads](https://img.shields.io/github/downloads/jely2002/youtube-dl-gui/total) ![CI badge](https://img.shields.io/github/workflow/status/jely2002/youtube-dl-gui/CI?label=CI) 4 | [https://jely2002.github.io/youtube-dl-gui](https://jely2002.github.io/youtube-dl-gui) 5 | 6 | A cross-platform GUI for youtube-dl made in Electron and node.js 7 | 8 | 9 | ### Features: 10 | - Download from all kind of platforms: YouTube, vimeo, twitter & many more 11 | - Download multiple videos/playlists/channels in one go 12 | - Select the resolution and format you want to download in 13 | - Download private videos (currently only tested on YouTube) 14 | - Multithreaded, up to 32 videos can be downloaded synchronously 15 | - Shows how much size the download will use up on your system 16 | - The app automatically keeps ytdl up-to-date 17 | 18 | Be sure to check out [a demo gif of the application](#Demo-gif)! 19 | 20 | ## How to use 21 | 1. Download the [applicable installer or executable](https://github.com/jely2002/youtube-dl-gui/releases/latest) for your system. 22 | 2. If you are on windows, make sure that the [Microsoft Visual C++ 2010 Redistributable Package (x86)](https://download.microsoft.com/download/1/6/5/165255E7-1014-4D0A-B094-B6A430A6BFFC/vcredist_x86.exe) is installed. 23 | 3. Paste a link into the box up top. 24 | 4. Wait for the app to gather all required metadata. 25 | 5. Press download, and the video(s) will get downloaded to your downloads folder. 26 | 27 | Want to know more about the features this app offers? Head over to the [wiki](https://github.com/jely2002/youtube-dl-gui/wiki/). 28 | 29 | ## Something is not working! 30 | Please see if the answer is in the [frequently answered questions](https://github.com/jely2002/youtube-dl-gui/wiki/FAQ) or in the [wiki](https://github.com/jely2002/youtube-dl-gui/wiki/). 31 | 32 | Still haven't found your answer? [Open up an issue](https://github.com/jely2002/youtube-dl-gui/issues), and describe the problem you're facing. 33 | 34 | ## Building from source 35 | First, clone the repository using `git clone https://github.com/jely2002/youtube-dl-gui.git`. 36 | 37 | Then navigate to the directory and install the npm modules by executing: `npm install`. 38 | 39 | The last step is to build using electron-builder [(documentation)](https://www.electron.build/cli). For example, the command to build a windows installer is: `npx electron-builder --win`. The output files can be found in the 'dist' folder. 40 | 41 | Please be aware that this app is only tested on windows, linux and macOS. If you decide to build for another platform/archtype it may or may not work. Builds other than those available in the releases come with absolutely no support. 42 | 43 | ## Planned features 44 | - Select individual audio and video codecs (advanced mode) 45 | - List all audio qualities 46 | - Support for downloading livestreams 47 | 48 | Feel free to [request a new feature](https://github.com/jely2002/youtube-dl-gui/issues). 49 | 50 | ## Demo gif 51 | demo 52 | 53 | 54 | ## Liability & License notice 55 | Youtube-dl-gui and its maintainers cannot be held liable for misuse of this application, as stated in the [AGPL-3.0 license (section 16)](https://github.com/jely2002/youtube-dl-gui/blob/master/LICENSE). 56 | The maintainers of youtube-dl-gui do not in any way condone the use of this application in practices that violate local laws such as but not limited to the DMCA. The maintainers of this application call upon the personal responsibility of its users to use this application in a fair way, as it is intended to be used. 57 | -------------------------------------------------------------------------------- /binaries/AtomicParsley.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/binaries/AtomicParsley.exe -------------------------------------------------------------------------------- /build/appimage-fix.js: -------------------------------------------------------------------------------- 1 | const child_process = require('child_process'), 2 | fs = require('fs'), 3 | path = require('path'); 4 | 5 | const appName = "open-video-downloader"; 6 | 7 | function isLinux(targets) { 8 | const re = /AppImage|snap|deb|rpm|freebsd|pacman/i; 9 | return !!targets.find(target => re.test(target.name)); 10 | } 11 | 12 | async function afterPack({targets, appOutDir}) { 13 | if (!isLinux(targets)) return; 14 | const script = '#!/bin/bash\n"${BASH_SOURCE%/*}"/' + appName + '.bin "$@" --no-sandbox', 15 | scriptPath = path.join(appOutDir, appName); 16 | 17 | new Promise((resolve) => { 18 | const child = child_process.exec(`mv ${appName} ${appName}.bin`, {cwd: appOutDir}); 19 | child.on('exit', () => { 20 | resolve(); 21 | }); 22 | }).then(() => { 23 | fs.writeFileSync(scriptPath, script); 24 | child_process.exec(`chmod +x ${appName}`, {cwd: appOutDir}); 25 | }); 26 | } 27 | 28 | module.exports = afterPack; 29 | -------------------------------------------------------------------------------- /build/appx/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/build/appx/Square150x150Logo.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/build/appx/Square44x44Logo.png -------------------------------------------------------------------------------- /build/appx/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/build/appx/StoreLogo.png -------------------------------------------------------------------------------- /build/appx/Wide310x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/build/appx/Wide310x150Logo.png -------------------------------------------------------------------------------- /build/elevate.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/build/elevate.exe -------------------------------------------------------------------------------- /build/vcredist.nsh: -------------------------------------------------------------------------------- 1 | !include LogicLib.nsh 2 | 3 | !macro customInstall 4 | ReadRegDword $R1 HKLM "SOFTWARE\WOW6432Node\Microsoft\VisualStudio\10.0\VC\VCRedist\x86" "Installed" 5 | ReadRegDword $R2 HKLM "SOFTWARE\WOW6432Node\Microsoft\VisualStudio\10.0\VC\VCRedist\x64" "Installed" 6 | ${If} $R1 != "1" 7 | ${AndIf} $R2 != "1" 8 | ${If} ${Silent} 9 | File /oname=$PLUGINSDIR\vcredist_x86.exe "${BUILD_RESOURCES_DIR}\vcredist_x86.exe" 10 | File /oname=$PLUGINSDIR\elevate.exe "${BUILD_RESOURCES_DIR}\elevate.exe" 11 | ExecWait '"$PLUGINSDIR\elevate.exe" "$PLUGINSDIR\vcredist_x86.exe" /q /norestart' 12 | ${Else} 13 | MessageBox MB_YESNO "For this program to work, the Visual C++ 2010 redistributable must be installed. Do you want to install it now? " IDYES true IDNO false 14 | true: 15 | File /oname=$PLUGINSDIR\vcredist_x86.exe "${BUILD_RESOURCES_DIR}\vcredist_x86.exe" 16 | File /oname=$PLUGINSDIR\elevate.exe "${BUILD_RESOURCES_DIR}\elevate.exe" 17 | ExecWait '"$PLUGINSDIR\elevate.exe" "$PLUGINSDIR\vcredist_x86.exe" /q /norestart' 18 | false: 19 | ${EndIf} 20 | ${EndIf} 21 | !macroend 22 | -------------------------------------------------------------------------------- /build/vcredist_x86.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/build/vcredist_x86.exe -------------------------------------------------------------------------------- /codecov.yaml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: off 4 | patch: off 5 | -------------------------------------------------------------------------------- /modules/Analytics.js: -------------------------------------------------------------------------------- 1 | const Sentry = require("@sentry/electron"); 2 | const Tracing = require("@sentry/tracing"); 3 | const path = require("path"); 4 | 5 | class Analytics { 6 | constructor(app) { 7 | this.app = app; 8 | } 9 | 10 | initSentry() { 11 | return new Promise(resolve => { 12 | require('dotenv').config({path: this.app.isPackaged ? path.join(this.app.getAppPath(), ".env") : path.resolve(process.cwd(), '.env')}); 13 | if(process.argv[2] === '--dev' && !process.argv.includes("--sentry")) resolve("Sentry disabled in dev mode, pass --sentry to enable."); 14 | Sentry.init({ 15 | dsn: process.env.SENTRY_DSN, 16 | release: "youtube-dl-gui@" + this.app.getVersion(), 17 | sendDefaultPii: true, 18 | environment: process.argv[2] === '--dev' ? "development" : "production", 19 | integrations: [new Tracing.Integrations.BrowserTracing()], 20 | tracesSampleRate: 0.01, 21 | autoSessionTracking: true 22 | }); 23 | resolve("Sentry initialized"); 24 | }); 25 | } 26 | 27 | async sendReport(id) { 28 | //Legacy code, no longer used. 29 | //Await axios.post('http://backend.jelleglebbeek.com/youtubedl/errorreport.php/', querystring.stringify({ id: id, version: this.version, code: err.error.code, description: err.error.description, platform: process.platform, url: err.url, type: err.type, quality: err.quality})); 30 | return id; 31 | } 32 | } 33 | 34 | module.exports = Analytics; 35 | -------------------------------------------------------------------------------- /modules/AppUpdater.js: -------------------------------------------------------------------------------- 1 | const Utils = require("./Utils"); 2 | const { autoUpdater } = require("electron-updater"); 3 | 4 | class AppUpdater { 5 | constructor(env, win) { 6 | this.env = env; 7 | this.win = win; 8 | this.initializeEvents(); 9 | } 10 | 11 | initializeEvents() { 12 | autoUpdater.on('download-progress', (progressObj) => { 13 | const percent = Math.round(progressObj.percent); 14 | const progress = `${Utils.convertBytes(progressObj.transferred)} / ${Utils.convertBytes(progressObj.total)}`; 15 | const speed = Utils.convertBytesPerSecond(progressObj.bytesPerSecond); 16 | this.win.webContents.send('toast', { 17 | type: "update", 18 | title: `Downloading update ${this.newVersion}`, 19 | body: `

${progress} | ${speed}

20 |
21 |
22 |
` 23 | }); 24 | }) 25 | } 26 | 27 | checkUpdate() { 28 | if(!this.isUpdateAllowed()) return; 29 | autoUpdater.checkForUpdates().then((result) => { 30 | if(result.downloadPromise != null) { 31 | if(process.platform === "darwin") { 32 | //Show only toast on mac 33 | this.win.webContents.send('toast', { 34 | type: "update", 35 | title: "An update has been found", 36 | body: `Update ${result.updateInfo.releaseName} is out now!
Download on GitHub` 37 | }); 38 | } else { 39 | this.newVersion = result.updateInfo.releaseName; 40 | result.downloadPromise.then(() => { 41 | this.win.webContents.send('toast', { 42 | type: "update", 43 | title: `Update ${result.updateInfo.releaseName} was downloaded`, 44 | body: `

Do you want to install it now?

` 45 | }); 46 | }) 47 | } 48 | } 49 | }) 50 | } 51 | 52 | installUpdate() { 53 | //IsSilent: false (show progress), forceReOpen: true (reopen the app after updating has finished) 54 | autoUpdater.quitAndInstall(false, true); 55 | } 56 | 57 | isUpdateAllowed() { 58 | return (this.env.settings.updateApplication && process.argv[2] !== '--dev') || (process.argv[2] === "--dev" && process.argv[3] === "--test-update"); 59 | } 60 | 61 | setUpdateSetting(value) { 62 | autoUpdater.autoInstallOnAppQuit = !!value; 63 | if(value === true) { 64 | this.checkUpdate(); 65 | } 66 | } 67 | 68 | } 69 | module.exports = AppUpdater; 70 | -------------------------------------------------------------------------------- /modules/BinaryUpdater.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const fs = require("fs"); 3 | const Sentry = require("@sentry/node"); 4 | const util = require('util'); 5 | const Utils = require('./Utils'); 6 | const exec = util.promisify(require('child_process').exec); 7 | 8 | class BinaryUpdater { 9 | 10 | constructor(paths, win) { 11 | this.paths = paths; 12 | this.win = win; 13 | this.action = "Installing"; 14 | } 15 | 16 | //Checks for an update and download it if there is. 17 | async checkUpdate() { 18 | if (await this.checkPreInstalled()) { 19 | console.log("yt-dlp already installed, skipping auto-install.") 20 | return; 21 | } 22 | const transaction = Sentry.startTransaction({ name: "checkUpdate" }); 23 | const span = transaction.startChild({ op: "task" }); 24 | console.log("Checking for a new version of yt-dlp."); 25 | const localVersion = await this.getLocalVersion(); 26 | const { remoteUrl, remoteVersion } = await this.getRemoteVersion(); 27 | if(remoteVersion === localVersion) { 28 | transaction.setTag("download", "up-to-data"); 29 | console.log(`Binaries were already up-to-date! Version: ${localVersion}`); 30 | } else if(localVersion == null) { 31 | transaction.setTag("download", "corrupted"); 32 | console.log("Downloading missing yt-dlp binary."); 33 | this.win.webContents.send("binaryLock", {lock: true, placeholder: `Installing yt-dlp version: ${remoteVersion}. Preparing...`}) 34 | await this.downloadUpdate(remoteUrl, remoteVersion); 35 | this.paths.setPermissions() 36 | } else if(remoteVersion == null) { 37 | transaction.setTag("download", "down"); 38 | console.log("Unable to check for new updates, GitHub may be down."); 39 | } else { 40 | console.log(`New version ${remoteVersion} found. Updating...`); 41 | transaction.setTag("download", "update"); 42 | this.action = "Updating to"; 43 | this.win.webContents.send("binaryLock", {lock: true, placeholder: `Updating yt-dlp to version: ${remoteVersion}. Preparing...`}) 44 | await this.downloadUpdate(remoteUrl, remoteVersion); 45 | this.paths.setPermissions() 46 | } 47 | span.finish(); 48 | transaction.finish(); 49 | } 50 | 51 | async checkPreInstalled() { 52 | try { 53 | await exec("yt-dlp"); 54 | await exec("ytdlp"); 55 | return true; 56 | } catch (e) { 57 | return false; 58 | } 59 | } 60 | 61 | async getRemoteVersion() { 62 | try { 63 | const url = process.platform === "win32" ? "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" : "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" 64 | await axios.get(url, { 65 | responseType: 'document', 66 | maxRedirects: 0, 67 | }) 68 | } catch (err) { 69 | const res = err.response; 70 | if (err.response == null) { 71 | console.error('An error occurred while retrieving the latest yt-dlp version data.') 72 | return null; 73 | } 74 | if (res.status === 302) { 75 | const versionRegex = res.data.match(/[0-9]+\.[0-9]+\.[0-9]+/); 76 | const urlRegex = res.data.match(/(?<=").+?(?=")/); 77 | return { 78 | remoteVersion: versionRegex ? versionRegex[0] : null, 79 | remoteUrl: urlRegex ? urlRegex[0] : null, 80 | }; 81 | } else { 82 | console.error('Did not get redirect for the latest version link. Status: ' + err.response.status); 83 | return null; 84 | } 85 | } 86 | return null; 87 | } 88 | 89 | //Returns the currently downloaded version of yt-dlp 90 | async getLocalVersion() { 91 | let data; 92 | try { 93 | const result = await fs.promises.readFile(this.paths.ytdlVersion); 94 | data = JSON.parse(result); 95 | if (!data.ytdlp) { 96 | data = null; 97 | } 98 | } catch (err) { 99 | console.error(err); 100 | data = null; 101 | } 102 | try { 103 | await fs.promises.access(this.paths.ytdl); 104 | } catch(e) { 105 | data = null; 106 | } 107 | if(data == null) { 108 | return null; 109 | } else { 110 | console.log("Current yt-dlp version: " + data.version); 111 | return data.version; 112 | } 113 | } 114 | 115 | //Downloads the file at the given url and saves it to the ytdl path. 116 | async downloadUpdate(remoteUrl, remoteVersion) { 117 | const writer = fs.createWriteStream(this.paths.ytdl); 118 | const { data, headers } = await axios.get(remoteUrl, {responseType: 'stream'}); 119 | const totalLength = +headers['content-length']; 120 | const total = Utils.convertBytes(totalLength); 121 | let received = 0; 122 | return await new Promise((resolve, reject) => { 123 | let error = null; 124 | data.on('data', (chunk) => { 125 | received += chunk.length; 126 | const percentage = ((received / totalLength) * 100).toFixed(0) + '%'; 127 | this.win.webContents.send("binaryLock", {lock: true, placeholder: `${this.action} yt-dlp ${remoteVersion} - ${percentage} of ${total}`}) 128 | }); 129 | writer.on('error', err => { 130 | error = err; 131 | reject(err); 132 | }); 133 | writer.on('close', async () => { 134 | if (!error) { 135 | await this.writeVersionInfo(remoteVersion); 136 | resolve(true); 137 | } 138 | }); 139 | data.pipe(writer); 140 | }); 141 | } 142 | 143 | //Writes the new version number to the ytdlVersion file 144 | async writeVersionInfo(version) { 145 | const data = { 146 | version: version, 147 | ytdlp: true 148 | }; 149 | await fs.promises.writeFile(this.paths.ytdlVersion, JSON.stringify(data)); 150 | console.log("New version data written to ytdlVersion."); 151 | } 152 | } 153 | 154 | module.exports = BinaryUpdater; 155 | -------------------------------------------------------------------------------- /modules/ClipboardWatcher.js: -------------------------------------------------------------------------------- 1 | const { clipboard } = require('electron'); 2 | 3 | class ClipboardWatcher { 4 | constructor(win, env) { 5 | this.win = win; 6 | this.env = env; 7 | this.urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)?/gi; 8 | } 9 | 10 | startPolling() { 11 | this.poll(); 12 | this.pollId = setInterval(() => this.poll(), 1000); 13 | } 14 | 15 | resetPlaceholder() { 16 | const standard = "Enter a video/playlist URL to add to the queue"; 17 | if (this.win != null) { 18 | this.win.webContents.send("updateLinkPlaceholder", {text: standard, copied: false}); 19 | } 20 | } 21 | 22 | poll() { 23 | if(this.env.settings.autoFillClipboard) { 24 | const text = clipboard.readText(); 25 | if (text != null) { 26 | if (this.previous != null && this.previous === text) return; 27 | this.previous = text; 28 | const isURL = text.match(this.urlRegex); 29 | if (isURL) { 30 | if (this.win != null) { 31 | this.win.webContents.send("updateLinkPlaceholder", {text: text, copied: true}); 32 | } 33 | } else { 34 | this.resetPlaceholder(); 35 | } 36 | } else { 37 | this.resetPlaceholder(); 38 | } 39 | } 40 | } 41 | } 42 | 43 | module.exports = ClipboardWatcher; 44 | -------------------------------------------------------------------------------- /modules/DetectPython.js: -------------------------------------------------------------------------------- 1 | const execa = require("execa"); 2 | 3 | class DetectPython { 4 | async test(command) { 5 | try { 6 | await execa(command, ['--version']); 7 | return true; 8 | } catch(e) { 9 | return false; 10 | } 11 | } 12 | 13 | async detect() { 14 | if(await this.test("python") === true) return "python"; 15 | else if(await this.test("python3") === true) return "python3"; 16 | else if(await this.test("python2") === true) return "python2"; 17 | else return "python"; 18 | } 19 | } 20 | 21 | module.exports = DetectPython; 22 | -------------------------------------------------------------------------------- /modules/DoneAction.js: -------------------------------------------------------------------------------- 1 | const execa = require("execa"); 2 | 3 | const actions = { 4 | "win32": { 5 | "Sleep": ["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"], 6 | "Lock": ["rundll32.exe", "user32.dll,LockWorkStation"], 7 | "Shutdown": ["shutdown", "/s", "/f", "/t", "0"], 8 | "Reboot": ["shutdown", "/r", "/f", "/t", "0"], 9 | }, 10 | "linux": { 11 | "Sleep": ["systemctl", "suspend"], 12 | "Shutdown": ["shutdown", "-h", "now"], 13 | "Reboot": ["shutdown", "-r", "now"] 14 | }, 15 | "darwin": { 16 | "Sleep": ["pmset", "sleepnow"], 17 | "Shutdown": ["shutdown", "-h", "now"], 18 | "Reboot": ["shutdown", "-r", "now"] 19 | } 20 | } 21 | 22 | class DoneAction { 23 | constructor() { 24 | this.platform = process.platform; 25 | } 26 | 27 | getActions() { 28 | return Object.keys(actions[this.platform]); 29 | } 30 | 31 | async executeAction(action) { 32 | if(action === "Close app") { 33 | process.exit(1); 34 | return; 35 | } else if(action === "Do nothing") { 36 | return; 37 | } 38 | const command = actions[this.platform][action][0]; 39 | const args = actions[this.platform][action].slice(1); 40 | try { 41 | await execa(command, args); 42 | } catch(e) { 43 | console.error(e) 44 | } 45 | } 46 | 47 | } 48 | 49 | module.exports = DoneAction; 50 | -------------------------------------------------------------------------------- /modules/Environment.js: -------------------------------------------------------------------------------- 1 | const Bottleneck = require("bottleneck"); 2 | const Filepaths = require("./Filepaths"); 3 | const Settings = require("./persistence/Settings"); 4 | const DetectPython = require("./DetectPython"); 5 | const Logger = require("./persistence/Logger"); 6 | const fs = require("fs").promises; 7 | 8 | class Environment { 9 | constructor(app, analytics) { 10 | this.app = app; 11 | this.analytics = analytics; 12 | this.version = app.getVersion(); 13 | this.cookiePath = null; 14 | this.mainAudioOnly = false; 15 | this.mainVideoOnly = false; 16 | this.mainAudioQuality = "best"; 17 | this.mainDownloadSubs = false; 18 | this.doneAction = "Do nothing"; 19 | this.logger = new Logger(this); 20 | this.paths = new Filepaths(app, this); 21 | this.downloadLimiter = new Bottleneck({ 22 | trackDoneStatus: true, 23 | maxConcurrent: 4, 24 | minTime: 0 25 | }) 26 | this.metadataLimiter = new Bottleneck({ 27 | trackDoneStatus: true, 28 | maxConcurrent: 4, 29 | minTime: 0 30 | }) 31 | } 32 | 33 | //Read the settings and start required services 34 | async initialize() { 35 | await this.paths.generateFilepaths(); 36 | this.settings = await Settings.loadFromFile(this.paths, this); 37 | this.changeMaxConcurrent(this.settings.maxConcurrent); 38 | if(this.settings.cookiePath != null) { //If the file does not exist anymore, null the value and save. 39 | fs.access(this.settings.cookiePath).catch(() => { 40 | this.settings.cookiePath = null; 41 | this.settings.save(); 42 | }) 43 | } 44 | if(process.platform === "linux") { 45 | const pythonDetect = new DetectPython(); 46 | this.pythonCommand = await pythonDetect.detect(); 47 | } else { 48 | this.pythonCommand = "python"; 49 | } 50 | await this.paths.validateDownloadPath(); 51 | } 52 | 53 | changeMaxConcurrent(max) { 54 | const settings = { 55 | trackDoneStatus: true, 56 | maxConcurrent: max, 57 | minTime: 0 58 | } 59 | this.downloadLimiter.updateSettings(settings); 60 | this.metadataLimiter.updateSettings(settings); 61 | } 62 | } 63 | module.exports = Environment; 64 | -------------------------------------------------------------------------------- /modules/FfmpegUpdater.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const fs = require("fs"); 3 | const Sentry = require("@sentry/node"); 4 | const path = require('path'); 5 | const util = require('util'); 6 | const exec = util.promisify(require('child_process').exec); 7 | const os = require("os"); 8 | const AdmZip = require("adm-zip"); 9 | const Utils = require('./Utils'); 10 | 11 | class FfmpegUpdater { 12 | 13 | constructor(paths, win) { 14 | this.paths = paths; 15 | this.win = win; 16 | this.action = "Installing"; 17 | } 18 | 19 | //Checks for an update and download it if there is. 20 | async checkUpdate() { 21 | if (await this.checkPreInstalled()) { 22 | console.log("FFmpeg and FFprobe already installed, skipping auto-install.") 23 | return; 24 | } 25 | const transaction = Sentry.startTransaction({ name: "checkUpdate" }); 26 | const span = transaction.startChild({ op: "task" }); 27 | console.log("Checking for a new version of ffmpeg."); 28 | const localVersion = await this.getLocalVersion(); 29 | const { remoteFfmpegUrl, remoteFfprobeUrl, remoteVersion } = await this.getRemoteVersion(); 30 | if(remoteVersion === localVersion) { 31 | transaction.setTag("download", "up-to-date"); 32 | console.log(`ffmpeg was already up-to-date! Version: ${localVersion}`); 33 | } else if(localVersion == null) { 34 | transaction.setTag("download", "corrupted"); 35 | console.log("Downloading missing ffmpeg binary."); 36 | this.win.webContents.send("binaryLock", {lock: true, placeholder: `Installing ffmpeg version: ${remoteVersion}. Preparing...`}) 37 | await this.downloadUpdate(remoteFfmpegUrl, remoteVersion, "ffmpeg" + this.getFileExtension()); 38 | this.win.webContents.send("binaryLock", {lock: true, placeholder: `Installing ffprobe version: ${remoteVersion}. Preparing...`}) 39 | await this.downloadUpdate(remoteFfprobeUrl, remoteVersion, "ffprobe" + this.getFileExtension()); 40 | await this.writeVersionInfo(remoteVersion); 41 | } else if(remoteVersion == null) { 42 | transaction.setTag("download", "down"); 43 | console.log("Unable to check for new updates, ffbinaries.com may be down."); 44 | } else { 45 | console.log(`New version ${remoteVersion} found. Updating...`); 46 | transaction.setTag("download", "update"); 47 | this.action = "Updating to"; 48 | this.win.webContents.send("binaryLock", {lock: true, placeholder: `Updating ffmpeg to version: ${remoteVersion}. Preparing...`}) 49 | await this.downloadUpdate(remoteFfmpegUrl, remoteVersion, "ffmpeg" + this.getFileExtension()); 50 | this.win.webContents.send("binaryLock", {lock: true, placeholder: `Updating ffprobe to version: ${remoteVersion}. Preparing...`}) 51 | await this.downloadUpdate(remoteFfprobeUrl, remoteVersion, "ffprobe" + this.getFileExtension()); 52 | await this.writeVersionInfo(remoteVersion); 53 | } 54 | span.finish(); 55 | transaction.finish(); 56 | } 57 | 58 | async checkPreInstalled() { 59 | try { 60 | await exec("ffmpeg"); 61 | await exec("ffprobe"); 62 | return true; 63 | } catch (e) { 64 | return false; 65 | } 66 | } 67 | 68 | async getRemoteVersion() { 69 | try { 70 | const res = await axios.get("https://ffbinaries.com/api/v1/version/latest"); 71 | let platform = "windows-64"; 72 | if (os.arch() === "x32" || os.arch() === "ia32") platform = "windows-32"; 73 | if (process.platform === "darwin") platform = "osx-64"; 74 | else if (process.platform === "linux") platform = "linux-32"; 75 | return { 76 | remoteVersion: res.data.version, 77 | remoteFfmpegUrl: res.data.bin[platform].ffmpeg, 78 | remoteFfprobeUrl: res.data.bin[platform].ffprobe, 79 | } 80 | } catch (err) { 81 | console.error('An error occurred while retrieving the latest ffmpeg version data.') 82 | if (err.response != null) { 83 | console.error('Status code: ' + err.response.status); 84 | } 85 | return null; 86 | } 87 | } 88 | 89 | //Returns the currently downloaded version of yt-dlp 90 | async getLocalVersion() { 91 | let data; 92 | try { 93 | const result = await fs.promises.readFile(this.paths.ffmpegVersion); 94 | data = JSON.parse(result); 95 | } catch (err) { 96 | console.error(err); 97 | data = null; 98 | } 99 | try { 100 | await fs.promises.access(path.join(this.paths.ffmpeg, "ffmpeg" + this.getFileExtension())); 101 | await fs.promises.access(path.join(this.paths.ffmpeg, "ffprobe" + this.getFileExtension())); 102 | } catch(e) { 103 | data = null; 104 | } 105 | if(data == null) { 106 | return null; 107 | } else { 108 | console.log("Current ffmpeg version: " + data.version); 109 | return data.version; 110 | } 111 | } 112 | 113 | //Downloads the file at the given url and saves it to the ffmpeg path. 114 | async downloadUpdate(url, version, filename) { 115 | const downloadPath = path.join(this.paths.ffmpeg, "downloads"); 116 | if (!fs.existsSync(downloadPath)) { 117 | fs.mkdirSync(downloadPath); 118 | } 119 | const writer = fs.createWriteStream(path.join(downloadPath, filename)); 120 | 121 | const { data, headers } = await axios.get(url, {responseType: 'stream'}); 122 | const totalLength = +headers['content-length']; 123 | const total = Utils.convertBytes(totalLength); 124 | const artifact = filename.replace(".exe", ""); 125 | let received = 0; 126 | await new Promise((resolve, reject) => { 127 | let error = null; 128 | data.on('data', (chunk) => { 129 | received += chunk.length; 130 | const percentage = ((received / totalLength) * 100).toFixed(0) + '%'; 131 | this.win.webContents.send("binaryLock", {lock: true, placeholder: `${this.action} ${artifact} ${version} - ${percentage} of ${total}`}) 132 | }); 133 | writer.on('error', err => { 134 | error = err; 135 | reject(err); 136 | }); 137 | writer.on('close', async () => { 138 | if (!error) { 139 | resolve(true); 140 | } 141 | }); 142 | data.pipe(writer); 143 | }); 144 | this.win.webContents.send("binaryLock", {lock: true, placeholder: `${this.action} ${artifact} ${version} - Extracting binaries...`}) 145 | const zipFile = new AdmZip(path.join(downloadPath, filename), {}); 146 | zipFile.extractEntryTo(filename, this.paths.ffmpeg, false, true, false, filename); 147 | fs.rmdirSync(path.join(this.paths.ffmpeg, "downloads"), { recursive: true, force: true }); 148 | } 149 | 150 | //Writes the new version number to the ytdlVersion file 151 | async writeVersionInfo(version) { 152 | const data = { 153 | version: version, 154 | }; 155 | await fs.promises.writeFile(this.paths.ffmpegVersion, JSON.stringify(data)); 156 | console.log("New version data written to ffmpegVersion."); 157 | } 158 | 159 | getFileExtension() { 160 | if (process.platform === "win32") return ".exe"; 161 | else return ""; 162 | } 163 | } 164 | 165 | module.exports = FfmpegUpdater; 166 | -------------------------------------------------------------------------------- /modules/Filepaths.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const mkdirp = require("mkdirp"); 3 | const fs = require("fs"); 4 | 5 | class Filepaths { 6 | constructor(app, env) { 7 | this.app = app; 8 | this.env = env; 9 | this.appPath = this.app.getAppPath(); 10 | this.platform = this.detectPlatform(); 11 | } 12 | 13 | async generateFilepaths() { 14 | switch (this.platform) { 15 | case "win32": 16 | this.unpackedPrefix = path.join(path.dirname(this.appPath), "app.asar.unpacked"); 17 | this.packedPrefix = this.appPath; 18 | this.ffmpeg = this.app.isPackaged ? path.join(this.unpackedPrefix, "binaries") : "binaries"; 19 | this.ytdl = this.app.isPackaged ? path.join(this.unpackedPrefix, "binaries/yt-dlp.exe") : "binaries/yt-dlp.exe"; 20 | this.icon = this.app.isPackaged ? path.join(this.packedPrefix, "renderer/img/icon.png") : "renderer/img/icon.png"; 21 | this.settings = this.app.isPackaged ? path.join(this.unpackedPrefix, "userSettings") : "userSettings"; 22 | this.taskList = this.app.isPackaged ? path.join(this.unpackedPrefix, "taskList") : "taskList"; 23 | this.ytdlVersion = this.app.isPackaged ? path.join(this.unpackedPrefix, "binaries/ytdlVersion") :"binaries/ytdlVersion"; 24 | this.ffmpegVersion = this.app.isPackaged ? path.join(this.unpackedPrefix, "binaries/ffmpegVersion") :"binaries/ffmpegVersion"; 25 | break; 26 | case "win32app": { 27 | const appDir = path.basename(path.join(this.appPath, "../../..")).replace(/_(.*)_/g, "_"); 28 | this.binaryPath = path.join(this.app.getPath('home'), "AppData/Local/Packages/" + appDir + "/LocalCache/Roaming/open-video-downloader-app"); 29 | this.persistentPath = path.join(this.app.getPath("appData"), "open-video-downloader-app"); 30 | this.unpackedPrefix = path.join(path.dirname(this.appPath), "app.asar.unpacked"); 31 | this.packedPrefix = this.appPath; 32 | await this.createFolder(this.persistentPath); 33 | this.ffmpeg = this.binaryPath; 34 | this.ytdl = path.join(this.binaryPath, "yt-dlp.exe"); 35 | this.icon = path.join(this.packedPrefix, "renderer/img/icon.png"); 36 | this.settings = path.join(this.binaryPath, "userSettings"); 37 | this.taskList = path.join(this.binaryPath, "taskList"); 38 | this.ytdlVersion = path.join(this.binaryPath, "ytdlVersion"); 39 | this.ffmpegVersion = path.join(this.binaryPath, "ffmpegVersion"); 40 | break; 41 | } 42 | case "win32portable": 43 | this.persistentPath = path.join(process.env.PORTABLE_EXECUTABLE_DIR , "open-video-downloader"); 44 | this.unpackedPrefix = path.join(path.dirname(this.appPath), "app.asar.unpacked"); 45 | this.packedPrefix = this.appPath; 46 | await this.createPortableFolder(); 47 | this.ffmpeg = this.persistentPath; 48 | this.ytdl = path.join(this.persistentPath, "yt-dlp.exe"); 49 | this.icon = path.join(this.packedPrefix, "renderer/img/icon.png"); 50 | this.settings = path.join(this.persistentPath, "userSettings"); 51 | this.taskList = path.join(this.persistentPath, "taskList"); 52 | this.ytdlVersion = path.join(this.persistentPath, "ytdlVersion"); 53 | this.ffmpegVersion = path.join(this.persistentPath, "ffmpegVersion"); 54 | break; 55 | case "darwin": 56 | this.packedPrefix = this.appPath; 57 | this.unpackedPrefix = this.appPath + ".unpacked"; 58 | this.ffmpeg = this.app.isPackaged ? path.join(this.unpackedPrefix, "binaries") : "binaries"; 59 | this.ytdl = this.app.isPackaged ? path.join(this.unpackedPrefix, "binaries/yt-dlp-unix") : "binaries/yt-dlp-unix"; 60 | this.icon = this.app.isPackaged ? path.join(this.packedPrefix, "renderer/img/icon.png") : "renderer/img/icon.png"; 61 | this.settings = this.app.isPackaged ? path.join(this.unpackedPrefix, "userSettings") : "userSettings"; 62 | this.taskList = this.app.isPackaged ? path.join(this.unpackedPrefix, "taskList") : "taskList"; 63 | this.ytdlVersion = this.app.isPackaged ? path.join(this.unpackedPrefix, "binaries/ytdlVersion") :"binaries/ytdlVersion"; 64 | this.ffmpegVersion = this.app.isPackaged ? path.join(this.unpackedPrefix, "binaries/ffmpegVersion") :"binaries/ffmpegVersion"; 65 | this.setPermissions() 66 | break; 67 | case "linux": 68 | this.persistentPath = path.join(this.app.getPath('home'), ".youtube-dl-gui"); 69 | this.packedPrefix = this.appPath; 70 | this.unpackedPrefix = this.appPath + ".unpacked"; 71 | if(this.app.isPackaged) await this.createFolder(this.persistentPath); 72 | this.ytdl = this.app.isPackaged ? path.join(this.persistentPath, "yt-dlp-unix") : "binaries/yt-dlp-unix"; 73 | this.ffmpeg = this.app.isPackaged ? this.persistentPath : "binaries"; 74 | this.icon = this.app.isPackaged ? path.join(this.packedPrefix, "renderer/img/icon.png") : "renderer/img/icon.png"; 75 | this.settings = this.app.isPackaged ? path.join(this.persistentPath, "userSettings") : "userSettings"; 76 | this.taskList = this.app.isPackaged ? path.join(this.persistentPath, "taskList") : "taskList"; 77 | this.ytdlVersion = this.app.isPackaged ? path.join(this.persistentPath, "ytdlVersion") :"binaries/ytdlVersion"; 78 | this.ffmpegVersion = this.app.isPackaged ? path.join(this.persistentPath, "ffmpegVersion") :"binaries/ffmpegVersion"; 79 | this.setPermissions() 80 | break; 81 | } 82 | await this.removeLeftOver(); 83 | } 84 | 85 | async validateDownloadPath() { 86 | const setPath = this.env.settings.downloadPath; 87 | try { 88 | await fs.promises.access(setPath); 89 | this.env.settings.downloadPath = setPath; 90 | } catch (e) { 91 | console.warn("The configured download path could not be found, switching to downloads folder."); 92 | this.setDefaultDownloadPath(); 93 | } 94 | } 95 | 96 | setDefaultDownloadPath() { 97 | try { 98 | this.env.settings.downloadPath = this.app.getPath('downloads'); 99 | } catch(e) { 100 | console.warn("Using home path as download location, as downloads was not found."); 101 | this.env.settings.downloadPath = this.app.getPath('home'); 102 | } 103 | } 104 | 105 | detectPlatform() { 106 | if(process.env.PORTABLE_EXECUTABLE_DIR != null) return "win32portable"; 107 | else if(this.appPath.includes("WindowsApps")) return "win32app" 108 | else return process.platform; 109 | } 110 | 111 | async removeLeftOver() { 112 | const filename = process.platform === "win32" ? "youtube-dl.exe" : "youtube-dl-unix"; 113 | if (fs.existsSync(path.join(this.ffmpeg, filename))) { 114 | await fs.promises.unlink(path.join(this.ffmpeg, filename)); 115 | } 116 | } 117 | 118 | setPermissions() { 119 | fs.readdirSync(this.ffmpeg).forEach(file => { 120 | if (file === "userSettings" || file === "ytdlVersion" || file === "taskList" || file === "ffmpegVersion") return; 121 | fs.chmod(path.join(this.ffmpeg, file), 0o755, (err) => { 122 | if(err) console.error(err); 123 | }); 124 | }); 125 | } 126 | 127 | async createPortableFolder() { 128 | try { 129 | await fs.promises.access(process.env.PORTABLE_EXECUTABLE_DIR, fs.constants.W_OK); 130 | if(await this.migrateExistingAppDataFolder()) return; 131 | const from = path.join(this.unpackedPrefix, "binaries"); 132 | const toCopy = ["AtomicParsley.exe"]; 133 | await this.copyFiles(from, this.persistentPath, toCopy); 134 | } catch (e) { 135 | setTimeout(() => console.error(e), 5000); 136 | this.persistentPath = path.join(this.app.getPath("appData"), "open-video-downloader"); 137 | await this.createFolder(this.persistentPath); 138 | } 139 | } 140 | 141 | async migrateExistingAppDataFolder() { 142 | const from = path.join(this.app.getPath("appData"), "youtube-dl-gui-portable"); 143 | try { 144 | await fs.promises.access(from, fs.constants.W_OK); 145 | const toCopy = ["yt-dlp.exe", "ffmpeg.exe", "ytdlVersion", "ffmpegVersion", "AtomicParsley.exe", "userSettings", "taskList"]; 146 | await this.copyFiles(from, this.persistentPath, toCopy); 147 | try { 148 | await fs.promises.rmdir(from, {recursive: true}); 149 | } catch (e) { 150 | console.error(e); 151 | } 152 | return true; 153 | } catch (e) { 154 | return false; 155 | } 156 | } 157 | 158 | async createFolder(path) { 159 | await new Promise((resolve) => { 160 | mkdirp(path).then(() => { 161 | resolve(); 162 | }); 163 | }); 164 | } 165 | 166 | async copyFiles(from, to, files) { 167 | await new Promise((resolve) => { 168 | mkdirp(to).then(made => { 169 | if (made != null) { 170 | for (const file of files) { 171 | this.copyFile(from, to, file); 172 | } 173 | } 174 | resolve(); 175 | }); 176 | }); 177 | } 178 | 179 | copyFile(from, to, filename) { 180 | const fromFile = path.join(from, filename); 181 | const toFile = path.join(to, filename); 182 | try { 183 | fs.copyFileSync(fromFile, toFile); 184 | } catch (e) { 185 | console.error("Could not copy " + filename + " to " + to + "."); 186 | } 187 | } 188 | } 189 | 190 | module.exports = Filepaths; 191 | -------------------------------------------------------------------------------- /modules/Utils.js: -------------------------------------------------------------------------------- 1 | const Format = require("./types/Format"); 2 | const crypto = require('crypto'); 3 | const ISO6392 = require('iso-639-2'); 4 | const channelRegex = /(?:https|http):\/\/(?:[\w]+\.)?youtube\.com\/(?:c\/|channel\/|user\/)([a-zA-Z0-9-]{1,})/; 5 | 6 | class Utils { 7 | 8 | static isYouTubeChannel(url) { 9 | return channelRegex.test(url); 10 | } 11 | 12 | static convertBytes(bytes) { 13 | const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 14 | let l = 0, n = parseInt(bytes, 10); 15 | while(n >= 1024 && ++l){ 16 | n /= 1024; 17 | } 18 | return(n.toFixed(n < 10 && l > 0 ? 1 : 0) + ' ' + units[l]); 19 | } 20 | 21 | static convertBytesPerSecond(bytes) { 22 | const units = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s']; 23 | let l = 0, n = parseInt(bytes, 10); 24 | while(n >= 1024 && ++l) { 25 | n /= 1024; 26 | } 27 | return(n.toFixed(n < 10 && l > 0 ? 1 : 0) + ' ' + units[l]); 28 | } 29 | 30 | static numberFormatter(number, digits) { 31 | const def = [ 32 | { value: 1, symbol: "" }, 33 | { value: 1E3, symbol: "K" }, 34 | { value: 1E6, symbol: "M" }, 35 | { value: 1E9, symbol: "B" }, 36 | { value: 1E12, symbol: "T" }, 37 | { value: 1E15, symbol: "Q" }, 38 | { value: 1E18, symbol: "Z" } 39 | ]; 40 | let rx = /\.0+$|(\.[0-9]*[1-9])0+$/; 41 | let i; 42 | for (i = def.length - 1; i > 0; i--) { 43 | if (number >= def[i].value) { 44 | break; 45 | } 46 | } 47 | return (number / def[i].value).toFixed(digits).replace(rx, "$1") + def[i].symbol; 48 | } 49 | 50 | static getRandomID(length) { 51 | return crypto.randomBytes(length / 2).toString("hex"); 52 | } 53 | 54 | static extractPlaylistUrls(infoQueryResult) { 55 | let urls = []; 56 | let alreadyDone = []; 57 | if(infoQueryResult.entries == null || infoQueryResult.entries.length === 0) { 58 | console.error("Cannot extract URLS, no entries in data.") 59 | return [urls, alreadyDone]; 60 | } 61 | for(const entry of infoQueryResult.entries) { 62 | let url; 63 | if (entry.url == null) url = entry.webpage_url; 64 | else url = entry.url; 65 | if(entry.formats != null && entry.formats.length > 0) { 66 | entry.url = url; 67 | alreadyDone.push(entry); 68 | continue; 69 | } 70 | urls.push(url); 71 | } 72 | return [urls, alreadyDone] 73 | } 74 | 75 | static getNameFromISO(sub) { 76 | if(sub === "iw") return "Hebrew"; 77 | if(sub === "zh-Hans") return "Chinese (Simplified)"; 78 | if(sub === "zh-Hant") return "Chinese (Traditional)"; 79 | const iso6391 = ISO6392.find(lang => { 80 | return lang.iso6391 === sub 81 | }) 82 | if(iso6391 == null) { 83 | const iso6392 = ISO6392.find(lang => { 84 | return lang.iso6392B === sub; 85 | }); 86 | if(iso6392 == null) return sub; 87 | return iso6392.name.split(";")[0].split(",")[0]; 88 | } else { 89 | return iso6391.name.split(";")[0].split(",")[0]; 90 | } 91 | } 92 | 93 | static sortSubtitles(a, b) { 94 | if (a.name < b.name){ 95 | return -1; 96 | } 97 | if (a.name > b.name){ 98 | return 1; 99 | } 100 | return 0; 101 | } 102 | 103 | static dedupeSubtitles(subs) { 104 | const keys = ['name']; 105 | return subs.filter((s => o => (k => !s.has(k) && s.add(k))(keys.map(k => o[k]).join('|')))(new Set())); 106 | } 107 | 108 | static detectInfoType(infoQueryResult) { 109 | if(infoQueryResult == null) return infoQueryResult; 110 | if(Object.keys(infoQueryResult).length === 0) return infoQueryResult; 111 | if(infoQueryResult.is_live != null && infoQueryResult.is_live === true) return "livestream"; 112 | if(infoQueryResult._type != null && infoQueryResult._type === "playlist") return "playlist"; 113 | if(infoQueryResult.entries != null && infoQueryResult.entries.length > 0) return "playlist"; 114 | return "single"; 115 | } 116 | 117 | static hasFilesizes(metadata) { 118 | let filesizeDetected = false 119 | if(metadata.formats == null) { 120 | console.error("No formats could be found."); 121 | return false; 122 | } 123 | for(const format of metadata.formats) { 124 | if(format.filesize != null) { 125 | filesizeDetected = true; 126 | break; 127 | } 128 | } 129 | return filesizeDetected 130 | } 131 | 132 | static parseAvailableAudioCodecs(metadata) { 133 | let codecs = []; 134 | if(metadata.formats == null) { 135 | console.error("No audio codecs could be found.") 136 | return codecs; 137 | } 138 | for(let dataFormat of metadata.formats) { 139 | if(dataFormat.height != null) continue; 140 | const acodec = dataFormat.acodec; 141 | if(acodec == null || acodec === "none") continue; 142 | if(codecs.includes(acodec)) continue; 143 | codecs.push(acodec); 144 | } 145 | return codecs; 146 | } 147 | 148 | static parseAvailableFormats(metadata) { 149 | let formats = []; 150 | let detectedFormats = []; 151 | if(metadata.formats == null) { 152 | console.error("No formats could be found.") 153 | return []; 154 | } 155 | for(let dataFormat of metadata.formats) { 156 | if(dataFormat.height == null) continue; 157 | let format = new Format(dataFormat.height, dataFormat.fps, null, null); 158 | if(!detectedFormats.includes(format.getDisplayName())) { 159 | for(const dataFormat of metadata.formats) { 160 | const vcodec = dataFormat.vcodec; 161 | if(dataFormat.height !== format.height || dataFormat.fps !== format.fps) continue; 162 | if(vcodec == null || vcodec === "none") continue; 163 | if(format.encodings.includes(vcodec)) continue; 164 | format.encodings.push(vcodec); 165 | } 166 | formats.push(format); 167 | detectedFormats.push(format.getDisplayName()); 168 | } 169 | } 170 | return formats; 171 | } 172 | 173 | static generatePlaylistMetadata(query) { 174 | const indexes = []; 175 | if(query.entries == null || query.entries.length === 0) { 176 | console.error("Cannot extract URLS, no entries in data.") 177 | return indexes; 178 | } 179 | for(const entry of query.entries) { 180 | let url; 181 | if (entry.url == null) url = entry.webpage_url; 182 | else url = (entry.ie_key != null && entry.ie_key === "Youtube") ? "https://youtube.com/watch?v=" + entry.url : entry.url; 183 | if(url != null && url.length > 0) { 184 | let playlist = "?"; 185 | if(query.title != null) { 186 | playlist = query.title; 187 | } else if(query.id != null) { 188 | playlist = query.id; 189 | } 190 | indexes.push({ 191 | video_url: url, 192 | playlist_url: query.webpage_url, 193 | playlist_index: query.entries.indexOf(entry), 194 | playlist_id: query.id, 195 | playlist_title: query.title, 196 | playlist: playlist, 197 | playlist_uploader: query.uploader, 198 | playlist_uploader_id: query.uploader_id 199 | }) 200 | } 201 | } 202 | return indexes; 203 | } 204 | 205 | static getVideoInPlaylistMetadata(video_url, playlist_url, metadata) { 206 | if(metadata == null) return null; 207 | for(const video of metadata) { 208 | if(video.video_url === video_url) { 209 | if(playlist_url == null) { 210 | return video; 211 | } else if(playlist_url === video.playlist_url) { 212 | return video; 213 | } 214 | } 215 | } 216 | return null; 217 | } 218 | 219 | static resolvePlaylistPlaceholders(format, metadata) { 220 | let actualMetadata = metadata; 221 | if(metadata == null) actualMetadata = {}; 222 | let formatParsed = format; 223 | const regex = new RegExp(/%\((\w+)\)(s?)/g); 224 | const placeholders = format.matchAll(regex); 225 | for(const match of placeholders) { 226 | if(match == null) continue; 227 | if(match[0] == null || match[1] == null) continue; 228 | const placeholderValue = actualMetadata[match[1]]; 229 | if(placeholderValue == null) continue; 230 | formatParsed = formatParsed.replace(match[0], placeholderValue) 231 | } 232 | return formatParsed; 233 | } 234 | 235 | } 236 | module.exports = Utils; 237 | -------------------------------------------------------------------------------- /modules/download/DownloadQueryList.js: -------------------------------------------------------------------------------- 1 | const DownloadQuery = require('./DownloadQuery'); 2 | const ProgressBar = require("../types/ProgressBar"); 3 | const Utils = require("../Utils"); 4 | 5 | class DownloadQueryList { 6 | constructor(videos, playlistMetadata, environment, manager, progressBar) { 7 | this.videos = videos; 8 | this.playlistMetadata = playlistMetadata; 9 | this.environment = environment; 10 | this.progressBar = progressBar; 11 | this.manager = manager; 12 | this.length = this.videos.length; 13 | this.done = 0; 14 | this.cancelled = 0; 15 | this.parentProgress = []; 16 | } 17 | 18 | cancel() { 19 | for(const video of this.videos) { 20 | if(!video.downloaded) { 21 | video.query.cancel(); 22 | } 23 | } 24 | } 25 | 26 | async start() { 27 | return await new Promise(((resolve) => { 28 | for(let video of this.videos) { 29 | let progressBar = new ProgressBar(this.manager, video); 30 | let task = new DownloadQuery(video.webpage_url, video, this.environment, progressBar, Utils.getVideoInPlaylistMetadata(video.url, null, this.playlistMetadata)); 31 | if(video.parentID != null && !this.parentProgress.some(e => e.id === video.parentID)) { 32 | const bar = new ProgressBar(this.manager, video.parentID); 33 | this.parentProgress.push({ 34 | id: video.parentID, 35 | done: 0, 36 | cancelled: 0, 37 | length: video.parentSize, 38 | bar: bar 39 | }); 40 | bar.updatePlaylist(0, video.parentSize); 41 | } 42 | video.setQuery(task); 43 | video.query.connect().then((returnValue) => { 44 | if(video.parentID != null) { 45 | const progress = this.parentProgress.find(e => e.id === video.parentID); 46 | if(returnValue === "killed" || returnValue !== "done") progress.cancelled++; 47 | progress.done++; 48 | if (returnValue === "killed") this.cancelled++; 49 | if (returnValue !== "done") { 50 | video.error = true; 51 | this.environment.errorHandler.checkError(returnValue, video.identifier); 52 | } 53 | this.done++; 54 | progress.bar.updatePlaylist(progress.done - progress.cancelled, progress.length - progress.cancelled); 55 | if(progress.done === progress.length) { 56 | progress.bar.done(video.audioOnly); 57 | } 58 | } else { 59 | if (returnValue === "killed") this.cancelled++; 60 | if (returnValue !== "done") { 61 | video.error = true; 62 | this.environment.errorHandler.checkError(returnValue, video.identifier); 63 | } 64 | this.done++; 65 | } 66 | this.progressBar.updatePlaylist(this.done - this.cancelled, this.length - this.cancelled); 67 | if(!video.error) { 68 | if(this.environment.settings.downloadJsonMetadata) this.manager.saveInfo(video, false); 69 | video.downloaded = true; 70 | video.query.progressBar.done(video.audioOnly); 71 | } 72 | if(this.done === this.length) { 73 | resolve(); 74 | } 75 | }); 76 | this.progressBar.updatePlaylist(this.done - this.cancelled, this.length - this.cancelled); 77 | } 78 | })) 79 | 80 | } 81 | } 82 | 83 | module.exports = DownloadQueryList; 84 | -------------------------------------------------------------------------------- /modules/exceptions/ErrorHandler.js: -------------------------------------------------------------------------------- 1 | const Sentry = require("@sentry/electron"); 2 | const Utils = require("../Utils"); 3 | const Path = require("path"); 4 | const fs = require("fs").promises; 5 | 6 | class ErrorHandler { 7 | constructor(win, queryManager, env) { 8 | this.env = env; 9 | this.queryManager = queryManager; 10 | this.win = win; 11 | this.unhandledErrors = []; 12 | this.errorDefinitions = []; 13 | this.loadErrorDefinitions().then(errorDefs => this.errorDefinitions = errorDefs); 14 | } 15 | 16 | checkError(stderr, identifier) { 17 | let foundError = false; 18 | if(stderr == null) { 19 | console.error("An error has occurred but no error message was given.") 20 | return false; 21 | } 22 | if(stderr.trim().startsWith("WARNING:")) { 23 | console.warn(stderr); 24 | return; 25 | } 26 | for(const errorDef of this.errorDefinitions) { 27 | if(Array.isArray(errorDef.trigger)) { 28 | for(const trigger of errorDef.trigger) { 29 | if(stderr.includes(trigger)) { 30 | if(errorDef.code === "ffmpeg not found" && process.argv[2] === '--dev') return false; //Do not raise a 'ffmpeg not found' error when in dev mode 31 | if(errorDef.code === "Thumbnail embedding not supported") return false; //Do not raise an error when thumbnails can't be embedded due to unsupported container 32 | foundError = true; 33 | errorDef.trigger = trigger; 34 | this.raiseError(errorDef, identifier); 35 | break; 36 | } 37 | } 38 | } else if(stderr.includes(errorDef.trigger)) { 39 | if(errorDef.code === "ffmpeg not found" && process.argv[2] === '--dev') return false; //Do not raise a 'ffmpeg not found' error when in dev mode 40 | if(errorDef.code === "Thumbnail embedding not supported") return false; //Do not raise an error when thumbnails can't be embedded due to unsupported container 41 | foundError = true; 42 | this.raiseError(errorDef, identifier); 43 | break; 44 | } 45 | } 46 | if(!foundError) { 47 | if(stderr.includes("ERROR")) { 48 | foundError = true; 49 | console.error(stderr) 50 | this.raiseUnhandledError(stderr, stderr, identifier); 51 | } 52 | } 53 | return foundError; 54 | } 55 | 56 | raiseUnhandledError(code, error, identifier) { 57 | const video = this.queryManager.getVideo(identifier); 58 | if(video == null) return; 59 | if(code.includes("[debug]")) return; 60 | if(video.type === "playlist") return; 61 | let errorDef = { 62 | identifier: identifier, 63 | error_id: Utils.getRandomID(8), 64 | unexpected: true, 65 | error: { 66 | code: error === code ? "Unhandled error" : code, 67 | description: error, 68 | } 69 | }; 70 | Sentry.captureMessage(error === code ? error : code, scope => { 71 | scope.setLevel(Sentry.Severity.Error); 72 | if(code !== error) { 73 | scope.setContext("error", {description: error}); 74 | } 75 | scope.setTag("url", video.url); 76 | scope.setTag("error_id", errorDef.error_id); 77 | if(video.selected_format_index != null) { 78 | scope.setContext("selected_format", video.formats[video.selected_format_index].serialize()) 79 | } 80 | const { env, paths, ...settings } = this.env.settings; 81 | scope.setContext("settings", settings); 82 | }); 83 | this.win.webContents.send("error", errorDef); 84 | this.unhandledErrors.push(errorDef); 85 | this.queryManager.onError(identifier); 86 | } 87 | 88 | raiseError(errorDef, identifier) { 89 | const video = this.queryManager.getVideo(identifier); 90 | if(video == null) return; 91 | if(video.type === "playlist") return; 92 | console.error(errorDef.code + " - " + errorDef.description); 93 | this.win.webContents.send("error", { error: errorDef, identifier: identifier, unexpected: false, url: video.url }); 94 | this.queryManager.onError(identifier); 95 | } 96 | 97 | async reportError(args) { 98 | for(const err of this.unhandledErrors) { 99 | if(err.identifier === args.identifier) { 100 | return await this.env.analytics.sendReport(err.error_id); 101 | } 102 | } 103 | } 104 | 105 | async loadErrorDefinitions() { 106 | try { 107 | let path = Path.join(this.env.paths.packedPrefix, "/modules/exceptions/errorDefinitions.json"); 108 | if(!this.env.paths.app.isPackaged) { 109 | path = "modules/exceptions/errorDefinitions.json" 110 | } 111 | const data = await fs.readFile(path); 112 | return JSON.parse(data.toString()); 113 | } catch (e) { 114 | console.error("Failed loading error definitions.") 115 | console.error(e); 116 | Sentry.captureException(e); 117 | } 118 | } 119 | } 120 | 121 | module.exports = ErrorHandler; 122 | -------------------------------------------------------------------------------- /modules/exceptions/errorDefinitions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "code": "No authentication/private", 4 | "description": "Authenticate using cookies and try again.", 5 | "trigger": [ 6 | "ERROR: Private video", 7 | "This video is private", 8 | "Please sign in or sign up to view this video.", 9 | "ERROR: This video is only available for registered users" 10 | ] 11 | }, 12 | { 13 | "code": "Members-only content", 14 | "description": "Authenticate using cookies and try again.", 15 | "trigger": [ 16 | "ERROR: Join this channel to get access to members-only content", 17 | "This video is available to this channel's members on level" 18 | ] 19 | }, 20 | { 21 | "code": "Blocked in country", 22 | "description": "The uploader has blocked this video in your country.", 23 | "trigger": " The uploader has not made this video available in your country" 24 | }, 25 | { 26 | "code": "Blocked", 27 | "description": "This video is blocked for an unspecified reason.", 28 | "trigger": " The video is blocked" 29 | }, 30 | { 31 | "code": "Channel unavailable", 32 | "description": "This channel is currently not available.", 33 | "trigger": "This channel is not available" 34 | }, 35 | { 36 | "code": "No connection could be made", 37 | "description": "The host or your internet/proxy connection is down.", 38 | "trigger": "getaddrinfo failed" 39 | }, 40 | { 41 | "code": "URL not supported", 42 | "description": "This URL is currently not supported by YTDL.", 43 | "trigger": [ 44 | "is not a valid URL", 45 | "Unsupported URL" 46 | ] 47 | }, 48 | { 49 | "code": "URL not found", 50 | "description": "This is an incorrect or non-existing URL.", 51 | "trigger": "[Errno 11001]" 52 | }, 53 | { 54 | "code": "Private or non-existent playlist", 55 | "description": "This playlist does not exist or is private.", 56 | "trigger": "ERROR: The playlist does not exist" 57 | }, 58 | { 59 | "code": "Age restricted video", 60 | "description": "To download this video log-in using  cookies.", 61 | "trigger": [ 62 | "ERROR: Sign in to confirm your age", 63 | "Verify your age" 64 | ] 65 | }, 66 | { 67 | "code": "Embed-only video", 68 | "description": "Try using the URL of the embed page.", 69 | "trigger": "ERROR: Cannot download embed-only video without embedding URL" 70 | }, 71 | { 72 | "code": "Possible broken extractor (404)", 73 | "description": "YTDL isn't working, please wait for an update.", 74 | "trigger": "HTTP Error 404" 75 | }, 76 | { 77 | "code": "Private or removed video", 78 | "description": "This video can not be extracted.", 79 | "trigger": "metadata.formats is not iterable" 80 | }, 81 | { 82 | "code": "ffmpeg not found", 83 | "description": "Format merging requires ffmpeg.", 84 | "trigger": "ffmpeg or avconv not found" 85 | }, 86 | { 87 | "code": "ffmpeg not found", 88 | "description": "Transcoding to mp3 or mp4 requires ffmpeg.", 89 | "trigger": [ 90 | "ffmpeg or avconv could not be found", 91 | "ffprobe/avprobe and ffmpeg/avconv not found" 92 | ] 93 | }, 94 | { 95 | "code": "Incomplete video ID", 96 | "description": "The URL you entered is incomplete.", 97 | "trigger": "Incomplete YouTube ID" 98 | }, 99 | { 100 | "code": "Too many requests (429)", 101 | "description": "You are being ratelimited by the service.", 102 | "trigger": "HTTP Error 429" 103 | }, 104 | { 105 | "code": "Unable to extract initial data", 106 | "description": "Please do try again in a moment.", 107 | "trigger": [ 108 | "ERROR: Unable to extract yt initial data", 109 | "Did not get any data blocks" 110 | ] 111 | }, 112 | { 113 | "code": "No write permission", 114 | "description": "No write permission in download folder.", 115 | "trigger": "[Errno 95]" 116 | }, 117 | { 118 | "code": "Blocked by firewall/program", 119 | "description": "Check your firewall and or internet settings.", 120 | "trigger": "[WinError 10013]" 121 | }, 122 | { 123 | "code": "Permission denied", 124 | "description": "Unable to write files to your download location.", 125 | "trigger": "[Errno 13]" 126 | }, 127 | { 128 | "code": "SSL verification failed", 129 | "description": "Disable the 'Validate HTTPS certificates' setting.", 130 | "trigger": " Microsoft Visual C++ 2010", 213 | "trigger": "3221225781" 214 | }, 215 | { 216 | "code": "Video not found", 217 | "description": "This video does not exist.", 218 | "trigger": [ 219 | "Video has not been found", 220 | "does not exist" 221 | ] 222 | }, 223 | { 224 | "code": "Geo-locked content", 225 | "description": "Try using a vpn or proxy.", 226 | "trigger": [ 227 | "bbc.co.uk returned error: geolocation", 228 | "This video is not available from your location due to geo restriction" 229 | ] 230 | }, 231 | { 232 | "code": "Access forbidden", 233 | "description": "Not enough permissions to access the download URL.", 234 | "trigger": "Access to this resource is forbidden by access policy" 235 | }, 236 | { 237 | "code": "Unknown URL type", 238 | "description": "An invalid url scheme was used, try using http/https.", 239 | "trigger": "urlopen error unknown url type" 240 | }, 241 | { 242 | "code": "Download file not found", 243 | "description": "The download file was deleted/moved while it wasn't done yet.", 244 | "trigger": "[WinError 2]" 245 | }, 246 | { 247 | "code": "Not premiered yet", 248 | "description": "This video hasn't premiered yet.", 249 | "trigger": "Premieres in" 250 | }, 251 | { 252 | "code": "Copyright claimed", 253 | "description": "This video was blocked because of a copyright claim.", 254 | "trigger": "who has blocked it on copyright grounds" 255 | }, 256 | { 257 | "code": "Cannot allocate memory", 258 | "description": "FFmpeg has run out of memory, converting failed.", 259 | "trigger": "Cannot allocate memory" 260 | }, 261 | { 262 | "code": "Livestream not started yet", 263 | "description": "Wait for the livestream to start.", 264 | "trigger": "This live event will begin in a few moments." 265 | }, 266 | { 267 | "code": "Thumbnail embedding not supported", 268 | "description": "Only mp3 and m4a/mp4 are supported for thumbnail embedding for now.", 269 | "trigger": "ERROR: Only mp3 and m4a/mp4 are supported for thumbnail embedding for now." 270 | }, 271 | { 272 | "code": "SponsorBlock API unreachable", 273 | "description": "Unable to communicate with SponsorBlock API, please try again.", 274 | "trigger": "ERROR: Unable to communicate with SponsorBlock API" 275 | } 276 | ] 277 | -------------------------------------------------------------------------------- /modules/info/InfoQuery.js: -------------------------------------------------------------------------------- 1 | const Query = require("../types/Query"); 2 | 3 | class InfoQuery extends Query { 4 | constructor(url, identifier, environment) { 5 | super(environment, identifier); 6 | this.url = url; 7 | this.environment = environment; 8 | this.identifier = identifier; 9 | } 10 | 11 | async connect() { 12 | try { 13 | let args = ["-J", "--flat-playlist"] 14 | let data = await this.environment.metadataLimiter.schedule(() => this.start(this.url, args)); 15 | return JSON.parse(data); 16 | } catch (e) { 17 | this.environment.errorHandler.checkError(e.stderr, this.identifier) 18 | return null; 19 | } 20 | } 21 | } 22 | module.exports = InfoQuery; 23 | -------------------------------------------------------------------------------- /modules/info/InfoQueryList.js: -------------------------------------------------------------------------------- 1 | const InfoQuery = require('./InfoQuery'); 2 | const Video = require('../types/Video'); 3 | const Utils = require("../Utils"); 4 | 5 | class InfoQueryList { 6 | constructor(query, environment, progressBar) { 7 | this.query = query; 8 | this.environment = environment; 9 | this.progressBar = progressBar; 10 | this.urls = null; 11 | this.length = null; 12 | this.done = 0; 13 | } 14 | 15 | async start() { 16 | return await new Promise(((resolve) => { 17 | let totalMetadata = []; 18 | let playlistUrls = Utils.extractPlaylistUrls(this.query); 19 | for (const videoData of playlistUrls[1]) { 20 | let video = this.createVideo(videoData, videoData.url); 21 | totalMetadata.push(video); 22 | } 23 | this.urls = playlistUrls[0]; 24 | this.length = this.urls.length; 25 | if (this.length === 0) resolve(totalMetadata); 26 | if (this.urls === []) { 27 | console.error("This playlist is empty."); 28 | this.length = 0; 29 | resolve(null); 30 | } 31 | for (const url of this.urls) { 32 | let task = new InfoQuery(url, this.progressBar.video.identifier, this.environment); 33 | task.connect().then((data) => { 34 | if (data.formats != null) { 35 | let video = this.createVideo(data, url); 36 | totalMetadata.push(video); 37 | } 38 | this.done++; 39 | this.progressBar.updatePlaylist(this.done, this.length); 40 | if (this.done === this.length) { 41 | resolve(totalMetadata); 42 | } 43 | }); 44 | } 45 | })); 46 | } 47 | 48 | createVideo(data, url) { 49 | let metadata = (data.entries != null && data.entries.length === 1 && data.entries[0].formats != null) ? data.entries[0] : data; 50 | let video = new Video(url, "single", this.environment); 51 | video.setMetadata(metadata); 52 | return video; 53 | } 54 | 55 | } 56 | 57 | module.exports = InfoQueryList; 58 | -------------------------------------------------------------------------------- /modules/persistence/Logger.js: -------------------------------------------------------------------------------- 1 | const {dialog} = require("electron"); 2 | const path = require("path"); 3 | const fs = require("fs"); 4 | 5 | class Logger { 6 | constructor(environment) { 7 | this.environment = environment; 8 | this.logs = {}; 9 | } 10 | 11 | log(identifier, line) { 12 | if(line == null || line === "") return; 13 | let trimmedLine; 14 | if(line === "done") { 15 | trimmedLine = "Download finished"; 16 | } else if(line === "killed") { 17 | trimmedLine = "Download stopped"; 18 | } else { 19 | trimmedLine = line.replace(/[\n\r]/g, ""); 20 | } 21 | if(identifier in this.logs) { 22 | this.logs[identifier].push(trimmedLine); 23 | } else { 24 | this.logs[identifier] = [trimmedLine]; 25 | } 26 | } 27 | 28 | get(identifier) { 29 | return this.logs[identifier]; 30 | } 31 | 32 | clear(identifier) { 33 | delete this.logs[identifier]; 34 | } 35 | 36 | async save(identifier) { 37 | const logLines = this.logs[identifier]; 38 | let log = ""; 39 | for(const line of logLines) { 40 | log += line + "\n"; 41 | } 42 | const date = new Date().toLocaleString() 43 | .replace(", ", "-") 44 | .replace(/\//g, "-") 45 | .replace(/:/g, "-") 46 | let result = await dialog.showSaveDialog(this.environment.win, { 47 | defaultPath: path.join(this.environment.settings.downloadPath, "ytdl-log-" + date.slice(0, date.length - 6)), 48 | buttonLabel: "Save metadata", 49 | filters: [ 50 | { name: "txt", extensions: ["txt"] }, 51 | { name: "All Files", extensions: ["*"] }, 52 | ], 53 | properties: ["createDirectory"] 54 | }); 55 | if(!result.canceled) { 56 | fs.promises.writeFile(result.filePath, log).then(() => console.log("Download log saved.")); 57 | } 58 | } 59 | 60 | } 61 | 62 | module.exports = Logger; 63 | -------------------------------------------------------------------------------- /modules/persistence/Settings.js: -------------------------------------------------------------------------------- 1 | const os = require("os"); 2 | const { globalShortcut, clipboard } = require('electron'); 3 | const fs = require("fs").promises; 4 | 5 | class Settings { 6 | constructor( 7 | paths, env, outputFormat, audioOutputFormat, downloadPath, 8 | proxy, rateLimit, autoFillClipboard, noPlaylist, globalShortcut, userAgent, 9 | validateCertificate, enableEncoding, taskList, nameFormat, nameFormatMode, 10 | sizeMode, splitMode, maxConcurrent, updateBinary, downloadType, updateApplication, cookiePath, 11 | statSend, sponsorblockMark, sponsorblockRemove, sponsorblockApi, downloadMetadata, downloadJsonMetadata, 12 | downloadThumbnail, keepUnmerged, calculateTotalSize, theme 13 | ) { 14 | this.paths = paths; 15 | this.env = env 16 | this.outputFormat = outputFormat == null ? "none" : outputFormat; 17 | this.audioOutputFormat = audioOutputFormat == null ? "none" : audioOutputFormat; 18 | this.downloadPath = downloadPath == null ? env.app.getPath("downloads") : downloadPath; 19 | this.proxy = proxy == null ? "" : proxy; 20 | this.rateLimit = rateLimit == null ? "" : rateLimit; 21 | this.autoFillClipboard = autoFillClipboard == null ? true : autoFillClipboard; 22 | this.noPlaylist = noPlaylist == null ? false : noPlaylist; 23 | this.globalShortcut = globalShortcut == null ? true : globalShortcut; 24 | this.userAgent = userAgent == null ? "spoof" : userAgent; 25 | this.validateCertificate = validateCertificate == null ? false : validateCertificate; 26 | this.enableEncoding = enableEncoding == null ? false : enableEncoding; 27 | this.taskList = taskList == null ? true : taskList; 28 | this.nameFormat = nameFormat == null ? "%(title).200s-(%(height)sp%(fps).0d).%(ext)s" : nameFormat; 29 | this.nameFormatMode = nameFormatMode == null ? "%(title).200s-(%(height)sp%(fps).0d).%(ext)s" : nameFormatMode; 30 | this.sponsorblockMark = sponsorblockMark == null ? "" : sponsorblockMark; 31 | this.sponsorblockRemove = sponsorblockRemove == null ? "" : sponsorblockRemove; 32 | this.sponsorblockApi = sponsorblockApi == null ? "https://sponsor.ajay.app" : sponsorblockApi; 33 | this.downloadMetadata = downloadMetadata == null ? true : downloadMetadata; 34 | this.downloadJsonMetadata = downloadJsonMetadata == null ? false : downloadJsonMetadata; 35 | this.downloadThumbnail = downloadThumbnail == null ? false : downloadThumbnail; 36 | this.keepUnmerged = keepUnmerged == null ? false : keepUnmerged; 37 | this.calculateTotalSize = calculateTotalSize == null ? true : calculateTotalSize; 38 | this.sizeMode = sizeMode == null ? "click" : sizeMode; 39 | this.splitMode = splitMode == null? "49" : splitMode; 40 | this.maxConcurrent = (maxConcurrent == null || maxConcurrent <= 0) ? Math.round(os.cpus().length / 2) : maxConcurrent; //Max concurrent is standard half of the system's available cores 41 | this.updateBinary = updateBinary == null ? true : updateBinary; 42 | this.downloadType = downloadType == null ? "video" : downloadType; 43 | this.updateApplication = updateApplication == null ? true : updateApplication; 44 | this.cookiePath = cookiePath; 45 | this.statSend = statSend == null ? false : statSend; 46 | this.theme = theme == null ? "dark" : theme; 47 | this.setGlobalShortcuts(); 48 | } 49 | 50 | static async loadFromFile(paths, env) { 51 | try { 52 | let result = await fs.readFile(paths.settings, "utf8"); 53 | let data = JSON.parse(result); 54 | return new Settings( 55 | paths, 56 | env, 57 | data.outputFormat, 58 | data.audioOutputFormat, 59 | data.downloadPath, 60 | data.proxy, 61 | data.rateLimit, 62 | data.autoFillClipboard, 63 | data.noPlaylist, 64 | data.globalShortcut, 65 | data.userAgent, 66 | data.validateCertificate, 67 | data.enableEncoding, 68 | data.taskList, 69 | data.nameFormat, 70 | data.nameFormatMode, 71 | data.sizeMode, 72 | data.splitMode, 73 | data.maxConcurrent, 74 | data.updateBinary, 75 | data.downloadType, 76 | data.updateApplication, 77 | data.cookiePath, 78 | data.statSend, 79 | data.sponsorblockMark, 80 | data.sponsorblockRemove, 81 | data.sponsorblockApi, 82 | data.downloadMetadata, 83 | data.downloadJsonMetadata, 84 | data.downloadThumbnail, 85 | data.keepUnmerged, 86 | data.calculateTotalSize, 87 | data.theme 88 | ); 89 | } catch(err) { 90 | console.log(err); 91 | let settings = new Settings(paths, env); 92 | settings.save(); 93 | console.log("Created new settings file.") 94 | return settings; 95 | } 96 | } 97 | 98 | update(settings) { 99 | this.outputFormat = settings.outputFormat; 100 | this.audioOutputFormat = settings.audioOutputFormat; 101 | this.proxy = settings.proxy; 102 | this.rateLimit = settings.rateLimit; 103 | this.autoFillClipboard = settings.autoFillClipboard; 104 | this.noPlaylist = settings.noPlaylist; 105 | this.globalShortcut = settings.globalShortcut; 106 | this.userAgent = settings.userAgent; 107 | this.validateCertificate = settings.validateCertificate; 108 | this.enableEncoding = settings.enableEncoding; 109 | this.taskList = settings.taskList; 110 | this.nameFormat = settings.nameFormat; 111 | this.nameFormatMode = settings.nameFormatMode; 112 | this.sponsorblockMark = settings.sponsorblockMark; 113 | this.sponsorblockRemove = settings.sponsorblockRemove; 114 | this.sponsorblockApi = settings.sponsorblockApi; 115 | this.downloadMetadata = settings.downloadMetadata; 116 | this.downloadJsonMetadata = settings.downloadJsonMetadata; 117 | this.downloadThumbnail = settings.downloadThumbnail; 118 | this.keepUnmerged = settings.keepUnmerged; 119 | this.calculateTotalSize = settings.calculateTotalSize; 120 | this.sizeMode = settings.sizeMode; 121 | this.splitMode = settings.splitMode; 122 | if(this.maxConcurrent !== settings.maxConcurrent) { 123 | this.maxConcurrent = settings.maxConcurrent; 124 | this.env.changeMaxConcurrent(settings.maxConcurrent); 125 | } 126 | this.updateBinary = settings.updateBinary; 127 | this.downloadType = settings.downloadType; 128 | this.updateApplication = settings.updateApplication; 129 | this.theme = settings.theme; 130 | this.save(); 131 | 132 | //Prevent installing already downloaded updates on app close. 133 | this.env.appUpdater.setUpdateSetting(settings.updateApplication); 134 | this.setGlobalShortcuts(); 135 | } 136 | 137 | serialize() { 138 | return { 139 | outputFormat: this.outputFormat, 140 | audioOutputFormat: this.audioOutputFormat, 141 | downloadPath: this.downloadPath, 142 | proxy: this.proxy, 143 | rateLimit: this.rateLimit, 144 | autoFillClipboard: this.autoFillClipboard, 145 | noPlaylist: this.noPlaylist, 146 | globalShortcut: this.globalShortcut, 147 | userAgent: this.userAgent, 148 | validateCertificate: this.validateCertificate, 149 | enableEncoding: this.enableEncoding, 150 | taskList: this.taskList, 151 | nameFormat: this.nameFormat, 152 | nameFormatMode: this.nameFormatMode, 153 | sizeMode: this.sizeMode, 154 | splitMode: this.splitMode, 155 | maxConcurrent: this.maxConcurrent, 156 | defaultConcurrent: Math.round(os.cpus().length / 2), 157 | updateBinary: this.updateBinary, 158 | downloadType: this.downloadType, 159 | updateApplication: this.updateApplication, 160 | cookiePath: this.cookiePath, 161 | statSend: this.statSend, 162 | sponsorblockMark: this.sponsorblockMark, 163 | sponsorblockRemove: this.sponsorblockRemove, 164 | sponsorblockApi: this.sponsorblockApi, 165 | downloadMetadata: this.downloadMetadata, 166 | downloadJsonMetadata: this.downloadJsonMetadata, 167 | downloadThumbnail: this.downloadThumbnail, 168 | keepUnmerged: this.keepUnmerged, 169 | calculateTotalSize: this.calculateTotalSize, 170 | theme: this.theme, 171 | version: this.env.version 172 | } 173 | } 174 | 175 | save() { 176 | fs.writeFile(this.paths.settings, JSON.stringify(this.serialize()), "utf8").then(() => { 177 | console.log("Saved settings file.") 178 | }); 179 | } 180 | 181 | setGlobalShortcuts() { 182 | if(globalShortcut == null) return; 183 | if(!this.globalShortcut) { 184 | globalShortcut.unregisterAll(); 185 | } else { 186 | if(!globalShortcut.isRegistered("Shift+CommandOrControl+V")) { 187 | globalShortcut.register('Shift+CommandOrControl+V', async () => { 188 | this.env.win.webContents.send("addShortcut", clipboard.readText()); 189 | }); 190 | } 191 | if(!globalShortcut.isRegistered("Shift+CommandOrControl+D")) { 192 | globalShortcut.register('Shift+CommandOrControl+D', async () => { 193 | this.env.win.webContents.send("downloadShortcut"); 194 | }); 195 | } 196 | } 197 | } 198 | } 199 | 200 | module.exports = Settings; 201 | -------------------------------------------------------------------------------- /modules/persistence/TaskList.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises 2 | 3 | class TaskList { 4 | constructor(paths, manager) { 5 | this.paths = paths 6 | this.manager = manager 7 | this.data = null; 8 | } 9 | 10 | async save() { 11 | const taskList = this.manager.getTaskList(); 12 | await fs.writeFile(this.paths.taskList, JSON.stringify(taskList)) 13 | } 14 | 15 | async load() { 16 | try { 17 | const res = await fs.readFile(this.paths.taskList) 18 | this.data = JSON.parse(res) 19 | } catch(err) { 20 | console.log("No tasks to restore.") 21 | return 22 | } 23 | if(this.data.length > 0) { 24 | const toastInfo = { 25 | type: "task-list", 26 | title: "Queue restoration", 27 | body: `

Do you want to restore the ${this.data.length} ${this.data.length > 1 ? "items" : "item"}
you had in your queue?

` 28 | } 29 | this.manager.window.webContents.send("toast", toastInfo) 30 | } else { 31 | console.log("No tasks to restore.") 32 | } 33 | } 34 | 35 | restore() { 36 | this.manager.loadTaskList(this.data) 37 | } 38 | } 39 | 40 | module.exports = TaskList 41 | -------------------------------------------------------------------------------- /modules/size/SizeQuery.js: -------------------------------------------------------------------------------- 1 | const Query = require("../types/Query"); 2 | const Utils = require("../Utils"); 3 | 4 | class SizeQuery extends Query { 5 | constructor(video, audioOnly, videoOnly, format, environment) { 6 | super(environment, video.identifier); 7 | this.video = video; 8 | this.audioOnly = audioOnly; 9 | this.videoOnly = videoOnly; 10 | this.audioQuality = environment.mainAudioQuality; 11 | this.format = format 12 | } 13 | 14 | async connect() { 15 | const encoding = this.video.selectedEncoding === "none" ? "" : "[vcodec=" + this.video.selectedEncoding + "]"; 16 | const audioEncoding = this.video.selectedAudioEncoding === "none" ? "" : "[acodec=" + this.video.selectedAudioEncoding + "]"; 17 | let formatArgument = `bestvideo[height=${this.format.height}][fps=${this.format.fps}]${encoding}+${this.audioQuality}audio/bestvideo[height=${this.format.height}][fps=${this.format.fps}]+${this.audioQuality}audio/bestvideo[height=${this.format.height}]+${this.audioQuality}audio/best[height=${this.format.height}]/bestvideo+bestaudio/best`; 18 | if(this.videoOnly) { 19 | formatArgument = `bestvideo[height=${this.format.height}][fps=${this.format.fps}]${encoding}/bestvideo[height=${this.format.height}][fps=${this.format.fps}]/bestvideo[height=${this.format.height}]/best[height=${this.format.height}]/bestvideo/best`; 20 | if (this.format.fps == null) { 21 | formatArgument = `bestvideo[height=${this.format.height}]${encoding}/bestvideo[height=${this.format.height}]/best[height=${this.format.height}]/bestvideo/best` 22 | } 23 | } else { 24 | formatArgument = `bestvideo[height=${this.format.height}][fps=${this.format.fps}]${encoding}+${this.video.audioQuality}audio${audioEncoding}/bestvideo[height=${this.format.height}][fps=${this.format.fps}]${encoding}+${this.video.audioQuality}audio/bestvideo[height=${this.format.height}][fps=${this.format.fps}]+${this.video.audioQuality}audio/bestvideo[height=${this.format.height}]+${this.video.audioQuality}audio/best[height=${this.format.height}]/bestvideo+bestaudio/best`; 25 | if (this.format.fps == null) { 26 | formatArgument = `bestvideo[height=${this.format.height}]${encoding}+${this.video.audioQuality}audio${audioEncoding}/bestvideo[height=${this.format.height}]${encoding}+${this.video.audioQuality}audio/bestvideo[height=${this.format.height}]+${this.video.audioQuality}audio/best[height=${this.format.height}]/bestvideo+bestaudio/best` 27 | } 28 | } 29 | if(this.audioOnly) { 30 | formatArgument = `bestvideo+${this.format}audio/bestvideo+bestaudio/best`; 31 | } 32 | 33 | let output = await this.environment.metadataLimiter.schedule(() => this.start(this.video.url, ["-J", "--flat-playlist", "-f", formatArgument])); 34 | let data = JSON.parse(output); 35 | let totalSize = 0; 36 | if(data.requested_formats != null) { 37 | if(this.audioOnly) { 38 | for (const requestedFormat of data.requested_formats) { 39 | if (requestedFormat.vcodec === "none") { 40 | totalSize += requestedFormat.filesize; 41 | break; 42 | } 43 | } 44 | } else if(this.videoOnly) { 45 | for (const requestedFormat of data.requested_formats) { 46 | if (requestedFormat.acodec === "none") { 47 | totalSize += requestedFormat.filesize; 48 | break; 49 | } 50 | } 51 | } else { 52 | for (const requestedFormat of data.requested_formats) { 53 | if (requestedFormat.filesize != null) { 54 | totalSize += requestedFormat.filesize; 55 | } else if (requestedFormat.filesize_approx != null) { 56 | totalSize += requestedFormat.filesize_approx; 57 | } 58 | } 59 | } 60 | } 61 | if(totalSize === 0) { 62 | if(!this.audioOnly && !this.videoOnly) { 63 | this.format.filesize = null; 64 | this.format.filesize_label = "Unknown"; 65 | } 66 | return "Unknown"; 67 | } else { 68 | if(!this.audioOnly && !this.videoOnly) { 69 | this.format.filesize = totalSize; 70 | this.format.filesize_label = Utils.convertBytes(totalSize); 71 | } 72 | return totalSize 73 | } 74 | } 75 | } 76 | 77 | module.exports = SizeQuery; 78 | -------------------------------------------------------------------------------- /modules/types/Format.js: -------------------------------------------------------------------------------- 1 | class Format { 2 | 3 | constructor(height, fps, filesize, filesize_label) { 4 | this.height = height; 5 | this.fps = fps; 6 | this.filesize = filesize; 7 | this.filesize_label = filesize_label; 8 | this.encodings = []; 9 | } 10 | 11 | getDisplayName() { 12 | if(this.fps == null) { 13 | return this.height + "p"; 14 | } else { 15 | return this.height + "p" + this.fps; 16 | } 17 | } 18 | 19 | serialize() { 20 | return { 21 | height: this.height, 22 | fps: this.fps, 23 | filesize: this.filesize, 24 | filesize_label: this.filesize_label, 25 | display_name: this.getDisplayName(), 26 | encodings: this.encodings 27 | }; 28 | } 29 | 30 | static deserialize(displayname) { 31 | return this.getFromDisplayName(displayname); 32 | } 33 | 34 | static getDisplayName(height, fps) { 35 | if(fps == null) { 36 | return height + "p"; 37 | } else { 38 | return height + "p" + fps; 39 | } 40 | } 41 | 42 | static getFromDisplayName(name) { 43 | let splitName = name.split("p"); 44 | let height = splitName[0]; 45 | let fps = splitName[1]; 46 | if(fps === "") fps = null; 47 | return new Format(height, fps) 48 | } 49 | } 50 | module.exports = Format; 51 | -------------------------------------------------------------------------------- /modules/types/ProgressBar.js: -------------------------------------------------------------------------------- 1 | class ProgressBar { 2 | constructor(manager, video) { 3 | this.manager = manager; 4 | this.video = video; 5 | } 6 | 7 | updatePlaylist(done, total) { 8 | if(done === 0 && total === 0) { 9 | this.manager.updateProgress(this.video, {resetTotal: true}) 10 | } else { 11 | let percent = ((done / total) * 100).toFixed(2) + "%"; 12 | this.manager.updateProgress(this.video, { 13 | percentage: percent, 14 | done: done, 15 | total: total, 16 | isPlaylist: this.isUnifiedPlaylist() 17 | }); 18 | } 19 | } 20 | 21 | updateDownload(percentage, eta, speed, isAudio) { 22 | this.manager.updateProgress(this.video, {percentage: percentage, eta: eta, speed: speed, isAudio: this.video.formats.length === 0 ? null : isAudio}); 23 | } 24 | 25 | reset() { 26 | this.manager.updateProgress(this.video, {reset: true}) 27 | } 28 | 29 | setInitial(message) { 30 | this.manager.updateProgress(this.video, {initial: true, message: message}); 31 | } 32 | 33 | done(isAudio) { 34 | let audio = isAudio; 35 | if(!this.isUnifiedPlaylist()) audio = this.video.formats.length === 0 ? null : isAudio; 36 | this.manager.updateProgress(this.video, {finished: true, isAudio: audio, isPlaylist: this.isUnifiedPlaylist()}) 37 | } 38 | 39 | isUnifiedPlaylist() { 40 | if(typeof this.video === "string") { 41 | return true; 42 | } else { 43 | return this.video.videos != null; 44 | } 45 | } 46 | } 47 | module.exports = ProgressBar; 48 | -------------------------------------------------------------------------------- /modules/types/Query.js: -------------------------------------------------------------------------------- 1 | const execa = require('execa'); 2 | const UserAgent = require('user-agents'); 3 | const Sentry = require("@sentry/node"); 4 | 5 | class Query { 6 | constructor(environment, identifier) { 7 | this.environment = environment; 8 | this.identifier = identifier 9 | this.process = null; 10 | this.stopped = false; 11 | } 12 | 13 | stop() { 14 | this.stopped = true; 15 | if(this.process != null) { 16 | this.process.cancel(); 17 | } 18 | } 19 | 20 | async start(url, args, cb) { 21 | if(this.stopped) return "killed"; 22 | args.push("--no-cache-dir"); 23 | args.push("--ignore-config"); 24 | 25 | if(this.environment.settings.userAgent === "spoof") { 26 | args.push("--user-agent"); //Add random user agent to slow down user agent profiling 27 | args.push(new UserAgent({ deviceCategory: 'desktop' }).toString()); 28 | } else if(this.environment.settings.userAgent === "empty") { 29 | args.push("--user-agent"); 30 | args.push("''"); //Add an empty user agent string to workaround VR video issues 31 | } 32 | 33 | if(this.environment.settings.proxy != null && this.environment.settings.proxy.length > 0) { 34 | args.push("--proxy"); 35 | args.push(this.environment.settings.proxy); 36 | } 37 | 38 | if(!this.environment.settings.validateCertificate) { 39 | args.push("--no-check-certificate"); //Dont check the certificate if validate certificate is false 40 | } 41 | 42 | if(this.environment.settings.cookiePath != null) { //Add cookie arguments if enabled 43 | args.push("--cookies"); 44 | args.push(this.environment.settings.cookiePath); 45 | } 46 | 47 | if(this.environment.settings.rateLimit !== "") { 48 | args.push("--limit-rate"); 49 | args.push(this.environment.settings.rateLimit + "K"); 50 | } 51 | 52 | if(this.environment.settings.noPlaylist) { 53 | args.push("--no-playlist"); 54 | } else { 55 | args.push("--yes-playlist") 56 | } 57 | 58 | args.push(url) //Url must always be added as the final argument 59 | 60 | let command = this.environment.paths.ytdl; //Set the command to be executed 61 | 62 | if(this.environment.pythonCommand !== "python") { //If standard python is not available use another install if detected 63 | args.unshift(this.environment.paths.ytdl); 64 | command = this.environment.pythonCommand; 65 | } 66 | if(cb == null) { 67 | const transaction = Sentry.startTransaction({ name: "infoQuery" }); 68 | const span = transaction.startChild({ op: "task" }); 69 | //Return the data after the query has completed fully. 70 | try { 71 | const {stdout} = await execa(command, args); 72 | span.finish(); 73 | transaction.finish(); 74 | return stdout 75 | } catch(e) { 76 | if(!this.environment.errorHandler.checkError(e.stderr, this.identifier)) { 77 | if(!this.environment.errorHandler.checkError(e.shortMessage, this.identifier)) { 78 | this.environment.errorHandler.raiseUnhandledError("Unhandled error (execa)", JSON.stringify(e, null, 2), this.identifier); 79 | } 80 | } 81 | span.finish(); 82 | transaction.finish(); 83 | return "{}"; 84 | } 85 | } else { 86 | const transaction = Sentry.startTransaction({ name: "liveQuery" }); 87 | const span = transaction.startChild({ op: "task" }); 88 | //Return data while the query is running (live) 89 | //Return "done" when the query has finished 90 | return await new Promise((resolve) => { 91 | this.process = execa(command, args); 92 | this.process.stdout.setEncoding('utf8'); 93 | this.process.stdout.on('data', (data) => { 94 | cb(data.toString()); 95 | }); 96 | this.process.stdout.on('close', () => { 97 | if(this.process.killed) { 98 | transaction.setTag("result", "killed"); 99 | span.finish(); 100 | transaction.finish(); 101 | cb("killed"); 102 | resolve("killed"); 103 | } 104 | transaction.setTag("result", "done"); 105 | span.finish(); 106 | transaction.finish(); 107 | cb("done"); 108 | resolve("done"); 109 | }); 110 | this.process.stderr.on("data", (data) => { 111 | cb(data.toString()); 112 | if(this.environment.errorHandler.checkError(data.toString(), this.identifier)) { 113 | cb("killed"); 114 | resolve("killed"); 115 | transaction.setTag("result", "killed"); 116 | span.finish(); 117 | transaction.finish(); 118 | } 119 | console.error(data.toString()) 120 | }) 121 | }); 122 | } 123 | } 124 | 125 | } 126 | module.exports = Query; 127 | -------------------------------------------------------------------------------- /modules/types/Video.js: -------------------------------------------------------------------------------- 1 | const Utils = require("../Utils"); 2 | const path = require("path"); 3 | 4 | class Video { 5 | constructor(url, type, environment) { 6 | this.url = url; 7 | this.type = type; 8 | this.environment = environment; 9 | this.audioQuality = environment.mainAudioQuality; 10 | this.audioOnly = environment.mainAudioOnly; 11 | this.videoOnly = environment.mainVideoOnly; 12 | this.videoOnlySizeCache = []; 13 | this.downloadSubs = environment.mainDownloadSubs; 14 | this.subLanguages = []; 15 | this.selectedSubs = []; 16 | this.downloadingAudio = false; 17 | this.webpage_url = this.url; 18 | this.hasMetadata = false; 19 | this.downloaded = false; 20 | this.error = false; 21 | this.filename = null; 22 | this.identifier = Utils.getRandomID(32); 23 | } 24 | 25 | setFilename(liveData) { 26 | if(liveData.includes("[download] Destination: ")) { 27 | const replaced = liveData.replace("[download] Destination: ", ""); 28 | this.filename = path.basename(replaced); 29 | } else if(liveData.includes("[ffmpeg] Merging formats into \"")) { 30 | const noPrefix = liveData.replace("[ffmpeg] Merging formats into \"", ""); 31 | this.filename = path.basename(noPrefix.trim().slice(0, -1)); 32 | } else if(liveData.includes("[ffmpeg] Adding metadata to '")) { 33 | const noPrefix = liveData.replace("[ffmpeg] Adding metadata to '", ""); 34 | this.filename = path.basename(noPrefix.trim().slice(0, -1)); 35 | } 36 | } 37 | 38 | getFilename() { 39 | if(this.hasMetadata) { 40 | let sanitizeRegex = /(?:[/<>:"|\\?*]|[\s.]$)/g; 41 | if(this.formats.length === 0) { 42 | const filename = this.title.substr(0, 200) + "-(p)" 43 | return filename.replace(sanitizeRegex, "_"); 44 | } else { 45 | let fps = (this.formats[this.selected_format_index].fps != null) ? this.formats[this.selected_format_index].fps : ""; 46 | let height = this.formats[this.selected_format_index].height; 47 | if(this.environment.settings.nameFormatMode === "%(title).200s-(%(height)sp%(fps).0d).%(ext)s") { 48 | return (this.title.substr(0, 200) + "-(" + height + "p" + fps.toString().substr(0,2) + ")").replace(sanitizeRegex, "_"); 49 | } else if(this.environment.settings.nameFormatMode !== "custom") { 50 | return this.title.substr(0, 200).replace(sanitizeRegex, "_"); 51 | } 52 | } 53 | } 54 | } 55 | 56 | setQuery(query) { 57 | this.query = query; 58 | //Set the download path when the video was downloaded 59 | this.environment.paths.validateDownloadPath().then(() => { 60 | this.downloadedPath = this.environment.settings.downloadPath; 61 | }) 62 | } 63 | 64 | serialize() { 65 | let formats = []; 66 | for(const format of this.formats) { 67 | formats.push(format.serialize()); 68 | } 69 | return { 70 | like_count: Utils.numberFormatter(this.like_count, 2), 71 | dislike_count: Utils.numberFormatter(this.dislike_count, 2), 72 | description: this.description, 73 | view_count: Utils.numberFormatter(this.view_count, 2), 74 | title: this.title, 75 | tags: this.tags, 76 | duration: this.duration, 77 | extractor: this.extractor, 78 | thumbnail: this.thumbnail, 79 | uploader: this.uploader, 80 | average_rating: this.average_rating, 81 | url: this.url, 82 | formats: formats 83 | }; 84 | } 85 | 86 | setMetadata(metadata) { 87 | this.hasMetadata = true; 88 | this.like_count = metadata.like_count; 89 | this.dislike_count = metadata.dislike_count; 90 | this.average_rating = metadata.average_rating 91 | this.view_count = metadata.view_count; 92 | this.title = metadata.title; 93 | this.description = metadata.description; 94 | this.tags = metadata.tags; 95 | this.subtitles = metadata.subtitles; 96 | this.autoCaptions = metadata.automatic_captions; 97 | 98 | this.duration = metadata.duration; 99 | if(metadata.duration != null) this.duration = new Date(metadata.duration * 1000) 100 | .toISOString() 101 | .substr(11, 8); 102 | if(this.duration != null && this.duration.split(":")[0] === "00") this.duration = this.duration.substr(3); 103 | 104 | this.extractor = metadata.extractor_key; 105 | this.uploader = metadata.uploader; 106 | this.thumbnail = metadata.thumbnail; 107 | 108 | this.hasFilesizes = Utils.hasFilesizes(metadata) 109 | this.formats = Utils.parseAvailableFormats(metadata); 110 | this.audioCodecs = Utils.parseAvailableAudioCodecs(metadata); 111 | this.selected_format_index = this.selectHighestQuality(); 112 | } 113 | 114 | selectHighestQuality() { 115 | this.formats.sort((a, b) => { 116 | return parseInt(b.height, 10) - parseInt(a.height, 10) || (a.fps == null) - (b.fps == null) || parseInt(b.fps, 10) - parseInt(a.fps, 10); 117 | }); 118 | return 0; 119 | } 120 | 121 | getFormatFromLabel(formatLabel) { 122 | for(const format of this.formats) { 123 | if(format.getDisplayName() === formatLabel) { 124 | return format; 125 | } 126 | } 127 | } 128 | } 129 | module.exports = Video; 130 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube-dl-gui", 3 | "version": "2.4.0", 4 | "description": "Open Video Downloader", 5 | "main": "main.js", 6 | "scripts": { 7 | "start": "electron . --dev", 8 | "build": "electron-builder", 9 | "lint": "eslint {**/modules/**/*.js,/*.js} && eslint renderer/*.js", 10 | "test": "jest --coverage", 11 | "quick-test": "jest && eslint {**/modules/**/*.js,/*.js} && eslint renderer/*.js" 12 | }, 13 | "jest": { 14 | "collectCoverageFrom": [ 15 | "**/modules/**/*.js" 16 | ] 17 | }, 18 | "keywords": [ 19 | "youtube-dl", 20 | "electron", 21 | "download", 22 | "youtube", 23 | "gui", 24 | "interface" 25 | ], 26 | "author": "Jelle Glebbeek", 27 | "license": "AGPL-3.0-only", 28 | "devDependencies": { 29 | "electron": "^11.5.0", 30 | "electron-builder": "^22.9.1", 31 | "eslint": "^7.21.0", 32 | "jest": "^27.3.0" 33 | }, 34 | "dependencies": { 35 | "@sentry/electron": "^2.5.0", 36 | "@sentry/node": "^5.27.6", 37 | "@sentry/tracing": "^5.27.6", 38 | "adm-zip": "^0.5.9", 39 | "axios": "^0.21.4", 40 | "bootstrap": "^4.5.3", 41 | "bootstrap-icons": "^1.3.0", 42 | "bottleneck": "^2.19.5", 43 | "dotenv": "^8.2.0", 44 | "electron-updater": "^4.3.8", 45 | "execa": "^4.1.0", 46 | "iso-639-2": "^2.0.0", 47 | "jquery": "^3.5.1", 48 | "mkdirp": "^1.0.4", 49 | "popper.js": "^1.16.1", 50 | "sortablejs": "^1.14.0", 51 | "user-agents": "^1.0.586", 52 | "windowbar": "^1.7.4" 53 | }, 54 | "repository": { 55 | "type": "git", 56 | "url": "https://github.com/jely2002/youtube-dl-gui.git" 57 | }, 58 | "build": { 59 | "afterPack": "./build/appimage-fix.js", 60 | "appId": "com.jelleglebbeek.youtube-dl-gui", 61 | "asarUnpack": "**/binaries/*", 62 | "appx": { 63 | "backgroundColor": "#292929", 64 | "displayName": "Open Video Downloader", 65 | "identityName": "3216JelleGlebbeek.youtube-dl-gui", 66 | "publisher": "CN=EBDD6AA4-D72E-42C6-BBCB-A288476F0CBE", 67 | "publisherDisplayName": "Jelle Glebbeek", 68 | "applicationId": "openvideodownloader" 69 | }, 70 | "nsis": { 71 | "include": "./build/vcredist.nsh", 72 | "packElevateHelper": false 73 | }, 74 | "productName": "Open Video Downloader", 75 | "copyright": "Copyright © 2020-2021 Jelle Glebbeek", 76 | "win": { 77 | "target": "nsis", 78 | "icon": "renderer/img/icon.ico", 79 | "files": [ 80 | "!**/renderer/img/icon.icns", 81 | "!README.md", 82 | "!.github${/*}", 83 | "!ytdlgui_demo.gif", 84 | "!appimage-fix.js", 85 | "!userSettings", 86 | "!coverage", 87 | "!tests", 88 | "!codecov.yaml", 89 | "!**/.eslintrc.js" 90 | ] 91 | }, 92 | "mac": { 93 | "target": "dmg", 94 | "icon": "renderer/img/icon.icns", 95 | "category": "public.app-category.utilities", 96 | "identity": null, 97 | "files": [ 98 | "!**/binaries/AtomicParsley.exe", 99 | "!**/renderer/img/icon.ico", 100 | "!README.md", 101 | "!.github${/*}", 102 | "!ytdlgui_demo.gif", 103 | "!appimage-fix.js", 104 | "!userSettings", 105 | "!coverage", 106 | "!tests", 107 | "!codecov.yaml", 108 | "!**/.eslintrc.js" 109 | ] 110 | }, 111 | "linux": { 112 | "target": "AppImage", 113 | "executableName": "open-video-downloader", 114 | "icon": "renderer/img/icon.png", 115 | "synopsis": "A cross-platform GUI for youtube-dl", 116 | "category": "X-utility", 117 | "desktop": { 118 | "Name": "Open-Video-Downloader", 119 | "Icon": "youtube-dl-gui", 120 | "Comment": "A cross-platform GUI for youtube-dl" 121 | }, 122 | "files": [ 123 | "!**/binaries/AtomicParsley.exe", 124 | "!**/renderer/img/icon.icns", 125 | "!**/renderer/img/icon.ico", 126 | "!README.md", 127 | "!.github${/*}", 128 | "!ytdlgui_demo.gif", 129 | "!appimage-fix.js", 130 | "!userSettings", 131 | "!coverage", 132 | "!tests", 133 | "!codecov.yaml", 134 | "!**/.eslintrc.js" 135 | ] 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /preload.js: -------------------------------------------------------------------------------- 1 | const Sentry = require("@sentry/electron"); 2 | const Tracing = require("@sentry/tracing"); 3 | const version = require('./package.json').version; 4 | const { contextBridge, ipcRenderer } = require('electron') 5 | 6 | function initSentry() { 7 | if(process.argv[2] === '--dev' && !process.argv.includes("--sentry")) return; 8 | Sentry.init({ 9 | dsn: process.env.SENTRY_DSN, 10 | release: "youtube-dl-gui@" + version, 11 | sendDefaultPii: true, 12 | integrations: [new Tracing.Integrations.BrowserTracing()], 13 | tracesSampleRate: process.argv[2] === '--dev' ? 1.0 : 0.01, 14 | environment: process.argv[2] === '--dev' ? "development" : "production", 15 | autoSessionTracking: true 16 | }); 17 | } 18 | 19 | initSentry(); 20 | 21 | contextBridge.exposeInMainWorld( 22 | "main", 23 | { 24 | invoke: async (channel, data) => { 25 | let validChannels = [ 26 | "platform", 27 | "messageBox", 28 | "errorReport", 29 | "titlebarClick", 30 | "openInputMenu", 31 | "openCopyMenu", 32 | "settingsAction", 33 | "videoAction", 34 | "cookieFile", 35 | "downloadFolder", 36 | "installUpdate", 37 | "iconProgress", 38 | "theme", 39 | "restoreTaskList", 40 | "getDoneActions", 41 | "setDoneAction", 42 | "getSubtitles", 43 | "getSelectedSubtitles", 44 | "getLog", 45 | "saveLog" 46 | ]; 47 | if (validChannels.includes(channel)) { 48 | return await ipcRenderer.invoke(channel, data); 49 | } 50 | }, 51 | receive: (channel, cb) => { 52 | let validChannels = [ 53 | "log", 54 | "error", 55 | "toast", 56 | "maximized", 57 | "videoAction", 58 | "updateGlobalButtons", 59 | "updateLinkPlaceholder", 60 | "totalSize", 61 | "binaryLock", 62 | "addShortcut", 63 | "downloadShortcut" 64 | ]; 65 | if (validChannels.includes(channel)) { 66 | ipcRenderer.on(channel, (event, arg) => { 67 | cb(arg) 68 | }); 69 | } 70 | } 71 | } 72 | ); 73 | -------------------------------------------------------------------------------- /renderer/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "globals": { 3 | "Sortable": "readonly" 4 | }, 5 | "env": { 6 | "browser": true, 7 | "es2021": true, 8 | "jquery": true 9 | }, 10 | "extends": "eslint:recommended", 11 | "parserOptions": { 12 | "ecmaVersion": 12 13 | }, 14 | "rules": { 15 | "accessor-pairs": "error", 16 | "array-bracket-newline": "error", 17 | "array-bracket-spacing": [ 18 | "error", 19 | "never" 20 | ], 21 | "array-callback-return": "error", 22 | "array-element-newline": "off", 23 | "arrow-body-style": "error", 24 | "arrow-parens": "off", 25 | "arrow-spacing": [ 26 | "error", 27 | { 28 | "after": true, 29 | "before": true 30 | } 31 | ], 32 | "block-scoped-var": "error", 33 | "block-spacing": [ 34 | "error", 35 | "always" 36 | ], 37 | "brace-style": [ 38 | "error", 39 | "1tbs", 40 | { 41 | "allowSingleLine": true 42 | } 43 | ], 44 | "camelcase": "error", 45 | "capitalized-comments": [ 46 | "error", 47 | "always" 48 | ], 49 | "class-methods-use-this": "error", 50 | "comma-dangle": "off", 51 | "comma-spacing": "off", 52 | "comma-style": [ 53 | "error", 54 | "last" 55 | ], 56 | "complexity": "error", 57 | "computed-property-spacing": [ 58 | "error", 59 | "never" 60 | ], 61 | "consistent-return": "off", 62 | "consistent-this": "off", 63 | "curly": "off", 64 | "default-case": "off", 65 | "default-case-last": "error", 66 | "default-param-last": "error", 67 | "dot-location": [ 68 | "error", 69 | "property" 70 | ], 71 | "dot-notation": "error", 72 | "eol-last": "error", 73 | "eqeqeq": "off", 74 | "func-call-spacing": "error", 75 | "func-name-matching": "error", 76 | "func-names": "off", 77 | "func-style": [ 78 | "error", 79 | "declaration" 80 | ], 81 | "function-paren-newline": "error", 82 | "generator-star-spacing": "error", 83 | "grouped-accessor-pairs": "error", 84 | "guard-for-in": "error", 85 | "id-denylist": "error", 86 | "id-length": "off", 87 | "id-match": "error", 88 | "implicit-arrow-linebreak": [ 89 | "error", 90 | "beside" 91 | ], 92 | "indent": "off", 93 | "init-declarations": "off", 94 | "jsx-quotes": "error", 95 | "key-spacing": "off", 96 | "keyword-spacing": "off", 97 | "line-comment-position": "error", 98 | "linebreak-style": "off", 99 | "lines-around-comment": "error", 100 | "lines-between-class-members": "error", 101 | "max-classes-per-file": "error", 102 | "max-depth": "error", 103 | "max-len": "off", 104 | "max-lines": "off", 105 | "max-lines-per-function": "off", 106 | "max-nested-callbacks": "error", 107 | "max-params": "off", 108 | "max-statements": "off", 109 | "max-statements-per-line": "off", 110 | "multiline-comment-style": "error", 111 | "new-cap": "off", 112 | "new-parens": "error", 113 | "newline-per-chained-call": "off", 114 | "no-alert": "error", 115 | "no-array-constructor": "error", 116 | "no-await-in-loop": "off", 117 | "no-bitwise": "error", 118 | "no-caller": "error", 119 | "no-confusing-arrow": "error", 120 | "no-console": "off", 121 | "no-constructor-return": "error", 122 | "no-continue": "off", 123 | "no-div-regex": "error", 124 | "no-duplicate-imports": "error", 125 | "no-else-return": "error", 126 | "no-empty-function": "error", 127 | "no-eq-null": "off", 128 | "no-eval": "error", 129 | "no-extend-native": "error", 130 | "no-extra-bind": "error", 131 | "no-extra-label": "error", 132 | "no-extra-parens": "off", 133 | "no-floating-decimal": "error", 134 | "no-implicit-coercion": "error", 135 | "no-implicit-globals": "off", 136 | "no-implied-eval": "error", 137 | "no-inline-comments": "error", 138 | "no-invalid-this": "error", 139 | "no-iterator": "error", 140 | "no-label-var": "error", 141 | "no-labels": "error", 142 | "no-lone-blocks": "error", 143 | "no-lonely-if": "off", 144 | "no-loop-func": "error", 145 | "no-loss-of-precision": "error", 146 | "no-magic-numbers": "off", 147 | "no-mixed-operators": "error", 148 | "no-multi-assign": "error", 149 | "no-multi-spaces": "off", 150 | "no-multi-str": "error", 151 | "no-multiple-empty-lines": "error", 152 | "no-negated-condition": "off", 153 | "no-nested-ternary": "error", 154 | "no-new": "off", 155 | "no-new-func": "error", 156 | "no-new-object": "error", 157 | "no-new-wrappers": "error", 158 | "no-nonoctal-decimal-escape": "error", 159 | "no-octal-escape": "error", 160 | "no-param-reassign": "error", 161 | "no-plusplus": "off", 162 | "no-promise-executor-return": "error", 163 | "no-proto": "error", 164 | "no-restricted-exports": "error", 165 | "no-restricted-globals": "error", 166 | "no-restricted-imports": "error", 167 | "no-restricted-properties": "error", 168 | "no-restricted-syntax": "error", 169 | "no-return-assign": "error", 170 | "no-return-await": "error", 171 | "no-script-url": "error", 172 | "no-self-compare": "error", 173 | "no-sequences": "error", 174 | "no-shadow": "error", 175 | "no-tabs": "error", 176 | "no-template-curly-in-string": "error", 177 | "no-ternary": "off", 178 | "no-throw-literal": "error", 179 | "no-trailing-spaces": "error", 180 | "no-undef-init": "error", 181 | "no-undefined": "error", 182 | "no-underscore-dangle": "error", 183 | "no-unmodified-loop-condition": "error", 184 | "no-unneeded-ternary": "error", 185 | "no-unreachable-loop": "error", 186 | "no-unsafe-optional-chaining": "error", 187 | "no-unused-expressions": "error", 188 | "no-use-before-define": "off", 189 | "no-useless-backreference": "error", 190 | "no-useless-call": "error", 191 | "no-useless-computed-key": "error", 192 | "no-useless-concat": "off", 193 | "no-useless-constructor": "error", 194 | "no-useless-rename": "error", 195 | "no-useless-return": "error", 196 | "no-var": "error", 197 | "no-void": "error", 198 | "no-warning-comments": "error", 199 | "no-whitespace-before-property": "error", 200 | "nonblock-statement-body-position": "error", 201 | "object-curly-newline": "error", 202 | "object-curly-spacing": [ 203 | "error", 204 | "never" 205 | ], 206 | "object-shorthand": "off", 207 | "one-var": "off", 208 | "one-var-declaration-per-line": "off", 209 | "operator-assignment": "off", 210 | "operator-linebreak": "error", 211 | "padded-blocks": "off", 212 | "padding-line-between-statements": "error", 213 | "prefer-arrow-callback": "off", 214 | "prefer-const": "off", 215 | "prefer-destructuring": "off", 216 | "prefer-exponentiation-operator": "error", 217 | "prefer-named-capture-group": "off", 218 | "prefer-numeric-literals": "error", 219 | "prefer-object-spread": "error", 220 | "prefer-promise-reject-errors": "error", 221 | "prefer-regex-literals": "error", 222 | "prefer-rest-params": "error", 223 | "prefer-spread": "error", 224 | "prefer-template": "off", 225 | "quote-props": "off", 226 | "quotes": "off", 227 | "radix": "off", 228 | "require-atomic-updates": "off", 229 | "require-await": "error", 230 | "require-unicode-regexp": "off", 231 | "rest-spread-spacing": "error", 232 | "semi": "off", 233 | "semi-spacing": "off", 234 | "semi-style": [ 235 | "error", 236 | "last" 237 | ], 238 | "sort-imports": "error", 239 | "sort-keys": "off", 240 | "sort-vars": "error", 241 | "space-before-blocks": "off", 242 | "space-before-function-paren": "off", 243 | "space-in-parens": "off", 244 | "space-infix-ops": "off", 245 | "space-unary-ops": "error", 246 | "spaced-comment": [ 247 | "error", 248 | "never" 249 | ], 250 | "strict": [ 251 | "error", 252 | "never" 253 | ], 254 | "switch-colon-spacing": "error", 255 | "symbol-description": "error", 256 | "template-curly-spacing": [ 257 | "error", 258 | "never" 259 | ], 260 | "template-tag-spacing": "error", 261 | "unicode-bom": [ 262 | "error", 263 | "never" 264 | ], 265 | "vars-on-top": "error", 266 | "wrap-iife": [ 267 | "error", 268 | "any" 269 | ], 270 | "wrap-regex": "error", 271 | "yield-star-spacing": "error", 272 | "yoda": [ 273 | "error", 274 | "never" 275 | ] 276 | } 277 | }; 278 | -------------------------------------------------------------------------------- /renderer/img/card-text-strike-light.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /renderer/img/card-text-strike.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /renderer/img/done-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/renderer/img/done-icon.png -------------------------------------------------------------------------------- /renderer/img/downloading-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/renderer/img/downloading-icon.png -------------------------------------------------------------------------------- /renderer/img/icon-titlebar-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/renderer/img/icon-titlebar-dark.png -------------------------------------------------------------------------------- /renderer/img/icon-titlebar-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/renderer/img/icon-titlebar-light.png -------------------------------------------------------------------------------- /renderer/img/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/renderer/img/icon.icns -------------------------------------------------------------------------------- /renderer/img/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/renderer/img/icon.ico -------------------------------------------------------------------------------- /renderer/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/renderer/img/icon.png -------------------------------------------------------------------------------- /renderer/img/plain-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/renderer/img/plain-placeholder.png -------------------------------------------------------------------------------- /tests/Analytics.test.js: -------------------------------------------------------------------------------- 1 | const Analytics = require('../modules/Analytics'); 2 | const Utils = require("../modules/Utils"); 3 | const dotenv = require("dotenv"); 4 | const Sentry = require("@sentry/electron"); 5 | const path = require("path"); 6 | 7 | jest.mock('dotenv'); 8 | 9 | jest.mock('@sentry/electron', () => ({ 10 | init: jest.fn() 11 | })); 12 | 13 | beforeEach(() => { 14 | jest.clearAllMocks(); 15 | }); 16 | 17 | 18 | describe('init sentry', () => { 19 | it("loads dotenv with packaged path", () => { 20 | const dotenvMock = jest.spyOn(dotenv, 'config').mockImplementation(() => {}); 21 | const instance = instanceBuiler(true); 22 | instance.app.getAppPath = jest.fn(() => "app/path"); 23 | instance.initSentry(); 24 | expect(dotenvMock).toBeCalledTimes(1); 25 | }); 26 | it("loads dotenv with test path", () => { 27 | const dotenvMock = jest.spyOn(dotenv, 'config').mockImplementation(() => {}); 28 | const instance = instanceBuiler(false); 29 | instance.app.getAppPath = jest.fn(() => "app/path"); 30 | instance.initSentry(); 31 | expect(dotenvMock).toBeCalledTimes(1); 32 | }); 33 | it("inits sentry in dev mode", () => { 34 | process.argv = ["", "", "--dev"]; 35 | jest.spyOn(dotenv, 'config').mockImplementation(() => {}); 36 | const instance = instanceBuiler(); 37 | instance.initSentry(); 38 | expect(Sentry.init).toBeCalledTimes(1); 39 | expect(Sentry.init.mock.calls[0][0].environment).toBe("development"); 40 | }); 41 | it("inits sentry in prod mode", () => { 42 | process.argv = ["", ""]; 43 | jest.spyOn(dotenv, 'config').mockImplementation(() => {}); 44 | const instance = instanceBuiler(); 45 | instance.initSentry(); 46 | expect(Sentry.init).toBeCalledTimes(1); 47 | expect(Sentry.init.mock.calls[0][0].environment).toBe("production"); 48 | }); 49 | }); 50 | 51 | describe('sendReport', () => { 52 | it('returns the report id', () => { 53 | const testID = "test__id"; 54 | const randomIDSpy = jest.spyOn(Utils, 'getRandomID').mockReturnValueOnce(testID) 55 | const instance = new Analytics(); 56 | instance.sendReport(testID).then((data) => { 57 | expect(data).toBe(testID); 58 | }); 59 | randomIDSpy.mockRestore(); 60 | }); 61 | }); 62 | 63 | function instanceBuiler(packaged) { 64 | const app = { isPackaged: packaged, getVersion: jest.fn()} 65 | return new Analytics(app); 66 | } 67 | -------------------------------------------------------------------------------- /tests/BinaryUpdater.test.js: -------------------------------------------------------------------------------- 1 | const BinaryUpdater = require("../modules/BinaryUpdater"); 2 | const fs = require("fs"); 3 | const axios = require("axios"); 4 | const { PassThrough } = require('stream'); 5 | 6 | beforeEach(() => { 7 | jest.clearAllMocks(); 8 | console.error = jest.fn().mockImplementation(() => {}); 9 | console.log = jest.fn().mockImplementation(() => {}); 10 | }) 11 | 12 | describe("writeVersionInfo", () => { 13 | it('writes the version to a file', () => { 14 | jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(""); 15 | const instance = new BinaryUpdater({ ytdlVersion: "a/test/path" }); 16 | instance.writeVersionInfo("v2.0.0-test1"); 17 | expect(fs.promises.writeFile).toBeCalledTimes(1); 18 | expect(fs.promises.writeFile).toBeCalledWith("a/test/path", "{\"version\":\"v2.0.0-test1\",\"ytdlp\":true}"); 19 | }); 20 | }); 21 | 22 | describe("getLocalVersion", () => { 23 | it('returns null when when the file does not exist', () => { 24 | jest.spyOn(fs.promises, 'readFile').mockRejectedValue("ENOTFOUND"); 25 | const instance = new BinaryUpdater({ ytdlVersion: "a/test/path" }); 26 | return instance.getLocalVersion().then((data) => { 27 | expect(data).toBe(null); 28 | }); 29 | }); 30 | it('returns the version property from the json file', () => { 31 | jest.spyOn(fs.promises, 'readFile').mockResolvedValue("{\"version\": \"v2.0.0-test1\",\"ytdlp\":true}") 32 | jest.spyOn(fs.promises, 'access').mockResolvedValue(""); 33 | const instance = new BinaryUpdater({ ytdlVersion: "a/test/path" }); 34 | return instance.getLocalVersion().then((data) => { 35 | expect(data).toBe("v2.0.0-test1"); 36 | }); 37 | }); 38 | it('returns null when ytdlp is unset or false', () => { 39 | jest.spyOn(fs.promises, 'readFile').mockResolvedValue("{\"version\": \"v2.0.0-test1\"}") 40 | jest.spyOn(fs.promises, 'access').mockResolvedValue(""); 41 | const instance = new BinaryUpdater({ ytdlVersion: "a/test/path" }); 42 | return instance.getLocalVersion().then((data) => { 43 | expect(data).toBe(null); 44 | }); 45 | }); 46 | }); 47 | 48 | describe('getRemoteVersion', () => { 49 | it('returns a null array when not redirected', () => { 50 | const axiosGetSpy = jest.spyOn(axios, 'get').mockRejectedValue({response: {status: 200}}); 51 | const instance = new BinaryUpdater({platform: "win32"}); 52 | return instance.getRemoteVersion().then((data) => { 53 | expect(data).toEqual(null); 54 | expect(axiosGetSpy).toBeCalledTimes(1); 55 | }); 56 | }); 57 | it('returns a null array on error', () => { 58 | const axiosGetSpy = jest.spyOn(axios, 'get').mockRejectedValue({response: null}); 59 | const instance = new BinaryUpdater({platform: "darwin"}); 60 | return instance.getRemoteVersion().then((data) => { 61 | expect(data).toEqual(null); 62 | expect(axiosGetSpy).toBeCalledTimes(1); 63 | }); 64 | }); 65 | it('returns array with the link and the version', () => { 66 | const redirectURL = "https://github.com/yt-dlp/yt-dlp/releases/download/2021.10.10/yt-dlp.exe" 67 | const axiosGetSpy = jest.spyOn(axios, 'get').mockRejectedValue({ 68 | response: { 69 | status: 302, 70 | data: "", 71 | headers: { 72 | location: redirectURL 73 | } 74 | } 75 | }); 76 | const instance = new BinaryUpdater({platform: "win32"}); 77 | return instance.getRemoteVersion().then((data) => { 78 | expect(data).toEqual({ 79 | remoteUrl: redirectURL, 80 | remoteVersion: "2021.10.10" 81 | }); 82 | expect(axiosGetSpy).toBeCalledTimes(1); 83 | }); 84 | }); 85 | }); 86 | 87 | describe('checkUpdate', () => { 88 | it('does nothing when local and remote version are the same', () => { 89 | const win = {webContents: {send: jest.fn()}}; 90 | const instance = new BinaryUpdater({platform: "win32"}, win); 91 | const downloadUpdateSpy = jest.spyOn(instance, 'downloadUpdate'); 92 | instance.paths.setPermissions = jest.fn(); 93 | jest.spyOn(instance, 'getLocalVersion').mockResolvedValue("v2.0.0"); 94 | jest.spyOn(instance, 'getRemoteVersion').mockResolvedValue(["link", "v2.0.0"]); 95 | return instance.checkUpdate().then(() => { 96 | expect(downloadUpdateSpy).not.toBeCalled(); 97 | expect(instance.win.webContents.send).not.toBeCalled(); 98 | }); 99 | }); 100 | it('does nothing when remote version returned null', () => { 101 | const win = {webContents: {send: jest.fn()}}; 102 | const instance = new BinaryUpdater({platform: "win32"}, win); 103 | const downloadUpdateSpy = jest.spyOn(instance, 'downloadUpdate'); 104 | instance.paths.setPermissions = jest.fn(); 105 | jest.spyOn(instance, 'getLocalVersion').mockResolvedValue("v2.0.0"); 106 | jest.spyOn(instance, 'getRemoteVersion').mockResolvedValue([null, null]); 107 | return instance.checkUpdate().then(() => { 108 | expect(downloadUpdateSpy).not.toBeCalled(); 109 | expect(instance.win.webContents.send).not.toBeCalled(); 110 | }); 111 | }); 112 | it('downloads the latest remote version when local version is null', () => { 113 | const win = {webContents: {send: jest.fn()}}; 114 | const instance = new BinaryUpdater({platform: "win32"}, win); 115 | const downloadUpdateSpy = jest.spyOn(instance, 'downloadUpdate').mockResolvedValue(""); 116 | instance.paths.setPermissions = jest.fn(); 117 | jest.spyOn(instance, 'getLocalVersion').mockResolvedValue(null); 118 | jest.spyOn(instance, 'getRemoteVersion').mockResolvedValue(["link", "v2.0.0"]); 119 | return instance.checkUpdate().then(() => { 120 | expect(downloadUpdateSpy).toBeCalledTimes(1); 121 | expect(instance.win.webContents.send).toBeCalledTimes(1); 122 | }); 123 | }); 124 | it('downloads the latest remote version when local version is different', () => { 125 | const win = {webContents: {send: jest.fn()}}; 126 | const instance = new BinaryUpdater({platform: "win32", ytdl: "a/path/to"}, win); 127 | const downloadUpdateSpy = jest.spyOn(instance, 'downloadUpdate').mockResolvedValue(""); 128 | instance.paths.setPermissions = jest.fn(); 129 | jest.spyOn(instance, 'getLocalVersion').mockResolvedValue("2021.03.10"); 130 | jest.spyOn(instance, 'getRemoteVersion').mockResolvedValue({ remoteUrl: "link", remoteVersion: "2021.10.10" }); 131 | return instance.checkUpdate().then(() => { 132 | expect(downloadUpdateSpy).toBeCalledTimes(1); 133 | expect(instance.win.webContents.send).toBeCalledTimes(1); 134 | }); 135 | }); 136 | }); 137 | 138 | describe("downloadUpdate", () => { 139 | it('does not write version info and rejects on error', async () => { 140 | const mockReadable = new PassThrough(); 141 | const mockWriteable = new PassThrough(); 142 | jest.spyOn(fs, 'createWriteStream').mockReturnValueOnce(mockWriteable); 143 | jest.spyOn(axios, 'get').mockResolvedValue({ data: mockReadable, headers: { "content-length": 1200 } }); 144 | setTimeout(() => { 145 | mockWriteable.emit('error', "Test error"); 146 | }, 100); 147 | const instance = new BinaryUpdater({platform: "win32"}); 148 | const versionInfoSpy = jest.spyOn(instance, 'writeVersionInfo').mockImplementation(() => {}); 149 | const actualPromise = instance.downloadUpdate("link", "v2.0.0"); 150 | await expect(actualPromise).rejects.toEqual("Test error"); 151 | expect(versionInfoSpy).not.toBeCalled(); 152 | }); 153 | it('writes version info and resolves when successful', async () => { 154 | const mockReadable = new PassThrough(); 155 | const mockWriteable = new PassThrough(); 156 | jest.spyOn(fs, 'createWriteStream').mockReturnValueOnce(mockWriteable); 157 | jest.spyOn(axios, 'get').mockResolvedValue({ data: mockReadable, headers: { "content-length": 1200 } }); 158 | setTimeout(() => { 159 | mockWriteable.emit('close'); 160 | }, 100); 161 | const instance = new BinaryUpdater({platform: "win32"}); 162 | const versionInfoSpy = jest.spyOn(instance, 'writeVersionInfo').mockImplementation(() => {}); 163 | const actualPromise = instance.downloadUpdate("link", "v2.0.0"); 164 | await expect(actualPromise).resolves.toBeTruthy(); 165 | expect(versionInfoSpy).toBeCalledWith("v2.0.0"); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /tests/ClipboardWatcher.test.js: -------------------------------------------------------------------------------- 1 | const ClipboardWatcher = require("../modules/ClipboardWatcher"); 2 | const { clipboard } = require('electron'); 3 | 4 | 5 | jest.mock('electron', () => ({ 6 | clipboard: { 7 | readText: jest.fn() 8 | } 9 | })); 10 | 11 | beforeEach(() => { 12 | jest.clearAllMocks(); 13 | jest.useFakeTimers(); 14 | }); 15 | 16 | describe('poll', () => { 17 | it('reads the text from the clipboard', () => { 18 | clipboard.readText.mockReturnValue("https://i.am.a.url.com"); 19 | const instance = instanceBuilder(true); 20 | const resetMock = jest.spyOn(instance, "resetPlaceholder").mockImplementation(() => {}); 21 | instance.poll(); 22 | expect(clipboard.readText).toBeCalledTimes(1); 23 | expect(resetMock).toBeCalledTimes(0); 24 | }); 25 | it('resets when it is not a URL', () => { 26 | clipboard.readText.mockReturnValue("im not a url"); 27 | const instance = instanceBuilder(true); 28 | const resetMock = jest.spyOn(instance, "resetPlaceholder").mockImplementation(() => {}); 29 | instance.poll(); 30 | expect(resetMock).toBeCalledTimes(1); 31 | }); 32 | it('resets when the copied text is null', () => { 33 | clipboard.readText.mockReturnValue(null); 34 | const instance = instanceBuilder(true); 35 | const resetMock = jest.spyOn(instance, "resetPlaceholder").mockImplementation(() => {}); 36 | instance.poll(); 37 | expect(resetMock).toBeCalledTimes(1); 38 | }); 39 | it('sends the URL to renderer if it is one', () => { 40 | clipboard.readText.mockReturnValue("https://i.am.a.url.com"); 41 | const instance = instanceBuilder(true); 42 | const resetMock = jest.spyOn(instance, "resetPlaceholder").mockImplementation(() => {}); 43 | instance.poll(); 44 | expect(instance.win.webContents.send).toBeCalledWith("updateLinkPlaceholder", {text: "https://i.am.a.url.com", copied: true}) 45 | expect(instance.win.webContents.send).toBeCalledTimes(1); 46 | expect(resetMock).toBeCalledTimes(0); 47 | }); 48 | it('doesnt poll when it is disabled in settings', () => { 49 | clipboard.readText.mockReturnValue("https://i.am.a.url.com"); 50 | const instance = instanceBuilder(false); 51 | const resetMock = jest.spyOn(instance, "resetPlaceholder").mockImplementation(() => {}); 52 | instance.poll(); 53 | expect(clipboard.readText).toBeCalledTimes(0); 54 | expect(resetMock).toBeCalledTimes(0); 55 | expect(instance.win.webContents.send).toBeCalledTimes(0); 56 | }); 57 | it('does nothing when the previous copied text matches the new one', () => { 58 | clipboard.readText.mockReturnValue("https://i.am.a.url.com"); 59 | const instance = instanceBuilder(true); 60 | const resetMock = jest.spyOn(instance, "resetPlaceholder").mockImplementation(() => {}); 61 | instance.poll(); 62 | instance.poll(); 63 | expect(clipboard.readText).toBeCalledTimes(2); 64 | expect(resetMock).toBeCalledTimes(0); 65 | expect(instance.win.webContents.send).toBeCalledTimes(1); 66 | }); 67 | }); 68 | 69 | describe('resetPlaceholder', () => { 70 | it('sends the standard placeholder to renderer', () => { 71 | const instance = instanceBuilder(true); 72 | instance.resetPlaceholder(); 73 | expect(instance.win.webContents.send).toBeCalledTimes(1); 74 | expect(instance.win.webContents.send.mock.calls[0][1].copied).toBeFalsy(); 75 | }); 76 | }); 77 | 78 | describe('startPolling', () => { 79 | it('Polls one time', () => { 80 | const instance = instanceBuilder(true); 81 | const pollMock = jest.spyOn(instance, "poll").mockImplementation(() => {}); 82 | instance.startPolling() 83 | expect(pollMock).toBeCalledTimes(1); 84 | }); 85 | it('Starts a polling loop', () => { 86 | const loops = 5; 87 | const instance = instanceBuilder(true); 88 | const pollMock = jest.spyOn(instance, "poll").mockImplementation(() => {}); 89 | instance.startPolling() 90 | jest.advanceTimersByTime(loops * 1000); 91 | expect(pollMock).toBeCalledTimes(loops + 1); 92 | }); 93 | }); 94 | 95 | function instanceBuilder(enabled) { 96 | const env = {settings: {autoFillClipboard: enabled}}; 97 | const win = {webContents: {send: jest.fn()}}; 98 | return new ClipboardWatcher(win, env); 99 | } 100 | -------------------------------------------------------------------------------- /tests/DetectPython.test.js: -------------------------------------------------------------------------------- 1 | const execa = require('execa'); 2 | const DetectPython = require("../modules/DetectPython"); 3 | 4 | jest.mock('execa'); 5 | 6 | beforeEach(() => { 7 | jest.clearAllMocks(); 8 | jest.doMock('execa', () => { 9 | const originalModule = jest.requireActual('execa') 10 | return { 11 | __esModule: true, 12 | ...originalModule, 13 | execa: jest.fn() 14 | } 15 | }); 16 | }); 17 | 18 | describe('test', () => { 19 | it('returns true when successful', () => { 20 | const instance = new DetectPython(); 21 | execa.mockResolvedValue("test output"); 22 | expect(instance.test()).resolves.toBeTruthy(); 23 | }); 24 | it('returns false on error', () => { 25 | const instance = new DetectPython(); 26 | execa.mockImplementation(() => { 27 | throw new Error("ENOENT"); 28 | }); 29 | expect(instance.test()).resolves.toBeFalsy(); 30 | }); 31 | }); 32 | 33 | describe('detect', () => { 34 | it("Returns python if all tests fail", () => { 35 | const instance = new DetectPython(); 36 | jest.spyOn(instance, 'test').mockResolvedValue(false); 37 | expect(instance.detect()).resolves.toEqual("python"); 38 | }); 39 | it("Returns the command of the first completed test", async () => { 40 | const commands = ["python", "python3", "python2"]; 41 | for(const command of commands) { 42 | const instance = new DetectPython(); 43 | jest.spyOn(instance, 'test').mockImplementation((cmd) => { 44 | return cmd === command; 45 | }) 46 | const result = await instance.detect(); 47 | expect(result).toEqual(command); 48 | } 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /tests/DoneAction.test.js: -------------------------------------------------------------------------------- 1 | const execa = require('execa'); 2 | const DoneAction = require("../modules/DoneAction"); 3 | 4 | jest.mock('execa'); 5 | 6 | const platforms = ["win32", "linux", "darwin"]; 7 | const actions = ["Lock", "Sleep", "Shutdown"]; 8 | const actionLength = [4, 3, 3]; 9 | 10 | beforeEach(() => { 11 | jest.clearAllMocks(); 12 | jest.doMock('execa', () => { 13 | const originalModule = jest.requireActual('execa') 14 | return { 15 | __esModule: true, 16 | ...originalModule, 17 | execa: jest.fn() 18 | } 19 | }); 20 | }); 21 | 22 | describe('execute', () => { 23 | it('executes the chosen action', () => { 24 | for(const platform of platforms) { 25 | Object.defineProperty(process, "platform", { 26 | value: platform 27 | }); 28 | const instance = new DoneAction(); 29 | execa.mockResolvedValue(""); 30 | instance.executeAction(actions[platforms.indexOf(platform)]); 31 | expect(execa.mock.calls[platforms.indexOf(platform)]).toBeTruthy(); 32 | } 33 | expect(execa).toBeCalledTimes(platforms.length); 34 | }); 35 | it('does nothing on Do nothing', () => { 36 | const instance = new DoneAction(); 37 | execa.mockResolvedValue(""); 38 | instance.executeAction("Do nothing"); 39 | expect(execa).toBeCalledTimes(0); 40 | }); 41 | it('exits on Close app', () => { 42 | const instance = new DoneAction(); 43 | execa.mockResolvedValue(""); 44 | process.exit = jest.fn(); 45 | instance.executeAction("Close app"); 46 | expect(process.exit).toBeCalledTimes(1); 47 | }); 48 | it('logs an error', async () => { 49 | const instance = new DoneAction(); 50 | execa.mockRejectedValue(""); 51 | console.error = jest.fn().mockImplementation(() => {}); 52 | await instance.executeAction("Sleep"); 53 | expect(console.error).toBeCalledTimes(1); 54 | }); 55 | }); 56 | 57 | describe('get', () => { 58 | it("Returns the actions for the platform", () => { 59 | for(const platform of platforms) { 60 | Object.defineProperty(process, "platform", { 61 | value: platform 62 | }); 63 | const instance = new DoneAction(); 64 | const actions = instance.getActions(); 65 | expect(actions.length).toEqual(actionLength[platforms.indexOf(platform)]); 66 | } 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /tests/ErrorHandler.test.js: -------------------------------------------------------------------------------- 1 | const ErrorHandler = require("../modules/exceptions/ErrorHandler"); 2 | const Utils = require("../modules/Utils"); 3 | const Sentry = require("@sentry/electron"); 4 | 5 | jest.mock('@sentry/electron', () => ({ 6 | Severity: { Error: "error", Warn: "warn" }, 7 | captureMessage: jest.fn() 8 | })); 9 | 10 | beforeEach(() => { 11 | Sentry.captureMessage.mockImplementation((code, scope) => scope({setLevel: jest.fn(), setContext: jest.fn(), setTag: jest.fn()})); 12 | jest.clearAllMocks(); 13 | }); 14 | 15 | describe('reportError', () => { 16 | it('calls sendReport with the appropriate error', async () => { 17 | const instance = await instanceBuilder(); 18 | instance.unhandledErrors.push({ 19 | identifier: "test__identifier", 20 | unexpected: true, 21 | error: { 22 | code: "Unhandled exception", 23 | description: "test__unhandled", 24 | } 25 | }); 26 | instance.queryManager.getVideo.mockReturnValue({ url: "http://a.url" }); 27 | instance.env.analytics.sendReport.mockResolvedValue("test__id"); 28 | await expect(instance.reportError({type: "single", quality: "best", identifier: "test__identifier"})).resolves.toBeTruthy(); 29 | expect(instance.env.analytics.sendReport).toBeCalledTimes(1); 30 | }); 31 | }); 32 | 33 | describe('raiseError', () => { 34 | it('does not raise an error if the video type is playlist', async () => { 35 | console.error = jest.fn(() => { 36 | }); 37 | const instance = await instanceBuilder(); 38 | instance.queryManager.getVideo.mockReturnValue({type: "playlist", identifier: "test__identifier"}); 39 | instance.raiseError(instance.errorDefinitions[1], "test__identifier"); 40 | expect(instance.win.webContents.send).not.toBeCalled(); 41 | expect(instance.queryManager.onError).not.toBeCalled(); 42 | }); 43 | it('sends the error to the renderer process', async () => { 44 | const instance = await instanceBuilder(); 45 | instance.queryManager.getVideo.mockReturnValue({type: "single", identifier: "test__identifier"}); 46 | instance.raiseError(instance.errorDefinitions[1], "test__identifier"); 47 | expect(instance.win.webContents.send).toBeCalledTimes(1); 48 | }); 49 | it('calls onError to mark the video as errored', async () => { 50 | const instance = await instanceBuilder(); 51 | instance.queryManager.getVideo.mockReturnValue({type: "single", identifier: "test__identifier"}); 52 | instance.raiseError(instance.errorDefinitions[1], "test__identifier"); 53 | expect(instance.queryManager.onError).toBeCalledWith("test__identifier"); 54 | }); 55 | }); 56 | 57 | describe('raiseUnhandledError', () => { 58 | it('does not raise an error if the video type is playlist', async () => { 59 | const instance = await instanceBuilder(); 60 | instance.queryManager.getVideo.mockReturnValue({type: "playlist", identifier: "test__identifier"}); 61 | instance.raiseUnhandledError("test__unhandled", "test__unhandled_desc", "test__identifier"); 62 | expect(instance.win.webContents.send).not.toBeCalled(); 63 | expect(instance.queryManager.onError).not.toBeCalled(); 64 | }); 65 | it('adds the error to the unhandled error list', async () => { 66 | const randomIDSpy = jest.spyOn(Utils, 'getRandomID').mockReturnValueOnce("12345678"); 67 | const instance = await instanceBuilder(); 68 | instance.queryManager.getVideo.mockReturnValue({type: "single", identifier: "test__identifier"}); 69 | instance.raiseUnhandledError("test__unhandled", "test__unhandled_desc", "test__identifier"); 70 | expect(instance.unhandledErrors).toContainEqual({ 71 | identifier: "test__identifier", 72 | unexpected: true, 73 | error_id: "12345678", 74 | error: { 75 | code: "test__unhandled", 76 | description: "test__unhandled_desc", 77 | } 78 | }); 79 | randomIDSpy.mockRestore(); 80 | }); 81 | it('reports the error to sentry', async () => { 82 | const instance = await instanceBuilder(); 83 | instance.queryManager.getVideo.mockReturnValue({type: "single", identifier: "test__identifier"}); 84 | instance.raiseUnhandledError("test__unhandled", "test__unhandled_desc", "test__identifier"); 85 | expect(Sentry.captureMessage).toBeCalledTimes(1); 86 | }); 87 | it('sends the error to the renderer process', async () => { 88 | const instance = await instanceBuilder(); 89 | instance.queryManager.getVideo.mockReturnValue({type: "single", identifier: "test__identifier"}); 90 | instance.raiseUnhandledError("test__unhandled", "test__unhandled_desc", "test__identifier"); 91 | expect(instance.win.webContents.send).toBeCalledTimes(1); 92 | }); 93 | it('calls onError to mark the video as errored', async () => { 94 | const instance = await instanceBuilder(); 95 | instance.queryManager.getVideo.mockReturnValue({type: "single", identifier: "test__identifier"}); 96 | instance.raiseUnhandledError("test__unhandled", "test__unhandled_desc", "test__identifier"); 97 | expect(instance.queryManager.onError).toBeCalledWith("test__identifier"); 98 | }); 99 | }); 100 | 101 | describe('checkError', () => { 102 | it('Raises an error if the message matches a trigger', async () => { 103 | const instance = await instanceBuilder(); 104 | instance.raiseError = jest.fn(); 105 | instance.checkError("ERROR: is not a valid URL", "test__identifier"); 106 | expect(instance.raiseError).toBeCalledWith({ 107 | code: "URL not supported", 108 | description: "This URL is currently not supported by YTDL.", 109 | trigger: "is not a valid URL" 110 | }, "test__identifier"); 111 | }); 112 | it('Raises no ffmpeg error when in dev mode', async () => { 113 | process.argv = ["", "", "--dev"]; //Set dev mode enabled 114 | const instance = await instanceBuilder(); 115 | instance.raiseError = jest.fn(); 116 | instance.checkError("ERROR: ffmpeg or avconv not found", "test__identifier"); 117 | expect(instance.raiseError).not.toBeCalled(); 118 | }); 119 | it('Raises an unhandled error if no trigger matches', async () => { 120 | const instance = await instanceBuilder(); 121 | instance.raiseError = jest.fn(); 122 | instance.raiseUnhandledError = jest.fn(); 123 | instance.checkError("ERROR: this is some weird kinda error"); 124 | expect(instance.raiseUnhandledError).toBeCalledTimes(1); 125 | expect(instance.raiseError).not.toBeCalled(); 126 | }); 127 | }); 128 | 129 | async function instanceBuilder() { 130 | const env = { 131 | analytics: { 132 | sendReport: jest.fn() 133 | }, 134 | settings: { 135 | testSetting: true 136 | }, 137 | paths: { 138 | app: { 139 | isPackaged: false 140 | }, 141 | packedPrefix: "a/prefix", 142 | testPath: "a/path/yes.txt" 143 | } 144 | }; 145 | const win = { 146 | webContents: { 147 | send: jest.fn() 148 | } 149 | }; 150 | const queryManager = { 151 | getVideo: jest.fn(), 152 | onError: jest.fn() 153 | }; 154 | const errorHandler = new ErrorHandler(win, queryManager, env); 155 | errorHandler.errorDefinitions = await errorHandler.loadErrorDefinitions(); 156 | return errorHandler; 157 | } 158 | -------------------------------------------------------------------------------- /tests/FfmpegUpdater.test.js: -------------------------------------------------------------------------------- 1 | const FfmpegUpdater = require("../modules/FfmpegUpdater"); 2 | const fs = require("fs"); 3 | const axios = require("axios"); 4 | const { PassThrough } = require('stream'); 5 | const os = require('os'); 6 | 7 | beforeEach(() => { 8 | jest.clearAllMocks(); 9 | console.error = jest.fn().mockImplementation(() => {}); 10 | console.log = jest.fn().mockImplementation(() => {}); 11 | }) 12 | 13 | describe("writeVersionInfo", () => { 14 | it('writes the version to a file', () => { 15 | jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(""); 16 | const instance = new FfmpegUpdater({ ffmpegVersion: "a/test/path" }); 17 | instance.writeVersionInfo("v2.0.0-test1"); 18 | expect(fs.promises.writeFile).toBeCalledTimes(1); 19 | expect(fs.promises.writeFile).toBeCalledWith("a/test/path", "{\"version\":\"v2.0.0-test1\"}"); 20 | }); 21 | }); 22 | 23 | describe("getLocalVersion", () => { 24 | it('returns null when when the file does not exist', () => { 25 | jest.spyOn(fs.promises, 'readFile').mockRejectedValue("ENOTFOUND"); 26 | const instance = new FfmpegUpdater({ ytdlVersion: "a/test/path" }); 27 | return instance.getLocalVersion().then((data) => { 28 | expect(data).toBe(null); 29 | }); 30 | }); 31 | it('returns the version property from the json file', () => { 32 | jest.spyOn(fs.promises, 'readFile').mockResolvedValue("{\"version\": \"v2.0.0-test1\"}") 33 | jest.spyOn(fs.promises, 'access').mockResolvedValue(""); 34 | const instance = new FfmpegUpdater({ ffmpegVersion: "a/test/path", ffmpeg: "ffmpeg/path" }); 35 | return instance.getLocalVersion().then((data) => { 36 | expect(data).toBe("v2.0.0-test1"); 37 | }); 38 | }); 39 | it('returns null when ffmpeg is unset or false', () => { 40 | jest.spyOn(fs.promises, 'readFile').mockResolvedValue("{\"version\": \"v2.0.0-test1\"}") 41 | jest.spyOn(fs.promises, 'access').mockResolvedValue(""); 42 | const instance = new FfmpegUpdater({ ffmpegVersion: "a/test/path" }); 43 | return instance.getLocalVersion().then((data) => { 44 | expect(data).toBe(null); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('getRemoteVersion', () => { 50 | it('returns null on error', () => { 51 | const axiosGetSpy = jest.spyOn(axios, 'get').mockRejectedValue({response: null}); 52 | jest.spyOn(os, 'arch').mockReturnValue('x64'); 53 | const instance = new FfmpegUpdater({platform: "darwin"}); 54 | return instance.getRemoteVersion().then((data) => { 55 | expect(data).toEqual(null); 56 | expect(axiosGetSpy).toBeCalledTimes(1); 57 | }); 58 | }); 59 | it('returns object with the links and the version', async () => { 60 | const axiosGetSpy = jest.spyOn(axios, 'get').mockResolvedValue({ 61 | data: { version: "4.2.1", bin: { "windows-32": { ffmpeg: "ffmpeg/link", ffprobe: "ffprobe/link" } } }, 62 | }); 63 | jest.spyOn(os, 'arch').mockReturnValue('ia32'); 64 | Object.defineProperty(process, "platform", { 65 | value: "win32" 66 | }); 67 | const instance = new FfmpegUpdater({platform: "win32"}); 68 | const result = await instance.getRemoteVersion(); 69 | expect(result).toEqual({ 70 | remoteFfmpegUrl: "ffmpeg/link", 71 | remoteFfprobeUrl: "ffprobe/link", 72 | remoteVersion: "4.2.1" 73 | }); 74 | expect(axiosGetSpy).toBeCalledTimes(1); 75 | }); 76 | }); 77 | 78 | describe('checkUpdate', () => { 79 | it('does nothing when local and remote version are the same', () => { 80 | const win = {webContents: {send: jest.fn()}}; 81 | const instance = new FfmpegUpdater({platform: "win32"}, win); 82 | const downloadUpdateSpy = jest.spyOn(instance, 'downloadUpdate'); 83 | jest.spyOn(instance, 'getLocalVersion').mockResolvedValue("v2.0.0"); 84 | jest.spyOn(instance, 'getRemoteVersion').mockResolvedValue(["link", "v2.0.0"]); 85 | return instance.checkUpdate().then(() => { 86 | expect(downloadUpdateSpy).not.toBeCalled(); 87 | expect(instance.win.webContents.send).not.toBeCalled(); 88 | }); 89 | }); 90 | it('does nothing when remote version returned null', () => { 91 | const win = {webContents: {send: jest.fn()}}; 92 | const instance = new FfmpegUpdater({platform: "win32"}, win); 93 | const downloadUpdateSpy = jest.spyOn(instance, 'downloadUpdate'); 94 | jest.spyOn(instance, 'getLocalVersion').mockResolvedValue("v2.0.0"); 95 | jest.spyOn(instance, 'getRemoteVersion').mockResolvedValue([null, null]); 96 | return instance.checkUpdate().then(() => { 97 | expect(downloadUpdateSpy).not.toBeCalled(); 98 | expect(instance.win.webContents.send).not.toBeCalled(); 99 | }); 100 | }); 101 | it('downloads the latest remote version when local version is null', () => { 102 | const win = {webContents: {send: jest.fn()}}; 103 | const instance = new FfmpegUpdater({platform: "win32"}, win); 104 | const downloadUpdateSpy = jest.spyOn(instance, 'downloadUpdate').mockResolvedValue(""); 105 | jest.spyOn(instance, 'getLocalVersion').mockResolvedValue(null); 106 | jest.spyOn(instance, 'getRemoteVersion').mockResolvedValue(["link", "v2.0.0"]); 107 | return instance.checkUpdate().then(() => { 108 | expect(downloadUpdateSpy).toBeCalledTimes(2); 109 | expect(instance.win.webContents.send).toBeCalledTimes(2); 110 | }); 111 | }); 112 | it('downloads the latest remote version when local version is different', () => { 113 | const win = {webContents: {send: jest.fn()}}; 114 | const instance = new FfmpegUpdater({platform: "win32", ytdl: "a/path/to"}, win); 115 | const downloadUpdateSpy = jest.spyOn(instance, 'downloadUpdate').mockResolvedValue(""); 116 | jest.spyOn(instance, 'getLocalVersion').mockResolvedValue("2021.03.10"); 117 | jest.spyOn(instance, 'getRemoteVersion').mockResolvedValue({ remoteUrl: "link", remoteVersion: "2021.10.10" }); 118 | return instance.checkUpdate().then(() => { 119 | expect(downloadUpdateSpy).toBeCalledTimes(2); 120 | expect(instance.win.webContents.send).toBeCalledTimes(2); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /tests/Filepaths.test.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const Filepaths = require("../modules/Filepaths"); 3 | const mkdirp = require("mkdirp"); 4 | const path = require("path"); 5 | 6 | jest.mock('mkdirp'); 7 | 8 | beforeEach(() => { 9 | jest.clearAllMocks(); 10 | fs.chmod = jest.fn(); 11 | jest.doMock('mkdirp', () => { 12 | const originalModule = jest.requireActual('mkdirp') 13 | return { 14 | __esModule: true, 15 | ...originalModule, 16 | mkdirp: jest.fn() 17 | } 18 | }); 19 | }); 20 | 21 | describe('set executable permissions', () => { 22 | it('sets the chmod of ytdl and ffmpeg to 0o755', () => { 23 | const instance = instanceBuilder(true); 24 | instance.ffmpeg = "ffmpeg/path/"; 25 | const ytdlp = "yt-dlp.exe"; 26 | const taskList = "taskList"; 27 | fs.readdirSync = jest.fn().mockReturnValue([ytdlp, taskList]); 28 | instance.setPermissions(); 29 | expect(fs.chmod).toBeCalledTimes(1); 30 | expect(fs.chmod.mock.calls[0]).toContain(path.join(instance.ffmpeg, ytdlp)); 31 | expect(fs.chmod.mock.calls[0]).toContain(493); 32 | }); 33 | }); 34 | 35 | describe('generate filepaths', () => { 36 | it('sets the unpacked and packed prefix', async () => { 37 | const platforms = ["win32", "linux", "darwin"]; 38 | for(const platform of platforms) { 39 | const instance = instanceBuilder(true); 40 | instance.platform = platform; 41 | instance.setPermissions = jest.fn(); 42 | instance.createFolder = jest.fn().mockResolvedValue(undefined); 43 | await instance.generateFilepaths(); 44 | expect(instance.packedPrefix).toBeTruthy(); 45 | expect(instance.unpackedPrefix).toBeTruthy(); 46 | if(platform === "linux") expect(instance.persistentPath).toBeTruthy(); 47 | } 48 | }); 49 | it('does not add prefixes when not packaged', async () => { 50 | const platforms = ["win32", "linux", "darwin"]; 51 | for(const platform of platforms) { 52 | const instance = instanceBuilder(false); 53 | instance.platform = platform; 54 | instance.setPermissions = jest.fn(); 55 | instance.createFolder = jest.fn().mockResolvedValue(undefined); 56 | const joinSpy = jest.spyOn(path, 'join').mockReturnValue("path"); 57 | jest.spyOn(instance, 'removeLeftOver').mockImplementation(() => Promise.resolve()); 58 | await instance.generateFilepaths(); 59 | if(platform === "linux" || platform === "win32") expect(joinSpy).toBeCalledTimes(1); 60 | else expect(joinSpy).not.toBeCalled(); 61 | joinSpy.mockRestore(); 62 | } 63 | }); 64 | it('calls create home folder on linux', async () => { 65 | const instance = instanceBuilder(true); 66 | instance.platform = "linux"; 67 | instance.setPermissions = jest.fn(); 68 | instance.createFolder = jest.fn().mockResolvedValue(undefined); 69 | await instance.generateFilepaths(); 70 | expect(instance.createFolder).toBeCalledTimes(1); 71 | }); 72 | it('calls create portable folder when this version is used', async () => { 73 | const instance = instanceBuilder(true, true); 74 | instance.platform = "win32portable"; 75 | process.env.PORTABLE_EXECUTABLE_DIR = "test/dir/for/portable/executable"; 76 | instance.createPortableFolder = jest.fn().mockResolvedValue(undefined); 77 | await instance.generateFilepaths(); 78 | expect(instance.createPortableFolder).toBeCalledTimes(1); 79 | }) 80 | it('sets permissions on darwin and linux', async () => { 81 | const platforms = ["win32", "linux", "darwin"]; 82 | for(const platform of platforms) { 83 | const instance = instanceBuilder(true); 84 | instance.platform = platform; 85 | instance.setPermissions = jest.fn(); 86 | instance.createFolder = jest.fn().mockResolvedValue(undefined); 87 | await instance.generateFilepaths(); 88 | if(platform === "win32") expect(instance.setPermissions).not.toBeCalled(); 89 | else expect(instance.setPermissions).toBeCalledTimes(platform.indexOf(platform) + 1); 90 | } 91 | }); 92 | }); 93 | 94 | describe('removeLeftOver', () => { 95 | it('removes youtube-dl.exe on win32', async () => { 96 | Object.defineProperty(process, "platform", { 97 | value: "win32" 98 | }); 99 | const instance = instanceBuilder(true); 100 | instance.ffmpeg = "ffmpeg/path"; 101 | fs.existsSync = jest.fn().mockImplementation(() => true); 102 | fs.promises.unlink = jest.fn().mockImplementation(() => Promise.resolve()); 103 | 104 | await instance.removeLeftOver(); 105 | 106 | expect(fs.promises.unlink).toBeCalledTimes(1); 107 | expect(fs.promises.unlink).toBeCalledWith(path.join("ffmpeg/path", "youtube-dl.exe")); 108 | }); 109 | it('removes youtube-dl-unix on other systems', async () => { 110 | Object.defineProperty(process, "platform", { 111 | value: "darwin" 112 | }); 113 | const instance = instanceBuilder(true); 114 | instance.ffmpeg = "ffmpeg/path"; 115 | fs.existsSync = jest.fn().mockImplementation(() => true); 116 | fs.promises.unlink = jest.fn().mockImplementation(() => Promise.resolve()); 117 | 118 | await instance.removeLeftOver(); 119 | 120 | expect(fs.promises.unlink).toBeCalledTimes(1); 121 | expect(fs.promises.unlink).toBeCalledWith(path.join("ffmpeg/path", "youtube-dl-unix")); 122 | }); 123 | }); 124 | 125 | function instanceBuilder(packaged, portable) { 126 | const app = { 127 | isPackaged: packaged, 128 | getPath: jest.fn(() => "path/to/downloads"), 129 | getAppPath: jest.fn(() => portable ? "\\AppData\\Local\\Temp\\" : "path/to/application") 130 | } 131 | return new Filepaths(app); 132 | } 133 | -------------------------------------------------------------------------------- /tests/Format.test.js: -------------------------------------------------------------------------------- 1 | const Format = require("../modules/types/Format"); 2 | 3 | describe('Get display name from format', () => { 4 | it('gets the display name with fps', () => { 5 | const format = new Format(1080, 59, null, null); 6 | expect(format.getDisplayName()).toBe("1080p59"); 7 | }); 8 | it('gets the display name without fps', () => { 9 | const format = new Format(3260, null, null, null); 10 | expect(format.getDisplayName()).toBe("3260p"); 11 | }); 12 | }); 13 | 14 | describe('get display name from parameters', () => { 15 | it('gets the display name with fps', () => { 16 | expect(Format.getDisplayName(1080, 60)).toBe("1080p60"); 17 | }); 18 | it('gets the display name without fps', () => { 19 | expect(Format.getDisplayName(1080, null)).toBe("1080p"); 20 | }); 21 | }); 22 | 23 | describe("Get format from display name", () => { 24 | it('returns a format with fps', () => { 25 | expect(Format.getFromDisplayName("1080p60")).toEqual(new Format("1080", "60")) 26 | }); 27 | it('returns a format without fps', () => { 28 | expect(Format.getFromDisplayName("720p")).toEqual(new Format("720", null)) 29 | }); 30 | }); 31 | 32 | describe('Serialization', () => { 33 | it('returns a serialized object', () => { 34 | const format = new Format(1440, 30, 999, "400 MB"); 35 | expect(format.serialize()).toEqual({ 36 | height: 1440, 37 | fps: 30, 38 | filesize: 999, 39 | encodings: [], 40 | filesize_label: "400 MB", 41 | display_name: "1440p30" 42 | }); 43 | }) 44 | it('adds the display name to the serialized object', () => { 45 | const format = new Format(1440, 30, 999, "400 MB"); 46 | const getDisplayNameSpy = jest.spyOn(format, 'getDisplayName'); 47 | format.serialize(); 48 | expect(getDisplayNameSpy).toBeCalledTimes(1); 49 | }); 50 | }); 51 | 52 | describe('Deserialization', () => { 53 | it('returns a format', () => { 54 | expect(Format.deserialize("1080p60")).toBeInstanceOf(Format); 55 | }); 56 | it('is a wrapper function for getFromDisplayName', () => { 57 | const getFromDisplayNameSpy = jest.spyOn(Format, 'getFromDisplayName'); 58 | Format.deserialize("1080p60"); 59 | expect(getFromDisplayNameSpy).toBeCalledTimes(1); 60 | }); 61 | }); 62 | 63 | -------------------------------------------------------------------------------- /tests/InfoQuery.test.js: -------------------------------------------------------------------------------- 1 | const InfoQuery = require("../modules/info/InfoQuery"); 2 | 3 | describe('Connect the InfoQuery', () => { 4 | beforeEach(() => { 5 | jest.clearAllMocks(); 6 | }); 7 | it('Checks the error when applicable', async () => { 8 | const [env, instance] = instanceBuilder(); 9 | env.metadataLimiter.schedule.mockRejectedValue({ stderr: "test-error"}); 10 | const result = instance.connect(); 11 | await result; 12 | expect(env.errorHandler.checkError).toBeCalledWith("test-error", "test__id"); 13 | }); 14 | it('Returns null on error', async () => { 15 | const [env, instance] = instanceBuilder(); 16 | env.metadataLimiter.schedule.mockRejectedValue({ stderr: "test-error"}); 17 | const result = instance.connect(); 18 | await expect(result).resolves.toBe(null); 19 | }); 20 | it('Schedules the query', async () => { 21 | const [env, instance] = instanceBuilder(); 22 | const jsonString = "{\"test\": \"data\"}"; 23 | env.metadataLimiter.schedule.mockResolvedValue(jsonString); 24 | const result = instance.connect(); 25 | await result; 26 | expect(env.metadataLimiter.schedule).toBeCalledTimes(1); 27 | }); 28 | it('Returns the parsed query data', async () => { 29 | const [env, instance] = instanceBuilder(); 30 | const jsonString = "{\"test\": \"data\"}"; 31 | env.metadataLimiter.schedule.mockResolvedValue(jsonString); 32 | const result = instance.connect(); 33 | await expect(result).resolves.toMatchObject(JSON.parse(jsonString)) 34 | }); 35 | }); 36 | 37 | function instanceBuilder() { 38 | const env = { 39 | metadataLimiter: { 40 | schedule: jest.fn() 41 | }, 42 | errorHandler: { 43 | checkError: jest.fn() 44 | } 45 | }; 46 | return [env, new InfoQuery("http://url.link", "test__id", env)]; 47 | } 48 | 49 | -------------------------------------------------------------------------------- /tests/InfoQueryList.test.js: -------------------------------------------------------------------------------- 1 | const InfoQueryList = require("../modules/info/InfoQueryList"); 2 | const video = require('../modules/types/Video'); 3 | 4 | describe("create video", () => { 5 | beforeEach(() => { 6 | jest.clearAllMocks(); 7 | }); 8 | it('returns a video', () => { 9 | const instance = instanceBuilder(); 10 | jest.spyOn(video.prototype, 'setMetadata').mockImplementation(() => {}); 11 | const result = instance.createVideo({test: "data"}, "https://test.url"); 12 | expect(result).toBeInstanceOf(video); 13 | }); 14 | it('sets the metadata', () => { 15 | const instance = instanceBuilder(); 16 | const metadataSpy = jest.spyOn(video.prototype, 'setMetadata').mockImplementation(() => {}); 17 | instance.createVideo({test: "data"}, "https://test.url"); 18 | expect(metadataSpy).toBeCalledTimes(1); 19 | }); 20 | it('uses data.entries[0] when appropriate', () => { 21 | const instance = instanceBuilder(); 22 | const metadataSpy = jest.spyOn(video.prototype, 'setMetadata').mockImplementation(() => {}); 23 | instance.createVideo({entries: [{formats: "insert_formats_here"}]}, "https://test.url"); 24 | expect(metadataSpy).toBeCalledWith({formats: "insert_formats_here"}); 25 | }); 26 | }); 27 | 28 | function instanceBuilder() { 29 | const progressbar = { 30 | video: { identifier: "test__id" }, 31 | updatePlaylist: jest.fn() 32 | } 33 | return new InfoQueryList("test__query", "test__env", progressbar); 34 | } 35 | -------------------------------------------------------------------------------- /tests/Logger.test.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs").promises; 2 | const {dialog} = require("electron"); 3 | const Logger = require("../modules/persistence/Logger"); 4 | 5 | const downloadPath = "a/download/path"; 6 | const savePath = "path/to/log"; 7 | 8 | jest.mock('electron', () => ({ 9 | dialog: { 10 | showSaveDialog: jest.fn().mockResolvedValue({canceled: false, filePath: "path/to/log"}), 11 | }, 12 | })); 13 | 14 | beforeEach(() => { 15 | jest.clearAllMocks(); 16 | fs.writeFile = jest.fn().mockResolvedValue(""); 17 | console.log = jest.fn().mockImplementation(() => {}); 18 | }); 19 | 20 | describe("log", () => { 21 | it("adds the line to the log", () => { 22 | const instance = instanceBuilder(); 23 | instance.logs = { 24 | "identifier": ["first line"] 25 | } 26 | instance.log("identifier", "second line"); 27 | expect(instance.logs.identifier).toEqual(["first line", "second line"]); 28 | }); 29 | it("creates a new log if it doesn't exist", () => { 30 | const instance = instanceBuilder(); 31 | instance.log("identifier", "first line"); 32 | expect(instance.logs["identifier"]).toEqual(["first line"]); 33 | }); 34 | it("Replaces done and killed with readable values", () => { 35 | const values = [{val: "done", res: "Download finished"}, {val: "killed", res: "Download stopped"}]; 36 | for(const value of values) { 37 | const instance = instanceBuilder(); 38 | instance.log("identifier", value.val); 39 | expect(instance.logs["identifier"]).toEqual([value.res]); 40 | } 41 | }); 42 | it("trims all new lines from the log line", () => { 43 | const instance = instanceBuilder(); 44 | instance.log("identifier", "\nfirst line \na line break\n"); 45 | expect(instance.logs["identifier"]).toEqual(["first line a line break"]); 46 | }); 47 | it("ignores the call if the line is empty or falsy", () => { 48 | const instance = instanceBuilder(); 49 | const values = ["", null, undefined]; 50 | for(const value of values) { 51 | instance.log("identifier", value); 52 | expect(instance.logs["identifier"]).toBeUndefined(); 53 | } 54 | }) 55 | }); 56 | 57 | describe("get", () => { 58 | it("returns the log associated with the identifier", () => { 59 | const instance = instanceBuilder(); 60 | const log = ["first line", "second line"]; 61 | const identifier = "identifier"; 62 | instance.logs[identifier] = log; 63 | expect(instance.get(identifier)).toEqual(log); 64 | }); 65 | }); 66 | 67 | describe("clear", () => { 68 | it("removes the log associated with the identifier", () => { 69 | const instance = instanceBuilder(); 70 | const identifier = "identifier"; 71 | instance.logs[identifier] = ["first line"]; 72 | instance.clear(identifier); 73 | expect(instance.logs[identifier]).toBeUndefined(); 74 | }); 75 | }); 76 | 77 | describe("save", () => { 78 | it("saves the log", async () => { 79 | const instance = instanceBuilder(); 80 | const log = ["first line", "second line"]; 81 | const identifier = "identifier"; 82 | 83 | instance.logs[identifier] = log; 84 | await instance.save(identifier); 85 | expect(fs.writeFile).toBeCalledTimes(1); 86 | expect(fs.writeFile).toHaveBeenCalledWith(savePath, "first line\nsecond line\n"); 87 | }); 88 | it("asks the user where to save", async () => { 89 | const instance = instanceBuilder(); 90 | const log = ["first line", "second line"]; 91 | const identifier = "identifier"; 92 | 93 | instance.logs[identifier] = log; 94 | await instance.save(identifier); 95 | expect(dialog.showSaveDialog).toBeCalledTimes(1); 96 | }); 97 | it("doesn't save when the user cancels", async () => { 98 | dialog.showSaveDialog = jest.fn().mockResolvedValue({canceled: true, filePath: "path/to/log"}) 99 | const instance = instanceBuilder(); 100 | const log = ["first line", "second line"]; 101 | const identifier = "identifier"; 102 | instance.logs[identifier] = log; 103 | await instance.save(identifier); 104 | expect(fs.writeFile).not.toBeCalled(); 105 | }) 106 | }) 107 | 108 | function instanceBuilder() { 109 | const environment = { 110 | win: "i'm a window", 111 | settings: { 112 | downloadPath: downloadPath 113 | } 114 | } 115 | return new Logger(environment); 116 | } 117 | -------------------------------------------------------------------------------- /tests/Query.test.js: -------------------------------------------------------------------------------- 1 | const UserAgent = require('user-agents'); 2 | const Query = require("../modules/types/Query"); 3 | const execa = require('execa'); 4 | const { PassThrough } = require('stream'); 5 | 6 | jest.mock('user-agents'); 7 | jest.mock('execa'); 8 | 9 | beforeEach(() => { 10 | jest.clearAllMocks(); 11 | jest.doMock('execa', () => { 12 | const originalModule = jest.requireActual('execa') 13 | return { 14 | __esModule: true, 15 | ...originalModule, 16 | execa: jest.fn() 17 | } 18 | }); 19 | }); 20 | 21 | describe('ytdl Query', () => { 22 | beforeEach(() => { 23 | execa.mockResolvedValue({stdout: "fake-data"}); 24 | }); 25 | it('adds a random user agent when this setting is enabled', () => { 26 | UserAgent.prototype.toString = jest.fn().mockReturnValue("agent"); 27 | const errorHandlerMock = jest.fn(); 28 | const instance = instanceBuilder("spoof", null, errorHandlerMock, "python"); 29 | return instance.start("https://url.link", [], null).then(() => { 30 | expect(UserAgent.prototype.toString).toBeCalledTimes(1); 31 | expect(execa.mock.calls[0][1]).toContain("--user-agent"); 32 | expect(execa.mock.calls[0][1]).toContain("agent"); 33 | }); 34 | }); 35 | it('adds an empty user agent when this setting is enabled', () => { 36 | UserAgent.prototype.toString = jest.fn().mockReturnValue("agent"); 37 | const errorHandlerMock = jest.fn(); 38 | const instance = instanceBuilder("empty", null, errorHandlerMock, "python"); 39 | return instance.start("https://url.link", [], null).then(() => { 40 | expect(UserAgent.prototype.toString).toBeCalledTimes(0); 41 | expect(execa.mock.calls[0][1]).toContain("--user-agent"); 42 | expect(execa.mock.calls[0][1]).toContain("''"); 43 | }); 44 | }); 45 | it('adds the proxy when one is set', () => { 46 | const errorHandlerMock = jest.fn(); 47 | const instance = instanceBuilder("default", "a/path/to/cookies.txt", errorHandlerMock, "python", "https://iama.proxy"); 48 | return instance.start("https://url.link", [], null).then(() => { 49 | expect(execa.mock.calls[0][1]).toContain("--proxy"); 50 | expect(execa.mock.calls[0][1]).toContain("https://iama.proxy"); 51 | }); 52 | }) 53 | it('does not add a proxy when none are set', () => { 54 | const errorHandlerMock = jest.fn(); 55 | const instance = instanceBuilder("default", "a/path/to/cookies.txt", errorHandlerMock, "python", ""); 56 | return instance.start("https://url.link", [], null).then(() => { 57 | expect(execa.mock.calls[0][1]).not.toContain("--proxy"); 58 | }); 59 | }) 60 | it('adds the cookies argument when specified in settings', () => { 61 | const errorHandlerMock = jest.fn(); 62 | const instance = instanceBuilder("default", "a/path/to/cookies.txt", errorHandlerMock, "python"); 63 | return instance.start("https://url.link", [], null).then(() => { 64 | expect(execa.mock.calls[0][1]).toContain("--cookies"); 65 | expect(execa.mock.calls[0][1]).toContain("a/path/to/cookies.txt"); 66 | }); 67 | }); 68 | it('uses the detected python command', () => { 69 | const errorHandlerMock = jest.fn(); 70 | const instance = instanceBuilder("default", null, errorHandlerMock, "python3"); 71 | return instance.start("https://url.link", [], null).then(() => { 72 | expect(execa.mock.calls[0][0]).toEqual("python3"); 73 | expect(execa.mock.calls[0][1][0]).toEqual("a/path/to/ytdl"); 74 | }); 75 | }); 76 | it('adds the url as final argument', () => { 77 | const errorHandlerMock = jest.fn(); 78 | const instance = instanceBuilder("default", null, errorHandlerMock, "python"); 79 | return instance.start("https://url.link", [], null).then(() => { 80 | expect(execa.mock.calls[0][1][execa.mock.calls[0][1].length - 1]).toContain("https://url.link"); 81 | }); 82 | }) 83 | it('adds the no-cache-dir as argument', () => { 84 | const errorHandlerMock = jest.fn(); 85 | const instance = instanceBuilder("default", null, errorHandlerMock, "python"); 86 | return instance.start("https://url.link", [], null).then(() => { 87 | expect(execa.mock.calls[0][1]).toContain("--no-cache-dir"); 88 | }); 89 | }); 90 | }) 91 | 92 | describe('Query with live callback', () => { 93 | it('Stops with return value killed when stop() is called', async () => { 94 | const [stdout, stderr, mock] = execaMockBuilder(true); 95 | execa.mockReturnValue(mock) 96 | const errorHandlerMock = jest.fn(); 97 | const callbackMock = jest.fn(); 98 | const instance = instanceBuilder("default", null, errorHandlerMock, "python"); 99 | const result = instance.start("https://url.link", [], callbackMock); 100 | setTimeout(() => { 101 | instance.stop(); 102 | }, 100); 103 | await expect(result).resolves.toEqual("killed"); 104 | expect(callbackMock).toBeCalledWith("killed"); 105 | }); 106 | it('Checks the error when stderr gets written to', async () => { 107 | const [stdout, stderr, mock] = execaMockBuilder(false); 108 | execa.mockReturnValue(mock) 109 | console.error = jest.fn(); 110 | const errorHandlerMock = jest.fn(); 111 | const callbackMock = jest.fn(); 112 | const instance = instanceBuilder("default", null, errorHandlerMock, "python"); 113 | const result = instance.start("https://url.link", [], callbackMock); 114 | setTimeout(() => { 115 | stderr.emit("data", "test-error"); 116 | }, 100); 117 | setTimeout(() => { 118 | stdout.emit("close"); 119 | }, 100); 120 | await result; 121 | expect(errorHandlerMock).toBeCalledWith("test-error", "test__id"); 122 | }); 123 | it('Resolves "done" when query was successful', async () => { 124 | const [stdout, stderr, mock] = execaMockBuilder(false); 125 | execa.mockReturnValue(mock) 126 | const callbackMock = jest.fn(); 127 | const instance = instanceBuilder("default", null, jest.fn(), "python"); 128 | const result = instance.start("https://url.link", [], callbackMock); 129 | setTimeout(() => { 130 | stdout.emit("close"); 131 | }, 100); 132 | await expect(result).resolves.toEqual("done"); 133 | expect(callbackMock).toBeCalledWith("done"); 134 | }); 135 | it('Sends live stdout to the callback', async () => { 136 | const [stdout, stderr, mock] = execaMockBuilder(false); 137 | execa.mockReturnValue(mock); 138 | const callbackMock = jest.fn(); 139 | const instance = instanceBuilder("default", null, jest.fn(), "python"); 140 | const result = instance.start("https://url.link", [], callbackMock); 141 | setTimeout(() => { 142 | stdout.emit("data", "test-data"); 143 | stdout.emit("data", "more-test-data"); 144 | stdout.emit("close"); 145 | }, 100); 146 | await result; 147 | expect(callbackMock).toBeCalledWith("test-data"); 148 | expect(callbackMock).toBeCalledWith("more-test-data"); 149 | }); 150 | }); 151 | 152 | describe('Query without callback', () => { 153 | it('Returns the data from the execa call', async () => { 154 | execa.mockResolvedValue({stdout: "fake-data"}); 155 | const errorHandlerMock = jest.fn(); 156 | const instance = instanceBuilder("default", null, errorHandlerMock, "python"); 157 | const result = instance.start("https://url.link", [], null) 158 | await expect(result).resolves.toEqual("fake-data"); 159 | }); 160 | it('Returns a stringified empty object on error', async () => { 161 | execa.mockResolvedValue(null); 162 | const errorHandlerMock = jest.fn(); 163 | const instance = instanceBuilder("default", null, errorHandlerMock, "python"); 164 | const result = instance.start("https://url.link", [], null) 165 | await expect(result).resolves.toEqual("{}"); 166 | }); 167 | it('Checks the error on error', () => { 168 | execa.mockResolvedValue(null); 169 | const errorHandlerMock = jest.fn(); 170 | const instance = instanceBuilder("default", null, errorHandlerMock, "python"); 171 | return instance.start("https://url.link", [], null).then(() => { 172 | expect(errorHandlerMock).toBeCalled(); 173 | }); 174 | }); 175 | }) 176 | 177 | function execaMockBuilder(killed) { 178 | const stdout = new PassThrough(); 179 | const stderr = new PassThrough(); 180 | const mock = {stdout: stdout, stderr: stderr, cancel: jest.fn(() => { stdout.emit("close") }), killed: killed} 181 | return [stdout, stderr, mock]; 182 | } 183 | 184 | function instanceBuilder(userAgent, cookiePath, errorHandlerMock, pythonCommand, proxy) { 185 | return new Query({pythonCommand: pythonCommand, errorHandler: {checkError: errorHandlerMock, raiseUnhandledError: errorHandlerMock}, paths: {ytdl: "a/path/to/ytdl"}, settings: {cookiePath: cookiePath, userAgent, proxy: proxy}}, "test__id"); 186 | } 187 | -------------------------------------------------------------------------------- /tests/Settings.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises; 2 | const os = require('os'); 3 | const { globalShortcut, clipboard } = require('electron'); 4 | 5 | jest.mock('electron', () => ({ 6 | clipboard: { 7 | readText: jest.fn() 8 | }, 9 | globalShortcut: { 10 | unregisterAll: jest.fn(), 11 | isRegistered: jest.fn(), 12 | register: jest.fn(), 13 | } 14 | })); 15 | 16 | const Settings = require('../modules/persistence/Settings'); 17 | const env = {version: '2.0.0-test1', app: {getPath: jest.fn().mockReturnValue('test/path')}}; 18 | const defaultSettingsInstance = new Settings({settings: 'tests/test-settings.json'}, env, 'none', 'none', 'test/path', '', '', true, false, true, 'spoof', false, false, true, '%(title).200s-(%(height)sp%(fps).0d).%(ext)s', '%(title).200s-(%(height)sp%(fps).0d).%(ext)s', 'click', '49', 8, true, 'video', true, 'C:\\Users\\user\\cookies.txt', false, '', '', 'https://sponsor.ajay.app', true, false, false, false, true, 'dark'); 19 | const defaultSettings = { 20 | outputFormat: 'none', 21 | audioOutputFormat: 'none', 22 | downloadPath: 'test/path', 23 | proxy: '', 24 | rateLimit: '', 25 | autoFillClipboard: true, 26 | noPlaylist: false, 27 | globalShortcut: true, 28 | userAgent: 'spoof', 29 | validateCertificate: false, 30 | enableEncoding: false, 31 | taskList: true, 32 | nameFormat: '%(title).200s-(%(height)sp%(fps).0d).%(ext)s', 33 | nameFormatMode: '%(title).200s-(%(height)sp%(fps).0d).%(ext)s', 34 | sizeMode: 'click', 35 | splitMode: '49', 36 | maxConcurrent: 8, 37 | defaultConcurrent: 8, 38 | updateBinary: true, 39 | downloadType: 'video', 40 | updateApplication: true, 41 | statSend: false, 42 | sponsorblockMark: '', 43 | sponsorblockRemove: '', 44 | sponsorblockApi: 'https://sponsor.ajay.app', 45 | downloadMetadata: true, 46 | downloadJsonMetadata: false, 47 | downloadThumbnail: false, 48 | keepUnmerged: false, 49 | calculateTotalSize: true, 50 | theme: 'dark', 51 | version: '2.0.0-test1', 52 | }; 53 | 54 | describe('Load settings from file', () => { 55 | beforeEach(() => { 56 | jest.clearAllMocks(); 57 | fs.writeFile = jest.fn().mockResolvedValue(''); 58 | console.log = jest.fn().mockImplementation(() => { 59 | }); 60 | }); 61 | it('reads the specified file', () => { 62 | const readFileSpy = jest.spyOn(fs, 'readFile'); 63 | return Settings.loadFromFile({settings: 'tests/test-settings.json'}, env).then((data) => { 64 | expect(readFileSpy).toBeCalledTimes(1); 65 | }); 66 | }); 67 | it('returns a settings instance', () => { 68 | return Settings.loadFromFile({settings: 'tests/test-settings.json'}, env).then((data) => { 69 | expect(data).toBeInstanceOf(Settings); 70 | }); 71 | }); 72 | it('returns a settings instance with the right values', () => { 73 | return Settings.loadFromFile({settings: 'tests/test-settings.json'}, env).then((data) => { 74 | expect(data).toMatchObject(defaultSettingsInstance); 75 | }); 76 | }); 77 | }); 78 | 79 | 80 | describe('Create new settings file on error', () => { 81 | beforeEach(() => { 82 | jest.clearAllMocks(); 83 | os.cpus = jest.fn().mockImplementation(() => { 84 | return new Array(16); 85 | }); 86 | fs.writeFile = jest.fn().mockResolvedValue(''); 87 | console.log = jest.fn().mockImplementation(() => { 88 | }); 89 | }); 90 | it('uses the path defined in paths', () => { 91 | return Settings.loadFromFile({settings: 'tests/non-existent-file.json'}, env).then(() => { 92 | expect(fs.writeFile.mock.calls[0]).toContain('tests/non-existent-file.json'); 93 | }); 94 | }); 95 | it('writes the new settings file', () => { 96 | return Settings.loadFromFile({settings: 'tests/non-existent-file.json'}, env).then(() => { 97 | expect(fs.writeFile).toHaveBeenCalledTimes(1); 98 | }); 99 | }); 100 | it('writes the given settings', () => { 101 | return Settings.loadFromFile({settings: 'tests/non-existent-file.json'}, env).then(() => { 102 | expect(fs.writeFile.mock.calls[0]).toContainEqual(JSON.stringify(defaultSettings)); 103 | }); 104 | }); 105 | }); 106 | 107 | describe('Update settings to file', () => { 108 | beforeEach(() => { 109 | jest.clearAllMocks(); 110 | fs.writeFile = jest.fn().mockResolvedValue(''); 111 | env.appUpdater = {setUpdateSetting: jest.fn()}; 112 | env.changeMaxConcurrent = jest.fn(); 113 | console.log = jest.fn().mockImplementation(() => { 114 | }); 115 | }); 116 | it('writes the updated file', () => { 117 | return Settings.loadFromFile({settings: 'tests/test-settings.json'}, env).then(data => { 118 | delete data.cookiePath; 119 | data.update(JSON.parse(JSON.stringify(defaultSettings))); 120 | expect(fs.writeFile).toBeCalledTimes(1); 121 | expect(fs.writeFile.mock.calls[0]).toContainEqual(JSON.stringify(defaultSettings)); 122 | }); 123 | }); 124 | it('updates the maxConcurrent value when it changes', () => { 125 | const changedDefaultSettings = JSON.parse(JSON.stringify(defaultSettings)); 126 | changedDefaultSettings.maxConcurrent = 4; 127 | 128 | return Settings.loadFromFile({settings: 'tests/test-settings.json'}, env).then(data => { 129 | data.update(changedDefaultSettings); 130 | expect(env.changeMaxConcurrent).toBeCalledTimes(1); 131 | }); 132 | }); 133 | }); 134 | 135 | -------------------------------------------------------------------------------- /tests/TaskList.test.js: -------------------------------------------------------------------------------- 1 | const TaskList = require('../modules/persistence/TaskList') 2 | const fs = require('fs').promises; 3 | 4 | beforeEach(() => { 5 | jest.clearAllMocks(); 6 | fs.writeFile = jest.fn().mockResolvedValue(""); 7 | fs.readFile = jest.fn().mockResolvedValue('["url1", "url2"]'); 8 | console.log = jest.fn().mockImplementation(() => {}); 9 | }); 10 | 11 | describe("save", () => { 12 | it("writes the tasklist to a file", () => { 13 | const instance = instanceBuilder() 14 | instance.save() 15 | expect(instance.manager.getTaskList).toBeCalledTimes(1) 16 | expect(fs.writeFile).toBeCalledTimes(1) 17 | }) 18 | }) 19 | 20 | describe("load", () => { 21 | it("reads the tasklist from a file", () => { 22 | const instance = instanceBuilder() 23 | instance.load() 24 | expect(fs.readFile).toBeCalledTimes(1) 25 | }) 26 | it("shows a restore toast", async () => { 27 | const instance = instanceBuilder() 28 | await instance.load() 29 | expect(instance.manager.window.webContents.send).toBeCalledTimes(1) 30 | }) 31 | it("logs to console when the file does not exist", async () => { 32 | const instance = instanceBuilder() 33 | fs.readFile = jest.fn().mockRejectedValue("") 34 | await instance.load() 35 | expect(console.log).toBeCalledTimes(1) 36 | }) 37 | it("logs to console when the file is empty", async () => { 38 | const instance = instanceBuilder() 39 | fs.readFile = jest.fn().mockResolvedValue("[]") 40 | await instance.load() 41 | expect(console.log).toBeCalledTimes(1) 42 | }) 43 | }) 44 | 45 | describe("restore", () => { 46 | it("loads the task list into query manager", async () => { 47 | const instance = instanceBuilder() 48 | await instance.restore() 49 | expect(instance.manager.loadTaskList).toBeCalledTimes(1) 50 | }) 51 | }) 52 | 53 | function instanceBuilder() { 54 | const paths = { taskList: "path/to/task/list" } 55 | const manager = { window: { webContents: { send: jest.fn() } }, getTaskList: jest.fn().mockResolvedValue(["url1", "url2"]), loadTaskList: jest.fn().mockResolvedValue("") } 56 | return new TaskList(paths, manager) 57 | } 58 | -------------------------------------------------------------------------------- /tests/Video.test.js: -------------------------------------------------------------------------------- 1 | const Video = require("../modules/types/Video"); 2 | const Format = require("../modules/types/Format"); 3 | 4 | describe('select highest quality', () => { 5 | it('Sorts the formats high to low', () => { 6 | const instance = instanceBuilder(); 7 | instance.selectHighestQuality(); 8 | expect(instance.formats[0].height).toEqual("1080"); 9 | expect(instance.formats[0].fps).toEqual("60"); 10 | }); 11 | it('Returns the index of the highest format', () => { 12 | const instance = instanceBuilder(); 13 | const result = instance.selectHighestQuality(); 14 | expect(result).toBe(0); 15 | }); 16 | }); 17 | 18 | describe('Get video format from label', () => { 19 | it('Returns the format matching the label', () => { 20 | const instance = instanceBuilder(); 21 | Format.prototype.getDisplayName = jest.fn(() => "1080p60"); 22 | const result = instance.getFormatFromLabel("1080p60"); 23 | expect(result).toEqual(instance.formats[0]); 24 | }); 25 | }); 26 | 27 | describe('Get filename', () => { 28 | it('Returns undefined when the video has no metadata', () => { 29 | const instance = instanceBuilder(); 30 | instance.hasMetadata = false; 31 | const result = instance.getFilename(); 32 | expect(result).toBeFalsy(); 33 | }); 34 | it('Returns only the title with (p) when there are no formats', () => { 35 | const instance = instanceBuilder(); 36 | instance.hasMetadata = true; 37 | instance.formats = []; 38 | instance.title = "test__title"; 39 | const result = instance.getFilename(); 40 | expect(result).toBe("test__title-(p)"); 41 | }); 42 | it('Returns the filename as ytdl outputs it', () => { 43 | const instance = instanceBuilder(); 44 | instance.hasMetadata = true; 45 | instance.title = "test__title*"; 46 | instance.selected_format_index = 0; 47 | const result = instance.getFilename(); 48 | expect(result).toBe("test__title_-(144p12)"); 49 | }); 50 | it('Clips the title length to 200', () => { 51 | const instance = instanceBuilder(); 52 | instance.hasMetadata = true; 53 | for(let i=0;i<20;i++) { 54 | instance.title += "test__title*"; 55 | } 56 | instance.selected_format_index = 0; 57 | const result = instance.getFilename(); 58 | expect(result.length).toBeLessThanOrEqual(215); 59 | }) 60 | }); 61 | 62 | function instanceBuilder(type) { 63 | const env = { 64 | paths: { 65 | downloadPath: "test__downloadpath" 66 | }, 67 | settings: { 68 | nameFormatMode: "%(title).200s-(%(height)sp%(fps).0d).%(ext)s" 69 | } 70 | }; 71 | const video = new Video("http://test.url", type, env); 72 | let formats = []; 73 | const displayNames = ["144p12", "1080p", "480p", "480p30", "480p29", "1080p60"]; 74 | for (const name of displayNames) { 75 | formats.push(Format.getFromDisplayName(name)); 76 | } 77 | video.formats = formats; 78 | return video; 79 | } 80 | -------------------------------------------------------------------------------- /tests/iso-test.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "iso": "tg", 4 | "name": "Tajik" 5 | }, 6 | { 7 | "iso": "nl", 8 | "name": "Dutch" 9 | }, 10 | { 11 | "iso": "es", 12 | "name": "Spanish" 13 | }, 14 | { 15 | "iso": "az", 16 | "name": "Azerbaijani" 17 | }, 18 | { 19 | "iso": "zh-Hant", 20 | "name": "Chinese (Traditional)" 21 | }, 22 | { 23 | "iso": "de", 24 | "name": "German" 25 | }, 26 | { 27 | "iso": "bg", 28 | "name": "Bulgarian" 29 | }, 30 | { 31 | "iso": "gu", 32 | "name": "Gujarati" 33 | }, 34 | { 35 | "iso": "yo", 36 | "name": "Yoruba" 37 | }, 38 | { 39 | "iso": "sw", 40 | "name": "Swahili" 41 | }, 42 | { 43 | "iso": "cy", 44 | "name": "Welsh" 45 | }, 46 | { 47 | "iso": "ht", 48 | "name": "Haitian" 49 | }, 50 | { 51 | "iso": "sq", 52 | "name": "Albanian" 53 | }, 54 | { 55 | "iso": "hu", 56 | "name": "Hungarian" 57 | }, 58 | { 59 | "iso": "mn", 60 | "name": "Mongolian" 61 | }, 62 | { 63 | "iso": "bs", 64 | "name": "Bosnian" 65 | }, 66 | { 67 | "iso": "zh-Hans", 68 | "name": "Chinese (Simplified)" 69 | }, 70 | { 71 | "iso": "lo", 72 | "name": "Lao" 73 | }, 74 | { 75 | "iso": "st", 76 | "name": "Sotho" 77 | }, 78 | { 79 | "iso": "kn", 80 | "name": "Kannada" 81 | }, 82 | { 83 | "iso": "la", 84 | "name": "Latin" 85 | }, 86 | { 87 | "iso": "hi", 88 | "name": "Hindi" 89 | }, 90 | { 91 | "iso": "pl", 92 | "name": "Polish" 93 | }, 94 | { 95 | "iso": "ug", 96 | "name": "Uighur" 97 | }, 98 | { 99 | "iso": "jv", 100 | "name": "Javanese" 101 | }, 102 | { 103 | "iso": "ga", 104 | "name": "Irish" 105 | }, 106 | { 107 | "iso": "fi", 108 | "name": "Finnish" 109 | }, 110 | { 111 | "iso": "ne", 112 | "name": "Nepali" 113 | }, 114 | { 115 | "iso": "tr", 116 | "name": "Turkish" 117 | }, 118 | { 119 | "iso": "id", 120 | "name": "Indonesian" 121 | }, 122 | { 123 | "iso": "en", 124 | "name": "English" 125 | }, 126 | { 127 | "iso": "pa", 128 | "name": "Panjabi" 129 | }, 130 | { 131 | "iso": "ca", 132 | "name": "Catalan" 133 | }, 134 | { 135 | "iso": "it", 136 | "name": "Italian" 137 | }, 138 | { 139 | "iso": "lv", 140 | "name": "Latvian" 141 | }, 142 | { 143 | "iso": "mr", 144 | "name": "Marathi" 145 | }, 146 | { 147 | "iso": "ka", 148 | "name": "Georgian" 149 | }, 150 | { 151 | "iso": "ceb", 152 | "name": "Cebuano" 153 | }, 154 | { 155 | "iso": "eu", 156 | "name": "Basque" 157 | }, 158 | { 159 | "iso": "te", 160 | "name": "Telugu" 161 | }, 162 | { 163 | "iso": "ta", 164 | "name": "Tamil" 165 | }, 166 | { 167 | "iso": "ig", 168 | "name": "Igbo" 169 | }, 170 | { 171 | "iso": "mi", 172 | "name": "Maori" 173 | }, 174 | { 175 | "iso": "fil", 176 | "name": "Filipino" 177 | }, 178 | { 179 | "iso": "or", 180 | "name": "Oriya" 181 | }, 182 | { 183 | "iso": "hy", 184 | "name": "Armenian" 185 | }, 186 | { 187 | "iso": "iw", 188 | "name": "Hebrew" 189 | }, 190 | { 191 | "iso": "el", 192 | "name": "Greek" 193 | }, 194 | { 195 | "iso": "eo", 196 | "name": "Esperanto" 197 | }, 198 | { 199 | "iso": "sd", 200 | "name": "Sindhi" 201 | }, 202 | { 203 | "iso": "zu", 204 | "name": "Zulu" 205 | }, 206 | { 207 | "iso": "af", 208 | "name": "Afrikaans" 209 | }, 210 | { 211 | "iso": "mk", 212 | "name": "Macedonian" 213 | }, 214 | { 215 | "iso": "ro", 216 | "name": "Romanian" 217 | }, 218 | { 219 | "iso": "ku", 220 | "name": "Kurdish" 221 | }, 222 | { 223 | "iso": "fr", 224 | "name": "French" 225 | }, 226 | { 227 | "iso": "mg", 228 | "name": "Malagasy" 229 | }, 230 | { 231 | "iso": "ja", 232 | "name": "Japanese" 233 | }, 234 | { 235 | "iso": "vi", 236 | "name": "Vietnamese" 237 | }, 238 | { 239 | "iso": "hmn", 240 | "name": "Hmong" 241 | }, 242 | { 243 | "iso": "fy", 244 | "name": "Western Frisian" 245 | }, 246 | { 247 | "iso": "no", 248 | "name": "Norwegian" 249 | }, 250 | { 251 | "iso": "sm", 252 | "name": "Samoan" 253 | }, 254 | { 255 | "iso": "pt", 256 | "name": "Portuguese" 257 | }, 258 | { 259 | "iso": "co", 260 | "name": "Corsican" 261 | }, 262 | { 263 | "iso": "ha", 264 | "name": "Hausa" 265 | }, 266 | { 267 | "iso": "ru", 268 | "name": "Russian" 269 | }, 270 | { 271 | "iso": "ar", 272 | "name": "Arabic" 273 | }, 274 | { 275 | "iso": "lt", 276 | "name": "Lithuanian" 277 | }, 278 | { 279 | "iso": "haw", 280 | "name": "Hawaiian" 281 | }, 282 | { 283 | "iso": "gd", 284 | "name": "Gaelic" 285 | }, 286 | { 287 | "iso": "be", 288 | "name": "Belarusian" 289 | }, 290 | { 291 | "iso": "sr", 292 | "name": "Serbian" 293 | }, 294 | { 295 | "iso": "si", 296 | "name": "Sinhala" 297 | }, 298 | { 299 | "iso": "km", 300 | "name": "Central Khmer" 301 | }, 302 | { 303 | "iso": "gl", 304 | "name": "Galician" 305 | }, 306 | { 307 | "iso": "xh", 308 | "name": "Xhosa" 309 | }, 310 | { 311 | "iso": "ny", 312 | "name": "Chichewa" 313 | }, 314 | { 315 | "iso": "mt", 316 | "name": "Maltese" 317 | }, 318 | { 319 | "iso": "ky", 320 | "name": "Kirghiz" 321 | }, 322 | { 323 | "iso": "sn", 324 | "name": "Shona" 325 | }, 326 | { 327 | "iso": "ps", 328 | "name": "Pushto" 329 | }, 330 | { 331 | "iso": "rw", 332 | "name": "Kinyarwanda" 333 | }, 334 | { 335 | "iso": "cs", 336 | "name": "Czech" 337 | }, 338 | { 339 | "iso": "am", 340 | "name": "Amharic" 341 | }, 342 | { 343 | "iso": "bn", 344 | "name": "Bengali" 345 | }, 346 | { 347 | "iso": "tk", 348 | "name": "Turkmen" 349 | }, 350 | { 351 | "iso": "lb", 352 | "name": "Luxembourgish" 353 | }, 354 | { 355 | "iso": "yi", 356 | "name": "Yiddish" 357 | }, 358 | { 359 | "iso": "so", 360 | "name": "Somali" 361 | }, 362 | { 363 | "iso": "da", 364 | "name": "Danish" 365 | }, 366 | { 367 | "iso": "uk", 368 | "name": "Ukrainian" 369 | }, 370 | { 371 | "iso": "tt", 372 | "name": "Tatar" 373 | }, 374 | { 375 | "iso": "hr", 376 | "name": "Croatian" 377 | }, 378 | { 379 | "iso": "my", 380 | "name": "Burmese" 381 | }, 382 | { 383 | "iso": "sl", 384 | "name": "Slovenian" 385 | }, 386 | { 387 | "iso": "uz", 388 | "name": "Uzbek" 389 | }, 390 | { 391 | "iso": "ur", 392 | "name": "Urdu" 393 | }, 394 | { 395 | "iso": "ml", 396 | "name": "Malayalam" 397 | }, 398 | { 399 | "iso": "sk", 400 | "name": "Slovak" 401 | }, 402 | { 403 | "iso": "kk", 404 | "name": "Kazakh" 405 | }, 406 | { 407 | "iso": "et", 408 | "name": "Estonian" 409 | }, 410 | { 411 | "iso": "ms", 412 | "name": "Malay" 413 | }, 414 | { 415 | "iso": "sv", 416 | "name": "Swedish" 417 | }, 418 | { 419 | "iso": "fa", 420 | "name": "Persian" 421 | }, 422 | { 423 | "iso": "su", 424 | "name": "Sundanese" 425 | }, 426 | { 427 | "iso": "is", 428 | "name": "Icelandic" 429 | }, 430 | { 431 | "iso": "th", 432 | "name": "Thai" 433 | }, 434 | { 435 | "iso": "ko", 436 | "name": "Korean" 437 | }, 438 | { 439 | "iso": "invalid", 440 | "name": "invalid" 441 | } 442 | ] 443 | -------------------------------------------------------------------------------- /tests/test-settings.json: -------------------------------------------------------------------------------- 1 | {"outputFormat":"none","audioOutputFormat":"none","downloadPath": "test/path","proxy": "","rateLimit": "","autoFillClipboard":true,"noPlaylist": false,"globalShortcut":true,"userAgent":"spoof","validateCertificate": false,"enableEncoding": false,"taskList":true,"nameFormat":"%(title).200s-(%(height)sp%(fps).0d).%(ext)s","nameFormatMode":"%(title).200s-(%(height)sp%(fps).0d).%(ext)s","sizeMode":"click","splitMode":"49","maxConcurrent":8,"defaultConcurrent":8,"updateBinary":true,"downloadType":"video","updateApplication":true,"cookiePath":"C:\\Users\\user\\cookies.txt","statSend":false,"sponsorblockMark":"","sponsorblockRemove":"","sponsorblockApi":"https://sponsor.ajay.app","downloadMetadata":true,"downloadJsonMetadata":false,"downloadThumbnail":false,"keepUnmerged":false,"calculateTotalSize":true,"theme": "dark","version":"2.0.0-test1"} 2 | -------------------------------------------------------------------------------- /ytdlgui_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jely2002/youtube-dl-gui/29f63a51d4ee224dcde27a0a0fbc522cc5874b9d/ytdlgui_demo.gif --------------------------------------------------------------------------------