BitChute: Video Download Button

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 resultList = /.+\.(.+)$/.exec(filePath)

    // Return
    return resultList ? resultList[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() }
})