Source for "Last.fm - Artist Fan Rank"

By kepp
Has 11 other scripts.


Add Syntax Highlighting (this will take a few seconds, probably freezing your browser while it works)

// ==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);

})();