RedGIFs Video Download Button

Adds a download button to the sidebar of each video on the RedGIFs site for one-click HD downloads.

// ==UserScript==
// @name         RedGIFs Video Download Button
// @namespace    https://github.com/p65536
// @version      1.0.0
// @license      MIT
// @description  Adds a download button to the sidebar of each video on the RedGIFs site for one-click HD downloads.
// @icon         https://www.redgifs.com/favicon.ico
// @author       p65536
// @match        https://*.redgifs.com/*
// @grant        none
// @run-at       document-idle
// @noframes
// ==/UserScript==

(function () {
    'use strict';

    // =================================================================================
    // SECTION: Script-Specific Definitions
    // =================================================================================

    const APPID = 'rgvdb';
    const LOG_PREFIX = `[${APPID.toUpperCase()}]`;
    const CONSTANTS = {
        SIDEBAR_SELECTOR: '.SideBar',
        VIDEO_CONTAINER_SELECTOR: '.GifPreview',
        API_URL_BASE: 'https://api.redgifs.com/v2/gifs/',
        API_AUTH_URL: 'https://api.redgifs.com/v2/auth/temporary',
        FILENAME_SUFFIX: '-hd.mp4',
        TOAST_DURATION: 3000,
        TOAST_FADE_OUT_DURATION: 300,
        ICON_REVERT_DELAY: 2000,
    };
    const BASE_ICON_PROPS = {
        xmlns: 'http://www.w3.org/2000/svg',
        height: '24px',
        viewBox: '0 0 24 24',
        width: '24px',
        fill: '#e3e3e3',
    };
    const ICONS = {
        DOWNLOAD: {
            tag: 'svg',
            props: BASE_ICON_PROPS,
            children: [
                { tag: 'path', props: { d: 'M0 0h24v24H0V0z', fill: 'none' } },
                { tag: 'path', props: { d: 'M19 9h-4V3H9v6H5l7 7 7-7zm-8 2V5h2v6h1.17L12 13.17 9.83 11H11zm-6 7h14v2H5z' } },
            ],
        },
        SPINNER: {
            tag: 'svg',
            props: { ...BASE_ICON_PROPS, class: `${APPID}-spinner` },
            children: [
                { tag: 'path', props: { d: 'M12,2A10,10,0,1,0,22,12,10,10,0,0,0,12,2Zm0,18A8,8,0,1,1,20,12,8,8,0,0,1,12,20Z', opacity: '0.3' } },
                { tag: 'path', props: { d: 'M12,2A10,10,0,0,1,22,12h-2A8,8,0,0,0,12,4Z' } },
            ],
        },
        SUCCESS: {
            tag: 'svg',
            props: BASE_ICON_PROPS,
            children: [
                { tag: 'path', props: { d: 'M0 0h24v24H0V0z', fill: 'none' } },
                { tag: 'path', props: { d: 'M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z' } },
            ],
        },
        ERROR: {
            tag: 'svg',
            props: BASE_ICON_PROPS,
            children: [
                { tag: 'path', props: { d: 'M0 0h24v24H0V0z', fill: 'none' } },
                { tag: 'path', props: { d: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z' } },
            ],
        },
    };

    // =================================================================================
    // SECTION: Execution Guard
    // =================================================================================

    window.__p65536_guard__ = window.__p65536_guard__ || {};
    if (window.__p65536_guard__[`${APPID}_executed`]) return;
    window.__p65536_guard__[`${APPID}_executed`] = true;

    // =================================================================================
    // SECTION: Logging Utility
    // =================================================================================

    const Logger = {
        log(...args) {
            console.log(LOG_PREFIX, ...args);
        },
        warn(...args) {
            console.warn(LOG_PREFIX, ...args);
        },
        error(...args) {
            console.error(LOG_PREFIX, ...args);
        },
    };

    // =================================================================================
    // SECTION: Utility Functions
    // =================================================================================

    /**
     * Creates a DOM element using a hyperscript-style syntax.
     * @param {string} tag - Tag name with optional ID/class (e.g., "div#app.container").
     * @param {Object|Array|string|Node} [propsOrChildren] - Attributes object or children.
     * @param {Array|string|Node} [children] - Children (if props are specified).
     * @returns {HTMLElement|SVGElement} - The created DOM element.
     */
    function h(tag, propsOrChildren, children) {
        const SVG_NS = 'http://www.w3.org/2000/svg';
        const match = tag.match(/^([a-z0-9-]+)(#[\w-]+)?((\.[\w-]+)*)$/i);
        if (!match) throw new Error(`Invalid tag syntax: ${tag}`);

        const [, tagName, id, classList] = match;
        const isSVG = ['svg', 'path'].includes(tagName);
        const el = isSVG ? document.createElementNS(SVG_NS, tagName) : document.createElement(tagName);

        if (id) el.id = id.slice(1);
        if (classList) el.className = classList.replace(/\./g, ' ').trim();

        let props = {};
        let childrenArray;
        if (propsOrChildren && typeof propsOrChildren === 'object' && !Array.isArray(propsOrChildren) && !(propsOrChildren instanceof Node)) {
            props = propsOrChildren;
            childrenArray = children;
        } else {
            childrenArray = propsOrChildren;
        }

        for (const [key, value] of Object.entries(props)) {
            if (key.startsWith('on') && typeof value === 'function') {
                el.addEventListener(key.slice(2).toLowerCase(), value);
            } else if (value !== false && value != null) {
                el.setAttribute(key, value === true ? '' : value);
            }
        }

        const fragment = document.createDocumentFragment();
        function append(child) {
            if (child == null || child === false) return;
            if (Array.isArray(child)) {
                child.forEach(append);
            } else {
                fragment.appendChild(child instanceof Node ? child : document.createTextNode(child));
            }
        }
        append(childrenArray);
        el.appendChild(fragment);
        return el;
    }

    /**
     * Recursively builds a DOM element from a definition object using the h() function.
     * @param {object} def The definition object for the element.
     * @returns {HTMLElement | SVGElement | null} The created DOM element.
     */
    function createIconFromDef(def) {
        if (!def) return null;
        const children = def.children ? def.children.map((child) => createIconFromDef(child)) : [];
        return h(def.tag, def.props, children);
    }

    // =================================================================================
    // SECTION: API Manager
    // =================================================================================

    class ApiManager {
        #guestToken = null;
        #videoCache = {};

        /**
         * Fetches a temporary guest token from the API if one is not already stored.
         * @returns {Promise<string>} The guest token.
         * @private
         */
        async #getGuestToken() {
            if (this.#guestToken) {
                return this.#guestToken;
            }

            const response = await fetch(CONSTANTS.API_AUTH_URL);
            if (!response.ok) {
                throw new Error(`API auth request failed with status ${response.status}`);
            }
            const responseData = await response.json();
            if (!responseData?.token) {
                throw new Error('Token not found in guest auth response.');
            }

            this.#guestToken = responseData.token;
            Logger.log('Successfully fetched guest token.');
            return this.#guestToken;
        }

        /**
         * Fetches video information from the RedGifs API, using a cache.
         * It ensures a guest token is available before making the request.
         * @param {string} videoId - The ID of the video to fetch.
         * @returns {Promise<object>} The video information object.
         */
        async getVideoInfo(videoId) {
            if (this.#videoCache[videoId]) {
                return this.#videoCache[videoId];
            }

            try {
                const token = await this.#getGuestToken();
                const response = await fetch(`${CONSTANTS.API_URL_BASE}${videoId}`, {
                    headers: { Authorization: `Bearer ${token}` },
                });

                if (!response.ok) {
                    throw new Error(`API request failed with status ${response.status}`);
                }
                const responseData = await response.json();
                this.#videoCache[videoId] = responseData; // Cache the successful response.
                return responseData;
            } catch (error) {
                Logger.error(`Error fetching video info for ${videoId}:`, error);
                // Rethrow the error to be handled by the caller.
                throw error;
            }
        }
    }

    // =================================================================================
    // SECTION: Main Application Controller
    // =================================================================================

    class RedGifsDownloader {
        constructor() {
            /** @type {ApiManager} */
            this.apiManager = new ApiManager();
            /** @type {HTMLElement|null} */
            this.toastContainer = null;

            this._injectStyles();
            this._createToastContainer();
        }

        /**
         * Initializes the script by setting up a MutationObserver to watch for new content.
         */
        init() {
            const observer = new MutationObserver((mutations) => {
                for (const mutation of mutations) {
                    if (mutation.addedNodes.length > 0) {
                        this._processNodes(mutation.addedNodes);
                    }
                }
            });

            observer.observe(document.body, {
                childList: true,
                subtree: true,
            });

            // Initial check for any sidebars that might have loaded before the observer started.
            this._processNodes(document.body.childNodes);
            Logger.log('Initialized and observing DOM for changes.');
        }

        /**
         * Injects the necessary CSS styles into the document's head.
         * @private
         */
        _injectStyles() {
            const css = `
                .${APPID}_DL_Button {
                    background-color: red;
                    border: none;
                    border-radius: 4px;
                    padding: 0;
                    display: flex;
                    align-items: center;
                    justify-content: center;
                    cursor: pointer;
                }
                .${APPID}-spinner {
                    animation: ${APPID}-spinner-rotate 1s linear infinite;
                    transform-origin: center;
                }
                .${APPID}-toast-container {
                    position: fixed;
                    top: 20px;
                    right: 20px;
                    z-index: 9999;
                    display: flex;
                    flex-direction: column;
                    gap: 10px;
                }
                .${APPID}-toast {
                    padding: 12px 18px;
                    border-radius: 6px;
                    color: white;
                    font-family: sans-serif;
                    font-size: 14px;
                    box-shadow: 0 4px 12px rgb(0 0 0 / 0.15);
                    animation: ${APPID}-toast-fade-in 0.3s ease-out;
                }
                .${APPID}-toast.exiting {
                    animation: ${APPID}-toast-fade-out 0.3s ease-in forwards;
                }
                .${APPID}-toast-success { background-color: rgb(40, 167, 69); }
                .${APPID}-toast-error { background-color: rgb(220, 53, 69); }
                .${APPID}-toast-info { background-color: rgb(23, 162, 184); }

                @keyframes ${APPID}-spinner-rotate {
                    from { transform: rotate(0deg); }
                    to { transform: rotate(360deg); }
                }
                @keyframes ${APPID}-toast-fade-in {
                    from { opacity: 0; transform: translateX(100%); }
                    to { opacity: 1; transform: translateX(0); }
                }
                @keyframes ${APPID}-toast-fade-out {
                    from { opacity: 1; transform: translateX(0); }
                    to { opacity: 0; transform: translateX(100%); }
                }
            `;
            const styleElement = h('style', { type: 'text/css' }, css);
            document.head.appendChild(styleElement);
        }

        /**
         * Creates and appends the toast container to the document body.
         * @private
         */
        _createToastContainer() {
            this.toastContainer = h(`div.${APPID}-toast-container`);
            document.body.appendChild(this.toastContainer);
        }

        /**
         * Displays a toast notification.
         * @param {string} message The message to display.
         * @param {'info'|'success'|'error'} type The type of toast.
         * @private
         */
        _showToast(message, type = 'info') {
            if (!this.toastContainer) {
                Logger.error('Toast container element not found. Cannot display toast.');
                return;
            }

            const toastClass = `${APPID}-toast-${type}`;
            const toastElement = h(`div.${APPID}-toast`, { class: toastClass }, message);

            this.toastContainer.appendChild(toastElement);

            // Start the process to remove the toast after a delay.
            setTimeout(() => {
                toastElement.classList.add('exiting');

                // Set a second, final timeout to remove the element from the DOM.
                setTimeout(() => {
                    toastElement.remove();
                }, CONSTANTS.TOAST_FADE_OUT_DURATION);
            }, CONSTANTS.TOAST_DURATION);
        }

        /**
         * Processes a list of nodes to find sidebars and add download buttons.
         * @param {NodeList} nodes - The list of nodes to process.
         * @private
         */
        _processNodes(nodes) {
            nodes.forEach((node) => {
                // Ensure the node is an element and can contain other elements before querying it.
                if (node.nodeType !== Node.ELEMENT_NODE) {
                    return;
                }

                // Find all sidebars within the node that don't already have our button.
                const watchSelector = `${CONSTANTS.SIDEBAR_SELECTOR}:not(:has(.${APPID}_DL_Button))`;
                const sidebars = node.querySelectorAll(watchSelector);

                sidebars.forEach((sidebar) => {
                    this._onSidebarFound(sidebar);
                });
            });
        }

        /**
         * Handles the discovery of a new sidebar element.
         * @param {HTMLElement} sidebar - The found sidebar element.
         * @private
         */
        _onSidebarFound(sidebar) {
            const gifContainer = sidebar.closest(CONSTANTS.VIDEO_CONTAINER_SELECTOR);
            if (gifContainer && gifContainer.id) {
                // ID is typically in the format "gif_some-id"
                const videoId = gifContainer.id.split('_')[1];
                if (videoId) {
                    this._addDownloadButton(sidebar, videoId);
                }
            }
        }

        /**
         * Sets the icon for a given button.
         * @param {HTMLButtonElement} button The button element to modify.
         * @param {'DOWNLOAD'|'SPINNER'|'SUCCESS'|'ERROR'} iconName The name of the icon to set.
         * @private
         */
        _setButtonIcon(button, iconName) {
            const iconDef = ICONS[iconName];
            if (!iconDef) {
                Logger.error(`Icon "${iconName}" not found.`);
                return;
            }

            // Clear existing content
            while (button.firstChild) {
                button.removeChild(button.firstChild);
            }

            // Add new icon
            const newIcon = createIconFromDef(iconDef);
            if (newIcon) {
                newIcon.style.width = '24px';
                newIcon.style.height = '24px';
                button.appendChild(newIcon);
            }
        }

        /**
         * Updates the button's visual state and reverts it after a delay for transient states.
         * @param {HTMLButtonElement} button The button to update.
         * @param {'IDLE'|'LOADING'|'SUCCESS'|'ERROR'} state The new state.
         * @private
         */
        _updateButtonState(button, state) {
            const stateMap = {
                IDLE: { icon: 'DOWNLOAD', disabled: false },
                LOADING: { icon: 'SPINNER', disabled: true },
                SUCCESS: { icon: 'SUCCESS', disabled: true },
                ERROR: { icon: 'ERROR', disabled: true },
            };

            const { icon, disabled } = stateMap[state] || stateMap.IDLE;
            this._setButtonIcon(button, icon);
            button.disabled = disabled;

            // Revert to IDLE state after a delay for success or error states.
            if (state === 'SUCCESS' || state === 'ERROR') {
                setTimeout(() => {
                    this._updateButtonState(button, 'IDLE');
                }, CONSTANTS.ICON_REVERT_DELAY);
            }
        }

        /**
         * Creates and adds a download button to the specified sidebar.
         * @param {HTMLElement} sidebar - The sidebar element to add the button to.
         * @param {string} videoId - The ID of the video to be downloaded.
         * @private
         */
        _addDownloadButton(sidebar, videoId) {
            const button = h(`button.${APPID}_DL_Button`, {
                title: 'Download HD Video',
                onclick: (e) => this._handleDownloadClick(e, videoId),
            });

            this._setButtonIcon(button, 'DOWNLOAD');

            // Styling the button to match others in the sidebar.
            const siblingButton = sidebar.querySelector('button');
            const size = siblingButton ? window.getComputedStyle(siblingButton).height : '30px';

            // Set dynamic dimensions. Static styles are now handled by an injected stylesheet.
            Object.assign(button.style, {
                width: size,
                height: size,
            });

            const listItem = h('li.SideBar-Item', [button]);
            sidebar.appendChild(listItem);
        }

        /**
         * Handles the click event on the download button.
         * @param {MouseEvent} e - The click event.
         * @param {string} videoId - The ID of the video to download.
         * @private
         */
        async _handleDownloadClick(e, videoId) {
            e.stopPropagation(); // Prevent parent elements from handling the click.

            const button = e.currentTarget;
            if (button.disabled) return; // Prevent multiple clicks while processing

            this._updateButtonState(button, 'LOADING');
            this._showToast('Download started...', 'info');

            try {
                const videoInfo = await this.apiManager.getVideoInfo(videoId); // Use the ApiManager
                if (!videoInfo?.gif?.urls?.hd) {
                    throw new Error('Failed to get video info or HD URL.');
                }

                const downloadUrl = videoInfo.gif.urls.hd;
                const filename = `${videoInfo.gif.id}${CONSTANTS.FILENAME_SUFFIX}`;
                await this._downloadFile(downloadUrl, filename);

                this._updateButtonState(button, 'SUCCESS');
                this._showToast('Download successful!', 'success');
            } catch (error) {
                Logger.error('Download failed:', error);
                this._updateButtonState(button, 'ERROR');
                // Display the specific error message from the exception.
                this._showToast(`Error: ${error.message}`, 'error');
            }
        }

        /**
         * Fetches a temporary guest token from the API if one is not already stored.
         * @returns {Promise<string|null>} The guest token or null on failure.
         * @private
         */
        async _getGuestToken() {
            if (this.guestToken) {
                return this.guestToken;
            }
            try {
                const response = await fetch(CONSTANTS.API_AUTH_URL);
                if (!response.ok) {
                    throw new Error(`API request failed with status ${response.status}`);
                }
                const responseData = await response.json();
                if (responseData && responseData.token) {
                    this.guestToken = responseData.token;
                    Logger.log('Successfully fetched guest token.');
                    return this.guestToken;
                }
                throw new Error('Token not found in guest auth response.');
            } catch (error) {
                Logger.error('Failed to get guest token:', error);
                return null;
            }
        }

        /**
         * Fetches video information from the RedGifs API, using a cache.
         * It ensures a guest token is available before making the request.
         * @param {string} videoId - The ID of the video to fetch.
         * @returns {Promise<object|null>} The video information object or null on failure.
         * @private
         */
        async _getVideoInfo(videoId) {
            if (this.videoCache[videoId]) {
                return this.videoCache[videoId];
            }
            try {
                const token = await this._getGuestToken();
                if (!token) {
                    throw new Error('Could not retrieve guest token to make the request.');
                }

                const response = await fetch(`${CONSTANTS.API_URL_BASE}${videoId}`, {
                    headers: { Authorization: `Bearer ${token}` },
                });

                if (!response.ok) {
                    throw new Error(`API request failed with status ${response.status}`);
                }
                const responseData = await response.json();
                this.videoCache[videoId] = responseData; // Cache the successful response.
                return responseData;
            } catch (error) {
                Logger.error('Error fetching video info:', error);
                return null;
            }
        }

        /**
         * Initiates a download for the given URL.
         * @param {string} url The URL of the video to download.
         * @param {string} filename The desired filename for the downloaded video.
         * @private
         */
        async _downloadFile(url, filename) {
            const response = await fetch(url);
            // Throw a more user-friendly error message for HTTP errors.
            if (!response.ok) throw new Error(`Server responded with ${response.status}`);
            const videoBlob = await response.blob();
            const link = h('a', {
                href: URL.createObjectURL(videoBlob),
                download: filename,
            });
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
            URL.revokeObjectURL(link.href);
        }
    }

    // =================================================================================
    // SECTION: Entry Point
    // =================================================================================

    const app = new RedGifsDownloader();
    app.init();
})();