squeek502 / Bandcamp Collection Filters

// ==UserScript==
// @name Bandcamp Collection Filters
// @version 1.0.5
// @description List items in a collection or wishlist that match certain filters (free, in common, etc)
// @namespace 289690-squeek502
// @license 0BSD
// @match http*://bandcamp.com/*
// @include http*://bandcamp.com/*
// @grant GM_xmlhttpRequest
// ==/UserScript==

if (!document.querySelector('#collection-grid') || !document.querySelector('#wishlist-grid'))
  return;

var collectionSummary;
var pageData;
var isOwner = document.querySelector('#fan-banner').classList.contains('owner');
var LOAD_URL_FORMAT = "https://bandcamp.com/api/fancollection/1/{}_items";

var pageTypes = ['collection', 'wishlist'];
var buttonTypes = ['free', 'purchased', 'wishlisted'];

var inCommonSeparator = document.createElement('span');
inCommonSeparator.style.color = '#828282';
inCommonSeparator.style.marginRight = '16px';
inCommonSeparator.textContent = 'in common:';
var separatorsAfter = {'free': inCommonSeparator};

var buttons = {};
var results = {};
var started = {};

pageTypes.forEach(function(type) {
  buttons[type] = {};
  results[type] = {};

  var buttonContainer = document.createElement('div');
  buttonContainer.style.marginTop = '0px';
  buttonContainer.classList.add('wishlist-controls');
  buttonContainer.classList.add('owner-controls');

  var grid = document.querySelector('#'+type+'-grid');
  var itemsContainer = grid.querySelector('#'+type+'-items-container') || grid.querySelector(':scope > .inner');
  // not all collection pages always have both a collection and a wishlist, so bail if this fails
  if (!itemsContainer) {
    return;
  }
  var items = itemsContainer.querySelector('#'+type+'-items');

  var resultContainer = document.createElement('div');

  buttonTypes.forEach(function(button) {
    var buttonElement = document.createElement('a');
    buttonElement.style.marginRight = '16px';
    buttonElement.innerHTML = button;
    buttonContainer.appendChild(buttonElement);

    var list = document.createElement('ul');
    list.style.display = 'none';
    resultContainer.appendChild(list);

    buttons[type][button] = buttonElement;
    results[type][button] = list;

    if (separatorsAfter[button]) {
      buttonContainer.appendChild(separatorsAfter[button].cloneNode(true));
    }
  });

  itemsContainer.insertBefore(buttonContainer, items);
  itemsContainer.insertBefore(resultContainer, items);
});

var onSummaryError = function(errmsg) {
  pageTypes.forEach(function(type) {
    buttonTypes.forEach(function(button) {
      // free doesn't depend on summary
      if (button == 'free') return;
      results[type][button].innerHTML = errmsg;
    });
  });
};

var handleItems = function(items, type) {
  items.forEach(function(item) {
    var isFree = item.price === 0;
    var lookupKey = item.tralbum_type + "" + item.tralbum_id;
    var tralbum = collectionSummary && collectionSummary.tralbum_lookup[lookupKey];
    var isPurchased = tralbum !== undefined && tralbum.purchased !== undefined && tralbum.purchased;
    var isWishlisted = tralbum !== undefined && !tralbum.purchased;
    if (!(isFree || isPurchased || isWishlisted))
      return;

    var li = document.createElement('li');
    var a = document.createElement('a');
    a.href = item.item_url;
    a.textContent = item.band_name + ' - ' + item.item_title;
    li.appendChild(a);

    if (isFree) {
      results[type].free.appendChild(li.cloneNode(true));
    }
    if (isPurchased) {
      results[type].purchased.appendChild(li.cloneNode(true));
    }
    if (isWishlisted) {
      results[type].wishlisted.appendChild(li.cloneNode(true));
    }
  });
  buttonTypes.forEach(function(button) {
     buttons[type][button].textContent = button + " (" + results[type][button].childElementCount + ")";
  });
};

var get = function(url, cb) {
  var opts = {
    method: 'GET',
    url: url,
    onload: function (res) {
      cb(res.status, res.responseText, res.finalUrl || url);
    }
  };
  GM_xmlhttpRequest(opts);
};

var post = function(url, data, cb) {
  var opts = {
    method: 'POST',
    url: url,
    data: data,
    headers: {
      "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
    },
    onload: function (res) {
      cb(res.status, res.responseText, res.finalUrl || url);
    }
  };
  GM_xmlhttpRequest(opts);
};

var getNext = function(fan_id, older_than_token, type) {
  var url = LOAD_URL_FORMAT.replace('{}', type);
  post(url, '{"fan_id":'+fan_id+',"older_than_token":"'+older_than_token+'","count":40}', function(status, res, url) {
    if (status != 200) {
      console.error("failed to get next " + type, status, res, url);
      return;
    }
    var parsed = JSON.parse(res);
    if (parsed.error) {
      console.error("error when getting next " + type, parsed.error_message, parsed);
      return;
    }
    handleItems(parsed.items, type);
    if (parsed.more_available) {
      // we should be able to use parsed.last_token here, but there is currently a bug
      // in the collection_items endpoint in that it always gives the 20th item's token
      // in last_token even if count is different than 20, so we need to get the actual
      // last item's token
      var last_token = parsed.items[parsed.items.length - 1].token;
      getNext(fan_id, last_token, type);
    }
  });
};

var setState = function(type, button, state) {
  if (state) {
    // hide all of the same type
    buttonTypes.forEach(function(other) {
      if (other == button) return;
      setState(type, other, false);
    });
    results[type][button].style.display = 'block';
    buttons[type][button].style.fontWeight = 'bold';
    buttons[type][button].style.textDecoration = 'underline';
  } else {
    results[type][button].style.display = 'none';
    buttons[type][button].style.fontWeight = 'normal';
    buttons[type][button].style.textDecoration = 'none';
  }
};

var getState = function(type, button) {
  return results[type][button].style.display != 'none';
};

var onclick = function(type, button, e) {
  if (!started[type]) {
    if (!pageData) {
      pageData = JSON.parse(document.querySelector('#pagedata').getAttribute('data-blob'));
    }
    var start = function() {
      var now = Math.floor(Date.now()/1000);
      var nowToken = pageData[type+'_data'].last_token.replace(/^\d+/, now);
      getNext(pageData.fan_data.fan_id, nowToken, type);
    };
    if (!collectionSummary) {
      get('https://bandcamp.com/api/fan/2/collection_summary', function(status, res, url) {
        if (status != 200) {
          console.warn("unexpected response from " + url, status, res);
          onSummaryError("unexpected http status code: " + status);
        }
        var parsedRes = JSON.parse(res);
        if (parsedRes.error) {
          onSummaryError(parsedRes.error_message);
        } else {
          collectionSummary = parsedRes.collection_summary;
        }
        start();
      });
    } else {
      start();
    }
    setState(type, button, true);
    started[type] = true;
  } else {
    setState(type, button, !getState(type, button));
  }
};
pageTypes.forEach(function(type) {
  buttonTypes.forEach(function(button) {
    buttons[type][button].addEventListener("click", onclick.bind(null, type, button));
  });
});