// ==UserScript==
// @name Rate Your Music for Gazelle
// @version 1.25
// @match https://*/torrents.php?id=*
// @match https://*/torrents.php?page=*&id=*
// @description Integrate RYM ratings into music release pages
// @run-at document-end
// @author Anakunda
// @copyright 2024, Anakunda (https://greasyfork.org/users/321857)
// @license GPL-3.0-or-later
// @connect rateyourmusic.com
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @require https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js
// @namespace https://greasyfork.org/users/321857
// @iconURL https://e.snmc.io/2.5/img/sonemic.png
// ==/UserScript==
'use strict';
function setTooltip(elem, tooltip, params) {
if (!(elem instanceof HTMLElement)) throw 'Invalid argument';
if (typeof jQuery.fn.tooltipster == 'function') {
if (tooltip) tooltip = tooltip.replace(/\r?\n/g, '<br>')
if ($(elem).data('plugin_tooltipster'))
if (tooltip) $(elem).tooltipster('update', tooltip).tooltipster('enable');
else $(elem).tooltipster('disable');
else if (tooltip) $(elem).tooltipster(Object.assign(params || { }, { content: tooltip }));
} else if (tooltip) elem.title = 'To see details for rating you need to\nenable styled tooltips in user settings'; else elem.removeAttribute('title');
}
const toASCII = str => str && str.normalize('NFKD').replace(/[\x00-\x1F\u0300-\u036F]/gu, '');
const cmpNorm = str => str && toASCII(str).replace(/[\s\–\x00-\x2F\x3A-\x40\x5B-\x5E\x60\x7B-\x7F\u2019]+/g, '').toLowerCase();
const origin = 'https://rateyourmusic.com', resolveUrl = a => new URL(a.getAttribute('href'), origin).href;
function rymFindReleases(artists, title, releaseType) {
const searchTerms = [ ];
if (artists) Array.prototype.push.apply(searchTerms, artists = artists.filter(Boolean));
if (title) searchTerms.push(title);
if (searchTerms.length <= 0) return Promise.reject('No search terms provided');
const url = new URL('search', origin);
url.searchParams.set('searchterm', searchTerms.map(searchTerm => '"' + searchTerm + '"').join(' '));
url.searchParams.set('searchtype', releaseType ? ['Compilation', 'DJ Mix'].includes(releaseType) ?
'y' : 'l' : !artists || artists.length <= 0 ? 'y' : 'l');
console.debug('Search url:', url.href);
return globalXHR(url).then(function({document}) {
let results = Array.from(document.body.querySelectorAll('div.page_search_results > table > tbody > tr > td > table'), function(table) {
const details = Array.prototype.find.call(table.querySelectorAll('table.mbgen > tbody > tr'),
tr => tr.cells.length == 5) || null;
const searchResult = {
artists: Array.from(table.querySelectorAll('a.artist'), a => a.textContent.trim()),
creditedName: table.querySelector('span.credited_name'),
title: table.querySelector('a.searchpage'),
year: details && details.querySelector(':scope > td:nth-of-type(1)'),
country: details && details.querySelector(':scope > td:nth-of-type(2) > span'),
medium: details && details.querySelector(':scope > td:nth-of-type(3)'),
label: details && details.querySelector(':scope > td:nth-of-type(4)'),
catNo: details && details.querySelector(':scope > td:nth-of-type(5)'),
};
if (searchResult.title != null) searchResult.url = resolveUrl(searchResult.title);
if (searchResult.year != null) searchResult.year = parseInt(searchResult.year.textContent); else delete searchResult.year;
if (searchResult.country != null) searchResult.country = searchResult.country.title; else delete searchResult.country;
for (let prop of ['creditedName', 'title', 'medium', 'label', 'catNo']) if (searchResult[prop])
searchResult[prop] = searchResult[prop].textContent.trim(); else delete searchResult[prop];
if (searchResult.url) {
searchResult.releaseType = /\/(album|single|comp|ep)\//i.exec(searchResult.url);
searchResult.releaseType = searchResult.releaseType != null ? searchResult.releaseType[1] : undefined;
}
return searchResult;
});
//console.debug('RYM search results:', results);
if ((results = results.filter(function(result) {
if (title && (!result.title || cmpNorm(result.title) != cmpNorm(title))) return false;
if (artists && !artists.every(artist => result.artists.some(artist2 => cmpNorm(artist2) == cmpNorm(artist))
|| result.creditedName && cmpNorm(result.creditedName).includes(cmpNorm(artist)))) return false;
return true;
})).length <= 0) return Promise.reject('Release not found on RYM');
return Promise.all(results.map(result => result.url ? globalXHR(result.url).then(function({document}) {
const release = {
avgRating: document.body.querySelector('table.album_info > tbody span.avg_rating'),
maxRating: document.body.querySelector('table.album_info > tbody span.max_rating span'),
numVotes: document.body.querySelector('table.album_info > tbody span.num_ratings span'),
};
if (release.avgRating == null) return Promise.reject('Not rated yet - ignoring');
for (let key of Object.keys(release)) if (release[key] == null) delete release[key];
else release[key] = parseFloat(release[key].textContent.replace(/[^\d\.]/g, ''));
console.assert(release.avgRating > 0 && release.maxRating > 0, release, document.body.querySelector('table.album_info > tbody'));
release.albumInfo = { };
for (let tr of document.body.querySelectorAll('table.album_info > tbody > tr'))
if (tr.cells[0].nodeName == 'TH' && tr.cells[1].nodeName == 'TD')
release.albumInfo[tr.cells[0].textContent.trim()] = tr.cells[1];
if (releaseType && 'Type' in release.albumInfo) {
const types = release.albumInfo.Type.textContent.split(',').map(t => t.trim());
if (!types.includes(releaseType)) {
const matchTypes = (...t) => t.some(type => types.includes(type)) == t.includes(releaseType);
if (!matchTypes('Single', 'EP')/* || !matchTypes('Single') || !matchTypes('EP')
|| !matchTypes('Compilation') || !matchTypes('DJ Mix') || !matchTypes('Mixtape')*/)
return Promise.reject('Release type mismatch');
}
}
release.trackList = Array.from(document.body.querySelectorAll("div.section_tracklisting > ul#tracks > li.track"));
release.artistCredits = Array.from(document.body.querySelectorAll("div.section_credits > ul#credits_ > li"));
let masterRelease = document.body.querySelector('div.section_issues ul.issues > li.issue_info.release_view > a[href]');
release.masterRelease = masterRelease != null ? resolveUrl(masterRelease) : result.url;
if (release.masterRelease == result.url) release.isMaster = 'Master Release';
let cover = document.body.querySelector('div.page_release_art_frame img');
release.cover = cover != null ? cover.src : undefined;
let reviews = document.body.querySelector('div#reviews_shell div.section_reviews div.release_page_header > h2');
if (reviews != null && (reviews = /\b(\d+) Reviews?\b/i.exec(reviews.textContent)) != null) {
release.numReviews = parseInt(reviews[1]);
release.reviews = Array.from(document.body.querySelectorAll("div#reviews_shell div.section_reviews div.review"));
}
return Object.assign(release, result);
}).catch(function(reason) {
console.warn('Fetch release failed (%s)', result.url);
return null;
}) : null)).then(ratings => (ratings = ratings.filter(Boolean)).length > 0 ? ratings
: Promise.reject('None of releases found was rated'));
});
}
if (document.body.querySelector('div#content div.sidebar > div.box.box_artists') == null) return; // not a music release
recoverableHttpErrors.splice(0);
const searchParams = new URLSearchParams(document.location.search);
if (searchParams.has('id') || searchParams.has('torrentid')) {
function addBoxRow(...cells) {
if (cells.length <= 0) throw 'Invalid argument';
let body = document.querySelector('div#content div.sidebar div.body.external_ratings');
if (body == null) {
let anchor = document.querySelector('div#content div.sidebar > div.box.box_tags');
if (anchor == null) throw 'Assertion failed: Cover box not found';
let box = Object.assign(document.createElement('div'), { className: 'box box_ratings' });
box.innerHTML = '<div class="head"><strong>Rate Your Music</strong></div><div class="body external_ratings" style="padding: 10pt; display: flex; flex-flow: column nowrap; row-gap: 8pt;"></div>';
body = box.querySelector('div.body');
if (body == null) throw 'Assertion failed: Body missing';
anchor.before(box);
}
const row = document.createElement('div');
row.style = 'display: flex; flex-flow: row; column-gap: 12pt; justify-content: space-between; align-items: center;';
row.className = 'row';
row.append(...cells);
body.append(row);
return row;
}
function createStars(rating, maxRating, fill = '#fed94b') {
const starWidth = 100, starHeight = 95.11, starSize = 24, ns = 'http://www.w3.org/2000/svg';
const strokeFill = fill => ({ stroke: 'grey', 'stroke-width': 4, fill: fill });
const stars = document.createElement('div');
stars.style = 'display: flex; flex-flow: row; justify-content: end; gap: 4px;';
stars.innerHTML = `<svg style="display: none;"><symbol id="star"><polygon points="50,0.17 64.79,31.72 99.84,36.38 74.4,60.19 80.8,94.97 50.29,78.13 19.2,94.97 25.78,60.75 0.16,36.38 34.74,32.07" /></symbol></svg>`;
const useStar = attributes => {
const use = document.createElementNS(ns, 'use');
use.setAttribute('href', '#star');
if (attributes) for (let key of Object.keys(attributes)) use.setAttribute(key, attributes[key]);
return use;
};
for (let index = 0; index < maxRating; ++index) {
const star = Object.assign(document.createElementNS(ns, 'svg'), { style: `height: ${starSize}px;` }); // filter: drop-shadow(0 0 3pt grey);
star.setAttribute('viewBox', `0 0 ${starWidth} ${starHeight}`);
if (rating < index + 1) {
//star.append(useStar({ fill: '#eee' }));
if (rating - index > 0) {
const pad = 15, perc = Math.round(pad + Math.max(Math.min(rating - index, 1), 0) * (100 - pad * 2));
const mask = Object.assign(document.createElementNS(ns, 'mask'), {
innerHTML: `<rect x="0" y="0" width="${perc}%" height="${starHeight}" fill="white" />`,
id: crypto.randomUUID(),
});
star.append(mask, useStar({ fill: fill, mask: `url(#${mask.id})` }));
}
star.append(useStar(strokeFill('none')));
} else star.append(useStar(strokeFill(fill)));
stars.append(star);
}
return stars;
}
let title = document.body.querySelector('div.header > h2'), year, releaseType, query;
if (title != null) {
releaseType = /\[(\d+)\] \[(.+)\]/.exec(title.lastChild.textContent.trim());
if (releaseType != null) { year = parseInt(releaseType[1]); releaseType = releaseType[2] }
title = title.lastElementChild.textContent.trim();
} else throw 'Title not found';
let artists = { };
for (let a of document.body.querySelectorAll('ul#artist_list > li > a[dir="ltr"]')) {
if (!(a.parentNode.className in artists)) artists[a.parentNode.className] = [ ];
artists[a.parentNode.className].push(a.textContent.trim());
}
const getArtists = (importance, maxArtists = 3) => importance in artists ?
artists[importance].slice(0, maxArtists) : undefined;
artists = 'artists_dj' in artists ? getArtists('artists_dj') : releaseType != 'Compilation' ?
'artist_main' in artists ? getArtists('artist_main') : undefined : undefined; //['Various'];
rymFindReleases(artists, title, releaseType).then(function(releases) {
function renderReviews() {
console.assert(reviewsBox != null && reviewsBody != null);
releases.forEach(function(release, index) {
if (release.numReviews > 0 && release.reviews.show) release.reviews.forEach(function(review) {
if (reviewsBody.childElementCount > 0) reviewsBody.append(document.createElement('hr'));
reviewsBody.append(review);
});
});
reviewsBox.hidden = reviewsBody.childElementCount <= 0;
}
releases.forEach(function(release, index) {
const cells = [document.createElement('span'), document.createElement('span')];
const a = Object.assign(document.createElement('a'), { href: release.url, target: '_blank' });
a.innerHTML = release.cover ? `<img src="${release.cover}" height="25" style="width: auto; box-shadow: 0 0 3pt grey;" />`
: '<img src="https://e.snmc.io/3.0/img/logo/sonemic-32.png" height="25" style="width: auto; filter: drop-shadow(0 0 3pt grey);" />';
cells[0].append(a);
const stars = createStars(release.avgRating, release.maxRating, release.isMaster ? 'gold' : '#ffe973');
cells[1].append(stars);
if (release.albumInfo.Ranked) Array.from(release.albumInfo.Ranked.getElementsByTagName('a'))
.forEach(a => { a.replaceWith(a.textContent) });
let tooltip = [
`<b>${release.title}</b>`,
[
release?.albumInfo?.Type?.textContent?.trim(),
release?.trackList?.length > 0 && `${release.trackList.length} ${release.trackList.length > 1 ? 'tracks' : 'track'}`,
].filter(Boolean).join(', '),
['isMaster', 'country', 'medium', 'label', 'catNo'].map(prop =>
release[prop] && `<span>${release[prop]}</span>`).filter(Boolean).join(' / '),
`<span style="color: blue; font-weight: bold;">${release.avgRating}</span> of <span>${release.maxRating}</span>`,
release.albumInfo?.Ranked?.innerHTML,
];
if (release.creditedName) tooltip[0] = `<b>${release.creditedName}</b> − ` + tooltip[0];
else if (release.artists.length > 0) {
let artists = release.artists.map(artist => `<b>${artist}</b>`);
tooltip[0] = [artists.pop(), artists.join(', ')].reverse().filter(Boolean).join(' & ') + ' − ' + tooltip[0];
}
if (release.year) tooltip[0] += ` (<b>${release.year}</b>)`;
if (release.numVotes > 0 || release.numReviews > 0) tooltip[3] += ' (' + ['Votes', 'Reviews'].map(function(prop) {
let count = release['num' + prop];
if (count > 0) return `<b>${count.toString()}</b> ${(count > 1 ? prop : prop.slice(0, -1)).toLowerCase()}`;
}).filter(Boolean).join(' / ') + ')';
tooltip[3] = `<span style="font-size: 13pt;">${tooltip[3]}</span>`;
tooltip = `<span style="font: 10pt Sans-Serif; line-height: 1.3;">${tooltip.filter(Boolean).join('<br>')}</span>`;
if (release.cover) tooltip = `<div style="display: flex; flex-flow: row nowrap; column-gap: 8pt;"><img src="${release.cover}" height="75" />${tooltip}</div>`;
if (release.numReviews > 0) {
console.assert(release.reviews);
release.reviews.forEach(function(review) {
if (!(review instanceof HTMLElement)) return;
review.style = 'display: flex; flex-flow: column nowrap; row-gap: 5pt;';
for (let selector of [
'div.review_header > a[title]',
'div[id^="review_voting_"]',
'div.review_publish_status',
'div.review_header > a',
]) {
let elem = review.querySelector(selector);
if (elem != null) elem.remove();
}
for (let a of review.getElementsByTagName('a')) Object.assign(a, { href: resolveUrl(a), target: '_blank' });
let elem = review.querySelector('div.review_header');
if (elem != null) elem.style = 'font: bold 10pt "Noto Sans", sans-serif;';
elem = review.querySelector('span > span.review_rating');
if (elem != null) elem.parentNode.style.float = 'right';
elem = review.querySelector('span.catalog_track_ratings');
if (elem != null) [elem.style.cursor, elem.style.float] = ['pointer', 'right'];
elem = review.querySelector('div.track_rating_hide');
if (elem != null) elem.style.display = 'none';
for (let li of review.querySelectorAll('li.track')) li.style.listStyle = 'none';
for (let span of review.querySelectorAll('li.track span.track_rating_disp')) span.style.float = 'right';
});
release.reviews.show = true;
cells.splice(1, 0, Object.assign(document.createElement('span'), {
innerHTML: '<svg height="16" viewBox="0 0 100 100"><circle fill="#369" cx="50" cy="50" r="50"/><path fill="white" d="M48.21 26.04c12.64,0 18.96,4.68 18.96,14.03 0,3.02 -0.81,5.57 -2.41,7.63 -1.6,2.06 -3.61,3.69 -6.02,4.87l13.76 20.47 -12.58 0 -10.59 -17.64 -4.41 0 0 17.64 -11.19 0 0 -47 14.48 0zm-0.46 8.83l-2.83 0 0 11.78 2.83 0c2.67,0 4.68,-0.5 6.02,-1.48 1.34,-0.99 2.01,-2.56 2.01,-4.71 0,-1.89 -0.64,-3.29 -1.91,-4.21 -1.27,-0.92 -3.31,-1.38 -6.12,-1.38z"/></svg>',
style: 'cursor: pointer;',
className: 'has-reviews',
onclick: function(evt) {
evt.currentTarget.style.opacity = (release.reviews.show = !release.reviews.show) ? 1 : 0.5;
while (reviewsBody.lastChild != null) reviewsBody.removeChild(reviewsBody.lastChild);
renderReviews();
},
title: 'Has review',
}));
}
setTooltip(addBoxRow(...cells), `<div style="padding: 3pt;">${tooltip}</div>`, { position: 'left', offsetX: 6 });
});
const reviewsBox = document.createElement('div');
reviewsBox.className = 'box box_reviews';
reviewsBox.innerHTML = '<div class="head"><a href="#">↑</a> <strong>RYM Reviews</strong></div><div class="body" style="padding: 10pt; display: flex; flex-flow: column nowrap; row-gap: 10pt;"></div>';
document.body.querySelector('div#content div.main_column > div#torrent_comments').before(reviewsBox);
const reviewsBody = reviewsBox.querySelector('div.body');
console.assert(reviewsBody != null);
renderReviews();
}).catch(function(reason) {
console.warn('Rating will not show for the reason', reason);
addBoxRow(reason);
let box = document.querySelector('div#content div.sidebar > div.box.box_ratings');
if (box != null) {
box.animate([{ offset: 9.5/10.5, opacity: 1 }, { offset: 10/10.5, opacity: 0 }, { offset: 1, opacity: 0 }], 10500);
setTimeout(box => { box.remove() }, 10000, box);
}
if (reason == 'HTTP error 503') GM_openInTab(origin, false);
});
}