By kepp
Has 11 other scripts.
// ==UserScript==
// @name Last.fm - Artist Fan Rank
// @namespace http://lastfm.artist.fan.rank/kepp
// @description Adds fan rank information to artist, track, and album charts
// @include http://www.last.fm/user/*
// @include http://www.last.fm/music/*
// @include http://www.last.fm/group/*
// ==/UserScript==
/**
* hello ^_^
* ----------------------------------------------
* v2.2.1
* - Changed incompatible Firefox version alert message
*
* v2.2 (3/30/2007)
* - Stopped using for each...in to iterate over arrays (make sure we're
* doing the items in order)
* - Use JSON compatible format to store cache data
* - Site update fix
* - Some shortcut key fixes
* - Small performance improvement
*
* v2.1.3 (02/25/2007)
* - Fixed preferences not showing their correct state
* - Modified cache saving to reduce load
* (the cache string is updated immediately)
* - Page being processed is now indicated in the menu in the page queue
* item
* - Fix for bad data being put in the page queue
* - Fix for batch size being set to 0
* (this would've locked up the script)
* - Removed auto batch size reset that I had in for testing
* (this made adjust the batch size useless)
*
* v2.1.2 (02/21/2007)
* - Filtered out playlist charts from processing
* - Processes only one page at a time now
* (added a menu item to 'reset page queue' if this breaks)
* - Added menu item to adjust batch size
* (number of items processed in one go)
* - Also has a dumb batch-size auto adjustment
* - Split up cache parsing more finely
* - Fix for keyboard toggling of cache deletion
* - Fix for site update
* - Added/fixed star image for weekly charts
*
* v2.1.1 (02/11/2007)
* - Fix for prefs not being toggled the first time
* - Fix for prefs menu not selecting the correct item after one is
* clicked
* - Removed broken support for versions of Firefox < 2.0
* - Split up cache processing
* - Update for site changes
*
* v2.1 (1/29/2007)
* - Migrated everything to the persistent storage system
* - Re-write/re-oranization to try and improve performance
* - Fix for some errors appearing if you're not logged in
*
* v2.0 (12/23/2006)
* - Small speed improvement for script load
* - Cache is saved after each section is completed
* - Fix for new charts with commas in the playcount
*
* v1.9.2 (12/13/2006)
* Are these new problems from the site update? o.O Can't believe I
* didn't notice them before
* - Removed fan rank color change on hover in charts
* - Fix for charts being processed twice on artist pages
* - Fix for position not being colored on artist pages
* - Repositioned stars so they're not at the very left
* - Fix for rank label toggle being immediately observed on artist pages
* - Fix for script not working on individual track pages
*
* v1.9.1 (12/08/2006)
* - Fix for messed up fan rank on items with RTL text
*
* v1.9 (11/25/2006)
* - Adds menu items only when there is something to process on the page
* - Fan rank is immediately updated/removed if you've dropped from the
* fan chart
* - Improved caching system (persistent storage system used in FF 2.0,
* ability to erase the cache, ...)
* - Menu cleanup, also added 'om' keyboard shortcut to show menu
* - When updating, the difference the old and new rank is also shown
* - Fix for album rankings
* - Moved tooltip location on charts
*
* v1.8.2 (10/31/2006)
* - Update for new site
* - Cleanup in script (removed unused images)
* - Fixed breakage on artist page "Listen Now" sections
* - Fix for play count not being captured
* - Timestamp is pushed ahead when updating because of play count change
* to prevent early re-updating
* - Changed so that it does both "Top Tracks" charts on artist pages
* (Last Week, Last Six Months charts)
* - Switched to using array's built-in forEach method
*
* v1.8.1 (9/26/2006)
* - CSS tweaks, rank label color is changed when the row color changes
* - Fix for album track listings with track preview link
* - Chart changes compatibility fix for toggling rank label
*
* v1.8 (9/19/06)
* - Fix to get it working with album charts again
* - Added some support for album track listings
* - Fixed pref toggling not updating the page
* - Items with no fan data are no longer rechecked every time
* - Added option to color the position
* - Added color change on hover for fan rank in artist page headers
*
* v1.7.1 (9/15/06)
* - Fixed the CSS added (no more repeating stars on red theme)
* - Changed the color on hover to white
* - Sharpened up the images used
*
* v1.7 (9/14/06)
* - Gets track fan rank data from webservices feed now
* - Compatible with new (beta) charts
*
* v1.6 (8/22/06)
* - Converted some stuff to XPath and E4X (yay fun lol)
* - Now handles rankings for tracks and albums
* - Adjusted the colors a bit
*
* v1.5 (8/02/06)
* - Added support for chart changes script using data: URIs
* (thanks to AliAlcoholic for adding this)
* - Fix so that artists beyond the top 50 have stars added to them
* - Fix so that it works on charts with image sidebars
* (thanks to AdriaNnLA for a helping hand here)
*
* v1.4 (7/30/06)
* - Made it more compatible with the quickfind script
* - Made it more compatible with the chart changes script
* - Auto-getting of fan rank data can be turned off from the menu
* - Menu option to "get fan rank data now"
* (this DOES NOT override the normal decision of whether to used
* cached data)
*
* v1.3 (7/20/06)
* - Really fixed for users with spaces in their usernames, not just when
* viewing those user's pages
* - An artist is tried again later if there was an empty response the
* first time
*
* v1.2 (7/18/06)
* - Added some theme support, black/red, etc
* - Some reorganization and cleanup
* - Changed some of the colors
* (anyone got any suggestions for colors?)
* - Fix for user's with spaces, etc in their usernames
* - Improved error handling
*
* v1.1 (updated minutes after v1.0)
* - Does all artist charts in the 'charts' section
*
* v1.0 (initial release)
* Tried adding all the stuff I could think of
* - Caches the rankings for each user + artist combo
* - Does the color change on hover thing for the fan rank
* (inspired by lednerg's version of the percentages script)
* - Changes the color of the artist being checked
* (this one inspired by the "top fan hilighter" script)
* - Also puts in a red star for "top fan" artists
* ("top fan hilighter" inspiration again)
* - Limits the number of artists checked simultaneously to 1
* - Menu commands to toggle display of the star and rank
**
* kepp on last.fm, message me if you see something wrong o_o;
* or have suggestions
* oh, and join the 'greasemonkeys' group :D
**/
// set up functions for storing, getting, and deleting values
var setItem, getItem, removeItem;
try {
var gs = globalStorage.namedItem("last.fm.artist.fan.rank." +
document.domain);
setItem = function(key, val) {
gs.setItem(key, val);
};
getItem = function(key, def) {
var item = gs.getItem(key);
return (item) ? item.value : def;
};
removeItem = function(key) {
gs.removeItem(key);
};
} catch (e) {
var msg = document.createElement("div");
msg.innerHTML = "Last.fm - Artist Fan Rank:<br/>" +
"Please install an updated version of Firefox<br/>" +
"Update Firefox or disable script to remove this alert";
msg.style.position = "absolute";
msg.style.top = "0";
msg.style.right = "0";
document.body.appendChild(msg);
}
// = xpath functions to grab page elements ==================================//
function $xa(query, context) {
var nodes = document.evaluate(query, (context || document),
null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
var nodeArray = new Array();
var node;
while (node = nodes.iterateNext()) {
nodeArray.push(node);
}
return nodeArray;
}
function $xf(query, context) {
return document.evaluate(query, (context || document), null,
XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
}
// number of items to process in one go
// used when grabbing the items from charts
// and when applying cached data and parsing cache
var batch_size = getBatchSize();
function getBatchSize() {
if (getItem("batch-size", "")) {
// this can't be set to zero anyway...
return Math.max(parseInt(getItem("batch-size")), 1);
} else {
var size = Math.floor(100/(Math.max(2, getItem("time", "4"))));
return Math.max(size, 1);
}
}
var pages = {
active: null, // whether this page is at the top of the page queue
length: null, // the number of pages in the queue
id: null, // the id for this page in the page queue
init: function() {
this.id = (new Date().getTime());
setInterval(function() {
try { // using the persisten storage system breaks sometimes
pages.length = getItem("page-queue", "")
.split("\n").length - 1;
} catch (e) {
}
}, 1000);
},
getLength: function() {
return pages.length;
},
addPage: function() {
var activeRe = new RegExp("^" + this.id + "\n");
setItem("page-queue", getItem("page-queue", "") + this.id + "\n");
this.length = getItem("page-queue", "").split("\n").length - 1;
this.active = (this.length == 1);
},
removePage: function() {
var re = new RegExp(this.id + "\n", "g");
var activeRe = new RegExp("^" + this.id + "\n");
setItem("page-queue", getItem("page-queue", "").replace(re, ""));
this.length = getItem("page-queue", "").split("\n").length - 1;
this.active = activeRe.test(getItem("page-queue", ""));
},
clear: function() {
setItem("page-queue", "");
pages.length = getItem("page-queue", "").split("\n").length - 1;
}
};
// = stuff for setting/applying prefs =======================================//
var preferences = {
menu: null, // the menu box dom node
list: null, // a list of the menu items to create
listItems: null, // dom nodes for items in the menu
active: null, // the <a> for the selected item
selected: 0, // the number of the selected item
init: function() {
var menu = document.createElement("div");
menu.id = "gm_fan_rank_menu";
menu.style.visibility = "hidden";
document.body.appendChild(menu);
this.menu = menu;
var p = this;
var o = function(opt) { return function() {
return p.getOption(opt); }};
var size = function() { return batch_size; };
var sq = function() { queueManager.startQueues(true, 0); };
this.list =
[["", "Get fan rank data now", sq ],
["Add top fan image: ", o("add_top_fan_image"), p.toggleTopFanImage ],
["Color top fan position: ", o("color_top_fan_position"), p.togglePositionColor],
["Add fan rank label: ", o("add_rank_label"), p.toggleRankLabel ],
["Auto-get fan rank data: ", o("auto_get_fan_rank"), p.toggleAutoGet ],
["Reset page queue: ", pages.getLength, pages.clear ],
["Batch size: ", size, p.setBatchSize ],
["", "Delete cache", p.confirmErase ]];
pages.watch("length", function(prop, oldVal, newVal) {
if (p.listItems && p.listItems[5]) { // test if the menu is showing
p.listItems[5].innerHTML = (pages.active) ?
"[active]" : newVal;
}
return newVal;
});
},
getOption: function(pref) {
return (getItem(pref, "true") == "true") ? "yes" : "no";
},
showMenu: function() {
var menu = this.menu;
menu.innerHTML = "";
menu.appendChild(this.createClose());
var items = document.createElement("ul");
this.listItems = new Array();
for (var i = 0, pref; pref = this.list[i]; i++) {
var item = this.createItem(pref);
items.appendChild(item);
}
document.body.appendChild(menu);
menu.appendChild(items);
menu.style.top = screen.availHeight/2 - menu.offsetHeight + "px";
menu.style.left = screen.availWidth/2 - menu.offsetWidth/2 + "px";
this.selectItem(0);
menu.style.visibility = "visible";
},
hideMenu: function() {
this.listItems = null;
this.menu.style.visibility = "hidden";
this.menu.innerHTML = "";
},
createClose: function() {
var row = document.createElement("div");
row.style.textAlign = "right";
row.style.height = "0";
var close = document.createElement("a");
close.innerHTML = "\u22A0";
close.title = "Close (esc)"
close.style.textDecoration = "none";
close.style.backgroundColor = "transparent";
close.href = "javascript:;";
close.addEventListener("click", function() {
preferences.hideMenu(); }, false);
row.appendChild(close);
return row;
},
createItem: function(pref) {
var link = document.createElement("a");
link.innerHTML = (typeof(pref[1]) == "function") ? pref[1]() : pref[1];
link.href = "javascript:;";
link.setAttribute("index", this.listItems.length);
link.addEventListener("click", function() {
preferences.active.setAttribute("selected", "false");
this.setAttribute("selected", "true");
preferences.active = this;
preferences.selected = Number(this.getAttribute("index"));
pref[2].call(preferences, link); }, false);
if (pref[0] != null) {
var item = document.createElement("li");
item.innerHTML = pref[0];
item.appendChild(link);
}
this.listItems.push(link);
return (item || link);
},
toggleAutoGet: function() {
this.toggleValue("auto_get_fan_rank", this.active);
queueManager.startQueues(false, 0); // kickstart it
},
toggleTopFanImage: function() {
var rows = $xa("/html/body/div[1]/div[@id='LastContent']" +
"/div[1]/div//table/tbody/tr[@class]");
var starPref = (getItem("add_top_fan_image", "true") == "true");
for (var i = 0, row; row = rows[i]; i++) {
this.toggleClassName(row, "gm_fan_rank_top_fan_star",
"gm_fan_rank_top_fan_star_off",
!starPref);
}
this.toggleValue("add_top_fan_image", this.active);
},
toggleRankLabel: function() {
var labels = $xa("/html/body/div[1]/div[@id='LastContent']" +
"/div[1]/div//table/tbody/tr[@class]" +
"/td[@class='subject']//span/span");
var labelPref = (getItem("add_rank_label", "true") == "true");
for (var i = 0, label; label = labels[i]; i++) {
label.style.display = !labelPref ? "inline" : "none";
}
this.toggleValue("add_rank_label", this.active);
},
togglePositionColor: function() {
var rows = $xa("/html/body/div[1]/div[@id='LastContent']" +
"/div[1]/div//table/tbody/tr[@class]");
var colorPref = (getItem("color_top_fan_position", "true") == "true");
for (var i = 0, row; row = rows[i]; i++) {
this.toggleClassName(row, "gm_fan_rank_top_fan_position",
"gm_fan_rank_top_fan_position_off",
!colorPref);
}
this.toggleValue("color_top_fan_position", this.active);
},
toggleClassName: function(row, first, second, dir) {
var oldClass = row.className;
var newClass = (dir) ?
oldClass.replace(second, first) :
oldClass.replace(first, second);
row.className = newClass;
},
toggleValue: function(pref, toggle) {
var newValue = !(getItem(pref, "true") == "true");
toggle.innerHTML = (newValue) ? "yes" : "no";
setItem(pref, String(newValue));
},
confirmErase: function(link) {
if (link.previousSibling) { return; }
var confirm = document.createElement("span");
var yes = this.createItem([null, "yes", this.deleteCache]);
var no = this.createItem([null, "no", this.cancelDelete]);
confirm.innerHTML = ": ";
confirm.appendChild(yes);
confirm.appendChild(document.createTextNode(" / "));
confirm.appendChild(no);
link.parentNode.insertBefore(confirm, link.nextSibling);
},
deleteCache: function(yes) {
cache.erase();
var confirm = yes.parentNode;
var verify = document.createTextNode(": cache deleted");
confirm.parentNode.replaceChild(verify, confirm);
var listItems = this.listItems;
this.selectItem(7);
listItems.splice(listItems.length - 2, listItems.length);
},
cancelDelete: function(no) {
var confirm = no.parentNode;
confirm.parentNode.removeChild(confirm);
var listItems = this.listItems;
this.selectItem(7);
listItems.splice(listItems.length - 2, listItems.length);
},
setBatchSize: function(link) {
// meh, pressing enter to change the batch size will call this again
if (!link.parentNode) {
return;
}
var input = document.createElement("input");
input.id = "gm_fan_rank_batch_size";
input.type = "text";
input.style.width = "3em";
input.value = link.textContent;
link.parentNode.replaceChild(input, link);
var num = this.selected;
this.listItems[num] = input;
input.select();
var restoreLink = function() {
input.parentNode.replaceChild(link, input);
preferences.listItems[num] = link;
}
input.addEventListener("keydown", function(event) {
var key = event.keyCode;
if (key == 13) { // enter
var size = parseInt(event.target.value);
if (!isNaN(size) && size > 0) {
setItem("batch-size", String(size));
batch_size = size;
link.innerHTML = size;
restoreLink();
preferences.selectItem(num);
}
} else if (key > 36 && key < 41) { // arrow keys
restoreLink();
link.setAttribute("selected", "false");
} else if (key == 27) { // esc
restoreLink();
}
}, false);
},
selectItem: function(num) {
var listItems = this.listItems;
listItems[this.selected].setAttribute("selected", "false");
listItems[num].setAttribute("selected", "true");
if (listItems[num].select) {
listItems[num].select();
}
this.selected = num;
this.active = listItems[num];
},
selectNext: function() {
if (this.listItems) {
var curr = this.selected;
var next = (curr < this.listItems.length - 1) ? curr + 1 : curr;
this.selectItem(next);
}
},
selectPrevious: function() {
if (this.listItems) {
var curr = this.selected;
var prev = (curr > 0) ? curr - 1 : curr;
this.selectItem(prev);
}
},
execute: function() {
if (this.listItems) {
var event = document.createEvent("MouseEvents");
event.initMouseEvent("click", true, true, window, 0, 0, 0, 0, 0,
false, false, false, false, 0, null);
var canceled = this.listItems[this.selected].dispatchEvent(event);
}
}
};
// add command to show script preferences menu to the browser menu
function addMenuItems() {
GM_registerMenuCommand("Artist Fan Rank Menu",
function() { preferences.showMenu(); },
"", "", "m");
}
// = stuff that does all the work ===========================================//
var queueManager = {
queues: null,
headers: null,
user: "",
re: null,
ready: null,
init: function (headers, user) {
this.headers = headers;
this.user = user;
this.queues = new Array();
pages.watch("active", function(prop, oldVal, newVal) {
if (newVal) {
queueManager.startQueues(false, 0);
}
return newVal;
});
},
start: function() {
if (this.headers.length) {
var func = (this.headers.length > 1) ?
function() { queueManager.start() } :
function() { queueManager.ready = true;
queueManager.startQueues(false, 0); };
var callback = function() { setTimeout(func, 0); };
var queue = new Queue(this.headers.shift(), this.user, callback);
this.queues.push(queue);
}
},
startQueues: function(override, next) {
// make sure this is the active page and all the queues have been
// initialized
if (!pages.active && !this.ready) {
return;
}
if (next < this.queues.length) {
var callback = function() { setTimeout(function() {
queueManager.startQueues(override, next + 1);
}, 1000); };
this.queues[next].start(override, callback);
} else {
pages.removePage();
}
},
stop: function() {
for (var i = 0, queue; queue = this.queues[i]; i++) {
queue.stop();
pages.removePage();
}
}
};
function Queue(header, user, callback) {
this.header = header;
this.user = user;
this.callback = callback;
this.init(header);
}
Queue.prototype = {
header: null, // header for the items to be processed
user: "", // user to get data for
callback: null, // function to call after all items have been processed
location: "", // location of the queue: header or chart
type: "", // queue type: see below
types: { h1artist: "artists",
h1track: "tracks",
h1album: "albums" },
items: null, // all items to process
overall: null, // is this a queue for an overall chart
requests: null, // items for which new data is needed
url: null, // function that returns the url to request data from
delay: 1000, // delay between sending requests for fan rank data
// i should add descriptions for these
regex: [/Artists/, // 0
/Tracks|Listen Now/, // 1
/\/music\/.*\/_\/./, // 2
/\/(?:group|music)\//, // 3
/Overall/, // 4
/charttype=overall/, // 5
/,/g, // 6
/gm_fan_rank[^ ]+/g, // 7
/\(?#\d+ fan\)?/, // 8
/(<ul class="resourceList [\s\S]+<\/ul>)\s+<p>/, // 9
/<li class="uContextualInfo">[\s\S]*?<\/li>/g, // 10
/^<\?xml version[^>]+?>/, // 11
/[\s\S]*<a.*\/music\/([^"?]*?)(?:"|\?)[\s\S]*?/, // 12
/[\s\S]*<a.*\/music\/([^"?]*?)(?:"|\?)[\s\S]*?(?:<div[\s\S]*?<span.*?>([\d,]+))/], // 13
init: function(header) {
var rows, className = $xf(".//h1/@class", header);
var type = (className) ? this.types[className.value] : "";
if (type) { // maybe not a good way to determine location
this.location = "header";
this.type = type
rows = [header];
this.overall = false;
} else {
this.location = "chart";
if (this.regex[0].test(header.innerHTML)) {
this.type = "artists";
} else if (this.regex[1].test(header.innerHTML) ||
this.regex[2].test(location.href)) {
this.type = "tracks";
} else {
this.type = "albums";
}
rows = this.getRows();
this.overall = this.isOverall();
}
this.url = this.getUrl();
this.items = new Array();
this.setItems(rows);
},
getRows: function() {
var rows = document.evaluate("following::table[1]/tbody/tr[./td]",
this.header, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE,
null);
var rowsArray = new Array(), row;
while (row = rows.iterateNext()) {
rowsArray.push(row);
}
return rowsArray;
},
// test if it's an overall chart
isOverall: function() {
if (this.regex[3].test(location.href)) {
return false;
}
if (this.regex[4].test(this.header.innerHTML) ||
this.regex[5].test(location.href)) {
return true;
}
return false;
},
getUrl: function() {
var webservices = "http://ws.audioscrobbler.com/1.0/";
switch (this.type) {
case "artists": return (function(item) {
return webservices + "artist/" + item.href.split("/")[0] +
"/fans.xml" });
case "tracks": return (function(item) {
var href = item.href.split("/");
return webservices + "track/" + href[0] + "/" + href[2] +
"/fans.xml" });
case "albums": return (function(item) {
var href = item.href.split("/");
return "http://www.last.fm/music/" + href[0] + "/" + href[1] +
"/+fans"});
}
},
setItems: function(rows) {
var re = (this.overall) ? this.regex[13] : this.regex[12];
var start = new Date().getTime();
for (var i = 0, row; i < batch_size && (row = rows[i]); i++) {
if (re.test(row.innerHTML)) {
this.items.push({
queue: this,
container: row,
href: RegExp.$1,
playcount: RegExp.$2.replace(this.regex[6], ""),
update: "" });
}
}
// time the slowest part
var time = (new Date().getTime() - start)/batch_size;
setItem("time", (time + Number(getItem("time", "20")))/2);
if (rows.length > batch_size) {
var q = this;
setTimeout(function() {
q.setItems(rows.slice(batch_size, rows.length));
}, 0);
} else {
this.requests = new Array();
this.applyCacheData();
}
},
stop: function() {
this.requests = new Array();
this.processing = false;
clearTimeout(this.timeoutID);
},
setUser: function(user) {
this.user = user;
},
start: function(override, callback) {
this.callback = callback;
// start getting new data if the auto-get pref is set or 'update now'
// command was used and make sure the queue isn't already being
// processed
this.prefAutoGet = (getItem("auto_get_fan_rank", "true") == "true");
if ((this.prefAutoGet || override) && !this.processing) {
this.override = override;
this.processing = true;
this.getNewData();
} else {
this.callback();
}
},
applyCacheData: function() {
var i = 0, item;
while (i < batch_size && (item = this.items.shift())) {
cache.setActive(item);
var rank = cache.getFanRank();
if (rank) {
this.addFanStatus(item, rank);
}
var update = this.shouldUpdate(item);
if (update.wanted) {
item.update = update.type;
this.requests.push(item);
}
i++;
}
if (this.items.length > 0) {
var q = this;
setTimeout(function() {
q.applyCacheData();
}, 0);
} else {
this.callback();
}
},
// called recursively to process the request queue and get new fan ranking
// data
getNewData: function() {
// check that a request should still be sent out
this.prefAutoGet = (getItem("auto_get_fan_rank", "true") == "true");
if (!this.prefAutoGet && !this.override) {
this.processing = false;
this.callback();
return;
}
var item = this.requests.shift();
if (item) {
this.setStatusChecking(item, true);
var q = this;
GM_xmlhttpRequest({
method: "GET",
url: q.url(item),
onload: function(details) {
q.processResponse(details, item); },
onerror: function(details) {
q.processError(details, item); },
onreadystatechange: function(details) {
q.processReadyStateChange(details, item, this.url); }
});
} else {
cache.save();
this.callback();
}
},
// check if difference between the timestamp on the stored data and
// the current time is beyond our cache timeout limit
cacheTimeout: function() {
var currDate = new Date();
var currTime = Math.ceil(currDate.getTime() / 3600000);
var age = currTime - cache.getTimestamp();
var today = currDate.getDay();
// allow updating every 6 hours on sundays and assume the data feeds
// are updated at 12 am Monday for other days
var hours = currDate.getHours();
var updateAge = (today == 0) ? 6 : (today - 1)*24 + hours;
return (age > updateAge);
},
// determines whether new data should be retrieved for an artist + user
// this is kind of messed up
shouldUpdate: function(item) {
var update = {};
if (item.playcount) {
update.wanted = (item.playcount > cache.getPlayCount());
update.type = "playcount";
} else {
update.wanted = this.cacheTimeout();
update.type = "timeout";
}
return update;
},
// process the fan rank data received
processResponse: function(details, item) {
this.setStatusChecking(item, false);
cache.setActive(item);
if (details.responseText) { // find the user's fan rank
var rank;
if (this.type == "albums") {
rank = this.parseHtmlRanking(details.responseText);
} else { // track or artist
rank = this.parseXmlRanking(details.responseText);
}
if (rank != undefined) {
this.addFanStatus(item, rank);
}
cache.updateItem(item, rank);
} else { // re-add it to the queue, but only retry once
// var current = item.artist + "&" + item.album + "&" + item.track;
var current = item.href;
if (this.retry != current) {
this.retry = current;
this.requests.unshift(item);
}
}
// get the next request ready
var q = this;
this.timeoutID = setTimeout(function() {
q.getNewData();
}, this.delay);
},
processError: function(details, item) {
this.setStatusError(item);
this.getNewData();
},
processReadyStateChange: function(details, item, url) {
if (details.status == "404") {
this.setStatusError(item);
GM_log(details.status + ": " + url); // URL changed?
}
},
// find the current user in the top fans xml file and return the rank
parseXmlRanking: function(responseText) {
var ranking = new XML(responseText.replace(this.regex[11], ""));
var rank;
try {
if (ranking.user.length()) {
rank = 0;
var user = ranking.user.(@username == this.user);
if (user.length()) {
rank = user.childIndex() + 1;
}
}
} catch (e) {
GM_log("Last.fm - Artist Fan Rank - Error parsing xml ranking: " +
e);
}
return rank;
},
// find the current user in the track/album fan list and return the ranking
parseHtmlRanking: function(responseText) {
// preprocess it so we can use E4X methods on it
var fanText = this.processHtml(responseText);
var list = new XML(fanText);
var rank;
if (list.li.length()) {
rank = 0;
var user = list.li.(ul.li[0].span.a == this.user);
if (user.length()) {
rank = user.childIndex() + 1;
}
}
return rank;
},
// xml-ify the xhtml page
processHtml: function(responseText) {
var test = this.regex[9].test(responseText);
var fanText = (test) ? RegExp.$1.replace(this.regex[10], "") : "";
return fanText;
},
// color the artist names as they are checked
setStatusChecking: function(item, status) {
var container = item.container;
var oldClass = container.className;
if (status) {
container.className = oldClass + " gm_fan_rank_checking";
} else {
container.className = oldClass.replace("gm_fan_rank_checking", "");
}
},
// set an indication if the XMLHttpRequest failed, not persisted
setStatusError: function(item) {
var oldClass = item.container.className;
item.container.className = oldClass.replace("gm_fan_rank_checking",
"gm_fan_rank_error");
},
// add the user's fan rank after the artist name, and to the title/tooltip
// also add an image at the beginning of the row
addFanStatus: function(item, rank) {
var container = item.container;
// location to append fan rank to
var xpath = ".//a[starts-with(@href, '/music/')][text()][last()]/..";
var target = $xf(xpath, container);
var titleBox = target.parentNode;
this.addMarkings(rank, target, container, titleBox);
},
addMarkings: function(rank, target, container, titleBox) {
var oldTitle;
var change = this.getChange(cache.getFanRank(), rank);
var rankLabel = this.createRankLabel(rank, change);
if (target.lastChild.className != "gm_fan_rank_rank") {
if (rank) {
this.addTopFanImage(container);
this.colorTopFanPosition(container);
}
target.appendChild(rankLabel);
oldTitle = titleBox.title;
} else {
if (!rank) {
container.className = container.className
.replace(this.regex[7], "");
}
target.replaceChild(rankLabel, target.lastChild);
oldTitle = titleBox.title.replace(this.regex[8], "");
}
var newTitle = (rank) ? (oldTitle ?
", #" + rank + " fan" : "#" + rank + " fan") : "";
titleBox.title = oldTitle + newTitle + change;
},
getChange: function(oldRank, rank) {
var change = oldRank - rank;
change = (oldRank == 0) ? "" :
(oldRank && rank == 0) ? "(-)" :
(change < 0) ? "(" + change + ")" :
(change > 0) ? "(+" + change + ")": "";
return change;
},
// create rank number to insert after the artist name
createRankLabel: function(rank, change) {
var rankLabel = document.createElement("span");
rankLabel.className = "gm_fan_rank_rank";
// \u200E, left-to-right mark, is added so ranking is displayed ltr
// when next to rtl text
rankLabel.innerHTML = "\u200E " + ((rank) ?
"(#" + rank + " fan)" : "") + change;
var display = (getItem("add_rank_label", "true") == "true") ?
"inline" : "none";
rankLabel.style.display = display;
return rankLabel;
},
addTopFanImage: function(container) {
var oldClassName = container.className;
var prefImage = (getItem("add_top_fan_image", "true") == "true");
var rowClass = prefImage ? "gm_fan_rank_top_fan_star" :
"gm_fan_rank_top_fan_star_off";
container.className = oldClassName + " " + rowClass;
},
colorTopFanPosition: function(container) {
var oldClassName = container.className;
var prefColor = (getItem("color_top_fan_position", "true") == "true");
var rowClass = prefColor ? "gm_fan_rank_top_fan_position" :
"gm_fan_rank_top_fan_position_off";
container.className = oldClassName + " " + rowClass;
}
};
// cache to store and retrieve stored data
var cache = {
user: "", // user this cache data is for
active: "", // the active cache item
updated: { artists: false, // keeps track of updated cache data
tracks: false,
albums: false },
users: null, // cache data sets
artists: null,
tracks: null,
albums: null,
init: function(user) {
// process old style cache strings
// REMOVE ME
var clean = function(key, def) {
var re = /^[^\[\{][\s\S]*/;
return eval("(" + (getItem(key, "").replace(re, "") || def) + ")");
}
this.users = clean("users", "new Array()");
this.artists = clean(user + "&artists", "new Object()");
this.tracks = clean(user + "&tracks", "new Object()");
this.albums = clean(user + "&albums", "new Object()");
this.setUser(user);
},
setUser: function(user) {
this.user = user;
var exists;
for each (var u in this.users) {
if (u == user) {
exists = true;
break;
}
}
if (!exists) {
this.users.push(user);
}
},
setActive: function(item) {
var blank = { rank: 0, timestamp: 0, playcount: 0 };
var type = item.queue.type;
var key = item.href;
if (!this[type][key]) {
this[type][key] = blank;
}
this.active = this[type][key];
},
// convert cache data to a JSON string
toJSON: function(obj) {
var string = new Array();
for (var name in obj) {
var data = new Array();
for (var prop in obj[name]) {
data.push("\"" + prop + "\":" + obj[name][prop]);
}
string.push("\"" + name + "\":{" + data.join(",") + "}");
}
return "{" + string.join(",") + "}";
},
updateItem: function(item, rank) {
this.updated[item.queue.type] = true;
// update the timestamp
var now = Math.ceil((new Date().getTime()) / 3600000);
now = (item.update == "playcount") ? now + 24*3600000 : now;
this.active.timestamp = now;
// update the playcount
if (item.playcount) {
this.active.playcount = item.playcount
}
// update the fan rank
if (rank != undefined) {
this.active.rank = rank;
}
},
getFanRank: function() {
return this.active.rank;
},
getPlayCount: function() {
return this.active.playcount;
},
getTimestamp: function() {
return this.active.timestamp;
},
erase: function() { // wipe everything
this["artists"] = {};
this["tracks"] = {};
this["albums"] = {};
for (var i = 0, user; user = this.user[i]; i++) {
removeItem(user + "&artists");
removeItem(user + "&tracks");
removeItem(user + "&albums");
}
this.users = new Array();
},
save: function() {
setItem("users", "[\"" + this.users.join("\",\"") + "\"]");
for (var type in this.updated) {
if (this.updated[type]) {
setItem(this.user + "&" + type, this.toJSON(this[type]));
this.updated[type] = false;
setTimeout(function() { cache.save(); }, 0);
break;
}
}
}
};
// "charts" to ignore based on their headers
function chartFilter(header) {
var re = new RegExp("Playlist|" + // user playlist charts
"music charts|" + // user charts selector
"Weekly|" + // user weekly chart on overview page
"Recently Listened Tracks|" + // user recent tracks
"Discussions|" + // group recent discussions
"Charts"); // group chart selector
return !re.test(header.textContent)
}
function getUser() {
var user;
if (/http:\/\/.*?\/user\/([^?\/]+)/.test(location.href)) {
user = RegExp.$1;
} else {
var link = $xf("/html/body/div/div/div/div/a[2]");
if (!link) { // you're not logged in
return;
}
user = link.href.match(/\/user\/([^\/]+)/)[1];
}
return user;
}
function getCSS() {
// base64 encoded images for the left end of the charts
const IMG_ARTIST_BLACK = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAMAAABhq6zVAAAAMFBMVEUFBQVWUlyupqYxMTGSipLPz8sNDQ0FBQ0WFh7X19cNDRV2en6enqamnqYNBQ3////9MbwFAAAAEHRSTlP///////////////////8A4CNdGQAAAEpJREFUeNpVjFsOgDAIBIfWPrQq97+tYYkxzscukwB4YKYik08atFd2A5P88BrZgRprFxwdzrzpAMNTECkLSoElGTPezluyxax6AIaKBQbHNY47AAAAAElFTkSuQmCC";
const IMG_ARTIST_RED = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMBAMAAACkW0HUAAAAMFBMVEXSHD7UJ0rvqbf31drmhJTOEzHIExXNEynrk6TMEyPLEx3eW3niYnzQFTfZQVn///+qGOmJAAAAEHRSTlP///////////////////8A4CNdGQAAAFFJREFUCFtj+P//9/n//xn+//85H0xZFYOoz1tjgFRaeWjpqjSGf1NDeSvfM/zXvcAbBJT7UBt+H0j9jAydD6S8gtRTgBTj//+yQKr///8f/wHj2jQafi98nQAAAABJRU5ErkJggg==";
// color of the appended fan rank
return ".gm_fan_rank_rank { color: rgb(150, 150, 150); }" +
// set the color of the fan rank in artist page headers on hover
"#LastHeadline .gm_fan_rank_rank:hover { color: rgb(210, 210, 210); }" +
// color of the artist link when it's being checked
".gm_fan_rank_checking a { color: rgb(249, 128, 134) !important; }" +
// color of the artist link when an error is detected
".gm_fan_rank_error a { font-weight: bold; color: rgb(255, 150, 150) !important; }" +
// widen chart left margin for star
".gm_fan_rank_top_fan_star td[class='position']," +
".gm_fan_rank_top_fan_star td:first-child[class='trackNumber'] {" +
" background-position: 5px center !important;" +
" background-repeat: no-repeat !important;" +
" padding-left: 20px; }" +
// add an image and/or color text to indicate top fan status
// black theme
"body.black .gm_fan_rank_top_fan_position td[class='position']," +
"body.black .gm_fan_rank_top_fan_position td[class='trackNumber'] {" +
" color: black !important; }" +
"body.black .gm_fan_rank_top_fan_star td:first-child[class='position']," +
"body.black .gm_fan_rank_top_fan_star td:first-child[class='trackNumber'] {" +
" background-image: url(" + IMG_ARTIST_BLACK + "); }" +
// red theme images
"body.red .gm_fan_rank_top_fan_position td[class='position']," +
"body.red .gm_fan_rank_top_fan_position td[class='trackNumber'] {" +
" color: #D01F3C !important; }" +
"body.red .gm_fan_rank_top_fan_star td[class='position']," +
"body.red .gm_fan_rank_top_fan_star td:first-child[class='trackNumber'] {" +
" background-image: url(" + IMG_ARTIST_RED + "); }" +
// popup menu
"#gm_fan_rank_menu {" +
" position: fixed;" +
" width: 260px;" +
" background-color: white;" +
" border: 2px solid black;" +
" z-index: 1000000;" +
" -moz-border-radius: 5px;" +
" text-align: left;" +
" font-size: 12pt;" +
" font-weight: bold; }" +
"#gm_fan_rank_menu ul li {" +
" line-height: 22px; }" +
"#gm_fan_rank_menu ul li a[selected=true] {" + // selected menu item
" background-color: #E5E8EE; }" +
"#gm_fan_rank_menu ul {" +
" margin-right: 0.7em; }";
}
function runShortcut(event) {
var key = event.keyCode;
var text = /text/.test(event.target.type);
if (!(event.altKey || event.shiftKey || event.ctrlKey || text ||
(key > 111 && key < 124 /* function keys */)) &&
preferences.listItems) {
event.preventDefault();
}
if (!text) {
if (this.capture) {
if (key == 109 || key == 77) { // (m/M)enu
preferences.showMenu();
} else if (key != 111 && key != 79) {
this.capture = false;
}
}
if (key == 111 || key == 79) { // (o/O)pen
this.capture = true;
}
if (key == 27 && !text) { // esc
preferences.hideMenu();
}
}
if (key == 13) { // enter
preferences.execute();
} else if (key == 40 || key == 39) { // down or right
preferences.selectNext();
} else if (key == 38 || key == 37) { // up or left
preferences.selectPrevious();
}
}
function getHeaders() {
// get the header on artist pages, where the artist/track/album name is
var headers = new Array();
if (/http:\/\/.*?\/music\//.test(location.href)) {
headers.push($xf("/html/body/div[1]/div[2]"));
}
// find all the charts (chart headers) to process
var xpath = "/html/body/div[1]/div[@id='LastContent']" +
"/div[1]/div//table/preceding::h3[1]";
headers = headers.concat($xa(xpath).filter(chartFilter));
return headers;
}
// = start processing the page ==============================================//
(function() {
var user = getUser();
if (!user) {
return;
}
preferences.init();
var headers = getHeaders();
if (headers.length) {
GM_addStyle(getCSS());
pages.init();
queueManager.init(headers, user);
cache.init(user); // load cached data
pages.addPage(); // add current page to page queue
queueManager.start(); // initialize the queues and apply cached data
addMenuItems();
}
window.addEventListener("keydown", runShortcut, true);
window.addEventListener("unload", function() {
queueManager.stop();
cache.save();
}, true);
})();