Sonemic: Auto count average tracks rating with modifiers

.

// ==UserScript==
// @name         Sonemic: Auto count average tracks rating with modifiers
// @namespace    http://tampermonkey.net/
// @version      0.6
// @description  .
// @author       https://rateyourmusic.com/~PusH
// @match        https://rateyourmusic.com/*
// @grant        none
// ==/UserScript==

(() => {
    'use strict';

    /*
    Use recountRating(...ratings) in console to manually count any rating combinations. Function should return object with ratings info.
    For example: recountRating(1,2,3,4,5,6,7,8,9,10) should print this object:
        averageRating: 5.5 - raw average rating counted by adding all rating values then multiplying by number of ratings
        bonusRating: 0.45 - added bonus rating for some number of tracks rated higher than average rating
        highestPercent: {highestRatingPercent: 40, highestRatingCount: 4} - object with two keys. "highestRatingPercent" is the percent of ratings and "highestRatingCount" is number of ratings higher or equal to the avarage rating
        ratingsString: "1, 2, 3, 4, 5, 6, 7, 8, 9, 10" - shows string of entered values
        realAverageRating: "7.24/20" - real counted average rating with all modifiers, excluding bonus rating, not converted to result rating scale
        resultRatingScale: 100 - current final rating scale (maximum rating)
        rymScaleSuggestedAverageRating: "3.5" - suggested rating on 5-grade rating scale
        shownAverageRating: 60 - final rating
        suggestedAverageRating: 7 - which rating will be suggested by script with that ratings combination

        So, avarage rating is 7 and ratings 7,8,9,10 are counted in highestPercent object. Release need more than 33.33% of highestPercent ratings to get counted suggested average rating.
        Also bonus rating is 0.45 (rating 8 gives additional +0.11, 9 adds +0.14 and 10 adds +0.2).
    */

    //Final rating scale.
    //RYM has only 5-grade rating scale, so, for RYM resultRatingScale should be 5. Sonemic has 100-grade rating scale, so, on Sonemic, resultRatingScale would be 100.
    //If this is more than 10, shownAverageRating would be rounded to integer number.
    const resultRatingScale = 100;

    //By how much single track rating will be multiplied.
    //For example, rating 5/10 will be multiplied by 0.9 and will result in 4.5. Otherwise, 9 will be multiplied by 1.7 what will result in 15.30.
    //Also, 10 * 2 is the highest rating (and 1 * 0.5 is the minimum rating), so ratings goes from 0.5 to 20. As result, script suggests RYM ratings going from 1 to 10.
    const ratingsModifiers = {
        1: .5, //0.50/20
        2: .6, //1.20
        3: .7, //2.10
        4: .8, //3.20
        5: .9, //4.50
        6: 1, //6.00
        7: 1.2, //8.40
        8: 1.4, //11.20
        9: 1.7, //15.30
        10: 2 //20
    };

    //Script will suggest overall release rating by average track rating values multiplied by rating modifiers, which are specified in ratingsModifiers object.
    //Example: release with all 9 ratings will have these multiplied by 1.7 modifier received from ratingsModifiers object. So, average multiplied rating will be 15.30.
    //This means, 15.30 > 13 but less than 16.25, so script will show 9 as suggested rating.
    //Also, release need at least one track of some rating to be elegible for that rating.
    const suggestedRatingValues = {
        1: 0,
        2: 0.9,
        3: 1.7,
        4: 2.65,
        5: 3.8,
        6: 4.85,
        7: 6.9,
        8: 9.6,
        9: 12.95,
        10: 16.25
    };

    //Script can suggest higher release average rating by this modifier if there is any tracks higher than average rating.
    //For example: recountRating(7, 7, 8, 9, 8, 8, 10, 7, 9, 9) results in 12.57 average rating, which is lower for 9/10 rating (13).
    //But release has one 10/10 track, so suggested average rating is reduced by modifier 0.6, 13 - 0.6 = 12.40, and script will suggest 9/10 for this release, because 12.57 > 12.40.
    //This is edge cases, I don't have much release ratings changed by these modifiers.
    const suggestedMinimumRatingValueModifier = .6;

    //There are bonus rating for releases with low average rating, but with some tracks with higher ratings. Mostly, this is a rather small value, but sometimes it makes a difference.
    //This means, for example, what for any tracks rated 10 on any release with average rating 9 or less, this rating gets + 0.2 points.
    //The purpose of this is to increase avarage rating of releases with high number of tracks. Releases with 15+ tracks tends to have much lower average rating, and bonus rating makes these albums comparable to releases with less number of tracks.
    //Bonus rating are added to releases with 5 or more tracks.
    const bonusRatings = {
        6: .04,
        7: .08,
        8: .11,
        9: .14,
        10: .2
    };




















    //----------------------------
    //----------------------------
    //----------------------------
    let pagetype;
    if (window.location.pathname.startsWith('/release/')) pagetype = 'release';
    else if (window.location.pathname.startsWith('/collection/')) pagetype = 'collection';
    else if (window.location.pathname.startsWith('/collection_t/')) pagetype = 'mass-edit';
    else if (window.location.pathname.startsWith('/list/') || window.location.pathname.startsWith('/lists/edit')) pagetype = 'list';

    const highestPercentCount = (averageRating, ratingsArray) => {
        let highestRatingCount = 0;

        ratingsArray.forEach(rating => {
            if (rating >= averageRating) highestRatingCount += 1;
        });

        const highestRatingPercent = Number.parseFloat((highestRatingCount / ratingsArray.length * 100).toFixed(2));

        return {
            highestRatingPercent,
            highestRatingCount
        };
    };

    let initial = true;

    //Main recount function. Can be used in console with provided track ratings as arguments. Example: recountRating(8, 9, 8, 7)
    window.recountRating = (ratingBlock, ...ratings) => {
        if (!ratingBlock) return console.error('"ratingBlock" is not defined');
        const ratingIsElement = ratingBlock instanceof Element;

        let trackRatings;
        let ratingsAverage;
        let suggestedRating;
        if (ratingIsElement) {
            ratingsAverage = ratingBlock.querySelectorAll('.track_rating_average');
            suggestedRating = ratingBlock.querySelector('.release_rating_suggestion');
            if (pagetype === 'release') {
                trackRatings = ratingBlock.querySelectorAll('.tracklisting .my_catalog_rating');
            }

            else if (pagetype === 'collection' || pagetype === 'list') {
                trackRatings = ratingBlock.querySelectorAll('.trackratings td:last-child');
            }
        }

        const ratingsArgs = (!ratingIsElement && ratings.length === 0) ? String(ratingBlock).split('').map(num => Number.parseInt(num, 10)) : [ratingBlock, ...ratings];
        const ratingsArray = ratingIsElement ? [] : ratingsArgs;
        let countedTracks = ratingIsElement ? trackRatings.length : ratingsArray.length;
        let totalRating = 0;
        let averageRating;
        let ratingPercent;
        let suggestedTotalRating = 0;
        let suggestedAverageRating;
        let suggestedAverageRatingValue = 0;
        let highestRating = 0;
        let bonusRating = 0.00;
        let highestPercent;

        if (ratingIsElement) {
            [...trackRatings].forEach(element => {
                let ratingValue;
                let ratingText = '';

                if (pagetype === 'release') {
                    if (initial) {
                        ratingText = element.nextElementSibling.textContent.split(', ')[2];
                        if (ratingText.startsWith('0')) {
                            countedTracks -= 1;
                            return;
                        }
                        ratingValue = Number.parseInt(ratingText, 10);
                    } else {
                        ratingText = element.querySelector('.rating_num').textContent;
                        if (ratingText === '---') {
                            countedTracks -= 1;
                            return;
                        }
                        ratingValue = Number.parseFloat(ratingText) * 2;
                    }
                }

                else if (pagetype === 'collection' || pagetype === 'list') {
                    ratingText = element.querySelector('img') && parseFloat(element.querySelector('img').getAttribute('title'));
                    if (!ratingText) {
                        countedTracks -= 1;
                        return;
                    }
                    ratingValue = Number.parseFloat(ratingText) * 2;
                }

                ratingsArray.push(ratingValue);
            });
            if (initial) {
                initial = false;
            }
        }

        ratingsArray.forEach(ratingValue => {
            let albumRatingTrackValue;
            if (ratingValue > highestRating) highestRating = ratingValue;
            totalRating += ratingValue;
            albumRatingTrackValue = ratingValue * ratingsModifiers[ratingValue];
            suggestedTotalRating += albumRatingTrackValue;
        });

        if (ratingIsElement && countedTracks === 0) {
            suggestedRating.style.display = 'none';
            return [...ratingsAverage].forEach(item => {
                item.style.display = 'none';
            });
        }

        averageRating = Number.parseFloat((totalRating / countedTracks).toFixed(2));
        suggestedAverageRatingValue = Number.parseFloat((suggestedTotalRating / countedTracks).toFixed(2));

        if (ratingIsElement) {
            [...ratingsAverage].forEach(item => {
                item.style.display = 'inline';
                item.querySelector('span').innerHTML = `<b>${averageRating.toFixed(2)}</b> from ${countedTracks}/${trackRatings.length}`;
            });
        }

        //Bonus rating added to releases with 5 or more tracks
        if (ratingsArray.length > 4) {
            ratingsArray.forEach(rating => {
                if (bonusRatings[rating] && suggestedAverageRatingValue <= suggestedRatingValues[rating] - (rating === 10 ? 0 : suggestedMinimumRatingValueModifier)) {
                    bonusRating += bonusRatings[rating];
                }
            });
            bonusRating = Number.parseFloat(bonusRating.toFixed(2));
            suggestedAverageRatingValue = Number.parseFloat((suggestedAverageRatingValue + bonusRating).toFixed(2));
        }

        suggestedAverageRating = (() => {
            let checkedRating = highestRating;
            while (suggestedAverageRatingValue < (suggestedRatingValues[checkedRating] - ((checkedRating === 10 || checkedRating <= 6) ? 0 : suggestedMinimumRatingValueModifier)) && highestRating === (checkedRating + 1) ||
                suggestedAverageRatingValue < suggestedRatingValues[checkedRating] && highestRating === checkedRating) {
                checkedRating -= 1;
            }
            return checkedRating;
        })();

        const prevSuggestedAverageRating = suggestedAverageRating;
        const currentSuggestedMinimumRatingValueModifier = (suggestedAverageRating === 10 || suggestedAverageRating <= 6) ? 0 : suggestedMinimumRatingValueModifier;
        highestPercent = highestPercentCount(suggestedAverageRating, ratingsArray);
        //Decreases suggested average rating if there are less than 33.33% of tracks (or number is less than 3) higher than average rating (except for releases with 3 tracks)
        if ((highestPercent.highestRatingPercent < 33.33 && suggestedAverageRatingValue < ((suggestedRatingValues[suggestedAverageRating + 1] || 20) - currentSuggestedMinimumRatingValueModifier)) ||
            (ratingsArray.length > 6 && highestPercent.highestRatingCount < 3) || (ratingsArray.length === 3 && highestPercent.highestRatingCount < 2)) {
            suggestedAverageRating -= 1;
        }
        highestPercent = highestPercentCount(suggestedAverageRating, ratingsArray);

        const ratingsString = ratingsArray.join(', ');
        const ratingValueSpan = (suggestedRatingValues[suggestedAverageRating + 1] || 20) - suggestedRatingValues[suggestedAverageRating];
        const ratingValueRest = suggestedAverageRatingValue - bonusRating - suggestedRatingValues[suggestedAverageRating];
        const tenScaleRating = (suggestedAverageRating - 1) + (ratingValueRest / ratingValueSpan);
        let shownAverageRating = (tenScaleRating / (10 / resultRatingScale)).toFixed(2);
        if (resultRatingScale > 10) shownAverageRating = Number.parseInt(shownAverageRating, 10);
        const realAverageRatingNumber = suggestedAverageRatingValue - bonusRating;
        const realAverageRating = `${(realAverageRatingNumber).toFixed(2)}/20`;
        let starsRating = shownAverageRating;
        const rymScaleSuggestedAverageRating = (suggestedAverageRating / 2).toFixed(1);
        const scaledTenRating = suggestedAverageRating / (10 / resultRatingScale);
        const hasMarkMinus = suggestedRatingValues[suggestedAverageRating + 1] < realAverageRatingNumber;
        const hasMarkPlus = suggestedRatingValues[suggestedAverageRating] > realAverageRatingNumber;
        if (resultRatingScale <= 10) {
            starsRating = (suggestedAverageRating / (10 / resultRatingScale)).toFixed(1);
        }

        //Print html to browser page
        if (ratingIsElement) {
            suggestedRating.style.display = 'inline-block';
            let suggestedRatingContent = `Suggested rating: <b class="release_rating_suggestion_value" title="${starsRating}/${resultRatingScale}">${starsRating}</b> ` +
                `(<span title="rating value by 5-grade scale">${rymScaleSuggestedAverageRating}</span>, ` +
                `<span title="bonus rating">+${bonusRating}</span>, <span title="percentage of ratings equal to or higher than average rating, ${highestPercent.highestRatingCount} ratings">${highestPercent.highestRatingPercent}%</span>)`;
            suggestedRating.innerHTML = suggestedRatingContent;
            ratingPercent = Number.parseInt(countedTracks / trackRatings.length * 100, 10);
            suggestedRating.style.opacity = (ratingPercent === 100) ? 1 : (ratingPercent >= 50 ? .66 : .33);
        }

        //Return info values
        return {
            averageRating,
            bonusRating,
            highestPercent,
            ratingsString,
            realAverageRating,
            resultRatingScale,
            rymScaleSuggestedAverageRating,
            shownAverageRating,
            suggestedAverageRating
        };
    };

    if (!document.querySelector('header')) return;

    (() => {
        //Correctly show average track rating on custom collection page
        if (pagetype === 'collection' && window.location.pathname.includes('/d.rp,')) {
            const tracksHeader = document.createElement('th');
            tracksHeader.textContent = 'Track ratings';
            const ratingsHeader = document.querySelector('.or_q_header:nth-child(2)');
            ratingsHeader.parentNode.insertBefore(tracksHeader, ratingsHeader.nextSibling);

            const ratingCol = document.querySelectorAll('.or_q_rating');
            ratingCol.forEach(item => {
                const reviewCol = document.createElement('td');
                reviewCol.classList.add('or_q_review_td');
                item.parentNode.insertBefore(reviewCol, item.nextSibling);
            });

            const tracksCol = document.querySelectorAll('.or_q_review_td[colspan="3"]');
            tracksCol.forEach(item => {
                const parentRow = item.parentNode;
                const previousRow = parentRow.previousSibling;
                const reviewInner = item.querySelector('.or_q_review');

                const reviewsCol = previousRow.querySelector('.or_q_review_td');
                reviewsCol.appendChild(reviewInner);

                const tagsCol = item.nextSibling;
                previousRow.appendChild(tagsCol);

                parentRow.remove();
            });
        }

        //Correctly show average track rating on mass-edit page
        else if (pagetype === 'mass-edit') {
            const tracksHeader = document.createElement('th');
            tracksHeader.textContent = 'Track ratings';
            const ratingsHeader = document.querySelector('.or_q_header:nth-child(4)');
            ratingsHeader.parentNode.insertBefore(tracksHeader, ratingsHeader.nextSibling);

            const ratingCol = document.querySelectorAll('.or_q_rating');
            ratingCol.forEach(item => {
                const reviewCol = document.createElement('td');
                reviewCol.classList.add('or_q_review_td');
                item.parentNode.insertBefore(reviewCol, item.nextSibling);
            });

            const tracksCol = document.querySelectorAll('.or_q_review_td[colspan="5"]');
            tracksCol.forEach(item => {
                const parentRow = item.parentNode;
                const previousRow = parentRow.previousSibling;
                const reviewInner = item.querySelector('.or_q_review');

                const reviewsCol = previousRow.querySelector('.or_q_review_td');
                reviewsCol.appendChild(reviewInner);

                const tagsCol = item.nextSibling;
                previousRow.appendChild(tagsCol);

                parentRow.remove();
            });
        }
    })();

    (() => {
        //Create DOM elements on release page
        if (pagetype === 'release') {
            const trackRatingsButton = document.getElementById('track_rating_btn');
            if (!trackRatingsButton) return;
            const trackRatingsSaveButton = document.getElementById('track_ratings_save_btn');
            const trackRatingsSuccess = document.getElementById('track_rating_success');
            let ratingsSaveInterval;
            let ratingsSaveIntervalIndex = 0;

            const ratingsAverage = [];
            ratingsAverage.push(document.createElement('span'));
            ratingsAverage[0].classList.add('track_rating_average');
            ratingsAverage[0].style.display = 'none';
            ratingsAverage[0].innerHTML = ': <span></span>';

            ratingsAverage.push(ratingsAverage[0].cloneNode(true));
            ratingsAverage[1].innerHTML = `Average${ratingsAverage[1].innerHTML}`;
            ratingsAverage[1].style.fontSize = '11px';
            ratingsAverage[1].style.marginLeft = '5px';

            trackRatingsButton.appendChild(ratingsAverage[0]);
            trackRatingsSuccess.parentNode.insertBefore(ratingsAverage[1], trackRatingsSuccess.nextSibling);

            const suggestedRating = document.createElement('span');
            const catalogTopDiv = document.querySelector('.release_my_catalog');
            suggestedRating.classList.add('release_rating_suggestion');
            suggestedRating.style.fontSize = '11px';
            suggestedRating.style.marginTop = '2px';
            suggestedRating.style.cursor = 'pointer';
            suggestedRating.style.opacity = .3;
            catalogTopDiv.parentNode.insertBefore(suggestedRating, catalogTopDiv.nextSibling);

            trackRatingsSaveButton.addEventListener('click', () => {
                window.recountRating(document.getElementById('my_catalog'));

                clearInterval(ratingsSaveInterval);
                ratingsSaveInterval = setInterval(() => {
                    ratingsSaveIntervalIndex++;
                    if (trackRatingsSaveButton.getAttribute('disabled') || ratingsSaveIntervalIndex >= 15) {
                        console.log(window.recountRating(document.getElementById('my_catalog')));
                        clearInterval(ratingsSaveInterval);
                        ratingsSaveIntervalIndex = 0;
                    }
                }, 300);
            });

            console.log(window.recountRating(document.getElementById('my_catalog')));
        }

        //Create DOM elements on collection pages
        else if ((pagetype === 'collection') || (pagetype === 'mass-edit')) {
            const resultsArray = [];
            const trackRatingsButton = document.querySelectorAll('.or_q_review');

            trackRatingsButton.forEach(item => {
                const trackRatingsHeader = item.querySelector('.track_rating_header');
                if (!trackRatingsHeader) return;
                const titleDiv = trackRatingsHeader.querySelector('div[style="float:left;"]');

                const suggestedRating = document.createElement('span');
                suggestedRating.classList.add('release_rating_suggestion');
                suggestedRating.style.marginLeft = '25px';
                suggestedRating.style.color = '#000';
                suggestedRating.style.opacity = .3;
                titleDiv.parentNode.insertBefore(suggestedRating, titleDiv.nextSibling);

                const ratingsAverage = document.createElement('span');
                ratingsAverage.classList.add('track_rating_average');
                ratingsAverage.style.marginLeft = '25px';
                ratingsAverage.style.fontSize = '11px';
                ratingsAverage.style.color = '#000';
                ratingsAverage.style.display = 'none';
                ratingsAverage.innerHTML = 'Average: <span></span>';
                titleDiv.parentNode.insertBefore(ratingsAverage, titleDiv.nextSibling);

                resultsArray.push(window.recountRating(item));
            });

            console.table(resultsArray);
        }

        //Create DOM elements on list pages
        else if (pagetype === 'list') {
            const loadingFunction = () => {
                const resultsArray = [];
                const spoilers = document.querySelectorAll('.spoiler');

                spoilers.forEach(item => {
                    if (!item.offsetParent) return;
                    const spoilerContent = item.querySelector('.spoiler_inner');
                    const trackRatingsWrapper = spoilerContent.querySelector('.rsummaryframe .mbgen > tbody > tr > td[style]');
                    if (!trackRatingsWrapper) return;

                    const trackRatingsTable = spoilerContent.querySelector('.trackratings');
                    if (!trackRatingsTable) return;

                    const suggestedRating = document.createElement('span');
                    suggestedRating.classList.add('release_rating_suggestion');
                    suggestedRating.style.color = '#000';
                    suggestedRating.style.opacity = .3;
                    trackRatingsWrapper.appendChild(suggestedRating);

                    resultsArray.push(window.recountRating(spoilerContent));

                    item.textContent = `(${spoilerContent.querySelector('.release_rating_suggestion_value').textContent})`;
                    item.parentNode.title = `${spoilerContent.querySelector('.artist').textContent} — ${spoilerContent.querySelector('.album').textContent} | ${spoilerContent.querySelector('.release_rating_suggestion').textContent}`;
                });

                console.table(resultsArray);
            };

            const loadingDiv = document.getElementById('list_loading');
            let loadingInterval = setInterval(() => {
                if (loadingDiv && loadingDiv.offsetParent) return;

                clearInterval(loadingInterval);

                loadingFunction();
            }, 100);

            //Load ratings on lists pages change
            const navbar = document.getElementById('navbar');
            if (navbar) {
                navbar.addEventListener('click', event => {
                    if (event.target.tagName !== 'A') return;

                    clearInterval(loadingInterval);

                    const currentPage = document.querySelector('.navlinkcurrent').textContent;
                    loadingInterval = setInterval(() => {
                        if (currentPage === document.querySelector('.navlinkcurrent').textContent) return;

                        clearInterval(loadingInterval);

                        loadingFunction();
                    }, 100);
                });
            }
        }
    })();
})();