Greasy Fork is available in English.

Bandcamp Helper

Improve downloading of discographies with the addition of an item count and total size.

// ==UserScript==
// @name         Bandcamp Helper
// @namespace    V.L
// @version      1.2.4
// @description  Improve downloading of discographies with the addition of an item count and total size.
// @author       Valerio Lyndon
// @match        https://bandcamp.com/download*
// @match        https://*.bandcamp.com/*
// @grant        none
// @license      AGPL-3.0-or-later
// ==/UserScript==

const debug = false;

const url = new URL(window.location);

function style( css ){
	const element = document.createElement('style');
	element.textContent = css;
	document.documentElement.append(element);
}

class MassDownload {
	constructor( ){
		this.wrapper = document.createElement('li');
		this.paragraph = document.createElement('p');
		this.wrapper.append(this.paragraph);
		document.querySelector('.download_list').prepend(this.wrapper);
		this.dropdowns = document.querySelectorAll('select#format-type');
		for( let dropdown of this.dropdowns ){
			dropdown.addEventListener('change', ()=>{
				this.calculateBytes();
			});
		}
		this.calculateBytes();
	}

	calculateBytes( ){
		let totalBytes = 0;
		let totalItems = this.dropdowns.length;
		for( let dropdown of this.dropdowns ){
			dropdown.addEventListener('change', this.calculateBytes);
			let selected = false;
			for( let opt of dropdown.getElementsByTagName('option') ){
				if( opt && opt.selected ){
					selected = opt;
				}
			}
			if( !selected ){
				console.log('skipping 1 entry due to unknown format');
				continue;
			}
			let match = selected.textContent.match(/([\d\.]+)([A-Za-z][bB])/);
			let bytes = Number(match[1]);
			let byteFormat = match[2];
			switch( byteFormat.toUpperCase() ){
				case 'TB':
					bytes *= 1024;
				case 'GB':
					bytes *= 1024;
				case 'MB':
					bytes *= 1024;
				case 'KB':
					bytes *= 1024;
			}

			totalBytes += bytes;
		}

		function formatBytes( bytes ){
			let format = 'B';
			if( bytes / 1024 >= 1 ){
				bytes /= 1024;
				format = 'KiB';
			}
			if( bytes / 1024 >= 1 ){
				bytes /= 1024;
				format = 'MiB';
			}
			if( bytes / 1024 >= 1 ){
				bytes /= 1024;
				format = 'GiB';
			}
			if( bytes / 1024 >= 1 ){
				bytes /= 1024;
				format = 'TiB';
			}
			// round to two decimal places
			bytes = Math.round(bytes*100) / 100;
			return `${bytes}${format}`;
		}

		this.paragraph.textContent = `Total download size for ${totalItems} items of selected quality is ${formatBytes(totalBytes)}`;
	}
}

class Discography {
	constructor( ){
		this.items = Array.from(document.getElementsByClassName('music-grid-item'));

		style(`
			.vl-price-tag {
				position: absolute;
				bottom: 4px;
				right: 4px;
				padding: 2px;
				background: rgba(0,0,0,0.7);
				border-radius: 2px;
				color: #fff;
				font-weight: bold;
				text-shadow: 0 0 5px rgb(0,0,0,0.7);
			}
		`);

		for( let item of this.items ){
			const cached = window.sessionStorage.getItem(`price-${item.dataset.itemId}`);
			let tag = document.createElement('span');
			tag.className = `vl-price-tag`;
			tag.textContent = cached !== null ? cached : '...';
			item.getElementsByClassName('art')[0].append(tag);
			if( cached === null ){
				item.addEventListener('mouseenter', ()=>{ this.assignPrice(item) });
			}
		}

		const grid = document.getElementById('music-grid');
		grid.style.marginTop = '15px';

		const loadAllBtn = document.createElement('a');
		loadAllBtn.href = "#";
		loadAllBtn.textContent = 'Load all prices.';
		loadAllBtn.addEventListener('click', ()=>{ this.lazyLoadItems(0); });
		grid.insertAdjacentElement('beforebegin', loadAllBtn);

		const clearCacheBtn = document.createElement('a');
		clearCacheBtn.href = "#";
		clearCacheBtn.textContent = 'Clear price cache.';
		clearCacheBtn.addEventListener('click', ()=>{ this.clearCache(); });
		if( debug ){
			grid.insertAdjacentElement('beforebegin', clearCacheBtn);
		}
	}

	async lazyLoadItems( index ){
		const item = this.items[index];
		let wait = await this.assignPrice(item);

		if( wait ){
			const delay = 50*(1+(index*0.15));
			setTimeout(()=>{
				this.lazyLoadItems(index+1);
			}, delay);
		}
		else {
			this.lazyLoadItems(index+1);
		}
	}

	async assignPrice( item ){
		if( item.dataset.priced ){
			return false;
		}
		const url = item.getElementsByTagName('a')[0].href;
		const price = await this.getPrice(url);
		let tag = item.querySelector('.vl-price-tag');
		tag.textContent = price;
		window.sessionStorage.setItem(`price-${item.dataset.itemId}`, price);
		item.dataset.priced = true;
		return true;
	}

	async getPrice( url ){
		let price = 'unknown price';
		let previousQuantity = 99999;
		try {
			const page = await fetch(url);
			const text = await page.text();
			const parser = new DOMParser();
			const dom = parser.parseFromString(text, 'text/html');

			const buyOptions = dom.querySelectorAll('.buyItem:not(.buyFullDiscography):not(.subscribeLink)');
			if( buyOptions.length === 0 ){
				price = 'not for sale';
			}
			for( let option of buyOptions ){
				const link = option.querySelector('h4 .buy-link');
				const detail = link ? link.nextElementSibling : null;

				if( option.textContent.includes('Free') ){
					price = 'free';
					break;
				}
				else if( detail === null && dom.querySelector('.buyItem:not(.buyFullDiscography):not(.subscribeLink) .you-own-this') ){
					price = 'owned';
					break;
				}
				else if( detail && detail.childElementCount > 0 ){
					const quantity = detail.querySelector('.base-text-color').textContent;
					const rawQuantity = parseInt(quantity.replaceAll(/\D+/g,''));
					if( rawQuantity < previousQuantity ){
						previousQuantity = rawQuantity;
						const currency = detail.querySelector('.secondaryText').textContent;
						price = `${quantity} ${currency}`;
					}
				}
				else if( detail && detail.className.includes('buyItemExtra') ){
					price = 'name your price';
					break;
				}
			}
		}
		catch {
			false;
		}
		return price;
	}

	clearCache( ){
		for( var i = 0; i < sessionStorage.length; i++ ){
			const key = sessionStorage.key(i);
			if( key.startsWith('price-') ){
				sessionStorage.removeItem(key);
				i--;
			}
		}
	}
}


// Download pages
if( url.pathname.startsWith('/download') ){
	document.querySelector('.bfd-download-dropdown').addEventListener('click', ()=>{ new MassDownload(); });
}

// Discographies
if( (url.hostname.match(/\./g) || []).length === 2 && /^\/$|^\/music\/?$/.test(url.pathname) ){
	new Discography();
}