GitHub Release Downloads

Shows total downloads for releases.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         GitHub Release Downloads
// @description  Shows total downloads for releases.
// @icon         https://github.githubassets.com/favicons/favicon-dark.svg
// @version      1.1
// @author       afkarxyz
// @namespace    https://github.com/afkarxyz/userscripts/
// @supportURL   https://github.com/afkarxyz/userscripts/issues
// @license      MIT
// @match        https://github.com/*
// @grant        GM_xmlhttpRequest
// @connect      api.codetabs.com
// @connect      api.cors.lol
// @connect      api.allorigins.win
// @connect      everyorigin.jwvbremen.nl
// @connect      api.github.com
// @run-at       document-start
// ==/UserScript==

;(() => {
    const proxyServices = [
        {
            name: "Direct GitHub API",
            url: "https://api.github.com/repos/",
            parseResponse: (response) => {
                return JSON.parse(response)
            },
        },
        {
            name: "CodeTabs Proxy",
            url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/repos/",
            parseResponse: (response) => {
                return JSON.parse(response)
            },
        },
        {
            name: "CORS.lol Proxy",
            url: "https://api.cors.lol/?url=https://api.github.com/repos/",
            parseResponse: (response) => {
                return JSON.parse(response)
            },
        },
        {
            name: "AllOrigins Proxy",
            url: "https://api.allorigins.win/get?url=https://api.github.com/repos/",
            parseResponse: (response) => {
                const parsed = JSON.parse(response)
                return JSON.parse(parsed.contents)
            },
        },
        {
            name: "EveryOrigin Proxy",
            url: "https://everyorigin.jwvbremen.nl/api/get?url=https://api.github.com/repos/",
            parseResponse: (response) => {
                const parsed = JSON.parse(response)
                return JSON.parse(parsed.html)
            },
        },
    ]

    async function fetchFromApi(proxyService, owner, repo, tag) {
        const apiUrl = `${proxyService.url}${owner}/${repo}/releases/tags/${tag}`

        return new Promise((resolve) => {
            if (typeof GM_xmlhttpRequest === "undefined") {
                resolve({ success: false, error: "GM_xmlhttpRequest is not defined" })
                return
            }
            GM_xmlhttpRequest({
                method: "GET",
                url: apiUrl,
                headers: {
                    Accept: "application/vnd.github.v3+json",
                },
                onload: (response) => {
                    if (response.responseText.includes("limit") && response.responseText.includes("API")) {
                        resolve({
                            success: false,
                            error: "Rate limit exceeded",
                            isRateLimit: true,
                        })
                        return
                    }

                    if (response.status >= 200 && response.status < 300) {
                        try {
                            const releaseData = proxyService.parseResponse(response.responseText)
                            resolve({ success: true, data: releaseData })
                        } catch (e) {
                            resolve({ success: false, error: "JSON parse error" })
                        }
                    } else {
                        resolve({
                            success: false,
                            error: `Status ${response.status}`,
                        })
                    }
                },
                onerror: () => {
                    resolve({ success: false, error: "Network error" })
                },
                ontimeout: () => {
                    resolve({ success: false, error: "Timeout" })
                },
            })
        })
    }

    async function getReleaseData(owner, repo, tag) {
        for (let i = 0; i < proxyServices.length; i++) {
            const proxyService = proxyServices[i]
            const result = await fetchFromApi(proxyService, owner, repo, tag)

            if (result.success) {
                return result.data
            }
        }
        return null
    }

    function createDownloadCounter() {
        const getThemeColor = () => {
            const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' || 
                               document.body.classList.contains('dark') ||
                               window.matchMedia('(prefers-color-scheme: dark)').matches
            return isDarkTheme ? '#3fb950' : '#1a7f37'
        }
        
        const downloadCounter = document.createElement('span')
        downloadCounter.className = 'download-counter-simple'
        downloadCounter.style.cssText = `
            margin-left: 8px;
            color: ${getThemeColor()};
            font-size: 14px;
            font-weight: 400;
            display: inline;
        `
        
        const downloadIcon = `
            <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 384 512" fill="currentColor" style="margin-right: 2px; vertical-align: -2px;">
                <path d="M32 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 480zM214.6 342.6c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 242.7 160 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 178.7 73.4-73.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3l-128 128z"/>
            </svg>
        `        
        downloadCounter.innerHTML = `${downloadIcon}Loading...`
        
        return downloadCounter
    }

    function getCachedDownloads(owner, repo, tag) {
        const key = `ghdl_${owner}_${repo}_${tag}`
        const cached = localStorage.getItem(key)
        return cached ? parseInt(cached, 10) : null
    }

    function setCachedDownloads(owner, repo, tag, count) {
        const key = `ghdl_${owner}_${repo}_${tag}`
        if (localStorage.getItem(key) === null) {
            localStorage.setItem(key, count)
        }
    }

    function updateDownloadCounter(counter, totalDownloads, diff) {
        const formatNumber = (num) => {
            return num.toLocaleString('en-US')
        }
        const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' || 
            document.body.classList.contains('dark') ||
            window.matchMedia('(prefers-color-scheme: dark)').matches
        const diffColor = isDarkTheme ? '#888' : '#1f2328'
        const downloadIcon = `
            <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 384 512" fill="currentColor" style="margin-right: 2px; vertical-align: -2px;">
                <path d="M32 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 480zM214.6 342.6c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 242.7 160 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 178.7 73.4-73.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3l-128 128z"/>
            </svg>
        `
        let diffText = ''
        if (typeof diff === 'number' && diff > 0) {
            diffText = ` <span class="download-diff" style="color:${diffColor};font-size:12px;">(+${formatNumber(diff)})</span>`
        }
        counter.innerHTML = `${downloadIcon}${formatNumber(totalDownloads)}${diffText}`
        counter.style.fontWeight = '600'
    }

    function setupThemeObserver(counter) {
        const getThemeColor = () => {
            const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' || 
                document.body.classList.contains('dark') ||
                window.matchMedia('(prefers-color-scheme: dark)').matches
            return isDarkTheme ? '#3fb950' : '#1a7f37'
        }
        const getDiffColor = () => {
            const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' || 
                document.body.classList.contains('dark') ||
                window.matchMedia('(prefers-color-scheme: dark)').matches
            return isDarkTheme ? '#888' : '#1f2328'
        }
        const updateCounterColor = () => {
            if (counter) {
                counter.style.color = getThemeColor()
                const diffSpan = counter.querySelector('.download-diff')
                if (diffSpan) {
                    diffSpan.style.color = getDiffColor()
                }
            }
        }
        
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                if (mutation.type === 'attributes' && 
                    (mutation.attributeName === 'data-color-mode' || 
                     mutation.attributeName === 'class')) {
                    updateCounterColor()
                }
            })
        })
        
        observer.observe(document.documentElement, {
            attributes: true,
            attributeFilter: ['data-color-mode', 'class']
        })
        
        observer.observe(document.body, {
            attributes: true,
            attributeFilter: ['class']
        })
        
        const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
        mediaQuery.addEventListener('change', updateCounterColor)
    }

    async function addDownloadCounter() {
        if (isProcessing) {
            return
        }
        isProcessing = true
        const currentUrl = window.location.href
        const urlMatch = currentUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/releases\/tag\/([^\/\?]+)/)
        if (!urlMatch) {
            isProcessing = false
            return
        }
        const [, owner, repo, tag] = urlMatch
        const existingCounter = document.querySelector('.download-counter-simple')
        if (existingCounter) {
            isProcessing = false
            return
        }
        let attempts = 0
        const maxAttempts = 50
        const waitForBreadcrumb = () => {
            return new Promise((resolve) => {
                const checkBreadcrumb = () => {
                    const selectedBreadcrumb = document.querySelector('.breadcrumb-item-selected a')
                    if (selectedBreadcrumb) {
                        resolve(selectedBreadcrumb)
                        return
                    }
                    attempts++
                    if (attempts < maxAttempts) {
                        setTimeout(checkBreadcrumb, 100)
                    } else {
                        resolve(null)
                    }
                }
                checkBreadcrumb()
            })
        }
        const selectedBreadcrumb = await waitForBreadcrumb()
        if (!selectedBreadcrumb) {
            isProcessing = false
            return
        }
        const downloadCounter = createDownloadCounter()
        selectedBreadcrumb.appendChild(downloadCounter)
        setupThemeObserver(downloadCounter)
        try {
            const releaseData = await getReleaseData(owner, repo, tag)
            if (!releaseData) {
                downloadCounter.remove()
                isProcessing = false
                return
            }
            const totalDownloads = releaseData.assets.reduce((total, asset) => {
                return total + asset.download_count
            }, 0)
            const cached = getCachedDownloads(owner, repo, tag)
            let diff = null
            if (cached !== null && totalDownloads > cached) {
                diff = totalDownloads - cached
            }
            updateDownloadCounter(downloadCounter, totalDownloads, diff)
            setCachedDownloads(owner, repo, tag, totalDownloads)
        } catch (error) {
            downloadCounter.remove()
        } finally {
            isProcessing = false
        }
    }

    let navigationTimeout = null
    let lastUrl = window.location.href
    let isProcessing = false

    function handleNavigation() {
        const currentUrl = window.location.href
        
        if (navigationTimeout) {
            clearTimeout(navigationTimeout)
        }
        
        if (currentUrl === lastUrl && isProcessing) {
            return
        }
        
        lastUrl = currentUrl
        
        navigationTimeout = setTimeout(() => {
            const existingCounters = document.querySelectorAll('.download-counter-simple')
            existingCounters.forEach(counter => counter.remove())
            
            if (currentUrl.includes('/releases/tag/')) {
                addDownloadCounter()
            }
        }, 300)
    }

    function init() {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', handleNavigation)
        } else {
            handleNavigation()
        }
        
        document.addEventListener('turbo:load', handleNavigation)
        document.addEventListener('turbo:render', handleNavigation)
        document.addEventListener('turbo:frame-load', handleNavigation)
        
        document.addEventListener('pjax:end', handleNavigation)
        document.addEventListener('pjax:success', handleNavigation)
        
        window.addEventListener('popstate', handleNavigation)
        
        const originalPushState = history.pushState
        const originalReplaceState = history.replaceState
        
        history.pushState = function(...args) {
            originalPushState.apply(history, args)
            setTimeout(handleNavigation, 100)
        }
        
        history.replaceState = function(...args) {
            originalReplaceState.apply(history, args)  
            setTimeout(handleNavigation, 100)
        }
    }

    init()
})()