// ==UserScript== // @name [GMT] Edition lookup by CD TOC // @namespace https://greasyfork.org/users/321857-anakunda // @version 1.15.13 // @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 // @downloadURL none // ==/UserScript== { 'use strict'; const requestsCache = new Map, mbRequestsCache = new Map; let mbLastRequest = null, noEditPerms = document.getElementById('nav_userclass'); noEditPerms = noEditPerms != null && ['User', 'Member', 'Power User'].includes(noEditPerms.textContent.trim()); const [mbOrigin, dcOrigin] = ['https://musicbrainz.org', 'https://www.discogs.com']; 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(Object.assign(params || { }, { 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, ''), mbOrigin); 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; })(); let dcApiResponses; 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); if (!dcApiResponses && 'dcApiResponseCache' in sessionStorage) try { dcApiResponses = JSON.parse(sessionStorage.getItem('dcApiResponseCache')); } catch(e) { sessionStorage.removeItem('dcApiResponseCache'); console.warn(e); } if (dcApiResponses && cacheKey in dcApiResponses) return Promise.resolve(dcApiResponses[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) { try { if (!dcApiResponses) dcApiResponses = { }; dcApiResponses[cacheKey] = response.response; sessionStorage.setItem('dcApiResponseCache', JSON.stringify(dcApiResponses)); } catch(e) { console.warn(e) } 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 msf = 75, preGap = 2 * msf, msfTime = /(?:(\d+):)?(\d+):(\d+)[\.\:](\d+)/.source; 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 rxRangeRip = /^(?: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 sessionHeader = '(?:' + [ '(?:EAC|XLD) extraction logfile from ', '(?:EAC|XLD) Auslese-Logdatei vom ', 'File di log (?:EAC|XLD) per l\'estrazione del ', 'Archivo Log de extracciones desde ', '(?:EAC|XLD) extraheringsloggfil från ', '(?:EAC|XLD) uitlezen log bestand van ', '(?:EAC|XLD) 抓取日志文件从', 'Отчёт (?:EAC|XLD) об извлечении, выполненном ', 'Отчет на (?:EAC|XLD) за извличане, извършено на ', 'Protokol extrakce (?:EAC|XLD) z ', '(?:EAC|XLD) log súbor extrakcie z ', 'Sprawozdanie ze zgrywania programem (?:EAC|XLD) z ', '(?:EAC|XLD)-ov fajl dnevnika ekstrakcije iz ', 'Log created by: whipper .+\r?\n+Log creation date: ', 'morituri extraction logfile from ', ].join('|') + ')'; const rxTrackExtractor = /^(?:(?:Track|Трек|Òðåê|音轨|Traccia|Spår|Pista|Трак|Utwór|Stopa)\s+\d+[^\S\r\n]*$(?:\r?\n^(?:[^\S\r\n]+.*)?$)*| +\d+:$\r?\n^ {4,}Filename:.+$(?:\r?\n^(?: {4,}.*)?$)*)/gm; function getTocEntries(session) { if (!session) return null; 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+') + '\\b', // whipper '^ +(\\d+): *' + [['Start', msfTime], ['Length', msfTime], ['Start sector', '\\d+'], ['End sector', '\\d+']] .map(([label, capture]) => `\\r?\\n {4,}${label}: *(${capture})\\b *`).join(''), ]; let tocEntries = tocParsers.reduce((m, rx) => m || session.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 getTrackDetails(session) { function extractValues(patterns, ...callbacks) { if (!Array.isArray(patterns) || patterns.length <= 0) return null; const rxs = patterns.map(pattern => new RegExp('^[^\\S\\r\\n]+' + pattern + '\\s*$', 'm')); return trackRecords.map(function(trackRecord, trackNdx) { trackRecord = rxs.map(rx => rx.exec(trackRecord)); const index = trackRecord.findIndex(matches => matches != null); return index < 0 || typeof callbacks[index] != 'function' ? null : callbacks[index](trackRecord[index]); }); } if (rxRangeRip.test(session)) return { }; // Nothing to extract from RR const trackRecords = session.match(rxTrackExtractor); if (trackRecords == null) return { }; const h2i = m => parseInt(m[1], 16); return Object.assign({ crc32: extractValues([ '(?:(?:Copy|复制|Kopie|Copia|Kopiera|Copiar|Копиран) CRC|CRC (?:копии|êîïèè|kopii|kopije|kopie|kópie)|コピーCRC)\\s+([\\da-fA-F]{8})', // 1272 '(?:CRC32 hash|Copy CRC)\\s*:\\s*([\\da-fA-F]{8})', ], h2i, h2i), peak: extractValues([ '(?:Peak level|Пиковый уровень|Ïèêîâûé óðîâåíü|峰值电平|ピークレベル|Spitzenpegel|Pauze lengte|Livello di picco|Peak-nivå|Nivel Pico|Пиково ниво|Poziom wysterowania|Vršni nivo|[Šš]pičková úroveň)\\s+(\\d+(?:\\.\\d+)?)\\s*\\%', // 1217 '(?:Peak(?: level)?)\\s*:\\s*(\\d+(?:\\.\\d+)?)', ], m => [parseFloat(m[1]) * 10, 3], m => [parseFloat(m[1]) * 1000, 6]), preGap: extractValues([ '(?:Pre-gap length|Длина предзазора|Äëèíà ïðåäçàçîðà|前间隙长度|Pausenlänge|Durata Pre-Gap|För-gap längd|Longitud Pre-gap|Дължина на предпразнина|Długość przerwy|Pre-gap dužina|[Dd]élka mezery|Dĺžka medzery pred stopou)\\s+' + msfTime, // 1270 '(?:Pre-gap length)\\s*:\\s*' + msfTime, ], msfToSector, msfToSector) }, Object.assign.apply(undefined, [1, 2].map(v => ({ ['arv' + v]: extractValues([ '.+?\\[([\\da-fA-F]{8})\\].+\\(AR v' + v + '\\)', '(?:AccurateRip v' + v + ' signature)\\s*:\\s*([\\da-fA-F]{8})', ], h2i, h2i) })))); } function getUniqueSessions(logFiles, detectVolumes = GM_getValue('detect_volumes', false)) { logFiles = Array.prototype.map.call(logFiles, function(logFile) { while (logFile.startsWith('\uFEFF')) logFile = logFile.slice(1); return logFile; }); const rxRipperSignatures = '(?:(?:' + [ 'Exact Audio Copy V', 'X Lossless Decoder version ', 'CUERipper v', 'EZ CD Audio Converter ', 'Log created by: whipper ', 'morituri version ', ].join('|') + ')\\d+)'; if (!detectVolumes) { const rxStackedLog = new RegExp('^[\\S\\s]*(?:\\r?\\n)+(?=' + rxRipperSignatures + ')'); return (logFiles = logFiles.map(logFile => rxStackedLog.test(logFile) ? logFile.replace(rxStackedLog, '') : logFile) .filter(RegExp.prototype.test.bind(new RegExp('^(?:' + rxRipperSignatures + '|' + sessionHeader + ')')))).length > 0 ? logFiles : null; } if ((logFiles = logFiles.map(function(logFile) { let rxSessionsIndexer = new RegExp('^' + rxRipperSignatures, 'gm'), indexes = [ ], match; while ((match = rxSessionsIndexer.exec(logFile)) != null) indexes.push(match.index); if (indexes.length <= 0) { rxSessionsIndexer = new RegExp('^' + sessionHeader, 'gm'); while ((match = rxSessionsIndexer.exec(logFile)) != null) indexes.push(match.index); } return (indexes = indexes.map((index, ndx, arr) => logFile.slice(index, arr[ndx + 1])).filter(function(logFile) { const rr = rxRangeRip.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; })).length > 0 ? indexes : null; }).filter(Boolean)).length <= 0) return null; const sessions = new Map, rxTitleExtractor = new RegExp('^' + sessionHeader + '(.+)$(?:\\r?\\n)+^(.+\\/.+)$', 'm'); for (const logFile of logFiles) for (const session of logFile) { let [uniqueKey, title] = [getTocEntries(session), rxTitleExtractor.exec(session)]; if (uniqueKey != null) uniqueKey = [uniqueKey[0].startSector].concat(uniqueKey.map(tocEntry => tocEntry.endSector + 1)).map(offset => offset.toString(32).padStart(4, '0')).join(''); else continue; if (title != null) title = title[2]; else if ((title = /^ +Release: *$\r?\n^ +Artist: *(.+)$\r?\n^ +Title: *(.+)$/m.exec(session)) != null) title = title[1] + '/' + title[2]; if (title != null) uniqueKey += '/' + title.replace(/\s+/g, '').toLowerCase(); sessions.set(uniqueKey, session); } //console.info('Unique keys:', Array.from(sessions.keys())); return sessions.size > 0 ? Array.from(sessions.values()) : null; } function getSessions(torrentId) { if (!(torrentId > 0)) throw 'Invalid argument'; if (requestsCache.has(torrentId)) return requestsCache.get(torrentId); // 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 => 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 => pre.textContent)); requestsCache.set(torrentId, request = request.then(getUniqueSessions).then(sessions => sessions || Promise.reject('No valid logfiles attached'))); 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 getSessions(torrentId).then(sessions => Promise.all(sessions.map(function(session, volumeNdx) { const isRangeRip = rxRangeRip.test(session), tocEntries = getTocEntries(session); 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, sessions.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 [rxNoLabel, rxNoCatno] = [ /^(?:Not On Label|No label|\[no label\]|None|\[none\]|iMD|Independ[ae]nt|Self[- ]?Released)\b/i, /^(?:None|\[none\])$/i, ]; const bareId = str => str ? str.trim().toLowerCase() .replace(/(?:\s+\b(?:Record(?:ing)?s)\b|,?\s+(?:Ltd|Inc|Co)\.?)+$/ig, '') .replace(rxNoLabel, '').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 (noEditPerms || !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 => rxNoLabel.test(label) ? 'self-released' : label).filter(Boolean).join(' / '); payload.remaster_catalogue_number = editionInfo.map(label => label.catNo).filter(uniqueValues) .map(catNo => !rxNoCatno.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 editableHosts = GM_getValue('editable_hosts', ['redacted.ch']); const incompleteEdition = /^(?:\d+ -|(?:Unconfirmed Release(?: \/.+)?|Unknown Release\(s\)) \/) CD$/; const minifyHTML = html => html.replace(/\s*(?:\r?\n)+\s*/g, ''); const svgFail = (color = 'red', height = '0.9em') => minifyHTML(` `); const svgCheckmark = (color = '#0c0', height = '0.9em') => minifyHTML(` `); const svgQuestionMark = (color = '#fc0', height = '0.9em') => minifyHTML(` `); const svgAniSpinner = (color = 'orange', phases = 12, height = '0.9em') => minifyHTML(` ${Array.from(Array(phases)).map((_, ndx) => ` `).join('')}`); const staticIconColor = 'cadetblue'; 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, a] = createElements('SPAN', 'A'); [span.className, span.dataset.torrentId] = ['brackets', torrentId]; span.style = 'display: inline-flex; flex-flow: row; align-items: baseline; column-gap: 5px; justify-content: space-around; color: initial;'; if (edition != null) span.dataset.edition = edition; [a.textContent, a.className, a.href] = [caption, 'toc-lookup', '#']; a.onclick = evt => { callback(evt); return false }; if (tooltip) setTooltip(a, tooltip); span.append(a); container.append(span); } function addClickableIcon(html, clickHandler, dropHandler, className, style, tooltip, tooltipster = false) { if (!html || typeof clickHandler != 'function') throw 'Invalid argument'; const span = document.createElement('SPAN'); span.innerHTML = html; if (className) span.className = className; span.style = 'cursor: pointer; transition: transform 100ms;' + (style ? ' ' + style : ''); span.onclick = clickHandler; if (typeof dropHandler == 'function') { span.ondragover = evt => Boolean(evt.currentTarget.disabled) || !evt.dataTransfer.types.includes('text/plain'); span.ondrop = function(evt) { evt.currentTarget.style.transform = null; if (evt.currentTarget.disabled || !evt.dataTransfer || !(evt.dataTransfer.items.length > 0)) return true; dropHandler(evt); return false; } span.ondragenter = function(evt) { if (evt.currentTarget.disabled) return true; for (let tgt = evt.relatedTarget; tgt != null; tgt = tgt.parentNode) if (tgt == evt.currentTarget) return false; evt.currentTarget.style.transform = 'scale(3)'; return false; }; span[`ondrag${'ondragexit' in span ? 'exit' : 'leave'}`] = function(evt) { if (evt.currentTarget.disabled) return true; for (let tgt = evt.relatedTarget; tgt != null; tgt = tgt.parentNode) if (tgt == evt.currentTarget) return false; evt.currentTarget.style.transform = null; return false; }; } if (tooltip) if (tooltipster) setTooltip(span, tooltip); else span.title = tooltip; return 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; } function svgSetTitle(elem, title) { if (!(elem instanceof Element)) return; for (let title of elem.getElementsByTagName('title')) title.remove(); if (title) elem.insertAdjacentHTML('afterbegin', `${title}`); } function mbFindEditionInfoInAnnotation(elem, mbId) { if (!mbId || !(elem instanceof HTMLElement)) throw 'Invalid argument'; return mbApiRequest('annotation', { query: `entity:${mbId} AND type:release` }).then(function(response) { if (response.count <= 0 || (response = response.annotations.filter(function(annotation) { console.assert(annotation.type == 'release' && annotation.entity == mbId, 'Unexpected annotation for MBID %s:', mbId, annotation); return /\b(?:Label|Catalog|Cat(?:alog(?:ue)?)?\s*(?:[#№]|Num(?:ber|\.?)|(?:No|Nr)\.?))\s*:/i.test(annotation.text); })).length <= 0) return Promise.reject('No edition info in annotation'); const a = document.createElement('A'); [a.href, a.target] = [mbOrigin + '/release/' + mbId, '_blank']; [a.textContent, a.style] = ['by annotation', 'font-style: italic; ' + noLinkDecoration]; a.title = response.map(annotation => annotation.text).join('\n'); elem.append(a); }); } function showAllEvents(release, releaseEvents) { if (!(release instanceof HTMLElement) || !Array.isArray(releaseEvents)) throw 'Invalid argument'; const [span, div] = createElements('SPAN', 'DIV'); [span.className, span.style] = ['show-all', 'font-style: italic; cursor: pointer;']; [span.textContent, span.onclick, span.title] = [`+ ${releaseEvents.length - 3} others…`, function(evt) { evt.currentTarget.remove(); div.hidden = false; }, 'Show all']; [div.innerHTML, div.hidden, div.className] = [releaseEvents.slice(3).join('
'), true, 'more-events']; release.append(document.createElement('BR'), span, div); } 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() : ''; 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 container = document.createElement('SPAN'); container.style = 'display: inline-flex; flex-flow: row nowrap; column-gap: 2pt; justify-content: space-around;'; linkBox.append(' ', container); const releaseEventToHtml = (country, date) => [ country && ``, date && `${date}`, ].filter(Boolean).join(' '); const releaseToHtml = (release, country = 'country', date = 'date') => release ? releaseEventToHtml(release[country], release[date]) : ''; const stripNameSuffix = name => name && name.replace(/\s+\(\d+\)$/, ''); const noLinkDecoration = 'background: none !important; padding: 0 !important;'; const linkHTML = (url, caption, cls) => `${caption}`; const svgBulletHTML = color => ``; const createElements = (...tagNames) => tagNames.map(Document.prototype.createElement.bind(document)); addLookup('MusicBrainz', function(evt) { const target = evt.currentTarget; console.assert(target instanceof HTMLElement); const torrentId = parseInt(target.parentNode.dataset.torrentId); console.assert(torrentId > 0); if (evt.altKey) { // alternate lookup by CDDB ID if (target.disabled) return; else target.disabled = true; lookupByToc(torrentId, (tocEntries, discNdx, totalDiscs) => 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 ('ids' in target.dataset) for (let id of JSON.parse(target.dataset.ids).reverse()) GM_openInTab('https://musicbrainz.org/release/' + id, false); // GM_openInTab(`${mbOrigin}/cdtoc/${evt.shiftKey ? 'attach?toc=' + JSON.parse(target.dataset.toc).join(' ') // : target.dataset.discId}`, false); } else { function getEntityFromCache(cacheName, entity, id, param) { if (!cacheName || !entity || !id) throw 'Invalid argument'; const result = eval(` if (!${cacheName} && '${cacheName}' in sessionStorage) try { ${cacheName} = JSON.parse(sessionStorage.getItem('${cacheName}')); } catch(e) { sessionStorage.removeItem('${cacheName}'); console.warn(e); } if (!${cacheName}) ${cacheName} = { }; if (!(entity in ${cacheName})) ${cacheName}[entity] = { }; if (param) { if (!(param in ${cacheName}[entity])) ${cacheName}[entity][param] = { }; ${cacheName}[entity][param][id]; } else ${cacheName}[entity][id]; `); if (result) return Promise.resolve(result); } 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); const params = { inc: 'artist-credits labels release-groups url-rels' }; if (!mbDiscID || allowTOCLookup) params.toc = mbTOC.join('+'); if (anyMedia) params['media-format'] = 'all'; return mbApiRequest('discid/' + (mbDiscID || '-'), params).then(function(result) { if (!Array.isArray(result.releases) || result.releases.length <= 0) return Promise.reject('MusicBrainz: no matches'); console.log('MusicBrainz lookup by discId/TOC successfull:', mbDiscID, '/', params, 'releases:', result.releases); console.assert(!result.id || result.id == mbDiscID, 'mbLookupByDiscID ids mismatch', result.id, mbDiscID); return { mbDiscID: mbDiscID, mbTOC: mbTOC, attached: Boolean(result.id), releases: result.releases }; }); } function frequencyAnalysis(literals, string) { if (!literals || typeof literals != 'object') throw 'Invalid argument'; if (typeof string == 'string') for (let index = 0; index.length < string.length; ++index) { const charCode = string.charCodeAt(index); if (charCode < 0x20 || charCode == 0x7F) continue; if (charCode in literals) ++literals[charCode]; else literals[charCode] = 1; } } function mbIdExtractor(expr, entity) { if (!expr || !(expr = expr.trim()) || !entity) return null; let mbId = rxMBID.exec(expr); if (mbId != null) return mbId[1]; else try { mbId = new URL(expr) } catch(e) { return null } return mbId.hostname.endsWith('musicbrainz.org') && (mbId = new RegExp(`^\\/${entity}\\/${mbID}\\b`, 'i').exec(mbId.pathname)) != null ? mbId[1] : null; } function discogsIdExtractor(expr, entity) { if (!expr || !(expr = expr.trim()) || !entity) return null; let discogsId = parseInt(expr); if (discogsId > 0) return discogsId; else try { discogsId = new URL(expr) } catch(e) { return null } return discogsId.hostname.endsWith('discogs.com') && (discogsId = new RegExp(`\\/${entity}s?\\/(\\d+)\\b`, 'i').exec(discogsId.pathname)) != null && (discogsId = parseInt(discogsId[1])) > 0 ? discogsId : null; } function getMediaFingerprint(session) { const tocEntries = getTocEntries(session), digests = getTrackDetails(session); let fingerprint = ` Track# │ Start │ End │ CRC32 │ ARv1 │ ARv2 │ Peak ──────────────────────────────────────────────────────────────────────`; for (let trackIndex = 0; trackIndex < tocEntries.length; ++trackIndex) { const getTOCDetail = (key, width = 6) => tocEntries[trackIndex][key].toString().padStart(width); const getTrackDetail = (key, callback, width = 8) => Array.isArray(digests[key]) && digests[key].length == tocEntries.length && digests[key][trackIndex] != null ? callback(digests[key][trackIndex]) : width > 0 ? ' '.repeat(width) : ''; const getTrackDigest = (key, width = 8) => getTrackDetail(key, value => value.toString(16).toUpperCase().padStart(width, '0'), 8); fingerprint += '\n' + [ getTOCDetail('trackNumber'), getTOCDetail('startSector'), getTOCDetail('endSector'), getTrackDigest('crc32'), getTrackDigest('arv1'), getTrackDigest('arv2'), getTrackDetail('peak', value => (value[0] / 1000).toFixed(value[1])), //getTrackDetail('preGap', value => value.toString().padStart(6)), ].map(column => ' ' + column + ' ').join('│').trimRight(); } return fingerprint; } function seedFromTorrent(formData, torrent) { if (!formData || typeof formData != 'object') throw 'Invalid argument'; formData.set('name', torrent.group.name); if (torrent.torrent.remasterTitle) formData.set('comment', torrent.torrent.remasterTitle/*.toLowerCase()*/); if (torrent.group.releaseType != 21) { formData.set('type', { 5: 'EP', 9: 'Single' }[torrent.group.releaseType] || 'Album'); switch (torrent.group.releaseType) { case 3: formData.append('type', 'Soundtrack'); break; case 6: case 7: formData.append('type', 'Compilation'); break; case 11: case 14: case 18: formData.append('type', 'Live'); break; case 13: formData.append('type', 'Remix'); break; case 15: formData.append('type', 'Interview'); break; case 16: formData.append('type', 'Mixtape/Street'); break; case 17: formData.append('type', 'Demo'); break; case 19: formData.append('type', 'DJ-mix'); break; } } if (torrent.group.releaseType == 7) formData.set('artist_credit.names.0.mbid', '89ad4ac3-39f7-470e-963a-56509c546377'); else if (torrent.group.musicInfo) { let artistIndex = -1; for (let role of ['dj', 'artists']) if (artistIndex < 0) torrent.group.musicInfo[role].forEach(function(artist, index, artists) { formData.set(`artist_credit.names.${++artistIndex}.name`, artist.name); formData.set(`artist_credit.names.${artistIndex}.artist.name`, artist.name); if (index < artists.length - 1) formData.set(`artist_credit.names.${artistIndex}.join_phrase`, index < artists.length - 2 ? ', ' : ' & '); }); } formData.set('status', torrent.group.releaseType == 14 ? 'bootleg' : 'official'); if (torrent.torrent.remasterYear) formData.set('events.0.date.year', torrent.torrent.remasterYear); if (torrent.torrent.remasterRecordLabel) if (rxNoLabel.test(torrent.torrent.remasterRecordLabel)) formData.set('labels.0.mbid', '157afde4-4bf5-4039-8ad2-5a15acc85176'); else formData.set('labels.0.name', torrent.torrent.remasterRecordLabel); if (torrent.torrent.remasterCatalogueNumber) { formData.set('labels.0.catalog_number', rxNoCatno.test(torrent.torrent.remasterCatalogueNumber) ? '[none]' : torrent.torrent.remasterCatalogueNumber); let barcode = torrent.torrent.remasterCatalogueNumber.split(' / ').map(catNo => catNo.replace(/\W+/g, '')); if (barcode = barcode.find(RegExp.prototype.test.bind(/^\d{9,13}$/))) formData.set('barcode', barcode); } if (GM_getValue('insert_torrent_reference', false)) formData.set('edit_note', ((formData.get('edit_note') || '') + ` Seeded from torrent ${document.location.origin}/torrents.php?torrentid=${torrent.torrent.id} edition info`).trimLeft()); } function seedFromTOCs(formData, mbTOCs) { if (!formData || typeof formData != 'object') throw 'Invalid argument'; for (let discIndex = 0; discIndex < mbTOCs.length; ++discIndex) { formData.set(`mediums.${discIndex}.format`, 'CD'); formData.set(`mediums.${discIndex}.toc`, mbTOCs[discIndex].join(' ')); } let editNote = (formData.get('edit_note') || '') + '\nSeeded from EAC/XLD ripping ' + (mbTOCs.length > 1 ? 'logs' : 'log').trimLeft(); return getSessions(torrentId).catch(console.error).then(function(sessions) { if (GM_getValue('mb_seed_with_fingerprints', false) && Array.isArray(sessions) && sessions.length > 0) editNote += '\n\nMedia fingerprint' + (sessions.length > 1 ? 's' : '') + ' :\n' + sessions.map(getMediaFingerprint).join('\n') + '\n'; formData.set('edit_note', editNote); return formData; }); } function seedFromDiscogs(formData, discogsId, cdLengths, idsLookupLimit = GM_getValue('mbid_search_size', 30)) { if (!formData || typeof formData != 'object' || !discogsId) throw 'Invalid argument'; if (discogsId < 0) [idsLookupLimit, discogsId] = [0, -discogsId]; return discogsId > 0 ? dcApiRequest('releases/' + discogsId).then(function(release) { function seedArtists(root, prefix) { if (root && Array.isArray(root)) root.forEach(function(artist, index, arr) { const creditPrefix = `${prefix || ''}artist_credit.names.${index}`; const name = stripNameSuffix(artist.name); formData.set(`${creditPrefix}.artist.name`, name); if (artist.anv) formData.set(`${creditPrefix}.name`, artist.anv); else formData.delete(`${creditPrefix}.name`); if (index < arr.length - 1) formData.set(`${creditPrefix}.join_phrase`, fmtJoinPhrase(artist.join)); else formData.delete(`${creditPrefix}.join_phrase`); if (!(artist.id in lookupIndexes.artist)) lookupIndexes.artist[artist.id] = { name: name, prefixes: [creditPrefix] }; else lookupIndexes.artist[artist.id].prefixes.push(creditPrefix); }); } function addUrlRef(url, linkType) { formData.set(`urls.${++urlRelIndex}.url`, url); if (linkType != undefined) formData.set(`urls.${urlRelIndex}.link_type`, linkType); } function mbLookupById(entity, param, mbid) { if (!entity || !param || !mbid) throw 'Invalid argument'; [entity, param] = [entity.toLowerCase(), param.toLowerCase()]; const loadPage = (offset = 0) => mbApiRequest(entity, { [param]: mbid, offset: offset, limit: 5000 }).then(function(response) { let results = response[entity + 's']; if (Array.isArray(results)) offset = response[entity + '-offset'] + results.length; else return [ ]; results = results.filter(result => !result.video); return offset < response[entity + '-count'] ? loadPage(offset) .then(Array.prototype.concat.bind(results)) : results; }); return loadPage(); } function sameTitles(...titles) { if (titles.length <= 0) return false; const titleNorm = title => title && [ /\s+(?:EP|E\.\s?P\.|-\s+(?:EP|E\.\s?P\.|Single|Live))$/i, /\s+\((?:EP|E\.\s?P\.|Single|Live)\)$/i, /\s+\[(?:EP|E\.\s?P\.|Single|Live)\]$/i, ].reduce((title, rx) => title.replace(rx, ''), title.trim()).toLowerCase().replace(/[^\w\u0080-\uFFFF]/g, ''); return titles.every(title => title && titleNorm(title) == titleNorm(titles[0])); } const layoutMatch = media => Array.isArray(cdLengths) && cdLengths.length > 0 ? (media = media.filter(isCD)).length == cdLengths.length && media.every((medium, mediumIndex) => medium.tracks.length == cdLengths[mediumIndex]) : undefined; const literals = { }, lookupIndexes = { artist: { }, label: { } }; formData.set('name', release.title); frequencyAnalysis(literals, release.title); let released, media; if ((released = /^\d{4}$/.exec(release.released)) != null) released = parseInt(released[0]); else if (isNaN(released = new Date(release.released))) released = release.year; (release.country ? { 'US': ['US'], 'UK': ['GB'], 'Germany': ['DE'], 'France': ['FR'], 'Japan': ['JP'], 'Italy': ['IT'], 'Europe': ['XE'], 'Canada': ['CA'], 'Netherlands': ['NL'], 'Unknown': ['??'], 'Spain': ['ES'], 'Australia': ['AU'], 'Russia': ['RU'], 'Sweden': ['SE'], 'Brazil': ['BR'], 'Belgium': ['BE'], 'Greece': ['GR'], 'Poland': ['PL'], 'Mexico': ['MX'], 'Finland': ['FI'], 'Jamaica': ['JM'], 'Switzerland': ['CH'], 'USSR': ['RU'], 'Denmark': ['DK'], 'Argentina': ['AR'], 'Portugal': ['PT'], 'Norway': ['NO'], 'Austria': ['AT'], 'UK & Europe': ['GB', 'XE'], 'New Zealand': ['NZ'], 'South Africa': ['ZA'], 'Yugoslavia': ['YU'], 'Hungary': ['HU'], 'Colombia': ['CO'], 'USA & Canada': ['US', 'CA'], 'Ukraine': ['UA'], 'Turkey': ['TR'], 'India': ['IN'], 'Czech Republic': ['CZ'], 'Czechoslovakia': ['CS'], 'Venezuela': ['VE'], 'Ireland': ['IE'], 'Romania': ['RO'], 'Indonesia': ['ID'], 'Taiwan': ['TW'], 'Chile': ['CL'], 'Peru': ['PE'], 'South Korea': ['KR'], 'Worldwide': ['XW'], 'Israel': ['IL'], 'Bulgaria': ['BG'], 'Thailand': ['TH'], 'Malaysia': ['MY'], 'Scandinavia': ['SE', 'NO', 'FI'], 'German Democratic Republic (GDR)': ['DE'], 'China': ['CN'], 'Croatia': ['HR'], 'Hong Kong': ['HK'], 'Philippines': ['PH'], 'Serbia': ['RS'], 'Ecuador': ['EC'], 'Lithuania': ['LT'], 'UK, Europe & US': ['GB', 'XE', 'US'], 'East Timor': ['TL'], 'Germany, Austria, & Switzerland': ['DE', 'AT', 'CH'], 'USA & Europe': ['US', 'XE'], 'Singapore': ['SG'], 'Slovenia': ['SI'], 'Slovakia': ['SK'], 'Uruguay': ['UY'], 'Australasia': ['AU'], 'Australia & New Zealand': ['AU', 'NZ'], 'Iceland': ['IS'], 'Bolivia': ['BO'], 'UK & Ireland': ['GB', 'IE'], 'Nigeria': ['NG'], 'Estonia': ['EE'], 'USA, Canada & Europe': ['US', 'CA', 'XE'], 'Benelux': ['BE', 'NL', 'LU'], 'Panama': ['PA'], 'UK & US': ['GB', 'US'], 'Pakistan': ['PK'], 'Lebanon': ['LB'], 'Egypt': ['EG'], 'Cuba': ['CU'], 'Costa Rica': ['CR'], 'Latvia': ['LV'], 'Puerto Rico': ['PR'], 'Kenya': ['KE'], 'Iran': ['IR'], 'Belarus': ['BY'], 'Morocco': ['MA'], 'Guatemala': ['GT'], 'Saudi Arabia': ['SA'], 'Trinidad & Tobago': ['TT'], 'Barbados': ['BB'], 'USA, Canada & UK': ['US', 'CA', 'GB'], 'Luxembourg': ['LU'], 'Czech Republic & Slovakia': ['CZ', 'SK'], 'Bosnia & Herzegovina': ['BA'], 'Macedonia': ['MK'], 'Madagascar': ['MG'], 'Ghana': ['GH'], 'Zimbabwe': ['ZW'], 'El Salvador': ['SV'], 'North America (inc Mexico)': ['US', 'CA', 'MX'], 'Algeria': ['DZ'], 'Singapore, Malaysia & Hong Kong': ['SG', 'MY', 'HK'], 'Dominican Republic': ['DO'], 'France & Benelux': ['FR', 'BE', 'NL', 'LU'], 'Ivory Coast': ['CI'], 'Tunisia': ['TN'], 'Reunion': ['RE'], 'Angola': ['AO'], 'Serbia and Montenegro': ['RS', 'ME'], 'Georgia': ['GE'], 'United Arab Emirates': ['AE'], 'Congo, Democratic Republic of the': ['CD'], 'Germany & Switzerland': ['DE', 'CH'], 'Malta': ['MT'], 'Mozambique': ['MZ'], 'Cyprus': ['CY'], 'Mauritius': ['MU'], 'Azerbaijan': ['AZ'], 'Zambia': ['ZM'], 'Kazakhstan': ['KZ'], 'Nicaragua': ['NI'], 'Syria': ['SY'], 'Senegal': ['SN'], 'Paraguay': ['PY'], 'Guadeloupe': ['GP'], 'UK & France': ['GB', 'FR'], 'Vietnam': ['VN'], 'UK, Europe & Japan': ['GB', 'XE', 'JP'], 'Bahamas, The': ['BS'], 'Ethiopia': ['ET'], 'Suriname': ['SR'], 'Haiti': ['HT'], 'Singapore & Malaysia': ['SG', 'MY'], 'Moldova, Republic of': ['MD'], 'Faroe Islands': ['FO'], 'Cameroon': ['CM'], 'South Vietnam': ['VN'], 'Uzbekistan': ['UZ'], 'South America': ['ZA'], 'Albania': ['AL'], 'Honduras': ['HN'], 'Martinique': ['MQ'], 'Benin': ['BJ'], 'Kuwait': ['KW'], 'Sri Lanka': ['LK'], 'Andorra': ['AD'], 'Liechtenstein': ['LI'], 'Curaçao': ['CW'], 'Mali': ['ML'], 'Guinea': ['GN'], 'Congo, Republic of the': ['CG'], 'Sudan': ['SD'], 'Mongolia': ['MN'], 'Nepal': ['NP'], 'French Polynesia': ['PF'], 'Greenland': ['GL'], 'Uganda': ['UG'], 'Bangladesh': ['BD'], 'Armenia': ['AM'], 'North Korea': ['KP'], 'Bermuda': ['BM'], 'Iraq': ['IQ'], 'Seychelles': ['SC'], 'Cambodia': ['KH'], 'Guyana': ['GY'], 'Tanzania': ['TZ'], 'Bahrain': ['BH'], 'Jordan': ['JO'], 'Libya': ['LY'], 'Montenegro': ['ME'], 'Gabon': ['GA'], 'Togo': ['TG'], 'Afghanistan': ['AF'], 'Yemen': ['YE'], 'Cayman Islands': ['KY'], 'Monaco': ['MC'], 'Papua New Guinea': ['PG'], 'Belize': ['BZ'], 'Fiji': ['FJ'], 'UK & Germany': ['UK', 'DE'], 'New Caledonia': ['NC'], 'Protectorate of Bohemia and Moravia': ['CS'], 'UK, Europe & Israel': ['GB', 'XE', 'IL'], 'French Guiana': ['GF'], 'Laos': ['LA'], 'Aruba': ['AW'], 'Dominica': ['DM'], 'San Marino': ['SM'], 'Kyrgyzstan': ['KG'], 'Burkina Faso': ['BF'], 'Turkmenistan': ['TM'], 'Namibia': ['NA'], 'Sierra Leone': ['SL'], 'Marshall Islands': ['MH'], 'Botswana': ['BW'], 'Eritrea': ['ER'], 'Saint Kitts and Nevis': ['KN'], 'Guernsey': ['GG'], 'Jersey': ['JE'], 'Guam': ['GU'], 'Central African Republic': ['CF'], 'Grenada': ['GD'], 'Qatar': ['QA'], 'Somalia': ['SO'], 'Liberia': ['LR'], 'Sint Maarten': ['SX'], 'Saint Lucia': ['LC'], 'Lesotho': ['LS'], 'Maldives': ['MV'], 'Bhutan': ['BT'], 'Niger': ['NE'], 'Saint Vincent and the Grenadines': ['VC'], 'Malawi': ['MW'], 'Guinea-Bissau': ['GW'], 'Palau': ['PW'], 'Comoros': ['KM'], 'Gibraltar': ['GI'], 'Cook Islands': ['CK'], 'Mauritania': ['MR'], 'Tajikistan': ['TJ'], 'Rwanda': ['RW'], 'Samoa': ['WS'], 'Oman': ['OM'], 'Anguilla': ['AI'], 'Sao Tome and Principe': ['ST'], 'Djibouti': ['DJ'], 'Mayotte': ['YT'], 'Montserrat': ['MS'], 'Tonga': ['TO'], 'Vanuatu': ['VU'], 'Norfolk Island': ['NF'], 'Solomon Islands': ['SB'], 'Turks and Caicos Islands': ['TC'], 'Northern Mariana Islands': ['MP'], 'Equatorial Guinea': ['GQ'], 'American Samoa': ['AS'], 'Chad': ['TD'], 'Falkland Islands': ['FK'], 'Antarctica': ['AQ'], 'Nauru': ['NR'], 'Niue': ['NU'], 'Saint Pierre and Miquelon': ['PM'], 'Tokelau': ['TK'], 'Tuvalu': ['TV'], 'Wallis and Futuna': ['WF'], 'Korea': ['KR'], 'Antigua & Barbuda': ['AG'], 'Austria-Hungary': ['AT', 'HU'], 'British Virgin Islands': ['VG'], 'Brunei': ['BN'], 'Burma': ['MM'], 'Cape Verde': ['CV'], 'Virgin Islands': ['VI'], 'Vatican City': ['VA'], 'Swaziland': ['SZ'], 'Southern Sudan': ['SS'], 'Palestine': ['PS'], 'Singapore, Malaysia, Hong Kong & Thailand': ['SG', 'MY', 'HK', 'TH'], 'Pitcairn Islands': ['PN'], 'Micronesia, Federated States of': ['FM'], 'Man, Isle of': ['IM'], 'Macau': ['MO'], 'Korea (pre-1945)': ['KR'], 'Hong Kong & Thailand': ['HK', 'TH'], 'Gambia, The': ['GM'], // 'Africa': ['??'], 'South West Africa': ['??'], // 'Central America': ['??'], 'North & South America': ['??'], // 'Asia': ['??'], 'South East Asia': ['??'], Middle East': ['??'], 'Gulf Cooperation Council': ['??'], // 'South Pacific': ['??'], // 'Dutch East Indies': ['??'], 'Gaza Strip': ['??'], 'Dahomey': ['??'], 'Indochina': ['??'], // 'Abkhazia': ['??'], 'Belgian Congo': ['??'], 'Bohemia': ['??'], 'Kosovo': ['??'], // 'Netherlands Antilles': ['??'], 'Ottoman Empire': ['??'], 'Rhodesia': ['??'], // 'Russia & CIS': ['??'], 'Southern Rhodesia': ['??'], 'Upper Volta': ['??'], 'West Bank': ['??'], // 'Zaire': ['??'], 'Zanzibar': ['??'], }[release.country] || [release.country] : [undefined]).forEach(function(countryCode, countryIndex) { if (countryCode) formData.set(`events.${countryIndex}.country`, countryCode); if (released instanceof Date) { formData.set(`events.${countryIndex}.date.year`, released.getUTCFullYear()); formData.set(`events.${countryIndex}.date.month`, released.getUTCMonth() + 1); formData.set(`events.${countryIndex}.date.day`, released.getUTCDate()); } else if (released > 0) formData.set(`events.${countryIndex}.date.year`, released); }); let defaultFormat = 'CD', descriptors = new Set; if ('formats' in release) { for (let format of release.formats) { if (format.text) descriptors.add(format.text); if (Array.isArray(format.descriptions)) for (let description of format.descriptions) descriptors.add(description); } if (!release.formats.some(format => format.name == 'CD') && release.formats.some(format => format.name == 'CDr')) defaultFormat = 'CD-R'; else if (descriptors.has('HDCD')) defaultFormat = 'HDCD'; else if (descriptors.has('CD+G')) defaultFormat = 'CD+G'; } if (release.labels) release.labels.forEach(function(label, index) { if (label.name) { const prefix = 'labels.' + index, name = stripNameSuffix(label.name); if (rxNoLabel.test(name)) formData.set(prefix + '.mbid', '157afde4-4bf5-4039-8ad2-5a15acc85176'); else { formData.set(prefix + '.name', name); if (label.id in lookupIndexes.label) lookupIndexes.label[label.id].prefixes.push(prefix); else lookupIndexes.label[label.id] = { name: stripNameSuffix(label.name) .replace(/(?:\s+\b(?:Record(?:ing)?s)\b|,?\s+(?:Ltd|Inc|Co)\.?)+$/i, ''), prefixes: [prefix], }; } } if (label.catno) formData.set(`labels.${index}.catalog_number`, rxNoCatno.test(label.catno) ? '[none]' : label.catno); }); if (release.identifiers) (barcode => { if (barcode) formData.set('barcode', barcode.value.replace(/\D+/g, '')) }) (release.identifiers.find(identifier => identifier.type == 'Barcode')); seedArtists(release.artists); //seedArtists(release.extraartists); if (!Array.isArray(cdLengths) || cdLengths.length <= 0) cdLengths = false; if ([ /^()?()?(\S+)$/, /^([A-Z]{2,})(?:[\-\ ](\d+))?[\ \-\.](\S+)$/, /^([A-Z]{2,})?(\d+)?[\ \-\.](\S+)$/, ].some(function(trackParser) { media = [ ]; let lastMediumId, heading; (function addTracks(root, titles) { if (Array.isArray(root)) for (let track of root) switch (track.type_) { case 'track': { const parsedTrack = trackParser.exec(track.position.trim()); let [mediumFormat, mediumId, trackPosition] = parsedTrack != null ? parsedTrack.slice(1) : [undefined, undefined, track.position.trim()]; if ((mediumId = (mediumFormat || '') + (mediumId || '')) !== lastMediumId) { for (let subst of [[/^(?:B(?:R?D|R))$/, 'Blu-ray'], [/^(?:LP)$/, 'Vinyl']]) if (subst[0].test(mediumFormat)) mediumFormat = subst[1]; media.push({ format: mediumFormat || defaultFormat, name: undefined, tracks: [ ] }); lastMediumId = mediumId; } media[media.length - 1].tracks.push({ number: trackPosition, heading: heading, titles: titles, name: track.title, length: track.duration, artists: track.artists, extraartists: track.extraartists, }); break; } case 'index': addTracks(track.sub_tracks, (titles || [ ]).concat(track.title)); break; case 'heading': heading = track.title != '-' && track.title || undefined; break; } })(release.tracklist); for (let medium of media) if (medium.tracks.every((track, ndx, tracks) => track.heading == tracks[0].heading)) { medium.name = medium.tracks[0].heading; medium.tracks.forEach(track => { track.heading = undefined }); } return layoutMatch(media); }) || !cdLengths || confirm('Tracks seem not mapped correctly to media (' + media.map(medium => medium.tracks.length).join('+') + ' ≠ ' + cdLengths.join('+') + '), attach tracks with this layout anyway?')) media.forEach(function(medium, mediumIndex) { formData.set(`mediums.${mediumIndex}.format`, medium.format); if (medium.name) formData.set(`mediums.${mediumIndex}.name`, medium.name); if (medium.tracks) medium.tracks.forEach(function(track, trackIndex) { if (track.number) formData.set(`mediums.${mediumIndex}.track.${trackIndex}.number`, track.number); if (track.name) { const prefix = str => str ? str + ': ' : ''; const fullTitle = prefix(track.heading) + prefix((track.titles || [ ]).join(' / ')) + track.name; formData.set(`mediums.${mediumIndex}.track.${trackIndex}.name`, fullTitle); frequencyAnalysis(literals, fullTitle); } if (track.length) formData.set(`mediums.${mediumIndex}.track.${trackIndex}.length`, track.length); if (track.artists) seedArtists(track.artists, `mediums.${mediumIndex}.track.${trackIndex}.`); //if (track.extraartists) seedArtists(track.extraartists, `mediums.${mediumIndex}.track.${trackIndex}.`); }); }); const charCodes = Object.keys(literals).map(key => parseInt(key)); if (charCodes.every(charCode => charCode < 0x100)) formData.set('script', 'Latn'); const packagings = { 'book': 'Book', 'box': 'Box', 'cardboard': 'Cardboard/Paper Sleeve', 'card sleeve': 'Cardboard/Paper Sleeve', 'cardboard sleeve': 'Cardboard/Paper Sleeve', 'paper sleeve': 'Cardboard/Paper Sleeve', 'cassette': 'Cassette Case', 'cassette case': 'Cassette Case', 'clamshell': 'Clamshell Case', 'clamshell case': 'Clamshell Case', 'digibook': 'Digibook', 'digisleeve': 'Digipak', 'digipak': 'Digipak', 'digipack': 'Digipak', 'discbox slider': 'Discbox Slider', 'fatbox': 'Fatbox', 'gatefold': 'Gatefold Cover', 'gatefold cover': 'Gatefold Cover', 'jewel': 'Jewel case', 'jewel case': 'Jewel case', 'keep': 'Keep Case', 'keep case': 'Keep Case', 'longbox': 'Longbox', 'metal tin': 'Metal Tin', 'plastic sleeve': 'Plastic sleeve', 'slidepack': 'Slidepack', 'slim jewel': 'Slim Jewel Case', 'slim jewel case': 'Slim Jewel Case', 'snap': 'Snap Case', 'snap case': 'Snap Case', 'snappack': 'SnapPack', 'super jewel': 'Super Jewel Box', 'super jewel box': 'Super Jewel Box', }; const setPackaging = (...packagings) => { packagings.forEach(packaging => { formData.set('packaging', packaging) }) }; if (/\b(?:jewel)\b/i.test(release.notes)) setPackaging('Jewel case'); if (/\b(?:slim\s+jewel)\b/i.test(release.notes)) setPackaging('Slim Jewel Case'); if (/\b(?:(?:card(?:board)?|paper)\s?sleeve\b)/i.test(release.notes)) setPackaging('Cardboard/Paper Sleeve'); if (/\b(?:plastic\s?sleeve\b)/i.test(release.notes)) setPackaging('Plastic sleeve'); if (/\b(?:digisleeve)\b/i.test(release.notes)) setPackaging('Digipak'); if (/\b(?:digipak)\b/i.test(release.notes)) setPackaging('Digipak'); if (/\b(?:gatefold)\b/i.test(release.notes)) setPackaging('Gatefold Cover'); setPackaging(...Array.from(descriptors, d => packagings[d.toLowerCase()]).filter(Boolean).reverse()); if (descriptors.has('Promo') && formData.get('status') != 'bootleg') formData.set('status', 'promotion'); if ((descriptors = dcFmtFilters.reduce((arr, filter) => arr.filter(filter), Array.from(descriptors)) .filter(desc => !(desc.toLowerCase() in packagings)) .filter(desc => !['Promo'].includes(desc))).length > 0) formData.set('comment', descriptors.join(', ')/*.toLowerCase()*/); // disambiguation const annotation = [ release.notes && release.notes.trim(), release.identifiers && release.identifiers .filter(identifier => !['Barcode', 'ASIN'].includes(identifier.type)) .map(identifier => identifier.type + ': ' + identifier.value).join('\n'), release.companies && (function() { const companies = { }; for (let company of release.companies) if (company.entity_type_name) { if (!(company.entity_type_name in companies)) companies[company.entity_type_name] = [ ]; companies[company.entity_type_name].push(company); } return Object.keys(companies).map(type => type + ' – ' + companies[type].map(company => company.catno ? company.name + ' – ' + company.catno : company.name).join(', ')); })().join('\n'), ].filter(Boolean); if (annotation.length > 0) formData.set('annotation', annotation.join('\n\n')); let urlRelIndex = -1; addUrlRef(dcOrigin + '/release/' + release.id, 76); if (release.identifiers) for (let identifier of release.identifiers) switch (identifier.type) { case 'ASIN': addUrlRef('https://www.amazon.com/dp/' + identifier.value, 77); break; } formData.set('edit_note', ((formData.get('edit_note') || '') + `\nSeeded from Discogs release id ${release.id}`).trimLeft()); return idsLookupLimit > 0 ? Promise.all(Object.keys(lookupIndexes).map(entity => Promise.all(Object.keys(lookupIndexes[entity]).map(discogsId => mbApiRequest(entity, { query: '"' + stripNameSuffix(lookupIndexes[entity][discogsId].name) + '"', limit: idsLookupLimit, }).then(results => results[entity + 's'].map(result => result.id)).then(function(mbids) { const noBindings = Promise.reject('No Discogs counterpart'); if (mbids.length <= 0) return noBindings; function getDiscogsReleases() { const getDiscogsArtistReleasesPage = (page = 1) => dcApiRequest(`${entity}s/${discogsId}/releases`, { page: page, per_page: 500 }); return getDiscogsArtistReleasesPage().then(function(response) { const fetchers = [ ]; for (let page = response.pagination.page; page < response.pagination.pages; ++page) fetchers.push(getDiscogsArtistReleasesPage(page + 1)); return Promise.all(fetchers).then(responses => Array.prototype.concat.apply(response.releases, responses.map(response => response.releases))); }); } function notifyEntityBinding(method, length = 6) { let div = document.body.querySelector('div.entity-binding-notify'); if (div == null) { div = document.createElement('DIV'); div.className = 'entity-binding-notify'; div.style = ` position: fixed; margin: 0 auto; padding: 5pt; bottom: 0; left: 0; right: 0; text-align: center; font: normal 9pt "Noto Sans", sans-serif; color: white; background-color: #000a; box-shadow: 0 0 7pt 2pt #000a; cursor: default; z-index: 999; opacity: 0;`; document.body.append(div); } div.innerHTML = `Entity (${entity}) MBID found by ${method}`; div.animate([ { offset: 0.00, opacity: 0, color: 'white', transform: 'scaleX(0.5)' }, { offset: 0.03, opacity: 1, color: 'orange', transform: 'scaleX(1)' }, { offset: 0.80, opacity: 1, color: 'orange' }, { offset: 1.00, opacity: 0 }, ], length * 1000); div.dataset.timer = setTimeout(elem => { elem.remove() }, length * 1000, div); } const dcCounterparts = (mbReleases, dcReleases, noEponymous = false) => [mbReleases, dcReleases].every(Array.isArray) ? mbReleases.filter(mbRelease => mbRelease != null && mbRelease.date && mbRelease.title && (!noEponymous || !sameTitles(mbRelease.title, stripNameSuffix(lookupIndexes[entity][discogsId].name))) && dcReleases.some(dcRelease => dcRelease.year == getReleaseYear(mbRelease.date) && sameTitles(dcRelease.title, mbRelease.title))).length : 0; const findByUrlRel = (index = 0) => index < mbids.length ? mbApiRequest(entity + '/' + mbids[index], { inc: 'url-rels releases' }).then(function(mbEntry) { if (mbEntry.relations.some(relation => relation.type == 'discogs' && discogsIdExtractor(relation.url.resource, entity) == parseInt(discogsId))) { notifyEntityBinding('having Discogs relative by URL'); return mbEntry.id; } if (mbEntry.releases && mbEntry.releases.length > 0) return getDiscogsReleases().then(function(dcReleases) { const score = dcCounterparts(mbEntry.releases, dcReleases, true); if (!(score * 8 > Math.min(mbEntry.releases.length, dcReleases.length))) return noBindings; console.log('Entity binding found by having %d common releases (basic list):\n%s\n%s', score, [dcOrigin, entity, discogsId].join('/') + '#' + entity, [mbOrigin, entity, mbEntry.id, 'releases'].join('/')); notifyEntityBinding('having ' + score + ' common releases (basic list)'); return mbEntry.id; }); return noBindings; }).catch(reason => findByUrlRel(index + 1)) : noBindings; return findByUrlRel().catch(reason => Promise.all([getDiscogsReleases(), Promise.all(mbids.map(mbid => Promise.all(({ artist: ['artist', 'track_artist'] }[entity] || [entity]).map(param => mbLookupById('release', param, mbid).catch(reason => null))).then(results => Array.prototype.concat.apply([ ], results.filter(Boolean)).filter((rls1, ndx, arr) => arr.findIndex(rls2 => rls2.id == rls1.id) == ndx))))]).then(function([dcReleases, mbReleases]) { const matchScores = mbReleases.map(releases => dcCounterparts(releases, dcReleases, false)); const hiScore = Math.max(...matchScores); if (!(hiScore > 0)) return noBindings; const mbid = mbids[matchScores.indexOf(hiScore)]; console.log('Entity binding found by having %d common releases (full list):\n%s\n%s', hiScore, [dcOrigin, entity, discogsId].join('/') + '#' + entity, [mbOrigin, entity, mbid, 'releases'].join('/')); if (matchScores.filter(score => score > 0).length > 1) { console.log('Matches by more entities:', matchScores.map((score, index) => score > 0 && [mbOrigin, entity, mbids[index], 'releases'].join('/') + ' (' + score + ')').filter(Boolean)); if (matchScores.reduce((sum, score) => sum + score, 0) >= hiScore * 2) return noBindings; } notifyEntityBinding('having ' + hiScore + ' common releases (full list)'); return mbid; })); }).catch(reason => null))))).then(function(lookupResults) { Object.keys(lookupIndexes).forEach(function(entity, ndx1) { Object.keys(lookupIndexes[entity]).forEach(function(discogsId, ndx2) { if (lookupResults[ndx1][ndx2] != null) for (let prefix of lookupIndexes[entity][discogsId].prefixes) formData.set(prefix + '.mbid', lookupResults[ndx1][ndx2]); }); }); if (!formData.has('release-group') && Array.isArray(release.artists) && release.artists.length > 0 && release.artists[0].id != 194) { let mbid = Object.keys(lookupIndexes.artist).findIndex(key => parseInt(key) == release.artists[0].id); mbid = release.artists[0].id >= 0 && lookupResults[Object.keys(lookupIndexes).indexOf('artist')][mbid]; if (mbid) return mbLookupById('release-group', 'artist', mbid).then(function(releaseGroups) { if ((releaseGroups = releaseGroups.filter(rG => sameTitles(rG.title, release.title))).length == 1) formData.set('release_group', releaseGroups[0].id); }, console.error).then(() => formData); } return formData; }) : formData; }) : Promise.reject('Invalid Discogs ID'); } function seedNewRelease(formData) { if (!formData || typeof formData != 'object') throw 'Invalid argument'; // if (!formData.has('language')) formData.set('language', 'eng'); if (formData.has('language')) formData.set('script', { eng: 'Latn', deu: 'Latn', spa: 'Latn', fra: 'Latn', heb: 'Hebr', ara: 'Arab', gre: 'Grek', ell: 'Grek', rus: 'Cyrl', jpn: 'Jpan', zho: 'Hant', kor: 'Kore', tha: 'Thai', }[(formData.get('language') || '').toLowerCase()] || 'Latn'); formData.set('edit_note', ((formData.get('edit_note') || '') + '\nSeeded by ' + scriptSignature).trimLeft()); formData.set('make_votable', 1); const form = document.createElement('FORM'); [form.method, form.action, form.target, form.hidden] = ['POST', mbOrigin + '/release/add', '_blank', true]; form.append(...Array.from(formData, entry => Object.assign(document.createElement(entry[1].includes('\n') ? 'TEXTAREA' : 'INPUT'), { name: entry[0], value: entry[1] }))); document.body.appendChild(form).submit(); document.body.removeChild(form); } function editNoteFromSession(session) { let editNote = GM_getValue('insert_torrent_reference', false) ? `Release identification from torrent ${document.location.origin}/torrents.php?torrentid=${torrentId} edition info\n` : ''; editNote += 'TOC derived from EAC/XLD ripping log'; if (session) editNote += '\n\n' + (mbSubmitLog ? session : 'Media fingerprint:\n' + getMediaFingerprint(session)) + '\n'; return editNote + '\nSubmitted by ' + scriptSignature; } const attachToMB = (mbId, attended = false, skipPoll = false) => getMbTOCs().then(function(mbTOCs) { function attachByHand() { for (let discNumber = mbTOCs.length; discNumber > 0; --discNumber) { url.searchParams.setTOC(discNumber - 1); GM_openInTab(url.href, discNumber > 1); } } const url = new URL('/cdtoc/attach', mbOrigin); url.searchParams.setTOC = function(index = 0) { this.set('toc', mbTOCs[index].join(' ')) }; return (mbId ? rxMBID.test(mbId) ? mbApiRequest('release/' + mbId, { inc: 'media discids' }).then(function(release) { if (release.media && sameMedia(release).length < mbTOCs.length) return Promise.reject('not enough attachable media in this release'); url.searchParams.set('filter-release.query', mbId); return mbId; }) : Promise.reject('invalid format') : Promise.reject(false)).catch(function(reason) { if (reason) alert(`Not linking to release id ${mbId} for the reason ` + reason); }).then(mbId => mbId && !attended && mbAttachMode > 1 ? Promise.all(mbTOCs.map(function(mbTOC, tocNdx) { url.searchParams.setTOC(tocNdx); return globalXHR(url).then(({document}) => Array.from(document.body.querySelectorAll('table > tbody > tr input[type="radio"][name="medium"][value]'), input => ({ id: input.value, title: input.nextSibling && input.nextSibling.textContent.trim().replace(/(?:\r?\n|[\t ])+/g, ' '), }))); })).then(function(mediums) { mediums = mediums.every(medium => medium.length == 1) ? mediums.map(medium => medium[0]) : mediums[0]; if (mediums.length != mbTOCs.length) return Promise.reject('Not logged in or unable to reliably bind volumes'); if (!confirm(`${mbTOCs.length} TOCs are going to be attached to release id ${mbId} ${mediums.length > 1 ? '\nMedia titles:\n' + mediums.map(medium => '\t' + medium.title).join('\n') : ''} Submit mode: ${!skipPoll && mbAttachMode < 3 ? 'apply after poll close (one week or sooner)' : 'auto-edit (without poll)'} Edit note: ${mbSubmitLog ? 'entire .LOG file per volume' : 'media fingerprint only'} Before you confirm make sure - - uploaded CD and MB release are identical edition - attached log(s) have no score deduction for uncalibrated read offset`)) return false; const postData = new FormData; if (!skipPoll && mbAttachMode < 3) postData.set('confirm.make_votable', 1); return getSessions(torrentId).then(sessions => Promise.all(mbTOCs.map(function(mbTOC, index) { url.searchParams.setTOC(index); url.searchParams.set('medium', mediums[index].id); postData.set('confirm.edit_note', editNoteFromSession(sessions[index])); return globalXHR(url, { responseType: null }, postData); }))).then(function(responses) { GM_openInTab(`${mbOrigin}/release/${mbId}/discids`, false); return true; }); }).catch(reason => { alert(reason + '\n\nAttach by hand'); attachByHand() }) : attachByHand()); }, alert); function attachToMBIcon(callback, style, tooltip, tooltipster) { // // // return addClickableIcon(minifyHTML(` `), function(evt) { if (evt.currentTarget.disabled) return; else evt.stopPropagation(); // let mbid = !style && evt.ctrlKey ? prompt('Enter MusicBrainz release ID or URL:\n\n') : undefined; // if (mbid === null) return; // if (mbid != undefined && !(mbid = mbIdExtractor(mbid, 'release'))) return alert('Invalid input'); callback(evt.altKey, evt.ctrlKey); }, !style ? function(evt) { let mbId = evt.dataTransfer.getData('text/plain'); if (mbId && (mbId = mbId.split(/(?:\r?\n)+/)).length > 0 && (mbId = mbIdExtractor(mbId[0], 'release'))) attachToMB(mbId, evt.altKey, evt.ctrlKey); return false; } : undefined, 'attach-toc', style, tooltip, tooltipster); } function seedToMB(target, torrent, discogsId, releaseGroupId) { if (!(target instanceof HTMLElement) || !torrent) throw 'Invalid argument'; getMbTOCs().then(function(mbTOCs) { const formData = new URLSearchParams; if (rxMBID.test(releaseGroupId)) formData.set('release_group', releaseGroupId); seedFromTorrent(formData, torrent); return seedFromTOCs(formData, mbTOCs).then(formData => discogsId > 0 || discogsId < 0 ? seedFromDiscogs(formData, discogsId, mbTOCs.map(mbTOC => mbTOC[1])) : formData); }).then(seedNewRelease).catch(alert).then(target.epilogue); } function seedToMBIcon(callback, style, tooltip, tooltipster) { const staticIcon = minifyHTML(` `); const span = addClickableIcon(staticIcon, function(evt) { if (evt.currentTarget.disabled) return; else evt.stopPropagation(); let discogsId = evt.ctrlKey ? prompt(`Enter Discogs release ID or URL: (note the data preparation process may take some time due to MB API rate limits, especially for compilations) `) : undefined; if (discogsId === null) return; if (discogsId != undefined && !((discogsId = discogsIdExtractor(discogsId, 'release')) > 0)) return alert('Invalid input'); evt.currentTarget.prologue(discogsId > 0 && !evt.shiftKey); callback(evt.currentTarget, evt.shiftKey ? -discogsId : discogsId); }, function(evt) { let data = evt.dataTransfer.getData('text/plain'), id, target = evt.currentTarget; if (data && (data = data.split(/(?:\r?\n)+/)).length > 0) { if ((id = discogsIdExtractor(data[0], 'release')) > 0) { target.prologue(!evt.shiftKey); callback(target, evt.shiftKey ? -id : id); } else if (id = mbIdExtractor(data[0], 'release-group')) callback(target, id); else if (id = mbIdExtractor(data[0], 'release')) mbApiRequest('release/' + id, { inc: 'release-groups' }) .then(release => { callback(target, release['release-group'].id) }); } return false; }, 'seed-mb-release', style, tooltip, tooltipster); span.prologue = function(waitingStatus = true) { if (this.disabled) return false; else this.disabled = true; if (waitingStatus) { this.classList.add('in-progress'); this.innerHTML = svgAniSpinner(); } return true; }.bind(span); span.epilogue = function() { if (this.classList.contains('in-progress')) { this.innerHTML = staticIcon; this.classList.remove('in-progress'); } this.disabled = false; }.bind(span); return span; } if (target.disabled) return; else target.disabled = true; [target.textContent, target.style.color] = ['Looking up...', null]; const getMbTOCs = () => lookupByToc(torrentId, tocEntries => Promise.resolve(tocEntriesToMbTOC(tocEntries))); const mbID = /([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})/i.source; const rxMBID = new RegExp(`^${mbID}$`, 'i'); const isCD = medium => /\b(?:(?:H[DQ])?CD|CDr|DualDisc)\b/.test(medium.format); const sameMedia = release => release.media.every(medium => !medium.format) ? release.media : release.media.filter(isCD); const dcFmtFilters = [ fmt => fmt && !['CD', 'Album', 'Single', 'EP', 'LP', 'Compilation', 'Stereo'].includes(fmt), fmt => !fmt || !['CDV', 'CD-ROM', 'SVCD', 'VCD'].includes(fmt), description => description && !['Mini-Album', 'Digipak', 'Digipack', 'Sampler'/*, 'Maxi-Single'*/].includes(description), ]; const scriptSignature = 'Edition lookup by CD TOC browser script (https://greasyfork.org/scripts/459083)'; const fmtJoinPhrase = (joinPhrase = ' & ') => [ [/^\s*(?:Feat(?:uring)?|Ft)\.?\s*$/i, ' feat. '], [/^\s*([\,\;])\s*$/, '$1 '], [/^\s*([\&\+\/\x\×]|vs\.|w\/|\/w|\w+)\s*$/i, (...m) => ` ${m[1].toLowerCase()} `], [/^\s*(?:,\s*(and|&|with))\s*$/i, (...m) => `, ${m[1].toLowerCase()} `], ].reduce((phrase, subst) => phrase.replace(...subst), joinPhrase); const mbAttachMode = Number(GM_getValue('mb_attach_toc', 2)); const mbSubmitLog = GM_getValue('mb_submit_log', false); const mbSeedNew = Number(GM_getValue('mb_seed_release', true)); lookupByToc(torrentId, (tocEntries, discNdx, totalDiscs) => mbLookupByDiscID(tocEntriesToMbTOC(tocEntries), !evt.ctrlKey)).then(function(results) { if (mbSeedNew) target.after(seedToMBIcon(function(target, id) { queryAjaxAPICached('torrent', { id: torrentId }) .then(torrent => { seedToMB(target, torrent, id, id) }, alert); }, undefined, `Seed new MusicBrainz release from this CD TOC Use Ctrl or drop Discogs release link to import Discogs metadata (+ Shift skips MBID lookup - faster, use when adding to exising release group) Drop exising MusicBrainz release group link to seed to this group MusicBrainz account required`, true)); if (mbAttachMode > 0) target.after(attachToMBIcon(function(attended, skipPoll) { attachToMB(undefined, attended, skipPoll); }, undefined, 'Attach this CD TOC by hand to release not shown in lookup results\nMusicBrainz account required', true)); let score = results.every(medium => medium == null) ? 8 : results[0] == null ? results.every(medium => medium == null || !medium.attached) ? 7 : 6 : 5; if (score < 6 || !evt.ctrlKey) target.dataset.haveResponse = true; if (score > 7) return Promise.reject('No matches'); else if (score > 5) { target.textContent = 'Unlikely matches'; target.style.color = score > 6 ? '#f40' : '#f80'; if (Boolean(target.dataset.haveResponse)) setTooltip(target, `Matched media found only for some volumes (${score > 6 ? 'fuzzy' : 'exact'})`); return; } const isSameRemaster = release => !release.media || sameMedia(release).length == results.length; let releases = results[0].releases.filter(isSameRemaster); if (releases.length > 0) score = results.every(result => result != null) ? results.every(result => result.attached) ? 0 : results.some(result => result.attached) ? 1 : 2 : results.some(result => result != null && result.attached) ? 3 : 4; if (releases.length <= 0) releases = results[0].releases; target.dataset.ids = JSON.stringify(releases.map(release => release.id)); [target.dataset.discId, target.dataset.toc] = [results[0].mbDiscID, JSON.stringify(results[0].mbTOC)]; (function(type, color) { type = `${releases.length} ${type} match`; target.textContent = releases.length != 1 ? type + 'es' : type; target.style.color = color; })(...[ ['exact', '#0a0'], ['hybrid', '#3a0'], ['fuzzy', '#6a0'], ['partial', '#9a0'], ['partial', '#ca0'], ['irrelevant', '#f80'], ][score]); if (GM_getValue('auto_open_tab', true) && score < 2) GM_openInTab(mbOrigin + '/cdtoc/' + (evt.shiftKey ? 'attach?toc=' + results[0].mbTOC.join(' ') : results[0].mbDiscID), true); if (score < 5) return queryAjaxAPICached('torrent', { id: torrentId }).then(function(torrent) { function appendDisambiguation(elem, disambiguation) { if (!(elem instanceof HTMLElement) || !disambiguation) return; const span = document.createElement('SPAN'); span.className = 'disambiguation'; span.style.opacity = 0.6; span.textContent = '(' + disambiguation + ')'; elem.append(' ', span); } const isCompleteInfo = torrent.torrent.remasterYear > 0 && Boolean(torrent.torrent.remasterRecordLabel) && Boolean(torrent.torrent.remasterCatalogueNumber); const is = what => !torrent.torrent.remasterYear && { unknown: torrent.torrent.remastered, unconfirmed: !torrent.torrent.remastered, }[what]; 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) : [ ]; // add inpage search results const [thead, table, tbody] = createElements('DIV', 'TABLE', 'TBODY'); thead.style = 'margin-bottom: 5pt;'; thead.innerHTML = `Applicable MusicBrainz matches (${[ 'exact', `${results.filter(result => result != null && result.attached).length} exact out of ${results.length} matches`, 'fuzzy', `${results.filter(result => result != null && result.attached).length} exact / ${results.filter(result => result != null).length} matches out of ${results.length}`, `${results.filter(result => result != null).length} matches out of ${results.length}`, ][score]})`; table.style = 'margin: 0; max-height: 10rem; overflow-y: auto; empty-cells: hide;'; table.className = 'mb-lookup-results mb-lookup-' + torrent.torrent.id; tbody.dataset.torrentId = torrent.torrent.id; tbody.dataset.edition = target.parentNode.dataset.edition; releases.forEach(function(release, index) { const [tr, artist, title, _release, editionInfo, barcode, groupSize, releasesWithId] = createElements('TR', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD'); tr.className = 'musicbrainz-release'; tr.style = 'word-wrap: break-word; transition: color 200ms ease-in-out;'; if (release.quality == 'low') tr.style.opacity = 0.75; tr.dataset.url = 'https://musicbrainz.org/release/' + release.id; [_release, barcode].forEach(elem => { elem.style.whiteSpace = 'nowrap' }); [groupSize, releasesWithId].forEach(elem => { elem.style.textAlign = 'right' }); if ('artist-credit' in release) release['artist-credit'].forEach(function(artistCredit, index, artists) { if ('artist' in artistCredit && artistCredit.artist.id && ![ '89ad4ac3-39f7-470e-963a-56509c546377', ].includes(artistCredit.artist.id)) { const a = document.createElement('A'); if (artistCredit.artist) a.href = 'https://musicbrainz.org/artist/' + artistCredit.artist.id; [a.target, a.style, a.textContent, a.className] = ['_blank', noLinkDecoration, artistCredit.name, 'musicbrainz-artist']; if (artistCredit.artist) a.title = artistCredit.artist.disambiguation || artistCredit.artist.id; artist.append(a); } else artist.append(artistCredit.name); if (index < artists.length - 1) artist.append(artistCredit.joinphrase || ' & '); }); title.innerHTML = linkHTML(tr.dataset.url, release.title, 'musicbrainz-release'); switch (release.quality) { case 'low': title.insertAdjacentHTML('afterbegin', svgBulletHTML('#ff6723')); break; case 'high': title.insertAdjacentHTML('afterbegin', svgBulletHTML('#00d26a')); break; } appendDisambiguation(title, release.disambiguation); // attach CD TOC if (mbAttachMode > 0 && (score > 0 || results.some(medium => !medium.releases.some(_release => _release.id == release.id)))) title.prepend(attachToMBIcon(function(attended, skipPoll) { attachToMB(release.id, attended, skipPoll); }, 'float: right; margin: 0 0 0 4pt;', `Attach CD TOC to release (verify CD rip and MB release are identical edition) Submission mode: ${mbAttachMode > 1 ? 'unattended (Alt+click enforces attended mode, Ctrl+click disables poll)' : 'attended'} MusicBrainz account required`)); // Seed new edition if (mbSeedNew) title.prepend(seedToMBIcon(function(target, discogsId) { seedToMB(target, torrent, discogsId, release['release-group'].id); }, 'float: right; margin: 0 0 0 4pt;', `Seed new MusicBrainz edition from this CD TOC in same release group Use Ctrl or drop Discogs release link to import Discogs metadata (+ Shift skips MBIDs lookup – faster) MusicBrainz account required`)); if ('release-events' in release) { let releaseEvents = [ ]; for (let releaseEvent of release['release-events']) { const events = releaseEvent.area && Array.isArray(releaseEvent.area['iso-3166-1-codes']) ? releaseEvent.area['iso-3166-1-codes'].map(countryCode => releaseEventToHtml(countryCode, releaseEvent.date)).filter(Boolean) : [ ]; if (events.length > 0) Array.prototype.push.apply(releaseEvents, events); else releaseEvents.push(releaseEventToHtml(undefined, releaseEvent.date)); } _release.innerHTML = (releaseEvents = releaseEvents.filter(Boolean)).slice(0, 3).join('
'); if (releaseEvents.length > 3) showAllEvents(_release, releaseEvents); } if (_release.childElementCount <= 0) _release.innerHTML = releaseToHtml(release); if ('label-info' in release) editionInfo.innerHTML = release['label-info'].map(labelInfo => [ labelInfo.label && labelInfo.label.name && `${labelInfo.label.name}`, labelInfo['catalog-number'] && `${labelInfo['catalog-number']}`, ].filter(Boolean).join(' ')).filter(Boolean).join('
'); if (editionInfo.childElementCount <= 0) mbFindEditionInfoInAnnotation(editionInfo, release.id); if (release.barcode) barcode.textContent = release.barcode; if (release['release-group']) { tr.dataset.groupUrl = 'https://musicbrainz.org/release-group/' + release['release-group'].id; mbApiRequest('release-group/' + release['release-group'].id, { inc: 'releases media discids', }).then(releaseGroup => releaseGroup.releases.filter(isSameRemaster)).then(function(releases) { const a = document.createElement('A'); a.href = 'https://musicbrainz.org/release-group/' + release['release-group'].id; [a.target, a.style, a.textContent] = ['_blank', noLinkDecoration, releases.length]; if (releases.length == 1) a.style.color = '#0a0'; groupSize.append(a); groupSize.title = 'Same media count in release group'; const counts = ['some', 'every'].map(fn => releases.filter(release => release.media && (release = sameMedia(release)).length > 0 && release[fn](medium => medium.discs && medium.discs.length > 0)).length); releasesWithId.textContent = counts[0] > counts[1] ? counts[0] + '/' + counts[1] : counts[1]; releasesWithId.title = 'Same media count with known TOC in release group'; }, function(reason) { if (releasesWithId.parentNode != null) releasesWithId.remove(); [groupSize.colSpan, groupSize.innerHTML, groupSize.title] = [2, svgFail(), reason]; }); } try { if (isCompleteInfo || !('edition' in target.parentNode.dataset) || score > (is('unknown') ? 0 : 3) || noEditPerms || !editableHosts.includes(document.domain) && !ajaxApiKey) throw 'Not applicable'; const releaseYear = getReleaseYear(release.date), editionInfo = labelInfoMapper(release); if (!(releaseYear > 0) || editionInfo.length <= 0 && !release.barcode && torrent.torrent.remasterYear > 0) throw 'Nothinng to update'; tr.dataset.releaseYear = releaseYear; if (editionInfo.length > 0) tr.dataset.editionInfo = JSON.stringify(editionInfo); if (release.barcode) tr.dataset.barcodes = JSON.stringify([ release.barcode ]); const editionTitle = (release.disambiguation || '').split(/\s*[\,\;]+\s*/).filter(Boolean); if (release.packaging && !['Jewel case', 'Slim Jewel Case'].includes(release.packaging)) editionTitle.push(release.packaging); if (release.status && !['Official', 'Bootleg'].includes(release.status)) editionTitle.push(release.status); if (editionTitle.length > 0) tr.dataset.editionTitle = editionTitle.join(' / '); if (!torrent.torrent.description.includes(release.id)) tr.dataset.description = torrent.torrent.description.trim(); applyOnClick(tr); } catch(e) { openOnClick(tr) } (tr.title ? title.querySelector('a.musicbrainz-release') : tr).title = [ release.quality && release.quality != 'normal' && release.quality + ' quality', release.media && release.media.map(medium => medium.format).join(' + '), [release.status != 'Official' && release.status, release.packaging].filter(Boolean).join(' / '), [release.id, release['cover-art-archive'] && release['cover-art-archive'].artwork && 'artwork'].filter(Boolean).join(' + '), ].filter(Boolean).join('\n'); tr.append(artist, title, _release, editionInfo, barcode, groupSize, releasesWithId); ['artist', 'title', 'release-event', 'edition-info', 'barcode', 'releases-count', 'discids-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 (title.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'; svgSetTitle(span.firstElementChild, 'Has defined Discogs relative(s)'); span.className = 'have-discogs-relatives'; title.append(' ', span); } dcApiRequest('releases/' + discogsId).then(function(release) { const [trDc, icon, artist, title, _release, editionInfo, barcode, groupSize] = createElements('TR', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD'); trDc.className = 'discogs-release'; trDc.style = 'background-color: #8882; word-wrap: break-word; transition: color 200ms ease-in-out;'; trDc.dataset.url = dcOrigin + '/release/' + release.id; [barcode].forEach(elem => { elem.style.whiteSpace = 'nowrap' }); [groupSize, icon].forEach(elem => { elem.style.textAlign = 'right' }); if (release.artists) release.artists.forEach(function(artistCredit, index, artists) { if (artistCredit.id > 0 && ![194].includes(artistCredit.id)) { const a = document.createElement('A'); if (artistCredit.id) a.href = dcOrigin + '/artist/' + artistCredit.id; [a.target, a.style, a.className, a.title] = ['_blank', noLinkDecoration, 'discogs-artist', artistCredit.role || artistCredit.id]; a.textContent = artistCredit.anv || stripNameSuffix(artistCredit.name); artist.append(a); } else artist.append(artistCredit.anv || stripNameSuffix(artistCredit.name)); if (index < artists.length - 1) artist.append(fmtJoinPhrase(artistCredit.join)); }); title.innerHTML = linkHTML(trDc.dataset.url, release.title, 'discogs-release'); const fmtCDFilter = fmt => ['CD', 'CDr', 'All Media'].includes(fmt); let descriptors = [ ]; if ('formats' in release) for (let format of release.formats) if (fmtCDFilter(format.name) && dcFmtFilters[1](format.text) && (!Array.isArray(format.descriptions) || format.descriptions.every(dcFmtFilters[1])) || format.name == 'Hybrid' && (format.text == 'DualDisc' || Array.isArray(format.descriptions) && format.descriptions.includes('DualDisc'))) { if (dcFmtFilters[0](format.text)) descriptors.push(format.text); if (Array.isArray(format.descriptions)) Array.prototype.push.apply(descriptors, format.descriptions.filter(dcFmtFilters[0])); } descriptors = descriptors.filter((d1, n, a) => a.findIndex(d2 => d2.toLowerCase() == d1.toLowerCase()) == n); if (descriptors.length > 0) appendDisambiguation(title, descriptors.join(', ')); _release.innerHTML = [ release.country && `${release.country}`, // ``, release.released && `${release.released}`, ].filter(Boolean).join(' '); if (Array.isArray(release.labels)) editionInfo.innerHTML = release.labels.map(label => [ label.name && `${stripNameSuffix(label.name)}`, label.catno && `${label.catno}`, ].filter(Boolean).join(' ')).filter(Boolean).join('
'); let barCode = release.identifiers && release.identifiers.find(id => id.type == 'Barcode'); if (barCode) barCode = barCode.value.replace(/\D+/g, ''); if (barCode) barcode.textContent = barCode; icon.innerHTML = GM_getResourceText('dc_icon'); icon.firstElementChild.style = ''; icon.firstElementChild.removeAttribute('width'); icon.firstElementChild.setAttribute('height', '1em'); svgSetTitle(icon.firstElementChild, release.id); if (release.master_id) { const masterUrl = new URL('/master/' + release.master_id, dcOrigin); for (let format of ['CD', 'CDr']) masterUrl.searchParams.append('format', format); masterUrl.hash = 'versions'; trDc.dataset.groupUrl = masterUrl; const getGroupSize1 = () => dcApiRequest(`masters/${release.master_id}/versions`) .then(({filters}) => (filters = filters && filters.available && filters.available.format) ? ['CD', 'CDr'].reduce((s, f) => s + (filters[f] || 0), 0) : Promise.reject('Filter totals missing')); const getGroupSize2 = (page = 1) => dcApiRequest(`masters/${release.master_id}/versions`, { page: page, per_page: 1000, }).then(function(versions) { const releases = versions.versions.filter(version => !Array.isArray(version.major_formats) || version.major_formats.some(fmtCDFilter)).length; if (!(versions.pagination.pages > versions.pagination.page)) return releases; return getGroupSize2(page + 1).then(releasesNxt => releases + releasesNxt); }); getGroupSize1().catch(reason => getGroupSize2()).then(function(_groupSize) { const a = document.createElement('A'); a.href = masterUrl; a.target = '_blank'; a.style = noLinkDecoration; a.textContent = _groupSize; if (_groupSize == 1) a.style.color = '#0a0'; groupSize.append(a); groupSize.title = 'Total of same media versions for master release'; }, function(reason) { groupSize.style.paddingTop = '5pt'; groupSize.innerHTML = svgFail(); groupSize.title = reason; }); } else { groupSize.textContent = '–'; groupSize.style.color = '#0a0'; groupSize.title = 'Without master release'; } try { if (isCompleteInfo || !('edition' in target.parentNode.dataset) || score > (is('unknown') ? 0 : 3) || noEditPerms || !editableHosts.includes(document.domain) && !ajaxApiKey) throw 'Not applicable'; const releaseYear = getReleaseYear(release.released); if (!(releaseYear > 0)) throw 'Year unknown'; const editionInfo = Array.isArray(release.labels) ? release.labels.map(label => ({ label: stripNameSuffix(label.name), catNo: label.catno, })).filter(label => label.label || label.catNo) : [ ]; if (editionInfo.length <= 0 && !barCode && torrent.torrent.remasterYear > 0) throw 'Nothing to update'; trDc.dataset.releaseYear = releaseYear; if (editionInfo.length > 0) trDc.dataset.editionInfo = JSON.stringify(editionInfo); if (barCode) trDc.dataset.barcodes = JSON.stringify([ barCode ]); if ((descriptors = descriptors.filter(dcFmtFilters[2])).length > 0) trDc.dataset.editionTitle = descriptors.join(' / '); if (!torrent.torrent.description.includes(trDc.dataset.url)) trDc.dataset.description = torrent.torrent.description.trim(); applyOnClick(trDc); } catch(e) { openOnClick(trDc) } (trDc.title ? title.querySelector('a.discogs-release') : trDc).title = release.formats.map(function(format) { const tags = [format.text].concat(format.descriptions || [ ]).filter(Boolean); if (format.name == 'All Media') return tags.length > 0 && tags.join(', '); let description = format.qty + '×' + format.name; if (tags.length > 0) description += ' (' + tags.join(', ') + ')'; return description; }).concat((release.series || [ ]).map(series => 'Series: ' + [stripNameSuffix(series.name), series.catno].filter(Boolean).join(' '))) .concat((release.identifiers || [ ]).filter(identifier => identifier.type != 'Barcode') .map(identifier => identifier.type + ': ' + identifier.value)) .concat([ [release.data_quality, release.status].filter(Boolean).join(' / '), release.id, ]).filter(Boolean).join('\n'); trDc.append(artist, title, _release, editionInfo, barcode, groupSize, icon); ['artist', 'title', 'release-event', 'edition-info', 'barcode', 'releases-count', 'discogs-icon'] .forEach((className, index) => trDc.cells[index].className = className); tr.after(trDc); //tbody.append(trDc); }, reason => { svgSetTitle(title.querySelector('span.have-discogs-relatives').firstElementChild, reason) }); } }); table.append(tbody); addLookupResults(torrentId, thead, table); if (isCompleteInfo || !('edition' in target.parentNode.dataset) || score > (is('unknown') ? 0 : 3) || noEditPerms || !editableHosts.includes(document.domain) && !ajaxApiKey || torrent.torrent.remasterYear > 0 && !(releases = releases.filter(release => !release.date || getReleaseYear(release.date) == torrent.torrent.remasterYear)) .some(release => release['label-info'] && release['label-info'].length > 0 || release.barcode) || releases.length > (is('unknown') ? 1 : 3)) return; const releaseYear = releases.reduce((year, release) => year > 0 ? year : getReleaseYear(release.date), undefined); if (!(releaseYear > 0) || releases.some(release1 => releases.some(release2 => getReleaseYear(release2.date) != getReleaseYear(release1.date))) || !releases.every((release, ndx, arr) => release['release-group'].id == arr[0]['release-group'].id)) return; const a = document.createElement('A'); a.className = 'update-edition'; a.href = '#'; a.textContent = '(set)'; a.style.fontWeight = score <= 0 && releases.length < 2 ? 'bold' : 300; a.dataset.releaseYear = releaseYear; const editionInfo = Array.prototype.concat.apply([ ], releases.map(labelInfoMapper)); if (editionInfo.length > 0) a.dataset.editionInfo = JSON.stringify(editionInfo); const barcodes = releases.map(release => release.barcode).filter(Boolean); if (barcodes.length > 0) a.dataset.barcodes = JSON.stringify(barcodes); if (releases.length < 2 && releases[0].disambiguation) a.dataset.editionTitle = releases[0].disambiguation; if (releases.length < 2 && !torrent.torrent.description.includes(releases[0].id)) { a.dataset.url = 'https://musicbrainz.org/release/' + releases[0].id; a.dataset.description = torrent.torrent.description.trim(); } setTooltip(a, 'Update edition info from matched release(s)\n\n' + releases.map(release => release['label-info'].map(labelInfo => [getReleaseYear(release.date), [ labelInfo.label && labelInfo.label.name, labelInfo['catalog-number'] || release.barcode, ].filter(Boolean).join(' / ')].filter(Boolean).join(' – ')).filter(Boolean).join('\n')).join('\n')); a.onclick = updateEdition; if (is('unknown') || releases.length > 1) a.dataset.confirm = true; target.after(a); }, alert); }).catch(function(reason) { target.textContent = reason; target.style.color = 'red'; }).then(() => { target.disabled = false }); } }, 'Lookup edition on MusicBrainz by Disc ID/TOC (Ctrl enforces strict TOC matching)\nUse Alt 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; for (let entry of JSON.parse(target.dataset.entries).reverse()) GM_openInTab(entryUrl(entry), false); return; } else if (target.disabled) return; 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) { 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 }); }, 'Lookup edition on GnuDb (CDDB)'); addLookup('CTDB', function(evt) { const target = evt.currentTarget; console.assert(target instanceof HTMLElement); if (target.disabled) return; 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 || Boolean(target.parentNode.dataset.haveQuery)) return; 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.pop().endSector + 1).join(':')); const saefInt = (base, property) => isNaN(property = parseInt(base.getAttribute(property))) ? undefined : property; 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: saefInt(metadata, 'year'), discNumber: saefInt(metadata, 'discnumber'), discCount: saefInt(metadata, 'disccount'), 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: saefInt(metadata, 'relevance'), })), entries: Array.from(responseXML.getElementsByTagName('entry'), entry => ({ confidence: saefInt(entry, 'confidence'), crc32: saefInt(entry, 'crc32'), hasparity: entry.getAttribute('hasparity') || undefined, id: saefInt(entry, 'id'), npar: saefInt(entry, 'npar'), stride: saefInt(entry, '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 => getSessions(torrentId).then(sessions => sessions.length == entries.length ? sessions.map(function(session, volumeNdx) { if (rxRangeRip.test(session)) 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 (session = session.match(rx[0]) || session.match(rx[1])) && session.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 || entries[volumeNdx] == 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,\nmismatching 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 { matched: getTotal(1), partiallyMatched: getTotal(2), anyMatched: getTotal(3), total: sum(scores.map(score => score[0])), }; }))(results.map(result => 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 }, ]; target.textContent = 'Looking up...'; target.style.color = null; (function execMethod(index = 0, reason = 'index out of range') { return index < methods.length ? ctdbLookup(methods[index]).then(results => Object.assign(results, { method: methods[index] }), reason => execMethod(index + 1, reason)) : Promise.reject(reason); })().then(function(results) { target.textContent = `${results.length}${Boolean(results.method.fuzzy) ? ' fuzzy' : ''} ${results.method.metadata} ${results.length == 1 ? 'match' : 'matches'}`; target.style.color = '#' + (['fast', 'default', 'extensive'].indexOf(results.method.metadata) + results.method.fuzzy * 3 << 1).toString(16) + 'a0'; return queryAjaxAPICached('torrent', { id: torrentId }).then(function(torrent) { const isCompleteInfo = torrent.torrent.remasterYear > 0 && Boolean(torrent.torrent.remasterRecordLabel) && Boolean(torrent.torrent.remasterCatalogueNumber); const is = what => !torrent.torrent.remasterYear && { unknown: torrent.torrent.remastered, unconfirmed: !torrent.torrent.remastered, }[what]; let [method, confidence] = [results.method, results.confidence]; const confidenceBox = document.createElement('SPAN'); confidence.then(function(confidence) { if (confidence.anyMatched <= 0) return Promise.reject('mismatch'); let color = confidence.matched || confidence.partiallyMatched || confidence.anyMatched; color = Math.round(color * 0x55 / confidence.total); color = 0x55 * (3 - Number(confidence.partiallyMatched > 0) - Number(confidence.matched > 0)) - color; confidenceBox.innerHTML = svgCheckmark('#' + (color << 16 | 0xCC00).toString(16).padStart(6, '0')); 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() : svgQuestionMark(); confidenceBox.className = 'ctdb-not-verified'; setTooltip(confidenceBox, `Could not verify checksums (${reason})`); }).then(() => { target.parentNode.append(confidenceBox) }); confidence = confidence.then(confidence => is('unknown') && confidence.anyMatched <= 0 ? Promise.reject('mismatch') : confidence, reason => ({ matched: undefined, partiallyMatched: undefined, anyMatched: undefined })); 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: metadata.source == 'discogs' ? stripNameSuffix(labelInfo.name) : labelInfo.name, catNo: labelInfo.catno, })).filter(labelInfo => labelInfo.label || labelInfo.catNo); // In-page results table const [thead, table, tbody] = createElements('DIV', 'TABLE', 'TBODY'); thead.style = 'margin-bottom: 5pt;'; thead.innerHTML = `Applicable CTDB matches (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, title, release, editionInfo, barcode, relevance] = createElements('TR', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD'); tr.className = 'ctdb-metadata'; tr.style = 'word-wrap: break-word; transition: color 200ms ease-in-out;'; tr.dataset.url = { musicbrainz: 'https://musicbrainz.org/release/' + metadata.id, discogs: dcOrigin + '/release/' + metadata.id, }[metadata.source]; [release, barcode, relevance].forEach(elem => { elem.style.whiteSpace = 'nowrap' }); [relevance].forEach(elem => { elem.style.textAlign = 'right' }); if (source.innerHTML = GM_getResourceText({ musicbrainz: 'mb_logo', discogs: 'dc_icon' }[metadata.source])) { source.firstElementChild.removeAttribute('width'); source.firstElementChild.setAttribute('height', '1em'); svgSetTitle(source.firstElementChild, metadata.source); } else source.innerHTML = ``; artist.textContent = metadata.source == 'discogs' ? stripNameSuffix(metadata.artist) : metadata.artist; source.style.alignTop = '1pt'; title.innerHTML = linkHTML(tr.dataset.url, metadata.album, metadata.source + '-release'); const releaseEvents = metadata.release.map(release => releaseToHtml(release)).filter(Boolean); release.innerHTML = releaseEvents.slice(0, 3).join('
'); if (releaseEvents.length > 3) showAllEvents(release, releaseEvents); editionInfo.innerHTML = metadata.labelInfo.map(labelInfo => [ labelInfo.name && `${stripNameSuffix(labelInfo.name)}`, labelInfo.catno && `${labelInfo.catno}`, ].filter(Boolean).join(' ')).filter(Boolean).join('
'); if (editionInfo.childElementCount <= 0 && metadata.source == 'musicbrainz') mbFindEditionInfoInAnnotation(editionInfo, metadata.id); if (metadata.barcode) barcode.textContent = metadata.barcode; if (metadata.relevance >= 0) { relevance.textContent = metadata.relevance + '%'; relevance.title = 'Relevance'; } (!isCompleteInfo && 'edition' in target.parentNode.dataset && !Boolean(method.fuzzy) && !noEditPerms && (editableHosts.includes(document.domain) || ajaxApiKey) && (!is('unknown') || method.metadata != 'extensive' || !(metadata.relevance < 100)) ? confidence : Promise.reject('Not applicable')).then(function(confidence) { const releaseYear = _getReleaseYear(metadata); if (!(releaseYear > 0)) return Promise.reject('Unknown or inconsistent release year'); const editionInfo = labelInfoMapper(metadata); if (editionInfo.length <= 0 && !metadata.barcode && torrent.torrent.remasterYear > 0) return Promise.reject('No additional edition information'); tr.dataset.releaseYear = releaseYear; if (editionInfo.length > 0) tr.dataset.editionInfo = JSON.stringify(editionInfo); if (metadata.barcode) tr.dataset.barcodes = JSON.stringify([ metadata.barcode ]); if (!torrent.torrent.description.includes(metadata.id)) tr.dataset.description = torrent.torrent.description.trim(); applyOnClick(tr); }).catch(reason => { openOnClick(tr) }); tr.append(source, artist, title, release, editionInfo, barcode, relevance); ['source', 'artist', 'title', 'release-events', 'edition-info', 'barcode', 'relevance'] .forEach((className, index) => tr.cells[index].className = className); tbody.append(tr); }); table.append(tbody); addLookupResults(torrentId, thead, table); // Group set if (isCompleteInfo || !('edition' in target.parentNode.dataset) || Boolean(method.fuzzy) || noEditPerms || !editableHosts.includes(document.domain) && !ajaxApiKey || torrent.torrent.remasterYear > 0 && !(results = results.filter(metadata => isNaN(metadata = _getReleaseYear(metadata)) || metadata == torrent.torrent.remasterYear)) .some(metadata => metadata.labelInfo && metadata.labelInfo.length > 0 || metadata.barcode) || results.length > (is('unknown') ? 1 : 3) || is('unknown') && method.metadata == 'extensive' && results.some(metadata => metadata.relevance < 100)) return; confidence.then(function(confidence) { const releaseYear = results.reduce((year, metadata) => isNaN(year) ? NaN : (metadata = _getReleaseYear(metadata)) > 0 && (year <= 0 || metadata == year) ? metadata : NaN, -Infinity); if (!(releaseYear > 0) || !results.every(m1 => m1.release.every(r1 => results.every(m2 => m2.release.every(r2 => getReleaseYear(r2.date) == getReleaseYear(r1.date)))))) return; const a = document.createElement('A'); a.className = 'update-edition'; a.href = '#'; a.textContent = '(set)'; if (results.length > 1 || results.some(result => result.relevance < 100) || !(confidence.partiallyMatched > 0)) { a.style.fontWeight = 300; a.dataset.confirm = true; } else a.style.fontWeight = 'bold'; a.dataset.releaseYear = releaseYear; const editionInfo = Array.prototype.concat.apply([ ], results.map(labelInfoMapper)); if (editionInfo.length > 0) a.dataset.editionInfo = JSON.stringify(editionInfo); const barcodes = results.map(metadata => metadata.barcode).filter(Boolean); if (barcodes.length > 0) a.dataset.barcodes = JSON.stringify(barcodes); if (results.length < 2 && !torrent.torrent.description.includes(results[0].id)) { a.dataset.description = torrent.torrent.description.trim(); a.dataset.url = { musicbrainz: mbOrigin + '/release/' + results[0].id, discogs: dcOrigin + '/release/' + results[0].id, }[results[0].source]; } setTooltip(a, 'Update edition info from matched release(s)\n\n' + results.map(metadata => metadata.labelInfo.map(labelInfo => ({ discogs: 'Discogs', musicbrainz: 'MusicBrainz', }[metadata.source]) + ' ' + [ _getReleaseYear(metadata), [stripNameSuffix(labelInfo.name), labelInfo.catno || metadata.barcode].filter(Boolean).join(' / '), ].filter(Boolean).join(' – ') + (metadata.relevance >= 0 ? ` (${metadata.relevance}%)` : '')) .filter(Boolean).join('\n')).join('\n')); a.onclick = updateEdition; target.parentNode.append(a); }); }, alert); }, function(reason) { target.textContent = reason; target.style.color = 'red'; }).then(() => { target.parentNode.dataset.haveQuery = true }); }, '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); } }