diff --git a/DownloadCollection.user.js b/DownloadCollection.user.js index 98de204..28557e4 100644 --- a/DownloadCollection.user.js +++ b/DownloadCollection.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Bandcamp Download Collection // @namespace https://bandcamp.com -// @version 1.4 +// @version 1.5 // @description Opens the download page for each item in your collection. // @author Ryan Bluth, Xerus2000, bb010g // @match https://bandcamp.com/YOUR_USERNAME @@ -53,6 +53,81 @@ var throttleDownloadInterval = 5; function newMouseEvent(type, view) { return new MouseEvent(type, {bubbles: true, cancelable: true, view: view}); } + // Bandcamp's collection page used to render every item in the DOM; it now ships only + // the first 20 and exposes the rest through the fancollection API. Seed our caches + // from the page's #pagedata blob, then page through the API as needed. + var allItems = {}; + var allRedownloadUrls = {}; + var nextToken = null; + var totalItemCount = 0; + var fanId = null; + + (function seedFromPageData() { + var el = document.querySelector('#pagedata'); + if (!el || !el.dataset.blob) { return; } + var pd; + try { pd = JSON.parse(el.dataset.blob); } catch (err) { return; } + fanId = (pd.fan_data && pd.fan_data.fan_id) || + (pd.current_fan && pd.current_fan.fan_id) || null; + if (pd.item_cache && pd.item_cache.collection) { + Object.assign(allItems, pd.item_cache.collection); + } + if (pd.collection_data) { + Object.assign(allRedownloadUrls, pd.collection_data.redownload_urls || {}); + nextToken = pd.collection_data.last_token || null; + totalItemCount = pd.collection_data.item_count || 0; + } + })(); + + // redownload_urls is keyed by {sale_item_type_char}{sale_item_id} — 'p' for packages, + // 'a' for albums, 't' for tracks. Try the item's own type first, then fall back. + function findDownloadUrl(item) { + if (!item.sale_item_id) { return null; } + var prefixes = [item.sale_item_type, 'p', 'a', 't']; + for (var i = 0; i < prefixes.length; i++) { + if (!prefixes[i]) { continue; } + var url = allRedownloadUrls[prefixes[i] + item.sale_item_id]; + if (url) { return /^https?:/i.test(url) ? url : 'https://bandcamp.com' + url; } + } + return null; + } + + // Bandcamp keys item_cache entries as {type_char}{id}; the API returns items as an + // array, so we have to rebuild the key ourselves before merging. + function itemKey(item) { + var typeChar = item.item_type === 'package' ? 'p' : item.item_type === 'album' ? 'a' : 't'; + var id = item.item_type === 'package' ? item.item_id : item.tralbum_id; + return typeChar + id; + } + + async function fetchAllItems(onProgress) { + var first = true; + while (nextToken) { + // Bandcamp throttles back-to-back requests by returning more_available:false, + // so pace ourselves between batches. + if (!first) { await new Promise(function (r) { setTimeout(r, 150); }); } + first = false; + var prev = nextToken; + var res = await fetch('/api/fancollection/1/collection_items', { + method: 'POST', + credentials: 'same-origin', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({fan_id: fanId, older_than_token: nextToken, count: 20}), + }); + if (!res.ok) { break; } + var data = await res.json(); + if (!data || data.error) { break; } + if (data.items) { + var items = Array.isArray(data.items) ? data.items : Object.values(data.items); + for (var i = 0; i < items.length; i++) { allItems[itemKey(items[i])] = items[i]; } + } + if (data.redownload_urls) { Object.assign(allRedownloadUrls, data.redownload_urls); } + nextToken = (data.more_available === false) ? null : (data.last_token || null); + if (nextToken === prev) { nextToken = null; } + if (onProgress) { onProgress(Object.keys(allItems).length, totalItemCount); } + } + } + var style = css` #bandcamp-greasy { background-color: #1DA0C3; @@ -102,6 +177,12 @@ var throttleDownloadInterval = 5; #bandcamp-greasy .album-infos .album-info input[type="checkbox"]:disabled ~ .item { text-decoration: line-through; } + /* Bandcamp's site CSS leaves our buttons/inputs invisible; force readable colors. */ + #bandcamp-greasy button, #bandcamp-greasy input[type="text"], #bandcamp-greasy input[type="number"] { + background: white !important; + color: #1DA0C3 !important; + border: 1px solid white !important; + } `; var mainContainer = element('div', {id: 'bandcamp-greasy'}, [ @@ -111,16 +192,6 @@ var throttleDownloadInterval = 5; element('button', {className: 'close', textContent: "Close"}, [], {onclick: {value(e) { hideMainContainer(); }}}), - element('button', {className: 'retry-pagination', textContent: "Retry pagination"}, [], {onclick: {value(e) { - var collectionGrid = window.CollectionGrids.collection; - if (collectionGrid.paginating) { - return; - } - if (collectionGrid.error) { - collectionGrid.error = false; - } - collectionGrid.paginate(); - }}}), ]), ]), element('div', {className: 'albums'}, [ @@ -135,63 +206,49 @@ var throttleDownloadInterval = 5; if (e.target.disabled) { return; } e.target.disabled = true; var albumInfos = e.target.parentNode.parentNode.querySelector('.album-infos'); - var collectionItems = document.getElementsByClassName('collection-item-container'); - document.querySelector('#bandcamp-greasy span.status').textContent = "Found the following albums:"; - for (var i = 0; i < collectionItems.length; i++) { - var collectionItem = collectionItems[i]; - if (collectionItem.getElementsByClassName('redownload-item').length === 0) { - continue; // skip non-downloads, i.e. subscriptions - } - var itemDetails = collectionItem.getElementsByClassName('collection-item-details-container')[0]; - var itemLink = collectionItem.getElementsByClassName('item-link')[0]; - var albumTitle = itemDetails.getElementsByClassName('collection-item-title')[0]; - var albumArtist = itemDetails.getElementsByClassName('collection-item-artist')[0]; - var downloadLink = collectionItem.getElementsByClassName('redownload-item')[0].children[0]; + var existingHrefs = new Set(); + for (var existing of albumInfos.querySelectorAll('li > label > a.item')) { existingHrefs.add(existing.href); } + var found = 0; + for (var key in allItems) { + var item = allItems[key]; + if (item.is_subscription_item || item.hidden) { continue; } + var downloadUrl = findDownloadUrl(item); + if (!downloadUrl) { continue; } var includeCheckbox = element('input', {type: 'checkbox'}); - for (var existingItemLink of albumInfos.querySelectorAll('li > label > a.item')) { - if (existingItemLink.href === itemLink.href) { - includeCheckbox.disabled = true; - break; - } - } + if (existingHrefs.has(item.item_url)) { includeCheckbox.disabled = true; } albumInfos.appendChild(element('li', {className: 'album-info'}, [ element('label', {}, [ includeCheckbox, - element('a', {className: 'item', href: itemLink.href, target: '_blank'}, [ - element('span', {className: 'artist', textContent: albumArtist.innerText.substring(3)}), + element('a', {className: 'item', href: item.item_url, target: '_blank'}, [ + element('span', {className: 'artist', textContent: item.band_name || ''}), ' - ', - element('span', {className: 'album', textContent: albumTitle.innerText}), + element('span', {className: 'album', textContent: item.item_title || ''}), ]), ' ', - element('a', {className: 'download', href: downloadLink.href, target: '_blank'}, ['(download)']), + element('a', {className: 'download', href: downloadUrl, target: '_blank'}, ['(download)']), ]), ])); + found++; } + document.querySelector('#bandcamp-greasy span.status').textContent = + "Found " + found + " downloadable album(s) (of " + (totalItemCount || found) + " in collection)."; e.target.disabled = false; }}}), - element('button', {className: 'auto-scan-albums', textContent: "Auto-scan albums"}, [], {onclick: {value(e) { + element('button', {className: 'auto-scan-albums', textContent: "Auto-scan albums"}, [], {onclick: {value: async function (e) { if (e.target.disabled) { return; } e.target.disabled = true; var parent = e.target.parentNode; - parent.parentNode.querySelector('.status').textContent = "Loading albums..."; - document.querySelector('#collection-items .show-more').click(); - - setTimeout(function () { - var scrollInterval = setInterval(function () { - window.scrollTo(0, window.scrollY + 1000); - }, 1); - - var doneInterval = setInterval(function () { - var loadMoreContainer = document.getElementsByClassName('expand-container')[0]; - if (window.getComputedStyle(loadMoreContainer).display === 'none') { - showMainContainer(); - window.clearInterval(scrollInterval); - window.clearInterval(doneInterval); - e.target.disabled = false; - parent.querySelector('.scan-albums').dispatchEvent(newMouseEvent('click', e.view)); - } - }, 2000); - }, 1000); + var status = parent.parentNode.querySelector('.status'); + try { + status.textContent = "Loading albums..."; + await fetchAllItems(function (loaded, total) { + status.textContent = "Loading albums... " + loaded + " / " + total; + }); + showMainContainer(); + parent.querySelector('.scan-albums').dispatchEvent(newMouseEvent('click', e.view)); + } finally { + e.target.disabled = false; + } }}}), ]), element('ol', {className: 'album-infos', start: 0}), @@ -209,11 +266,13 @@ var throttleDownloadInterval = 5; let i = 0; for (let link of e.target.parentNode.parentNode.querySelectorAll('.album-infos .album-info input[type="checkbox"]:enabled:checked ~ a.download')) { window.setTimeout(() => { - window.open(link.href, '_blank'); + window.open(link.href, '_blank'); }, throttleDownloads ? i * throttleDownloadInterval * 1000 : 0); i++; } }}}), + ]), + element('div', {className: 'controls'}, [ element('input', {type: 'text', className: 'download-range range-start', placeholder: "Range start"}), element('input', {type: 'text', className: 'download-range range-end', placeholder: "Range end"}), element('button', {className: 'download-range', textContent: "Download range"}, [], {onclick: {value(e) {