// ==UserScript== // @name [GMT] Edition lookup by CD TOC // @namespace https://greasyfork.org/users/321857-anakunda // @version 1.08 // @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 // @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 // @downloadURL none // ==/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; throw 'Failed to get torrent id'; } 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'), pre => pre.textContent).filter(function(logFile) { logFile = logFile.trimLeft(); 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')); } function getCDDBiD(tocEntries) { if (!Array.isArray(tocEntries)) throw 'Invalid argument'; let checksum = 0; for (let entry of tocEntries) checksum += (function(n) { let sum = 0; while (n > 0) { sum += n % 10; n = Math.floor(n / 10); } return sum; })(Math.floor((parseInt(entry.startSector) + preGap) / msf)); const length_seconds = Math.floor((tocEntries[tocEntries.length - 1].endSector + 1 - tocEntries[0].startSector) / msf); let id = checksum % 0xFF << 24 | length_seconds << 8 | tocEntries.length; if (id < 0) id = 0xFFFFFFFF + id + 1; return id.toString(16).padStart(8, '0'); } 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, '_'); function getCTDBtocId(trackoffsets) { if (!Array.isArray(trackoffsets)) throw 'Invalid argument'; if (trackoffsets.length > 100) throw 'TOC size exceeded limit'; return encodeTocStr(stringifyArray(trackoffsets, 8).padEnd(800, '0')); } 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); } let 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; const results = JSON.parse(target.dataset.results); for (let result of results.releases) 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; target.textContent = 'No matches'; target.style.color = 'red'; } else { 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 results[0].releases) 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); if (target.disabled) return false; else target.disabled = true; lookupByToc(parseInt(target.dataset.torrentId), tocEntries => Promise.resolve(getCDDBiD(tocEntries))).then(function(discIds) { for (let discId of discIds) if (discId != null) GM_openInTab('https://gnudb.org/cd/ro' + discId, false); }).catch(function(reason) { target.textContent = reason; target.style.color = 'red'; }).then(() => { target.disabled = false }); return false; }, 'Lookup edition on GnuDb (CDDB ID)'); 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(getCTDBtocId(tocEntries.map(tocEntry => tocEntry.endSector + 1 - preGap))); }).then(function(tocIds) { for (let tocId of tocIds) if (tocId != null) GM_openInTab('http://db.cuetools.net/top.php?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)'); } }