Spotify Web - Download Cover

Adds a button to download the full size cover art from Spotify Web Player

// ==UserScript==
// @name                 Spotify Web - Download Cover
// @name:es              Spotify Web - Descargar portada
// @name:pt              Spotify Web - Descarregar capa
// @name:pt-BR           Spotify Web - Baixar capa
// @name:it              Spotify Web - Scarica copertina
// @name:fr              Spotify Web - Télécharger pochette
// @name:de              Spotify Web - Cover herunterladen
// @name:ru              Spotify Web - Скачать обложку
// @name:zh-CN           Spotify Web - 下载封面
// @name:ja              Spotify Web - カバーをダウンロード
// @namespace            http://tampermonkey.net/
// @description          Adds a button to download the full size cover art from Spotify Web Player
// @description:es       Agrega un botón para descargar la portada en tamaño completo del reproductor web de Spotify
// @description:pt       Adiciona um botão para descarregar a capa em tamanho completo do reproductor web do Spotify
// @description:pt-BR    Adiciona um botão para baixar a capa em tamanho real do Spotify Web Player
// @description:it       Aggiunge un pulsante per scaricare la copertina a dimensione piena dal lettore web di Spotify
// @description:fr       Ajoute un bouton pour télécharger la pochette en taille réelle depuis le lecteur web Spotify
// @description:de       Fügt eine Schaltfläche zum Herunterladen des Covers in voller Größe vom Spotify Web Player hinzu
// @description:ru       Добавляет кнопку для скачивания обложки в полном размере из веб-плеера Spotify
// @description:zh-CN    为Spotify网页播放器添加下载全尺寸封面艺术的按钮
// @description:ja       Spotify Web Playerからフルサイズのカバーアートをダウンロードするボタンを追加します
// @version              1.7.2
// @match                https://open.spotify.com/*
// @author               Levi Somerset
// @license              MIT
// @icon                 https://www.google.com/s2/favicons?sz=64&domain=spotify.com
// @grant                GM_download
// @grant                GM_xmlhttpRequest
// @grant                window.onurlchange
// @connect              i.scdn.co
// @require              https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// ==/UserScript==

