// ==UserScript== // @name [RED] Import music release details from bandcamp // @namespace https://greasyfork.org/users/321857-anakunda // @version 0.10.0 // @match https://redacted.ch/upload.php // @match https://redacted.ch/torrents.php?id=* // @match https://redacted.ch/torrents.php?page=*&id=* // @match https://orpheus.network/upload.php // @match https://orpheus.network/torrents.php?id=* // @match https://orpheus.network/torrents.php?page=*&id=* // @run-at document-end // @author Anakunda // @description Lets find music release on Bandcamp and imports text description, artist credits, image and tags into existing release group. // @copyright 2022, Anakunda (https://greasyfork.org/users/321857-anakunda) // @license GPL-3.0-or-later // @connect bandcamp.com // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @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 // @require https://openuserjs.org/src/libs/Anakunda/QobuzLib.min.js // @require https://openuserjs.org/src/libs/Anakunda/GazelleTagManager.min.js // @downloadURL none // ==/UserScript== 'use strict'; const imageHostHelper = (function() { const input = document.head.querySelector('meta[name="ImageHostHelper"]'); return (input != null ? Promise.resolve(input) : new Promise(function(resolve, reject) { const mo = new MutationObserver(function(mutationsList, mo) { for (let mutation of mutationsList) for (let node of mutation.addedNodes) { if (node.nodeName != 'META' || node.name != 'ImageHostHelper') continue; clearTimeout(timer); mo.disconnect(); return resolve(node); } }), timer = setTimeout(function(mo) { mo.disconnect(); reject('Timeout reached'); }, 15000, mo); mo.observe(document.head, { childList: true }); })).then(function(node) { console.assert(node instanceof HTMLElement); const propName = node.getAttribute('propertyname'); console.assert(propName); return unsafeWindow[propName] || Promise.reject(`Assertion failed: '${propName}' not in unsafeWindow`); }); })(); function fetchBandcampDetails(artists, album) { function tryQuery(query) { if (!query) throw 'Invalid qrgument'; const url = new URL('https://bandcamp.com/search'); url.searchParams.set('q', query); url.searchParams.set('item_type', 'a'); return globalXHR(url).then(function({document}) { const results = document.body.querySelectorAll('div.search ul.result-items > li.searchresult'); return results.length > 0 ? results : Promise.reject('Not found'); }); } if (album) album = [ /\s+(?:EP|E\.\s?P\.|-\s+(?:EP|E\.\s?P\.))$/i, /\s+\((?:EP|E\.\s?P\.|Live)\)$/i, /\s+\[(?:EP|E\.\s?P\.|Live)\]$/i, /\s+\((?:feat\.|ft\.|featuring\s).+\)$/i, /\s+\[(?:feat\.|ft\.|featuring\s).+\]$/i, ].reduce((title, rx) => title.replace(rx, ''), album.trim()); else throw 'Invalid argument'; return (Array.isArray(artists) && artists.length > 0 ? tryQuery(artists.map(artist => '"' + artist + '"').join(' ') + ' "' + album + '"') : Promise.reject('No artist')).catch(reason => tryQuery('"' + album + '"')).then(searchResults => new Promise(function(resolve, reject) { console.assert(searchResults.length > 0); let selectedRow = null; const list = document.createElement('UL'); for (let li of searchResults) { for (let a of li.getElementsByTagName('A')) a.onclick = evt => { if (!evt.ctrlKey && !evt.shiftKey) return false }; for (let styleSheet of [ ['.searchresult .art img', 'max-height: 145px; max-width: 145px;'], ['.result-info', 'display: inline-block; color: #595959; vertical-align: top; width: 475px; margin-left: 1.3em; line-height: 1.4em;'], ['.itemtype', 'font-size: 10px; color: #999; margin-bottom: 0.5em;'], ['.heading', 'font-size: 16px; margin-bottom: 0.1em;'], ['.subhead', 'font-size: 13px; margin-bottom: 0.3em;'], ['.released', 'font-size: 11px;'], ['.itemurl', 'color: #999; font-size: 11px;'], ['.itemurl a', 'color: #84c67d;'], ['.tags', 'color: #999; font-size: 11px;'], ]) for (let elem of li.querySelectorAll(styleSheet[0])) elem.style = styleSheet[1]; li.style = 'cursor: pointer; margin: 0; padding: 8px;'; for (let child of li.children) child.style.display = 'inline-block'; li.children[1].removeChild(li.children[1].children[0]); li.onclick = function(evt) { if (selectedRow != null) selectedRow.style.backgroundColor = null; (selectedRow = evt.currentTarget).style.backgroundColor = 'cornsilk'; buttons[0].disabled = false; }; list.append(li); } list.style = 'width: 665px; max-height: 70vw; background-color: white; padding: 5px; overflow-y: auto; overscroll-behavior-y: none; scrollbar-gutter: stable; scroll-behavior: auto; margin-bottom: 10pt; list-style-type: none; box-shadow: 1px 1px 5px #555 inset;'; const dialog = document.createElement('DIALOG'); dialog.innerHTML = `
`; dialog.style = 'padding: 1rem; position: fixed; top: 5%; left: 0; right: 0; margin-left: auto; margin-right: auto; background-color: lightgray; z-index: 9999;'; dialog.onclose = evt => { document.body.removeChild(evt.currentTarget) }; const form = dialog.querySelector('form#bandcamp-search-results'); form.prepend(list); const buttons = dialog.querySelectorAll('input[type="button"]'); buttons[0].onclick = function(evt) { console.assert(selectedRow instanceof HTMLTableRowElement); evt.currentTarget.disabled = true; const a = selectedRow.querySelector('div.result-info > div.heading > a'); if (a != null) globalXHR(a.href.replace(/\?.*$/, '')).then(function({document}) { const details = { tags: new TagManager(...Array.from(document.querySelectorAll('div.tralbumData.tralbum-tags > a.tag'), a => a.textContent.trim())), }; let elem = document.querySelector('div#tralbumArt > a.popupImage'); if (elem != null) details.image = elem.href; else if ((elem = document.head.querySelector('meta[property="og:image"]')) != null) details.image = elem.content; if (details.image) details.image = details.image.replace(/_\d+(?=\.\w+$)/, '_10'); if ((elem = document.head.querySelector('script[data-tralbum]')) == null) throw 'tralbum data not found'; const tralbum = JSON.parse(elem.dataset.tralbum); if (typeof tralbum != 'object') throw 'invalid tralbum format'; if (Array.isArray(tralbum.packages) && tralbum.packages.length > 0) for (let key in tralbum.packages[0]) if (!tralbum.current[key] && tralbum.packages.every(pkg => pkg[key] == tralbum.packages[0][key])) tralbum.current[key] = tralbum.packages[0][key]; if (tralbum.current.minimum_price <= 0) details.tags.add('freely.available'); if (tralbum.url) details.url = tralbum.url; if (tralbum.current.about) details.description = tralbum.current.about.replace(/\r\n/g, '\n'); if (tralbum.current.credits) details.credits = tralbum.current.credits.replace(/\r\n/g, '\n'); resolve(details); }, reject); else reject('Link to album could not be found'); dialog.close(); }; buttons[1].onclick = function(evt) { reject('Cancelled'); dialog.close(); }; document.body.append(dialog); dialog.showModal(); })); } switch (document.location.pathname) { case '/torrents.php': { if (document.querySelector('div.sidebar > div.box_artists') == null) break; // Nothing to do here - not music torrent //if (!ajaxApiKey) throw 'AJAX API key not configured'; const urlParams = new URLSearchParams(document.location.search), groupId = parseInt(urlParams.get('id')); if (!(groupId > 0)) throw 'Invalid group id'; const linkBox = document.body.querySelector('div.header > div.linkbox'); if (linkBox == null) throw 'LinkBox not found'; const a = document.createElement('A'); a.textContent = 'Bandcamp import'; a.href = '#'; a.title = 'Import album textual description, tags and cover image from Bandcamp release page'; a.className = 'brackets'; a.onclick = function(evt) { if (!this.disabled) this.disabled = true; else return false; this.style.color = 'orange'; queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup => fetchBandcampDetails(torrentGroup.group.releaseType != 6 ? torrentGroup.group.musicInfo.artists.map(artist => artist.name).slice(0, 3) : null, torrentGroup.group.name) .then(function(details) { const updateWorkers = [ ]; updateWorkers.push(localXHR('/torrents.php?' + new URLSearchParams({ action: 'editgroup', groupid: torrentGroup.group.id, })).then(function(document) { const form = document.querySelector('form.edit_form'); if (form == null) throw 'Edit form not found'; const rehostWorker = (details.image ? imageHostHelper.then(ihh => ihh.rehostImageLinks([details.image]) .then(ihh.singleImageGetter)).catch(reason => details.image) : Promise.resolve(null)); let image = form.elements.namedItem('image').value, body = form.elements.namedItem('body').value.trim(); if (details.description && !body.includes(details.description)) { if (body.length <= 0) body = '[quote]' + details.description + '[/quote]'; else if (/^\[pad=\d+\|\d+\]/i.test(body)) body = RegExp.leftContext + RegExp.lastMatch + '[quote]' + details.description + '[/quote]\n' + RegExp.rightContext; else body += '\n\n[quote]' + details.description + '[/quote]'; } if (details.credits && !body.includes(details.credits)) { const credits = '[hide=Credits]' + details.credits + '[/hide]'; if (body.length <= 0) body = credits; else if (/\[\/size\]\[\/pad\]$/i.test(body)) body = RegExp.leftContext + '\n\n' + credits + RegExp.lastMatch + RegExp.rightContext; else body += '\n\n' + credits; } if (details.url && !body.includes(details.url)) { const url = '[url=' + details.url + ']Bandcamp[/url]'; if (body.length <= 0) body = url; else if (/\[\/size\]\[\/pad\]$/i.test(body)) body = RegExp.leftContext + '\n\n' + url + RegExp.lastMatch + RegExp.rightContext; else body += '\n\n' + url; } return rehostWorker.then(function(rehostedImageUrl) { if (rehostedImageUrl != null && rehostedImageUrl != image || body != form.elements.namedItem('body').value.trim()) { const formData = new FormData; formData.set('action', 'takegroupedit'); formData.set('auth', form.elements.namedItem('auth').value); formData.set('groupid', form.elements.namedItem('groupid').value); formData.set('image', rehostedImageUrl || image); formData.set('body', body); formData.set('groupeditnotes', form.elements.namedItem('groupeditnotes').value); formData.set('releasetype', form.elements.namedItem('releasetype').value); formData.set('summary', 'Image/description update from Bandcamp'); return localXHR('/torrents.php', { responseType: null }, formData).then(response => true, reason => reason); } else return 'No changes made'; }); })); if (details.tags instanceof TagManager) { let userAuth = document.body.querySelector('input[name="auth"][value]'); if (userAuth != null) { userAuth = userAuth.value; let tags = Array.from(document.body.querySelectorAll('div.box_tags ul > li'), function(li) { const tag = { name: li.querySelector(':scope > a'), id: li.querySelector('span.remove_tag > a') }; if (tag.name != null) tag.name = tag.name.textContent.trim(); if (tag.id != null) tag.id = parseInt(new URLSearchParams(tag.id.search).get('tagid')); return tag.name && tag.id ? tag : null; }).filter(Boolean); const addTags = Array.from(details.tags).filter(tag => !tags.map(tag => tag.name).includes(tag)); if (addTags.length > 0) updateWorkers.push(localXHR('/torrents.php', { responseType: null }, new URLSearchParams({ action: 'add_tag', groupid: torrentGroup.group.id, tagname: addTags.join(', '), auth: userAuth, })).then(response => true, reason => reason)); const deleteTags = tags.filter(tag => !details.tags.includes(tag.name)); if (deleteTags.length > 0) Array.prototype.push.apply(updateWorkers, deleteTags.map(tag => localXHR('/torrents.php?' + new URLSearchParams({ action: 'delete_tag', groupid: torrentGroup.group.id, tagid: tag.id, auth: userAuth, }), { responseType: null }).then(response => true, reason => reason))); } } // Update by API is broken // if (details.image) updateWorkers.push(imageHostHelper.then(ihh => ihh.rehostImageLinks([details.image]) // .then(ihh.singleImageGetter)).catch(reason => details.image).then(function(imageUrl) { // if (imageUrl == torrentGroup.group.wikiImage) return false; // return queryAjaxAPI('groupedit', { id: torrentGroup.group.id }, { // image: imageUrl, // summary: 'Cover update from Bandcamp', // }).then(response => true, reason => reason); // })); // const ta = document.createElement('TEXTAREA'); // ta.innerHTML = torrentGroup.group.bbBody; // let body = ta.textContent.trim(); // if (details.description && !body.includes(details.description)) { // if (body.length <= 0) body = '[quote]' + details.description + '[/quote]'; // else if (/^\[pad=\d+\|\d+\]/i.test(body)) // body = RegExp.leftContext + RegExp.lastMatch + '[quote]' + details.description + '[/quote]\n' + RegExp.rightContext; // else body += '\n\n[quote]' + details.description + '[/quote]'; // } // if (details.credits && !body.includes(details.credits)) { // const credits = '[hide=Credits]' + details.credits + '[/hide]'; // if (body.length <= 0) body = credits; // else if (/\[\/size\]\[\/pad\]$/i.test(body)) // body = RegExp.leftContext + '\n\n' + credits + RegExp.lastMatch + RegExp.rightContext; // else body += '\n\n' + credits; // } // if (details.url && !body.includes(details.url)) { // const url = '[url=' + details.url + ']Bandcamp[/url]'; // if (body.length <= 0) body = url; // else if (/\[\/size\]\[\/pad\]$/i.test(body)) // body = RegExp.leftContext + '\n\n' + url + RegExp.lastMatch + RegExp.rightContext; // else body += '\n\n' + url; // } // if (body != ta.textContent) { // const formData = new FormData; // formData.set('body', body); // formData.set('summary', 'Description update from Bandcamp'); // updateWorkers.push(queryAjaxAPI('groupedit', { id: groupId }, formData).then(response => true, reason => reason)); // } if (updateWorkers.length > 0) return Promise.all(updateWorkers).then(function(results) { if (results.filter(result => result === true).length > 0) document.location.reload(); else return Promise.reject(`All of ${results.length} update workers failed (see browser console for more details)`); }); })).catch(reason => { if (!['Cancelled'].includes(reason)) alert(reason) }).then(() => { this.style.color = null; this.disabled = false; }); return false; }; linkBox.append(' ', a); break; } case '/upload.php': { function hasStyleSheet(name) { if (name) name = name.toLowerCase(); else throw 'Invalid argument'; const hrefRx = new RegExp('\\/' + name + '\\b', 'i'); if (document.styleSheets) for (let styleSheet of document.styleSheets) if (styleSheet.title && styleSheet.title.toLowerCase() == name) return true; else if (styleSheet.href && hrefRx.test(styleSheet.href)) return true; return false; } const checkFields = function() { const visible = ['0', 'Music'].includes(categories.value) && title.textLength > 0; if (div.hidden != !visible) div.hidden = !visible; }; const categories = document.getElementById('categories'); if (categories == null) throw 'Categories select not found'; let title = document.getElementById('title'); if (title != null) title.addEventListener('input', checkFields); else throw 'Title select not found'; const dynaForm = document.getElementById('dynamic_form'); if (dynaForm != null) new MutationObserver(function(ml, mo) { for (let mutation of ml) if (mutation.addedNodes.length > 0) { if (title != null) title.removeEventListener('input', checkFields); if ((title = document.getElementById('title')) != null) title.addEventListener('input', checkFields); else throw 'Assertion failed: title input not found!'; div.hidden = true; } }).observe(dynaForm, { childList: true }); const isLightTheme = ['postmod', 'shiro', 'layer_cake', 'proton', 'red_light', '2iUn3'].some(hasStyleSheet); if (isLightTheme) console.log('Light Gazelle theme detected'); const isDarkTheme = ['kuro', 'minimal', 'red_dark', 'Vinyl'].some(hasStyleSheet); if (isDarkTheme) console.log('Dark Gazelle theme detected'); const div = document.createElement('DIV'); div.style = 'position: fixed; top: 64pt; right: 10pt; padding: 5pt; border-radius: 50%; z-index: 999;'; div.style.backgroundColor = `#${isDarkTheme ? '2f4f4f' : 'b8860b'}80`; const bcButton = document.createElement('BUTTON'), img = document.createElement('IMG'); bcButton.id = 'import-from-bandcamp'; bcButton.style = ` padding: 10px; color: white; background-color: white; cursor: pointer; border: none; border-radius: 50%; transition: background-color 200ms; `; bcButton.dataset.backgroundColor = bcButton.style.backgroundColor; bcButton.setDisabled = function(disabled = true) { this.disabled = disabled; this.style.opacity = disabled ? 0.5 : 1; this.style.cursor = disabled ? 'not-allowed' : 'pointer'; }; bcButton.onclick = function(evt) { this.setDisabled(true); this.style.backgroundColor = 'red'; const artists = Array.from(document.body.querySelectorAll('tr#artist_tr input[name="artists[]"]'), function(input) { const artist = input.value.trim(); return input.nextElementSibling.value == 1 && artist; }).filter(Boolean); const releaseType = document.getElementById('releasetype'); fetchBandcampDetails(releaseType == null || releaseType.value != 7 ? artists.slice(0, 3) : [ ], title.value.trim()).then(function(details) { const tags = document.getElementById('tags'), image = document.getElementById('image'), description = document.getElementById('album_desc'); if (tags != null && details.tags instanceof TagManager) tags.value = details.tags.toString(); if (image != null && details.image) { image.value = details.image; imageHostHelper.then(function(ihh) { ihh.rehostImageLinks([details.image]).then(ihh.singleImageGetter).then(rehostedUrl => { image.value = rehostedUrl }); }); } if (description != null) { let body = description.value.trim(); if (details.description && !body.includes(details.description)) { if (body.length <= 0) body = '[quote]' + details.description + '[/quote]'; else if (/^\[pad=\d+\|\d+\]/i.test(body)) body = RegExp.leftContext + RegExp.lastMatch + '[quote]' + details.description + '[/quote]\n' + RegExp.rightContext; else body += '\n\n[quote]' + details.description + '[/quote]'; } if (details.credits && !body.includes(details.credits)) { const credits = '[hide=Credits]' + details.credits + '[/hide]'; if (body.length <= 0) body = credits; else if (/\[\/size\]\[\/pad\]$/i.test(body)) body = RegExp.leftContext + '\n\n' + credits + RegExp.lastMatch + RegExp.rightContext; else body += '\n\n' + credits; } if (details.url && !body.includes(details.url)) { const url = '[url=' + details.url + ']Bandcamp[/url]'; if (body.length <= 0) body = url; else if (/\[\/size\]\[\/pad\]$/i.test(body)) body = RegExp.leftContext + '\n\n' + url + RegExp.lastMatch + RegExp.rightContext; else body += '\n\n' + url; } description.value = body; } }, reason => { if (!['Cancelled'].includes(reason)) alert(reason) }).then(() => { this.style.backgroundColor = this.dataset.backgroundColor; this.setDisabled(false); }); }; bcButton.onmouseenter = bcButton.onmouseleave = function(evt) { if (evt.relatedTarget == evt.currentTarget || evt.currentTarget.disabled) return false; evt.currentTarget.style.backgroundColor = evt.type == 'mouseenter' ? 'orange' : evt.currentTarget.dataset.backgroundColor || null; }; bcButton.title = 'Import description, cover image and tags from Bandcamp'; img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAbFBMVEX////3///v///v9//m9//m9/fe9/fe7/fW7/fO7/fF7/fF5ve95u+15u+13u+t3u+l3u+l3uac1uaU1uaM1uaMzuaEzt57zt57xd5zxd5rxd5jvdZavdZSvdZStdZKtdZKtc5Ctc5Crc4ZpcWSSeq2AAAAAXRSTlMAQObYZgAAAAlwSFlzAAALEgAACxIB0t1+/AAAAoBJREFUeJzt2mtvgjAUBuCC93sUdR/2/3/bsqlbNqfzVpgGExXa02N5ZVly+gVBpU+a0retVtUfl6oABCAAAQhAAAIQgAAEIAABCEAAArjjs60oUSpI0pNCx/jFBxA8Ve7QkmV9eXkHYAKrX7/5ACptVP3xSvsAprAGSGZXJ2xAo4GqX39dn7EBY1gDqHcfQKuGql5/3JxyAZPw/CIJCh7Vpw+gG56/r4oe4/ntnZkAXA+Iv30AA1T1Si8yF3iAIawBDisfwAhVvdLz7BUWoA9rgN3GBzBBVa/0LHeJAQg6pwYo/Pyfjpu9DyCdBiDGAf2av7sbUGu6jbyiV4kPADcPUfkewAA066jqb2OYDXhUDHMBndDxAXbJxDAXEMHmAZkYZgK6IeT5P5ZDNoV4gEsPKDoOJJkY5gFwMbw3PYJuAC6G9Y8P4JExzALgYni79QFMA+LNu8r1YpAPqLRhg9BaW98iAGkKIcYBwzyEATjHMGAeEC8NMewGAFfDlkGQBjRxQ4A5BFyAMS6FzDHoAHRg22faOA1wAiLcYtA4EXIB+rh5CN0ANsCogpoHaEsM04AhZh2gqBQiAQNYD9jbYpgERKjqyUGYAPRwMbzzAZQSwwQAF8MxEcMEAJhC7gYwAOrpnixgHNBLBjIPOK+GEeMAFcNWAHBPloxhKwCXQnQM2wDdsmLYBhiVFcMWAC6G95wemAcAGyC7J8sCDEH7gcRqmAYcYxg1D7AuBilAVGYKmQA9WBc07MkyAMAY5vaAG0C5MWwAlBvDecCjfhplA57THgAYB0JeCmQBC9iutGspYAGw0htf/tV/SAQgAAEIQAACEIAABCAAAQhAAAJ4SPkFdtLUKHgfCmoAAAAASUVORK5CYII=' // https://s4.bcbits.com/img/favicon/apple-touch-icon.png img.width = 32; bcButton.append(img); div.append(bcButton); checkFields(); document.body.append(div); break; } }