Deez Notes

Add label, UPC, genres, release dates, and tracklist copy to Deezer album pages

Verze ze dne 18. 10. 2025. Zobrazit nejnovější verzi.

// ==UserScript==
// @name         Deez Notes
// @namespace    http://tampermonkey.net/
// @version      2.1
// @description  Add label, UPC, genres, release dates, and tracklist copy to Deezer album pages
// @author       waiter7
// @contributors ilikepeaches
// @match        https://www.deezer.com/*
// @license      MIT
// @grant        none
// ==/UserScript==
    
(function() {
    'use strict';
    
    // Debug mode - set to true to enable console logging
    const DEBUG = false;
    
    function waitForElement(selector, timeout = 10000) {
        return new Promise((resolve, reject) => {
            const element = document.querySelector(selector);
            if (element) {
                resolve(element);
                return;
            }
    
            const observer = new MutationObserver((mutations, obs) => {
                const element = document.querySelector(selector);
                if (element) {
                    obs.disconnect();
                    resolve(element);
                }
            });
    
            observer.observe(document.body, {
                childList: true,
                subtree: true
            });
    
            setTimeout(() => {
                observer.disconnect();
                reject(new Error(`Element '${selector}' not found within timeout`));
            }, timeout);
        });
    }
    
    function formatDate(dateString) {
        if (!dateString || dateString === '0000-00-00') return 'N/A';
        try {
            const date = new Date(dateString);
            return date.toLocaleDateString('en-US', {
                year: 'numeric',
                month: '2-digit',
                day: '2-digit',
                timeZone: 'UTC'
            });
        } catch (e) {
            return dateString;
        }
    }
    
    function extractAlbumId() {
        const path = window.location.pathname;
        const match = path.match(/\/album\/(\d+)/);
        return match ? match[1] : null;
    }
    
    
    
    function addTracklistCopyIcon(apiData) {
        // Find the track count element
        const trackCountElement = document.querySelector('li.dMJfv');
        if (!trackCountElement || trackCountElement.querySelector('.tracklist-copy-icon')) {
            return; // Element not found or icon already added
        }
        
        // Create clipboard icon
        const copyIcon = document.createElement('span');
        copyIcon.className = 'tracklist-copy-icon';
        copyIcon.innerHTML = '⧉';
        copyIcon.title = 'Copy Tracklist';
        copyIcon.style.cssText = `
            margin-left: 8px;
            cursor: pointer;
            opacity: 0.7;
            transition: opacity 0.2s ease;
            font-size: 14px;
            color: #666;
            background: rgba(255, 255, 255, 0.9);
            border-radius: 3px;
            padding: 2px 4px;
        `;
        
        // Add hover effect
        copyIcon.addEventListener('mouseenter', () => {
            copyIcon.style.opacity = '1';
        });
        
        copyIcon.addEventListener('mouseleave', () => {
            copyIcon.style.opacity = '0.7';
        });
        
        // Add click handler
        copyIcon.addEventListener('click', async () => {
            // Show loading state
            const originalText = copyIcon.innerHTML;
            copyIcon.innerHTML = '⏳';
            copyIcon.style.cursor = 'wait';
            
            try {
                // Format tracklist from API data
                const tracklist = apiData.tracks?.data?.length 
                    ? apiData.tracks.data.map((track, index) => {
                        const minutes = Math.floor(track.duration / 60);
                        const seconds = track.duration % 60;
                        const formattedDuration = `${minutes}:${seconds.toString().padStart(2, '0')}`;
                        return `${index + 1}. ${track.title} (${formattedDuration})`;
                    }).join('\n')
                    : 'No tracks available';
                
                await navigator.clipboard.writeText(tracklist);
                
                // Show success feedback
                copyIcon.innerHTML = '✓';
                copyIcon.style.color = '#4CAF50';
                
                setTimeout(() => {
                    copyIcon.innerHTML = originalText;
                    copyIcon.style.color = '#666';
                    copyIcon.style.cursor = 'pointer';
                }, 1500);
                
                if (DEBUG) console.log('Tracklist copied to clipboard');
            } catch (error) {
                console.error('Error copying tracklist:', error);
                
                // Show error feedback
                copyIcon.innerHTML = '✗';
                copyIcon.style.color = '#f44336';
                
                setTimeout(() => {
                    copyIcon.innerHTML = originalText;
                    copyIcon.style.color = '#666';
                    copyIcon.style.cursor = 'pointer';
                }, 1500);
            }
        });
        
        // Add the icon to the track count element
        trackCountElement.appendChild(copyIcon);
    }
    
    
    function copyToClipboard(text, element) {
        navigator.clipboard.writeText(text).then(() => {
            const copiedMsg = element.querySelector('.copied-message');
            if (copiedMsg) {
                copiedMsg.style.opacity = '1';
                copiedMsg.style.visibility = 'visible';
                
                setTimeout(() => {
                    copiedMsg.style.opacity = '0';
                    setTimeout(() => copiedMsg.style.visibility = 'hidden', 200);
                }, 1000);
            }
        }).catch(err => {
            if (DEBUG) console.error('Failed to copy text:', err);
        });
    }
    
    function createCopyableCell(content, isApiCell = false, isGenresCell = false) {
        const td = document.createElement('td');
        
        if (isApiCell) {
            // Handle API cell differently - no copy functionality
            td.style.cssText = `
                padding: 12px;
                vertical-align: top;
                background: #ffffff;
                text-align: center;
                border-top: 1px solid #dee2e6;
                border-bottom: 1px solid #dee2e6;
            `;
            
            const apiLink = document.createElement('a');
            apiLink.href = content;
            apiLink.target = '_blank';
            apiLink.textContent = 'View API';
            apiLink.style.cssText = `
                color: #a238ff; text-decoration: none; font-weight: 500;
                padding: 4px 8px; border-radius: 4px; background: #f8f5ff;
                border: 1px solid #a238ff; display: inline-block; transition: all 0.2s ease;
            `;
            
            // Add hover effects for API link
            apiLink.addEventListener('mouseenter', () => {
                apiLink.style.background = '#a238ff';
                apiLink.style.color = '#ffffff';
            });
            apiLink.addEventListener('mouseleave', () => {
                apiLink.style.background = '#f8f5ff';
                apiLink.style.color = '#a238ff';
            });
            
            td.appendChild(apiLink);
            
        } else {
            // Handle regular data cells
            td.textContent = content || 'N/A';
            td.style.cssText = `
                padding: 12px;
                border-right: 1px solid #dee2e6;
                border-top: 1px solid #dee2e6;
                border-bottom: 1px solid #dee2e6;
                vertical-align: top;
                background: #ffffff;
                position: relative;
                cursor: pointer;
                transition: background-color 0.2s ease;
            `;
            
            // Make "Requires Page Refresh" italic and add refresh functionality
            const isRefreshCell = content === 'Requires Page Refresh';
            if (isRefreshCell) {
                td.style.fontStyle = 'italic';
                td.style.color = '#666';
            }
            
            // Add copy/refresh icon
            const actionIcon = document.createElement('span');
            actionIcon.innerHTML = isRefreshCell ? '🔄' : '⧉';
            actionIcon.title = isRefreshCell ? 'Refresh Page' : 'Copy to Clipboard';
            actionIcon.style.cssText = `
                position: absolute;
                top: 8px;
                right: 8px;
                cursor: pointer;
                opacity: 0;
                transition: opacity 0.2s ease;
                font-size: 14px;
                color: #666;
                background: rgba(255, 255, 255, 0.9);
                border-radius: 3px;
                padding: 2px 4px;
            `;
            
            // Add copied message
            const copiedMessage = document.createElement('span');
            copiedMessage.className = 'copied-message';
            copiedMessage.textContent = 'Copied';
            copiedMessage.style.cssText = `
                position: absolute;
                bottom: 8px;
                right: 8px;
                color: #666;
                font-size: 10px;
                font-style: italic;
                opacity: 0;
                visibility: hidden;
                transition: opacity 0.2s ease;
                white-space: nowrap;
                z-index: 1000;
                pointer-events: none;
            `;
            
            td.appendChild(actionIcon);
            td.appendChild(copiedMessage);
            
            // Show/hide action icon on hover
            td.addEventListener('mouseenter', () => {
                actionIcon.style.opacity = '0.7';
                td.style.backgroundColor = '#f8f9fa';
            });
            
            td.addEventListener('mouseleave', () => {
                actionIcon.style.opacity = '0';
                td.style.backgroundColor = '#ffffff';
            });
            
            // Click functionality - refresh or copy
            td.addEventListener('click', () => {
                if (isRefreshCell) {
                    location.reload();
                } else {
                    let copyText = content || 'N/A';
                    if (isGenresCell && copyText !== 'N/A' && copyText !== 'Loading...') {
                        // Format genres for copying: lowercase, trim spaces around commas, replace spaces within genres with periods
                        copyText = copyText.toLowerCase()
                            .split(',')
                            .map(genre => genre.trim().replace(/\s+/g, '.'))
                            .join(',');
                    }
                    copyToClipboard(copyText, td);
                }
            });
        }
        
        return td;
    }
    
    async function addMetadata() {
        try {
            // Check if metadata table already exists
            if (document.querySelector('.custom-metadata-table')) {
                if (DEBUG) console.log('Metadata table already exists, skipping...');
                return true;
            }

            const albumId = extractAlbumId();
            if (!albumId) {
                if (DEBUG) console.log('Could not extract album ID from URL');
                return false;
            }

            // Find the play button container for positioning
            const playButtonContainer = document.querySelector('[data-testid="play"]');
            if (!playButtonContainer) {
                if (DEBUG) console.log('Play button container not found');
                return false;
            }
            const buttonStrip = playButtonContainer.parentElement.parentElement;

            // Always fetch from API first to get all available data
            if (DEBUG) console.log('Fetching album data from API...');
            let apiData;
            try {
                const response = await fetch(`https://api.deezer.com/album/${albumId}`);
                apiData = await response.json();
            } catch (error) {
                if (DEBUG) console.log('API fetch failed, will retry...');
                return false;
            }
    
            // Extract data from API
            const label = apiData.label || 'N/A';
            const upc = apiData.upc || 'N/A';
            const genres = apiData.genres?.data?.length 
                ? apiData.genres.data.map(genre => genre.name).join(', ')
                : 'N/A';
            const originalReleaseDate = formatDate(apiData.release_date);

            // Get additional date fields from app state (only works on fresh page loads)
            let digitalReleaseDate, physicalReleaseDate;
            if (isFreshPageLoad) {
                const appState = window.__DZR_APP_STATE__;
                if (appState?.DATA) {
                    if (DEBUG) console.log('Fresh page load - using app state for additional dates...');
                    digitalReleaseDate = formatDate(appState.DATA.ORIGINAL_RELEASE_DATE);
                    physicalReleaseDate = formatDate(appState.DATA.PHYSICAL_RELEASE_DATE);
                } else {
                    if (DEBUG) console.log('Fresh page load but no app state, using refresh message...');
                    digitalReleaseDate = physicalReleaseDate = 'Requires Page Refresh';
                }
            } else {
                if (DEBUG) console.log('SPA navigation detected, using refresh message for missing dates...');
                digitalReleaseDate = physicalReleaseDate = 'Requires Page Refresh';
            }

            if (DEBUG) {
                console.log('Album metadata:', {
                    label,
                    upc,
                    genres,
                    digitalReleaseDate,
                    physicalReleaseDate,
                    originalReleaseDate
                });
            }
    
            // Create a clean table for metadata
            const tableContainer = document.createElement('div');
            tableContainer.className = 'custom-metadata-table';
            tableContainer.style.cssText = `
                margin-top: 19px;
                margin-bottom: 19px;
                background: #f8f9fa;
                border-radius: 8px;
                padding: 14px;
                border: 1px solid #e9ecef;
            `;
    
            const table = document.createElement('table');
            table.style.cssText = `
                width: 100%;
                border-collapse: separate;
                border-spacing: 0;
                font-size: 13px;
                color: #495057;
            `;
    
            // Create table header
            const thead = document.createElement('thead');
            const headerRow = document.createElement('tr');
            headerRow.style.cssText = `
                border-top: 1px solid #dee2e6;
                border-bottom: 2px solid #dee2e6;
            `;
    
            const headers = ['Label', 'UPC', 'Genres', 'Digital Release', 'Physical Release', 'Original Release', 'API'];
            headers.forEach((headerText, index) => {
                const th = document.createElement('th');
                th.textContent = headerText;
                th.style.cssText = `
                    text-align: left;
                    padding: 8px 12px;
                    font-weight: 600;
                    color: #343a40;
                    background: #e9ecef;
                    border-right: 1px solid #dee2e6;
                    border-top: 1px solid #dee2e6;
                    border-bottom: 1px solid #dee2e6;
                `;
                if (index === 0) th.style.borderLeft = '1px solid #dee2e6';
                if (headerText === 'API') th.style.borderRight = '1px solid #dee2e6';
                headerRow.appendChild(th);
            });
            thead.appendChild(headerRow);
            table.appendChild(thead);
    
            // Create table body
            const tbody = document.createElement('tbody');
            const dataRow = document.createElement('tr');
            dataRow.style.cssText = `
                background-color: #ffffff;
                border-bottom: 1px solid #dee2e6;
            `;
    
            const values = [label, upc, genres, digitalReleaseDate, physicalReleaseDate, originalReleaseDate];
    
            // Add data cells with copy functionality
            values.forEach((value, index) => {
                const isGenresCell = index === 2; // genres is the 3rd item (index 2)
                const td = createCopyableCell(value, false, isGenresCell);
                if (index === 0) td.style.borderLeft = '1px solid #dee2e6';
                dataRow.appendChild(td);
            });
    
            // Add API link cell with copy functionality
            const apiUrl = `https://api.deezer.com/album/${albumId}`;
            const apiTd = createCopyableCell(apiUrl, true);
            apiTd.style.borderRight = '1px solid #dee2e6';
            dataRow.appendChild(apiTd);
    
            tbody.appendChild(dataRow);
            table.appendChild(tbody);
            tableContainer.appendChild(table);
    
            // Insert the table after the button strip
            buttonStrip.parentNode.insertBefore(tableContainer, buttonStrip.nextSibling);
            
            // Add tracklist copy icon (uses the same apiData we already fetched)
            addTracklistCopyIcon(apiData);
    
            if (DEBUG) console.log('Metadata added successfully');
            return true;
    
        } catch (error) {
            console.error('Error adding metadata:', error);
            return false;
        }
    }
    
    function init() {
        if (DEBUG) console.log('Deez Notes: Starting...');
        
        // Check if we're on an album page
        if (!location.href.includes('/album/')) {
            if (DEBUG) console.log('Not on an album page, skipping...');
            return;
        }
    
        // Wait for the page to load and the play button to be available
        waitForElement('[data-testid="play"]')
            .then(() => {
                if (DEBUG) console.log('Play button found, attempting to add custom metadata...');
    
                // Try to add metadata with multiple retries for SPA navigation
                let attempts = 0;
                const maxAttempts = 5;
                
                async function tryAddMetadata() {
                    attempts++;
                    try {
                        const success = await addMetadata();
                        if (success) {
                            if (DEBUG) console.log('Metadata added successfully on attempt', attempts);
                            return;
                        }
                    } catch (error) {
                        if (DEBUG) console.log('Error in addMetadata:', error);
                    }
                    
                    if (attempts < maxAttempts) {
                        if (DEBUG) console.log(`Attempt ${attempts} failed, retrying in ${attempts}s...`);
                        setTimeout(tryAddMetadata, attempts * 1000); // 1s, 2s, 3s, 4s delays
                    } else {
                        if (DEBUG) console.log('Max attempts reached, giving up');
                    }
                }
                
                tryAddMetadata();
            })
            .catch(error => {
                console.error('Failed to find play button:', error);
            });
    }
    
    // Enhanced navigation detection for SPA
        let lastUrl = location.href;
    if (DEBUG) console.log('Initial URL:', lastUrl);
    
    // Track if this is a fresh page load or SPA navigation
    let isFreshPageLoad = true;
    
    // Simple navigation detection like the working old code
    if (DEBUG) console.log('Setting up MutationObserver...');
        new MutationObserver(() => {
        // Only check URL on mutations, don't log every trigger
            const url = location.href;
            if (url !== lastUrl) {
            if (DEBUG) console.log(`Navigation detected: ${lastUrl} → ${url}`);
                lastUrl = url;
            
                if (url.includes('/album/')) {
                 if (DEBUG) console.log('Album page detected, initializing...');
                 // Mark as SPA navigation for subsequent runs
                 isFreshPageLoad = false;
                 // Use the same approach as the working old code
                    setTimeout(init, 1000);
             } else {
                 if (DEBUG) console.log('Not an album page, skipping...');
            }
    }
    }).observe(document.body, { 
        subtree: true, 
        childList: true 
    });
    
    // Start when DOM is ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();