(function () {
    'use strict';

    const CONFIG = {
        COVER_BASE: 'https://i.scdn.co/image/',
        SIZES: ['ab67616d0000b273', 'ab67616d00001e02', 'ab67616d00004851'],
        FULL_SIZE: 'ab67616d000082c1',
        DEBOUNCE_DELAY: 300
    };

    const translations = {
        en: ['Download Cover', 'Cover downloaded: %s', 'Failed to download cover'],
        es: ['Descargar portada', 'Portada descargada: %s', 'No se pudo descargar la portada'],
        pt: ['Descarregar capa', 'Capa descarregada: %s', 'Falha ao descarregar a capa'],
        "pt-BR": ['Baixar capa', 'Capa baixada: %s', 'Falha ao baixar a capa'],
        it: ['Scarica copertina', 'Copertina scaricata: %s', 'Impossibile scaricare la copertina'],
        fr: ['Télécharger pochette', 'Pochette téléchargée: %s', 'Échec du téléchargement de la pochette'],
        de: ['Cover herunterladen', 'Cover heruntergeladen: %s', 'Cover konnte nicht heruntergeladen werden'],
        ru: ['Скачать обложку', 'Обложка скачана: %s', 'Не удалось скачать обложку'],
        "zh-CN": ['下载封面', '封面已下载: %s', '下载封面失败'],
        ja: ['カバーをダウンロード', 'カバーがダウンロードされました: %s', 'カバーのダウンロードに失敗しました']
    };

    function initScript(saveAs) {
        function log(...args) {
            if (CONFIG.DEBUG) {
                console.log('[Spotify Cover Downloader]', ...args);
            }
        }

        function detectLanguage() {
            const htmlLang = document.documentElement.lang;
            if (htmlLang) {
                log('Language detected from HTML:', htmlLang);
                return htmlLang.split('-')[0];
            }

            const urlMatch = window.location.pathname.match(/\/intl-([a-z]{2})\//);
            if (urlMatch) {
                log('Language detected from URL:', urlMatch[1]);
                return urlMatch[1];
            }

            log('Language detected from browser:', navigator.language);
            return navigator.language.split('-')[0];
        }

        let language = detectLanguage();
        let [buttonText, downloadedText, errorText] = translations.en;

        if (language in translations) {
            [buttonText, downloadedText, errorText] = translations[language];
            log('Using translations for language:', language);
        } else {
            log('No translations for language:', language, 'using English');
        }

        const style = document.createElement('style');
        style.innerText = `
            .cover-download-btn {
                background-color: transparent;
                border: none;
                color: #b3b3b3;
                cursor: pointer;
                display: inline-flex;
                align-items: center;
                justify-content: center;
                margin-left: 0px;
                padding: 0px;
                position: relative;
                text-align: center;
                touch-action: manipulation;
                transition-duration: 33ms;
                transition-property: background-color, border-color, color, box-shadow, filter, transform;
                transition-timing-function: cubic-bezier(.3,0,.4,1);
                user-select: none;
                vertical-align: middle;
            }

            .cover-download-btn:hover {
                color: #ffffff;
                transform: scale(1.04);
            }

            .cover-download-btn:active {
                transform: scale(1);
            }

            .cover-download-btn svg {
                fill: currentColor;
            }
        `;
        document.head.appendChild(style);

        function downloadImage(imageSrc, fileName) {
            return new Promise((resolve, reject) => {
                log('Downloading image:', imageSrc, 'as', fileName);
                GM.xmlHttpRequest({
                    method: 'GET',
                    url: imageSrc,
                    responseType: 'blob',
                    onload: function (response) {
                        try {
                            const blob = response.response;
                            saveAs(blob, fileName);
                            showInfo(downloadedText.replace('%s', fileName), 'success');
                            log('Download successful');
                            resolve();
                        } catch (error) {
                            console.error('Error saving file:', error);
                            log('Error saving file:', error);
                            reject(error);
                        }
                    },
                    onerror: function (error) {
                        console.error('Error downloading image:', error);
                        log('Error downloading image:', error);
                        reject(error);
                    }
                });
            });
        }

        const albumInfoCache = new Map();

        function getAlbumInfo() {
            const cacheKey = window.location.href;
            if (albumInfoCache.has(cacheKey)) {
                log('Using cached album info for:', cacheKey);
                return albumInfoCache.get(cacheKey);
            }

            let artistName, albumName;

            const artistElements = document.querySelectorAll('a[data-testid="creator-link"]');
            if (artistElements.length > 0) {
                const artistNames = Array.from(artistElements).map(el => el.textContent.trim());
                artistName = artistNames.join(', ');
                log('Found artists:', artistName);
            }

            const albumTitleSelectors = [
                'span[data-testid="entityTitle"] h1[data-encore-id="text"]',
                'h1[data-encore-id="text"]',
                '.gj6rSoF7K4FohS2DJDEm',
                'span[data-testid="entityTitle"]',
                '.rEN7ncpaUeSGL9z0NGQR h1'
            ];

            for (const selector of albumTitleSelectors) {
                const element = document.querySelector(selector);
                if (element) {
                    albumName = element.textContent.trim();
                    log('Found album name using selector:', selector, ':', albumName);
                    break;
                }
            }

            if (!artistName) {
                const artistFallbackSelectors = [
                    '.b81TNrTkVyPCOH0aDdLG a',
                    '.rEN7ncpaUeSGL9z0NGQR',
                    '.RANLXG3qKB61Bh33I0r2 a'
                ];

                for (const selector of artistFallbackSelectors) {
                    const element = document.querySelector(selector);
                    if (element) {
                        artistName = element.textContent.trim();
                        log('Found artist using fallback selector:', selector, ':', artistName);
                        break;
                    }
                }
            }

            const info = {
                artist: artistName || 'Unknown Artist',
                album: albumName || 'Unknown Album'
            };

            log('Final album info:', info);
            albumInfoCache.set(cacheKey, info);
            return info;
        }

        function findAlbumCover() {
            const selectors = [
                'section[data-testid="album-page"] img[srcset*="i.scdn.co/image/"][sizes]',
                'section[data-testid="album-page"] img[src*="i.scdn.co/image/"]',
                'button[class*="_osiFNXU9Cy1X0CYaU9Z"] img[src*="i.scdn.co/image/"]',
                '.CmkY1Ag0tJDfnFXbGgju img[src*="i.scdn.co/image/"]',
                'img[srcset*="i.scdn.co/image/"][sizes]',
                'img[src*="i.scdn.co/image/"]'
            ];

            for (const selector of selectors) {
                const element = document.querySelector(selector);
                if (element && element.width >= 100) {
                    log('Found album cover using selector:', selector);
                    return element;
                }
            }

            log('Album cover not found');
            return null;
        }

        function getFullSizeCoverUrl() {
            const coverElement = findAlbumCover();

            if (!coverElement) {
                log('Cover element not found');
                return null;
            }

            const coverUrl = coverElement.src;
            for (const size of CONFIG.SIZES) {
                if (coverUrl.includes(size)) {
                    const fullSizeUrl = coverUrl.replace(size, CONFIG.FULL_SIZE);
                    log('Generated full size URL:', fullSizeUrl);
                    return fullSizeUrl;
                }
            }

            log('Failed to extract cover hash');
            return null;
        }

        function debounce(func, delay) {
            let timeoutId;
            return function (...args) {
                clearTimeout(timeoutId);
                timeoutId = setTimeout(() => func.apply(this, args), delay);
            };
        }

        function shouldShowButton() {
            const isAlbumPage = window.location.pathname.includes('/album/');
            log('Is album page:', isAlbumPage, 'Path:', window.location.pathname);
            return isAlbumPage;
        }

        function findActionBarRow() {
            const selectors = [
                '[data-testid="action-bar-row"]',
                '.eSg4ntPU2KQLfpLGXAww',
                '.action-bar-row'
            ];

            for (const selector of selectors) {
                const element = document.querySelector(selector);
                if (element) {
                    log('Found action bar using selector:', selector);
                    return element;
                }
            }

            log('Action bar not found, trying alternative approach');
            const moreButton = document.querySelector('[data-testid="more-button"]');
            if (moreButton) {
                log('Found more button, using its parent as action bar');
                return moreButton.parentNode;
            }

            log('Action bar not found');
            return null;
        }

        function createDownloadButton() {
            const button = document.createElement('button');

            button.className = 'Button-sc-1dqy6lx-0 iwlsM e-9890-overflow-wrap-anywhere e-9890-button-tertiary--icon-only e-9890-button-tertiary--condensed cover-download-btn';
            button.setAttribute('data-encore-id', 'buttonTertiary');
            button.setAttribute('title', buttonText);
            button.setAttribute('aria-label', buttonText);

            button.innerHTML = `
                <span aria-hidden="true" class="e-9890-button__icon-wrapper">
                    <svg data-encore-id="icon" role="img" aria-hidden="true" class="e-9890-icon e-9890-baseline" viewBox="0 0 24 24" style="--encore-icon-height: var(--encore-graphic-size-decorative-larger); --encore-icon-width: var(--encore-graphic-size-decorative-larger);">
                        <path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM1 12C1 5.925 5.925 1 12 1s11 4.925 11 11-4.925 11-11 11S1 18.075 1 12z"></path>
                        <path d="M12 6.05a1 1 0 0 1 1 1v7.486l1.793-1.793a1 1 0 1 1 1.414 1.414L12 18.364l-4.207-4.207a1 1 0 1 1 1.414-1.414L11 14.536V7.05a1 1 0 0 1 1-1z"></path>
                    </svg>
                </span>
            `;

            return button;
        }

        function addDownloadButton() {
            if (!shouldShowButton()) return;

            let actionBarRow = findActionBarRow();
            if (!actionBarRow) return;

            if (actionBarRow.querySelector('.cover-download-btn')) return;

            const moreButton = actionBarRow.querySelector('[data-testid="more-button"]');
            if (!moreButton) {
                log('More button not found, adding at the end of action bar');
                const downloadButton = createDownloadButton();
                actionBarRow.appendChild(downloadButton);

                downloadButton.addEventListener('click', async function () {
                    const fullSizeCoverUrl = getFullSizeCoverUrl();
                    if (fullSizeCoverUrl) {
                        try {
                            const albumInfo = getAlbumInfo();
                            const fileName = `${albumInfo.artist} - ${albumInfo.album}.jpg`;
                            await downloadImage(fullSizeCoverUrl, fileName);
                        } catch (error) {
                            showInfo(errorText, 'error');
                        }
                    } else {
                        showInfo(errorText, 'error');
                    }
                });

                return;
            }

            log('Found more button, inserting download button after it');
            const downloadButton = createDownloadButton();
            moreButton.parentNode.insertBefore(downloadButton, moreButton.nextSibling);

            downloadButton.addEventListener('click', async function () {
                const fullSizeCoverUrl = getFullSizeCoverUrl();
                if (fullSizeCoverUrl) {
                    try {
                        const albumInfo = getAlbumInfo();
                        const fileName = `${albumInfo.artist} - ${albumInfo.album}.jpg`;
                        await downloadImage(fullSizeCoverUrl, fileName);
                    } catch (error) {
                        showInfo(errorText, 'error');
                    }
                } else {
                    showInfo(errorText, 'error');
                }
            });
        }

        function removeDownloadButton() {
            const existingButton = document.querySelector('.cover-download-btn');
            if (existingButton) {
                log('Removing existing download button');
                existingButton.remove();
            }
        }

        function addOrRemoveButton() {
            if (shouldShowButton()) {
                log('Adding download button');
                addDownloadButton();
            } else {
                log('Removing download button');
                removeDownloadButton();
            }
        }

        function showInfo(str, type = 'success') {
            const infoDiv = document.createElement('div');
            infoDiv.style.cssText = `
                position: fixed;
                bottom: 20px;
                left: 50%;
                transform: translateX(-50%);
                background-color: ${type === 'success' ? '#1db954' : '#ff4444'};
                color: white;
                padding: 10px 20px;
                border-radius: 5px;
                z-index: 9999;
                font-family: spotify-circular, Helvetica, Arial, sans-serif;
                font-weight: 700;
            `;
            infoDiv.textContent = str;
            document.body.appendChild(infoDiv);
            setTimeout(() => infoDiv.remove(), 3000);
        }

        function watchForActionBar() {
            log('Starting aggressive DOM observation');
            const observer = new MutationObserver(() => {
                if (shouldShowButton()) {
                    const actionBar = findActionBarRow();
                    if (actionBar && !actionBar.querySelector('.cover-download-btn')) {
                        log('Action bar found during observation, adding button');
                        addDownloadButton();
                    }
                }
            });

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

            const intervalId = setInterval(() => {
                if (shouldShowButton()) {
                    log('Interval check: trying to add button');
                    addDownloadButton();
                } else {
                    log('Interval check: not an album page, clearing interval');
                    clearInterval(intervalId);
                }
            }, 1000);

            setTimeout(() => {
                log('Clearing interval after timeout');
                clearInterval(intervalId);
            }, 10000);

            return observer;
        }

        const debouncedAddOrRemoveButton = debounce(addOrRemoveButton, CONFIG.DEBOUNCE_DELAY);

        function init() {
            log('Initializing script');

            addOrRemoveButton();

            const observer = watchForActionBar();

            if ('onurlchange' in window) {
                log('URL change event supported, adding listener');
                window.addEventListener('urlchange', (e) => {
                    log('URL changed:', e.url);
                    debouncedAddOrRemoveButton();
                });
            }

            const regularObserver = new MutationObserver(() => {
                debouncedAddOrRemoveButton();
            });

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

            const originalPushState = history.pushState;
            history.pushState = function () {
                log('History pushState detected');
                originalPushState.apply(this, arguments);
                debouncedAddOrRemoveButton();
            };

            window.addEventListener('popstate', () => {
                log('Popstate event detected');
                debouncedAddOrRemoveButton();
            });

            log('Initialization complete');
        }

        init();
    }

    if (typeof saveAs === 'undefined') {
        const script = document.createElement('script');
        script.src = 'https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js';
        script.onload = () => initScript(saveAs);
        document.head.appendChild(script);
    } else {
        initScript(saveAs);
    }
})();