[RED] Import release details from Bandcamp

Lets find music release on Bandcamp and import release about, personnel credits, cover image and tags.

当前为 2022-11-06 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name [RED] Import release details from Bandcamp
  3. // @namespace https://greasyfork.org/users/321857-anakunda
  4. // @version 0.24.2
  5. // @match https://redacted.ch/upload.php
  6. // @match https://redacted.ch/upload.php&url=*
  7. // @match https://redacted.ch/requests.php?action=new
  8. // @match https://redacted.ch/requests.php?action=new&groupid=*
  9. // @match https://redacted.ch/requests.php?action=new&url=*
  10. // @match https://redacted.ch/torrents.php?id=*
  11. // @match https://redacted.ch/torrents.php?page=*&id=*
  12. // @match https://orpheus.network/upload.php
  13. // @match https://orpheus.network/upload.php?url=*
  14. // @match https://orpheus.network/requests.php?action=new
  15. // @match https://orpheus.network/requests.php?action=new&groupid=*
  16. // @match https://orpheus.network/requests.php?action=new&url=*
  17. // @match https://orpheus.network/torrents.php?id=*
  18. // @match https://orpheus.network/torrents.php?page=*&id=*
  19. // @run-at document-end
  20. // @iconURL https://s4.bcbits.com/img/favicon/favicon-32x32.png
  21. // @author Anakunda
  22. // @description Lets find music release on Bandcamp and import release about, personnel credits, cover image and tags.
  23. // @copyright 2022, Anakunda (https://greasyfork.org/users/321857-anakunda)
  24. // @license GPL-3.0-or-later
  25. // @connect *
  26. // @grant GM_xmlhttpRequest
  27. // @grant GM_getValue
  28. // @grant GM_setValue
  29. // @require https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js
  30. // @require https://openuserjs.org/src/libs/Anakunda/libLocks.min.js
  31. // @require https://openuserjs.org/src/libs/Anakunda/gazelleApiLib.min.js
  32. // @require https://openuserjs.org/src/libs/Anakunda/QobuzLib.min.js
  33. // @require https://openuserjs.org/src/libs/Anakunda/GazelleTagManager.min.js
  34. // ==/UserScript==
  35.  
  36. 'use strict';
  37.  
  38. const imageHostHelper = (function() {
  39. const input = document.head.querySelector('meta[name="ImageHostHelper"]');
  40. return (input != null ? Promise.resolve(input) : new Promise(function(resolve, reject) {
  41. const mo = new MutationObserver(function(mutationsList, mo) {
  42. for (let mutation of mutationsList) for (let node of mutation.addedNodes) {
  43. if (node.nodeName != 'META' || node.name != 'ImageHostHelper') continue;
  44. clearTimeout(timer); mo.disconnect();
  45. return resolve(node);
  46. }
  47. }), timer = setTimeout(function(mo) {
  48. mo.disconnect();
  49. reject('Timeout reached');
  50. }, 15000, mo);
  51. mo.observe(document.head, { childList: true });
  52. })).then(function(node) {
  53. console.assert(node instanceof HTMLElement);
  54. const propName = node.getAttribute('propertyname');
  55. console.assert(propName);
  56. return unsafeWindow[propName] || Promise.reject(`Assertion failed: '${propName}' not in unsafeWindow`);
  57. });
  58. })();
  59.  
  60. const nothingFound = 'Nothing found', bracketStripper = /(?:\s+(?:\([^\(\)]+\)|\[[^\[\]]+\]|\{[^\{\}]+\}))+$/g;
  61. const stripText = text => text ? [
  62. [/\r\n/gm, '\n'], [/[^\S\n]+$/gm, ''], [/\n{3,}/gm, '\n\n'],
  63. ].reduce((text, subst) => text.replace(...subst), text.trim()) : ''
  64.  
  65. const matchingResultsCount = groupId => groupId > 0 ? new Promise(function(resolve, reject) {
  66. const meta = document.head.querySelector('meta[name="torrentgroup"]');
  67. if (meta != null && 'torrentGroup' in unsafeWindow) return resolve(unsafeWindow.torrentGroup);
  68. const mo = new MutationObserver(function(ml, mo) {
  69. for (let mutation of ml) for (let node of mutation.addedNodes) if (node.tagName == 'META' && node.name == 'torrentgroup') {
  70. clearTimeout(timeout); mo.disconnect();
  71. if (unsafeWindow.torrentGroup) resolve(unsafeWindow.torrentGroup);
  72. else reject('Assertion failed: ' + node.name + ' not in unsafeWindow');
  73. }
  74. });
  75. mo.observe(document.head, { childList: true });
  76. const timeout = setTimeout(function(mo) {
  77. mo.disconnect();
  78. reject('torrentGroup monitor timed out');
  79. }, 2500, mo);
  80. }).catch(function(reason) {
  81. console.log(reason);
  82. return queryAjaxAPICached('torrentgroup', { id: groupId }, true);
  83. }).then(function(torrentGroup) {
  84. function tryQuery(terms) {
  85. if (!Array.isArray(terms)) throw 'Invalid qrgument';
  86. if (terms.length <= 0) return Promise.reject(nothingFound);
  87. const url = new URL('https://bandcamp.com/search');
  88. url.searchParams.set('q', terms.map(term => '"' + term + '"').join(' '));
  89. const searchType = itemType => (function getPage(page = 1) {
  90. if (itemType) url.searchParams.set('item_type', itemType); else url.searchParams.delete('item_type');
  91. url.searchParams.set('page', page);
  92. return globalXHR(url).then(function({document}) {
  93. const results = Array.prototype.filter.call(document.body.querySelectorAll('div.search ul.result-items > li.searchresult'), function(li) {
  94. let searchType = li.dataset.search;
  95. if (searchType) try { searchType = JSON.parse(searchType).type.toLowerCase() } catch(e) { console.warn(e) }
  96. return !searchType || ['a', 't'].includes(searchType);
  97. });
  98. const nextLink = document.body.querySelector('div.pager > a.next');
  99. return nextLink != null ? getPage(page + 1, itemType).then(_results => results.concat(_results)) : results;
  100. });
  101. })().then(results => results.length > 0 ? results : Promise.reject(nothingFound));
  102. return searchType();
  103. }
  104.  
  105. const artists = torrentGroup.group.releaseType != 6 && torrentGroup.group.musicInfo
  106. && Array.isArray(torrentGroup.group.musicInfo.artists) && torrentGroup.group.musicInfo.artists.length > 0 ?
  107. torrentGroup.group.musicInfo.artists.map(artist => artist.name).slice(0, 2) : null;
  108. const album = [
  109. /\s+(?:EP|E\.\s?P\.|-\s+(?:EP|E\.\s?P\.))$/i,
  110. /\s+\((?:EP|E\.\s?P\.|Live)\)$/i, /\s+\[(?:EP|E\.\s?P\.|Live)\]$/i,
  111. /\s+\((?:feat\.|ft\.|featuring\s).+\)$/i, /\s+\[(?:feat\.|ft\.|featuring\s).+\]$/i,
  112. ].reduce((title, rx) => title.replace(rx, ''), torrentGroup.group.name);
  113. return (artists != null ? tryQuery(artists.concat(album)) : Promise.reject(nothingFound)).catch(function(reason) {
  114. if (reason != nothingFound) return Promise.reject(reason);
  115. if (artists != null && (artists.some(artist => bracketStripper.test(artist)) || bracketStripper.test(album)))
  116. return tryQuery(artists.map(artist => artist.replace(bracketStripper, '')).concat(album.replace(bracketStripper, '')));
  117. return Promise.reject(nothingFound);
  118. }).catch(reason => reason == nothingFound ? artists == null ? tryQuery([album]) : Promise.reject(nothingFound) : Promise.reject(reason)).catch(function(reason) {
  119. if (reason != nothingFound) return Promise.reject(reason);
  120. return artists == null && bracketStripper.test(album) ?
  121. tryQuery([album.replace(bracketStripper, '')]) : Promise.reject(nothingFound);
  122. }).then(searchResults => searchResults.map(function(li) {
  123. let itemType = li.querySelector('div.itemtype');
  124. if (itemType != null) itemType = itemType.textContent.trim().toLowerCase();
  125. const searchResult = {
  126. numTracks: li.querySelector('div.length'),
  127. releaseYear: li.querySelector('div.released'),
  128. };
  129. if (searchResult.releaseYear != null
  130. && (searchResult.releaseYear = /\b(\d{4})\b/.exec(searchResult.releaseYear.textContent)) != null)
  131. searchResult.releaseYear = parseInt(searchResult.releaseYear[1]);
  132. if (itemType == 'track') searchResult.numTracks = 1;
  133. else if (searchResult.numTracks != null
  134. && (searchResult.numTracks = /\b(\d+)\s+(?:tracks?)\b/i.exec(searchResult.numTracks.textContent)) != null)
  135. searchResult.numTracks = parseInt(searchResult.numTracks[1]);
  136. return searchResult;
  137. })).then(searchResults => searchResults.filter(searchResult => torrentGroup.torrents.some(function(torrent) {
  138. if (torrent.remasterYear > 0 && searchResult.releaseYear > 0 && torrent.remasterYear != searchResult.releaseYear) return false;
  139. const audioFileCount = torrent.fileList ? torrent.fileList.split('|||').filter(file =>
  140. /^(.+\.(?:flac|mp3|m4[ab]|aac|dts(?:hd)?|truehd|ac3|ogg|opus|wv|ape))\{{3}(\d+)\}{3}$/i.test(file)).length : 0;
  141. console.assert(audioFileCount > 0);
  142. return searchResult.releaseYear > 0 && (searchResult.numTracks == 0 || audioFileCount == searchResult.numTracks);
  143. }))).then(matchingResults => matchingResults.length > 0 ? matchingResults.length : Promise.reject(nothingFound));
  144. }) : Promise.reject('Invalid argument');
  145.  
  146. function fetchBandcampDetails(artists, album, isSingle = false) {
  147. function tryQuery(terms) {
  148. if (!Array.isArray(terms)) throw 'Invalid qrgument';
  149. if (terms.length <= 0) return Promise.reject(nothingFound);
  150. const url = new URL('https://bandcamp.com/search');
  151. url.searchParams.set('q', terms.map(term => '"' + term + '"').join(' '));
  152. const searchType = itemType => (function getPage(page = 1) {
  153. if (itemType) url.searchParams.set('item_type', itemType); else url.searchParams.delete('item_type');
  154. url.searchParams.set('page', page);
  155. return globalXHR(url).then(function({document}) {
  156. const results = Array.prototype.filter.call(document.body.querySelectorAll('div.search ul.result-items > li.searchresult'), function(li) {
  157. let searchType = li.dataset.search;
  158. if (searchType) try { searchType = JSON.parse(searchType).type.toLowerCase() } catch(e) { console.warn(e) }
  159. return !searchType || ['a', 't'].includes(searchType);
  160. });
  161. const nextLink = document.body.querySelector('div.pager > a.next');
  162. return nextLink != null && fullSearchResults ? getPage(page + 1, itemType).then(_results => results.concat(_results)) : results;
  163. });
  164. })().then(results => results.length > 0 ? results : Promise.reject(nothingFound));
  165. return searchType(); //searchType('a').catch(reason => isSingle && reason == nothingFound ? searchType('t') : Promise.reject(reason));
  166. }
  167.  
  168. if (!Array.isArray(artists) || artists.length <= 0) artists = null;
  169. if (album) album = [
  170. /\s+(?:EP|E\.\s?P\.|-\s+(?:EP|E\.\s?P\.))$/i,
  171. /\s+\((?:EP|E\.\s?P\.|Live)\)$/i, /\s+\[(?:EP|E\.\s?P\.|Live)\]$/i,
  172. /\s+\((?:feat\.|ft\.|featuring\s).+\)$/i, /\s+\[(?:feat\.|ft\.|featuring\s).+\]$/i,
  173. ].reduce((title, rx) => title.replace(rx, ''), album.trim()); else throw 'Invalid argument';
  174. const fullSearchResults = GM_getValue('full_search_results', true);
  175. return (artists != null ? tryQuery(artists.concat(album)) : Promise.reject(nothingFound)).catch(function(reason) {
  176. if (reason != nothingFound) return Promise.reject(reason);
  177. if (artists != null && (artists.some(artist => bracketStripper.test(artist)) || bracketStripper.test(album)))
  178. return tryQuery(artists.map(artist => artist.replace(bracketStripper, '')).concat(album.replace(bracketStripper, '')));
  179. return Promise.reject(nothingFound);
  180. }).catch(reason => reason == nothingFound ? tryQuery([album]) : Promise.reject(reason)).catch(function(reason) {
  181. if (reason != nothingFound) return Promise.reject(reason);
  182. return bracketStripper.test(album) ? tryQuery([album.replace(bracketStripper, '')]) : Promise.reject(nothingFound);
  183. }).then(searchResults => new Promise(function(resolve, reject) {
  184. console.assert(searchResults.length > 0);
  185. let selectedRow = null, dialog = document.createElement('DIALOG');
  186. dialog.innerHTML = `
  187. <form method="dialog">
  188. <div style="margin-bottom: 10pt; padding: 4px; background-color: #111; box-shadow: 1pt 1pt 5px #bbb inset;">
  189. <ul id="bandcamp-search-results" style="list-style-type: none; width: 645px; max-height: 70vw; overflow-y: auto; overscroll-behavior-y: none; scrollbar-gutter: stable; scroll-behavior: auto; scrollbar-color: #444 #222;" />
  190. </div>
  191. <input value="Import" type="button" disabled /><input value="Cancel" type="button" style="margin-left: 5pt;" /><div style="display: inline-block; margin-left: 15pt;">
  192. <label style="border: 2px groove black; padding: 3pt 3pt 3pt 4pt;"><input name="update-tags" style="margin-right: 4pt;" type="checkbox" />Tags
  193. <label style="margin-left: 5pt;"><input style="margin-right: 4pt;" name="preserve-tags" type="checkbox" />Preserve</label>
  194. </label><label style="margin-left: 5pt;border: 2px groove black; padding: 3pt 3pt 3pt 4pt;"><input style="margin-right: 4pt;" name="update-image" type="checkbox" />Cover</label><label style="margin-left: 4pt;border: 2px groove black; padding: 3pt 3pt 3pt 4pt;"><input style="margin-right: 4pt;" name="update-description" type="checkbox" />Description
  195. <label style="margin-left: 5pt;"><input style="margin-right: 4pt;" name="insert-about" type="checkbox" />About</label><label style="margin-left: 5pt;"><input style="margin-right: 4pt;" name="insert-credits" type="checkbox" />Credits</label><label style="margin-left: 5pt;"><input style="margin-right: 4pt;" name="insert-url" type="checkbox" />URL</label>
  196. </label>
  197. </div>
  198. </form>`;
  199. const form = dialog.querySelector(':scope > form');
  200. console.assert(form != null);
  201. dialog.style = 'position: fixed; top: 5%; left: 0; right: 0; padding: 10pt; margin-left: auto; margin-right: auto; background-color: gray; box-shadow: 5px 5px 10px; z-index: 9999;';
  202. dialog.onkeyup = function(evt) {
  203. if (evt.key != 'Escape') return true;
  204. evt.currentTarget.close();
  205. return false;
  206. };
  207. dialog.oncancel = evt => { reject('Cancelled') };
  208. dialog.onclose = function(evt) {
  209. if (!evt.currentTarget.returnValue) reject('Cancelled');
  210. document.body.removeChild(evt.currentTarget);
  211. };
  212. const ul = dialog.querySelector('ul#bandcamp-search-results'), buttons = dialog.querySelectorAll('input[type="button"]');
  213. for (let li of searchResults) {
  214. for (let a of li.getElementsByTagName('A')) {
  215. a.onclick = evt => { if (!evt.ctrlKey && !evt.shiftKey) return false };
  216. a.search = '';
  217. }
  218. for (let styleSheet of [
  219. ['.searchresult .art img', 'max-height: 145px; max-width: 145px;'],
  220. ['.result-info', 'display: inline-block; color: white; padding: 1pt 10pt; box-sizing: border-box; vertical-align: top; width: 475px; line-height: 1.4em;'],
  221. ['.itemtype', 'font-size: 10px; color: #999; margin-bottom: 0.5em; padding: 0;'],
  222. ['.heading', 'font-size: 16px; margin-bottom: 0.1em; padding: 0;'],
  223. ['.subhead', 'font-size: 13px; margin-bottom: 0.3em; padding: 0;'],
  224. ['.released', 'font-size: 11px; padding: 0;'],
  225. ['.itemurl', 'font-size: 11px; padding: 0;'],
  226. ['.itemurl a', 'color: #84c67d;'],
  227. ['.tags', 'color: #aaa; font-size: 11px; padding: 0;'],
  228. ]) for (let elem of li.querySelectorAll(styleSheet[0])) elem.style = styleSheet[1];
  229. li.style = 'cursor: pointer; margin: 0; padding: 4px;';
  230. for (let child of li.children) child.style.display = 'inline-block';
  231. //li.children[1].removeChild(li.children[1].children[0]);
  232. li.onclick = function(evt) {
  233. if (selectedRow != null) selectedRow.style.backgroundColor = null;
  234. (selectedRow = evt.currentTarget).style.backgroundColor = '#066';
  235. buttons[0].disabled = false;
  236. };
  237. ul.append(li);
  238. }
  239. buttons[0].onclick = function(evt) {
  240. evt.currentTarget.disabled = true;
  241. console.assert(selectedRow instanceof HTMLLIElement);
  242. const a = selectedRow.querySelector('div.result-info > div.heading > a');
  243. if (a != null) globalXHR(a.href).then(function({document}) {
  244. function safeParse(serialized) {
  245. if (serialized) try { return JSON.parse(serialized) } catch (e) { console.warn('BC meta invalid: %s', e, serialized) }
  246. return null;
  247. }
  248.  
  249. const savePreset = (prefName, inputName) => { GM_setValue(prefName, form.elements[inputName].checked) };
  250. savePreset('update_tags', 'update-tags');
  251. GM_setValue('preserve_tags', form.elements['preserve-tags'].checked ? 1 : 0);
  252. savePreset('update_image', 'update-image');
  253. savePreset('update_description', 'update-description');
  254. savePreset('description_insert_about', 'insert-about');
  255. savePreset('description_insert_credits', 'insert-credits');
  256. savePreset('description_insert_url', 'insert-url');
  257.  
  258. const details = { };
  259. let elem = document.head.querySelector(':scope > script[type="application/ld+json"]');
  260. const releaseMeta = elem && safeParse(elem.text);
  261. const tralbum = (elem = document.head.querySelector('script[data-tralbum]')) && safeParse(elem.dataset.tralbum);
  262. if (tralbum != null && Array.isArray(tralbum.packages) && tralbum.packages.length > 0) for (let key in tralbum.packages[0])
  263. if (!tralbum.current[key] && tralbum.packages.every(pkg => pkg[key] == tralbum.packages[0][key]))
  264. tralbum.current[key] = tralbum.packages[0][key];
  265. if (releaseMeta != null && releaseMeta.byArtist) details.artist = releaseMeta.byArtist.name;
  266. if (releaseMeta != null && releaseMeta.name) details.title = releaseMeta.name;
  267. if (releaseMeta != null && releaseMeta.numTracks) details.numTracks = releaseMeta.numTracks;
  268. if (releaseMeta != null && releaseMeta.datePublished) details.releaseDate = new Date(releaseMeta.datePublished);
  269. else if (tralbum != null && tralbum.current.album_publish_date) details.releaseDate = new Date(tralbum.current.album_publish_date);
  270. if (releaseMeta != null && releaseMeta.publisher) details.publisher = releaseMeta.publisher.name;
  271. if (tralbum != null && tralbum.current.upc) details.upc = tralbum.current.upc;
  272. if (releaseMeta != null && releaseMeta.image) details.image = releaseMeta.image;
  273. else if ((elem = document.head.querySelector('meta[property="og:image"][content]')) != null) details.image = elem.content;
  274. else if ((elem = document.querySelector('div#tralbumArt > a.popupImage')) != null) details.image = elem.href;
  275. if (details.image) details.image = details.image.replace(/_\d+(?=\.\w+$)/, '_10');
  276. details.tags = releaseMeta != null && Array.isArray(releaseMeta.keywords) ? new TagManager(...releaseMeta.keywords)
  277. : new TagManager(...Array.from(document.querySelectorAll('div.tralbum-tags > a.tag'), a => a.textContent.trim()));
  278. if (details.tags.length < 0) delete details.tags;
  279. if (tralbum != null && tralbum.current.minimum_price <= 0) details.tags.add('freely.available');
  280. if (releaseMeta != null && releaseMeta.description) details.description = releaseMeta.description;
  281. else if (tralbum != null && tralbum.current.about) details.description = tralbum.current.about;
  282. if (details.description) details.description = stripText(details.description)
  283. .replace(/^24[^\S\n]*bits?[^\S\n]*\/[^\S\n]*\d+(?:\.\d+)?[^\S\n]*k(?:Hz)?$\n+/m, '');
  284. if (releaseMeta != null && releaseMeta.creditText) details.credits = tralbum.current.credits;
  285. else if (tralbum != null && tralbum.current.credits) details.credits = tralbum.current.credits;
  286. if (details.credits) details.credits = stripText(details.credits);
  287. if (releaseMeta != null && releaseMeta.mainEntityOfPage) details.url = releaseMeta.mainEntityOfPage;
  288. else if (tralbum != null && tralbum.url) details.url = tralbum.url;
  289. if (tralbum != null && tralbum.art_id) details.artId = tralbum.art_id;
  290. if (tralbum != null && tralbum.current.album_id) details.id = tralbum.current.album_id;
  291. resolve(details);
  292. }, reject); else reject('Assertion failed: BC release link not found');
  293. dialog.close(a != null ? a.href : '');
  294. };
  295. buttons[1].onclick = evt => { dialog.close() };
  296.  
  297. const loadPreset = (inputName, presetName, presetDefault) =>
  298. { form.elements[inputName].checked = GM_getValue(presetName, presetDefault) };
  299. loadPreset('update-tags', 'update_tags', true);
  300. form.elements['update-tags'].onchange = function(evt) {
  301. form.elements['preserve-tags'].disabled = !evt.currentTarget.checked;
  302. };
  303. form.elements['update-tags'].dispatchEvent(new Event('change'));
  304. form.elements['preserve-tags'].checked = GM_getValue('preserve_tags', 0) > 0;
  305. loadPreset('update-image', 'update_image', true);
  306. loadPreset('update-description', 'update_description', true);
  307. form.elements['update-description'].onchange = function(evt) {
  308. form.elements['insert-about'].disabled = form.elements['insert-credits'].disabled =
  309. form.elements['insert-url'].disabled = !evt.currentTarget.checked;
  310. };
  311. form.elements['update-description'].dispatchEvent(new Event('change'));
  312. loadPreset('insert-about', 'description_insert_about', true);
  313. loadPreset('insert-credits', 'description_insert_credits', true);
  314. loadPreset('insert-url', 'description_insert_url', true);
  315.  
  316. document.body.append(dialog);
  317. dialog.showModal();
  318. ul.focus();
  319. }));
  320. }
  321.  
  322. const siteTagsCache = 'siteTagsCache' in localStorage ? (function(serialized) {
  323. try { return JSON.parse(serialized) } catch(e) { return { } }
  324. })(localStorage.getItem('siteTagsCache')) : { };
  325. function getVerifiedTags(tags, confidencyThreshold = GM_getValue('tags_confidency_threshold', 1)) {
  326. if (!Array.isArray(tags)) throw 'Invalid argument';
  327. return Promise.all(tags.map(function(tag) {
  328. if (!(confidencyThreshold > 0) || tmWhitelist.includes(tag) || siteTagsCache[tag] >= confidencyThreshold)
  329. return Promise.resolve(tag);
  330. return queryAjaxAPICached('browse', { taglist: tag }).then(function(response) {
  331. const usage = response.pages > 1 ? (response.pages - 1) * 50 + 1 : response.results.length;
  332. if (usage < confidencyThreshold) return false;
  333. siteTagsCache[tag] = usage;
  334. Promise.resolve(siteTagsCache).then(cache => { localStorage.setItem('siteTagsCache', JSON.stringify(cache)) });
  335. return tag;
  336. }, reason => false);
  337. })).then(results => results.filter(Boolean));
  338. }
  339.  
  340. function importToBody(bcReleaseDetails, body) {
  341. function insertSection(section, afterBegin = true, beforeLinks = true, beforeEnd = true) {
  342. if (section) if (!body) body = section;
  343. else if (afterBegin && rx.afterBegin.some(rx => rx.test(body)))
  344. body = RegExp.lastMatch + section + '\n\n' + RegExp.rightContext.trimLeft();
  345. else if (beforeLinks && rx.beforeLinks.test('\n' + body))
  346. body = (RegExp.leftContext + '\n\n').trimLeft() + section + '\n\n' + RegExp.lastMatch.trimLeft();
  347. else if (beforeEnd && rx.beforeEnd.some(rx => rx.test(body)))
  348. body = RegExp.leftContext.trimRight() + '\n\n' + section + RegExp.lastMatch.trimLeft();
  349. else body += '\n\n' + section;
  350. }
  351.  
  352. body = stripText(body);
  353. const rx = {
  354. afterBegin: [/^\[pad=\d+\|\d+\]/i, /^Releas(?:ing|ed) .+\d{4}$(?:\r?\n){2,}/im],
  355. beforeLinks: /(?:(?:\r?\n)+(?:(?:More info(?:rmation)?:|\[b\]More info(?:rmation)?:\[\/b\])[^\S\r\n]+)?(?:\[url(?:=[^\[\]]+)?\].+\[\/url\]|https?:\/\/\S+))+(?:\[\/size\]\[\/pad\])?$/i,
  356. beforeEnd: [/\[\/size\]\[\/pad\]$/i],
  357. };
  358. if (bcReleaseDetails.description && bcReleaseDetails.description.length > 10
  359. && ![ ].some(rx => rx.test(bcReleaseDetails.description))
  360. && GM_getValue('description_insert_about', true) && !body.includes(bcReleaseDetails.description))
  361. insertSection('[quote][plain]' + bcReleaseDetails.description + '[/plain][/quote]', true, true, true);
  362. if (document.location.pathname != '/requests.php' && bcReleaseDetails.credits && bcReleaseDetails.credits.length > 10
  363. && ![ ].some(rx => rx.test(bcReleaseDetails.credits))
  364. && GM_getValue('description_insert_credits', true) && !body.includes(bcReleaseDetails.credits))
  365. insertSection('[hide=Credits][plain]' + bcReleaseDetails.credits + '[/plain][/hide]', false, true, true);
  366. if (bcReleaseDetails.url && GM_getValue('description_insert_url', true) && !body.includes(bcReleaseDetails.url))
  367. insertSection('[url=' + bcReleaseDetails.url + ']Bandcamp[/url]', false, false, true);
  368. return body.replace(/(\[\/quote\])(?:\r?\n){2,}/ig, '$1\n');
  369. }
  370.  
  371. const urlParams = new URLSearchParams(document.location.search);
  372. switch (document.location.pathname) {
  373. case '/torrents.php': {
  374. if (document.querySelector('div.sidebar > div.box_artists') == null) break; // Nothing to do here - not music torrent
  375. const groupId = parseInt(urlParams.get('id'));
  376. if (!(groupId > 0)) throw 'Invalid group id';
  377. const linkBox = document.body.querySelector('div.header > div.linkbox');
  378. if (linkBox == null) throw 'LinkBox not found';
  379. const a = document.createElement('A');
  380. a.textContent = 'Bandcamp import';
  381. a.href = '#';
  382. a.title = 'Import album textual description, tags and cover image from Bandcamp release page';
  383. a.className = 'brackets';
  384. a.onclick = function(evt) {
  385. if (!this.disabled) this.disabled = true; else return false;
  386. this.style.color = 'orange';
  387. queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup =>
  388. fetchBandcampDetails(torrentGroup.group.releaseType != 6 && torrentGroup.group.musicInfo.artists ?
  389. torrentGroup.group.musicInfo.artists.map(artist => artist.name).slice(0, 2) : null,
  390. torrentGroup.group.name, torrentGroup.group.releaseType == 9).then(function(bcRelease) {
  391. const updateWorkers = [ ];
  392. if (bcRelease.tags instanceof TagManager && GM_getValue('update_tags', true) && bcRelease.tags.length > 0) {
  393. const releaseTags = Array.from(document.body.querySelectorAll('div.box_tags ul > li'), function(li) {
  394. const tag = { name: li.querySelector(':scope > a'), id: li.querySelector('span.remove_tag > a') };
  395. if (tag.name != null) tag.name = tag.name.textContent.trim();
  396. if (tag.id != null) tag.id = parseInt(new URLSearchParams(tag.id.search).get('tagid'));
  397. return tag.name && tag.id ? tag : null;
  398. }).filter(Boolean);
  399. let bcTags = [ ];
  400. if (torrentGroup.group.musicInfo) for (let importance of Object.keys(torrentGroup.group.musicInfo))
  401. if (Array.isArray(torrentGroup.group.musicInfo[importance]))
  402. Array.prototype.push.apply(bcTags, torrentGroup.group.musicInfo[importance].map(artist => artist.name));
  403. if (Array.isArray(torrentGroup.torrents)) for (let torrent of torrentGroup.torrents) {
  404. if (!torrent.remasterRecordLabel) continue;
  405. const labels = torrent.remasterRecordLabel.split('/').map(label => label.trim());
  406. if (labels.length > 0) {
  407. Array.prototype.push.apply(bcTags, labels);
  408. Array.prototype.push.apply(bcTags, labels.map(function(label) {
  409. const bareLabel = label.replace(/(?:\s+(?:under license.+|Records|Recordings|(?:Ltd|Inc)\.?))+$/, '');
  410. if (bareLabel != label) return bareLabel;
  411. }).filter(Boolean));
  412. }
  413. }
  414. bcTags = new TagManager(...bcTags);
  415. bcTags = Array.from(bcRelease.tags).filter(tag => !bcTags.includes(tag));
  416. if (bcTags.length > 0 && (releaseTags.length <= 0 || !(Number(GM_getValue('preserve_tags', 0)) > 1)))
  417. updateWorkers.push(getVerifiedTags(bcTags, 3).then(function(verifiedBcTags) {
  418. if (verifiedBcTags.length <= 0) return false;
  419. let userAuth = document.body.querySelector('input[name="auth"][value]');
  420. if (userAuth != null) userAuth = userAuth.value; else throw 'Failed to capture user auth';
  421. const updateWorkers = [ ];
  422. const addTags = verifiedBcTags.filter(tag => !releaseTags.map(tag => tag.name).includes(tag));
  423. if (addTags.length > 0) Array.prototype.push.apply(updateWorkers, addTags.map(tag => localXHR('/torrents.php', { responseType: null }, new URLSearchParams({
  424. action: 'add_tag',
  425. groupid: torrentGroup.group.id,
  426. tagname: tag,
  427. auth: userAuth,
  428. }))));
  429. const deleteTags = releaseTags.filter(tag => !verifiedBcTags.includes(tag.name)).map(tag => tag.id);
  430. if (deleteTags.length > 0 && !(Number(GM_getValue('preserve_tags', 0)) > 0))
  431. Array.prototype.push.apply(updateWorkers, deleteTags.map(tagId => localXHR('/torrents.php?' + new URLSearchParams({
  432. action: 'delete_tag',
  433. groupid: torrentGroup.group.id,
  434. tagid: tagId,
  435. auth: userAuth,
  436. }), { responseType: null })));
  437. return updateWorkers.length > 0 ? Promise.all(updateWorkers.map(updateWorker =>
  438. updateWorker.then(responseCode => true, reason => reason))).then(function(results) {
  439. if (results.some(result => result === true)) return results;
  440. return Promise.reject(`All of ${results.length} tag update workers failed (see browser console for more details)`);
  441. }) : false;
  442. }));
  443. }
  444. const rehostWorker = bcRelease.image && GM_getValue('update_image', true) ?
  445. imageHostHelper.then(ihh => ihh.rehostImageLinks([bcRelease.image])
  446. .then(ihh.singleImageGetter)).catch(reason => bcRelease.image) : Promise.resolve(null);
  447. if (ajaxApiKey) {
  448. let origBody = document.createElement('TEXTAREA');
  449. origBody.innerHTML = torrentGroup.group.bbBody;
  450. const body = importToBody(bcRelease, (origBody = origBody.textContent)), formData = new FormData;
  451. updateWorkers.push(rehostWorker.then(function(rehostedImageUrl) {
  452. const updateImage = rehostedImageUrl != null && (!torrentGroup.group.wikiImage || !GM_getValue('preserve_image', false))
  453. && rehostedImageUrl != torrentGroup.group.wikiImage;
  454. if (updateImage || GM_getValue('update_description', true) && body != origBody.trim()) {
  455. if (updateImage) formData.set('image', rehostedImageUrl);
  456. if (GM_getValue('update_description', true) && body != origBody) formData.set('body', body);
  457. formData.set('summary', 'Additional description/cover image import from Bandcamp');
  458. return queryAjaxAPI('groupedit', { id: torrentGroup.group.id }, formData).then(function(response) {
  459. console.log(response);
  460. return true;
  461. });
  462. } else return false;
  463. }));
  464. } else {
  465. updateWorkers.push(localXHR('/torrents.php?' + new URLSearchParams({
  466. action: 'editgroup',
  467. groupid: torrentGroup.group.id,
  468. })).then(function(document) {
  469. const editForm = document.querySelector('form.edit_form');
  470. if (editForm == null) throw 'Edit form not exists';
  471. const formData = new FormData(editForm);
  472. const image = editForm.elements.namedItem('image').value, origBody = editForm.elements.namedItem('body').value;
  473. const body = importToBody(bcRelease, origBody);
  474. return rehostWorker.then(function(resolvedImageUrl) {
  475. const updateImage = resolvedImageUrl != null && (!image || !GM_getValue('preserve_image', false))
  476. && resolvedImageUrl != image;
  477. if (updateImage || GM_getValue('update_description', true) && body != origBody.trim()) {
  478. if (updateImage) formData.set('image', resolved[1]);
  479. if (GM_getValue('update_description', true) && body != origBody) formData.set('body', body);
  480. formData.set('summary', 'Additional description/cover image import from Bandcamp');
  481. return localXHR('/torrents.php', { responseType: null }, formData).then(responseCode => true);
  482. } else return false;
  483. });
  484. }));
  485. if (document.domain == 'redacted.ch' && !(GM_getValue('red_nag_shown', 0) >= 3)) {
  486. const cpLink = new URL('/user.php?action=edit#api_key_settings', document.location.origin);
  487. let userId = document.body.querySelector('#userinfo_username a.username');
  488. if (userId != null) userId = parseInt(new URLSearchParams(userId.search).get('id'));
  489. if (userId > 0) cpLink.searchParams.set('userid', userId);
  490. alert('Please consider generating your personal API token (' + cpLink.href + ')\nSet up as "redacted_api_key" script storage entry');
  491. GM_setValue('red_nag_shown', GM_getValue('red_nag_shown', 0) + 1 || 1);
  492. // updateWorkers.push(localXHR('/user.php?' + URLSearchParams({ action: 'edit', userid: userId })).then(function(document) {
  493. // const form = document.body.querySelector('form#userform');
  494. // if (form == null) throw 'User form not found';
  495. // const formData = new FormData(form), newApiKey = formData.get('new_api_key');
  496. // if (newApiKey) formData.set('confirmapikey', 'on'); else throw 'API key not exist';
  497. // formData.set('api_torrents_scope', 'on');
  498. // for (let name of ['api_user_scope', 'api_requests_scope', 'api_forums_scope', 'api_wiki_scope']) formData.delete(name);
  499. // return localXHR('/user.php', { responseType: null }, formData).then(statusCode => newApiKey);
  500. // }).then(function(newApiKey) {
  501. // GM_setValue('redacted_api_key', newApiKey);
  502. // alert('Your personal API key [' + newApiKey + '] was successfulloy created and saved');
  503. // return false;
  504. // }));
  505. }
  506. }
  507. if (updateWorkers.length > 0) return Promise.all(updateWorkers.map(updateWorker =>
  508. updateWorker.then(response => Boolean(response), function(reason) {
  509. console.warn('Update worker failed with reason ' + reason);
  510. return reason;
  511. }))).then(function(results) {
  512. if (results.filter(Boolean).length > 0 && !results.some(result => result === true))
  513. return Promise.reject(`All of ${results.length} update workers failed (see browser console for more details)`);
  514. if (results.some(result => result === true)) return (document.location.reload(), true);
  515. });
  516. })).catch(reason => { if (!['Cancelled'].includes(reason)) alert(reason) }).then(status => {
  517. this.style.color = status ? 'springgreen' : null;
  518. this.disabled = false;
  519. });
  520. return false;
  521. };
  522. linkBox.append(' ', a);
  523.  
  524. if (urlParams.has('presearch-bandcamp')) matchingResultsCount(groupId).then(function(matchedCount) {
  525. a.style.fontWeight = 'bold';
  526. a.title += `\n\n${matchedCount} possibly matching release(s)`;
  527. }, reason => { a.style.color = 'gray' });
  528. break;
  529. }
  530. case '/upload.php':
  531. if (urlParams.has('groupid')) break;
  532. case '/requests.php': {
  533. function hasStyleSheet(name) {
  534. if (name) name = name.toLowerCase(); else throw 'Invalid argument';
  535. const hrefRx = new RegExp('\\/' + name + '\\b', 'i');
  536. if (document.styleSheets) for (let styleSheet of document.styleSheets)
  537. if (styleSheet.title && styleSheet.title.toLowerCase() == name) return true;
  538. else if (styleSheet.href && hrefRx.test(styleSheet.href)) return true;
  539. return false;
  540. }
  541. function checkFields() {
  542. const visible = ['0', 'Music'].includes(categories.value) && title.textLength > 0;
  543. if (container.hidden != !visible) container.hidden = !visible;
  544. }
  545.  
  546. const categories = document.getElementById('categories');
  547. if (categories == null) throw 'Categories select not found';
  548. const form = document.getElementById('upload_table') || document.getElementById('request_form');
  549. if (form == null) throw 'Main form not found';
  550. let title = form.elements.namedItem('title');
  551. if (title != null) title.addEventListener('input', checkFields); else throw 'Title select not found';
  552. const dynaForm = document.getElementById('dynamic_form');
  553. if (dynaForm != null) new MutationObserver(function(ml, mo) {
  554. for (let mutation of ml) if (mutation.addedNodes.length > 0) {
  555. if (title != null) title.removeEventListener('input', checkFields);
  556. if ((title = document.getElementById('title')) != null) title.addEventListener('input', checkFields);
  557. else throw 'Assertion failed: title input not found!';
  558. container.hidden = true;
  559. }
  560. }).observe(dynaForm, { childList: true });
  561. const isLightTheme = ['postmod', 'shiro', 'layer_cake', 'proton', 'red_light', '2iUn3'].some(hasStyleSheet);
  562. if (isLightTheme) console.log('Light Gazelle theme detected');
  563. const isDarkTheme = ['kuro', 'minimal', 'red_dark', 'Vinyl'].some(hasStyleSheet);
  564. if (isDarkTheme) console.log('Dark Gazelle theme detected');
  565. const container = document.createElement('DIV');
  566. container.style = 'position: fixed; top: 64pt; right: 10pt; padding: 5pt; border-radius: 50%; z-index: 999;';
  567. container.style.backgroundColor = `#${isDarkTheme ? '2f4f4f' : 'b8860b'}80`;
  568. const bcButton = document.createElement('BUTTON'), img = document.createElement('IMG');
  569. bcButton.id = 'import-from-bandcamp';
  570. bcButton.style = `
  571. padding: 10px; color: white; background-color: white; cursor: pointer;
  572. border: none; border-radius: 50%; transition: background-color 200ms;
  573. `;
  574. bcButton.dataset.backgroundColor = bcButton.style.backgroundColor;
  575. bcButton.setDisabled = function(disabled = true) {
  576. this.disabled = disabled;
  577. this.style.opacity = disabled ? 0.5 : 1;
  578. this.style.cursor = disabled ? 'not-allowed' : 'pointer';
  579. };
  580. bcButton.onclick = function(evt) {
  581. this.setDisabled(true);
  582. this.style.backgroundColor = 'red';
  583. const artists = Array.from(form.querySelectorAll('input[name="artists[]"]'), function(input) {
  584. const artist = input.value.trim();
  585. return input.nextElementSibling.value == 1 && artist;
  586. }).filter(Boolean);
  587. const releaseType = form.elements.namedItem('releasetype');
  588. fetchBandcampDetails(releaseType == null || releaseType.value != 7 ? artists.slice(0, 2) : null,
  589. title.value.trim(), releaseType != null && releaseType.value == 9).then(function(bcRelease) {
  590. const tags = form.elements.namedItem('tags'), image = form.elements.namedItem('image'),
  591. description = form.elements.namedItem('album_desc') || form.elements.namedItem('description');
  592. if (tags != null && bcRelease.tags instanceof TagManager && bcRelease.tags.length > 0 && GM_getValue('update_tags', true)
  593. && (!tags.value || !(Number(GM_getValue('preserve_tags', 0)) > 1))) {
  594. let bcTags = Array.from(form.querySelectorAll('input[name="artists[]"]'), input => input.value.trim()).filter(Boolean);
  595. let labels = form.elements.namedItem('remaster_record_label') || form.elements.namedItem('record_label');
  596. if (labels != null && (labels = labels.value.trim().split('/').map(label => label.trim())).length > 0) {
  597. Array.prototype.push.apply(bcTags, labels);
  598. Array.prototype.push.apply(bcTags, labels.map(function(label) {
  599. const bareLabel = label.replace(/(?:\s+(?:under license.+|Records|Recordings|(?:Ltd|Inc)\.?))+$/, '');
  600. if (bareLabel != label) return bareLabel;
  601. }).filter(Boolean));
  602. }
  603. bcTags = new TagManager(...bcTags);
  604. bcTags = Array.from(bcRelease.tags).filter(tag => !bcTags.includes(tag));
  605. getVerifiedTags(bcTags).then(function(bcVerifiedTags) {
  606. if (bcVerifiedTags.length <= 0) return;
  607. if (Number(GM_getValue('preserve_tags', 0)) > 0) {
  608. const mergedTags = new TagManager(tags.value, ...bcVerifiedTags);
  609. tags.value = mergedTags.toString();
  610. } else tags.value = bcVerifiedTags.join(', ');
  611. });
  612. }
  613. if (image != null && bcRelease.image && GM_getValue('update_image', true) && (!image.value || !GM_getValue('preserve_image', false))) {
  614. image.value = bcRelease.image;
  615. imageHostHelper.then(ihh => { ihh.rehostImageLinks([bcRelease.image]).then(ihh.singleImageGetter).then(rehostedUrl =>
  616. { image.value = rehostedUrl }) });
  617. }
  618. if (description != null && GM_getValue('update_description', true)) {
  619. const body = importToBody(bcRelease, description.value);
  620. if (body != description.value) description.value = body;
  621. }
  622. }, reason => { if (!['Cancelled'].includes(reason)) alert(reason) }).then(() => {
  623. this.style.backgroundColor = this.dataset.backgroundColor;
  624. this.setDisabled(false);
  625. });
  626. };
  627. bcButton.onmouseenter = bcButton.onmouseleave = function(evt) {
  628. if (evt.relatedTarget == evt.currentTarget || evt.currentTarget.disabled) return false;
  629. evt.currentTarget.style.backgroundColor = evt.type == 'mouseenter' ? 'orange'
  630. : evt.currentTarget.dataset.backgroundColor || null;
  631. };
  632. bcButton.title = 'Import description, cover image and tags from Bandcamp';
  633. 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
  634. img.width = 32;
  635. bcButton.append(img);
  636. container.append(bcButton);
  637. checkFields();
  638. document.body.append(container);
  639. break;
  640. }
  641. }