Netflix Queue Sorter

By Maarten Last update Aug 26, 2009 — Installed 4,650 times. Daily Installs: 10, 8, 11, 18, 21, 11, 9, 8, 15, 13, 11, 11, 12, 27, 12, 26, 21, 13, 21, 29, 21, 5, 9, 17, 10, 14, 19, 17, 26, 12, 20, 26

There are 15 previous versions of this script.

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

///////////////////////////////////////////////////////////////////////////////
//
// This is a Greasemonkey user script.
//
// Netflix Queue Sorter
// Version 1.12, 2009-08-26
// Coded by Maarten van Egmond.  See namespace URL below for contact info.
// Released under the GPL license: http://www.gnu.org/copyleft/gpl.html
//
// ==UserScript==
// @name        Netflix Queue Sorter
// @namespace   http://userscripts.org/users/64961
// @author      Maarten
// @version     1.12
// @description v1.12: Sort your Netflix queue by movie title, length, genre, average rating, star/suggested/user rating, availability, or playability.  Includes options to shuffle/randomize or reverse your queue.
// @include     http://www.netflix.com/Queue*
// ==/UserScript==
//
///////////////////////////////////////////////////////////////////////////////
//
// For install, uninstall, and known issues, see the namespace link above.
//
///////////////////////////////////////////////////////////////////////////////
//
// This script allows you to shuffle (that is: randomize), reverse, or sort
// your DVD or Instant Queue by movie title, length, genre, star rating (that
// is: suggested rating or user rating), average rating, availability, or
// playability.
//
///////////////////////////////////////////////////////////////////////////////

// Satisfy JSLint.
/*global alert, clearTimeout, document, GM_getValue, GM_setValue, GM_registerMenuCommand, GM_xmlhttpRequest, setTimeout */

