[RED] Import release details from Bandcamp

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

  1. // ==UserScript==
  2. // @name [RED] Import release details from Bandcamp
  3. // @namespace https://greasyfork.org/users/321857-anakunda
  4. // @version 0.26.4
  5. // @match https://redacted.sh/upload.php
  6. // @match https://redacted.sh/upload.php&url=*
  7. // @match https://redacted.sh/requests.php?action=new
  8. // @match https://redacted.sh/requests.php?action=new&groupid=*
  9. // @match https://redacted.sh/requests.php?action=new&url=*
  10. // @match https://redacted.sh/torrents.php?id=*
  11. // @match https://redacted.sh/torrents.php?page=*&id=*
  12. // @match https://redacted.sh/better.php?method=notags
  13. // @match https://redacted.sh/better.php?page=*&method=notags
  14. // @match https://orpheus.network/upload.php
  15. // @match https://orpheus.network/upload.php?url=*
  16. // @match https://orpheus.network/requests.php?action=new
  17. // @match https://orpheus.network/requests.php?action=new&groupid=*
  18. // @match https://orpheus.network/requests.php?action=new&url=*
  19. // @match https://orpheus.network/torrents.php?id=*
  20. // @match https://orpheus.network/torrents.php?page=*&id=*
  21. // @match https://orpheus.network/better.php?method=notags
  22. // @match https://orpheus.network/better.php?page=*&method=notags
  23. // @run-at document-end
  24. // @iconURL https://s4.bcbits.com/img/favicon/favicon-32x32.png
  25. // @author Anakunda
  26. // @description Lets find music release on Bandcamp and import release about, personnel credits, cover image and tags.
  27. // @copyright 2023, Anakunda (https://greasyfork.org/users/321857-anakunda)
  28. // @license GPL-3.0-or-later
  29. // @connect *
  30. // @grant GM_xmlhttpRequest
  31. // @grant GM_getValue
  32. // @grant GM_setValue
  33. // @grant GM_openInTab
  34. // @grant GM_saveTab
  35. // @grant GM_getTabs
  36. // @require https://openuserjs.org/src/libs/Anakunda/Requests.min.js
  37. // @require https://openuserjs.org/src/libs/Anakunda/libLocks.min.js
  38. // @require https://openuserjs.org/src/libs/Anakunda/gazelleApiLib.min.js
  39. // @require https://openuserjs.org/src/libs/Anakunda/QobuzLib.min.js
  40. // @require https://openuserjs.org/src/libs/Anakunda/GazelleTagManager.min.js
  41. // ==/UserScript==
  42.  
  43. {
  44.  
  45. 'use strict';
  46.  
  47. const imageHostHelper = 'imageHostHelper' in unsafeWindow ? unsafeWindow.imageHostHelper ? Promise.resolve(unsafeWindow.imageHostHelper)
  48. : Promise.reject('Assertion failed: void unsafeWindow.imageHostHelper') : new Promise(function(resolve, reject) {
  49. function listener(evt) {
  50. clearTimeout(timeout);
  51. unsafeWindow.removeEventListener('imageHostHelper', listener);
  52. //console.log('imageHostHelper exports triggered:', evt);
  53. if (evt.data) resolve(evt.data); else if (unsafeWindow.imageHostHelper) resolve(unsafeWindow.imageHostHelper);
  54. else reject('Assertion failed: void unsafeWindow.imageHostHelper');
  55. }
  56.  
  57. unsafeWindow.addEventListener('imageHostHelper', listener);
  58. const timeout = setTimeout(function() {
  59. unsafeWindow.removeEventListener('imageHostHelper', listener);
  60. reject('Timeout reached');
  61. }, 15000);
  62. });
  63.  
  64. const nothingFound = 'Nothing found';
  65. const stripText = text => text ? [
  66. [/\r\n/gm, '\n'], [/[^\S\n]+$/gm, ''], [/\n{3,}/gm, '\n\n'],
  67. ].reduce((text, subst) => text.replace(...subst), text.trim()) : '';
  68.  
  69. function getSearchResults(torrentGroup, fullSearchResults = true, thoroughSearch = false) {
  70. function tryQuery(terms) {
  71. if (!Array.isArray(terms)) throw 'Invalid qrgument';
  72. if (terms.length <= 0) return Promise.reject(nothingFound);
  73. const url = new URL('https://bandcamp.com/search');
  74. url.searchParams.set('q', terms.map(term => '"' + term + '"').join(' '));
  75. const searchType = itemType => (function getPage(page = 1) {
  76. if (itemType) url.searchParams.set('item_type', itemType); else url.searchParams.delete('item_type');
  77. url.searchParams.set('page', page);
  78. return GlobalXHR.get(url).then(function({document}) {
  79. const results = Array.prototype.filter.call(document.body.querySelectorAll('div.search ul.result-items > li.searchresult'), function(li) {
  80. let searchType = li.dataset.search;
  81. if (searchType) try { searchType = JSON.parse(searchType).type.toLowerCase() } catch(e) { console.warn(e) }
  82. return !searchType || ['a', 't'].includes(searchType);
  83. });
  84. return document.body.querySelector('div.pager > a.next') != null && fullSearchResults ?
  85. getPage(page + 1, itemType).then(_results => results.concat(_results)) : results;
  86. });
  87. })().then(results => results.length > 0 ? results : Promise.reject(nothingFound));
  88. return searchType();
  89. // return ('a').catch(reason => [5, 9].includes(torrentGroup.group.releaseType) && reason == nothingFound ?
  90. // searchType('t') : Promise.reject(reason));
  91. }
  92.  
  93. if (!torrentGroup) return Promise.reject('Assertion failed: invalid argument (torrentGroup)');
  94. const artists = torrentGroup.group.releaseType != 7 && torrentGroup.group.musicInfo
  95. && Array.isArray(torrentGroup.group.musicInfo.artists) && torrentGroup.group.musicInfo.artists.length > 0 ?
  96. torrentGroup.group.musicInfo.artists.map(artist => artist.name.trim()).slice(0, 2) : null;
  97. const album = [
  98. /\s+(?:EP|E\.\s?P\.|-\s+(?:EP|E\.\s?P\.))$/i,
  99. /\s+\((?:EP|E\.\s?P\.|Live)\)$/i, /\s+\[(?:EP|E\.\s?P\.|Live)\]$/i,
  100. /\s+\((?:feat\.|ft\.|featuring\s).+\)$/i, /\s+\[(?:feat\.|ft\.|featuring\s).+\]$/i,
  101. ].reduce((title, rx) => title.replace(rx, ''), torrentGroup.group.name.trim());
  102. if (!album) return Promise.reject('Album title missing');
  103. const bracketStripper = /(?:\s+(?:\([^\(\)]+\)|\[[^\[\]]+\]|\{[^\{\}]+\}))+$/g;
  104. const searchAlbumOnly = () => tryQuery([album]).catch(reason => reason == nothingFound ? bracketStripper.test(album) ?
  105. tryQuery([album.replace(bracketStripper, '')]) : Promise.reject(nothingFound) : Promise.reject(reason));
  106. if (artists == null) return searchAlbumOnly();
  107. let lookupWorker = tryQuery(artists.concat(album)).catch(reason => reason == nothingFound ?
  108. artists.some(artist => bracketStripper.test(artist)) || bracketStripper.test(album) ?
  109. tryQuery(artists.map(artist => artist.replace(bracketStripper, '')).concat(album.replace(bracketStripper, '')))
  110. : Promise.reject(nothingFound) : Promise.reject(reason));
  111. if (thoroughSearch || torrentGroup.group.musicInfo.artists.length >= 3)
  112. lookupWorker = lookupWorker.catch(reason => reason == nothingFound ? searchAlbumOnly() : Promise.reject(reason));
  113. return lookupWorker;
  114. }
  115.  
  116. const matchingResultsCount = torrentGroup => getSearchResults(torrentGroup, true).then(searchResults => searchResults.map(function(li) {
  117. const searchResult = {
  118. itemType: li.querySelector('div.itemtype'),
  119. numTracks: li.querySelector('div.length'),
  120. releaseYear: li.querySelector('div.released'),
  121. };
  122. if (searchResult.itemType != null) searchResult.itemType = searchResult.itemType.textContent.trim().toLowerCase();
  123. if (searchResult.releaseYear != null
  124. && (searchResult.releaseYear = /\b(\d{4})\b/.exec(searchResult.releaseYear.textContent)) != null)
  125. searchResult.releaseYear = parseInt(searchResult.releaseYear[1]);
  126. if (searchResult.itemType == 'track') searchResult.numTracks = 1;
  127. else if (searchResult.numTracks != null
  128. && (searchResult.numTracks = /\b(\d+)\s+(?:tracks?)\b/i.exec(searchResult.numTracks.textContent)) != null)
  129. searchResult.numTracks = parseInt(searchResult.numTracks[1]);
  130. else searchResult.numTracks = 0;
  131. return searchResult;
  132. })).then(searchResults => searchResults.filter(function(searchResult) {
  133. if (searchResult.releaseYear > 0 && torrentGroup.group.year > searchResult.releaseYear) return false;
  134. return torrentGroup.torrents.some(function(torrent) {
  135. if (torrent.remasterYear > 0 && searchResult.releaseYear > 0 && torrent.remasterYear != searchResult.releaseYear) return false;
  136. const audioFileCount = torrent.fileList ? torrent.fileList.split('|||').filter(file =>
  137. /^(.+\.(?:flac|mp3|m4[ab]|aac|dts(?:hd)?|truehd|ac3|ogg|opus|wv|ape))\{{3}(\d+)\}{3}$/i.test(file)).length : 0;
  138. console.assert(audioFileCount > 0);
  139. if (audioFileCount > 0 && searchResult.numTracks > 0 && audioFileCount != searchResult.numTracks) return false;
  140. return torrent.remasterYear > 0 && searchResult.releaseYear > 0 || audioFileCount > 0 && searchResult.numTracks > 0;
  141. });
  142. })).then(matchingResults => matchingResults.length > 0 ? matchingResults.length : Promise.reject(nothingFound));
  143.  
  144. const fetchBandcampDetails = torrentGroup => getSearchResults(torrentGroup, GM_getValue('full_search_results', true), true)
  145. .then(searchResults => new Promise(function(resolve, reject) {
  146. function kbdControl(key, altKey, ctrlKey, shiftKey) {
  147. function moveCursor(offset) {
  148. const direction = offset > 0 ? 'next' : offset < 0 ? 'previous' : undefined;
  149. if (direction) offset = Math.abs(offset); else return;
  150. if (selectedRow instanceof HTMLLIElement) selection = selectedRow; else {
  151. selection = ul.querySelector(':scope > li:first-of-type');
  152. --offset;
  153. }
  154. while (offset-- > 0) {
  155. const cursor = selection[direction + 'ElementSibling'];
  156. if (cursor != null) selection = cursor; else break;
  157. }
  158. }
  159.  
  160. if (!altKey) switch (key) {
  161. case 'Escape': dialog.close(); return true;
  162. case 'Enter': if (selectedRow instanceof HTMLLIElement) buttons[0].click(); return false;
  163. case 'Home': var selection = ul.querySelector(':scope > li:first-of-type'); break;
  164. case 'PageUp': moveCursor(-Math.round(ul.offsetHeight / 156)); break;
  165. case 'ArrowUp': moveCursor(-1); break;
  166. case 'ArrowDown': moveCursor(+1); break;
  167. case 'PageDown': moveCursor(+Math.round(ul.offsetHeight / 156)); break;
  168. case 'End': selection = ul.querySelector(':scope > li:last-of-type'); break;
  169. default: return true;
  170. } else switch (key) {
  171. case 'c': {
  172. var elem = form.elements.namedItem('update-image');
  173. if (elem) elem.checked = !elem.checked;
  174. return false;
  175. }
  176. default: return true;
  177. }
  178. if (selection == selectedRow) return false;
  179. if (selectedRow != null) selectedRow.style.backgroundColor = null;
  180. selection.style.backgroundColor = '#066';
  181. (selectedRow = selection).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  182. buttons[0].disabled = false;
  183. return false;
  184. }
  185.  
  186. console.assert(searchResults.length > 0);
  187. let selectedRow = null, dialog = document.createElement('DIALOG');
  188. dialog.innerHTML = `
  189. <form method="dialog" style="padding: 10pt; background-color: gray;">
  190. <div style="margin-bottom: 10pt; padding: 4px; background-color: #111; box-shadow: 1pt 1pt 5px #bbb inset;">
  191. <ul id="bandcamp-search-results" style="list-style-type: none; width: 645px; max-height: 75vh; overflow-y: auto; overscroll-behavior-y: none; scrollbar-gutter: stable; scroll-behavior: auto; scrollbar-color: #444 #222;" />
  192. </div>
  193. <input value="Import" type="button" disabled />
  194. <input value="Cancel" type="button" style="margin-left: 5pt;" />
  195. <div style="display: inline-block; margin-left: 15pt;">
  196. <label style="border: 2px groove black; padding: 3pt 3pt 3pt 4pt;">
  197. <input name="update-tags" style="margin-right: 4pt;" type="checkbox" />Tags
  198. <label style="margin-left: 5pt;"><input style="margin-right: 4pt;" name="preserve-tags" type="checkbox" />Preserve</label>
  199. </label>
  200. <label style="margin-left: 5pt; border: 2px groove black; padding: 3pt 3pt 3pt 4pt;">
  201. <input style="margin-right: 4pt;" name="update-image" type="checkbox" />Cover
  202. </label>
  203. <label style="margin-left: 4pt;border: 2px groove black; padding: 3pt 3pt 3pt 4pt;">
  204. <input style="margin-right: 4pt;" name="update-description" type="checkbox" />Description
  205. <label style="margin-left: 5pt;"><input style="margin-right: 4pt;" name="insert-about" type="checkbox" />About</label>
  206. <label style="margin-left: 5pt;"><input style="margin-right: 4pt;" name="insert-credits" type="checkbox" />Credits</label>
  207. <label style="margin-left: 5pt;"><input style="margin-right: 4pt;" name="insert-url" type="checkbox" />URL</label>
  208. </label>
  209. </div>
  210. </form>`.replace(/\r?\n[^\S\r\n]*/g, '');
  211. const form = dialog.querySelector(':scope > form');
  212. console.assert(form != null);
  213. dialog.style = 'position: fixed; top: 0; left: 0; margin: auto; box-shadow: 5px 5px 10px; z-index: 99999;';
  214. dialog.oncancel = evt => { reject('Cancelled') };
  215. dialog.onclose = function(evt) {
  216. [document.body.onkeydown, document.body.onkeyup] = keyHandlers;
  217. if (!evt.currentTarget.returnValue) reject('Cancelled');
  218. document.body.removeChild(evt.currentTarget);
  219. };
  220. dialog.onclick = function(evt) {
  221. if (evt.target != evt.currentTarget) return true;
  222. evt.currentTarget.close();
  223. return false;
  224. };
  225. const ul = dialog.querySelector('ul#bandcamp-search-results'), buttons = dialog.querySelectorAll('input[type="button"]');
  226. ul.scrollTop = 0;
  227. for (let li of searchResults) {
  228. for (let a of li.getElementsByTagName('A')) {
  229. a.onclick = evt => { if (!evt.ctrlKey && !evt.shiftKey) return false };
  230. a.search = '';
  231. }
  232. for (let styleSheet of [
  233. ['.searchresult .art img', 'max-height: 145px; max-width: 145px;'],
  234. ['.result-info', 'display: inline-block; color: white; padding: 1pt 10pt; box-sizing: border-box; vertical-align: top; width: 475px; line-height: 1.4em;'],
  235. ['.itemtype', 'font-size: 10px; color: #999; margin-bottom: 0.5em; padding: 0;'],
  236. ['.heading', 'font-size: 16px; margin-bottom: 0.1em; padding: 0;'],
  237. ['.subhead', 'font-size: 13px; margin-bottom: 0.3em; padding: 0;'],
  238. ['.released', 'font-size: 11px; padding: 0;'],
  239. ['.itemurl', 'font-size: 11px; padding: 0;'],
  240. ['.itemurl a', 'color: #84c67d;'],
  241. ['.tags', 'color: #aaa; font-size: 11px; padding: 0;'],
  242. ]) for (let elem of li.querySelectorAll(styleSheet[0])) elem.style = styleSheet[1];
  243. li.style = 'cursor: pointer; margin: 0; padding: 4px;';
  244. for (let child of li.children) child.style.display = 'inline-block';
  245. let confidence = 1;
  246. let [itemType, numTracks, releaseYear] = ['div.itemtype', 'div.length', 'div.released'].map(sel => li.querySelector(sel));
  247. if (itemType != null) itemType = itemType.textContent.trim().toLowerCase();
  248. if (releaseYear != null && (releaseYear = /\b(\d{4})\b/.exec(releaseYear.textContent)) != null) releaseYear = parseInt(releaseYear[1]);
  249. if (itemType == 'track') numTracks = 1;
  250. else if (numTracks != null && (numTracks = /\b(\d+)\s+(?:tracks?)\b/i.exec(numTracks.textContent)) != null)
  251. numTracks = parseInt(numTracks[1]);
  252. if (releaseYear > 0 && torrentGroup.group.year > releaseYear) confidence *= 1/2;
  253. else if ('torrents' in torrentGroup && torrentGroup.torrents.length > 0) {
  254. if (releaseYear > 0 && !torrentGroup.torrents.some(torrent =>
  255. !(torrent.remasterYear > 0) || torrent.remasterYear == releaseYear)) confidence *= 3/4;
  256. if (numTracks > 0 && !torrentGroup.torrents.some(function(torrent) {
  257. const audioFileCount = torrent.fileList ? torrent.fileList.split('|||').filter(file =>
  258. /^(.+\.(?:flac|mp3|m4[ab]|aac|dts(?:hd)?|truehd|ac3|ogg|opus|wv|ape))\{{3}(\d+)\}{3}$/i.test(file)).length : 0;
  259. return !(audioFileCount > 0) || audioFileCount == numTracks;
  260. })) confidence *= 3/4;
  261. }
  262. if (confidence < 1) li.style.opacity = confidence;
  263. li.onclick = function(evt) {
  264. if (evt.currentTarget == selectedRow) return false;
  265. if (selectedRow != null) selectedRow.style.backgroundColor = null;
  266. (selectedRow = evt.currentTarget).style.backgroundColor = '#066';
  267. buttons[0].disabled = false;
  268. return false;
  269. };
  270. li.ondblclick = evt => (buttons[0].click(), false);
  271. }
  272. for (let li of searchResults.sort(function(a, b) {
  273. const getConfidence = li => parseFloat(li.style.opacity) || 1;
  274. const getItemType = li => ['track', 'album']
  275. .indexOf((li = li.querySelector('div.itemtype')) != null && li.textContent.trim().toLowerCase());
  276. return getConfidence(b) - getConfidence(a) || getItemType(b) - getItemType(a);
  277. })) ul.append(li);
  278. buttons[0].onclick = function(evt) {
  279. evt.currentTarget.disabled = true;
  280. console.assert(selectedRow instanceof HTMLLIElement);
  281. const a = selectedRow.querySelector('div.result-info > div.heading > a');
  282. if (a != null) GlobalXHR.get(a).then(function({document}) {
  283. function safeParse(serialized) {
  284. if (serialized) try { return JSON.parse(serialized) } catch (e) { console.warn('BC meta invalid: %s', e, serialized) }
  285. return null;
  286. }
  287.  
  288. const savePreset = (prefName, inputName) => { GM_setValue(prefName, form.elements[inputName].checked) };
  289. savePreset('update_tags', 'update-tags');
  290. GM_setValue('preserve_tags', form.elements['preserve-tags'].checked ? 1 : 0);
  291. savePreset('update_image', 'update-image');
  292. savePreset('update_description', 'update-description');
  293. savePreset('description_insert_about', 'insert-about');
  294. savePreset('description_insert_credits', 'insert-credits');
  295. savePreset('description_insert_url', 'insert-url');
  296.  
  297. const details = { };
  298. let elem = document.head.querySelector(':scope > script[type="application/ld+json"]');
  299. const releaseMeta = elem && safeParse(elem.text);
  300. const tralbum = (elem = document.head.querySelector('script[data-tralbum]')) && safeParse(elem.dataset.tralbum);
  301. if (tralbum && Array.isArray(tralbum.packages) && tralbum.packages.length > 0) for (let key in tralbum.packages[0])
  302. if (!tralbum.current[key] && tralbum.packages.every(pkg => pkg[key] == tralbum.packages[0][key]))
  303. tralbum.current[key] = tralbum.packages[0][key];
  304. if (releaseMeta != null && releaseMeta.byArtist) details.artist = releaseMeta.byArtist.name;
  305. if (releaseMeta != null && releaseMeta.name) details.title = releaseMeta.name;
  306. if (releaseMeta != null && releaseMeta.numTracks) details.numTracks = releaseMeta.numTracks;
  307. if (releaseMeta != null && releaseMeta.datePublished) details.releaseDate = new Date(releaseMeta.datePublished);
  308. else if (tralbum != null && tralbum.current.album_publish_date) details.releaseDate = new Date(tralbum.current.album_publish_date);
  309. if (releaseMeta != null && releaseMeta.publisher) details.publisher = releaseMeta.publisher.name;
  310. if (tralbum != null && tralbum.current.upc) details.upc = tralbum.current.upc;
  311. if (releaseMeta != null && releaseMeta.image) details.image = releaseMeta.image;
  312. else if ((elem = document.head.querySelector('meta[property="og:image"][content]')) != null) details.image = elem.content;
  313. else if ((elem = document.querySelector('div#tralbumArt > a.popupImage')) != null) details.image = elem.href;
  314. if (details.image) details.image = details.image.replace(/_\d+(?=\.\w+$)/, '_10');
  315. details.tags = releaseMeta != null && Array.isArray(releaseMeta.keywords) ? new TagManager(...releaseMeta.keywords)
  316. : new TagManager(...Array.from(document.querySelectorAll('div.tralbum-tags > a.tag'), a => a.textContent.trim()));
  317. if (details.tags.length < 0) delete details.tags;
  318. if (tralbum != null && tralbum.current.minimum_price <= 0) details.tags.add('freely.available');
  319. if (releaseMeta != null && releaseMeta.description) details.description = releaseMeta.description;
  320. else if (tralbum != null && tralbum.current.about) details.description = tralbum.current.about;
  321. if (details.description) details.description = stripText(details.description)
  322. .replace(/^24[^\S\n]*bits?[^\S\n]*\/[^\S\n]*\d+(?:\.\d+)?[^\S\n]*k(?:Hz)?$\n+/m, '');
  323. if (releaseMeta != null && releaseMeta.creditText) details.credits = tralbum.current.credits;
  324. else if (tralbum != null && tralbum.current.credits) details.credits = tralbum.current.credits;
  325. if (details.credits) details.credits = stripText(details.credits);
  326. if (releaseMeta != null && releaseMeta.mainEntityOfPage) details.url = releaseMeta.mainEntityOfPage;
  327. else if (tralbum != null && tralbum.url) details.url = tralbum.url;
  328. if (tralbum != null && tralbum.art_id) details.artId = tralbum.art_id;
  329. if (tralbum != null && tralbum.current.album_id) details.id = tralbum.current.album_id;
  330. resolve(details);
  331. }, reject); else reject('Assertion failed: BC release link not found');
  332. dialog.close(a != null ? a.href : '');
  333. };
  334. buttons[1].onclick = evt => { dialog.close() };
  335. let keyHandlers = [document.body.onkeydown, document.body.onkeyup], keyDown = false;
  336. [document.body.onkeydown, document.body.onkeyup] = [function(evt) {
  337. if (!keyDown) keyDown = true; else kbdControl(evt.key, evt.altKey, evt.ctrlKey, evt.shiftKey);
  338. return false;
  339. }, function(evt) {
  340. keyDown = false;
  341. return kbdControl(evt.key, evt.altKey, evt.ctrlKey, evt.shiftKey);
  342. }];
  343.  
  344. const loadPreset = (inputName, presetName, presetDefault) =>
  345. { form.elements[inputName].checked = GM_getValue(presetName, presetDefault) };
  346. loadPreset('update-tags', 'update_tags', true);
  347. form.elements['update-tags'].onchange = function(evt) {
  348. form.elements['preserve-tags'].disabled = !evt.currentTarget.checked;
  349. };
  350. form.elements['update-tags'].dispatchEvent(new Event('change'));
  351. form.elements['preserve-tags'].checked = GM_getValue('preserve_tags', 0) > 0;
  352. loadPreset('update-image', 'update_image', true);
  353. loadPreset('update-description', 'update_description', true);
  354. form.elements['update-description'].onchange = function(evt) {
  355. form.elements['insert-about'].disabled = form.elements['insert-credits'].disabled =
  356. form.elements['insert-url'].disabled = !evt.currentTarget.checked;
  357. };
  358. form.elements['update-description'].dispatchEvent(new Event('change'));
  359. loadPreset('insert-about', 'description_insert_about', true);
  360. loadPreset('insert-credits', 'description_insert_credits', true);
  361. loadPreset('insert-url', 'description_insert_url', true);
  362.  
  363. document.body.append(dialog);
  364. dialog.showModal();
  365. ul.focus();
  366. }));
  367.  
  368. const siteTagsCache = 'siteTagsCache' in localStorage ? (function(serialized) {
  369. try { return JSON.parse(serialized) } catch(e) { return { } }
  370. })(localStorage.getItem('siteTagsCache')) : { };
  371. function getVerifiedTags(tags, confidencyThreshold = GM_getValue('tags_confidency_threshold', 1)) {
  372. if (!Array.isArray(tags)) throw 'Invalid argument';
  373. return Promise.all(tags.map(function(tag) {
  374. if (!(confidencyThreshold > 0) || tmWhitelist.includes(tag) || siteTagsCache[tag] >= confidencyThreshold)
  375. return Promise.resolve(tag);
  376. return queryAjaxAPICached('browse', { taglist: tag }).then(function(response) {
  377. const usage = response.pages > 1 ? (response.pages - 1) * 50 + 1 : response.results.length;
  378. if (usage < confidencyThreshold) return false;
  379. siteTagsCache[tag] = usage;
  380. Promise.resolve(siteTagsCache).then(cache => { localStorage.setItem('siteTagsCache', JSON.stringify(cache)) });
  381. return tag;
  382. }, reason => false);
  383. })).then(results => results.filter(Boolean));
  384. }
  385.  
  386. function importToBody(bcReleaseDetails, body) {
  387. function insertSection(section, afterBegin = true, beforeLinks = true, beforeEnd = true) {
  388. if (section) if (!body) body = section;
  389. else if (afterBegin && rx.afterBegin.some(rx => rx.test(body)))
  390. body = RegExp.lastMatch + section + '\n\n' + RegExp.rightContext.trimLeft();
  391. else if (beforeLinks && rx.beforeLinks.test('\n' + body))
  392. body = (RegExp.leftContext + '\n\n').trimLeft() + section + '\n\n' + RegExp.lastMatch.trimLeft();
  393. else if (beforeEnd && rx.beforeEnd.some(rx => rx.test(body)))
  394. body = RegExp.leftContext.trimRight() + '\n\n' + section + RegExp.lastMatch.trimLeft();
  395. else body += '\n\n' + section;
  396. }
  397.  
  398. const spamFilter = description => description && (description.length >= 180
  399. && !/\b(?:Buy|Bookings?|(?:Pre)?Orders?|Sales|Purchases?|Store|Tickets|sold out|please visit|available (?:at|on|here)|Physical (?:album|copy|media)|\d+\s+copies|Take a look|Download|Track\s?list(?:ing)?|(?:facebook|soundcloud|bandcamp|twitter|myspace|instagram|residentadvisor|youtube)\.com)\b/im.test(description)
  400. || confirm('Release about supposedly contains spammy content, insert anyway?\n\nAbout excerpt:\n\n' + (maxLength =>
  401. description.length > maxLength ? description.slice(0, maxLength) + '...' : description)(1500)));
  402. body = stripText(body);
  403. const rx = {
  404. afterBegin: [/^\[pad=\d+\|\d+\]/i, /^Releas(?:ing|ed) .+\d{4}$(?:\r?\n){2,}/im],
  405. beforeLinks: /(?:(?:\r?\n)+(?:(?:More info(?:rmation)?:|\[b\]More info(?:rmation)?:\[\/b\])[^\S\r\n]+)?(?:\[url(?:=[^\[\]]+)?\].+\[\/url\]|https?:\/\/\[^\s\[\]]+))+(?:\[\/size\]\[\/pad\])?$/i,
  406. beforeEnd: [/\[\/size\]\[\/pad\]$/i],
  407. };
  408. if (bcReleaseDetails.description && bcReleaseDetails.description.length > 10
  409. && ![ ].some(rx => rx.test(bcReleaseDetails.description))
  410. && GM_getValue('description_insert_about', true) && !body.includes(bcReleaseDetails.description)
  411. && spamFilter(bcReleaseDetails.description))
  412. insertSection('[quote][plain]' + bcReleaseDetails.description + '[/plain][/quote]', true, true, true);
  413. if (document.location.pathname != '/requests.php' && bcReleaseDetails.credits && bcReleaseDetails.credits.length > 10
  414. && ![/^\s*released\b.{0,20}\s*$/i].some(rx => rx.test(bcReleaseDetails.credits))
  415. && GM_getValue('description_insert_credits', true) && !body.includes(bcReleaseDetails.credits))
  416. insertSection('[hide=Credits][plain]' + bcReleaseDetails.credits + '[/plain][/hide]', false, true, true);
  417. if (bcReleaseDetails.url && GM_getValue('description_insert_url', true) && !body.includes(bcReleaseDetails.url))
  418. insertSection('[url=' + bcReleaseDetails.url + ']Bandcamp[/url]', false, false, true);
  419. return body.replace(/(\[\/quote\])(?:\r?\n){2,}/ig, '$1\n');
  420. }
  421.  
  422. function getGroupId(root) {
  423. if (root instanceof HTMLElement) for (let a of root.getElementsByTagName('A')) {
  424. if (a.origin != document.location.origin || a.pathname != '/torrents.php') continue;
  425. a = new URLSearchParams(a.search);
  426. if (a.has('id') && !a.has('action') && (a = parseInt(a.get('id'))) > 0) return a;
  427. }
  428. console.warn('[Cover Inspector] Failed to find group id:', root);
  429. }
  430.  
  431. const urlParams = new URLSearchParams(document.location.search);
  432. const maxOpenTabs = GM_getValue('max_open_tabs', 25), autoCloseTimeout = GM_getValue('tab_auto_close_timeout', 0);
  433. const tabsQueueRecovery = [ ];
  434. let openedTabs = [ ], lastOnQueue;
  435.  
  436. function openTabLimited(endpoint, params, hash) {
  437. function updateQueueInfo() {
  438. const id = 'waiting-tabs-counter';
  439. let counter = document.getElementById(id);
  440. if (counter == null) {
  441. if (tabsQueueRecovery.length <= 0) return;
  442. const queueInfo = document.createElement('DIV');
  443. queueInfo.style = `
  444. position: fixed; left: 10pt; bottom: 10pt; padding: 5pt; z-index: 999;
  445. font-size: 8pt; color: white; background-color: sienna;
  446. border: thin solid black; box-shadow: 2pt 2pt 5pt black; cursor: default;
  447. `;
  448. const tooltip = 'By closing this tab the queue will be discarded';
  449. if (typeof jQuery.fn.tooltipster == 'function') $(queueInfo).tooltipster({ content: tooltip });
  450. else queueInfo.title = tooltip;
  451. counter = document.createElement('SPAN');
  452. counter.id = id;
  453. counter.style.fontWeight = 'bold';
  454. queueInfo.append(counter, ' release group(s) queued to view');
  455. document.body.append(queueInfo);
  456. } else if (tabsQueueRecovery.length <= 0) {
  457. document.body.removeChild(counter.parentNode);
  458. return;
  459. }
  460. counter.textContent = tabsQueueRecovery.length;
  461. }
  462.  
  463. if (typeof GM_openInTab != 'function') return Promise.reject('Not supported');
  464. if (!endpoint) return Promise.reject('Invalid argument');
  465. const saveQueue = () => localStorage.setItem('coverInspectorTabsQueue', JSON.stringify(tabsQueueRecovery));
  466. let recoveryEntry;
  467. if (maxOpenTabs > 0) {
  468. tabsQueueRecovery.push(recoveryEntry = { endpoint: endpoint, params: params || null, hash: hash || '' });
  469. if (openedTabs.length >= maxOpenTabs) updateQueueInfo();
  470. saveQueue();
  471. }
  472. const waitFreeSlot = () => (maxOpenTabs > 0 && openedTabs.length >= maxOpenTabs ?
  473. Promise.race(openedTabs.map(tabHandler => new Promise(function(resolve) {
  474. console.assert(!tabHandler.closed);
  475. if (!tabHandler.closed) tabHandler.resolver = resolve; //else resolve(tabHandler);
  476. }))) : Promise.resolve(null)).then(function(tabHandler) {
  477. console.assert(openedTabs.length <= maxOpenTabs);
  478. const url = new URL(endpoint + '.php', document.location.origin);
  479. if (params) for (let param in params) url.searchParams.set(param, params[param]);
  480. if (hash) url.hash = hash;
  481. (tabHandler = GM_openInTab(url.href, true)).onclose = function() {
  482. console.assert(this.closed);
  483. if (this.autoCloseTimer >= 0) clearTimeout(this.autoCloseTimer);
  484. const index = openedTabs.indexOf(this);
  485. console.assert(index >= 0);
  486. if (index >= 0) openedTabs.splice(index, 1);
  487. else openedTabs = openedTabs.filter(opernGroup => !opernGroup.closed);
  488. if (typeof this.resolver == 'function') this.resolver(this);
  489. }.bind(tabHandler);
  490. if (autoCloseTimeout > 0) tabHandler.autoCloseTimer = setTimeout(tabHandler =>
  491. { if (!tabHandler.closed) tabHandler.close() }, autoCloseTimeout * 1000, tabHandler);
  492. openedTabs.push(tabHandler);
  493. if (maxOpenTabs > 0) {
  494. const index = tabsQueueRecovery.indexOf(recoveryEntry);
  495. console.assert(index >= 0);
  496. if (index >= 0) tabsQueueRecovery.splice(index, 1);
  497. updateQueueInfo();
  498. saveQueue();
  499. }
  500. return tabHandler;
  501. });
  502. return (lastOnQueue = lastOnQueue instanceof Promise ? lastOnQueue.then(waitFreeSlot) : waitFreeSlot());
  503. }
  504.  
  505. const autoOpenSucceed = GM_getValue('auto_open_succeed', true);
  506. const openTabParams = { }, tabData = { torrentGroups: { } };
  507. if (GM_getValue('view_group_presearch_bandcamp', true)) openTabParams['presearch-bandcamp'] = 1;
  508. function openGroup(torrentGroup) {
  509. if (!torrentGroup) throw 'Invalid argument';
  510. if (!(torrentGroup.group.id > 0)) return null;
  511. tabData.torrentGroups[torrentGroup.group.id] = torrentGroup;
  512. GM_saveTab(tabData);
  513. return openTabLimited('torrents', Object.assign({ id: torrentGroup.group.id }, openTabParams));
  514. }
  515.  
  516. // Crash recovery
  517. if ('bcTabsQueue' in localStorage) try {
  518. const savedQueue = JSON.parse(localStorage.getItem('bcTabsQueue'));
  519. if (Array.isArray(savedQueue) && savedQueue.length > 0) {
  520. GM_registerMenuCommand('Restore open tabs queue', function() {
  521. if (!confirm('Process saved queue? (' + savedQueue.length + ' tabs to open)')) return;
  522. for (let queuedEntry of savedQueue) openTabLimited(queuedEntry.endpoint, queuedEntry.params, queuedEntry.hash);
  523. });
  524. GM_registerMenuCommand('Load saved queue for later', function() {
  525. if (confirm('Saved queue (' + savedQueue.length + ' tabs to open) will be prepended to current, continue?'))
  526. tabsQueueRecovery = savedQueue.concat(tabsQueueRecovery);
  527. });
  528. }
  529. } catch(e) { console.warn(e) }
  530.  
  531. function checkSavedRecovery() {
  532. if ('bcTabsQueue' in localStorage) try {
  533. const savedQueue = JSON.parse(localStorage.getItem('bcTabsQueue'));
  534. if (!Array.isArray(savedQueue) || savedQueue.length <= 0) return true;
  535. const unloadedCount = savedQueue.filter(item1 => !tabsQueueRecovery.some(function(item2) {
  536. if (item1.endpoint != item2.endpoint || item1.hash != item2.hash) return false;
  537. if ((item1.params == null) != (item2.params == null)) return false;
  538. return item1.params == null || Object.keys(item1.params).every(key => item2[key] == item1[key])
  539. && Object.keys(item2.params).every(key => item1[key] == item2[key]);
  540. })).length;
  541. if (unloadedCount <= 0) return true;
  542. return confirm('Saved queue (' + (unloadedCount < savedQueue.length ? unloadedCount + '/' + savedQueue.length
  543. : savedQueue.length) + ' tabs to open) will be lost, continue?');
  544. }catch(e) { console.warn(e) }
  545. return true;
  546. }
  547.  
  548. let noEditPerms = document.getElementById('nav_userclass');
  549. noEditPerms = noEditPerms != null && ['User', 'Member', 'Power User'].includes(noEditPerms.textContent.trim());
  550.  
  551. switch (document.location.pathname) {
  552. case '/torrents.php': {
  553. if (noEditPerms) break;
  554. const groupId = parseInt(urlParams.get('id'));
  555. if (!(groupId > 0)) throw 'Invalid group id';
  556. if (typeof GM_getTabs == 'function') GM_getTabs(function(tabs) {
  557. for (let tab in tabs) if ((tab = tabs[tab]) && 'torrentGroups' in tab) try {
  558. if (!(tab = tab.torrentGroups[groupId])) continue;
  559. console.info('Torrent group %d found in tabs data', groupId);
  560. unsafeWindow.torrentGroup = tab;
  561. unsafeWindow.dispatchEvent(Object.assign(new Event('torrentGroup'), { data: tab }));
  562. } catch (e) { console.warn(e) }
  563. });
  564. if (document.querySelector('div.sidebar > div.box_artists') == null) break; // Nothing to do here - not music torrent
  565. const linkBox = document.body.querySelector('div.header > div.linkbox');
  566. if (linkBox == null) throw 'LinkBox not found';
  567. const a = document.createElement('A');
  568. a.textContent = 'Bandcamp import';
  569. a.href = '#';
  570. a.title = 'Import album textual description, tags and cover image from Bandcamp release page (Ctrl+F9)';
  571. a.className = 'brackets';
  572. a.onclick = function(evt) {
  573. if (!this.disabled) this.disabled = true; else return false;
  574. this.style.color = 'orange';
  575. queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup => fetchBandcampDetails(torrentGroup).then(function(bcRelease) {
  576. const updateWorkers = [ ];
  577. if (bcRelease.tags instanceof TagManager && GM_getValue('update_tags', true) && bcRelease.tags.length > 0) {
  578. const releaseTags = Array.from(document.body.querySelectorAll('div.box_tags ul > li'), function(li) {
  579. const tag = { name: li.querySelector(':scope > a'), id: li.querySelector('span.remove_tag > a') };
  580. if (tag.name != null) tag.name = tag.name.textContent.trim();
  581. if (tag.id != null) tag.id = parseInt(new URLSearchParams(tag.id.search).get('tagid'));
  582. return tag.name && tag.id ? tag : null;
  583. }).filter(Boolean);
  584. let bcTags = [ ];
  585. if (torrentGroup.group.musicInfo) for (let importance of Object.keys(torrentGroup.group.musicInfo))
  586. if (Array.isArray(torrentGroup.group.musicInfo[importance]))
  587. Array.prototype.push.apply(bcTags, torrentGroup.group.musicInfo[importance].map(artist => artist.name));
  588. if (Array.isArray(torrentGroup.torrents)) for (let torrent of torrentGroup.torrents) {
  589. if (!torrent.remasterRecordLabel) continue;
  590. const labels = torrent.remasterRecordLabel.split('/').map(label => label.trim());
  591. if (labels.length > 0) {
  592. Array.prototype.push.apply(bcTags, labels);
  593. Array.prototype.push.apply(bcTags, labels.map(function(label) {
  594. const bareLabel = label.replace(/(?:\s+(?:under license.+|Records|Recordings|(?:Ltd|Inc)\.?))+$/, '');
  595. if (bareLabel != label) return bareLabel;
  596. }).filter(Boolean));
  597. }
  598. }
  599. bcTags = new TagManager(...bcTags);
  600. bcTags = Array.from(bcRelease.tags).filter(tag => !bcTags.includes(tag));
  601. if (bcTags.length > 0 && (releaseTags.length <= 0 || !(Number(GM_getValue('preserve_tags', 0)) > 1)))
  602. updateWorkers.push(getVerifiedTags(bcTags, 3).then(function(verifiedBcTags) {
  603. if (verifiedBcTags.length <= 0) return false;
  604. let userAuth = document.body.querySelector('input[name="auth"][value]');
  605. if (userAuth != null) userAuth = userAuth.value; else throw 'Failed to capture user auth';
  606. const updateWorkers = [ ];
  607. const addTags = verifiedBcTags.filter(tag => !releaseTags.map(tag => tag.name).includes(tag));
  608. if (addTags.length > 0) Array.prototype.push.apply(updateWorkers, addTags.map(tag => LocalXHR.post('/torrents.php', new URLSearchParams({
  609. action: 'add_tag',
  610. groupid: torrentGroup.group.id,
  611. tagname: tag,
  612. auth: userAuth,
  613. }), { responseType: null })));
  614. const deleteTags = releaseTags.filter(tag => !verifiedBcTags.includes(tag.name)).map(tag => tag.id);
  615. if (deleteTags.length > 0 && !(Number(GM_getValue('preserve_tags', 0)) > 0))
  616. Array.prototype.push.apply(updateWorkers, deleteTags.map(tagId => LocalXHR.get('/torrents.php?' + new URLSearchParams({
  617. action: 'delete_tag',
  618. groupid: torrentGroup.group.id,
  619. tagid: tagId,
  620. auth: userAuth,
  621. }), { responseType: null })));
  622. return updateWorkers.length > 0 ? Promise.all(updateWorkers.map(updateWorker =>
  623. updateWorker.then(responseCode => true, reason => reason))).then(function(results) {
  624. if (results.some(result => result === true)) return results;
  625. return Promise.reject(`All of ${results.length} tag update workers failed (see browser console for more details)`);
  626. }) : false;
  627. }));
  628. }
  629. const rehostWorker = bcRelease.image && GM_getValue('update_image', true) ?
  630. imageHostHelper.then(ihh => ihh.rehostImageLinks([bcRelease.image])
  631. .then(ihh.singleImageGetter)).catch(reason => bcRelease.image) : Promise.resolve(null);
  632. if (ajaxApiKey) {
  633. let origBody = document.createElement('TEXTAREA');
  634. origBody.innerHTML = torrentGroup.group.bbBody;
  635. const body = importToBody(bcRelease, (origBody = origBody.textContent)), formData = new FormData;
  636. updateWorkers.push(rehostWorker.then(function(rehostedImageUrl) {
  637. const updateImage = rehostedImageUrl != null && (!torrentGroup.group.wikiImage || !GM_getValue('preserve_image', false))
  638. && rehostedImageUrl != torrentGroup.group.wikiImage;
  639. if (updateImage || GM_getValue('update_description', true) && body != origBody.trim()) {
  640. if (updateImage) formData.set('image', rehostedImageUrl);
  641. if (GM_getValue('update_description', true) && body != origBody) formData.set('body', body);
  642. formData.set('summary', 'Additional description/cover image import from Bandcamp');
  643. return queryAjaxAPI('groupedit', { id: torrentGroup.group.id }, formData).then(function(response) {
  644. console.log(response);
  645. return true;
  646. });
  647. } else return false;
  648. }));
  649. } else {
  650. updateWorkers.push(LocalXHR.get('/torrents.php?' + new URLSearchParams({
  651. action: 'editgroup',
  652. groupid: torrentGroup.group.id,
  653. })).then(function({document}) {
  654. const editForm = document.querySelector('form.edit_form');
  655. if (editForm == null) throw 'Edit form not exists';
  656. const formData = new FormData(editForm);
  657. const image = editForm.elements.namedItem('image').value, origBody = editForm.elements.namedItem('body').value;
  658. const body = importToBody(bcRelease, origBody);
  659. return rehostWorker.then(function(resolvedImageUrl) {
  660. const updateImage = resolvedImageUrl != null && (!image || !GM_getValue('preserve_image', false))
  661. && resolvedImageUrl != image;
  662. if (updateImage || GM_getValue('update_description', true) && body != origBody.trim()) {
  663. if (updateImage) formData.set('image', resolved[1]);
  664. if (GM_getValue('update_description', true) && body != origBody) formData.set('body', body);
  665. formData.set('summary', 'Additional description/cover image import from Bandcamp');
  666. return LocalXHR.post('/torrents.php', formData, { responseType: null }).then(status => true);
  667. } else return false;
  668. });
  669. }));
  670. if (document.domain == 'redacted.sh' && !(GM_getValue('red_nag_shown', 0) >= 3)) {
  671. const cpLink = new URL('/user.php?action=edit#api_key_settings', document.location.origin);
  672. let userId = document.body.querySelector('#userinfo_username a.username');
  673. if (userId != null) userId = parseInt(new URLSearchParams(userId.search).get('id'));
  674. if (userId > 0) cpLink.searchParams.set('userid', userId);
  675. alert('Please consider generating your personal API token (' + cpLink.href + ')\nSet up as "redacted_api_key" script storage entry');
  676. GM_setValue('red_nag_shown', GM_getValue('red_nag_shown', 0) + 1 || 1);
  677. // updateWorkers.push(LocalXHR.get('/user.php?' + URLSearchParams({ action: 'edit', userid: userId })).then(function({document}) {
  678. // const form = document.body.querySelector('form#userform');
  679. // if (form == null) throw 'User form not found';
  680. // const formData = new FormData(form), newApiKey = formData.get('new_api_key');
  681. // if (newApiKey) formData.set('confirmapikey', 'on'); else throw 'API key not exist';
  682. // formData.set('api_torrents_scope', 'on');
  683. // for (let name of ['api_user_scope', 'api_requests_scope', 'api_forums_scope', 'api_wiki_scope']) formData.delete(name);
  684. // return LocalXHR.post('/user.php', formData, { responseType: null }).then(status => newApiKey);
  685. // }).then(function(newApiKey) {
  686. // GM_setValue('redacted_api_key', newApiKey);
  687. // alert('Your personal API key [' + newApiKey + '] was successfulloy created and saved');
  688. // return false;
  689. // }));
  690. }
  691. }
  692. if (updateWorkers.length > 0) return Promise.all(updateWorkers.map(updateWorker =>
  693. updateWorker.then(response => Boolean(response), function(reason) {
  694. console.warn('Update worker failed with reason ' + reason);
  695. return reason;
  696. }))).then(function(results) {
  697. if (results.filter(Boolean).length > 0 && !results.some(result => result === true))
  698. return Promise.reject(`All of ${results.length} update workers failed (see browser console for more details)`);
  699. if (results.some(result => result === true)) return (document.location.reload(), true);
  700. });
  701. })).catch(reason => { if (!['Cancelled'].includes(reason)) alert(reason) }).then(status => {
  702. this.style.color = status ? 'springgreen' : null;
  703. this.disabled = false;
  704. });
  705. return false;
  706. };
  707. linkBox.append(' ', a);
  708. document.body.addEventListener('keyup', function(evt) {
  709. if (!evt.ctrlKey || evt.key != 'F9') return true;
  710. a.click();
  711. return false;
  712. });
  713.  
  714. const findSharedTorrentGroup = () => new Promise(function(resolve, reject) {
  715. if ('torrentGroup' in unsafeWindow) if (unsafeWindow.torrentGroup) resolve(unsafeWindow.torrentGroup)
  716. else reject('Assertion failed: void unsafeWindow.torrentGroup');
  717. else {
  718. function listener(evt) {
  719. clearTimeout(timeout);
  720. unsafeWindow.removeEventListener('torrentGroup', listener);
  721. if (evt.data) resolve(evt.data); else if (unsafeWindow.torrentGroup) resolve(unsafeWindow.torrentGroup);
  722. else reject('Assertion failed: void unsafeWindow.torrentGroup');
  723. }
  724.  
  725. unsafeWindow.addEventListener('torrentGroup', listener);
  726. const timeout = setTimeout(function() {
  727. unsafeWindow.removeEventListener('torrentGroup', listener);
  728. reject('torrentGroup monitor timed out');
  729. }, GM_getValue('tab_data_timeout', 2500));
  730. }
  731. });
  732. if (urlParams.has('presearch-bandcamp')) findSharedTorrentGroup().catch(function(reason) {
  733. console.log(reason);
  734. return queryAjaxAPICached('torrentgroup', { id: groupId }, true);
  735. }).then(matchingResultsCount).then(function(matchedCount) {
  736. a.style.fontWeight = 'bold';
  737. a.title += `\n\n${matchedCount} possibly matching release(s)`;
  738. }, reason => { a.style.color = 'gray' });
  739. break;
  740. }
  741. case '/upload.php':
  742. if (urlParams.has('groupid')) break;
  743. case '/requests.php': {
  744. function hasStyleSheet(name) {
  745. if (name) name = name.toLowerCase(); else throw 'Invalid argument';
  746. const hrefRx = new RegExp('\\/' + name + '\\b', 'i');
  747. if (document.styleSheets) for (let styleSheet of document.styleSheets)
  748. if (styleSheet.title && styleSheet.title.toLowerCase() == name) return true;
  749. else if (styleSheet.href && hrefRx.test(styleSheet.href)) return true;
  750. return false;
  751. }
  752. function checkFields() {
  753. const visible = ['0', 'Music'].includes(categories.value) && title.value.length > 0;
  754. if (container.hidden != !visible) container.hidden = !visible;
  755. if (visible && timer != undefined) {
  756. clearInterval(timer);
  757. timer = undefined;
  758. }
  759. }
  760.  
  761. const categories = document.getElementById('categories');
  762. if (categories == null) throw 'Categories select not found';
  763. const form = document.getElementById('upload_table') || document.getElementById('request_form');
  764. if (form == null) throw 'Main form not found';
  765. let title = form.elements.namedItem('title');
  766. if (title != null) title.addEventListener('input', checkFields); else throw 'Title select not found';
  767. const dynaForm = document.getElementById('dynamic_form');
  768. if (dynaForm != null) new MutationObserver(function(ml, mo) {
  769. for (let mutation of ml) if (mutation.addedNodes.length > 0) {
  770. if (title != null) title.removeEventListener('input', checkFields);
  771. if ((title = document.getElementById('title')) != null) title.addEventListener('input', checkFields);
  772. else throw 'Assertion failed: title input not found!';
  773. container.hidden = true;
  774. }
  775. }).observe(dynaForm, { childList: true });
  776. let timer;
  777. if (GM_getValue('watch_input_by_timer', true)) timer = setInterval(checkFields, 1000);
  778. const isLightTheme = ['postmod', 'shiro', 'layer_cake', 'proton', 'red_light', '2iUn3'].some(hasStyleSheet);
  779. if (isLightTheme) console.log('Light Gazelle theme detected');
  780. const isDarkTheme = ['kuro', 'minimal', 'red_dark', 'Vinyl'].some(hasStyleSheet);
  781. if (isDarkTheme) console.log('Dark Gazelle theme detected');
  782. const container = document.createElement('DIV');
  783. container.style = 'position: fixed; top: 64pt; right: 10pt; padding: 5pt; border-radius: 50%; z-index: 999;';
  784. container.style.backgroundColor = `#${isDarkTheme ? '2f4f4f' : 'b8860b'}80`;
  785. const bcButton = document.createElement('BUTTON'), img = document.createElement('IMG');
  786. bcButton.id = 'import-from-bandcamp';
  787. bcButton.style = `
  788. padding: 10px; color: white; background-color: white; cursor: pointer;
  789. border: none; border-radius: 50%; transition: background-color 200ms;
  790. `;
  791. bcButton.dataset.backgroundColor = bcButton.style.backgroundColor;
  792. bcButton.setDisabled = function(disabled = true) {
  793. this.disabled = disabled;
  794. this.style.opacity = disabled ? 0.5 : 1;
  795. this.style.cursor = disabled ? 'not-allowed' : 'pointer';
  796. };
  797. bcButton.onclick = function(evt) {
  798. this.setDisabled(true);
  799. this.style.backgroundColor = 'red';
  800. const torrentGroup = { group: {
  801. name: title.value,
  802. musicInfo: { },
  803. releaseType: parseInt(form.elements.namedItem('releasetype').value),
  804. } };
  805. for (let artist of form.querySelectorAll('input[name="artists[]"]')) {
  806. const importance = ['artists', 'with', 'composers', 'conductor', 'dj', 'remixedby', 'producer']
  807. [parseInt(artist.nextElementSibling.value) - 1];
  808. if (!importance || !(artist = artist.value.trim())) continue;
  809. if (!(importance in torrentGroup.group.musicInfo)) torrentGroup.group.musicInfo[importance] = [ ];
  810. torrentGroup.group.musicInfo[importance].push({ name: artist });
  811. }
  812. let remasterYear = form.elements.namedItem('remaster_year');
  813. if (remasterYear != null) torrentGroup.year = parseInt(form.elements.namedItem('year').value);
  814. else remasterYear = form.elements.namedItem('year');
  815. if (remasterYear != null) torrentGroup.torrents = [{ remasterYear: parseInt(remasterYear.value) }];
  816. fetchBandcampDetails(torrentGroup).then(function(bcRelease) {
  817. const tags = form.elements.namedItem('tags'), image = form.elements.namedItem('image'),
  818. description = form.elements.namedItem('album_desc') || form.elements.namedItem('description');
  819. if (tags != null && bcRelease.tags instanceof TagManager && bcRelease.tags.length > 0 && GM_getValue('update_tags', true)
  820. && (!tags.value || !(Number(GM_getValue('preserve_tags', 0)) > 1))) {
  821. let bcTags = Array.from(form.querySelectorAll('input[name="artists[]"]'), input => input.value.trim()).filter(Boolean);
  822. let labels = form.elements.namedItem('remaster_record_label') || form.elements.namedItem('record_label');
  823. if (labels != null && (labels = labels.value.trim().split('/').map(label => label.trim())).length > 0) {
  824. Array.prototype.push.apply(bcTags, labels);
  825. Array.prototype.push.apply(bcTags, labels.map(function(label) {
  826. const bareLabel = label.replace(/(?:\s+(?:under license.+|Records|Recordings|(?:Ltd|Inc)\.?))+$/, '');
  827. if (bareLabel != label) return bareLabel;
  828. }).filter(Boolean));
  829. }
  830. bcTags = new TagManager(...bcTags);
  831. bcTags = Array.from(bcRelease.tags).filter(tag => !bcTags.includes(tag));
  832. getVerifiedTags(bcTags).then(function(bcVerifiedTags) {
  833. if (bcVerifiedTags.length <= 0) return;
  834. if (Number(GM_getValue('preserve_tags', 0)) > 0) {
  835. const mergedTags = new TagManager(tags.value, ...bcVerifiedTags);
  836. tags.value = mergedTags.toString();
  837. } else tags.value = bcVerifiedTags.join(', ');
  838. });
  839. }
  840. if (image != null && bcRelease.image && GM_getValue('update_image', true) && (!image.value || !GM_getValue('preserve_image', false))) {
  841. image.value = bcRelease.image;
  842. imageHostHelper.then(ihh => { ihh.rehostImageLinks([bcRelease.image])
  843. .then(ihh.singleImageGetter).then(rehostedUrl => { image.value = rehostedUrl }) });
  844. }
  845. if (description != null && GM_getValue('update_description', true)) {
  846. const body = importToBody(bcRelease, description.value);
  847. if (body != description.value) description.value = body;
  848. }
  849. }, reason => { if (!['Cancelled'].includes(reason)) alert(reason) }).then(() => {
  850. this.style.backgroundColor = this.dataset.backgroundColor;
  851. this.setDisabled(false);
  852. });
  853. };
  854. bcButton.onmouseenter = bcButton.onmouseleave = function(evt) {
  855. if (evt.relatedTarget == evt.currentTarget || evt.currentTarget.disabled) return false;
  856. evt.currentTarget.style.backgroundColor = evt.type == 'mouseenter' ? 'orange'
  857. : evt.currentTarget.dataset.backgroundColor || null;
  858. };
  859. bcButton.title = 'Import description, cover image and tags from Bandcamp';
  860. 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
  861. img.width = 32;
  862. bcButton.append(img);
  863. container.append(bcButton);
  864. checkFields();
  865. document.body.append(container);
  866. break;
  867. }
  868. case '/better.php': {
  869. function resolveTorrentRow(tr) {
  870. function setStatus(newStatus, ...addedText) {
  871. let td = tr.querySelector('td.status');
  872. console.assert(td != null); if (td == null) return; // assertion failed
  873. if (typeof newStatus == 'number' && (status == undefined || newStatus < status)) status = newStatus;
  874. if (status == undefined) return;
  875. td.textContent = status > 1 ? 'success' : 'failed';
  876. td.className = 'status ' + td.textContent + ' status-code-' + status;
  877. if (addedText.length > 0) Array.prototype.push.apply(tooltips, addedText);
  878. if (tooltips.length > 0) td.title = tooltips.join('\n'); else td.removeAttribute('title');
  879. //setTooltip(td, tooltips.join('\n'));
  880. td.style.color = ['red', 'orange', '#adad00', 'green'][status];
  881. td.style.opacity = 1;
  882. if (status <= 0) if (autoHideFailed) tr.hidden = true;
  883. else if ((td = document.getElementById('hide-status-failed')) != null) td.hidden = false;
  884. }
  885.  
  886. const groupId = getGroupId(tr), tooltips = [ ];
  887. let status;
  888. if (!(groupId > 0)) return setStatus(0, 'Could not extract torrent id');
  889. const autoHideFailed = GM_getValue('auto_hide_failed', false);
  890. queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup => matchingResultsCount(torrentGroup).then(function(matchingCount) {
  891. setStatus(2, matchingCount.toString() + ' possible match(es) on Bandcamp');
  892. if (autoOpenSucceed) openGroup(torrentGroup);
  893. })).catch(function(reason) {
  894. setStatus(0, reason);
  895. console.log('groupId %d bandcamp lookup fail for the reason ', groupId, reason);
  896. });
  897. }
  898.  
  899. if (noEditPerms || !['notags'].includes(urlParams.get('method'))) break;
  900. const linkBox = document.body.querySelector('div.header > div.linkbox');
  901. console.assert(linkBox != null);
  902. if (linkBox == null) throw 'Linkbox not found';
  903. const a = document.createElement('A');
  904. a.id = 'auto-tags-lookup';
  905. a.className = 'brackets';
  906. a.textContent = 'Auto lookup on Bandcamp';
  907. a.href = '#';
  908. a.onclick = function(evt) {
  909. if (!checkSavedRecovery()) return false;
  910. evt.currentTarget.previousSibling.remove();
  911. evt.currentTarget.remove();
  912. const pager = document.body.querySelector('div.linkbox.pager');
  913. if (pager != null) pager.scrollIntoView({ behavior: 'smooth', block: 'start' });
  914. const div = document.createElement('DIV'), span = document.createElement('SPAN');
  915. div.style = 'position: fixed; top: 10pt; right: 10pt; padding: 3pt; background-color: #2f4f4f8a; color: white; font-weight: 600; border-radius: 5pt; box-shadow: 2px 2px 3px black';
  916. div.id = 'hide-status-failed';
  917. div.hidden = true;
  918. span.textContent = 'Hide failed';
  919. span.style = 'display: inline-block; padding: 5pt; cursor: pointer; transition: color 250ms;';
  920. span.onclick = function(evt) {
  921. evt.currentTarget.parentNode.hidden = true;
  922. for (let td of document.body.querySelectorAll('table.torrent_table > tbody > tr.torrent > td.status.status-code-0'))
  923. td.parentNode.hidden = true;
  924. };
  925. span.onmouseenter = span.onmouseleave = function(evt) {
  926. if (evt.relatedTarget == evt.currentTarget) return false;
  927. evt.currentTarget.style.color = evt.type == 'mouseenter' ? 'orange' : 'white';
  928. };
  929. div.append(span);
  930. document.body.append(div);
  931. document.body.querySelectorAll('table.torrent_table > tbody > tr').forEach(function(tr) {
  932. const td = document.createElement('TD');
  933. tr.append(td);
  934. if (!(tr.classList.contains('torrent'))) return; // assertion failed
  935. td.className = 'status';
  936. td.style = 'opacity: 0.3;';
  937. td.textContent = 'unknown';
  938. resolveTorrentRow(tr);
  939. });
  940. return false;
  941. };
  942. linkBox.append(' ', a);
  943. break;
  944. }
  945. }
  946.  
  947. }