// ==UserScript==
// @name         Twitter DL - Click "Always Allow"!
// @version      1.1.2
// @description  Download twitter videos directly from your browser! (CLICK "ALWAYS ALLOW" IF PROMPTED!)
// @author       realcoloride
// @license      MIT
// @namespace    https://twitter.com/*
// @namespace    https://x.com/*
// @match        https://twitter.com/*
// @match        https://x.com/*
// @match        https://pro.twitter.com/*
// @match        https://pro.x.com/*
// @connect      twitter-video-download.com
// @connect      twimg.com
// @icon         https://www.google.com/s2/favicons?sz=64&domain=twitter.com
// @grant        GM.xmlHttpRequest
// ==/UserScript==
(function() {
    let injectedTweets = [];
    const checkFrequency = 150; // in milliseconds
    const apiEndpoint = "https://twitter-video-download.com/fr/tweet/";
    const downloadText = "Download"
    const style =
    `.dl-video {
        padding: 6px;
        padding-left: 5px;
        padding-right: 5px;
        margin-left: 5px;
        margin-bottom: 2px;
        border-color: black;
        border-style: none;
        border-radius: 10px;
        color: white;
        background-color: rgba(39, 39, 39, 0.46);
        font-family: Arial, Helvetica, sans-serif;
        font-size: xx-small;
        cursor: pointer;
    }
    .dl-hq {
        background-color: rgba(28, 199, 241, 0.46);
    }
    .dl-lq {
        background-color: rgba(185, 228, 138, 0.46);
    }
    .dl-gif {
        background-color: rgba(219, 117, 22, 0.46);
    }
    `;
    // Styles
    function injectStyles() {
        const styleElement = document.createElement("style");
        styleElement.textContent = style;
        document.head.appendChild(styleElement);
    }
    injectStyles();
    // Snippet extraction
    function getRetweetFrame(tweetElement) {
        let retweetFrame = null;
        const candidates = tweetElement.querySelectorAll(`[id^="id__"]`);
        candidates.forEach((candidate) => {
            const candidateFrame = candidate.querySelector('div[tabindex="0"][role="link"]');
            if (candidateFrame)
                retweetFrame = candidateFrame;
        });
        return retweetFrame;
    }
    function getTopBar(tweetElement, isRetweet) {
        // I know its kind of bad but it works
        let element = tweetElement;
        if (isRetweet) {
            const retweetFrame = getRetweetFrame(tweetElement);
            const videoPlayer = tweetElement.querySelector('[data-testid="videoPlayer"]');
            const videoPlayerOnRetweet = retweetFrame.querySelector('[data-testid="videoPlayer"]')
            const isVideoOnRetweet = (videoPlayer == videoPlayerOnRetweet);
            if (videoPlayerOnRetweet && isVideoOnRetweet) element = retweetFrame;
            else if (videoPlayerOnRetweet == null) element = tweetElement;
        }
        const userName = element.querySelector('[data-testid="User-Name"]');
        if (isRetweet && element != tweetElement) return userName.parentNode.parentNode;
        return userName.parentNode.parentNode.parentNode;
    }
    // Fetching
    async function getMediasFromTweetId(tweetInformation) {
        const id = tweetInformation.id;
        const payload = {
            "url": `${apiEndpoint}${id}`,
            "headers": {
                "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
                "accept-language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
                "cache-control": "max-age=0",
                "sec-ch-ua": "\"Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"115\", \"Chromium\";v=\"115\"",
                "sec-ch-ua-mobile": "?0",
                "sec-ch-ua-platform": "\"Windows\"",
                "sec-fetch-dest": "document",
                "sec-fetch-mode": "navigate",
                "sec-fetch-site": "same-origin",
                "sec-fetch-user": "?1",
                "upgrade-insecure-requests": "1"
              },
            "referrer": "https://twitter-video-download.com/en",
            "referrerPolicy": "strict-origin-when-cross-origin",
            "body": null,
            "method": "GET",
            "mode": "cors",
            "credentials": "omit"
        };
        const request = await GM.xmlHttpRequest(payload);
        let lq = null;
        let hq = null;
        try {
            const regex = /https:\/\/[a-zA-Z0-9_-]+\.twimg\.com\/[a-zA-Z0-9_\-./]+\.mp4/g;
            const text = request.responseText;
            const links = text.match(regex);
            // Calculate the size of a video based on resolution
            function calculateSize(resolution) {
                const parts = resolution.split("x");
                const width = parseInt(parts[0]);
                const height = parseInt(parts[1]);
                return width * height;
            }
            if (!links) return null;
            // Map links to objects with resolution and size
            const linkObjects = links.map(link => {
                const resolutionMatch = link.match(/\/(\d+x\d+)\//);
                const resolution = resolutionMatch ? resolutionMatch[1] : "";
                const size = calculateSize(resolution);
                return { link, resolution, size };
            });
            // Sort linkObjects based on size in descending order
            linkObjects.sort((a, b) => a.size - b.size);
            // Create a Set to track seen links and store unique links
            const uniqueLinks = new Set();
            const deduplicatedLinks = [];
            for (const obj of linkObjects) {
                if (!uniqueLinks.has(obj.link)) {
                    uniqueLinks.add(obj.link);
                    deduplicatedLinks.push(obj.link);
                }
            }
            if (tweetInformation.isGif && tweetInformation.tabIndex == "-1" ||
                links[0].startsWith('https://video.twimg.com/tweet_video/')
            ) {
                lq = links[0];
            } else {
                lq = deduplicatedLinks[0];
                if (deduplicatedLinks.length > 1) hq = deduplicatedLinks[deduplicatedLinks.length-1];
                // first quality is VERY bad so if can swap to second (medium) then its better
                if (deduplicatedLinks.length > 2) lq = deduplicatedLinks[1];
            }
        } catch (error) {
            console.error(error);
            return null;
        }
        return {lq, hq};
    }
    // Downloading
    async function downloadFile(button, url, mode, filename) {
        const baseText = `${downloadText} (${mode.toUpperCase()})`;
        button.disabled = true;
        button.innerText = "Downloading...";
        console.log(`[TwitterDL] Downloading Tweet URL (${mode.toUpperCase()}): ${url}`);
        function finish() {
            if (button.innerText == baseText) return;
            button.disabled = false;
            button.innerText = baseText;
        }
        GM.xmlHttpRequest({
            method: 'GET',
            url: url,
            responseType: 'blob',
            onload: function(response) {
                const blob = response.response;
                const link = document.createElement('a');
                link.href = URL.createObjectURL(blob);
                link.setAttribute('download', filename);
                link.click();
                URL.revokeObjectURL(link.href);
                button.innerText = 'Downloaded!';
                button.disabled = false;
                setTimeout(finish, 1000);
            },
            onerror: function(error) {
                console.error('[TwitterDL] Download Error:', error);
                button.innerText = 'Download Failed';
                setTimeout(finish, 1000);
            },
            onprogress: function(progressEvent) {
                if (progressEvent.lengthComputable) {
                    const percentComplete = Math.round((progressEvent.loaded / progressEvent.total) * 100);
                    button.innerText = `Downloading: ${percentComplete}%`;
                } else
                    button.innerText = 'Downloading...';
            }
        });
    }
    function createDownloadButton(tweetInformation, url, tag) {
        const button = document.createElement("button");
        button.hidden = true;
        const username = tweetInformation.username;
        const filename = `TwitterDL_${username}_${tweetInformation.id}`;
        button.classList.add("dl-video", `dl-${tag}`);
        button.innerText = `${downloadText} (${tag.toUpperCase()})`;
        button.setAttribute("href", url);
        button.setAttribute("download", "");
        button.addEventListener('click', async() => {
            await downloadFile(button, url, tag, filename);
        });
        button.hidden = false;
        return button;
    }
    function createDownloadButtons(tweetElement) {
        const tweetInformation = getTweetInformation(tweetElement);
        if (!tweetInformation) return;
        getMediasFromTweetId(tweetInformation).then((medias) => {
            if (!medias) return;
            const retweetFrame = getRetweetFrame(tweetElement);
            const isRetweet = (retweetFrame != null);
            let lowQualityButton;
            let highQualityButton;
            const lq = medias.lq;
            const hq = medias.hq;
            if (lq) lowQualityButton  = createDownloadButton(tweetInformation, lq, tweetInformation.isGif ? "gif" : "lq");
            if (hq && !tweetInformation.isGif) highQualityButton = createDownloadButton(tweetInformation, hq, "hq");
            const videoPlayer = isRetweet ? tweetElement.querySelector('[data-testid="videoPlayer"]') : null;
            const videoPlayerOnRetweet = isRetweet ? retweetFrame.querySelector('[data-testid="videoPlayer"]') : null;
            const topBar = getTopBar(tweetElement, isRetweet);
            const threeDotsElement = topBar.lastChild
            const isVideoOnRetweet = (videoPlayer == videoPlayerOnRetweet);
            if (!lowQualityButton && !highQualityButton) return;
            // Order: HQ then LQ
            if (videoPlayer != null && isRetweet && isVideoOnRetweet) {
                // Add a little side dot
                addSideTextToRetweet(tweetElement, " · ", 6, 5);
                if (highQualityButton) topBar.appendChild(highQualityButton);
                if (lowQualityButton)  topBar.appendChild(lowQualityButton);
            } else {
                if (lowQualityButton)  topBar.insertBefore(lowQualityButton, threeDotsElement);
                if (highQualityButton) topBar.insertBefore(highQualityButton, lowQualityButton);
            }
        })
    }
    function addSideTextToRetweet(tweetElement, text, forcedMargin, forcedWidth) {
        const timeElement = tweetElement.querySelector("time");
        const computedStyles = window.getComputedStyle(timeElement);
        // Make a new text based on the font and color
        const textElement = timeElement.cloneNode(true);
        textElement.innerText = text;
        textElement.setAttribute("datetime", "");
        for (const property of computedStyles) {
            textElement.style[property] = computedStyles.getPropertyValue(property);
        }
        textElement.style.overflow = "visible";
        textElement.style["padding-left"] = "4px";
        textElement.style["margin-left"] = forcedMargin || 0;
        const tweetAvatarElement = tweetElement.querySelectorAll('[data-testid="Tweet-User-Avatar"]')[1];
        const targetTweetBar = tweetAvatarElement.parentNode;
        targetTweetBar.appendChild(textElement);
        const contentWidth = textElement.scrollWidth;
        textElement.style.width = (forcedWidth || contentWidth) + 'px';
        injectedFallbacks.push(tweetElement);
    }
    // Page information gathering
    function getTweetsInPage() {
        return document.getElementsByTagName("article");
    }
    let injectedFallbacks = [];
    function getTweetInformation(tweetElement) {
        let information = {};
        // ID
        // Check the tweet timestamp, it has a link with the id at the end
        // In case something goes wrong, a fallback text is shown
        let id = null;
        let username = null;
        let tweetUrl = null;
        let isGif = false;
        let tabIndex = null;
        const retweetFrame = getRetweetFrame(tweetElement);
        const isRetweet = (retweetFrame != null);
        const videoPlayer = isRetweet ? retweetFrame.querySelector('[data-testid="videoPlayer"]') : null;
        const isPost = (isStatusUrl(window.location.href));
        tabIndex = tweetElement.getAttribute('tabindex');
        const regex = /https:\/\/(?:pro\.)?x\.com\/([^\/]+)\/status\/(\d+)/;
        function setInfo(url) {
            const match = url.match(regex);
            id = match[2];
            username = match[1];
            tweetUrl = url;
        }
        const url = window.location.href;
        try {
            setInfo(url);
        } catch {}
        function fallback(reason) {
            if (injectedFallbacks.includes(tweetElement)) return;
            console.log("[TwitterDL] Twitter quote retweets from statuses are not supported yet, sorry! Throwing fallback... \nScope: " + reason);
            addSideTextToRetweet(tweetElement, " · Open to Download");
        }
        try {
            if (isRetweet) {
                if (isPost) {
                    const hasRetweetVideoPlayer = (videoPlayer != null);
                    if (hasRetweetVideoPlayer)
                        fallback("isretweet, ispost, hasretweetvideoplayer");
                } else fallback("isretweet");
            } else {
                const timeElement = tweetElement.querySelector("time");
                const timeHref = timeElement.parentNode;
                const tweetUrl = timeHref.href;
                if (tweetUrl) setInfo(tweetUrl);
                else fallback("no time info");
            }
        } catch (error) {
            fallback("internal error: " + error);
            console.error(error);
        }
        // VideoPlayer element
        const videoPlayerElement = tweetElement.querySelector('[data-testid="videoPlayer"]');
        const spanElement = videoPlayerElement.querySelector('div[dir="ltr"] > span');
        if (spanElement)
            isGif = spanElement.innerText == "GIF";
        if (!id) return;
        information.id = id;
        information.username = username;
        information.url = tweetUrl;
        information.videoPlayer = videoPlayerElement;
        information.isGif = isGif;
        information.tabIndex = tabIndex;
        // Play button
        return information;
    }
    // Page injection
    async function injectAll() {
        const tweets = getTweetsInPage();
        for (let i = 0; i < tweets.length; i++) {
            const tweet = tweets[i];
            const alreadyInjected = injectedTweets.includes(tweet);
            if (!alreadyInjected) {
                const videoPlayer = tweet.querySelector('[data-testid="videoPlayer"]');
                const isVideo = (videoPlayer != null);
                if (!isVideo) continue;
                createDownloadButtons(tweet);
                injectedTweets.push(tweet);
            }
        }
    }
    function checkForInjection() {
        const tweets = getTweetsInPage();
        const shouldInject = (injectedTweets.length != tweets.length);
        if (shouldInject) injectAll();
    }
    function isStatusUrl(url) {
        const statusUrlRegex = /^https?:\/\/(pro\.x|x)\.com\/\w+\/status\/\d+$/;
        return statusUrlRegex.test(url);
    }
    function isValidUrl(url) {
        const tweetUrlRegex = /^https?:\/\/(pro\.x|x)\.com\/\w+(\/\w+)*$/;
        return tweetUrlRegex.test(url) || isStatusUrl(window.location.href);
    }
    if (isValidUrl(window.location.href)) {
        console.log("[TwitterDL] by (real)coloride - 2023 // Loading... ");
        setInterval(async() => {
            try {
                checkForInjection();
            } catch (error) {
                console.error("[TwitterDL] Fatal error: ", error);
            }
        }, checkFrequency);
    }
})();