// ==UserScript== // @name [GMT] Edition lookup by CD TOC // @namespace https://greasyfork.org/users/321857-anakunda // @version 1.15.2 // @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 // @grant GM_getValue // @grant GM_setValue // @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 // @require https://openuserjs.org/src/libs/Anakunda/libLocks.min.js // @require https://openuserjs.org/src/libs/Anakunda/gazelleApiLib.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 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, '
') 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; } 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 msfTime = '(?:(\\d+):)?(\\d+):(\\d+)[\\.\\:](\\d+)'; // 1211 + 1287 const tocParser = '^\\s*' + ['\\d+', msfTime, msfTime, '\\d+', '\\d+'] .map(pattern => '(' + pattern + ')').join('\\s+\\|\\s+') + '\\s*$'; function tocEntriesMapper(tocEntry, trackNdx) { 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; 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]), }; } function getLogs(torrentId) { if (!(torrentId > 0)) throw 'Invalid argument'; if (requestsCache.has(torrentId)) return requestsCache.get(torrentId); const 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; } function lookupByToc(torrentId, callback) { if (typeof callback != 'function') return Promise.reject('Invalid argument'); return getLogs(torrentId).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, volumeNdx); }).map(results => results.catch(function(reason) { console.log('Edition lookup failed for the reason', reason); return null; }))) : Promise.reject('No valid log files found')); } 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 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)$|(?:\s+\b(?:Record(?:ing)?s)\b|,?\s+(?:Ltd|Inc|Co)\.?)+$|[\s\-]+/ig, '') .replace(/\W/g, '') : ''; function updateEdition(evt) { if (evt.ctrlKey && evt.currentTarget.dataset.url) return (GM_openInTab(evt.currentTarget.dataset.url, false), false); if (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; if (target.dataset.editionInfo) { const editionInfo = JSON.parse(target.dataset.editionInfo); const uniqueValues = ((el1, ndx, arr) => el1 && arr.findIndex(el2 => bareId(el2) == bareId(el1)) == ndx); payload.remaster_record_label = editionInfo.map(label => label.label).filter(uniqueValues) .map(label => label.replace(/^(?:Not On Label|\[no label\]|No label|None)$/ig, '')).filter(Boolean).join(' / '); payload.remaster_catalogue_number = editionInfo.map(label => label.catNo).filter(uniqueValues) .map(catNo => catNo.replace(/^(?:None)$/ig, '')).filter(Boolean).join(' / '); } if (Boolean(target.dataset.confirm) && !confirm(`Edition group is going to be updated Edition year: ${payload.remaster_year || ''} Record label: ${payload.remaster_record_label || ''} Catalogue number: ${payload.remaster_catalogue_number || ''} Are 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 && target.dataset.description) postData.set('release_desc', target.dataset.description); return queryAjaxAPI('torrentedit', { id: torrentId }, postData); return `torrentId: ${torrentId}, postData: ${postData.toString()}`; })).then(function(responses) { target.style.color = 'green'; 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; setTooltip(tr, 'Apply edition info from this release'); tr.onmouseenter = tr.onmouseleave = evt => { evt.currentTarget.style.color = evt.type == 'mouseenter' ? 'orange' : null }; } function openOnClick(tr) { tr.onclick = function(evt) { if (!evt.ctrlKey || !evt.currentTarget.dataset.url) return true; GM_openInTab(evt.currentTarget.dataset.url, false); return false; }; 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); }; } 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; if (edition != null) span.dataset.edition = edition; if (isUnknownRelease) span.dataset.isUnknownRelease = true; if (isUnconfirmedRelease) span.dataset.isUnconfirmedRelease = true; a.textContent = caption; a.className = 'toc-lookup'; a.href = '#'; a.onclick = callback; if (tooltip) setTooltip(a, tooltip); span.append(a); linkBox.append(' ', span); } const torrentId = getTorrentId(tr); if (!(torrentId > 0)) continue; let edition = /\b(?:edition_(\d+))\b/.exec(tr.className); if (edition != null) edition = parseInt(edition[1]); for (var isUnconfirmedRelease = tr; isUnconfirmedRelease != null; isUnconfirmedRelease = isUnconfirmedRelease.previousElementSibling) if (isUnconfirmedRelease.classList.contains('edition')) break; if (isUnconfirmedRelease != null) isUnconfirmedRelease = isUnconfirmedRelease.querySelector('td.edition_info > strong'); const isUnknownRelease = isUnconfirmedRelease != null && isUnconfirmedRelease.textContent.startsWith('− Unknown Release(s)'); isUnconfirmedRelease = isUnconfirmedRelease != null && isUnconfirmedRelease.textContent.startsWith('− Unconfirmed Release'); if ((tr = tr.nextElementSibling) == null || !tr.classList.contains('torrentdetails')) continue; const linkBox = tr.querySelector('div.linkbox'); if (linkBox == null) continue; addLookup('MusicBrainz', function(evt) { const target = evt.currentTarget; console.assert(target instanceof HTMLElement); const baseUrl = 'https://musicbrainz.org/cdtoc/'; if (!target.disabled) 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 (Boolean(target.dataset.haveResponse)) { if (!('results' in target.dataset)) return false; const results = JSON.parse(target.dataset.results); for (let result of results.releases.reverse()) GM_openInTab('https://musicbrainz.org/release/' + result.id, false); // if (results.mbDiscID) GM_openInTab(baseUrl + // (evt.shiftKey ? 'attach?toc=' + results.mbTOC.join(' ') : 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 new DiscID().addValues(mbTOC.slice(0, 2), 2).addValues(mbTOC.slice(2), 8, 100).toDigest(); } 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 = { inc: ['artist-credits', 'labels', 'release-groups'].join('+') }; 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.textContent = 'Looking up...'; target.style.color = null; lookupByToc(parseInt(target.parentNode.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'); } 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 = 'green'; 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 result of Array.from(results[0].releases).reverse()) // GM_openInTab('https://musicbrainz.org/release/' + result.id, true); } target.dataset.haveResponse = true; target.dataset.results = JSON.stringify(results[0]); if (!('edition' in target.parentNode.dataset) || !['redacted.ch'].includes(document.domain) || Boolean(target.parentNode.dataset.haveQuery)) return; results = results[0].releases.filter(release => release.media.reduce((totalDiscs, media) => totalDiscs + Number(!media.format || /\b(?:HD|HQ)?CD\b/.test(media.format)), 0) == results.length); if (results.length > 0) target.parentNode.dataset.haveQuery = true; else return; queryAjaxAPICached('torrent', { id: parseInt(target.parentNode.dataset.torrentId) }).then(function(response) { const isCompleteInfo = response.torrent.remasterYear > 0 && response.torrent.remasterRecordLabel && response.torrent.remasterCatalogueNumber; const isUnknownRelease = Boolean(target.parentNode.dataset.isUnknownRelease); if (!isCompleteInfo && exactMatch) { const filteredResults = response.torrent.remasterYear > 0 ? results.filter(release => !release.date || new Date(release.date).getUTCFullYear() == response.torrent.remasterYear) : results; const releaseYear = filteredResults.reduce((year, release) => year || new Date(release.date).getUTCFullYear(), undefined); if (filteredResults.length > 0 && filteredResults.length < (isUnknownRelease ? 2 : 4) && releaseYear > 0 && !filteredResults.some(release1 => filteredResults.some(release2 => new Date(release2.date).getUTCFullYear() != new Date(release1.date).getUTCFullYear()))) { 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 = [ ]; for (let release of filteredResults) { if ('label-info' in release) Array.prototype.push.apply(editionInfo, release['label-info'].map(labelInfo => ({ label: labelInfo.label && labelInfo.label.name, catNo: labelInfo['catalog-number'], }))); if (release.barcode) editionInfo.push({ catNo: release.barcode }); } if (editionInfo.length > 0) a.dataset.editionInfo = JSON.stringify(editionInfo); if (filteredResults.length < 2 && !response.torrent.description.includes(filteredResults[0].id)) a.dataset.description = ((response.torrent.description.trim() + '\n\n').trimLeft() + '[url]https://musicbrainz.org/release/' + filteredResults[0].id + '[/url]').trim(); setTooltip(a, 'Update edition info from matched release(s)\n\n' + filteredResults.map(function(release) { let title = new Date(release.date).getUTCFullYear(); 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 parent = document.getElementById('release_' + torrentId); if (parent == null) throw '#release_' + torrentId + ' not found'; const [bq, thead, table, tbody] = ['BLOCKQUOTE', 'DIV', 'TABLE', 'TBODY'] .map(Document.prototype.createElement.bind(document)); thead.style = 'margin-bottom: 5pt; font-weight: bold;'; thead.textContent = `Applicable MusicBrainz matches (${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; for (let release of results) { const [tr, artist, album, _release, editionInfo, barcode] = ['TR', 'TD', 'TD', 'TD', 'TD', 'TD'].map(Document.prototype.createElement.bind(document)); 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 disambiguation = document.createElement('SPAN'); disambiguation.style.opacity = 0.6; disambiguation.textContent = '(' + release.disambiguation + ')'; album.append(' ', disambiguation); } _release.innerHTML = [ release.country ? `` : null, release.date, ].filter(Boolean).join(' '); editionInfo.innerHTML = release['label-info'].map(labelInfo => [labelInfo.label && labelInfo.label.name, labelInfo['catalog-number']].filter(Boolean).join(' ')).filter(Boolean).join('
'); if (release.barcode) barcode.textContent = release.barcode; tr.dataset.url = 'https://musicbrainz.org/release/' + release.id; const releaseYear = new Date(release.date).getUTCFullYear(); if (!isCompleteInfo && releaseYear && (!isUnknownRelease || exactMatch)) { if (releaseYear > 0) tr.dataset.releaseYear = releaseYear; const _editionInfo = release['label-info'].map(labelInfo => ({ label: labelInfo.label && labelInfo.label.name, catNo: labelInfo['catalog-number'] })); if (release.barcode) _editionInfo.push({ catNo: release.barcode }); if (_editionInfo.length > 0) tr.dataset.editionInfo = JSON.stringify(_editionInfo); if (!response.torrent.description.includes(release.id)) tr.dataset.description = ((response.torrent.description.trim() + '\n\n').trimLeft() + '[url]https://musicbrainz.org/release/' + release.id + '[/url]').trim(); applyOnClick(tr); } else openOnClick(tr); tr.append(artist, album, _release, editionInfo, barcode); tbody.append(tr); } table.append(tbody); bq.append(thead, table); parent.append(bq); }, 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 (!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.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) { 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 (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)); }).catch(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 = (metadata = 'fast') => lookupByToc(torrentId, function(tocEntries, volumeNdx) { if (volumeNdx > 0) return Promise.resolve(null); const url = new URL('https://db.cue.tools/lookup2.php'); url.searchParams.set('version', 3); url.searchParams.set('ctdb', 1); url.searchParams.set('metadata', metadata); url.searchParams.set('fuzzy', 0); 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) results:', metadata, 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 > 0 ? logfiles.map(function(logfile, volumeNdx) { if (volumeNdx > 0) return null; else if (rxRR.test(logfile)) throw 'range rip'; 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)\s*:\s*([\da-fA-F]{8})$/gm, ]; const matches = logfile.match(rx[0]) || logfile.match(rx[1]); return matches != null && matches.map(match => parseInt(rx.reduce((m, rx) => m || (rx.lastIndex = 0, rx.exec(match)), null)[1], 16)); }) : Promise.reject('no valid logfiles found')).then(function(checksums) { if (checksums.length > 0 && checksums[0] != null) checksums = checksums[0]; else return Promise.reject('no checksums found in logfile'); if (checksums.length < 3) return Promise.reject('tracklist too short'); const total = entries.reduce((sum, entry) => sum + entry.confidence, 0); const computeEntryScore = entry => entry.trackcrcs.length == checksums.length ? entry.trackcrcs.slice(1, -1) .filter((crc32, ndx) => crc32 == checksums[ndx + 1]).length / (entry.trackcrcs.length - 2) : -Infinity; const [fullmatchCount, partialMatchCount, minimumMatchCount] = [entries.reduce(function(sum, entry, ndx) { const matchScore = computeEntryScore(entry); return matchScore >= 1 ? sum + entry.confidence : sum; }, 0), entries.reduce(function(sum, entry, ndx) { const matchScore = computeEntryScore(entry); return matchScore >= 0.5 ? sum + entry.confidence : sum; }, 0), entries.reduce(function(sum, entry, ndx) { const matchScore = computeEntryScore(entry); return matchScore > 0 ? sum + entry.confidence : sum; }, 0)]; return partialMatchCount > 0 ? { matched: fullmatchCount, partiallyMatched: partialMatchCount, anyMatched: minimumMatchCount, total: total, } : Promise.reject('mismatch'); }))(results[0].entries) })).length > 0 ? results : Promise.reject('No matches') }); const methods = ['fast', 'default', 'extensive']; (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(results => queryAjaxAPICached('torrent', { id: torrentId }).then(function(response) { const isCompleteInfo = response.torrent.remasterYear > 0 && response.torrent.remasterRecordLabel && response.torrent.remasterCatalogueNumber; const isUnknownRelease = Boolean(target.parentNode.dataset.isUnknownRelease); const [method, confidence] = [results.method, results.confidence]; const confidenceBox = document.createElement('SPAN'); confidence.then(function(confidence) { confidenceBox.innerHTML = ` `; confidenceBox.className = confidence.matched > 0 ? 'ctdb-verified' : 'ctdb-partially-verified'; setTooltip(confidenceBox, `Checksums ${confidence.matched > 0 ? '' : 'partially '}matched (confidence ${confidence.matched || confidence.partiallyMatched}/${confidence.total})`); }).catch(function(reason) { confidenceBox.innerHTML = ` `; confidenceBox.className = 'ctdb-not-verified'; setTooltip(confidenceBox, `Could not verify checksums (${reason})`); }).then(() => { target.parentNode.append(' ', confidenceBox) }); const stripSuffix = name => name && name.replace(/\s*\(\d+\)$/, ''); const getReleaseYear = metadata => (metadata = metadata.release.map(release => new Date(release.date).getUTCFullYear())) .every((year, ndx, arr) => year > 0 && year == arr[0]) ? metadata[0] : NaN; if (!isCompleteInfo) { 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) && (method == 'fast' || filteredResults.every(metadata => !(metadata.relevance < 100))) && filteredResults.every(m1 => m1.release.every(r1 => filteredResults.every(m2 => m2.release.every(r2 => new Date(r2.date).getUTCFullYear() == new Date(r1.date).getUTCFullYear()))))) { const a = document.createElement('A'); a.className = 'update-edition'; a.href = '#'; a.textContent = '(set)'; (isUnknownRelease ? confidence : confidence.catch(reason => ({ matches: 0 }))).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 = [ ]; for (let metadata of filteredResults) { Array.prototype.push.apply(editionInfo, metadata.labelInfo.map(labelInfo => ({ label: stripSuffix(labelInfo.name), catNo: labelInfo.catno }))); if (metadata.barcode) editionInfo.push({ catNo: metadata.barcode }); } if (editionInfo.length > 0) a.dataset.editionInfo = JSON.stringify(editionInfo); if (filteredResults.length < 2 && !response.torrent.description.includes(filteredResults[0].id)) a.dataset.description = ((response.torrent.description.trim() + '\n\n').trimLeft() + '[url]' + { musicbrainz: 'https://musicbrainz.org/release/' + results[0].id, discogs: 'https://www.discogs.com/release/' + results[0].id, }[filteredResults[0].source] + '[/url]').trim(); 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 => [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; if (!isUnknownRelease) target.parentNode.append(' ', a); else confidence.then(confidence => { target.parentNode.append(' ', a) }); } } const parent = document.getElementById('release_' + torrentId); if (parent == null) throw '#release_' + torrentId + ' not found'; const [bq, thead, table, tbody] = ['BLOCKQUOTE', 'DIV', 'TABLE', 'TBODY'] .map(Document.prototype.createElement.bind(document)); thead.style = 'margin-bottom: 5pt; font-weight: bold;'; thead.textContent = `Applicable CTDB matches (method: ${method})`; 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; for (let metadata of results) { 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.style = 'word-wrap: break-word; transition: color 200ms ease-in-out;'; [source, release, barcode, relevance].forEach(elem => { elem.style.whiteSpace = 'nowrap' }); source.innerHTML = ``; artist.textContent = metadata.artist; album.textContent = metadata.album; release.innerHTML = metadata.release.map(release => [ release.country ? `` : null, release.date, ].filter(Boolean).join(' ')).join('
'); editionInfo.innerHTML = metadata.labelInfo.map(labelInfo => [stripSuffix(labelInfo.name), labelInfo.catno].filter(Boolean).join(' ')).filter(Boolean).join('
'); 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); if (!isCompleteInfo && releaseYear > 0 && (method == 'fast' || !isUnknownRelease || !(metadata.relevance < 100))) { if (releaseYear > 0) tr.dataset.releaseYear = releaseYear; const _editionInfo = metadata.labelInfo.map(labelInfo => ({ label: stripSuffix(labelInfo.name), catNo: labelInfo.catno })); if (metadata.barcode) _editionInfo.push({ catNo: metadata.barcode }); if (_editionInfo.length > 0) tr.dataset.editionInfo = JSON.stringify(_editionInfo); if (!response.torrent.description.includes(metadata.id)) tr.dataset.description = ((response.torrent.description.trim() + '\n\n').trimLeft() + '[url]' + { musicbrainz: 'https://musicbrainz.org/release/' + metadata.id, discogs: 'https://www.discogs.com/release/' + metadata.id, }[metadata.source] + '[/url]').trim(); applyOnClick(tr); } else openOnClick(tr); tr.append(source, artist, album, release, editionInfo, barcode, relevance); tbody.append(tr); } table.append(tbody); bq.append(thead, table); parent.append(bq); })).catch(console.warn).then(() => { target.parentNode.dataset.haveQuery = true }); return false; }, 'Lookup edition in CUETools DB (TOCID)'); } }