├── .gitignore ├── src ├── icon128.png ├── icon16.png ├── icon32.png ├── icon48.png ├── manifest.json ├── bradlys-ytd-injector.js └── bradlys-ytd.js ├── .gitattributes ├── LICENSE ├── changelog ├── contributing.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | promo/* 3 | breaks/ 4 | -------------------------------------------------------------------------------- /src/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradlys/monochromatic-panda/HEAD/src/icon128.png -------------------------------------------------------------------------------- /src/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradlys/monochromatic-panda/HEAD/src/icon16.png -------------------------------------------------------------------------------- /src/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradlys/monochromatic-panda/HEAD/src/icon32.png -------------------------------------------------------------------------------- /src/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradlys/monochromatic-panda/HEAD/src/icon48.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Bradly's YouTube Downloader", 4 | "version": "0.0.4.0", 5 | "description": "Download YouTube videos without pain", 6 | "homepage_url": "http://bradly.me/", 7 | "permissions": ["http://*.youtube.com/*", "https://*.ytimg.com/*"], 8 | "content_security_policy": "default-src 'self' ", 9 | "content_scripts": [ 10 | { 11 | "matches": ["https://*.youtube.com/*"], 12 | "js": ["bradlys-ytd-injector.js"], 13 | "run_at": "document_end", 14 | "all_frames": true 15 | } 16 | ], 17 | "icons": { 18 | "16": "icon16.png", 19 | "32": "icon32.png", 20 | "48": "icon48.png", 21 | "128": "icon128.png" 22 | }, 23 | 24 | "web_accessible_resources": ["bradlys-ytd.js"] 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Bradly Schlenker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /changelog: -------------------------------------------------------------------------------- 1 | 2 | UPDATE 0.0.2.4: 3 | Updated URL encoding methodology. Filenames should be saved properly rather than with a bunch of %u0402's and so forth. Old method didn't work well with non-ASCII characters. 4 | 5 | UPDATE 0.0.2.1: 6 | No new tab will appear for regular video downloads. New tabs will appear for alternative/streaming formats still. 7 | 8 | UPDATE 0.0.2.0: 9 | Changed method of decrypting youtube signatures! Hopefully I don't have to do updates every few days for this extension anymore! Let me know if this ends up failing you. I'll rollback to previous strategy if it doesn't. 10 | 11 | UPDATE 0.0.0.8: 12 | Resolved issue with download button not appearing when clicking on related videos and when using playlists. 13 | 14 | UPDATE 0.0.0.4, 0.0.0.5, 0.0.0.6, 0.0.0.7, 0.0.0.8, 0.0.0.9, 0.0.1.0, 0.0.1.1, 0.0.2.2, 0.0.2.3, 0.0.2.5, 0.0.2.6: 15 | Updated signature decrypting algorithm. 16 | 17 | UPDATE 0.0.0.3: 18 | Added alternative video formats. These are mostly streaming audio only or streaming video only formats and, personally, I wouldn't bother with them unless you have a very specific purpose. Click the "Alternative Formats (experimental) -->" link to see them. You'll have to figure out a way to play them but you can. (Open VLC, use network streaming, and copy+paste the link in.) 19 | 20 | UPDATE 0.0.0.2: 21 | Updated algorithm for figuring out correct signatures. YouTube will update and change this often, thus breaking the extension for certain videos. -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Please ensure your pull request adheres to the following guidelines: 4 | 5 | - [Read this.](https://github.com/blog/1943-how-to-write-the-perfect-pull-request) 6 | - Search previous suggestions before making a new one, as yours may be a duplicate. 7 | - Make an individual pull request for each suggestion. 8 | - All new functions should be documented and have jsdoc compatible documentation. 9 | - Keep descriptions short and simple, but descriptive. 10 | - Start the description with a capital and end with a full stop/period. 11 | - Check your spelling and grammar. 12 | - Use similar method naming and syntax to existing codebase. 13 | - Make sure your text editor is set to remove trailing whitespace and always leave a new line at the end of any file. 14 | 15 | Thank you for your improvements, criticisms, and suggestions! Anything to push forward. 16 | 17 | ## Needs 18 | 19 | Unless otherwise shown, there are no tests or build tools. This is something that needs to be addressed immediately and is the top priority of this project. As it would be wise to have unit tests and Selenium tests to verify the correctness of the extension. It would be nice to have build tools to streamline the build process (simple as it stands but could get complex in the future) and minimize the code for distribution. However, the first step to this is to rewrite the code into testable code. As it stands, it's not very testable. This is the main reason for unit tests not existing. 20 | 21 | There is also the desire for a more streamlined UI, error reporting (automatic or done by the user), and some kind of MP3 link (possibly using a third party service but only if desired by the client). 22 | 23 | Overall, many thoughts but so few developers! Tests are of utmost importance though and, without them, it is hard for anyone to contribute to the codebase without fear of destroying the entire thing. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bradly's YouTube Downloader 2 | Google Chrome Extension for Downloading YouTube Videos 3 | 4 | ## Introduction 5 | 6 | Download YouTube videos without pain. Inserts a dropdown button with links to directly download the current video from YouTube in multiple formats. Easily integrates with the UI and does not interfere or rely on third party resources. 7 | 8 | As of December 20th, 2015, Google removed the extension from their web store. 9 | 10 | If there are any issues, please do not hesitate to create an issue here on Github so that I can fix it! Always follow this repository for any updates as I do sincerely try to update this as I can. 11 | 12 | ## Usage 13 | 14 | Go to a video on YouTube, click the Download button, and click on a format that you desire. The download will begin and come directly from YouTube's servers. 15 | 16 | ## Motivation 17 | 18 | The motivation behind this extension was to allow for users to easily and safely download YouTube videos. There were already extensions and websites available that allowed for the downloading of YouTube videos but they came at a great cost in convenience or safety. None of them did it without taking your information, inserting advertisements into the page, or trying to insert malware into your computer. This is where Bradly's YouTube Downloader comes in and saves the day! 19 | 20 | ## Installation 21 | 22 | Enable Developer Mode in Google Chrome and install the extension, [as described here](https://developer.chrome.com/extensions/getstarted#unpacked). 23 | 24 | A detailed video is [available here](https://www.youtube.com/watch?v=wRBKYiumhQI) that details error reporting, installation, updating, and a bit of troubleshooting. 25 | 26 | * I've since removed the dist folder and you should now use the src folder and use the unpacked files there. 27 | 28 | ## Contributing 29 | 30 | Contributions are always welcome! 31 | Please read the [contribution guidelines](contributing.md) first. 32 | 33 | ## License 34 | 35 | [MIT (c) 2015 Bradly Schlenker](LICENSE) 36 | -------------------------------------------------------------------------------- /src/bradlys-ytd-injector.js: -------------------------------------------------------------------------------- 1 | let URLToDecryptionFunction = {}; 2 | 3 | /** 4 | * Takes in a URL to a text type file and returns the content once the promise resolves. 5 | * @param {string} url 6 | * @returns {Promise} 7 | */ 8 | function getContent(url) { 9 | return new Promise(function(resolve, reject) { 10 | let request = new XMLHttpRequest(); 11 | request.onreadystatechange = function() { 12 | if (request.readyState === 4) { 13 | if (request.status === 200) { 14 | resolve(request.responseText); 15 | } else { 16 | reject(''); 17 | } 18 | } 19 | }; 20 | request.open("GET", url); 21 | request.send(); 22 | }); 23 | } 24 | 25 | /** 26 | * Gets the URL to a file that matches the needle from document.scripts 27 | * @returns {string} Will return the url string or will return empty if not found 28 | */ 29 | function getScriptURL(needle) { 30 | let haystacks = document.scripts; 31 | // try to get a url for a script that has needle in it 32 | for (let i of haystacks) { 33 | let haystack = haystacks[i].src; 34 | if (haystack && haystack.indexOf(needle) !== -1) { 35 | return haystack; 36 | } 37 | } 38 | return ''; 39 | } 40 | 41 | function getDecryptionFunctionName(haystack) { 42 | // Use two different methods for getting the decryption function name. These vary but generally have these traits. 43 | // look for something like: `signature", functionName(` <-- capture functionName 44 | let gen2 = /signature['"]\s*,\s*([a-zA-Z0-9$]+)\(/; 45 | // look for something like: `.sig||functionName(` <-- capture functionName 46 | let gen1 = /\.sig\|\|([a-zA-Z0-9$]+)\(/; 47 | let functionName = haystack.match(gen1); 48 | if (!functionName) { 49 | functionName = haystack.match(gen2); 50 | } 51 | if (!functionName) return ''; 52 | return functionName[1]; 53 | } 54 | 55 | function getFunction(needle, haystack) { 56 | // group 1 is the function declaration up to but not including params. (3 different attempts) But don't capture it 57 | // group 2 is the params declaration 58 | // group 3 is the code for the function 59 | // JS doesn't support named capture groups (annoying!) 60 | let escaped_needle = regexEscapeString(needle); 61 | let functionCaptureRegex = ` 62 | (?:function\\s+${ escaped_needle } | [{;,]\\s*${ escaped_needle }\\s*=\\s*function | var\\s+${ escaped_needle }\\s*=\\s*function)\\s* 63 | \\(([^)]*)\\) 64 | \\s*{([^}]+)}`.replace(/\s/g, ''); // JS has no free-spacing mode (also annoying!) 65 | 66 | let match = haystack.match(new RegExp(functionCaptureRegex), 'g'); 67 | if (!match) return getObject(needle, haystack); 68 | 69 | let params = match[1]; // Although labeled params - this is usually 1 parameter with YouTube code. 70 | let code = match[2]; 71 | let needleFunction = `var ${ needle } = function(${ params }) { ${ code } }`; 72 | 73 | let subFunction = getFirstSubFunction(needleFunction, params, needle, haystack); 74 | // if no subfunctions inside then we can just pass back the needleFunction we made 75 | if (!subFunction) { 76 | return needleFunction; 77 | } 78 | 79 | // otherwise, we need to add the subfunction code to the inside of the code. 80 | // Basically, put it in the same scope while retaining function names. 81 | return `var ${ needle } = function(${ params }) { 82 | ${ subFunction } 83 | ${ code } 84 | };`; 85 | } 86 | 87 | /** 88 | * Given a haystack and needle, get the declaration of the needle in the haystack. Presuming it's an object declaration. 89 | * @param {string} needle 90 | * @param {string} haystack 91 | * @returns {string} 92 | */ 93 | function getObject(needle, haystack) { 94 | let escaped_needle = regexEscapeString(needle); 95 | let match = haystack.match(new RegExp("(var " + escaped_needle + "={[\\S\\s]*?(?=}};)}};)"), 'm'); 96 | if (!match) return ''; 97 | return match[1]; 98 | } 99 | 100 | /** 101 | * Obtains the first subfunction reference out of the provided haystack and then 102 | * returns that subfunction's declaration. 103 | * 104 | * WARNING: This is not a very generic method - as it is specifically tuned for YouTube. 105 | * Unlike getFunction which is more generic. 106 | * 107 | * @param {string} haystack haystack to look for subfunctions in 108 | * @param {string} haystackParameter function 109 | * @param {string} needle function to look for 110 | * @param {string} script script to search 111 | * @returns {string} 112 | */ 113 | function getFirstSubFunction(haystack, haystackParameter, needle, script) { 114 | haystackParameter = regexEscapeString(haystackParameter); 115 | // We know that the code generally is like `XX.YY(haystackParameter, arg2);` - Not bulletproof but good enough 116 | let firstSubFunctionName = haystack.match(new RegExp("([\\w$]+)\\.[\\w$]+\\(" + haystackParameter + "[^)]*\\)", 'm')); 117 | if (!firstSubFunctionName) return ''; 118 | 119 | // Need to look for the function in the entire script 120 | let subFunction = getFunction(firstSubFunctionName[1], script); 121 | if (!subFunction) return ''; 122 | 123 | return subFunction; 124 | } 125 | 126 | /** 127 | * Take a string and return the regex safe version of it. 128 | * Which is to say, a version that can be inserted into regexp as is. 129 | * 130 | * @param string 131 | * @returns {string} 132 | */ 133 | function regexEscapeString(string) { 134 | return string.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); 135 | } 136 | 137 | /** 138 | * Starts the process of putting the decryption signature into state for use by other processes. 139 | * 140 | * @param {string} url optional parameter is the link to the JS file to get the decryption signature from 141 | * @returns {Promise} Promise will resolve when decryption is done or fail if a problem occurs. 142 | */ 143 | function putDecryptionSignatureIntoState(url) { 144 | if (!url) { 145 | // try to get a script that has html5player in the url 146 | url = getScriptURL('html5player'); 147 | // if not that then try to get a script that has player in the url 148 | if (!url) url = getScriptURL('player'); 149 | if (!url) return new Promise(function(resolve, reject) { reject(false); }); 150 | } 151 | let p = getContent(url); 152 | return p.then(function(text) { 153 | let functionName = getDecryptionFunctionName(text); 154 | if (!functionName) return false; 155 | 156 | let func = getFunction(functionName, text); 157 | if (!func) return false; 158 | 159 | // put the function into state 160 | URLToDecryptionFunction[url] = func; 161 | return url; 162 | }); 163 | } 164 | 165 | /** 166 | * Add event listener to DOM to know when the injected script needs the decryption scheme. 167 | */ 168 | document.addEventListener('BYTD_connectExtension', function(e) { 169 | if (e.detail) { 170 | let p = putDecryptionSignatureIntoState(e.detail); 171 | p.then(function (url) { 172 | if (!url) return; 173 | // since we know the URL now and know that this is in state, we can add the function to the DOM. 174 | let scriptElement = document.createElement('script'); 175 | // Get function name and then wrap the function in decrypt_signature so that we can call it consistently. 176 | let func = URLToDecryptionFunction[url]; 177 | let funcName = func.slice(4, func.indexOf('=')-1); 178 | 179 | scriptElement.innerText = `decrypt_signature = function(zzzz) { 180 | ${ func } 181 | return ${ funcName }(zzzz); 182 | }`; 183 | scriptElement.onload = function() { this.parentNode.removeChild(this);}; 184 | (document.head||document.documentElement).appendChild(scriptElement); 185 | }); 186 | } 187 | }, false); 188 | 189 | // inject our download button creator script into the user's current DOM 190 | let s = document.createElement('script'); 191 | s.src = chrome.extension.getURL('bradlys-ytd.js'); 192 | s.onload = function() { 193 | this.parentNode.removeChild(this); 194 | }; 195 | (document.head||document.documentElement).appendChild(s); 196 | -------------------------------------------------------------------------------- /src/bradlys-ytd.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | //Every 500ms, see if we can create the youtube downloader element 3 | setInterval(function () { 4 | try { 5 | createYouTubeDownloader(); 6 | } catch (e) { 7 | BYTDERRORS.addError(e); 8 | } 9 | }, 500); 10 | 11 | const BUTTON_APPEND_SELECTOR = '#menu-container > #menu > ytd-menu-renderer'; 12 | const VIDEO_TITLE_SELECTOR = '.title.style-scope.ytd-video-primary-info-renderer'; 13 | const BRADLYS_YOUTUBE_DOWNLOADER_BUTTON_SELECTOR = '#bradlys-youtube-downloader'; 14 | const BRADLYS_YOUTUBE_DOWNLOADER_UL_SELECTOR = '#bradlys-youtube-downloader-ul'; 15 | const BRADLYS_YOUTUBE_DOWNLOADER_ID = 'bradlys-youtube-downloader'; 16 | const BRADLYS_YOUTUBE_DOWNLOADER_ERRORS_SELECTOR = '#bradlys-youtube-downloader .bytd-error'; 17 | const ALTERNATIVE_FORMATS_MENU_NAME = 'Alternative Formats (Experimental) -->'; 18 | let LAST_LOCATION_HREF = ''; 19 | 20 | //logging all errors that occur within the program 21 | let BYTDERRORS = { 22 | errors: [], 23 | addError: function (err) { 24 | this.errors.push(err); 25 | //addErrorToView(err); 26 | } 27 | }; 28 | 29 | class Item { 30 | constructor(text, parent) { 31 | if (typeof text !== 'string' && text !== undefined) { 32 | throw 'Non-String type provided for Text.'; 33 | } 34 | this._text = text; 35 | let randomInt = 'bytd' + intGen.next().replace('.', '').replace('+', ''); 36 | let selector = '#' + randomInt; 37 | while (document.querySelector(selector)) { 38 | randomInt = 'bytd' + intGen.next().replace('.', '').replace('+', ''); 39 | selector = '#' + randomInt; 40 | } 41 | this._id = randomInt; 42 | //this is where things get weird 43 | if (parent === undefined || parent instanceof Menu) { 44 | this._parent = parent; 45 | } else { 46 | throw 'Incorrect type provided for parent.'; 47 | } 48 | } 49 | 50 | getText() { 51 | return this._text ? this._text : ''; 52 | } 53 | 54 | getId() { 55 | return this._id; 56 | } 57 | 58 | setId(id) { 59 | this._id = id; 60 | } 61 | 62 | getParent() { 63 | return this._parent; 64 | } 65 | 66 | getHTML() { 67 | let id = this.getId(); 68 | let text = this.getText(); 69 | let linkTemplate = 70 | `
  • 71 | ${text} 72 |
  • `; 73 | return linkTemplate; 74 | } 75 | } 76 | 77 | class Link extends Item { 78 | constructor(text, url, parent) { 79 | super(text, parent); 80 | if (typeof url === 'boolean') { 81 | if (url !== false) { 82 | throw 'True Boolean value provided for URL.'; 83 | } 84 | } else if (typeof url === 'string') { 85 | if (url.length < 0) { 86 | throw 'Length of 0 String provided for URL.'; 87 | } 88 | } else { 89 | throw 'Invalid type provided for URL.'; 90 | } 91 | this._url = url; 92 | } 93 | 94 | getURL() { 95 | return this._url; 96 | } 97 | 98 | getHTML() { 99 | let id = this.getId(); 100 | let text = this.getText(); 101 | let url = this.getURL(); 102 | return ` 103 | ` 108 | } 109 | } 110 | 111 | class Menu extends Item { 112 | constructor(text, parent) { 113 | super(text, parent); 114 | this._children = []; 115 | } 116 | 117 | getChildren() { 118 | return this._children; 119 | } 120 | 121 | addChild(item) { 122 | if ((item instanceof Item) || (item instanceof Link)) { 123 | if (!this.contains(item)) { 124 | this._children.push(item); 125 | } 126 | } else { 127 | throw 'Provided item is not an instance of Item or Link.'; 128 | } 129 | } 130 | 131 | contains(item) { 132 | if ((item instanceof Item)) { 133 | for (let child of this.getChildren()) { 134 | if (item.getId() === child.getId()) { 135 | return true; 136 | } 137 | if (child instanceof Menu && child.contains(item)) { 138 | return true; 139 | } 140 | } 141 | return false; 142 | } else { 143 | throw 'Provided item is not an instance of Item.'; 144 | } 145 | } 146 | 147 | getHTML() { 148 | let items = ''; 149 | for (let child of this.getChildren()) { 150 | items += child.getHTML(); 151 | } 152 | return ` 153 | 154 | `; 157 | } 158 | } 159 | 160 | class UniqueNumberGenerator { 161 | constructor() { 162 | this._queue = new Queue(); 163 | this._allEver = {}; 164 | } 165 | 166 | next() { 167 | let next = this._queue.dequeue(); 168 | if (typeof next !== 'number') { 169 | let ints = {}; 170 | for (let i = 0; i < 50; i++) { 171 | let randomInt = getRandomIntInclusive(1e+50, 1e+200); 172 | if (randomInt in ints || randomInt in this._allEver) { 173 | i--; 174 | } else { 175 | ints[randomInt] = true; 176 | } 177 | } 178 | for (let key in ints) { 179 | if (ints.hasOwnProperty(key)) { 180 | this._queue.enqueue(key); 181 | this._allEver[key] = false; 182 | } 183 | } 184 | next = this._queue.dequeue(); 185 | } 186 | this._allEver[next] = true; 187 | return next; 188 | } 189 | } 190 | 191 | class Queue { 192 | constructor() { 193 | this._queue = []; 194 | this._front = 0; 195 | } 196 | 197 | getLength() { 198 | return this._queue.length; 199 | } 200 | 201 | isEmpty() { 202 | return this._queue.length === 0 203 | } 204 | 205 | enqueue(item) { 206 | this._queue.push(item); 207 | } 208 | 209 | dequeue() { 210 | if (this.isEmpty()) { 211 | return undefined; 212 | } 213 | let item = this._queue[this._front]; 214 | let length = this.getLength(); 215 | if (length > 25 && (this._front * 2) > length) { 216 | this._queue = this._queue.slice(this._front + 1); 217 | this._front = 0; 218 | } else { 219 | this._front++; 220 | } 221 | return item; 222 | } 223 | 224 | peek() { 225 | return this.isEmpty() ? undefined : this._queue[this._front]; 226 | } 227 | } 228 | 229 | function getRandomIntInclusive(min, max) { 230 | return Math.floor(Math.random() * (max - min + 1)) + min; 231 | } 232 | 233 | /** 234 | * Adds the download button to the view. 235 | * 236 | * @param {Menu} menu 237 | */ 238 | function addDownloadButtonToView(menu) { 239 | if (!(menu instanceof Menu)) { 240 | throw 'Provided menu is not an instance of Menu.'; 241 | } 242 | if (downloadButtonExists()) { 243 | //Update it??? Yikes. :/ 244 | let html = menu.getHTML(); 245 | let currButton = document.querySelector(BRADLYS_YOUTUBE_DOWNLOADER_BUTTON_SELECTOR).outerHTML; 246 | //there's no good solution for this at the moment beyond full replace. 247 | if (currButton !== html) { 248 | currButton.outerHTML = html; 249 | } 250 | return; 251 | } 252 | if (!downloadButtonAppendSectionExists()) { 253 | throw 'Could not find region to append button.'; 254 | } 255 | let html = menu.getHTML(); 256 | let element = document.createElement('p'); 257 | element.innerHTML = html; 258 | let appendTo = document.querySelector(BUTTON_APPEND_SELECTOR); 259 | LAST_LOCATION_HREF = window.location.href; 260 | // Take newly created nodes and append them to the selected element. 261 | while (element.childNodes.length) { 262 | appendTo.appendChild(element.childNodes[0]); 263 | } 264 | } 265 | 266 | function downloadButtonExists() { 267 | return document.querySelector(BRADLYS_YOUTUBE_DOWNLOADER_BUTTON_SELECTOR) !== null; 268 | } 269 | 270 | function downloadButtonHasErrors() { 271 | return document.querySelector(BRADLYS_YOUTUBE_DOWNLOADER_ERRORS_SELECTOR) !== null; 272 | } 273 | 274 | function downloadButtonIsOld() { 275 | return window.location.href !== LAST_LOCATION_HREF || ![getVideoTitle(), 'YouTube Video'].includes(videoTitle); 276 | } 277 | 278 | function removeDownloadButton() { 279 | document.querySelector(BRADLYS_YOUTUBE_DOWNLOADER_BUTTON_SELECTOR).remove(); 280 | document.querySelector(BRADLYS_YOUTUBE_DOWNLOADER_UL_SELECTOR).remove(); 281 | } 282 | 283 | function downloadButtonAppendSectionExists() { 284 | return document.querySelector(BUTTON_APPEND_SELECTOR) !== null; 285 | } 286 | 287 | function getVideoTitle() { 288 | let vT = document.querySelector(VIDEO_TITLE_SELECTOR); 289 | if (vT) { 290 | try { 291 | vT = encodeURIComponent(vT.innerText); 292 | } catch (e) { 293 | vT = 'YouTube Video'; 294 | } 295 | } else { 296 | vT = 'YouTube Video'; 297 | } 298 | return vT; 299 | } 300 | 301 | function videosToMenu(videos) { 302 | let menu = new Menu('Download'); 303 | menu.setId(BRADLYS_YOUTUBE_DOWNLOADER_ID); 304 | for (let video of videos) { 305 | let visibleText = []; 306 | if ('height' in video && 'width' in video) { 307 | visibleText.push(video.width + 'x' + video.height + 'p'); 308 | } else if ('height' in video) { 309 | visibleText.push(video.height + 'p'); 310 | } else if ('width' in video) { 311 | visibleText.push(video.width + 'x?'); 312 | } else if (!('audio' in video)) { 313 | visibleText.push('?x?'); 314 | } 315 | if ('fps' in video) { 316 | visibleText.push(video.fps + 'fps'); 317 | } 318 | if ('ext' in video) { 319 | visibleText.push(video.ext); 320 | } 321 | if ('format_note' in video) { 322 | visibleText.push(video.format_note); 323 | } 324 | if ('abr' in video) { 325 | visibleText.push(video.abr + 'kbps'); 326 | } 327 | if ('flags' in video) { 328 | for (let flag of video.flags) { 329 | visibleText.push(flag); 330 | } 331 | } 332 | if ('video' in video && !('audio' in video)) { 333 | visibleText.push('VO'); 334 | } else if (!('video' in video) && 'audio' in video) { 335 | visibleText.push('AO'); 336 | } 337 | visibleText = visibleText.join(' '); 338 | //unfortunate but business logic has to be mixed in, I guess 339 | let foundMenu = menu; 340 | 341 | /* Nesting menus is ugly right now. 342 | if ('video' in video && 'audio' in video) { 343 | foundMenu = menu; 344 | } 345 | else { 346 | foundMenu = menu.getMenuWithText(ALTERNATIVE_FORMATS_MENU_NAME); 347 | if (!foundMenu) { 348 | foundMenu = new Menu(ALTERNATIVE_FORMATS_MENU_NAME, menu); 349 | menu.addChild(foundMenu); 350 | } 351 | } 352 | */ 353 | let link = new Link(visibleText, video.url, foundMenu); 354 | foundMenu.addChild(link); 355 | } 356 | return menu; 357 | } 358 | 359 | /** 360 | * Business logic for creating the YouTube Downloader. Call this when you want to attempt to create the downloader. 361 | * 362 | * @returns {boolean} 363 | */ 364 | function createYouTubeDownloader() { 365 | let YTDHolderElement = document.querySelector(BUTTON_APPEND_SELECTOR); 366 | //if we can't find the element to append the button to then we should stop the program and wait for it to load 367 | if (!YTDHolderElement) { 368 | return false; 369 | } 370 | //ytplayer needs to be fully initialized for us to do anything 371 | if (typeof window.ytplayer === 'undefined' || typeof window.ytplayer.config === 'undefined' || typeof window.ytplayer.config.args === 'undefined' || typeof window.ytplayer.config.args.url_encoded_fmt_stream_map === 'undefined') { 372 | return false; 373 | } 374 | //if it exists and has no errors, we're good! 375 | if (downloadButtonExists() && !downloadButtonHasErrors()) { 376 | // we also need to check that it does exist but because of page redirection - it's outdated 377 | if (downloadButtonIsOld()) { 378 | removeDownloadButton(); 379 | } else { 380 | return false; 381 | } 382 | } 383 | videoTitle = getVideoTitle(); 384 | let videos = getYouTubeVideos(); 385 | //if no videos are returned then we don't need to create the youtube downloader element 386 | if (!videos || videos.length < 1) { 387 | //actually, we want to put up an error element instead 388 | BYTDERRORS.addError('Issue retrieving videos. Returned empty.'); 389 | return false; 390 | } 391 | let menu = videosToMenu(videos); 392 | addDownloadButtonToView(menu); 393 | } 394 | 395 | /** 396 | * Gets YouTube videos out of YouTube's state and returns an array of all videos with relevant information. 397 | * 398 | * @returns {Array|boolean} 399 | */ 400 | function getYouTubeVideos() { 401 | //Make sure that the ytplayer variable is there and properly initialized 402 | if (!(typeof window.ytplayer !== 'undefined' && typeof window.ytplayer.config !== 'undefined' && typeof window.ytplayer.config.args !== 'undefined')) { 403 | return false; 404 | } 405 | //grab the videos out of the ytplayer variable 406 | let videos = []; 407 | let video, index, YTPlayerVideos; 408 | if (window.ytplayer.config.args.url_encoded_fmt_stream_map) { 409 | YTPlayerVideos = window.ytplayer.config.args.url_encoded_fmt_stream_map.split(','); 410 | //parse out the information for each video and put it into the videos variable 411 | for (index = 0; index < YTPlayerVideos.length; index++) { 412 | video = parseVideoURIIntoObject(YTPlayerVideos[index]); 413 | if (video) { 414 | videos.push(video); 415 | } 416 | } 417 | } 418 | //If we don't have adaptive formats available then just return videos we have so far. 419 | if (window.ytplayer.config.args.adaptive_fmts) { 420 | YTPlayerVideos = window.ytplayer.config.args.adaptive_fmts.split(','); 421 | //parse out the information for each video and put it into the videos variable 422 | for (index = 0; index < YTPlayerVideos.length; index++) { 423 | video = parseVideoURIIntoObject(YTPlayerVideos[index]); 424 | if (video) { 425 | videos.push(video); 426 | } 427 | } 428 | } 429 | return videos.length > 0 ? videos : false; 430 | } 431 | 432 | /** 433 | * Parses video URIs presented in the ytplayer variable into a digestable key/value object. 434 | * Also preps the object for being consumed by the createYoutubeDownloader function. 435 | * 436 | * @param {string} URI 437 | * @returns {object} 438 | */ 439 | function parseVideoURIIntoObject(URI) { 440 | //split parameters from URI to obtain all information 441 | let currentVideo = URI.split('&'); 442 | if (currentVideo.length < 3) { 443 | return false; 444 | } 445 | //find the itag, url, and signature elements where applicable 446 | let video = {}; 447 | let itag = 0; 448 | let url = ''; 449 | let signature = ''; 450 | let elem; 451 | for (elem = 0; elem < currentVideo.length; elem++) { 452 | if (currentVideo[elem].indexOf('itag=') === 0) { 453 | itag = currentVideo[elem].split('=')[1]; 454 | } else if (currentVideo[elem].indexOf('url=') === 0) { 455 | url = decodeURIComponent(currentVideo[elem].split('=')[1]); 456 | } else if (currentVideo[elem].indexOf('s=') === 0) { 457 | signature = decodeURIComponent(currentVideo[elem].split('=')[1]); 458 | } 459 | } 460 | //if we found them then let's fetch the relevant information 461 | //and add it to the videos list 462 | if (url.length > 0 && itag !== 0 && itag in YTVideoFormats) { 463 | //copy base itag information over 464 | video = YTVideoFormats[itag]; 465 | //add in the URL 466 | video.url = url; 467 | //if the url doesn't contain the signature then we need to add it to the URL 468 | if (url.indexOf('signature') < 1 && signature.length > 0) { 469 | try { 470 | if (!(window.ytplayer.config.assets.js in dispatchedEvents)) { 471 | let event = new CustomEvent('BYTD_connectExtension', { 472 | detail: window.ytplayer.config.assets.js 473 | }); 474 | document.dispatchEvent(event); 475 | dispatchedEvents[window.ytplayer.config.assets.js] = true; 476 | } 477 | //and if we have to add it then we need to decrypt it too 478 | //This is the single biggest source of the entire thing breaking. 479 | if (typeof decrypt_signature === 'undefined' || !decrypt_signature) { 480 | return false; 481 | } 482 | video.url += '&signature=' + decrypt_signature(signature); 483 | } catch (err) { 484 | console.log("Issue with decrypting signature for Bradly's YouTube Downloader."); 485 | return false; 486 | } 487 | } 488 | //add title to url so that the file downloads with a proper title 489 | video.url += '&title=' + videoTitle; 490 | } else { 491 | //if we didn't find the URL or itag then something is wrong. 492 | console.log("Bradly's YouTube Downloader did not find an itag and/or URL for a video."); 493 | BYTDERRORS.addError("URI Parsing Issue w/ URI: " + URI); 494 | return false; 495 | } 496 | return video; 497 | } 498 | 499 | let videoTitle = 'YouTube Video'; 500 | 501 | //URLs we have dispatched events for, we don't want to duplicate event calls too often. 502 | let dispatchedEvents = {}; 503 | 504 | //video formats information taken from youtube-dl project 505 | //each number corresponds to a unique type of video format 506 | let YTVideoFormats = { 507 | '5': {'ext': 'flv', 'width': 400, 'height': 240, 'audio': true, 'video': true}, 508 | '6': {'ext': 'flv', 'width': 450, 'height': 270, 'audio': true, 'video': true}, 509 | '13': {'ext': '3gp', 'width': 176, 'height': 144, 'format_note': 'Mono', 'audio': true, 'video': true}, 510 | '17': {'ext': '3gp', 'width': 176, 'height': 144, 'audio': true, 'video': true}, 511 | '18': {'ext': 'mp4', 'width': 640, 'height': 360, 'audio': true, 'video': true}, 512 | '22': {'ext': 'mp4', 'width': 1280, 'height': 720, 'audio': true, 'video': true}, 513 | '34': {'ext': 'flv', 'width': 640, 'height': 360, 'audio': true, 'video': true}, 514 | '35': {'ext': 'flv', 'width': 854, 'height': 480, 'audio': true, 'video': true}, 515 | '36': {'ext': '3gp', 'width': 320, 'height': 240, 'audio': true, 'video': true}, 516 | '37': {'ext': 'mp4', 'width': 1920, 'height': 1080, 'audio': true, 'video': true}, 517 | '38': {'ext': 'mp4', 'width': 4096, 'height': 3072, 'audio': true, 'video': true}, 518 | '43': {'ext': 'webm', 'width': 640, 'height': 360, 'audio': true, 'video': true}, 519 | '44': {'ext': 'webm', 'width': 854, 'height': 480, 'audio': true, 'video': true}, 520 | '45': {'ext': 'webm', 'width': 1280, 'height': 720, 'audio': true, 'video': true}, 521 | '46': {'ext': 'webm', 'width': 1920, 'height': 1080, 'audio': true, 'video': true}, 522 | '59': {'ext': 'mp4', 'width': 854, 'height': 480, 'audio': true, 'video': true}, 523 | '78': {'ext': 'mp4', 'width': 854, 'height': 480, 'audio': true, 'video': true}, 524 | //3D videos 525 | '82': {'ext': 'mp4', 'height': 360, 'format_note': '3D', 'preference': -20, 'video': true}, 526 | '83': {'ext': 'mp4', 'height': 480, 'format_note': '3D', 'preference': -20, 'video': true}, 527 | '84': {'ext': 'mp4', 'height': 720, 'format_note': '3D', 'preference': -20, 'video': true}, 528 | '85': {'ext': 'mp4', 'height': 1080, 'format_note': '3D', 'preference': -20, 'video': true}, 529 | '100': {'ext': 'webm', 'height': 360, 'format_note': '3D', 'preference': -20, 'video': true}, 530 | '101': {'ext': 'webm', 'height': 480, 'format_note': '3D', 'preference': -20, 'video': true}, 531 | '102': {'ext': 'webm', 'height': 720, 'format_note': '3D', 'preference': -20, 'video': true}, 532 | //Apple HTTP Live Streaming 533 | '92': {'ext': 'mp4', 'height': 240, 'format_note': 'HLS', 'preference': -10, 'video': true}, 534 | '93': {'ext': 'mp4', 'height': 360, 'format_note': 'HLS', 'preference': -10, 'video': true}, 535 | '94': {'ext': 'mp4', 'height': 480, 'format_note': 'HLS', 'preference': -10, 'video': true}, 536 | '95': {'ext': 'mp4', 'height': 720, 'format_note': 'HLS', 'preference': -10, 'video': true}, 537 | '96': {'ext': 'mp4', 'height': 1080, 'format_note': 'HLS', 'preference': -10, 'video': true}, 538 | '132': {'ext': 'mp4', 'height': 240, 'format_note': 'HLS', 'preference': -10, 'video': true}, 539 | '151': {'ext': 'mp4', 'height': 72, 'format_note': 'HLS', 'preference': -10, 'video': true}, 540 | //DASH mp4 video 541 | '133': { 542 | 'ext': 'mp4', 543 | 'height': 240, 544 | 'format_note': 'DASH video', 545 | 'acodec': 'none', 546 | 'preference': -40, 547 | 'video': true 548 | }, 549 | '134': { 550 | 'ext': 'mp4', 551 | 'height': 360, 552 | 'format_note': 'DASH video', 553 | 'acodec': 'none', 554 | 'preference': -40, 555 | 'video': true 556 | }, 557 | '135': { 558 | 'ext': 'mp4', 559 | 'height': 480, 560 | 'format_note': 'DASH video', 561 | 'acodec': 'none', 562 | 'preference': -40, 563 | 'video': true 564 | }, 565 | '136': { 566 | 'ext': 'mp4', 567 | 'height': 720, 568 | 'format_note': 'DASH video', 569 | 'acodec': 'none', 570 | 'preference': -40, 571 | 'video': true 572 | }, 573 | '137': { 574 | 'ext': 'mp4', 575 | 'height': 1080, 576 | 'format_note': 'DASH video', 577 | 'acodec': 'none', 578 | 'preference': -40, 579 | 'video': true 580 | }, 581 | '138': {'ext': 'mp4', 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40, 'video': true}, 582 | '160': { 583 | 'ext': 'mp4', 584 | 'height': 144, 585 | 'format_note': 'DASH video', 586 | 'acodec': 'none', 587 | 'preference': -40, 588 | 'video': true 589 | }, 590 | '264': { 591 | 'ext': 'mp4', 592 | 'height': 1440, 593 | 'format_note': 'DASH video', 594 | 'acodec': 'none', 595 | 'preference': -40, 596 | 'video': true 597 | }, 598 | '298': { 599 | 'ext': 'mp4', 600 | 'height': 720, 601 | 'format_note': 'DASH video', 602 | 'acodec': 'none', 603 | 'preference': -40, 604 | 'fps': 60, 605 | 'vcodec': 'h264', 606 | 'video': true 607 | }, 608 | '299': { 609 | 'ext': 'mp4', 610 | 'height': 1080, 611 | 'format_note': 'DASH video', 612 | 'acodec': 'none', 613 | 'preference': -40, 614 | 'fps': 60, 615 | 'vcodec': 'h264', 616 | 'video': true 617 | }, 618 | '266': { 619 | 'ext': 'mp4', 620 | 'height': 2160, 621 | 'format_note': 'DASH video', 622 | 'acodec': 'none', 623 | 'preference': -40, 624 | 'vcodec': 'h264', 625 | 'video': true 626 | }, 627 | //DASH mp4 audio 628 | '139': { 629 | 'ext': 'm4a', 630 | 'format_note': 'DASH audio', 631 | 'acodec': 'aac', 632 | 'vcodec': 'none', 633 | 'abr': 48, 634 | 'preference': -50, 635 | 'container': 'm4a_dash', 636 | 'audio': true 637 | }, 638 | '140': { 639 | 'ext': 'm4a', 640 | 'format_note': 'DASH audio', 641 | 'acodec': 'aac', 642 | 'vcodec': 'none', 643 | 'abr': 128, 644 | 'preference': -50, 645 | 'container': 'm4a_dash', 646 | 'audio': true 647 | }, 648 | '141': { 649 | 'ext': 'm4a', 650 | 'format_note': 'DASH audio', 651 | 'acodec': 'aac', 652 | 'vcodec': 'none', 653 | 'abr': 256, 654 | 'preference': -50, 655 | 'container': 'm4a_dash', 656 | 'audio': true 657 | }, 658 | //Dash webm 659 | '167': { 660 | 'ext': 'webm', 661 | 'height': 360, 662 | 'width': 640, 663 | 'format_note': 'DASH video', 664 | 'acodec': 'none', 665 | 'container': 'webm', 666 | 'vcodec': 'vp8', 667 | 'preference': -40, 668 | 'video': true 669 | }, 670 | '168': { 671 | 'ext': 'webm', 672 | 'height': 480, 673 | 'width': 854, 674 | 'format_note': 'DASH video', 675 | 'acodec': 'none', 676 | 'container': 'webm', 677 | 'vcodec': 'vp8', 678 | 'preference': -40, 679 | 'video': true 680 | }, 681 | '169': { 682 | 'ext': 'webm', 683 | 'height': 720, 684 | 'width': 1280, 685 | 'format_note': 'DASH video', 686 | 'acodec': 'none', 687 | 'container': 'webm', 688 | 'vcodec': 'vp8', 689 | 'preference': -40, 690 | 'video': true 691 | }, 692 | '170': { 693 | 'ext': 'webm', 694 | 'height': 1080, 695 | 'width': 1920, 696 | 'format_note': 'DASH video', 697 | 'acodec': 'none', 698 | 'container': 'webm', 699 | 'vcodec': 'vp8', 700 | 'preference': -40, 701 | 'video': true 702 | }, 703 | '218': { 704 | 'ext': 'webm', 705 | 'height': 480, 706 | 'width': 854, 707 | 'format_note': 'DASH video', 708 | 'acodec': 'none', 709 | 'container': 'webm', 710 | 'vcodec': 'vp8', 711 | 'preference': -40, 712 | 'video': true 713 | }, 714 | '219': { 715 | 'ext': 'webm', 716 | 'height': 480, 717 | 'width': 854, 718 | 'format_note': 'DASH video', 719 | 'acodec': 'none', 720 | 'container': 'webm', 721 | 'vcodec': 'vp8', 722 | 'preference': -40, 723 | 'video': true 724 | }, 725 | '278': { 726 | 'ext': 'webm', 727 | 'height': 144, 728 | 'format_note': 'DASH video', 729 | 'acodec': 'none', 730 | 'preference': -40, 731 | 'container': 'webm', 732 | 'vcodec': 'vp9', 733 | 'video': true 734 | }, 735 | '242': { 736 | 'ext': 'webm', 737 | 'height': 240, 738 | 'format_note': 'DASH video', 739 | 'acodec': 'none', 740 | 'preference': -40, 741 | 'video': true 742 | }, 743 | '243': { 744 | 'ext': 'webm', 745 | 'height': 360, 746 | 'format_note': 'DASH video', 747 | 'acodec': 'none', 748 | 'preference': -40, 749 | 'video': true 750 | }, 751 | '244': { 752 | 'ext': 'webm', 753 | 'height': 480, 754 | 'format_note': 'DASH video', 755 | 'acodec': 'none', 756 | 'preference': -40, 757 | 'video': true 758 | }, 759 | '245': { 760 | 'ext': 'webm', 761 | 'height': 480, 762 | 'format_note': 'DASH video', 763 | 'acodec': 'none', 764 | 'preference': -40, 765 | 'video': true 766 | }, 767 | '246': { 768 | 'ext': 'webm', 769 | 'height': 480, 770 | 'format_note': 'DASH video', 771 | 'acodec': 'none', 772 | 'preference': -40, 773 | 'video': true 774 | }, 775 | '247': { 776 | 'ext': 'webm', 777 | 'height': 720, 778 | 'format_note': 'DASH video', 779 | 'acodec': 'none', 780 | 'preference': -40, 781 | 'video': true 782 | }, 783 | '248': { 784 | 'ext': 'webm', 785 | 'height': 1080, 786 | 'format_note': 'DASH video', 787 | 'acodec': 'none', 788 | 'preference': -40, 789 | 'video': true 790 | }, 791 | '271': { 792 | 'ext': 'webm', 793 | 'height': 1440, 794 | 'format_note': 'DASH video', 795 | 'acodec': 'none', 796 | 'preference': -40, 797 | 'video': true 798 | }, 799 | '272': { 800 | 'ext': 'webm', 801 | 'height': 2160, 802 | 'format_note': 'DASH video', 803 | 'acodec': 'none', 804 | 'preference': -40, 805 | 'video': true 806 | }, 807 | '302': { 808 | 'ext': 'webm', 809 | 'height': 720, 810 | 'format_note': 'DASH video', 811 | 'acodec': 'none', 812 | 'preference': -40, 813 | 'fps': 60, 814 | 'vcodec': 'vp9', 815 | 'video': true 816 | }, 817 | '303': { 818 | 'ext': 'webm', 819 | 'height': 1080, 820 | 'format_note': 'DASH video', 821 | 'acodec': 'none', 822 | 'preference': -40, 823 | 'fps': 60, 824 | 'vcodec': 'vp9', 825 | 'video': true 826 | }, 827 | '308': { 828 | 'ext': 'webm', 829 | 'height': 1440, 830 | 'format_note': 'DASH video', 831 | 'acodec': 'none', 832 | 'preference': -40, 833 | 'fps': 60, 834 | 'vcodec': 'vp9', 835 | 'video': true 836 | }, 837 | '313': { 838 | 'ext': 'webm', 839 | 'height': 2160, 840 | 'format_note': 'DASH video', 841 | 'acodec': 'none', 842 | 'preference': -40, 843 | 'vcodec': 'vp9', 844 | 'video': true 845 | }, 846 | '315': { 847 | 'ext': 'webm', 848 | 'height': 2160, 849 | 'format_note': 'DASH video', 850 | 'acodec': 'none', 851 | 'preference': -40, 852 | 'fps': 60, 853 | 'vcodec': 'vp9', 854 | 'video': true 855 | }, 856 | //DASH webm audio 857 | '171': {'ext': 'webm', 'vcodec': 'none', 'format_note': 'DASH audio', 'abr': 128, 'preference': -50, 'audio': true}, 858 | '172': {'ext': 'webm', 'vcodec': 'none', 'format_note': 'DASH audio', 'abr': 256, 'preference': -50, 'audio': true}, 859 | //DASH webm audio with opus inside 860 | '249': { 861 | 'ext': 'webm', 862 | 'vcodec': 'none', 863 | 'format_note': 'DASH audio', 864 | 'acodec': 'opus', 865 | 'abr': 50, 866 | 'preference': -50, 867 | 'audio': true 868 | }, 869 | '250': { 870 | 'ext': 'webm', 871 | 'vcodec': 'none', 872 | 'format_note': 'DASH audio', 873 | 'acodec': 'opus', 874 | 'abr': 70, 875 | 'preference': -50, 876 | 'audio': true 877 | }, 878 | '251': { 879 | 'ext': 'webm', 880 | 'vcodec': 'none', 881 | 'format_note': 'DASH audio', 882 | 'acodec': 'opus', 883 | 'abr': 160, 884 | 'preference': -50, 885 | 'audio': true 886 | } 887 | }; 888 | 889 | let intGen = new UniqueNumberGenerator(); 890 | --------------------------------------------------------------------------------