// ==UserScript==
// @name [GMT] Edition lookup by CD TOC
// @namespace https://greasyfork.org/users/321857-anakunda
// @version 1.15.7
// @description Lookup edition by CD TOC on MusicBrainz, GnuDb and in CUETools DB
// @match https://*/torrents.php?id=*
// @match https://*/torrents.php?page=*&id=*
// @run-at document-end
// @iconURL https://ptpimg.me/5t8kf8.png
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_getResourceText
// @grant GM_getResourceURL
// @connect musicbrainz.org
// @connect api.discogs.com
// @connect www.discogs.com
// @connect db.cuetools.net
// @connect db.cue.tools
// @connect gnudb.org
// @author Anakunda
// @license GPL-3.0-or-later
// @resource mb_logo https://upload.wikimedia.org/wikipedia/commons/9/9e/MusicBrainz_Logo_%282016%29.svg
// @resource mb_icon https://upload.wikimedia.org/wikipedia/commons/9/9a/MusicBrainz_Logo_Icon_%282016%29.svg
// @resource dc_icon https://upload.wikimedia.org/wikipedia/commons/6/69/Discogs_record_icon.svg
// @require https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js
// @require https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js
// @require https://openuserjs.org/src/libs/Anakunda/libLocks.min.js
// @require https://openuserjs.org/src/libs/Anakunda/gazelleApiLib.min.js
// ==/UserScript==
{
'use strict';
const requestsCache = new Map, mbRequestsCache = new Map;
const msf = 75, preGap = 2 * msf;
let mbLastRequest = null;
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({ content: tooltip });
} else if (tooltip) elem.title = tooltip; else elem.removeAttribute('title');
}
function getTorrentId(tr) {
if (!(tr instanceof HTMLElement)) throw 'Invalid argument';
if ((tr = tr.querySelector('a.button_pl')) != null
&& (tr = parseInt(new URLSearchParams(tr.search).get('torrentid'))) > 0) return tr;
}
function mbApiRequest(endPoint, params) {
if (!endPoint) throw 'Endpoint is missing';
const url = new URL('/ws/2/' + endPoint.replace(/^\/+|\/+$/g, ''), 'https://musicbrainz.org');
url.search = new URLSearchParams(Object.assign({ fmt: 'json' }, params));
const cacheKey = url.pathname.slice(6) + url.search;
if (mbRequestsCache.has(cacheKey)) return mbRequestsCache.get(cacheKey);
const request = new Promise((resolve, reject) => { (function request(reqCounter = 1) {
if (reqCounter > 60) return reject('Request retry limit exceeded');
if (mbLastRequest == Infinity) return setTimeout(request, 100, reqCounter);
const now = Date.now();
if (now <= mbLastRequest + 1000) return setTimeout(request, mbLastRequest + 1000 - now, reqCounter);
mbLastRequest = Infinity;
globalXHR(url, { responseType: 'json' }).then(function({response}) {
mbLastRequest = Date.now();
resolve(response);
}, function(reason) {
mbLastRequest = Date.now();
if (/^HTTP error (?:429|430)\b/.test(reason)) return setTimeout(request, 1000, reqCounter + 1);
reject(reason);
});
})() });
mbRequestsCache.set(cacheKey, request);
return request;
}
const dcApiRateControl = { }, dcApiRequestsCache = new Map;
const dcAuth = (function() {
const [token, consumerKey, consumerSecret] =
['discogs_api_token', 'discogs_api_consumerkey', 'discogs_api_consumersecret'].map(name => GM_getValue(name));
return token ? 'token=' + token : consumerKey && consumerSecret ?
`key=${consumerKey}, secret=${consumerSecret}` : undefined;
})();
function dcApiRequest(endPoint, params) {
if (endPoint) endPoint = new URL(endPoint, 'https://api.discogs.com');
else return Promise.reject('No endpoint provided');
if (params instanceof URLSearchParams) endPoint.search = params;
else if (typeof params == 'object') for (let key in params) endPoint.searchParams.set(key, params[key]);
else if (params) endPoint.search = new URLSearchParams(params);
const cacheKey = endPoint.pathname.slice(1) + endPoint.search;
if (dcApiRequestsCache.has(cacheKey)) return dcApiRequestsCache.get(cacheKey);
const reqHeaders = { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
if (dcAuth) reqHeaders.Authorization = 'Discogs ' + dcAuth;
let requestsMax = reqHeaders.Authorization ? 60 : 25, retryCounter = 0;
const request = new Promise((resolve, reject) => (function request() {
const now = Date.now();
const postpone = () => { setTimeout(request, dcApiRateControl.timeFrameExpiry - now) };
if (!dcApiRateControl.timeFrameExpiry || now > dcApiRateControl.timeFrameExpiry) {
dcApiRateControl.timeFrameExpiry = now + 60 * 1000 + 500;
if (dcApiRateControl.requestDebt > 0) {
dcApiRateControl.requestCounter = Math.min(requestsMax, dcApiRateControl.requestDebt);
dcApiRateControl.requestDebt -= dcApiRateControl.requestCounter;
console.assert(dcApiRateControl.requestDebt >= 0, 'dcApiRateControl.requestDebt >= 0');
} else dcApiRateControl.requestCounter = 0;
}
if (++dcApiRateControl.requestCounter <= requestsMax) GM_xmlhttpRequest({
method: 'GET',
url: endPoint,
responseType: 'json',
headers: reqHeaders,
onload: function(response) {
let requestsUsed = /^(?:x-discogs-ratelimit):\s*(\d+)\b/im.exec(response.responseHeaders);
if (requestsUsed != null && (requestsUsed = parseInt(requestsUsed[1])) > 0) requestsMax = requestsUsed;
requestsUsed = /^(?:x-discogs-ratelimit-used):\s*(\d+)\b/im.exec(response.responseHeaders);
if (requestsUsed != null && (requestsUsed = parseInt(requestsUsed[1]) + 1) > dcApiRateControl.requestCounter) {
dcApiRateControl.requestCounter = requestsUsed;
dcApiRateControl.requestDebt = Math.max(requestsUsed - requestsMax, 0);
}
if (response.status >= 200 && response.status < 400) resolve(response.response);
else if (response.status == 429/* && ++retryCounter < xhrLibmaxRetries*/) {
console.warn(defaultErrorHandler(response), response.response.message, '(' + retryCounter + ')',
`Rate limit used: ${requestsUsed}/${requestsMax}`);
postpone();
} else if (recoverableHttpErrors.includes(response.status) && ++retryCounter < xhrLibmaxRetries)
setTimeout(request, xhrRetryTimeout);
else reject(defaultErrorHandler(response));
},
onerror: function(response) {
if (recoverableHttpErrors.includes(response.status) && ++retryCounter < xhrLibmaxRetries)
setTimeout(request, xhrRetryTimeout);
else reject(defaultErrorHandler(response));
},
ontimeout: response => { reject(defaultTimeoutHandler(response)) },
}); else postpone();
})());
dcApiRequestsCache.set(cacheKey, request);
return request;
}
const rxRR = /^(?:Selected range|Выбранный диапазон|Âûáðàííûé äèàïàçîí|已选择范围|選択された範囲|Gewählter Bereich|Intervallo selezionato|Geselecteerd bereik|Utvalt område|Seleccionar gama|Избран диапазон|Wybrany zakres|Izabrani opseg|Vybraný rozsah)(?:[^\S\r\n]+\((?:Sectors|Секторы|扇区|Sektoren|Settori|Sektorer|Sectores|Сектори|Sektora|Sektory)[^\S\r\n]+(\d+)[^\S\r\n]*-[^\S\r\n]*(\d+)\))?$/m;
function getTocEntries(logFile) {
if (!logFile) return null;
const msfTime = '(?:(\\d+):)?(\\d+):(\\d+)[\\.\\:](\\d+)';
const msfToSector = time => Array.isArray(time) || (time = new RegExp('^\\s*' + msfTime + '\\s*$').exec(time)) != null ?
(((time[1] ? parseInt(time[1]) : 0) * 60 + parseInt(time[2])) * 60 + parseInt(time[3])) * msf + parseInt(time[4]) : NaN;
const tocParsers = [
'^\\s*' + ['\\d+', msfTime, msfTime, '\\d+', '\\d+'] // EAC / XLD
.map(pattern => '(' + pattern + ')').join('\\s+\\|\\s+') + '\\s*$',
'^\\s*\[X\]\\s+' + ['\\d+', msfTime, msfTime, '\\d+', '\\d+'] // EZ CD
.map(pattern => '(' + pattern + ')').join('\\s+'),
];
let tocEntries = tocParsers.reduce((m, rx) => m || logFile.match(new RegExp(rx, 'gm')), null);
return tocEntries != null && (tocEntries = tocEntries.map(function(tocEntry, trackNdx) {
if ((tocEntry = tocParsers.reduce((m, rx) => m || new RegExp(rx).exec(tocEntry), null)) == null)
throw `assertion failed: track ${trackNdx + 1} ToC entry invalid format`;
console.assert(msfToSector(tocEntry[2]) == parseInt(tocEntry[12]));
console.assert(msfToSector(tocEntry[7]) == parseInt(tocEntry[13]) + 1 - parseInt(tocEntry[12]));
return {
trackNumber: parseInt(tocEntry[1]),
startSector: parseInt(tocEntry[12]),
endSector: parseInt(tocEntry[13]),
};
})).length > 0 ? tocEntries : null;
}
function getLogs(torrentId) {
function logFileValidator(logFile) {
if (!logFile || !['Exact Audio Copy', 'EAC', 'X Lossless Decoder', 'EZ CD Audio Converter']
.some(prefix => logFile.startsWith(prefix))) return false;
const rr = rxRR.exec(logFile);
if (rr == null) return true;
// Ditch HTOA logs
const tocEntries = getTocEntries(logFile);
return tocEntries == null || parseInt(rr[1]) != 0 || parseInt(rr[2]) + 1 != tocEntries[0].startSector;
}
if (!(torrentId > 0)) throw 'Invalid argument';
if (requestsCache.has(torrentId)) return requestsCache.get(torrentId);
const stackedLogRx = /^[\S\s]*(?:\r?\n)+(?=(?:Exact Audio Copy V|X Lossless Decoder version |EZ CD Audio Converter )\d+\b)/;
const stackedLogReducer = logFile => stackedLogRx.test(logFile) ? logFile.replace(stackedLogRx, '') : logFile;
// let request = queryAjaxAPICached('torrent', { id: torrentId }).then(({torrent}) => torrent.logCount > 0 ?
// Promise.all(torrent.ripLogIds.map(ripLogId => queryAjaxAPICached('riplog', { id: torrentId, logid: ripLogId })
// .then(response => stackedLogReducer(response)))) : Promise.reject('No logfiles attached'));
let request = localXHR('/torrents.php?' + new URLSearchParams({ action: 'loglist', torrentid: torrentId }))
.then(document => Array.from(document.body.querySelectorAll(':scope > blockquote > pre:first-child'), pre =>
stackedLogReducer(pre.textContent.trimLeft())));
request = request.then(logfiles => (logfiles = logfiles.filter(logFileValidator)).length > 0 ?
logfiles : Promise.reject('No valid logfiles attached'));
requestsCache.set(torrentId, request);
return request;
}
function getlayoutType(tocEntries) {
for (let index = 0; index < tocEntries.length - 1; ++index) {
const gap = tocEntries[index + 1].startSector - tocEntries[index].endSector - 1;
if (gap != 0) return gap == 11400 && index == tocEntries.length - 2 ? 1 : -1;
}
return 0;
}
function lookupByToc(torrentId, callback) {
if (typeof callback != 'function') return Promise.reject('Invalid argument');
return getLogs(torrentId).then(logfiles => Promise.all(logfiles.map(function(logfile, volumeNdx) {
const isRangeRip = rxRR.test(logfile), tocEntries = getTocEntries(logfile);
if (tocEntries == null) throw `disc ${volumeNdx + 1} ToC not found`;
const layoutType = getlayoutType(tocEntries);
if (layoutType == 1) tocEntries.pop(); // ditch data track for CD Extra
else if (layoutType != 0) console.warn('Disc %d unknown layout type', volumeNdx + 1);
return callback(tocEntries, volumeNdx, logfiles.length);
}).map(results => results.catch(function(reason) {
console.log('Edition lookup failed for the reason', reason);
return null;
}))));
}
class DiscID {
constructor() { this.id = '' }
addValues(values, width = 0, length = 0) {
if (!Array.isArray(values)) values = [values];
values = values.map(value => value.toString(16).toUpperCase().padStart(width, '0')).join('');
this.id += width > 0 && length > 0 ? values.padEnd(length * width, '0') : values;
return this;
}
toDigest() {
return CryptoJS.SHA1(this.id).toString(CryptoJS.enc.Base64)
.replace(/\=/g, '-').replace(/\+/g, '.').replace(/\//g, '_');
}
}
function mbComputeDiscID(mbTOC) {
if (!Array.isArray(mbTOC) || mbTOC.length != mbTOC[1] - mbTOC[0] + 4 || mbTOC[1] - mbTOC[0] > 98)
throw 'Invalid or too long MB TOC';
return new DiscID().addValues(mbTOC.slice(0, 2), 2).addValues(mbTOC.slice(2), 8, 100).toDigest();
}
function tocEntriesToMbTOC(tocEntries) {
if (!Array.isArray(tocEntries) || tocEntries.length <= 0) throw 'Invalid argument';
const isHTOA = tocEntries[0].startSector > preGap, mbTOC = [tocEntries[0].trackNumber, tocEntries.length];
mbTOC.push(preGap + tocEntries[tocEntries.length - 1].endSector + 1);
return Array.prototype.concat.apply(mbTOC, tocEntries.map(tocEntry => preGap + tocEntry.startSector));
}
if (typeof unsafeWindow == 'object') {
unsafeWindow.lookupByToc = lookupByToc;
unsafeWindow.mbComputeDiscID = mbComputeDiscID;
unsafeWindow.tocEntriesToMbTOC = tocEntriesToMbTOC;
}
function getCDDBiD(tocEntries) {
if (!Array.isArray(tocEntries)) throw 'Invalid argument';
const tt = Math.floor((tocEntries[tocEntries.length - 1].endSector + 1 - tocEntries[0].startSector) / msf);
let discId = tocEntries.reduce(function(sum, tocEntry) {
let n = Math.floor((parseInt(tocEntry.startSector) + preGap) / msf), s = 0;
while (n > 0) { s += n % 10; n = Math.floor(n / 10) }
return sum + s;
}, 0) % 0xFF << 24 | tt << 8 | tocEntries.length;
if (discId < 0) discId = 2**32 + discId;
return discId.toString(16).toLowerCase().padStart(8, '0');
}
function getARiD(tocEntries) {
if (!Array.isArray(tocEntries)) throw 'Invalid argument';
const discIds = [0, 0];
for (let index = 0; index < tocEntries.length; ++index) {
discIds[0] += tocEntries[index].startSector;
discIds[1] += Math.max(tocEntries[index].startSector, 1) * (index + 1);
}
discIds[0] += tocEntries[tocEntries.length - 1].endSector + 1;
discIds[1] += (tocEntries[tocEntries.length - 1].endSector + 1) * (tocEntries.length + 1);
return discIds.map(discId => discId.toString(16).toLowerCase().padStart(8, '0'))
.concat(getCDDBiD(tocEntries)).join('-');
}
const bareId = str => str ? str.trim().toLowerCase()
.replace(/^(?:Not On Label|No label|\[no label\]|None|\[none\]|Self[\s\-]?Released)(?:\s*\(.+\))?$|(?:\s+\b(?:Record(?:ing)?s)\b|,?\s+(?:Ltd|Inc|Co)\.?)+$/ig, '')
.replace(/\W/g, '') : '';
const uniqueValues = ((el1, ndx, arr) => el1 && arr.findIndex(el2 => bareId(el2) == bareId(el1)) == ndx);
function openTabHandler(evt) {
if (!evt.ctrlKey) return true;
if (evt.shiftKey && evt.currentTarget.dataset.groupUrl)
return (GM_openInTab(evt.currentTarget.dataset.groupUrl, false), false);
if (evt.currentTarget.dataset.url)
return (GM_openInTab(evt.currentTarget.dataset.url, false), false);
return true;
}
function updateEdition(evt) {
if (!openTabHandler(evt) || evt.currentTarget.disabled) return false; else if (!ajaxApiKey) {
if (!(ajaxApiKey = prompt('Set your API key with torrent edit permission:\n\n'))) return false;
GM_setValue('redacted_api_key', ajaxApiKey);
}
const target = evt.currentTarget, payload = { };
if (target.dataset.releaseYear) payload.remaster_year = target.dataset.releaseYear; else return false;
if (target.dataset.editionInfo) try {
const editionInfo = JSON.parse(target.dataset.editionInfo);
payload.remaster_record_label = editionInfo.map(label => label.label).filter(uniqueValues)
.map(label => /^(?:Not On Label|No label|\[no label\]|None|\[none\])(?:\s*\(.+\))?$|\b(?:Self[\s\-]?Released)\b/i.test(label) ? 'self-released' : label).filter(Boolean).join(' / ');
payload.remaster_catalogue_number = editionInfo.map(label => label.catNo).filter(uniqueValues)
.map(catNo => !/^(?:\[none\]|None)$/i.test(catNo) && catNo).filter(Boolean).join(' / ');
} catch (e) { console.warn(e) }
if (!payload.remaster_catalogue_number && target.dataset.barcodes) try {
payload.remaster_catalogue_number = JSON.parse(target.dataset.barcodes)
.filter((barcode, ndx, arr) => barcode && arr.indexOf(barcode) == ndx).join(' / ');
} catch (e) { console.warn(e) }
if (target.dataset.editionTitle) payload.remaster_title = target.dataset.editionTitle;
const entries = [ ];
if ('remaster_year' in payload) entries.push('Edition year: ' + payload.remaster_year);
if ('remaster_title' in payload) entries.push('Edition title: ' + payload.remaster_title);
if ('remaster_record_label' in payload) entries.push('Record label: ' + payload.remaster_record_label);
if ('remaster_catalogue_number' in payload) entries.push('Catalogue number: ' + payload.remaster_catalogue_number);
if (entries.length <= 0 || Boolean(target.dataset.confirm) && !confirm('Edition group is going to be updated\n\n' +
entries.join('\n') + '\n\nAre you sure the information is correct?')) return false;
target.disabled = true;
target.style.color = 'orange';
let selector = target.parentNode.dataset.edition;
if (!selector) return (alert('Assertion failed: edition group not found'), false);
selector = 'table#torrent_details > tbody > tr.torrent_row.edition_' + selector;
Promise.all(Array.from(document.body.querySelectorAll(selector), function(tr) {
const torrentId = getTorrentId(tr);
if (!(torrentId > 0)) return null;
const postData = new URLSearchParams(payload);
if (parseInt(target.parentNode.dataset.torrentId) == torrentId && 'description' in target.dataset
&& target.dataset.url) postData.set('release_desc', (target.dataset.description + '\n\n').trimLeft() +
'[url]' + target.dataset.url + '[/url]');
return queryAjaxAPI('torrentedit', { id: torrentId }, postData);
return `torrentId: ${torrentId}, postData: ${postData.toString()}`;
})).then(function(responses) {
target.style.color = '#0a0';
console.log('Edition updated successfully:', responses);
document.location.reload();
}, function(reason) {
target.style.color = 'red';
alert(reason);
target.disabled = false;
});
return false;
}
function applyOnClick(tr) {
tr.style.cursor = 'pointer';
tr.dataset.confirm = true;
tr.onclick = updateEdition;
let tooltip = 'Apply edition info from this release\n(Ctrl + click opens release page';
if (tr.dataset.groupUrl) tooltip += ' / Ctrl + Shift + click opens release group page';
setTooltip(tr, (tooltip += ')'));
tr.onmouseenter = tr.onmouseleave = evt =>
{ evt.currentTarget.style.color = evt.type == 'mouseenter' ? 'orange' : null };
}
function openOnClick(tr) {
tr.onclick = openTabHandler;
const updateCursor = evt => { tr.style.cursor = evt.ctrlKey ? 'pointer' : 'auto' };
tr.onmouseenter = function(evt) {
updateCursor(evt);
document.addEventListener('keyup', updateCursor);
document.addEventListener('keydown', updateCursor);
};
tr.onmouseleave = function(evt) {
document.removeEventListener('keyup', updateCursor);
document.removeEventListener('keydown', updateCursor);
};
let tooltip = 'Ctrl + click opens release page';
if (tr.dataset.groupUrl) tooltip += '\nCtrl + Shift + click opens release group page';
setTooltip(tr, tooltip);
}
function addLookupResults(torrentId, ...elems) {
if (!(torrentId > 0)) throw 'Invalid argument'; else if (elems.length <= 0) return;
let elem = document.getElementById('torrent_' + torrentId);
if (elem == null) throw '#torrent_' + torrentId + ' not found';
let container = elem.querySelector('div.toc-lookup-tables');
if (container == null) {
if ((elem = elem.querySelector('div.linkbox')) == null) throw 'linkbox not found';
container = document.createElement('DIV');
container.className = 'toc-lookup-tables';
container.style = 'margin: 10pt 0; padding: 0; display: flex; flex-flow: column; row-gap: 10pt;';
elem.after(container);
}
(elem = document.createElement('DIV')).append(...elems);
container.append(elem);
}
const incompleteEdition = /^(?:\d+ -|(?:Unconfirmed Release(?: \/.+)?|Unknown Release\(s\)) \/) CD$/;
const minifyHTML = html => html.replace(/(?:\s*\r?\n)+\s*/g, '');
const svgFail = (height = '1em', color = '#f00') => minifyHTML(`
<svg height="${height}" version="1.1" viewBox="0 0 256 256">
<circle fill="${color}" cx="128" cy="128" r="128" />
<path fill="white" d="M197.7 83.38l-1.78 1.78 -1.79 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.75 1.75 1.75 1.75 1.79 1.79 1.78 1.78 1.79 1.79 1.79 1.79 1.78 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.78 1.79 1.79 1.79 1.79 1.78 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.78c6.58,6.58 -18.5,31.66 -25.08,25.08l-44.62 -44.62 -1.75 1.75 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.78 1.78c-6.58,6.58 -31.66,-18.5 -25.08,-25.08l44.62 -44.62 -44.62 -44.62c-6.58,-6.58 18.5,-31.66 25.08,-25.08l1.78 1.78 1.79 1.79 1.79 1.79 1.78 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.78 1.79 1.79 1.79 1.79 1.78 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.78 1.79 1.79 1.79 1.79 1.78 1.78 1.79 1.79 1.75 1.75 44.62 -44.62c6.58,-6.58 31.66,18.5 25.08,25.08z" />
</svg>`);
const svgCheckmark = (height = '1em', color = '#0c0') => minifyHTML(`
<svg height="${height}" version="1.1" viewBox="0 0 4120.39 4120.39">
<circle fill="${color}" cx="2060.2" cy="2060.2" r="2060.2" />
<path fill="white" d="M1849.38 3060.71c-131.17,0 -356.12,-267.24 -440.32,-351.45 -408.56,-408.55 -468.78,-397.75 -282.81,-581.74 151.52,-149.91 136.02,-195.31 420.15,88.91 66.71,66.73 168.48,183.48 238.34,230.26 60.59,-40.58 648.52,-923.59 736.78,-1056.81 262.36,-396.02 237.77,-402.28 515.29,-195.27 150.69,112.4 -16.43,237.96 -237.31,570.2l-749.75 1108.47c-44.39,66.71 -104.04,187.43 -200.37,187.43z" />
</svg>`);
const svgQuestionmark = (height = '1em', color = '#fc0') => minifyHTML(`
<svg height="${height}" version="1.1" viewBox="0 0 256 256">
<circle fill="${color}" cx="128" cy="128" r="128" />
<path fill="white" d="M103.92 165.09c-0.84,-2.13 -1.46,-4.52 -1.92,-7.15 -0.46,-2.68 -0.67,-5.19 -0.67,-7.54 0,-3.76 0.37,-7.19 1.09,-10.29 0.75,-3.14 1.84,-6.06 3.3,-8.78 1.51,-2.72 3.35,-5.36 5.52,-7.83 2.22,-2.51 4.81,-4.98 7.74,-7.45 3.1,-2.59 5.82,-5.02 8.16,-7.28 2.3,-2.25 4.31,-4.47 5.94,-6.73 1.63,-2.26 2.85,-4.6 3.68,-6.99 0.8,-2.42 1.22,-5.1 1.22,-8.03 0,-2.55 -0.46,-4.9 -1.34,-7.07 -0.92,-2.14 -2.18,-4.02 -3.89,-5.57 -1.67,-1.54 -3.68,-2.76 -6.11,-3.68 -2.43,-0.88 -5.1,-1.34 -8.03,-1.34 -6.36,0 -12.97,1.34 -19.88,3.98 -6.86,2.68 -13.34,6.69 -19.45,12.09l0 -36.9c6.27,-3.77 13.14,-6.57 20.58,-8.45 7.45,-1.89 15.11,-2.85 23.06,-2.85 7.57,0 14.64,0.84 21.17,2.55 6.57,1.68 12.26,4.31 17.11,7.91 4.85,3.56 8.66,8.16 11.42,13.77 2.72,5.6 4.1,12.34 4.1,20.16 0,4.98 -0.58,9.5 -1.71,13.56 -1.18,4.01 -2.85,7.86 -5.03,11.46 -2.21,3.6 -4.97,7.03 -8.24,10.34 -3.26,3.3 -7.03,6.73 -11.25,10.25 -2.89,2.38 -5.4,4.56 -7.53,6.61 -2.18,2.05 -3.98,4.05 -5.4,6.06 -1.42,2.01 -2.51,4.14 -3.26,6.36 -0.71,2.26 -1.09,4.81 -1.09,7.7 0,1.93 0.25,3.93 0.79,5.98 0.51,2.05 1.26,3.77 2.14,5.15l-32.22 0zm17.87 53.68c-6.53,0 -11.97,-1.93 -16.28,-5.86 -4.35,-4.1 -6.53,-8.91 -6.53,-14.47 0,-5.74 2.18,-10.51 6.53,-14.36 4.31,-3.84 9.75,-5.73 16.28,-5.73 6.48,0 11.8,1.89 16.06,5.73 4.27,3.77 6.36,8.54 6.36,14.36 0,5.89 -2.05,10.75 -6.23,14.6 -4.27,3.8 -9.67,5.73 -16.19,5.73z" />
</svg>`);
for (let tr of Array.prototype.filter.call(document.body.querySelectorAll('table#torrent_details > tbody > tr.torrent_row'),
tr => (tr = tr.querySelector('td > a')) != null && /\b(?:FLAC)\b.+\b(?:Lossless)\b.+\b(?:Log) \(\-?\d+\s*\%\)/.test(tr.textContent))) {
function addLookup(caption, callback, tooltip) {
const span = document.createElement('SPAN'), a = document.createElement('A');
span.className = 'brackets';
span.dataset.torrentId = torrentId;
span.style = 'display: inline-flex; flex-flow: row; column-gap: 3pt; color: initial;';
if (edition != null) span.dataset.edition = edition;
if (isUnknownRelease) span.dataset.isUnknownRelease = true;
else if (isUnconfirmedRelease) span.dataset.isUnconfirmedRelease = true;
if (incompleteEdition.test(editionInfo)) span.dataset.editionInfoMissing = true;
a.textContent = caption;
a.className = 'toc-lookup';
a.href = '#';
a.onclick = callback;
if (tooltip) setTooltip(a, tooltip);
span.append(a);
linkBox.append(' ', span);
}
function getReleaseYear(date) {
if (!date) return undefined;
let year = new Date(date).getUTCFullYear();
return (!isNaN(year) || (year = /\b(\d{4})\b/.exec(date)) != null
&& (year = parseInt(year[1]))) && year >= 1900 ? year : NaN;
}
const torrentId = getTorrentId(tr);
if (!(torrentId > 0)) continue; // assertion failed
let edition = /\b(?:edition_(\d+))\b/.exec(tr.className);
if (edition != null) edition = parseInt(edition[1]);
const editionRow = (function(tr) {
while(tr != null) { if (tr.classList.contains('edition')) return tr; tr = tr.previousElementSibling }
return null;
})(tr);
let editionInfo = editionRow && editionRow.querySelector('td.edition_info > strong');
editionInfo = editionInfo != null ? editionInfo.lastChild.textContent.trim() : '';
const isUnknownRelease = editionInfo.startsWith('Unknown Release(s)');
const isUnconfirmedRelease = editionInfo.startsWith('Unconfirmed Release');
if (incompleteEdition.test(editionInfo)) editionRow.cells[0].style.backgroundColor = '#f001';
if ((tr = tr.nextElementSibling) == null || !tr.classList.contains('torrentdetails')) continue;
const linkBox = tr.querySelector('div.linkbox');
if (linkBox == null) continue;
const releaseToHtml = (release, country = 'country', date = 'date') => release ? [
release[country] && `<img src="http://s3.cuetools.net/flags/${release[country].toLowerCase()}.png" height="9" class="country" title="${release.country.toUpperCase()}" onerror="this.replaceWith('${release[country].toUpperCase()}')" />`,
release[date] && `<span class="date">${release[date]}</span>`,
].filter(Boolean).join(' ') : '';
const stripSuffix = name => name && name.replace(/\s*\(\d+\)$/, '');
addLookup('MusicBrainz', function(evt) {
const target = evt.currentTarget;
console.assert(target instanceof HTMLElement);
const baseUrl = 'https://musicbrainz.org/cdtoc/';
if (evt.altKey) {
target.disabled = true;
lookupByToc(parseInt(target.parentNode.dataset.torrentId), tocEntries => Promise.resolve(getCDDBiD(tocEntries))).then(function(discIds) {
for (let discId of Array.from(discIds).reverse()) if (discId != null)
GM_openInTab('https://musicbrainz.org/otherlookup/freedbid?other-lookup.freedbid=' + discId, false);
}).catch(function(reason) {
target.textContent = reason;
target.style.color = 'red';
}).then(() => { target.disabled = false });
} else if (!target.disabled) if (Boolean(target.dataset.haveResponse)) {
if ('ids' in target.dataset) for (let id of JSON.parse(target.dataset.ids).reverse())
GM_openInTab('https://musicbrainz.org/release/' + id, false);
// GM_openInTab(baseUrl + (evt.shiftKey ? 'attach?toc=' + JSON.parse(target.dataset.toc).join(' ')
// : target.dataset.discId), false);
} else {
function mbLookupByDiscID(mbTOC, allowTOCLookup = true, anyMedia = false) {
if (!Array.isArray(mbTOC) || mbTOC.length != mbTOC[1] - mbTOC[0] + 4)
return Promise.reject('mbLookupByDiscID(…): missing or invalid TOC');
const mbDiscID = mbComputeDiscID(mbTOC),
params = { inc: ['artist-credits', 'labels', 'release-groups', 'url-rels'].join('+') };
if (!mbDiscID || allowTOCLookup) params.toc = mbTOC.join('+');
if (anyMedia) params['media-format'] = 'all';
return mbApiRequest('discid/' + (mbDiscID || '-'), params).then(function(result) {
if (!('releases' in result) && !/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(result.id))
return Promise.reject('MusicBrainz: no matches');
const releases = result.releases || (['id', 'title'].every(key => key in result) ? [result] : null);
if (!Array.isArray(releases) || releases.length <= 0) return Promise.reject('MusicBrainz: no matches');
console.log('MusicBrainz lookup by discId/TOC successfull:', mbDiscID, '/', params, 'releases:', releases);
return { mbDiscID: result.id, mbTOC: mbTOC, releases: releases };
});
}
target.disabled = true;
target.textContent = 'Looking up...';
target.style.color = null;
lookupByToc(parseInt(target.parentNode.dataset.torrentId), tocEntries =>
mbLookupByDiscID(tocEntriesToMbTOC(tocEntries), !evt.ctrlKey)).then(function(results) {
if (results.length <= 0 || results[0] == null) {
if (!evt.ctrlKey) target.dataset.haveResponse = true;
return Promise.reject('No matches');
}
const exactMatch = Boolean(results[0].mbDiscID);
let caption = `${results[0].releases.length} ${exactMatch ? 'exact' : ' fuzzy'} match`;
if (results[0].releases.length > 1) caption += 'es';
target.textContent = caption;
target.style.color = '#0a0';
if (Boolean(target.dataset.haveResponse) || GM_getValue('auto_open_tab', true)) {
if (results[0].mbDiscID && results[0].releases.length > 0)
GM_openInTab(baseUrl + (evt.shiftKey ? 'attach?toc=' + results[0].mbTOC.join(' ') : results[0].mbDiscID), true);
// else if (results[0].releases.length <= 1) for (let id of results[0].releases.map(release => release.id).reverse())
// GM_openInTab('https://musicbrainz.org/release/' + id, true);
}
target.dataset.ids = JSON.stringify(results[0].releases.map(release => release.id));
target.dataset.discId = results[0].mbDiscID;
target.dataset.toc = JSON.stringify(results[0].mbTOC);
target.dataset.haveResponse = true;
if (!('edition' in target.parentNode.dataset) || !['redacted.ch'].includes(document.domain)
|| Boolean(target.parentNode.dataset.haveQuery)) return;
const totalDiscs = results.length;
const mediaCD = media => !media.format || /\b(?:H[DQ])?CD\b/.test(media.format);
const releaseFilter = release => !release.media || release.media.filter(mediaCD).length == totalDiscs;
if ((results = results[0].releases.filter(releaseFilter)).length <= 0) return;
target.parentNode.dataset.haveQuery = true;
queryAjaxAPICached('torrent', { id: parseInt(target.parentNode.dataset.torrentId) }).then(function(response) {
const isCompleteInfo = response.torrent.remasterYear > 0
&& Boolean(response.torrent.remasterRecordLabel)
&& Boolean(response.torrent.remasterCatalogueNumber);
const [isUnknownRelease, isUnconfirmedRelease] = ['isUnknownRelease', 'isUnconfirmedRelease']
.map(prop => Boolean(target.parentNode.dataset[prop]));
const labelInfoMapper = release => Array.isArray(release['label-info']) ?
release['label-info'].map(labelInfo => ({
label: labelInfo.label && labelInfo.label.name,
catNo: labelInfo['catalog-number'],
})).filter(labelInfo => labelInfo.label || labelInfo.catNo) : [ ];
if (!isCompleteInfo && exactMatch) {
const filteredResults = response.torrent.remasterYear > 0 ? results.filter(release => !release.date
|| getReleaseYear(release.date) == response.torrent.remasterYear) : results;
const releaseYear = filteredResults.reduce((year, release) =>
year > 0 ? year : getReleaseYear(release.date), undefined);
if (releaseYear > 0 && filteredResults.length > 0 && filteredResults.length < (isUnknownRelease ? 2 : 4)
&& !filteredResults.some(release1 => filteredResults.some(release2 =>
getReleaseYear(release2.date) != getReleaseYear(release1.date)))
&& filteredResults.every((release, ndx, arr) =>
release['release-group'].id == arr[0]['release-group'].id)) {
const a = document.createElement('A');
a.className = 'update-edition';
a.href = '#';
a.textContent = '(set)';
a.style.fontWeight = filteredResults.length < 2 ? 'bold' : 300;
a.dataset.releaseYear = releaseYear;
const editionInfo = Array.prototype.concat.apply([ ], filteredResults.map(labelInfoMapper));
if (editionInfo.length > 0) a.dataset.editionInfo = JSON.stringify(editionInfo);
const barcodes = filteredResults.map(release => release.barcode).filter(Boolean);
if (barcodes.length > 0) a.dataset.barcodes = JSON.stringify(barcodes);
if (filteredResults.length < 2 && !response.torrent.description.includes(filteredResults[0].id)) {
a.dataset.url = 'https://musicbrainz.org/release/' + filteredResults[0].id;
a.dataset.description = response.torrent.description.trim();
}
setTooltip(a, 'Update edition info from matched release(s)\n\n' + filteredResults.map(function(release) {
let title = getReleaseYear(release.date);
title = (title > 0 ? title.toString() : '') + (' ' + release['label-info'].map(labelInfo => [
labelInfo.label && labelInfo.label.name,
labelInfo['catalog-number'],
].filter(Boolean).join(' - ')).concat(release.barcode).filter(Boolean).join(' / ')).trimRight();
return title;
}).join('\n'));
a.onclick = updateEdition;
if (isUnknownRelease || filteredResults.length > 1) a.dataset.confirm = true;
target.parentNode.append(a);
}
}
const [thead, table, tbody] = ['DIV', 'TABLE', 'TBODY'].map(Document.prototype.createElement.bind(document));
thead.style = 'margin-bottom: 5pt;';
thead.innerHTML = `<b>Applicable MusicBrainz matches</b> (${exactMatch ? 'exact' : 'fuzzy'})`;
table.style = 'margin: 0; max-height: 10rem; overflow-y: auto; empty-cells: hide;';
table.className = 'mb-lookup-results mb-lookup-' + torrentId;
tbody.dataset.torrentId = torrentId; tbody.dataset.edition = target.parentNode.dataset.edition;
results.forEach(function(release, index) {
const [tr, artist, album, _release, editionInfo, barcode, groupSize, releasesWithId] =
['TR', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD'].map(Document.prototype.createElement.bind(document));
tr.className = 'musicbrainz-release';
tr.style = 'word-wrap: break-word; transition: color 200ms ease-in-out;';
[_release, barcode].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
artist.textContent = release['artist-credit'].map(artist => artist.name).join(' & ');
album.textContent = release.title;
if (release.disambiguation) {
const span = document.createElement('SPAN');
span.className = 'disambiguation';
span.style.opacity = 0.6;
span.textContent = '(' + release.disambiguation + ')';
album.append(' ', span);
}
_release.innerHTML = releaseToHtml(release);
editionInfo.innerHTML = release['label-info'].map(labelInfo => [
labelInfo.label && labelInfo.label.name && `<span class="label">${labelInfo.label.name}</span>`,
labelInfo['catalog-number'] && `<span class="catno" style="white-space: nowrap;">${labelInfo['catalog-number']}</span>`,
].filter(Boolean).join(' ')).filter(Boolean).join('<br>');
if (release.barcode) barcode.textContent = release.barcode;
mbApiRequest('release-group/' + release['release-group'].id, { inc: ['releases', 'media', 'discids'].join('+') }).then(function(releaseGroup) {
const releases = releaseGroup.releases.filter(releaseFilter);
groupSize.textContent = releases.length;
if (releases.length == 1) groupSize.style.color = '#0a0';
groupSize.title = 'Same media count in release group';
const haveDiscId = releases.filter(release =>
(release = release.media.filter(media => mediaCD(media))).length > 0
&& release[0].discs && release[0].discs.length > 0);
releasesWithId.textContent = haveDiscId.length;
releasesWithId.title = 'Same media count with known TOC in release group';
}, function(reason) {
if (releasesWithId.parentNode != null) releasesWithId.remove();
groupSize.colSpan = 2;
groupSize.innerHTML = svgFail('1em');
groupSize.title = reason;
});
tr.dataset.url = 'https://musicbrainz.org/release/' + release.id;
if (release['release-group'])
tr.dataset.groupUrl = 'https://musicbrainz.org/release-group/' + release['release-group'].id;
const releaseYear = getReleaseYear(release.date);
if (!isCompleteInfo && releaseYear > 0 && (!isUnknownRelease || exactMatch)) {
tr.dataset.releaseYear = releaseYear;
const editionInfo = labelInfoMapper(release);
if (editionInfo.length > 0) tr.dataset.editionInfo = JSON.stringify(editionInfo);
if (release.barcode) tr.dataset.barcodes = JSON.stringify([ release.barcode ]);
if (release.disambiguation) tr.dataset.editionTitle = release.disambiguation;
if (!response.torrent.description.includes(release.id))
tr.dataset.description = response.torrent.description.trim();
applyOnClick(tr);
} else openOnClick(tr);
tr.append(artist, album, _release, editionInfo, barcode, groupSize, releasesWithId);
['artist', 'album', 'release-event', 'edition-info', 'barcode', 'releases-count', 'tocs-count']
.forEach((className, index) => tr.cells[index].className = className);
tbody.append(tr);
if (release.relations) for (let relation of release.relations) {
if (relation.type != 'discogs' || !relation.url) continue;
let discogsId = /\/releases?\/(\d+)\b/i.exec(relation.url.resource);
if (discogsId != null) discogsId = parseInt(discogsId[1]); else continue;
if (album.querySelector('span.have-discogs-relatives') == null) {
const span = document.createElement('SPAN');
span.innerHTML = GM_getResourceText('dc_icon');
span.firstElementChild.setAttribute('height', 6);
span.firstElementChild.removeAttribute('width');
span.firstElementChild.style.verticalAlign = 'top';
span.style = 'margin: 0 0 0 1pt; padding: 0;';
span.className = 'have-discogs-relatives';
span.title = 'Has defined Discogs relative(s)';
album.append(span);
}
dcApiRequest('releases/' + discogsId).then(function(release) {
const [trDc, icon, artist, album, _release, editionInfo, barcode, groupSize] =
['TR', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD'].map(Document.prototype.createElement.bind(document));
trDc.className = 'discogs-release';
trDc.style = 'background-color: #8882; word-wrap: break-word; transition: color 200ms ease-in-out;';
[_release, barcode].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
icon.innerHTML = GM_getResourceText('dc_icon');
icon.firstElementChild.style = '';
icon.firstElementChild.removeAttribute('width');
icon.firstElementChild.setAttribute('height', 12);
icon.title = release.id;
artist.textContent = release.artists.map(artist =>
(artist.anv || stripSuffix(artist.name)) + ' ' + artist.join + ' ').join('')
.trimRight().replace(/\s+([,])/g, '$1').replace(/\s+/g, ' ');
album.textContent = release.title;
//`<img src="http://s3.cuetools.net/flags/${release.country.toLowerCase()}.png" height="9" title="${release.country}" onerror="this.replaceWith('${release.country}')" />`;
_release.innerHTML = [
release.country && `<span class="country">${release.country}</span>`,
release.released && `<span class="date">${release.released}</span>`,
].filter(Boolean).join(' ');
if (Array.isArray(release.labels)) editionInfo.innerHTML = release.labels.map(label => [
label.name && `<span class="label">${stripSuffix(label.name)}</span>`,
label.catno && `<span class="catno" style="white-space: nowrap;">${label.catno}</span>`,
].filter(Boolean).join(' ')).filter(Boolean).join('<br>');
let _barcode = release.identifiers && release.identifiers.find(id => id.type == 'Barcode');
if (_barcode) _barcode = _barcode.value.replace(/\D+/g, '');
if (_barcode) barcode.textContent = _barcode;
trDc.dataset.url = 'https://www.discogs.com/release/' + release.id;
if (release.master_id) {
const masterUrl = new URL('https://www.discogs.com/master/' + release.master_id);
for (let format of ['CD', 'CDr', 'HDCD', 'CD+G', 'CDi', 'CDV', 'CD-Record', 'AVCD', 'XRCD'])
masterUrl.searchParams.append('format', format);
trDc.dataset.groupUrl = masterUrl.href;
dcApiRequest('masters/' + release.master_id + '/versions', { per_page: 500 }).then(function(versions) {
const masterTotal = versions.versions.filter(version => 'major_formats' in version
&& version.major_formats.some(RegExp.prototype.test.bind(/^CD[ir]$|CD(?:V)?|\b(?:H[DQ]|AV|XR)?CD\b/))).length;
groupSize.textContent = masterTotal;
if (versions.pages > versions.page) groupSize.textContent += '+';
if (masterTotal == 1) groupSize.style.color = '#0a0';
groupSize.title = 'Total of same media versions for master release';
}, function(reason) {
groupSize.style.paddingTop = '5pt';
groupSize.innerHTML = svgFail('0.75em');
groupSize.title = reason;
});
} else {
groupSize.textContent = '-';
groupSize.style.color = '#0a0';
groupSize.title = 'Without master release';
}
const releaseYear = getReleaseYear(release.released);
if (!isCompleteInfo && releaseYear > 0 && (!isUnknownRelease || exactMatch)) {
trDc.dataset.releaseYear = releaseYear;
const editionInfo = Array.isArray(release.labels) ? release.labels.map(label => ({
label: stripSuffix(label.name),
catNo: label.catno,
})).filter(label => label.label || label.catNo) : [ ];
if (editionInfo.length > 0) trDc.dataset.editionInfo = JSON.stringify(editionInfo);
if (_barcode) trDc.dataset.barcodes = JSON.stringify([ _barcode ]);
if (!response.torrent.description.includes(trDc.dataset.url))
trDc.dataset.description = response.torrent.description.trim();
applyOnClick(trDc);
} else openOnClick(trDc);
trDc.append(artist, album, _release, editionInfo, barcode, groupSize, icon);
['artist', 'album', 'release-event', 'edition-info', 'barcode', 'releases-count', 'discogs-icon']
.forEach((className, index) => trDc.cells[index].className = className);
tr.after(trDc); //tbody.append(trDc);
}, reason => { album.querySelector('span.have-discogs-relatives').title = reason });
}
});
table.append(tbody);
addLookupResults(torrentId, thead, table);
}, alert);
}).catch(function(reason) {
target.textContent = reason;
target.style.color = 'red';
}).then(() => { target.disabled = false });
}
return false;
}, 'Lookup edition on MusicBrainz by Disc ID/TOC\n(Ctrl + click enforces strict TOC matching)\nUse Alt + click to lookup by CDDB ID');
addLookup('GnuDb', function(evt) {
const target = evt.currentTarget;
console.assert(target instanceof HTMLElement);
const entryUrl = entry => `https://gnudb.org/cd/${entry[1].slice(0, 2)}${entry[2]}`;
if (Boolean(target.dataset.haveResponse)) {
if (!('entries' in target.dataset)) return false;
for (let entry of JSON.parse(target.dataset.entries).reverse()) GM_openInTab(entryUrl(entry), false);
} else if (!target.disabled) {
target.disabled = true;
target.textContent = 'Looking up...';
target.style.color = null;
lookupByToc(parseInt(target.parentNode.dataset.torrentId), function(tocEntries) {
console.info('Local CDDB ID:', getCDDBiD(tocEntries));
console.info('Local AR ID:', getARiD(tocEntries));
const reqUrl = new URL('https://gnudb.gnudb.org/~cddb/cddb.cgi');
let tocDef = [tocEntries.length].concat(tocEntries.map(tocEntry => preGap + tocEntry.startSector));
const tt = preGap + tocEntries[tocEntries.length - 1].endSector + 1 - tocEntries[0].startSector;
tocDef = tocDef.concat(Math.floor(tt / msf)).join(' ');
reqUrl.searchParams.set('cmd', `discid ${tocDef}`);
reqUrl.searchParams.set('hello', `name ${document.domain} userscript.js 1.0`);
reqUrl.searchParams.set('proto', 6);
return globalXHR(reqUrl, { responseType: 'text' }).then(function({responseText}) {
console.log('GnuDb CDDB discid:', responseText);
const response = /^(\d+) Disc ID is ([\da-f]{8})$/i.exec(responseText.trim());
if (response == null) return Promise.reject(`Unexpected response format (${responseText})`);
console.assert((response[1] = parseInt(response[1])) == 200);
reqUrl.searchParams.set('cmd', `cddb query ${response[2]} ${tocDef}`);
return globalXHR(reqUrl, { responseType: 'text', context: response });
}).then(function({responseText}) {
console.log('GnuDb CDDB query:', responseText);
let entries = /^(\d+)\s+(.+)/.exec((responseText = responseText.trim().split(/\r?\n/))[0]);
if (entries == null) return Promise.reject('Unexpected response format');
const statusCode = parseInt(entries[1]);
if (statusCode < 200 || statusCode >= 400) return Promise.reject(`Server response error (${statusCode})`);
if (statusCode == 202) return Promise.reject('No matches');
entries = (statusCode >= 210 ? responseText.slice(1) : [entries[2]])
.map(RegExp.prototype.exec.bind(/^(\w+)\s+([\da-f]{8})\s+(.*)$/i)).filter(Boolean);
return entries.length <= 0 ? Promise.reject('No matches')
: { status: statusCode, discId: arguments[0].context[2], entries: entries };
});
}).then(function(results) {
if (results.length <= 0 || results[0] == null) return Promise.reject('No matches');
let caption = `${results[0].entries.length} ${['exact', 'fuzzy'][results[0].status % 10]} match`;
if (results[0].entries.length > 1) caption += 'es';
target.textContent = caption;
target.style.color = '#0a0';
if (results[0].entries.length <= 5) for (let entry of Array.from(results[0].entries).reverse())
GM_openInTab(entryUrl(entry), true);
target.dataset.entries = JSON.stringify(results[0].entries);
target.dataset.haveResponse = true;
}).catch(function(reason) {
target.textContent = reason;
target.style.color = 'red';
}).then(() => { target.disabled = false });
}
return false;
}, 'Lookup edition on GnuDb (CDDB)');
addLookup('CTDB', function(evt) {
const target = evt.currentTarget;
console.assert(target instanceof HTMLElement);
if (target.disabled) return false; else target.disabled = true;
const torrentId = parseInt(target.parentNode.dataset.torrentId);
if (!(torrentId > 0)) throw 'Assertion failed: invalid torrentId';
lookupByToc(torrentId, function(tocEntries) {
if (tocEntries.length > 100) throw 'TOC size exceeds limit';
tocEntries = tocEntries.map(tocEntry => tocEntry.endSector + 1 - tocEntries[0].startSector);
return Promise.resolve(new DiscID().addValues(tocEntries, 8, 100).toDigest());
}).then(function(tocIds) {
if (!Boolean(target.parentNode.dataset.haveQuery) && !GM_getValue('auto_open_tab', true)) return;
for (let tocId of Array.from(tocIds).reverse()) if (tocId != null)
GM_openInTab('https://db.cue.tools/?tocid=' + tocId, !Boolean(target.parentNode.dataset.haveQuery));
}, function(reason) {
target.textContent = reason;
target.style.color = 'red';
}).then(() => { target.disabled = false });
if (!target.parentNode.dataset.edition || !['redacted.ch'].includes(document.domain)
|| Boolean(target.parentNode.dataset.haveQuery)) return false;
const ctdbLookup = params => lookupByToc(torrentId, function(tocEntries, volumeNdx) {
const url = new URL('https://db.cue.tools/lookup2.php');
url.searchParams.set('version', 3);
url.searchParams.set('ctdb', 1);
if (params) for (let param in params) url.searchParams.set(param, params[param]);
url.searchParams.set('toc', tocEntries.map(tocEntry => tocEntry.startSector)
.concat(tocEntries[tocEntries.length - 1].endSector + 1).join(':'));
return globalXHR(url).then(({responseXML}) => ({
metadata: Array.from(responseXML.getElementsByTagName('metadata'), metadata => ({
source: metadata.getAttribute('source') || undefined,
id: metadata.getAttribute('id') || undefined,
artist: metadata.getAttribute('artist') || undefined,
album: metadata.getAttribute('album') || undefined,
year: parseInt(metadata.getAttribute('year')) || undefined,
discNumber: parseInt(metadata.getAttribute('discnumber')) || undefined,
discCount: parseInt(metadata.getAttribute('disccount')) || undefined,
release: Array.from(metadata.getElementsByTagName('release'), release => ({
date: release.getAttribute('date') || undefined,
country: release.getAttribute('country') || undefined,
})),
labelInfo: Array.from(metadata.getElementsByTagName('label'), label => ({
name: label.getAttribute('name') || undefined,
catno: label.getAttribute('catno') || undefined,
})),
barcode: metadata.getAttribute('barcode') || undefined,
relevance: (relevance => isNaN(relevance) ? undefined : relevance)
(parseInt(metadata.getAttribute('relevance'))),
})),
entries: Array.from(responseXML.getElementsByTagName('entry'), entry => ({
confidence: parseInt(entry.getAttribute('confidence')),
crc32: parseInt(entry.getAttribute('crc32')),
hasparity: entry.getAttribute('hasparity') || undefined,
id: parseInt(entry.getAttribute('id')),
npar: parseInt(entry.getAttribute('npar')),
stride: parseInt(entry.getAttribute('stride')),
syndrome: entry.getAttribute('syndrome') || undefined,
toc: entry.hasAttribute('toc') ?
entry.getAttribute('toc').split(':').map(offset => parseInt(offset)) : undefined,
trackcrcs: entry.hasAttribute('trackcrcs') ?
entry.getAttribute('trackcrcs').split(' ').map(crc => parseInt(crc, 16)) : undefined,
})),
}));
}).then(function(results) {
console.log('CTDB lookup (%s, %d) results:', params.metadata, params.fuzzy, results);
return results.length > 0 && results[0] != null && (results = Object.assign(results[0].metadata.filter(function(metadata) {
if (!['musicbrainz', 'discogs'].includes(metadata.source)) return false;
if (metadata.discCount > 0 && metadata.discCount != results.length) return false;
return true;
}), { confidence: (entries => getLogs(torrentId).then(logfiles => logfiles.length == entries.length ? logfiles.map(function(logfile, volumeNdx) {
if (rxRR.test(logfile)) return null;
const rx = [
/^\s+(?:(?:Copy|复制|Kopie|Copia|Kopiera|Copiar|Копиран) CRC|CRC (?:копии|êîïèè|kopii|kopije|kopie|kópie)|コピーCRC)\s+([\da-fA-F]{8})$/gm,
/^\s+(?:CRC32 hash|CRC)\s*:\s*([\da-fA-F]{8})$/gm, // XLD / EZ CD
];
return (logfile = logfile.match(rx[0]) || logfile.match(rx[1])) && logfile.map(match =>
parseInt(rx.reduce((m, rx) => m || (rx.lastIndex = 0, rx.exec(match)), null)[1], 16));
}).map(function getScores(checksums, volumeNdx) {
if (checksums == null || checksums.length < 3
|| !entries[volumeNdx].some(entry => entry.trackcrcs.length == checksums.length)) return null; // tracklist too short
const getMatches = matchFn => entries[volumeNdx].reduce((sum, entry, ndx) =>
matchFn(entry.trackcrcs.length == checksums.length ? entry.trackcrcs.slice(1, -1) .filter((crc32, ndx) =>
crc32 == checksums[ndx + 1]).length / (entry.trackcrcs.length - 2) : -Infinity) ?
sum + entry.confidence : sum, 0);
return [entries[volumeNdx].reduce((sum, entry) => sum + entry.confidence, 0),
getMatches(score => score >= 1), getMatches(score => score >= 0.5), getMatches(score => score > 0)];
}) : Promise.reject('assertion failed: LOGfiles miscount')).then(function getTotal(scores) {
if ((scores = scores.filter(Boolean)).length <= 0)
return Promise.reject('all media having too short tracklist, mismatching tracklist length, range rip or failed to extract checksums');
const sum = array => array.reduce((sum, val) => sum + val, 0);
const getTotal = index => Math.min(...(index = scores.map(score => score[index]))) > 0 ? sum(index) : 0;
return (scores = {
matched: getTotal(1),
partiallyMatched: getTotal(2),
anyMatched: getTotal(3),
total: sum(scores.map(score => score[0])),
}).anyMatched > 0 ? scores : Promise.reject('mismatch');
}))(results.map(result => result.entries)) })).length > 0 ? results : Promise.reject('No matches');
});
const methods = [
{ metadata: 'fast', fuzzy: 0 }, { metadata: 'default', fuzzy: 0 }, { metadata: 'extensive', fuzzy: 0 },
{ metadata: 'fast', fuzzy: 1 }, { metadata: 'default', fuzzy: 1 }, { metadata: 'extensive', fuzzy: 1 },
];
(function execMethod(index = 0) {
return index < methods.length ? ctdbLookup(methods[index]).then(results =>
Object.assign(results, { method: methods[index] }),
reason => execMethod(index + 1)) : Promise.reject('No matches');
})().then(function(results) {
target.textContent = `${results.length}${Boolean(results.method.fuzzy) ? ' fuzzy' : ''} ${results.method.metadata} ${results.length == 1 ? 'match' : 'matches'}`;
target.style.color = '#0a0';
queryAjaxAPICached('torrent', { id: torrentId }).then(function(response) {
const isCompleteInfo = response.torrent.remasterYear > 0
&& Boolean(response.torrent.remasterRecordLabel)
&& Boolean(response.torrent.remasterCatalogueNumber);
const [isUnknownRelease, isUnconfirmedRelease] = ['isUnknownRelease', 'isUnconfirmedRelease']
.map(prop => Boolean(target.parentNode.dataset[prop]));
const [method, confidence] = [results.method, results.confidence];
const confidenceBox = document.createElement('SPAN');
confidence.then(function(confidence) {
let color = confidence.matched || confidence.partiallyMatched || confidence.anyMatched;
color = Math.round(color * 0x55 / confidence.total);
color = 0x55 * (3 - (confidence.matched > 0) - (confidence.partiallyMatched > 0)) - color;
color = '#' + (color << 16 | 0xCC00).toString(16).padStart(6, '0');
confidenceBox.innerHTML = svgCheckmark('1em', color);
confidenceBox.className = confidence.matched > 0 ? 'ctdb-verified' : 'ctdb-partially-verified';
setTooltip(confidenceBox, `Checksums${confidence.matched > 0 ? '' : ' partially'} matched (confidence ${confidence.matched || confidence.partiallyMatched || confidence.anyMatched}/${confidence.total})`);
}).catch(function(reason) {
confidenceBox.innerHTML = reason == 'mismatch' ? svgFail('1em') : svgQuestionmark('1em');
confidenceBox.className = 'ctdb-not-verified';
setTooltip(confidenceBox, `Could not verify checksums (${reason})`);
}).then(() => { target.parentNode.append(confidenceBox) });
const _getReleaseYear = metadata => (metadata = metadata.release.map(release => getReleaseYear(release.date)))
.every((year, ndx, arr) => year > 0 && year == arr[0]) ? metadata[0] : NaN;
const labelInfoMapper = metadata => metadata.labelInfo.map(labelInfo =>
({ label: stripSuffix(labelInfo.name), catNo: labelInfo.catno }))
.filter(labelInfo => labelInfo.label || labelInfo.catNo);
if (!isCompleteInfo && !Boolean(method.fuzzy)) {
const filteredResults = response.torrent.remasterYear > 0 ? results.filter(metadata =>
isNaN(metadata = _getReleaseYear(metadata)) || metadata == response.torrent.remasterYear) : results;
const releaseYear = filteredResults.reduce((year, metadata) => isNaN(year) ? NaN :
(metadata = _getReleaseYear(metadata)) > 0 && (year <= 0 || metadata == year) ? metadata : NaN, -Infinity);
if (releaseYear > 0 && filteredResults.length > 0 && filteredResults.length < (isUnknownRelease ? 2 : 4)
&& (!isUnknownRelease || method.metadata != 'extensive' || filteredResults.every(metadata => !(metadata.relevance < 100)))
&& filteredResults.every(m1 => m1.release.every(r1 => filteredResults.every(m2 =>
m2.release.every(r2 => getReleaseYear(r2.date) == getReleaseYear(r1.date)))))) {
const a = document.createElement('A');
a.className = 'update-edition';
a.href = '#';
a.textContent = '(set)';
confidence.then(function(confidence) {
if (filteredResults.length > 1 || filteredResults[0].relevance < 100 || confidence.matches <= 0)
return Promise.reject('Weak');
a.style.fontWeight = 'bold';
}).catch(function(reason) {
a.style.fontWeight = 300;
a.dataset.confirm = true;
});
a.dataset.releaseYear = releaseYear;
const editionInfo = Array.prototype.concat.apply([ ], filteredResults.map(labelInfoMapper));
if (editionInfo.length > 0) a.dataset.editionInfo = JSON.stringify(editionInfo);
const barcodes = filteredResults.map(metadata => metadata.barcode).filter(Boolean);
if (barcodes.length > 0) a.dataset.barcodes = JSON.stringify(barcodes);
if (filteredResults.length < 2 && !response.torrent.description.includes(filteredResults[0].id)) {
a.dataset.description = response.torrent.description.trim();
a.dataset.url = {
musicbrainz: 'https://musicbrainz.org/release/' + results[0].id,
discogs: 'https://www.discogs.com/release/' + results[0].id,
}[filteredResults[0].source];
}
setTooltip(a, 'Update edition info from matched release(s)\n\n' + filteredResults.map(function(metadata) {
let title = { discogs: 'Discogs', musicbrainz: 'MusicBrainz' }[metadata.source];
const releaseYear = _getReleaseYear(metadata);
if (releaseYear > 0) title += ' ' + releaseYear.toString();
title += (' ' + metadata.labelInfo.map(labelInfo => [stripSuffix(labelInfo.name), labelInfo.catno]
.filter(Boolean).join(' - ')).concat(metadata.barcode).filter(Boolean).join(' / ')).trimRight();
if (metadata.relevance >= 0) title += ` (${metadata.relevance}%)`;
return title.trim();
}).join('\n'));
a.onclick = updateEdition;
(isUnknownRelease ? confidence : Promise.resolve({ partiallyMatched: Infinity }))
.then(confidence => { if (confidence.partiallyMatched > 0) target.parentNode.append(a) });
}
}
const [thead, table, tbody] = ['DIV', 'TABLE', 'TBODY'].map(Document.prototype.createElement.bind(document));
thead.style = 'margin-bottom: 5pt;';
thead.innerHTML = `<b>Applicable CTDB matches</b> (method: ${Boolean(method.fuzzy) ? 'fuzzy, ' : ''}${method.metadata})`;
table.style = 'margin: 0; max-height: 10rem; overflow-y: auto; empty-cells: hide;';
table.className = 'ctdb-lookup-results ctdb-lookup-' + torrentId;
tbody.dataset.torrentId = torrentId; tbody.dataset.edition = target.parentNode.dataset.edition;
results.forEach(function(metadata) {
const [tr, source, artist, album, release, editionInfo, barcode, relevance] =
['TR', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD'].map(Document.prototype.createElement.bind(document));
tr.className = 'ctdb-metadata';
tr.style = 'word-wrap: break-word; transition: color 200ms ease-in-out;';
[release, barcode, relevance].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
if (source.innerHTML = GM_getResourceText({ musicbrainz: 'mb_logo', discogs: 'dc_icon' }[metadata.source])) {
source.children[0].removeAttribute('width');
source.children[0].setAttribute('height', 12);
} else {
const img = document.createElement('IMG');
img.src = `http://s3.cuetools.net/icons/${metadata.source}.png`;
img.height = 12;
source.append(img);
}
artist.textContent = metadata.artist;
album.textContent = metadata.album;
release.innerHTML = metadata.release.map(release => releaseToHtml(release)).filter(Boolean).join('<br>');
editionInfo.innerHTML = metadata.labelInfo.map(labelInfo => [
labelInfo.name && `<span class="label">${stripSuffix(labelInfo.name)}</span>`,
labelInfo.catno && `<span class="catno" style="white-space: nowrap;">${labelInfo.catno}</span>`,
].filter(Boolean).join(' ')).filter(Boolean).join('<br>');
if (metadata.barcode) barcode.textContent = metadata.barcode;
if (metadata.relevance >= 0) {
relevance.textContent = metadata.relevance + '%';
relevance.title = 'Relevance';
}
tr.dataset.url = {
musicbrainz: 'https://musicbrainz.org/release/' + metadata.id,
discogs: 'https://www.discogs.com/release/' + metadata.id,
}[metadata.source];
const releaseYear = _getReleaseYear(metadata);
(!isCompleteInfo && !Boolean(method.fuzzy) && releaseYear > 0
&& (!isUnknownRelease || method.metadata != 'extensive' || !(metadata.relevance < 100)) ?
confidence : Promise.reject('Not applicable')).then(function(confidence) {
if (isUnknownRelease && confidence.anyMatched <= 0) return Promise.reject('Low confidence rate');
tr.dataset.releaseYear = releaseYear;
const editionInfo = labelInfoMapper(metadata);
if (editionInfo.length > 0) tr.dataset.editionInfo = JSON.stringify(editionInfo);
if (metadata.barcode) tr.dataset.barcodes = JSON.stringify([ metadata.barcode ]);
if (!response.torrent.description.includes(metadata.id))
tr.dataset.description = response.torrent.description.trim();
applyOnClick(tr);
}).catch(reason => { openOnClick(tr) });
tr.append(source, artist, album, release, editionInfo, barcode, relevance);
['source', 'artist', 'album', 'release-events', 'edition-info', 'barcode', 'relevance']
.forEach((className, index) => tr.cells[index].className = className);
tbody.append(tr);
});
table.append(tbody);
addLookupResults(torrentId, thead, table);
}, console.warn);
}, function(reason) {
target.textContent = reason;
target.style.color = 'red';
}).then(() => { target.parentNode.dataset.haveQuery = true });
return false;
}, 'Lookup edition in CUETools DB (TOCID)');
}
let elem = document.body.querySelector('div#discog_table > div.box.center > a:last-of-type');
if (elem != null) {
const a = document.createElement('A'), captions = ['Incomplete editions only', 'All editions'];
a.textContent = captions[0];
a.href = '#';
a.className = 'brackets';
a.style.marginLeft = '2rem';
a.onclick = function(evt) {
if (captions.indexOf(evt.currentTarget.textContent) == 0) {
for (let strong of document.body.querySelectorAll('div#discog_table > table.torrent_table.grouped > tbody > tr.edition.discog > td.edition_info > strong')) (function(tr, show = true) {
if (show) (function(tr) {
show = false;
while ((tr = tr.nextElementSibling) != null && tr.classList.contains('torrent_row')) {
const a = tr.querySelector('td > a:last-of-type');
if (a == null || !/\bFLAC\s*\/\s*Lossless\s*\/\s*Log\s*\(\-?\d+%\)/.test(a.textContent)) continue;
show = true;
break;
}
})(tr);
if (show) (function(tr) {
while (tr != null && !tr.classList.contains('group')) tr = tr.previousElementSibling;
if (tr != null && (tr = tr.querySelector('div > a.show_torrents_link')) != null
&& tr.parentNode.classList.contains('show_torrents')) tr.click();
})(tr); else (function(tr) {
do tr.hidden = true;
while ((tr = tr.nextElementSibling) != null && tr.classList.contains('torrent_row'));
})(tr);
})(strong.parentNode.parentNode, incompleteEdition.test(strong.lastChild.textContent.trim()));
for (let tr of document.body.querySelectorAll('div#discog_table > table.torrent_table.grouped > tbody > tr.group.discog')) (function(tr) {
if (!(function(tr) {
while ((tr = tr.nextElementSibling) != null && !tr.classList.contains('group'))
if (tr.classList.contains('edition') && !tr.hidden) return true;
return false;
})(tr)) tr.hidden = true;
})(tr);
} else for (let tr of document.body.querySelectorAll('div#discog_table > table.torrent_table.grouped > tbody > tr.discog'))
tr.hidden = false;
evt.currentTarget.textContent = captions[1 - captions.indexOf(evt.currentTarget.textContent)];
};
elem.after(a);
}
}