RYM Display Track Ratings

Displays individual track ratings, genres, and track rankings on RateYourMusic album or any release pages.

// ==UserScript==
// @name         RYM Display Track Ratings
// @namespace    http://tampermonkey.net/
// @version      1.4
// @description  Displays individual track ratings, genres, and track rankings on RateYourMusic album or any release pages.
// @author       cariosa
// @match        https://rateyourmusic.com/release/*
// @icon         https://e.snmc.io/2.5/img/sonemic.png
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @license      GPL-3.0-or-later
// ==/UserScript==

(function() {
    'use strict';

    // Constants
    const CACHE_EXPIRATION = 7 * 24 * 60 * 60 * 1000; // Cache expiration time in milliseconds (7 days)
    const DEBUG_MODE = false; // Enable debug mode for logging
    const DEFAULT_DELAY = 500; // Default delay between requests in milliseconds

    // Variables to manage state
    let ratingsVisible = false; // Track visibility state of ratings and additional info
    let loadClickCount = 0; // Count of how many times "Load Track Ratings" has been clicked
    let trackDataCache = GM_getValue('trackDataCache', {}); // Retrieve cache from storage or initialize empty object

    // Logging function for debug messages
    const log = (message) => {
        if (DEBUG_MODE) {
            console.log(message);
        }
    };

    // Create a button with specified text and click handler
    function createButton(text, onClick) {
        const button = document.createElement('button');
        button.textContent = text;
        button.style.cssText = `
            margin-left: 3px;
            padding: 3px;
            border: 0;
            border-radius: 2px;
            background: #cba6f7;
            cursor: pointer;
            font-size: 10px;
        `;
        button.addEventListener('mouseover', () => button.style.backgroundColor = '#f2cdcd');
        button.addEventListener('mouseout', () => button.style.backgroundColor = '#cba6f7');
        button.addEventListener('click', onClick);
        return button;
    }

    // Insert control buttons for loading track ratings and genres/rankings
    function insertButtons() {
        const trackContainers = [
            document.getElementById('tracks'),
            document.getElementById('tracks_mobile')
        ];

        trackContainers.forEach((tracksContainer) => {
            if (!tracksContainer) {
                log('Tracks container not found');
                return;
            }

            const buttonContainer = document.createElement('div');
            buttonContainer.style.marginBottom = '10px';

            // Create buttons for loading track ratings, toggling genre/rankings, and clearing cache
            const loadButton = createButton('Load Track Ratings', toggleTrackRatings);
            const toggleButton = createButton('Toggle Genre/Rankings', toggleGenreRankings);
            const clearCacheButton = createButton('Clear Cache', clearCache);
            buttonContainer.appendChild(loadButton);
            buttonContainer.appendChild(toggleButton);
            buttonContainer.appendChild(clearCacheButton);

            // Insert button container before the track list
            tracksContainer.parentNode.insertBefore(buttonContainer, tracksContainer);
            log('Buttons inserted successfully');
        });
    }

    // Parse the rating and count from the track's HTML
    function parseTrackRating(html) {
        const parser = new DOMParser();
        const doc = parser.parseFromString(html, 'text/html');

        const ratingElement = doc.querySelector('.page_section_main_info_music_rating_value_rating');
        const countElement = doc.querySelector('.page_section_main_info_music_rating_value_number');
        if (!ratingElement || !countElement) {
            log('Failed to find rating or count elements in HTML');
            return null;
        }

        const rating = ratingElement.textContent.trim().match(/\d+\.\d+/)?.[0];
        const count = countElement.textContent.trim().match(/[\d,]+/)?.[0];
        const isBold = ratingElement.querySelector('img[alt="rating bolded"]') !== null;

        return { rating, count, isBold };
    }

    // Parse genre and rankings from the track's HTML
    function parseTrackInfo(html) {
        const parser = new DOMParser();
        const doc = parser.parseFromString(html, 'text/html');

        const genreElement = doc.querySelector('.page_song_header_info_genre_item_primary .genre');
        const genre = genreElement ? genreElement.outerHTML : null;

        const rankingElements = doc.querySelectorAll('.page_song_header_info_rest .comma_separated');
        const rankings = Array.from(rankingElements).map(el => el.outerHTML).join('<br>');

        return { genre, rankings };
    }

    // Create HTML for displaying the track rating
    function createRatingHTML(rating, count, isBold) {
        const starClass = isBold ? 'metadata-star-bold' : 'metadata-star';
        return `
            <span data-tiptip="${rating} from ${count} ratings" class="has_tip page_release_section_tracks_songs_song_stats significant">
                <span class="page_release_section_tracks_track_stats_scores">
                    <span class="page_release_section_tracks_track_stats_score_star pipe_separated">
                        <img alt="${isBold ? 'bold star' : 'star'}" class="${starClass}">
                        <div class="page_release_section_tracks_track_stats_rating pipe_separated">
                            ${rating}
                        </div>
                    </span>
                    <div class="page_release_section_tracks_track_stats_count pipe_separated">
                        ${count}
                    </div>
                </span>
            </span>
        `;
    }

    // Insert track rating HTML into the track element
    function insertTrackRating(trackElement, rating, count, isBold) {
        const tracklistLine = trackElement.querySelector('.tracklist_line');
        const trackNumber = trackElement.querySelector('.tracklist_num');
        if (tracklistLine && trackNumber) {
            const ratingElement = document.createElement('span');
            ratingElement.innerHTML = createRatingHTML(rating, count, isBold);
            tracklistLine.insertBefore(ratingElement, trackNumber);
            log('Successfully inserted rating for track');
        }
    }

    // Insert genre and rankings HTML into the track element
    function insertTrackInfo(trackElement, genre, rankings) {
        const tracklistLine = trackElement.querySelector('.tracklist_line');
        if (tracklistLine) {
            const genreElement = document.createElement('div');
            genreElement.innerHTML = genre;
            genreElement.style.marginTop = '2px';
            genreElement.classList.add('genre-info');
            tracklistLine.appendChild(genreElement);

            const rankingElement = document.createElement('div');
            rankingElement.innerHTML = rankings;
            rankingElement.style.marginTop = '2px';
            rankingElement.classList.add('ranking-info');
            tracklistLine.appendChild(rankingElement);
        }
    }

    // Process the track data by fetching ratings and genre/rankings
    async function processTrackData(trackElement, index) {
        log(`Processing track ${index + 1}`);

        const songLink = trackElement.querySelector('a.song');
        if (!songLink) {
            log(`No song link found for track ${index + 1}`);
            return;
        }

        const url = songLink.href;
        const trackName = songLink.textContent.trim();
        const cacheKey = `rym_track_data_${trackName}`;
        const cachedData = trackDataCache[cacheKey];

        if (cachedData) {
            const { rating, count, isBold, genre, rankings } = cachedData;
            log(`Using cached data for "${trackName}"`);
            insertTrackRating(trackElement, rating, count, isBold);
            insertTrackInfo(trackElement, genre, rankings);
            return;
        }

        try {
            log(`Fetching data for track: "${trackName}" from URL: ${url}`);

            const response = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    onload: (response) => resolve(response),
                    onerror: (error) => reject(error)
                });
            });

            const trackRating = parseTrackRating(response.responseText);
            const trackInfo = parseTrackInfo(response.responseText);

            if (!trackRating || !trackInfo) {
                log(`Failed to fetch data for "${trackName}"`);
                return;
            }

            // Cache the fetched track data
            trackDataCache[cacheKey] = {
                rating: trackRating.rating,
                count: trackRating.count,
                isBold: trackRating.isBold,
                genre: trackInfo.genre,
                rankings: trackInfo.rankings,
            };
            GM_setValue('trackDataCache', trackDataCache); // Save cache to storage

            insertTrackRating(trackElement, trackRating.rating, trackRating.count, trackRating.isBold);
            insertTrackInfo(trackElement, trackInfo.genre, trackInfo.rankings);
        } catch (error) {
            console.error(`Error processing "${trackName}":`, error);
            alert(`Failed to fetch data for "${trackName}". Please try again later.`);
        }

        await new Promise(resolve => setTimeout(resolve, DEFAULT_DELAY)); // Delay between requests
    }

    // Process all tracks on the current page
    async function processAllTracks() {
        log('Starting to process tracks');
        const trackContainers = [
            document.getElementById('tracks'),
            document.getElementById('tracks_mobile')
        ];

        for (const tracksContainer of trackContainers) {
            if (!tracksContainer) {
                log('Could not find tracks container');
                return;
            }

            const tracks = tracksContainer.querySelectorAll('li.track');
            log(`Found ${tracks.length} tracks`);

            for (let i = 0; i < tracks.length; i++) {
                await processTrackData(tracks[i], i); // Wait for each track to be processed
            }
        }

        log('Finished processing all tracks');
    }

    // Toggle visibility of track ratings and additional info
    function toggleTrackRatings() {
        loadClickCount++;

        const tracksContainers = [
            document.getElementById('tracks'),
            document.getElementById('tracks_mobile')
        ];

        tracksContainers.forEach((tracksContainer) => {
            const trackRatings = tracksContainer.querySelectorAll('.page_release_section_tracks_songs_song_stats');
            const genreElements = tracksContainer.querySelectorAll('.genre-info');
            const rankingElements = tracksContainer.querySelectorAll('.ranking-info');

            if (loadClickCount % 2 !== 0) {
                if (!ratingsVisible) {
                    processAllTracks();
                    ratingsVisible = true;
                    log('Track ratings and additional info loaded');
                }
            } else {
                trackRatings.forEach(rating => rating.remove());
                genreElements.forEach(genre => genre.remove());
                rankingElements.forEach(ranking => ranking.remove());
                ratingsVisible = false;
                log('Track ratings and additional info removed');
            }
        });
    }

    // Toggle visibility of genre and ranking information
    function toggleGenreRankings() {
        const genreElements = document.querySelectorAll('.genre-info');
        const rankingElements = document.querySelectorAll('.ranking-info');
        const isVisible = genreElements.length && genreElements[0].style.display !== 'none';

        genreElements.forEach(el => el.style.display = isVisible ? 'none' : 'block');
        rankingElements.forEach(el => el.style.display = isVisible ? 'none' : 'block');
    }

    // Clear the track data cache
    function clearCache() {
        trackDataCache = {};
        GM_setValue('trackDataCache', {}); // Clear cache from storage
        alert('Cache cleared!');
    }

    // Initialize the script after the page is fully loaded
    window.addEventListener('load', () => {
        log('Page loaded, inserting buttons');
        insertButtons();
    });
})();