Adds a "Download" button to the BitChute player. Also downloads thumbnails. Supports WebTorrent and native player.
// ==UserScript== // @name BitChute: Video Download Button // @namespace org.sidneys.userscripts // @homepage https://gist.githubusercontent.com/sidneys/b4783b0450e07e12942aa22b3a11bc00/raw/ // @version 30.7.7 // @description Adds a "Download" button to the BitChute player. Also downloads thumbnails. Supports WebTorrent and native player. // @author sidneys // @icon https://i.imgur.com/4GUWzW5.png // @noframes // @match *://www.bitchute.com/* // @require https://openuserjs.org/src/libs/sizzle/GM_config.js // @require https://greasyfork.org/scripts/38888-greasemonkey-color-log/code/Greasemonkey%20%7C%20Color%20Log.js // @require https://greasyfork.org/scripts/374849-library-onelementready-es7/code/Library%20%7C%20onElementReady%20ES7.js // @require https://cdn.jsdelivr.net/npm/[email protected]/moment.min.js // @connect bitchute.com // @grant GM.addStyle // @grant GM.download // @grant GM.registerMenuCommand // @grant GM.unregisterMenuCommand // @grant GM_getValue // @grant GM_setValue // @grant unsafeWindow // @run-at document-start // ==/UserScript== /** * ESLint * @global */ /* global Debug, onElementReady, moment */ Debug = false /** * Defaults * @constant * @default */ const timestampFormat = 'YYYY-MM-DD' const fileTitleSeparator = ' ' // const imageExtensions = ['jpg', 'png'] /** * Inject Stylesheet */ let injectStylesheet = () => { console.debug('injectStylesheet') GM.addStyle(` /* ========================================================================== ELEMENTS ========================================================================== */ /* a.plyr__control__download ========================================================================== */ a.plyr__control__download, a.plyr__control__download:hover { color: rgb(255, 255, 255); display: inline-block; animation: fade-in 0.3s; pointer-events: all; filter: none; cursor: pointer; white-space: nowrap; transition: all 500ms ease-in-out; } a.plyr__control__download:not(.plyr__control__download--download-ready) { opacity: 0; width: 0; padding: 0; } a.plyr__control__download--download-error { animation: 5000ms flash-red cubic-bezier(0.455, 0.03, 0.515, 0.955) 0s; } a.plyr__control__download--download-started { color: rgb(48, 162, 71); pointer-events: none; cursor: default; animation: 1000ms pulsating-opacity cubic-bezier(0.455, 0.03, 0.515, 0.955) 0s infinite alternate; } /* ========================================================================== ANIMATIONS ========================================================================== */ @keyframes pulsating-opacity { 0% { filter: opacity(1); } 25% { filter: opacity(1); } 50% { filter: opacity(0.75); } 75% { filter: opacity(1); } 100% { filter: opacity(1); } } @keyframes flash-red { 0% { color: unset; } 5% { color: rgb(239, 65, 54); } 50% { color: rgb(239, 65, 54); } 80% { color: rgb(239, 65, 54); } 100% { color: unset; } } `) } /** * @callback saveAsCallback * @param {Error} error - Error * @param {Number} progress - Progress fraction * @param {Boolean} complete - Completion Yes/No */ /** * Download File via Greasemonkey * @param {String} url - Target URL * @param {String} fileName - Target Filename * @param {saveAsCallback} callback - Callback */ let saveAs = (url, fileName, callback = () => {}) => { console.debug('saveAs') // Parse URL const urlObject = new URL(url) const urlHref = urlObject.href // Download // noinspection JSValidateTypes GM.download({ url: urlHref, name: fileName, saveAs: true, onerror: (download) => { console.debug('saveAs', 'onerror') callback(new Error(download.error ? download.error.toUpperCase() : 'Unknown')) }, onload: () => { console.debug('saveAs', 'onload') callback(null) }, ontimeout: () => { console.debug('saveAs', 'ontimeout') callback(new Error('Network timeout')) } }) } /** * Sanitize file name component for safe usage ("filename:.extension" -> ) * @param {String} fileName - File name * @return {String} - Safe Filename */ let sanitizeFileNameComponent = (fileName = '') => fileName.replace(/[^a-z0-9._-]/gi, '_') /** * Parse file title ("title.extension") * @param {String} filePath - File path * @return {String} File title */ let parseFileTitle = (filePath = '') => filePath.split('/').pop().split('.')[0] /** * Parse file extension ("title.extension") * @param {String} filePath - File path * @return {String} File extension */ let parseFileExtension = (filePath = '') => { console.debug('parseFileExtension') // Apply regular expression const r###ltList = /.+\.(.+)$/.exec(filePath) // Return return r###ltList ? r###ltList[1] : void 0 } /** * Look up Video Timestamp * @return {String|void} - Video Timestamp */ let lookupVideoTimestamp = () => { console.debug('lookupVideoTimestamp') // Look up const element = document.querySelector('.video-publish-date') if (!element) { return } // Format date components const text = element.textContent.split('at').pop() const formatted = moment.utc(text, 'HH:mm UTC on MMMM Do, YYYY').format(timestampFormat) // Return return formatted } /** * Look up Video Author * @return {String|void} - Video Author */ let lookupVideoAuthor = () => { console.debug('lookupVideoAuthor') // Look up const element = document.querySelector('p.owner > a') // Return return element ? element.textContent.trim() : void 0 } /** * Look up Video Title * @return {String|void} - Video Title */ let lookupVideoTitle = () => { console.debug('lookupVideoTitle') // Look up const element = document.querySelector('h1.page-title') || document.querySelector('title') // Return return element ? element.textContent.trim() : void 0 } /** * Look up Video Poster Image * @return {String|void} - Poster Image URL */ let lookupPosterUrl = () => { console.debug('lookupVideoPoster') // Look up const url = document.querySelector('video').poster || document.querySelector('meta[name="twitter:url"]') // Return return url } /** * Generate file title for downloaded files ("title.extension") * @return {String} File name */ let generateDownloadedFileTitle = () => { console.debug('generateDownloadedFileTitle') // Lookup file title components const timestamp = lookupVideoTimestamp() const author = sanitizeFileNameComponent(lookupVideoAuthor()) const title = sanitizeFileNameComponent(lookupVideoTitle()) // Set file title components, removing empty components let fileTitleList = [ timestamp, author, title ] fileTitleList = fileTitleList.filter(Boolean) // Join file title components const fileTitle = fileTitleList.join(fileTitleSeparator) // Return return fileTitle } /** * Render download button * @param {Array} urlList - Target URLs */ let renderDownloadButton = (urlList) => { console.debug('renderDownloadButton') /** * Create Button */ // Setup Button Element const anchorElement = document.createElement('a') anchorElement.className = 'plyr__control plyr__control__download' anchorElement.innerHTML = ` <svg role="presentation" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <path fill="currentColor" d="M216 0h80c13.3 0 24 10.7 24 24v168h87.7c17.8 0 26.7 21.5 14.1 34.1L269.7 378.3c-7.5 7.5-19.8 7.5-27.3 0L90.1 226.1c-12.6-12.6-3.7-34.1 14.1-34.1H192V24c0-13.3 10.7-24 24-24zm296 376v112c0 13.3-10.7 24-24 24H24c-13.3 0-24-10.7-24-24V376c0-13.3 10.7-24 24-24h146.7l49 49c20.1 20.1 52.5 20.1 72.6 0l49-49H488c13.3 0 24 10.7 24 24zm-124 88c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20zm64 0c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20z"></path> </svg> <span class="plyr__tooltip">Download Video</span> ` //anchorElement.href = '#' anchorElement.href = urlList[0] anchorElement.target = '_blank' anchorElement.rel = 'noopener noreferrer' anchorElement.type = 'video/mp4' // Render Button Element const parentElement = document.querySelector('.plyr__controls') parentElement.appendChild(anchorElement) anchorElement.classList.add('plyr__control__download--download-ready') /** const thumbnail = GM_config.get('Thumbnail') console.warn(11111, urlList) console.warn(44444, thumbnail) /** * URL Filter / Restrict downloads */ /** if (thumbnail) { urlList = urlList.filter((url) => { const extension = url.split('.').pop() console.warn(33333, extension) if (imageExtensions.includes(extension)) { return false } }) } console.warn(22222, urlList) */ /** * Download URLs */ // Add Button Events anchorElement.onclick = (event) => { // Cancel regular download event.preventDefault() // Reset classes anchorElement.classList.remove('plyr__control__download--download-error') anchorElement.classList.add('plyr__control__download--download-started') // Download each URL urlList.forEach((url, urlIndex) => { // Parse URL const urlObject = new URL(url) const urlHref = urlObject.href const urlPathname = urlObject.pathname // Generate file name const fileTitle = generateDownloadedFileTitle() || parseFileTitle(urlPathname) const fileExtension = parseFileExtension(urlPathname) const fileName = fileTitle + (fileExtension ? `.${fileExtension}` : '') // Status console.info('Downloading:', urlHref, `(${urlIndex + 1} of ${urlList.length})`) // Start download saveAs(urlHref, fileName, (error) => { // Error if (error) { anchorElement.classList.remove('plyr__control__download--download-started') anchorElement.classList.add('plyr__control__download--download-error') return } // Success anchorElement.classList.remove('plyr__control__download--download-started') // Status console.info('Download complete:', fileName) }) }) } // Status console.debug('Download button added for URLs:', urlList.join(', ')) } /** * Init */ let init = () => { console.info('init') // Add Stylesheet injectStylesheet() //GM.registerMenuCommand('Download thumbnails', func) GM_config.init( { 'id': 'MyConfig', 'title': 'Script Settings', 'fields': { 'Thumbnails': { 'label': 'Download Thumbnails', 'type': 'checkbox', 'default': true } } }) // GM_config.open() // Wait for HTML video player (.plyr) onElementReady('.plyr', false, () => { // Check if BitChute is using WebTorrent Player or Native Player if (unsafeWindow.webtorrent) { console.info('Detected WebTorrent Video Player.') // WebTorrent: Wait for WebTorrent instance const torrent = unsafeWindow.webtorrent.torrents[0] torrent.on('ready', () => { // Create Download Button for Poster Image and Video // renderDownloadButton([ lookupPosterUrl(), torrent.urlList[0] ]) renderDownloadButton([ torrent.urlList[0] ]) }) } else { console.info('Detected Native Video Player.') // Native Player: Wait for <source> element onElementReady('source', false, (element) => { // Create Download Button for Poster Image and Video // rrenderDownloadButton([ lookupPosterUrl(), element.src ]) renderDownloadButton([ element.src ]) }) } }) } /** * @listens document:Event#readystatechange */ document.addEventListener('readystatechange', () => { console.debug('document#readystatechange', document.readyState) if (document.readyState === 'interactive') { init() } })