// ==UserScript==
// @name [GMT] Edition lookup by CD TOC
// @namespace https://greasyfork.org/users/321857-anakunda
// @version 1.11
// @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
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @connect musicbrainz.org
// @connect db.cuetools.net
// @connect db.cue.tools
// @connect gnudb.org
// @author Anakunda
// @license GPL-3.0-or-later
// @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
// ==/UserScript==
{
'use strict';
const requestsCache = new Map, mbRequestsCache = new Map;
const msf = 75, preGap = 2 * msf, dataTrackGap = 11400;
let mbLastRequest = null;
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 lookupByToc(torrentId, callback) {
if (!(torrentId > 0) || typeof callback != 'function') return Promise.reject('Invalid argument');
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;
// 1211 + 1287
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;
const tocParser = '^\\s*' + ['\\d+', msfTime, msfTime, '\\d+', '\\d+']
.map(pattern => '(' + pattern + ')').join('\\s+\\|\\s+') + '\\s*$';
function tocEntriesMapper(tocEntry, trackNdx) {
if ((tocEntry = new RegExp(tocParser).exec(tocEntry)) == 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]),
};
};
let request;
if (requestsCache.has(torrentId)) request = requestsCache.get(torrentId); else {
request = localXHR('/torrents.php?' + new URLSearchParams({ action: 'loglist', torrentid: torrentId })).then(document =>
Array.from(document.body.querySelectorAll(':scope > blockquote > pre:first-child'), function(pre) {
const rx = /^[\S\s]+(?:\r?\n){2,}(?=(?:Exact Audio Copy V|X Lossless Decoder version )\d+\b)/;
return rx.test(pre = pre.textContent.trimLeft()) ? pre.replace(rx, '') : pre;
}).filter(function(logFile) {
if (!['Exact Audio Copy', 'EAC', 'X Lossless Decoder'].some(prefix => logFile.startsWith(prefix))) return false;
const rr = rxRR.exec(logFile);
if (rr == null) return true;
// Ditch HTOA logs
let tocEntries = logFile.match(new RegExp(tocParser, 'gm'));
if (tocEntries != null) tocEntries = tocEntries.map(tocEntriesMapper); else return true;
return parseInt(rr[1]) != 0 || parseInt(rr[2]) + 1 != tocEntries[0].startSector;
}));
requestsCache.set(torrentId, request);
}
return request.then(logfiles => logfiles.length > 0 ? Promise.all(logfiles.map(function(logfile, volumeNdx) {
const isRangeRip = rxRR.test(logfile);
let tocEntries = logfile.match(new RegExp(tocParser, 'gm'));
if (tocEntries != null && tocEntries.length > 0) tocEntries = tocEntries.map(tocEntriesMapper);
else throw `disc ${volumeNdx + 1} ToC not found`;
let layoutType = 0;
for (let index = 0; index < tocEntries.length - 1; ++index) {
const gap = tocEntries[index + 1].startSector - tocEntries[index].endSector - 1;
if (gap == 0) continue; else layoutType = gap == dataTrackGap && index == tocEntries.length - 2 ? 1 : -1;
break;
}
if (layoutType == 1) tocEntries.pop();
return callback(tocEntries);
}).map(results => results.catch(function(reason) {
console.log('DiscID lookup failed:', reason);
return null;
}))) : Promise.reject('no valid log files found'));
}
const stringifyArray = (arr, width = 8) =>
arr.map(n => n.toString(16).toUpperCase().padStart(width, '0')).join('');
const encodeTocStr = tocStr => CryptoJS.SHA1(tocStr).toString(CryptoJS.enc.Base64)
.replace(/\=/g, '-').replace(/\+/g, '.').replace(/\//g, '_');
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 a = document.createElement('A');
a.textContent = caption;
a.className = 'brackets toc-lookup';
a.href = '#';
a.dataset.torrentId = torrentId;
a.onclick = callback;
if (tooltip) a.title = tooltip;
tr.append(' ', a);
}
const torrentId = getTorrentId(tr);
if (!(torrentId > 0)) continue;
if ((tr = tr.nextElementSibling) == null || !tr.classList.contains('torrentdetails')) continue;
if ((tr = tr.querySelector('div.linkbox')) == null) continue;
addLookup('Disc ID lookup', function(evt) {
const target = evt.currentTarget;
console.assert(target instanceof HTMLElement);
const baseUrl = 'https://musicbrainz.org/cdtoc/';
if (!target.disabled) if (Boolean(target.dataset.haveResponse)) {
if (!('results' in target.dataset)) return false;
for (let result of JSON.parse(target.dataset.results).releases.reverse())
GM_openInTab('https://musicbrainz.org/release/' + result.id, false);
// if (results.mbDiscID) {
// GM_openInTab(baseUrl + 'attach?toc=' + results.mbTOC.join(' '), false);
// GM_openInTab(baseUrl + results.mbDiscID, false);
// }
} else {
function mbQueryAPI(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;
}
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 encodeTocStr(stringifyArray(mbTOC.slice(0, 2), 2) + stringifyArray(mbTOC.slice(2), 8).padEnd(800, '0'));
}
function mbLookupByDiscID(mbTOC, allowTOCLookup = true) {
if (!Array.isArray(mbTOC) || mbTOC.length != mbTOC[1] - mbTOC[0] + 4)
return Promise.reject('mbLookupByDiscID(…): missing or invalid TOC');
const mbDiscID = mbComputeDiscID(mbTOC), params = { };
if (!mbDiscID || allowTOCLookup) params.toc = mbTOC.join('+');
//params['media-format'] = 'all';
return mbQueryAPI('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.style.color = null;
target.textContent = 'Looking up...';
lookupByToc(parseInt(target.dataset.torrentId), function(tocEntries) {
const isHTOA = tocEntries[0].startSector > preGap, mbTOC = [tocEntries[0].trackNumber, tocEntries.length];
mbTOC.push(preGap + tocEntries[tocEntries.length - 1].endSector + 1);
Array.prototype.push.apply(mbTOC, tocEntries.map(tocEntry => preGap + tocEntry.startSector));
return mbLookupByDiscID(mbTOC, !evt.ctrlKey);
}).then(function(results) {
if (results.length <= 0 || results[0] == null) {
if (!evt.ctrlKey) target.dataset.haveResponse = true;
return Promise.reject('No matches');
}
target.dataset.haveResponse = true;
let caption = `${results[0].releases.length} ${results[0].mbDiscID ? 'exact' : ' fuzzy'} match`;
if (results[0].releases.length > 1) caption += 'es';
target.textContent = caption;
target.style.color = 'green';
if (results[0].mbDiscID && results[0].releases.length > 0)
GM_openInTab(baseUrl + results[0].mbDiscID, true);
//GM_openInTab(baseUrl + 'attach?toc=' + results[0].mbTOC.join(' '), true);
else if (results[0].releases.length <= 5) for (let result of Array.from(results[0].releases).reverse())
GM_openInTab('https://musicbrainz.org/release/' + result.id, true);
target.dataset.results = JSON.stringify(results[0]);
}).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)');
addLookup('GnuDb lookup', 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 (!target.disabled) 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 {
target.disabled = true;
target.style.color = null;
target.textContent = 'Looking up...';
lookupByToc(parseInt(target.dataset.torrentId), function(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 discId = /^200 Disc ID is ([\da-f]{8})$/i.exec(responseText.trim());
if (discId == null) return Promise.reject(`Unexpected response format (${responseText})`);
reqUrl.searchParams.set('cmd', `cddb query ${discId[1]} ${tocDef}`);
return globalXHR(reqUrl, { responseType: 'text', context: discId[1] });
}).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');
const rx = /^(\w+)\s+([\da-f]{8})\s+(.*)$/i;
return (entries = (statusCode >= 210 ? responseText.slice(1).map(RegExp.prototype.exec.bind(rx))
: [rx.exec(entries[2])]).filter(Boolean)).length > 0 ? { status: statusCode, entries: entries }
: Promise.reject('No matches');
});
}).then(function(results) {
target.dataset.haveResponse = true;
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 = 'green';
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);
}).catch(function(reason) {
target.textContent = reason;
target.style.color = 'red';
}).then(() => { target.disabled = false });
}
return false;
}, 'Lookup edition on GnuDb');
addLookup('CTDB lookup', function(evt) {
const target = evt.currentTarget;
console.assert(target instanceof HTMLElement);
if (target.disabled) return false; else target.disabled = true;
lookupByToc(parseInt(target.dataset.torrentId), function(tocEntries) {
if (evt.ctrlKey && tocEntries.length < 2) return Promise.reject('one track only');
let preGap = 0;
if (evt.ctrlKey) preGap += tocEntries.shift().startSector;
preGap += tocEntries[0].startSector;
return Promise.resolve((function getCTDBtocId(trackoffsets) {
if (trackoffsets.length > 100) throw 'TOC size exceeded limit';
return encodeTocStr(stringifyArray(trackoffsets, 8).padEnd(800, '0'));
})(tocEntries.map(tocEntry => tocEntry.endSector + 1 - preGap)));
}).then(function(tocIds) {
for (let tocId of tocIds) if (tocId != null)
GM_openInTab('https://db.cue.tools/?tocid=' + tocId, false);
}).catch(function(reason) {
target.textContent = reason;
target.style.color = 'red';
}).then(() => { target.disabled = false });
return false;
}, 'Lookup edition in CUETools DB (CTDB TOCID)\n(Ctrl + click for enhanced TOCID)');
}
}