// ==UserScript==
// @name [GMT] Edition lookup by CD TOC
// @namespace https://greasyfork.org/users/321857-anakunda
// @version 1.15.15
// @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 mb_logo_text https://github.com/metabrainz/metabrainz-logos/raw/master/logos/MusicBrainz/SVG/MusicBrainz_logo.svg
// @resource mb_text https://github.com/metabrainz/metabrainz-logos/raw/master/logos/MusicBrainz/SVG/MusicBrainz_logo_text_only.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, quotaExceeded = false;
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 && 'discogsApiResponseCache' in sessionStorage) try {
dcApiResponses = JSON.parse(sessionStorage.getItem('discogsApiResponseCache'));
} catch(e) {
sessionStorage.removeItem('discogsApiResponseCache');
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) {
if (!quotaExceeded) try {
if (!dcApiResponses) dcApiResponses = { };
dcApiResponses[cacheKey] = response.response;
sessionStorage.setItem('discogsApiResponseCache', JSON.stringify(dcApiResponses));
} catch(e) {
quotaExceeded = true;
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 ? tocEntries.length - index : 0) - 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`;
let layoutType = getLayoutType(tocEntries);
if (layoutType < 0) console.warn('Disc %d unknown layout type', volumeNdx + 1);
else while (layoutType-- > 0) tocEntries.pop(); // ditch data tracks for CD with data track(s)
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, rxBareLabel, rxNoCatno] = [
/^(?:Not On Label|No label|\[no label\]|None|\[none\]|iMD|Independ[ae]nt|Self[- ]?Released)\b/i,
/(?:\s+\b(?:Record(?:ing)?s|Productions?|Int(?:ernationa|\')l)\b|,?\s+(?:Ltd|Inc|Co|LLC|Intl)\.?)+$/ig,
/^(?:None|\[none\])$/i,
];
const bareId = str => str ? str.trim().toLowerCase().replace(rxBareLabel, '').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';
elem.after(container = Object.assign(document.createElement('div'), {
className: 'toc-lookup-tables',
style: 'margin: 10pt 0; padding: 0; display: flex; flex-flow: column; row-gap: 10pt;',
}));
}
(elem = document.createElement('div')).append(...elems);
container.append(elem);
}
function decodeHTML(html) {
const textArea = document.createElement('textarea');
textArea.innerHTML = html;
return textArea.value;
}
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(`
`);
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;
if (caption instanceof Element) a.append(caption); else a.textContent = caption;
[a.className, a.href, a.onclick] = ['toc-lookup', '#', evt => { callback(evt); return false }];
if (tooltip) setTooltip(a, tooltip);
span.append(a);
container.append(span);
}
function svgCaption(resourceName, style, fallbackText) {
console.assert(resourceName || fallbackText);
if (!resourceName) return fallbackText;
let svg = new DOMParser().parseFromString(GM_getResourceText(resourceName), 'text/html');
if ((svg = svg.body.getElementsByTagName('svg')).length > 0) svg = svg[0]; else return fallbackText;
for (let attr of ['id', 'width', 'class', 'x', 'y', 'style']) svg.removeAttribute(attr);
if (style) svg.style = style;
svg.setAttribute('height', '0.9em');
return svg;
}
function imgCaption(src, style) {
const img = document.createElement('img');
img.src = src;
if (style) img.style = style;
return img;
}
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, a.textContent, a.style] =
[mbOrigin + '/release/' + mbId, '_blank', 'by annotation', 'font-style: italic; ' + noLinkDecoration];
a.title = response.map(annotation => annotation.text).join('\n');
elem.append(a);
});
}
function editionInfoMatchingStyle(elem) {
if (!(elem instanceof Element)) throw 'Invalid argument';
elem.style.textDecoration = 'underline yellowgreen dotted 2pt';
//elem.style.textShadow = '0 0 2pt yellowgreen';
}
function releaseEventMapper(countryCode, date, editionYear) {
if (!countryCode && !date) return null;
const components = [ ];
if (countryCode) {
const [span, img] = createElements('span', 'img');
span.className = 'country';
if (/^[A-Z]{2}$/i.test(countryCode)) {
[img.height, img.referrerPolicy, img.title] = [9, 'same-origin', countryCode.toUpperCase()];
img.setAttribute('onerror', 'this.replaceWith(this.title)');
img.src = 'http://s3.cuetools.net/flags/' + countryCode.toLowerCase() + '.png';
span.append(img);
} else span.textContent = countryCode;
components.push(span);
}
if (date) {
const span = document.createElement('span');
[span.className, span.textContent] = ['date', date];
if (editionYear > 0 && editionYear == getReleaseYear(date)) editionInfoMatchingStyle(span);
components.push(span);
}
return components;
}
function editionInfoMapper(labelName, catNo, recordLabels, catalogueNumbers) {
if (!labelName && !catNo) return null;
const components = [ ];
if (labelName) {
const span = document.createElement('span');
[span.className, span.textContent] = ['label', labelName];
if (Array.isArray(recordLabels) && recordLabels.some(recordLabel =>
sameLabels(recordLabel, labelName))) editionInfoMatchingStyle(span);
components.push(span);
}
if (catNo) {
const span = document.createElement('span');
[span.className, span.textContent, span.style] = ['catno', catNo, 'white-space: nowrap;'];
if (Array.isArray(catalogueNumbers) && catalogueNumbers.some(catalogueNumber =>
sameCatnos(catalogueNumber, catNo))) editionInfoMatchingStyle(span);
components.push(span);
}
return components;
}
function fillListRows(container, listElements, maxRowsToShow) {
function addRows(root, range) {
for (let row of range) {
const div = document.createElement('div');
row.forEach((elem, index) => { if (index > 0) div.append(' '); div.append(elem) });
container.append(div);
}
}
if (!(container instanceof HTMLElement)) throw 'Invalid argument';
if (!Array.isArray(listElements) || (listElements = listElements.filter(listElement =>
Array.isArray(listElement) && listElement.length > 0)).length <= 0) return;
addRows(container, maxRowsToShow > 0 ? listElements.slice(0, maxRowsToShow) : listElements);
if (!(maxRowsToShow > 0 && listElements.length > maxRowsToShow)) return;
const divs = createElements('div', 'div');
[divs[0].className, divs[0].style] = ['show-all', 'font-style: italic; cursor: pointer;'];
[divs[0].onclick, divs[0].textContent, divs[0].title] = [function(evt) {
evt.currentTarget.remove();
divs[1].hidden = false;
}, `+ ${listElements.length - maxRowsToShow} others…`, 'Show all'];
[divs[1].className, divs[1].hidden] = ['more-events', true];
addRows(divs[1], listElements.slice(maxRowsToShow));
container.append(...divs);
}
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 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));
const editionInfoSplitter = infoStr =>
infoStr ? (infoStr = decodeHTML(infoStr)).split(/[\/\|\•]+/).concat(infoStr.split(/\s+[\/\|\•]+\s+/))
.map(expr => expr.trim()).filter((s, n, a) => s && a.indexOf(s) == n) : [ ];
const cmpNorm = expr => expr && expr.toLowerCase().replace(/[^\w\u0080-\uFFFF]/g, '');
const sameLabels = (...labels) => labels.length > 0 && labels.every((label, ndx, arr) =>
cmpNorm(label.replace(rxBareLabel, '')) == cmpNorm(arr[0].replace(rxBareLabel, ''))
|| label.toLowerCase().startsWith(arr[0].toLowerCase()) && /^\W/.test(label.slice(arr[0].length))
|| arr[0].toLowerCase().startsWith(label.toLowerCase()) && /^\W/.test(arr[0].slice(label.length)));
const sameCatnos = (...catnos) => catnos.length > 0
&& catnos.every((catno, ndx, arr) => cmpNorm(catno) == cmpNorm(arr[0]));
const sameBarcodes = (...barcodes) => barcodes.length > 0
&& barcodes.every((barcode, ndx, arr) => parseInt(cmpNorm(barcode)) == parseInt(cmpNorm(arr[0])));
addLookup(svgCaption('mb_text', 'filter: saturate(30%) brightness(130%);', '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([mbOrigin, 'otherlookup', 'freedbid?other-lookup.freedbid=' + discId].join('/'), false);
}).catch(reason => { [target.textContent, target.style.color] = [reason, 'red'] }).then(() => { target.disabled = false });
} else if (Boolean(target.dataset.haveResponse)) {
if ('releaseIds' in target.dataset) for (let id of JSON.parse(target.dataset.releaseIds).reverse())
GM_openInTab([mbOrigin, 'release', id].join('/'), false);
// GM_openInTab([mbOrigin, 'cdtoc', evt.shiftKey ? 'attach?toc=' + JSON.parse(target.dataset.toc).join(' ')
// : target.dataset.discId].join('/'), 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', decodeHTML(torrent.torrent.remasterRecordLabel));
if (torrent.torrent.remasterCatalogueNumber) {
formData.set('labels.0.catalog_number', rxNoCatno.test(torrent.torrent.remasterCatalogueNumber) ?
'[none]' : decodeHTML(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) {
sessions.forEach(function(session, discIndex) {
switch (getLayoutType(getTocEntries(session))) {
case 0: break;
case 1: formData.set(`mediums.${discIndex}.format`, 'Enhanced CD'); break;
case 2: formData.set(`mediums.${discIndex}.format`, 'Copy Control CD'); break;
default: console.warn(`Disc ${discIndex + 1} unknown TOC type`);
}
});
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: artist.name, searchName: 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 && cmpNorm([
/\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()));
return titles.every(title => title && titleNorm(title) == titleNorm(titles[0]));
}
function findDiscogsToMbBinding(entity, discogsId) {
function notifyEntityBinding(method, color = 'orange', length = 6) {
let div = document.body.querySelector('div.entity-binding-notify'), animation;
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: #000b; box-shadow: 0 0 7pt 2pt #000b;
cursor: default; z-index: 999; opacity: 0;`;
animation = [{ opacity: 0, color: 'white', transform: 'scaleX(0.5)' }];
document.body.append(div);
} else {
animation = [{ color: 'white' }];
if ('timer' in div.dataset) clearTimeout(parseInt(div.dataset.timer));
}
div.innerHTML = `MBID for ${entity} ${lookupIndexes[entity][discogsId].name} ${method || 'found'}`;
div.animate(animation.concat(
{ offset: 0.03, opacity: 1, color: color, transform: 'scaleX(1)' },
{ offset: 0.80, opacity: 1, color: color },
{ offset: 1.00, opacity: 0 },
), length * 1000);
div.dataset.timer = setTimeout(elem => { elem.remove() }, length * 1000, div);
}
function saveToCache(mbid) {
if (!['artist', 'label', 'release'].includes(entity = entity.toLowerCase())) return;
if (!(entity in bindingsCache)) bindingsCache[entity] = { };
bindingsCache[entity][discogsId] = mbid;
GM_setValue('discogs_to_mb_bindings', bindingsCache);
}
if (!bindingsCache) if (bindingsCache = GM_getValue('discogs_to_mb_bindings'))
console.info('Discogs to MB bindings cache loaded:',
Object.keys(bindingsCache.artist || { }).length, 'artists,',
Object.keys(bindingsCache.label || { }).length, 'labels');
else bindingsCache = {
artist: { 194: '89ad4ac3-39f7-470e-963a-56509c546377' }, // VA
label: {
750: '49b58bdb-3d74-40c6-956a-4c4b46115c9c', // Virgin
1003: '29d7c88f-5200-4418-a683-5c94ea032e38', // BMG
1866: '011d1192-6f65-45bd-85c4-0400dd45693e', // Columbia
26126: 'c029628b-6633-439e-bcee-ed02e8a338f7', // EMI
108701: '7c439400-a83c-48bc-9042-2041711c9599', // Virgin JP
},
};
if (discogsId in bindingsCache[entity]) {
console.log('Entity binding for', entity, discogsId, 'got from cache');
notifyEntityBinding('got from cache', 'sandybrown');
return Promise.resolve(bindingsCache[entity][discogsId]);
}
const dcCounterparts = (dcReleases, mbReleases) => [mbReleases, dcReleases].every(Array.isArray) ?
mbReleases.filter(mbRelease => mbRelease && mbRelease.date && mbRelease.title
&& dcReleases.some(dcRelease => dcRelease && dcRelease.year == getReleaseYear(mbRelease.date)
&& sameTitles(dcRelease.title, mbRelease.title))).length : 0;
const getDiscogsArtistReleases = (page = 1) =>
dcApiRequest(`${entity}s/${discogsId}/releases`, { page: page, per_page: 500 })
.then(response => response.pagination.page < response.pagination.pages ?
getDiscogsArtistReleases(response.pagination.page + 1)
.then(releases => response.releases.concat(releases)) : response.releases);
const findByCommonReleases = mbids => Array.isArray(mbids) ? Promise.all([
getDiscogsArtistReleases(),
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(dcReleases, releases));
const hiScore = Math.max(...matchScores);
if (!(hiScore > 0)) return Promise.reject('Not found by common releases');
const hiIndex = matchScores.indexOf(hiScore);
const dataSize = Math.min(dcReleases.length, mbReleases[hiIndex].length);
if (hiScore * 50 < dataSize) return Promise.reject('Found by common releases with too low match rate');
console.log('Entity binding found by having %d common releases:\n%s\n%s',
hiScore, [dcOrigin, entity, discogsId].join('/') + '#' + entity,
[mbOrigin, entity, mbids[hiIndex], '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 Promise.reject('Ambiguity');
}
notifyEntityBinding(`found by having ${hiScore} common release${hiScore != 1 ? 's' : ''} out of ${dataSize}`, 'gold');
saveToCache(mbids[hiIndex]);
return mbids[hiIndex];
}) : Promise.reject('Invalid argument');
const findByUrlRel = urlTerm => mbApiRequest('url', { query: 'url_descendent:' + urlTerm, limit: 100 })
.then(results => results.count > 0 && (results = results.urls.filter(url =>
discogsIdExtractor(url.resource, entity) == parseInt(discogsId))).length > 0 ?
Promise.all(results.map(url => mbApiRequest('url/' + url.id, { inc: entity + '-rels' })
.then(url => url.relations.filter(relation => relation['target-type'] == entity))))
: Promise.reject('Not found by URL')).then(relations => Array.prototype.concat.apply([ ], relations)).then(function(relations) {
console.assert(relations.length == 1, 'Ambiguous %s linkage for Discogs id',
entity, discogsId, relations);
if (relations.length > 1) return findByCommonReleases(relations.map(relation => relation[entity].id));
notifyEntityBinding('found by having Discogs relative set', 'salmon');
saveToCache(relations[0][entity].id)
return relations[0][entity].id;
});
return findByUrlRel(`*discogs.com/${entity}/${discogsId}`).catch(reason => reason != 'Ambiguity' ? mbApiRequest(entity, {
query: '"' + (lookupIndexes[entity][discogsId].searchName || lookupIndexes[entity][discogsId].name) + '"',
limit: idsLookupLimit,
}).then(results => findByCommonReleases(results[entity + 's'].map(result => result.id))) : Promise.reject(reason));
}
function parseTracks(trackParser, collapseSubtracks = false) {
if (!(trackParser instanceof RegExp)) throw 'Invalid argument';
const media = [ ];
let lastMediumId, heading;
(function addTracks(root, titles) {
function addTrack(track) {
const parsedTrack = trackParser.exec(track.position.trim());
let [mediumFormat, mediumId, trackPosition] = parsedTrack != null ?
parsedTrack.slice(1) : [undefined, undefined, track.position.trim()];
if (!mediumFormat && /^[A-Z]\d*$/.test(trackPosition)) mediumFormat = 'LP';
if (!titles && (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;
}
let name = track.title;
if (track.type_ == 'index' && 'sub_tracks' in track && track.sub_tracks.length > 0)
name += ` (${track.sub_tracks.map(track => track.title).join(' / ')})`;
media[media.length - 1].tracks.push({
number: trackPosition, heading: heading, titles: titles, name: name,
length: track.duration, artists: track.artists, extraartists: track.extraartists,
});
}
if (Array.isArray(root)) for (let track of root) switch (track.type_) {
case 'track': addTrack(track); break;
case 'heading': heading = track.title != '-' && track.title || undefined; break;
case 'index':
if (collapseSubtracks) addTrack(track);
else addTracks(track.sub_tracks, (titles || [ ]).concat(track.title));
break;
}
})(release.tracklist);
return media;
}
const literals = { }, lookupIndexes = { artist: { }, label: { } };
formData.set('name', release.title);
frequencyAnalysis(literals, release.title);
let released, media, bindingsCache;
if ((released = /^\d{4}$/.exec(release.released)) != null) released = parseInt(released[0]);
else if (isNaN(released = new Date(release.released))) released = release.year;
discogsCountryToIso3166Mapper(release.country).forEach(function(countryCode, countryIndex) {
if (countryCode) formData.set(`events.${countryIndex}.country`, countryCode);
if (released instanceof Date && !isNaN(released)) {
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';
if (descriptors.has('HDCD')) defaultFormat = 'HDCD';
if (descriptors.has('CD+G')) defaultFormat = 'CD+G';
if (descriptors.has('SHM-CD')) defaultFormat = 'SHM-CD';
if (descriptors.has('Enhanced')) defaultFormat = 'Enhanced CD';
}
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: label.name,
searchName: stripNameSuffix(label.name).replace(rxBareLabel, ''),
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;
const rxParsingMethods = [
/^()?()?(\S+)$/,
/^([A-Z]{2,})(?:[\-\ ](\d+))?[\ \-\.]?\b(\S+)$/i,
/^([A-Z]{2,})?(\d+)?[\ \-\.]?\b(\S+)$/i,
];
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;
if (rxParsingMethods.some(rx => layoutMatch(media = parseTracks(rx, true))) || !cdLengths
|| rxParsingMethods.some(rx => layoutMatch(media = parseTracks(rx, false)))
|| cdLengths.length > 1 && media.filter(isCD).length < 2 && rxParsingMethods.reverse().some(function(rx) {
if (Array.isArray(cdLengths) && cdLengths.length > 0) for (let collapseSubtracks of [true, false]) {
let rearrangedMedia = parseTracks(rx, collapseSubtracks);
const cdMedia = rearrangedMedia.filter(isCD);
if (cdMedia.length <= 0) continue;
const cdTracks = Array.prototype.concat.apply([ ], cdMedia.map(medium => medium.tracks));
if (cdTracks.length > 0 && cdTracks.length == cdLengths.reduce((sum, totalTracks) => sum + totalTracks, 0)
&& layoutMatch(rearrangedMedia = cdLengths.map(function(discNumTracks, discIndex) {
const trackOffset = cdLengths.slice(0, discIndex).reduce((sum, totalTracks) => sum + totalTracks, 0);
const mediaIndex = Math.min(discIndex, cdMedia.length - 1);
return {
format: cdMedia[mediaIndex].format,
name: cdMedia[mediaIndex].name,
tracks: cdTracks.slice(trackOffset, trackOffset + discNumTracks)
.map((track, index) => Object.assign(track, { number: index + 1 })),
};
}).concat(rearrangedMedia.filter(medium => !isCD(medium))))) media = rearrangedMedia;
if (media == rearrangedMedia) return true;
}
return false;
}) || confirm('Could not find appropriatte tracks mapping to media (' +
media.map(medium => medium.tracks.length).join('+') + ' ≠ ' + cdLengths.join('+') +
'), attach tracks with this layout anyway?')) {
for (let medium of media) if (medium.tracks.every((track, ndx, tracks) => track.heading == tracks[0].heading)) {
medium.name = medium.tracks[0].heading;
for (let track of medium.tracks) track.heading = undefined;
}
media.filter(isCD).concat(media.filter(medium => !isCD(medium))).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(?:digipac?k)\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)
&& !['promo', 'enhanced'].includes(desc.toLowerCase()))).length > 0)
formData.set('comment', descriptors.map(descriptor => // disambiguation
descriptor.replace(/\b(\w+[a-z])\b/g, (...m) => m[1].toLowerCase())).join(', '));
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'),
].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 =>
findDiscogsToMbBinding(entity, discogsId).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 != 1 ? mbTOCs.length.toString() + ' TOCs are' : 'TOC is'} 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 deductions 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) {
//