Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 113 additions & 54 deletions DownloadCollection.user.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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'}, [
Expand All @@ -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'}, [
Expand All @@ -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}),
Expand All @@ -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) {
Expand Down