Rate Your Music for Gazelle

Integrate RYM ratings into music release pages

// ==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);
	});
}