// Singleton pattern.
var NetflixQueueSorter = (function () {
    //
    // Private variables
    //
    var sortButtons = [];
    var sortInfo = [];
    var getQueue = [];
    var totalQueueCount = 0;
    var seriesLookup = {};
    var cancelledSort = false;
    var XHR_DELAY = 500;

    //
    // Private functions
    //

    // Add support for document.getElementsByClassName, e.g. for FF2.
    function customGetElementsByClassName(elt, tag, name) {
        if ("undefined" === typeof elt.getElementsByClassName) {
            var result = [];

            if (undefined === tag) { 
                alert('Internal error: must pass tag name!');
            } else {
                var elts = elt.getElementsByTagName(tag);
                for (var ii = 0; ii < elts.length; ii++) {
                    if (elts[ii].className === name) {
                        result.push(elts[ii]);
                    }
                }
            }

            return result;
        } else {
            return elt.getElementsByClassName(name);
        }
    }

    // To somewhat synchronize AJAX calls and the cancelling of the sort,
    // the button click will just set the flag, and only after the last 
    // AJAX result is processed will the cancel command itself be processed.
    function cancelSort() {
        cancelledSort = true;
    }
    function realCancelSort() {
        // Clear the status message, since we're done.
        clearProgressStatus();

        // Re-enable the sort buttons.
        setSortButtonState(true);
    }

    function createSortButton(value, label, title, onClickFn) {
        var button = document.createElement('button');
        button.setAttribute('type', 'button');
        button.setAttribute('value', value);
        button.setAttribute('title', title);
        button.setAttribute('style', 'font-size: smaller');
        var buttonText = document.createTextNode(label);
        button.appendChild(buttonText);
        button.addEventListener('click', onClickFn, true);
        return {
            'button': button,
            'text': buttonText
        };
    }

    function addOptions(header, options) {
        var div = document.createElement('div');

        for (var idx = 0; idx < options.length; idx++) {
            if (options[idx].isProgress) {
                // Progress feedback area.
                var span = document.createElement('span');
                span.setAttribute('id', 'gm_progress_' + header.className);
                span.setAttribute('style', 'padding: 0 0 2px 2px');
                div.appendChild(span);
                span = document.createElement('span');
                span.setAttribute('id',
                        'gm_progress_cancel_' + header.className);
                span.setAttribute('style',
                        'padding: 0 0 2px 7px; visibility: hidden');
                span.appendChild(document.createTextNode('['));
                // Cancel button.
                var link = document.createElement('a');
                link.setAttribute('style', 'cursor: pointer');
                link.appendChild(document.createTextNode('cancel'));
                link.addEventListener('click', cancelSort, true);
                span.appendChild(link);
                span.appendChild(document.createTextNode(']'));
                div.appendChild(span);
            } else {
                var buttonInfo = createSortButton(
                        options[idx].sort, options[idx].label,
                        options[idx].title, reorderQueue);
                sortButtons.push(buttonInfo);
                div.appendChild(buttonInfo.button);
            }
            div.appendChild(document.createElement('br'));
        }
        div.appendChild(document.createElement('br'));

        var headerText = header.childNodes[0];
        header.replaceChild(div, headerText);
        header.appendChild(headerText);
    }

    function addOrderSortOption(header) {
        addOptions(header, [
            {
                'sort': 'shuffle',
                'label': 'Shuffle',
                'title': 'Shuffles your queue into a random order.'
            },
            {
                'sort': 'reverse',
                'label': 'Reverse',
                'title': 'Reverses the current list order.'
            }
        ]);
    }

    function addTitleSortOption(header) {
        addOptions(header, [
            {
                'isProgress': true 
            },
            {
                'sort': 'title',
                'label': 'Sort by Title',
                'title': 'Alphabetically sorts your queue by movie title.'
            },
            {
                'sort': 'length',
                'label': 'Sort by Length / Display Length',
                'title': 'Displays the length of each movie and sorts your ' +
                        'queue by length from short to long.'
            }
        ]);
    }

    function addInstantSortOption(header) {
        addOptions(header, [
            {
                'sort': 'instantTop',
                'label': '/\\',
                'title': 'Move instantly playable movies to the top of your ' +
                        'queue.'
            },
            {
                'sort': 'instantBottom',
                'label': '\\/',
                'title': 'Move instantly playable movies to the bottom of ' +
                        'your queue.'
            }
        ]);
    }

    function addStarSortOption(header) {
        addOptions(header, [
            {
                'isProgress': true 
            },
            {
                'sort': 'usrRating',
                'label': 'Sort by Star Rating',
                'title': 'Sorts all movies by star rating from high to low.'
            },
            {
                'sort': 'avgRating',
                'label': 'Sort by Avg Rating',
                'title': 'Sorts all movies by average rating from high to low.'
            }
        ]);
    }

    function addGenreSortOption(header) {
        addOptions(header, [
            {
                'sort': 'genre',
                'label': 'Sort by Genre',
                'title': 'Alphabetically sorts your queue by genre.'
            }
        ]);
    }

    function addAvailabilitySortOption(header) {
        addOptions(header, [
            {
                'sort': 'availability',
                'label': 'Sort by Availability',
                'title': 'Moves the most desirable movies to the top of ' +
                        'your queue.'
            }
        ]);
    }

    // This function builds the GUI and adds it to the page body.
    function buildGui() {
        var elt = document.getElementById('inqueue-header-row');
        var children = elt.childNodes;
        for (var ii = 0; ii < children.length; ii++) {
            if (children[ii].tagName === "TH") {
                if (children[ii].className === "prmt") {
                    addOrderSortOption(children[ii]);
                } else if (children[ii].className === "tt") {
                    addTitleSortOption(children[ii]);
                } else if (document.URL.indexOf('inqt=wn') < 0 &&
                        // Not instant so allow sort by playability.
                        children[ii].className === "wn") {
                    addInstantSortOption(children[ii]);
                } else if (children[ii].className === "st") {
                    addStarSortOption(children[ii]);
                } else if (children[ii].className === "gn") {
                    addGenreSortOption(children[ii]);
                } else if (children[ii].className === "av") {
                    addAvailabilitySortOption(children[ii]);
                }
            }
        }
    }

    function setProgressStatus(id, msg, canCancel) {
        canCancel = undefined === canCancel;   // Init.

        var elt = document.getElementById('gm_progress_' + id);
        if (elt) {
            elt.innerHTML = msg;
        }

        elt = document.getElementById('gm_progress_cancel_' + id);
        elt.style.visibility = canCancel ? 'visible' : 'hidden';
    }

    function clearProgressStatus() {
        setProgressStatus('tt', '', false);
        setProgressStatus('st', '', false);
    }

    function setButtonState(button, enabled) {
        if (enabled) {
            button.removeAttribute('disabled');
        } else {
            button.setAttribute('disabled', true);
        }
    }

    function setSortButtonState(enabled) {
        for (var idx = 0, len = sortButtons.length; idx < len; idx++) {
            setButtonState(sortButtons[idx].button, enabled);
        }
    }

    function done(enableUpdateQueueButton, firstBox) {
        // Re-enable the sort buttons.
        setSortButtonState(true);

        // Enable the Update Queue button.
        if (firstBox) {
            firstBox.focus();   // This will enable the button.
            firstBox.blur();   // Don't interfere with keyboard navigation.

            // The focus() above will also color the row, so remove that.
            // (Either color all changed rows (see v1.0), or none.)
            var row = document.getElementById('firstqitem');
            if (row) {
                row.className = row.className.replace('bgreorder', '');
            }
        }
    }

    function setOrder(sortValue, elts) {
        elts = elts || customGetElementsByClassName(document, 'input', 'o');

        var elt, firstBox, len, pos;
        for (pos = 0, len = sortInfo.length; pos < len; pos++) {
            // Note: sortValue is 1-based, elts index is 0-based, so sub 1
            elt = elts[sortInfo[pos][sortValue] - 1];

            // Set new value.
            elt.value = sortInfo.length - pos;

            if (sortInfo[pos].origPos === 1) {
                firstBox = elt;
            }
        }

        // Clear the status message, since we're done.
        clearProgressStatus();

        done(true, firstBox);

        // Inform the user that sort has finished and what the next steps are.
        elt = document.getElementById('updateQueue1');
        alert("Sort completed.  Now press the " + elt.alt +
                " button to save it.");
    }

    function doActualSort(algorithm) {
        var sortFn = function (a, b) {
            if (a[algorithm] === b[algorithm]) {
                return a.title > b.title ? -1 : 1;
            }
            return a[algorithm] > b[algorithm] ? 1 : -1;
        };
        sortInfo.sort(sortFn);

        setOrder("origPos");
    }

    // Return publicly accessible variables and functions.
    function reverse() {
        var elts = customGetElementsByClassName(document, 'input', 'o');

        var maxIdx = Math.floor(elts.length / 2);
        for (var idx = 0; idx < maxIdx; idx++) {
            var otherIdx = elts.length - idx - 1;

            // Swap the values.
            var tmp = elts[otherIdx].value;
            elts[otherIdx].value = elts[idx].value;
            elts[idx].value = tmp;
        }

        done(true, elts[0]);

        // Inform the user that sort has finished and what the next steps are.
        var elt = document.getElementById('updateQueue1');
        alert("Reversal completed.  Now press the " + elt.alt +
                " button to save it.");
    }

    function shuffle() {
        var idx;
        var elts = customGetElementsByClassName(document, 'input', 'o');

        // Generate a list of random positions.
        var slots = [];
        for (idx = 0; idx < elts.length; idx++) {
            slots.push(idx);
        }

        for (idx = 0; idx < elts.length; idx++) {
            // Generate number between 0 and slots.length - 1.
            // Math.random() generates a number between 0 (incl) and 1 (excl).
            var slotsIdx = Math.floor(Math.random() * slots.length);
            elts[idx].value = slots[slotsIdx];
            
            // Remove used position from slots array.
            slots.splice(slotsIdx, 1);
            // Note: if splice turns out to be expensive, we could just move
            // the slotsIdx value to the front of the array and keep a pointer
            // to the end of the "used" positions.
        }

        done(true, elts[0]);

        // Inform the user that sort has finished and what the next steps are.
        var elt = document.getElementById('updateQueue1');
        alert("Shuffle completed.  Now press the " + elt.alt +
                " button to save it.");
    }

    function sortByLength() {
        sortInfo = [];
        var pos = 1;

        for (var idx = 0; idx < getQueue.length; idx++) {
            var qq = getQueue[idx];

            var record = {
                "id": qq.boxId,
                "len": qq.len,
                "origPos": pos++
            };
            sortInfo.push(record);
        }

        var sortFn = function (a, b) {
            return a.len > b.len ? -1 : 1;
        };
        sortInfo.sort(sortFn);

        setOrder("origPos");
    }

    function getLength(queueIdx) {
        if (queueIdx < totalQueueCount) {
            var record = getQueue[queueIdx];
            if (!record) {
                // Unexpected result.
                alert('Unexpected situation: no record found in queue.\n' +
                        'Please let the script owner know.\n\n' +
                        'GetLength: ' + queueIdx + ' out of ' +
                        totalQueueCount);
                done(false);
                return;
            }

            // Update progress.
            if (queueIdx < totalQueueCount - 1) {
                var pct = ((queueIdx / totalQueueCount) * 100).toFixed(0);
                setProgressStatus('tt', 'Getting length info: ' + pct + '%');
            } else {
                setProgressStatus('tt', 'Getting length info: 100%', false);
            }

            // Since user can cancel and then re-start the sort, check if 
            // length was already added.  Code here mimics parseGetLength.
            var elt = document.getElementById(record.titleId);
            elt = elt.parentNode;
            if (/^<code><b>\[\d+:\d+/.test(elt.innerHTML)) {
                // Next item in the queue.
                getLength(queueIdx + 1);
            } else {
                var url = record.url;
                GM_xmlhttpRequest({
                    'method': 'GET',
                    'url': url,
                    'onload': function (xhr) {
                        parseGetLength(queueIdx, xhr.responseText);
                    }
                });
            }
        } else {
            // Now we can sort.
            sortByLength();
        }
    }

    function parseGetLength(queueIdx, text) {
        // Use low value to make them appear on top if length cannot be
        // retrieved.
        var len = -Infinity;
        var readableLen = " N/A ";
        var isEpisode = false;

        // In JavaScript, "everything until and including a newline" is
        // represented as the expression "(?:.*?\n)*?".  So that matches
        // wherever you are in the string until the end-of-line, and any
        // lines underneath it.  To continue matching on another line,
        // skip into the line first using ".*?".
        var regex = /id="movielength"(?:.*?\n)*?.*?(\d+?) minutes</;
        if (regex.test(text)) {
            len = RegExp.$1 * 1;   // Convert to number.
        } else {   // Could be a series... take the first episode.
            regex = /Length:<.*?(\d+?) minutes</;
            if (regex.test(text)) {
                len = RegExp.$1 * 1;   // Convert to number.
                isEpisode = true;
            }
            // Else no match... use high default values.
        }

        // Store value in minutes for sort cycle happening later.
        getQueue[queueIdx].len = len * 1;

        if (-Infinity !== len) {
            // Format minutes in something more readable.
            var hh = Math.floor(len / 60);
            var mm = len - (hh * 60);
            readableLen = hh + ":" + (mm < 10 ? "0" : "") + mm;
        }

        // Add duration to text in title column.
        var elt = document.getElementById(getQueue[queueIdx].titleId);
        elt = elt.parentNode;   // Use parent node to avoid linking time.
        elt.innerHTML = '<code><b>[' + readableLen + (isEpisode ? '+' : '') +
                ']</b> </code>' + elt.innerHTML;
 
        if (cancelledSort) {
            realCancelSort();
        } else {
            // Next item in the queue.
            var delayed = function () {
                getLength(queueIdx + 1);
            };
            setTimeout(delayed, XHR_DELAY);
        }
    }

    function showLength() {
        getQueue = [];

        var elts = customGetElementsByClassName(document, 'input', 'o');
        for (var idx = 0; idx < elts.length; idx++) {
            var boxName = elts[idx].name;
            var boxId = boxName.substring(2);

            // Some BOBs include length but not all do.  Rather than risking
            // having to make another request, just use the details page which
            // always contains the length.
            // TODO: Once Netflix has updated all BOBs to include length,
            //       (only series discs don't have it yet) switch to BOBs
            //       as it is less bytes.

            // If a movie is both at home and in the queue, or a movie has been
            // watched but is still in the queue, there is both _0 and _1.
            // There's even been a _2.  Only the highest ending is in the
            // sortable table and the length is added to that element.
            var ii = 0;
            var href, titleId, titleElt;
            while (titleElt =
                    document.getElementById('b0' + boxId + '_' + ii)) {
                titleId = 'b0' + boxId + '_' + ii;
                href = titleElt.href;
                ii++;
            }

            // Save time in the sort cycle by storing the boxId right now,
            // and by storing the length in minutes in the queue records when
            // the length is retrieved.  Otherwise the sort cycle would need
            // to figure all of that out again.
            var record = {
                "boxId": boxId,       // needed for sort cycle
                "titleId": titleId,   // needed to add length in UI
                "url": href           // the URL that has the length info
                // (added later: length in minutes)
            };
            getQueue.push(record);
        }

        totalQueueCount = getQueue.length;
        if (0 !== totalQueueCount) {
            getLength(0);
        } else {
            sortByLength();
        }
    }

    function sortByTitle() {
        var articles;
        sortInfo = [];
        var pos = 1;

        var articlesKey = 'sortByTitle.articles';
        var ignoreArticlesKey = 'sortByTitle.ignoreArticles';
        var ignoreArticles = GM_getValue(ignoreArticlesKey);
        if (undefined === ignoreArticles) {
            // Use true as default as Netflix ignores articles too.
            ignoreArticles = true;

            // Store keys so that users can change it via about:config.
            GM_setValue(ignoreArticlesKey, ignoreArticles);
            // The articles are used "as-is", so there must be a space after
            // each one in most cases.  To avoid typos in the default, use [].
            articles = [
                "A ",
                "AN ",
                "THE ",
                "EL ",
                "LA ",
                "LE ",
                "LES ",
                "IL ",
                "L'"
            ];
            GM_setValue(articlesKey, articles.join(',').toUpperCase());
        }

        var elts = customGetElementsByClassName(document, 'input', 'o');
        for (var idx = 0; idx < elts.length; idx++) {
            var boxName = elts[idx].name;
            var boxId = boxName.substring(2);
            // If a movie is both at home and in the queue, or a movie has been
            // watched but is still in the queue, there is both _0 and _1.
            // Here we either one works.
            var titleId = 'b0' + boxId + '_0';
            var titleElt = document.getElementById(titleId);

            var title = titleElt.innerHTML.toUpperCase();
            if (ignoreArticles) {
                // Get the articles, but default to empty string.
                var articlesStr = GM_getValue(articlesKey, '').toUpperCase();
                articles = articlesStr.split(',');
                for (var aa = 0; aa < articles.length; aa++) {
                    var article = articles[aa].toUpperCase();
                    if (0 === title.indexOf(article)) {
                        // Move article to the end of the string.
                        title = title.substring(article.length) +
                                ', ' + article;
                        break;
                    }
                }
            }

            var record = {
                "id": boxId,
                "title": title,
                "origPos": pos++
            };
            sortInfo.push(record);
        }

        var sortFn = function (a, b) {
            return a.title > b.title ? -1 : 1;
        };
        sortInfo.sort(sortFn);

        setOrder("origPos", elts);
    }

    function sortByPlayability(moveToTop) {
        sortInfo = [];

        // Don't take the whole document.body.innerHTML as text.
        // Luckily there's a div containing just the items we need.
        var text = document.getElementById('qbody').innerHTML;

        var pos = 1;

        // In JavaScript, "everything until and including a newline" is
        // represented as the expression "(?:.*?\n)*?".  So that matches
        // wherever you are in the string until the end-of-line, and any
        // lines underneath it.  To continue matching on another line,
        // skip into the line first using ".*?".
        var regex = /name="OR(\d+)"(?:.*?\n)*?.*?class="wn">(.*?)<\/td/g;
        while (regex.test(text)) {
            var id = RegExp.$1;
            var playable = RegExp.$2.length !== 0;
            var record = {
                "id": id,
                "play": playable,
                "origPos": pos++
            };
            sortInfo.push(record);
        }

        // TODO: fix position of series discs.

        var sortVal = moveToTop ? 1 : -1;
        var sortFn = function (a, b) {
            if (a.play && b.play) {
                return 1;   // Keeps playable items in current order.
            }
            if (a.play) {
                return sortVal;
            }
            if (b.play) {
                return -sortVal;
            }
            return 1;   // Keeps non-playable items in current order.
        };
        sortInfo.sort(sortFn);

        setOrder("origPos");
    }

    function moveInstantTop() {
        sortByPlayability(true);
    }

    function moveInstantBottom() {
        sortByPlayability(false);
    }

    function sortByGenre() {
        sortInfo = [];

        // Don't take the whole document.body.innerHTML as text.
        // Luckily there's a div containing just the items we need.
        var text = document.getElementById('qbody').innerHTML;

        var pos = 1;

        // In JavaScript, "everything until and including a newline" is
        // represented as the expression "(?:.*?\n)*?".  So that matches
        // wherever you are in the string until the end-of-line, and any
        // lines underneath it.  To continue matching on another line,
        // skip into the line first using ".*?".
        var regex = /name="OR(\d+)"(?:.*?\n)*?.*?class="gn">.*?>(.*?)</g;
        while (regex.test(text)) {
            var id = RegExp.$1;
            var genre = RegExp.$2;
            var record = {
                "id": id,
                "genre": genre.toUpperCase(),
                "origPos": pos++
            };
            sortInfo.push(record);
        }

        // TODO: fix position of series discs.

        var sortFn = function (a, b) {
            return a.genre > b.genre ? -1 : 1;
        };
        sortInfo.sort(sortFn);

        setOrder("origPos");
    }

    function sortByAvailability() {
        sortInfo = [];

        // Don't take the whole document.body.innerHTML as text.
        // Luckily there's a div containing just the items we need.
        var text = document.getElementById('qbody').innerHTML;

        var pos = 1;

        // In JavaScript, "everything until and including a newline" is
        // represented as the expression "(?:.*?\n)*?".  So that matches
        // wherever you are in the string until the end-of-line, and any
        // lines underneath it.  To continue matching on another line,
        // skip into the line first using ".*?".
        var regex = /name="OR(\d+)"(?:.*?\n)*?.*?class="(av|km)">(.*?)<\/td/g;
        while (regex.test(text)) {
            var id = RegExp.$1;
            var avail = RegExp.$3;
            var record = {
                "id": id,
                "avail": avail.toUpperCase(),
                "origPos": pos++
            };
            sortInfo.push(record);
        }

        // TODO: fix position of series discs.

        var sortFn = function (a, b) {
            var dateA, dateB;

            // DVD Queue: "To be released" should always be on top.
            if (a.avail.indexOf('RELEASES') >= 0 &&
                    b.avail.indexOf('RELEASES') >= 0) {
                // Sort by date.

                /(\d+\/\d+\/\d+)</.test(a.avail);
                dateA = new Date(RegExp.$1);

                /(\d+\/\d+\/\d+)</.test(b.avail);
                dateB = new Date(RegExp.$1);

                return dateA.getTime() > dateB.getTime() ? -1 : 1;
            }
            if (a.avail.indexOf('RELEASES') >= 0) {
                return 1;
            }
            if (b.avail.indexOf('RELEASES') >= 0) {
                return -1;
            }

            // Instant Queue: "Available until" should always be on top.
            // 
            if (a.avail.indexOf('UNTIL') >= 0 &&
                    b.avail.indexOf('UNTIL') >= 0) {
                // Sort by date.

                /(\d+\/\d+\/\d+)/.test(a.avail);
                dateA = new Date(RegExp.$1);

                /(\d+\/\d+\/\d+)/.test(b.avail);
                dateB = new Date(RegExp.$1);

                return dateA.getTime() > dateB.getTime() ? -1 : 1;
            }
            if (a.avail.indexOf('UNTIL') >= 0) {
                return 1;
            }
            if (b.avail.indexOf('UNTIL') >= 0) {
                return -1;
            }

            // Order "wait" as: Very Long, Long, Short, Now, Unavailable.
            if (a.avail.indexOf('VERY') >= 0) {
                return 1;
            }
            if (b.avail.indexOf('VERY') >= 0) {
                return -1;
            }
            if (a.avail.indexOf('LONG') >= 0) {
                return 1;
            }
            if (b.avail.indexOf('LONG') >= 0) {
                return -1;
            }
            if (a.avail.indexOf('SHORT') >= 0) {
                return 1;
            }
            if (b.avail.indexOf('SHORT') >= 0) {
                return -1;
            }
            if (a.avail.indexOf('NOW') >= 0) {
                return 1;
            }
            if (b.avail.indexOf('NOW') >= 0) {
                return -1;
            }
            if (a.avail.indexOf('UNAVAILABLE') >= 0) {
                return 1;
            }
            if (b.avail.indexOf('UNAVAILABLE') >= 0) {
                return -1;
            }

            // All other cases.
            return 1;   // Keeps rest of items in current order.
        };
        sortInfo.sort(sortFn);

        setOrder("origPos");
    }

    function matchForNetflixRatingGranulizer(pos, seriesInfo, movieInfo) {
        var regex2 = /OR(\d+)(?:.*?\n)*?.*?class="tt".*?<a.*?>(.*?)<\/a>(?:.*?\n)*?.*?stars_.*?_(.*?).gif.*?>(.*?)</;
        if (regex2.test(movieInfo)) {
            var id = RegExp.$1;
            var title = RegExp.$2;
            var usrRating = RegExp.$3 / 10;   // The img tag has rating * 10.

            // The average rating is never there. Will handle this later.
            var avgRating = '';

            var record = {
                "id": id,
                "title": title,
                "usrRating": usrRating,
                "avgRating": avgRating,
                "origPos": pos++
            };
            sortInfo.push(record);

            // Check if series disc.
            regex2 = /series="(\d+)"/;
            if (regex2.test(seriesInfo)) {
                var linkId = RegExp.$1;

                // Don't add "link" key to record, as we're filtering 
                // on that later.

                seriesLookup[linkId] = record;
            }
            // Else it was a non-series movie.  No problem.

            return true;
        }
        return false;
    }

    function sortByRating(sortByAvgRating) {
        var id, linkId, pos, len, record, title;

        sortInfo = [];

        seriesLookup = {};

        // Don't take the whole document.body.innerHTML as text.
        // Luckily there's a div containing just the items we need.
        var text = document.getElementById('qbody').innerHTML;

        pos = 1;

        // In JavaScript, "everything until and including a newline" is
        // represented as the expression "(?:.*?\n)*?".  So that matches
        // wherever you are in the string until the end-of-line, and any
        // lines underneath it.  To continue matching on another line,
        // skip into the line first using ".*?".
        var regex = /<tr(.*?)>((?:.*?\n)*?.*?)<td class="gn">/g;
        while (regex.test(text)) {
            var seriesInfo = RegExp.$1;
            var movieInfo = RegExp.$2;

            // Check if non-series disc, or first-in-series disc.
            // Users that have the Netflix Rating Granulizer script installed
            // will have different markup, so need to check that first.
            if (matchForNetflixRatingGranulizer(pos, seriesInfo, movieInfo)) {
                // Yes, user also has the NRG script installed.
                pos++;
                continue;
            }
            // Now we know there's no altered markup, so deal with standard
            // Netflix markup.
            var regex2 = /OR(\d+)(?:.*?\n)*?.*?class="tt".*?<a.*?>(.*?)<\/a>(?:.*?\n)*?.*?sbmf-(.*?)".*?>(.*?)</;
            if (regex2.test(movieInfo)) {
                id = RegExp.$1;
                title = RegExp.$2;
                var usrRating = RegExp.$3 / 95 * 5;   // mask of 95px is 5 stars
                var avgRatingText = RegExp.$4;

                // Not all movies have avg rating (e.g. 70057842),
                // and user-rated movies don't have their avg rating listed.
                var avgRating = 0;
                regex2 = /Average rating:.*?([\d\.]+)/;
                if (regex2.test(avgRatingText)) {
                    avgRating = RegExp.$1;
                }
                // Else missing average rating. Will handle this later.

                record = {
                    "id": id,
                    "title": title,
                    "usrRating": usrRating,
                    "avgRating": avgRating,
                    "origPos": pos++
                };
                sortInfo.push(record);

                // Check if series disc.
                regex2 = /series="(\d+)"/;
                if (regex2.test(seriesInfo)) {
                    linkId = RegExp.$1;

                    // Don't add "link" key to record, as we're filtering 
                    // on that later.

                    seriesLookup[linkId] = record;
                }
                // Else it was a non-series movie.  No problem.
            } else {
                // Check if series disc.
                regex2 = /series="(\d+)"/;
                if (regex2.test(seriesInfo)) {
                    linkId = RegExp.$1;
                } else if (regex2.test(movieInfo)) {
                    // If one disc of a series is at home, and the other is 
                    // still in the queue, the one in the queue will not have
                    // a rating.
                    // If the series disc in the queue is the first movie in
                    // the queue, all info will be in the movieInfo, not the
                    // seriesInfo... this is that case.
                    linkId = RegExp.$1;
                } else {
                    // Unexpected result.
                    alert('Unexpected situation: no series ID found.\n' +
                            'Please let the script owner know.\n\n' +
                            'Position:' + pos + '\n\n' +
                            'Series info:\n' + seriesInfo +
                            '\n\nMovie info:\n' + movieInfo);
                    done(false);
                    return;
                }

                regex2 = /OR(\d+)(?:.*?\n)*?.*?class="tt".*?<a.*?>(.*?)<\/a>/;
                if (regex2.test(movieInfo)) {
                    id = RegExp.$1;
                    title = RegExp.$2;

                    record = {
                        "id": id,
                        "title": title,
                        "link": linkId,
                        "origPos": pos++
                    };
                    sortInfo.push(record);
                } else {
                    // Unexpected result.
                    alert('Unexpected situation: no movie ID found.\n' +
                            'Please let the script owner know.\n\n' + 
                            'Position:' + pos + '\n\n' +
                            'Series info:\n' + seriesInfo +
                            '\n\nMovie info:\n' + movieInfo);
                    done(false);
                    return;
                }
            }
        }

        getQueue = [];
        var algorithm = sortByAvgRating ? "avgRating" : "usrRating";

        // Make sure all movies have the rating that is being sorted on.
        for (pos = 0, len = sortInfo.length; pos < len; pos++) {
            // Only do this for non-links.
            if (!sortInfo[pos].link) {
                if (!sortInfo[pos][algorithm]) {
                    getQueue.push(sortInfo[pos]);
                }
            }
        }
        totalQueueCount = getQueue.length;
        if (0 !== totalQueueCount) {
            fixRatings(false, algorithm);
        } else {
            checkSeriesLinks(algorithm);
        }
    }

    function reorderQueue(evt) {
        // Prevent the user from pressing the buttons again.
        setSortButtonState(false);

        // Reset sort options.
        cancelledSort = false;

        // Let GUI redraw buttons.
        var delayed = function () {
            switch (evt.target.value) {
            case 'reverse':
                reverse();
                break;
            case 'shuffle':
                shuffle();
                break;
            case 'length':
                showLength();
                break;
            case 'title':
                sortByTitle();
                break;
            case 'instantTop':
                moveInstantTop();
                break;
            case 'instantBottom':
                moveInstantBottom();
                break;
            case 'usrRating':
                sortByRating(false);
                break;
            case 'avgRating':
                sortByRating(true);
                break;
            case 'genre':
                sortByGenre();
                break;
            case 'availability':
                sortByAvailability();
                break;
            }
        };
        setTimeout(delayed, 0);
    }

    function fixRatings(fixLinks, algorithm) {
        var record = getQueue.pop();
        if (!record) {
            // Unexpected result.
            alert('Unexpected situation: no record found in queue.\n' +
                    'Please let the script owner know.\n\n' +
                    'FixLinks: ' + fixLinks + '\nAlgorithm: ' + algorithm);
            done(false);
            return;
        }

        // Update progress.
        var txt = fixLinks ? 'series' : 'movie';
        if (0 !== getQueue.length) {
            var pct = ((1 - getQueue.length / totalQueueCount) * 100).toFixed(0);
            setProgressStatus('st', 'Getting ' + txt + ' info: ' + pct + '%');
        } else {
            setProgressStatus('st', 'Getting ' + txt + ' info: 100%', false);
        }

        var id = fixLinks ? record.link : record.id;
        var url = "http://www.netflix.com/JSON/BobMovieHtml?movieid=" + id;
        GM_xmlhttpRequest({
            'method': 'GET',
            'url': url,
            'onload': function (xhr) {
                parseFixRatings(fixLinks, algorithm, record, xhr.responseText);
            }
        });
    }

    function parseFixRatings(fixLinks, algorithm, record, text) {
        // Use value > 5 to make them appear on top if rating cannot be
        // retrieved.
        var usrRating = 100;
        var avgRating = 100;

        // JSON is returned, so escape quotes.
        var regex = /starbar-.*?<span class=\\"rating\\">(.*?)<.*?starbar-avg.*?<span class=\\"rating\\">(.*?)</;
        if (regex.test(text)) {
            usrRating = RegExp.$1;
            avgRating = RegExp.$2;
        } 
        // Else no match... use high default values.

        record.usrRating = usrRating;
        record.avgRating = avgRating;
        if (fixLinks) {
            var linkId = record.link;
            record = {
                "usrRating": usrRating,
                "avgRating": avgRating
            };
            seriesLookup[linkId] = record;
        }

        if (cancelledSort) {
            realCancelSort();
        } else {
            var delayed;
            if (0 === getQueue.length) {
                // Processed all items in getQueue; on to next step.
                if (fixLinks) {
                    doActualSort(algorithm);
                } else {
                    delayed = function () {
                        checkSeriesLinks(algorithm);
                    };
                    setTimeout(delayed, XHR_DELAY);
                }
            } else {
                delayed = function () {
                    fixRatings(fixLinks, algorithm);
                };
                setTimeout(delayed, XHR_DELAY);
            }
        }
    }

    function checkSeriesLinks(algorithm) {
        getQueue = [];

        // Try to fix series links.
        for (var pos = 0, len = sortInfo.length; pos < len; pos++) {
            var linkId = sortInfo[pos].link;
            if (linkId) {
                var record = seriesLookup[linkId];
                if (record) {
                    sortInfo[pos].usrRating = record.usrRating;
                    sortInfo[pos].avgRating = record.avgRating;
                } else {
                    getQueue.push(sortInfo[pos]);
                }
            }
        }
        totalQueueCount = getQueue.length;
        if (0 !== getQueue.length) {
            fixRatings(true, algorithm);
        } else {
            doActualSort(algorithm);
        }
    }

    return {
        //
        // Public functions
        // (These access private variables and functions through "closure".)
        //

        // Initialize this script.
        init: function () {
            // Build the GUI for this script.
            buildGui();

            // Now wait for the user to press a button.
        }
    };
}());
// End singleton pattern.

// Run this script.
NetflixQueueSorter.init();

///////////////////////////////////////////////////////////////////////////////