diff --git a/server/djrandom.init b/server/djrandom.init
new file mode 100755
index 0000000000000000000000000000000000000000..53dbd31730c9b70ece759157ee8b474ef01c9c89
--- /dev/null
+++ b/server/djrandom.init
@@ -0,0 +1,88 @@
+#!/bin/bash
+#
+# Init script for djrandom frontend and backend workers.
+#
+### BEGIN INIT INFO
+# Provides:          djrandom
+# Required-Start:    $local_fs $remote_fs $network $syslog $named
+# Required-Stop:     $local_fs $remote_fs $network $syslog $named
+# Default-Start:     2 3 4 5
+# Default-Stop:      0 1 6
+# X-Interactive:     true
+# Short-Description: Start/stop DJRandom daemons
+### END INIT INFO
+
+# Usually tools will be installed in /usr/local/bin.
+PATH=/usr/local/bin:$PATH
+
+RUN_DIR=/var/run/djrandom
+USER=djrandom
+DAEMONS="receiver scanner fingerprinter frontend"
+COMMON_OPTIONS="--debug --user $USER"
+
+. /lib/lsb/init-functions
+
+[ -r /etc/default/djrandom ] && . /etc/default/djrandom
+
+
+start_daemons() {
+    ret=0
+    [ -d ${RUN_DIR} ] || mkdir -p ${RUN_DIR}
+    [ -n "${USER}" ] && chown ${USER} ${RUN_DIR}
+    for daemon in ${DAEMONS} ; do
+        program_name="djrandom-${daemon}"
+        pidfile="${RUN_DIR}/${program_name}.pid"
+
+        log_daemon_msg "Starting DJRandom ${daemon}" "${program_name}"
+
+        upcase_prog=$(echo ${program_name} | tr a-z A-Z)
+        program_opts=$(eval echo \${$(echo ${upcasevar})_OPTIONS})
+        options="${COMMON_OPTIONS} ${program_opts}"
+        if start-stop-daemon --start --quiet --pidfile ${pidfile} \
+            --exec ${program_name} -- --pidfile ${pidfile} --user ${USER} \
+            ${options} ; then
+            log_end_msg 0
+        else
+            log_end_msg 1
+            ret=1
+        fi
+    done
+    return ${ret}
+}
+
+stop_daemons() {
+    for daemon in ${DAEMONS} ; do
+        program_name="djrandom-${daemon}"
+        pidfile="${RUN_DIR}/${program_name}.pid"
+
+        log_daemon_msg "Stopping DJRandom ${daemon}" "${program_name}"
+        start-stop-daemon --stop --oknodo --quiet --pidfile ${pidfile}
+        log_end_msg 0
+    done
+}
+
+
+err=0
+
+case "$1" in
+    start)
+        start_daemons
+        err=$?
+        ;;
+
+    stop)
+        stop_daemons
+        ;;
+
+    restart)
+        stop_daemons
+        start_daemons
+        err=$?
+        ;;
+
+    *)
+        echo "Usage: $0 {start|stop|restart}"
+        ;;
+esac
+
+exit $err
diff --git a/server/djrandom/fingerprint/dedup.py b/server/djrandom/fingerprint/dedup.py
index 3d98b2f1535b75a02e69bc64487431f0595f54e1..652369f955b14c00af61480bb0d1ec305942768a 100644
--- a/server/djrandom/fingerprint/dedup.py
+++ b/server/djrandom/fingerprint/dedup.py
@@ -1,3 +1,4 @@
+import eyeD3
 import fp
 import os
 import optparse
@@ -11,9 +12,17 @@ from djrandom.database import Session, init_db
 
 log = logging.getLogger(__name__)
 
+# Monkey-patch eyeD3 so that it does not look at file extensions to
+# figure out if something is an MP3 or not.
+eyeD3.tag.isMp3File = lambda x: True
+
 
 class DeDuper(object):
 
+    def __init__(self):
+        self.songs_to_remove = {}
+        self.dupes_cache = set()
+
     def _generate_code_json(self, jsondata, track_id):
         """Parse the JSON string output of echoprint-codegen, and return
         a structure that fp.ingest() can deal with.
@@ -42,9 +51,11 @@ class DeDuper(object):
         if title: data["track"] = title
         return data
 
-    def dedupe(self):
+    def dedupe(self, dry_run=True):
         self._ingest()
         self._scan_for_dupes()
+        if not dry_run:
+            self._cleanup()
 
     def _ingest(self):
         self.codes = {}
@@ -58,7 +69,7 @@ class DeDuper(object):
         mp3s = MP3.query.filter(
             (MP3.artist == u'bonobo') 
             & (MP3.state == MP3.READY)
-            & (MP3.echoprint_fp != None))
+            & (MP3.has_fingerprint == True))
         for mp3 in mp3s:
             code = self._generate_code_json(mp3.echoprint_fp, mp3.sha1)
             if not code:
@@ -72,13 +83,17 @@ class DeDuper(object):
         # Now dedupe by going through all our codes over again.
         log.debug('de-duping fingerprint database...')
         start = time.time()
+        dup_count = 0
         for sha1, code in self.codes.iteritems():
             results = fp.query_fp(code, local=True).results
             if len(results) < 2:
                 continue
-            self._dedupe_song(sha1, code, results)
+            if self._dedupe_song(sha1, code, results):
+                dup_count += 1
         elapsed = time.time() - start
         log.debug('de-duped fingerprint database in %g seconds' % elapsed)
+        log.debug('found %d duplicates' % dup_count)
+        return dup_count
 
     def _dedupe_song(self, sha1, code_string, results):
         """Find fingerprint matches and eventually de-duplicate a song.
@@ -135,34 +150,87 @@ class DeDuper(object):
 
         # Actually de-duplicate the songs we've found.
         self._resolve_dupes([x[0] for x in dupes])
+        return True
+
+    def _get_song_score(self, mp3):
+        af = eyeD3.Mp3AudioFile(mp3.path)
+
+        # Get encoding parameters.
+        bitrate = af.getBitRate()[1]
+        duration = 30 * (int(af.getPlayTime()) / 30) # round to 30 secs
+
+        # Count metadata tags.
+        tag = af.getTag()
+        has_album = not (not tag.getAlbum())
+        has_artist = not (not tag.getArtist())
+        has_title = not (not tag.getTitle())
+        has_genre = not (not tag.getGenre())
+        has_year = not (not tag.getYear())
+        has_tracknum = (tag.getTrackNum()[0] is not None)
+        has_images = not (not tag.getImages())
+        num_meta = (4 * int(has_images) 
+                    + 2 * sum(map(int, (has_album, has_artist, has_title)))
+                    + sum(map(int, (has_genre, has_year, has_tracknum))))
+
+        return (bitrate, duration, num_meta)
 
     def _resolve_dupes(self, hashes):
         """Perform best duplicate selection and remove dupes from db."""
-        log.debug('remove_dupes(%s)' % ','.join(hashes))
+        hashes_key = ','.join(sorted(hashes))
+        log.debug('remove_dupes(%s)' % hashes_key)
+        if hashes_key in self.dupes_cache:
+            return self.dupes_cache[hashes_key]
+
+        def _compare_score(a, b):
+            a_bitrate, a_duration, a_nmeta = a[0]
+            b_bitrate, b_duration, b_nmeta = b[0]
+            res = cmp(a_bitrate, b_bitrate)
+            if res == 0:
+                res = cmp(a_duration, b_duration)
+                if res == 0:
+                    res = cmp(a_nmeta, b_nmeta)
+            return res
 
         # Compute 'score' for each song and sort them.
         scores = []
-        mp3s = MP3.query.filter(MP3.sha1 in hashes)
+        mp3s = MP3.query.filter(MP3.sha1.in_(hashes))
         for mp3 in mp3s:
             scores.append((self._get_song_score(mp3), mp3.sha1))
-        scores.sort(key=lambda x: x[0])
+        scores.sort(cmp=_compare_score, reverse=True)
         best_song = scores[0][1]
         log.debug('remove_dupes: best song is %s' % best_song)
+        log.debug('remove_dupes: score dump:')
+        for score, sha1 in scores:
+            bitrate, duration, nmeta = score
+            log.debug(' * (bitrate=%s, duration=%s, nmeta=%s) %s' % (
+                    bitrate, duration, nmeta, sha1))
 
         # Remove all the other songs.
-        songs_to_remove = [x for x in hashes if x != best_song]
-        
-
-
-def run_deduper(db_url):
+        songs_to_remove = dict((x, best_song) for x in hashes if x != best_song)
+        log.info('remove_dupes: songs to remove: %s' % str(songs_to_remove))
+        self.songs_to_remove.update(songs_to_remove)
+        self.dupes_cache[hashes_key] = best_song
+        return best_song
+
+    def _cleanup(self):
+        for sha1, duplicate_of in self.songs_to_remove.iteritems():
+            # Mark the MP3 as duplicate, remove the associated file.
+            mp3 = MP3.query.get(sha1)
+            mp3.mark_as_duplicate(duplicate_of)
+            Session.add(mp3)
+        Session.commit()
+
+
+def run_deduper(db_url, dry_run):
     init_db(db_url)
     dup = DeDuper()
-    dup.dedupe()
+    dup.dedupe(dry_run)
 
 
 def main():
     parser = optparse.OptionParser()
     parser.add_option('--db_url')
+    parser.add_option('--apply', action='store_true')
     daemonize.add_standard_options(parser)
     utils.read_config_defaults(
         parser, os.getenv('DJRANDOM_CONF', '/etc/djrandom.conf'))
@@ -173,7 +241,7 @@ def main():
         parser.error('Too many arguments')
 
     daemonize.daemonize(opts, run_deduper,
-                        (opts.db_url,))
+                        (opts.db_url, not opts.apply))
 
 
 if __name__ == '__main__':
diff --git a/server/djrandom/fingerprint/fingerprint.py b/server/djrandom/fingerprint/fingerprint.py
index 8249395dfe6ff86d47e89b18e4279ef6e9064317..c1fd67c859edb892bf11addfde1ef5828a57474b 100644
--- a/server/djrandom/fingerprint/fingerprint.py
+++ b/server/djrandom/fingerprint/fingerprint.py
@@ -26,20 +26,17 @@ class Fingerprinter(object):
         if fp_json:
             # Remove the square brackets that make fp_json an array.
             # (Ugly Hack!)
-            mp3.echoprint_fp = fp_json[2:-2]
+            mp3.set_fingerprint(fp_json[2:-2])
 
     def compute_fingerprints(self, run_once):
         """Compute fingerprints of new files."""
         while True:
-            mp3 = MP3.query.filter((MP3.state == MP3.READY)
-                                   & (MP3.echoprint_fp == None)
-                                   ).limit(1).first()
+            mp3 = MP3.get_with_no_fingerprint().limit(1).first()
             if not mp3:
                 if run_once:
                     break
                 Session.remove()
-                self.idx.commit()
-                time.sleep(60)
+                time.sleep(300)
                 continue
             log.info('fingerprinting %s' % mp3.sha1)
             try:
diff --git a/server/djrandom/frontend/api_views.py b/server/djrandom/frontend/api_views.py
index 8cae5d75bf0a8bae791bc125d7dfb041695e057b..7b19685aacda620f0357838abf76f1a4203cbdb1 100644
--- a/server/djrandom/frontend/api_views.py
+++ b/server/djrandom/frontend/api_views.py
@@ -80,6 +80,7 @@ def get_album_image(artist, album):
 def save_playlist():
     hashes = request.form.get('h', '')
     uuid = request.form.get('uuid')
+    title = request.form.get('title')
     if uuid == 'null':
         uuid = None
     playlist = None
@@ -89,6 +90,7 @@ def save_playlist():
         playlist = Playlist(uuid=uuid, userid=g.userid)
     playlist.modified_at = datetime.now()
     playlist.contents = hashes
+    playlist.title = title
     Session.add(playlist)
     Session.commit()
     return jsonify(uuid=playlist.uuid, status=True)
@@ -120,6 +122,23 @@ def playlist_info_json(uuid):
     return jsonify(playlist.to_dict())
 
 
+@app.route('/json/playlist/by_title/<uuid>/<title>')
+@require_auth
+def playlist_info_by_title_json(uuid, title):
+    title = urllib.unquote(title)
+    playlist = Playlist.get_by_title(uuid, title)
+    if not playlist:
+        abort(404)
+    return jsonify(playlist.to_dict())
+
+
+@app.route('/json/playlist/list/<uuid>')
+@require_auth
+def playlist_list_by_uuid_json(uuid):
+    playlists = [x.to_dict() for x in Playlist.get_all_by_user(uuid)]
+    return jsonify(results=playlists)
+
+
 @app.route('/json/search')
 @require_auth
 def search_json():
@@ -141,9 +160,10 @@ def search_json():
 @require_auth
 def more_like_these_json():
     hashes = request.form.get('h', '').split(',')
+    n = int(request.form.get('n', 5))
     results = []
     if hashes:
-        results = svcs['searcher'].more_like_these(hashes)
+        results = svcs['searcher'].more_like_these(hashes, n)
     return jsonify(results=results)
 
 
@@ -156,6 +176,14 @@ def most_played_json():
     return jsonify(results=most_played)
 
 
+@app.route('/json/never_played', methods=['GET'])
+@require_auth
+def never_played_json():
+    n = int(request.args.get('n', 20))
+    never_played = MP3.never_played(n)
+    return jsonify(results=never_played)
+
+
 @app.route('/json/last_uploaded', methods=['GET'])
 @require_auth
 def last_uploaded_json():
@@ -169,8 +197,8 @@ def last_uploaded_json():
 def markov_json():
     n = int(request.form.get('n', 10))
     hashes = request.form.get('h', '').split(',')
-    last_song = hashes[-1]
-    sequence = svcs['markov'].generate_sequence(last_song, 2, n)
+    last_songs = hashes[-2:]
+    sequence = svcs['markov'].generate_sequence(last_songs, 2, n)
     return jsonify(results=sequence)
 
 
diff --git a/server/djrandom/frontend/frontend.py b/server/djrandom/frontend/frontend.py
index c0ed117cb9330a5c696a9af812b41df488fc06c4..8eca9b368081cb42e99f270dd04f83757f59a09b 100644
--- a/server/djrandom/frontend/frontend.py
+++ b/server/djrandom/frontend/frontend.py
@@ -19,7 +19,7 @@ log = logging.getLogger(__name__)
 
 
 def run_frontend(port, solr_url, db_url, lastfm_api_key, album_art_dir,
-                 email_sender, markov_data_file):
+                 email_sender, markov_data_file, do_profile):
     init_db(db_url)
 
     svcs['searcher'] = Searcher(solr_url)
@@ -32,6 +32,18 @@ def run_frontend(port, solr_url, db_url, lastfm_api_key, album_art_dir,
         log.error('Could not read Markov data from %s: %s' % (
                 markov_data_file, str(e)))
 
+    # Start the WSGI profiling middleware, if requested.
+    if do_profile:
+        from repoze.profile.profiler import AccumulatingProfileMiddleware
+        app.wsgi_app = AccumulatingProfileMiddleware(
+            app.wsgi_app,
+            log_filename='/var/tmp/djrandom-profile.log',
+            cachegrind_filename='/var/tmp/djrandom-profile.cachegrind',
+            discard_first_request=True,
+            flush_at_shutdown=True,
+            path='/__profile__'
+            )
+
     http_server = WSGIServer(('0.0.0.0', port), app)
     http_server.serve_forever()
 
@@ -46,6 +58,7 @@ def main():
     parser.add_option('--album_art_dir', default='/var/tmp/album-image-cache')
     parser.add_option('--markov_data',
                       default='/var/lib/djrandom/djrandom-markov.dat')
+    parser.add_option('--profile', action='store_true')
     daemonize.add_standard_options(parser)
     utils.read_config_defaults(
         parser, os.getenv('DJRANDOM_CONF', '/etc/djrandom.conf'))
@@ -58,7 +71,7 @@ def main():
     daemonize.daemonize(opts, run_frontend,
                         (opts.port, opts.solr_url, opts.db_url,
                          opts.lastfm_api_key, opts.album_art_dir,
-                         opts.email_sender, opts.markov_data),
+                         opts.email_sender, opts.markov_data, opts.profile),
                         support_gevent=True)
 
 
diff --git a/server/djrandom/frontend/search.py b/server/djrandom/frontend/search.py
index 720b631c185a21fcbdc57148edfd4637800eb3e2..9f96651b332fc103de39c6f522eb0ac57aaadba2 100644
--- a/server/djrandom/frontend/search.py
+++ b/server/djrandom/frontend/search.py
@@ -14,11 +14,11 @@ class Searcher(object):
         for doc in results.results:
             yield doc['score'], doc['id']
 
-    def more_like_these(self, hashes):
+    def more_like_these(self, hashes, n=5):
         """Return a set of hashes that are related to the ones provided."""
         query_str = ' OR '.join('(id:%s)' % x for x in hashes)
         results = self.solr.select(
-            q=query_str, fields='score,id',
+            q=query_str, fields='score,id', rows=n,
             mlt='true', mlt_fl='text', mlt_mindf=1, mlt_mintf=1)
 
         # Parse and uniq-ify the results.
diff --git a/server/djrandom/frontend/static/circle.skin/bgr.jpg b/server/djrandom/frontend/static/circle.skin/bgr.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..3cf5998fae6ae8911f52d3ebb3141c51b8b03c02
Binary files /dev/null and b/server/djrandom/frontend/static/circle.skin/bgr.jpg differ
diff --git a/server/djrandom/frontend/static/circle.skin/bgr.png b/server/djrandom/frontend/static/circle.skin/bgr.png
new file mode 100644
index 0000000000000000000000000000000000000000..61d52e5edc80378aa9f3038ba2f81f11d74d9922
Binary files /dev/null and b/server/djrandom/frontend/static/circle.skin/bgr.png differ
diff --git a/server/djrandom/frontend/static/circle.skin/buffer.png b/server/djrandom/frontend/static/circle.skin/buffer.png
new file mode 100644
index 0000000000000000000000000000000000000000..e36c65ae7ac1cd4c61d30343681fba5b804e4e6d
Binary files /dev/null and b/server/djrandom/frontend/static/circle.skin/buffer.png differ
diff --git a/server/djrandom/frontend/static/circle.skin/circle.player.css b/server/djrandom/frontend/static/circle.skin/circle.player.css
new file mode 100644
index 0000000000000000000000000000000000000000..d8be510925a53d12616e4c5173adc892fa7d11d2
--- /dev/null
+++ b/server/djrandom/frontend/static/circle.skin/circle.player.css
@@ -0,0 +1,132 @@
+/*
+ * Project: CirclePlayer
+ * http://www.jplayer.org
+ *
+ * Copyright (c) 2011 Happyworm Ltd
+ *
+ * Author: Silvia Benvenuti
+ * Edited by: Mark J Panaghiston
+ * Date: 6th May 2011
+ * Artwork inspired by: http://forrst.com/posts/Untitled-CJz
+ */
+
+.cp-container {
+	position: relative;
+	width: 104px; /* 200 - (2 * 48) */
+	height: 104px;
+	background: url("bgr.png") 0 0 no-repeat;
+	padding: 48px;
+	-webkit-tap-highlight-color:rgba(0,0,0,0);
+}
+
+.cp-container :focus {
+	border:none; 
+	outline:0;
+}
+
+.cp-buffer-1,
+.cp-buffer-2,
+.cp-progress-1,
+.cp-progress-2 {
+	position: absolute;
+	top: 0;
+	left: 0;
+	width: 104px;
+	height: 104px;
+	clip:rect(0px,52px,104px,0px);
+
+	-moz-border-radius:52px;
+	-webkit-border-radius:52px;
+	border-radius:52px;
+}
+
+.cp-buffer-1,
+.cp-buffer-2 {
+	background: url("buffer.png") 0 0 no-repeat;
+}     
+
+
+/* FALLBACK for .progress
+ * (24 steps starting from 1hr filled progress, Decrease second value by 104px for next step)
+ * (It needs the container selector to work. Or use div)
+ */ 
+
+.cp-container .cp-fallback {
+	background: url("progress_sprite.jpg") no-repeat;
+	background-position: 0 104px; 
+}
+
+.cp-progress-1,
+.cp-progress-2 {
+	background: url("progress.png") 0 0 no-repeat;
+} 
+
+.cp-buffer-holder,
+.cp-progress-holder,
+.cp-circle-control {
+	position:absolute;
+	width:104px;
+	height:104px;
+} 
+
+.cp-circle-control {
+	cursor:pointer;
+}
+
+.cp-buffer-holder,
+.cp-progress-holder {
+	clip:rect(0px,104px,104px,52px);
+	display:none;
+}
+
+
+/* This is needed when progress is greater than 50% or for fallback */
+
+.cp-buffer-holder.cp-gt50,
+.cp-progress-holder.cp-gt50,
+.cp-progress.cp-fallback{
+	clip:rect(auto, auto, auto, auto);
+}  
+
+.cp-controls {
+	margin:0;
+	padding: 26px;
+}
+
+.cp-controls li{
+	list-style-type:none;
+	display: block;
+
+	/*IE Fix*/  
+	position:absolute;
+}
+
+.cp-controls li a{
+	position: relative;
+	display: block;
+	width:50px;
+	height:50px;
+	text-indent:-9999px;
+	z-index:1;      
+}
+
+.cp-controls .cp-play {
+	background: url("controls.jpg") 0 0 no-repeat;
+}
+
+.cp-controls .cp-play:hover {
+	background: url("controls.jpg") -50px 0 no-repeat;
+}
+
+.cp-controls .cp-pause {
+	background: url("controls.jpg") 0 -50px no-repeat;
+}
+
+.cp-controls .cp-pause:hover {
+	background: url("controls.jpg") -50px -50px no-repeat;
+}
+
+.cp-jplayer {
+	width: 0;
+	height: 0; 
+}
diff --git a/server/djrandom/frontend/static/circle.skin/controls.jpg b/server/djrandom/frontend/static/circle.skin/controls.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..be3798f97992df3bc740202539bf380ffefbd63a
Binary files /dev/null and b/server/djrandom/frontend/static/circle.skin/controls.jpg differ
diff --git a/server/djrandom/frontend/static/circle.skin/progress.png b/server/djrandom/frontend/static/circle.skin/progress.png
new file mode 100644
index 0000000000000000000000000000000000000000..d839da4df26f14a8fc609dd7986a2a418a186cc2
Binary files /dev/null and b/server/djrandom/frontend/static/circle.skin/progress.png differ
diff --git a/server/djrandom/frontend/static/circle.skin/progress_sprite.jpg b/server/djrandom/frontend/static/circle.skin/progress_sprite.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..5e55393db43d764d45831a2af28613b08b42a2d8
Binary files /dev/null and b/server/djrandom/frontend/static/circle.skin/progress_sprite.jpg differ
diff --git a/server/djrandom/frontend/static/css/style.css b/server/djrandom/frontend/static/css/style.css
index c052cd69c7c8df9a5f9e79b5d63bd43ec0f0ee5d..8ae4ecff686c61f03c2288eb79fddbf2b9373848 100644
--- a/server/djrandom/frontend/static/css/style.css
+++ b/server/djrandom/frontend/static/css/style.css
@@ -12,13 +12,22 @@ body {
 }
 
 #rbox {
-    float: right;
-    width: 500px;
+   float: right;
+   width: 500px;
+   padding-top: 45px;
 }
 
 #lbox {
-    padding-top: 40px;
-    margin-right: 501px;
+   padding-top: 5px;
+   margin-right: 501px;
+}
+
+#loaderImgDiv {
+   float: left;
+   width: 20px;
+   margin: 0;
+   margin-left: -24px;
+   padding-top: 7px;
 }
 
 #tophdr {
@@ -29,26 +38,23 @@ body {
 }
 
 #queryField {
-	width:75%;
-    font-size: 1.1em;
-	border:1px solid red;
+   width:75%;
+   font-size: 1.1em;
+   border:1px solid red;
 }
 
-a#playlistLast25,a#playlistRandom,a#playlistMost {
-	font-size:0.8em;
+.buttonbox a {
+   font-size:0.8em;
+   cursor: pointer;
 }
 
-a#playlistExtendBtn,a#playlistClearBtn,a#playlistStreamBtn {
-	font-size:0.8em;
-}
-h1 {
-    font-size: 66px;
-    letter-spacing: -4px;
-    margin-bottom: 10px;
-    padding-top: 10px;
-    padding-left: 140px;
-    background: url(/static/img/pirate_tape_small.png) top left no-repeat;
-    min-height: 90px;
+h1.title {
+   font-size: 32px;
+   letter-spacing: -3px;
+   margin: 0;
+   padding-left: 50px;
+   background: url(/static/img/pirate_tape_tiny.png) 0px 4px no-repeat;
+   min-height: 36px;
 }
 
 #playerintf {
@@ -128,6 +134,7 @@ h1 {
     cursor: pointer;
     outline: none;
     text-decoration: none;
+    margin-left: 5px;
 }
 
 .ctlbox .ctl_remove {
@@ -205,10 +212,13 @@ table.summary {
   background: #ff8;
 }
 
-
-
 #debug {
-    margin-top: 30px;
-    font-size: 75%;
-    color: #999;
+   margin-top: 30px;
+   font-size: 75%;
+   color: #999;
+}
+
+#debug p {
+   margin: 0;
+   padding: 0;
 }
diff --git a/server/djrandom/frontend/static/img/loader.gif b/server/djrandom/frontend/static/img/loader.gif
new file mode 100644
index 0000000000000000000000000000000000000000..599f03bdf26b8b2c3f1ee20aeb03a8ad65f615ce
Binary files /dev/null and b/server/djrandom/frontend/static/img/loader.gif differ
diff --git a/server/djrandom/frontend/static/img/pirate_tape_tiny.png b/server/djrandom/frontend/static/img/pirate_tape_tiny.png
new file mode 100644
index 0000000000000000000000000000000000000000..0998239842f129f28baf73cf95e8fb5197424456
Binary files /dev/null and b/server/djrandom/frontend/static/img/pirate_tape_tiny.png differ
diff --git a/server/djrandom/frontend/static/js/circle.player.js b/server/djrandom/frontend/static/js/circle.player.js
new file mode 100644
index 0000000000000000000000000000000000000000..02c408dab66da615f1ea92d397cb74af86b01b2b
--- /dev/null
+++ b/server/djrandom/frontend/static/js/circle.player.js
@@ -0,0 +1,243 @@
+/*
+ * CirclePlayer for the jPlayer Plugin (jQuery)
+ * http://www.jplayer.org
+ *
+ * Copyright (c) 2009 - 2011 Happyworm Ltd
+ * Dual licensed under the MIT and GPL licenses.
+ *  - http://www.opensource.org/licenses/mit-license.php
+ *  - http://www.gnu.org/copyleft/gpl.html
+ *
+ * Version: 1.0.1 (jPlayer 2.0.9)
+ * Date: 30th May 2011
+ *
+ * Author: Mark J Panaghiston @thepag
+ *
+ * CirclePlayer prototype developed by:
+ * Mark Boas @maboa
+ * Silvia Benvenuti @aulentina
+ * Jussi Kalliokoski @quinirill
+ *
+ * Inspired by :
+ * Neway @imneway http://imneway.net/ http://forrst.com/posts/Untitled-CPt
+ * and
+ * Liam McKay @liammckay http://dribbble.com/shots/50882-Purple-Play-Pause
+ *
+ * Standing on the shoulders of :
+ * John Resig @jresig
+ * Mark Panaghiston @thepag
+ * Louis-Rémi Babé @Louis_Remi
+ */
+
+
+var CirclePlayer = function(jPlayerSelector, media, options) {
+	var	self = this,
+
+		defaults = {
+			// solution: "flash, html", // For testing Flash with CSS3
+			supplied: "m4a, oga",
+			// Android 2.3 corrupts media element if preload:"none" is used.
+			// preload: "none", // No point preloading metadata since no times are displayed. It helps keep the buffer state correct too.
+			cssSelectorAncestor: "#cp_container_1",
+			cssSelector: {
+				play: ".cp-play",
+				pause: ".cp-pause"
+			}
+		},
+
+		cssSelector = {
+			bufferHolder: ".cp-buffer-holder",
+			buffer1: ".cp-buffer-1",
+			buffer2: ".cp-buffer-2",
+			progressHolder: ".cp-progress-holder",
+			progress1: ".cp-progress-1",
+			progress2: ".cp-progress-2",
+			circleControl: ".cp-circle-control"
+		};
+
+	this.cssClass = {
+		gt50: "cp-gt50",
+		fallback: "cp-fallback"
+	};
+
+	this.spritePitch = 104;
+	this.spriteRatio = 0.24; // Number of steps / 100
+
+	this.player = $(jPlayerSelector);
+	this.media = $.extend({}, media);
+	this.options = $.extend(true, {}, defaults, options); // Deep copy
+
+	this.cssTransforms = Modernizr.csstransforms;
+	this.audio = {};
+	this.dragging = false; // Indicates if the progressbar is being 'dragged'.
+
+	this.eventNamespace = ".CirclePlayer"; // So the events can easily be removed in destroy.
+
+	this.jq = {};
+	$.each(cssSelector, function(entity, cssSel) {
+		self.jq[entity] = $(self.options.cssSelectorAncestor + " " + cssSel);
+	});
+
+	this._initSolution();
+	this._initPlayer();
+};
+
+CirclePlayer.prototype = {
+	_createHtml: function() {
+	},
+	_initPlayer: function() {
+		var self = this;
+		this.player.jPlayer(this.options);
+
+		this.player.bind($.jPlayer.event.ready + this.eventNamespace, function(event) {
+			if(event.jPlayer.html.used && event.jPlayer.html.audio.available) {
+				self.audio = $(this).data("jPlayer").htmlElement.audio;
+			}
+			$(this).jPlayer("setMedia", self.media);
+			self._initCircleControl();
+		});
+
+		this.player.bind($.jPlayer.event.play + this.eventNamespace, function(event) {
+			$(this).jPlayer("pauseOthers");
+		});
+
+		// This event fired as play time increments
+		this.player.bind($.jPlayer.event.timeupdate + this.eventNamespace, function(event) {
+			if (!self.dragging) {
+				self._timeupdate(event.jPlayer.status.currentPercentAbsolute);
+			}
+		});
+
+		// This event fired as buffered time increments
+		this.player.bind($.jPlayer.event.progress + this.eventNamespace, function(event) {
+			var percent = 0;
+			if((typeof self.audio.buffered === "object") && (self.audio.buffered.length > 0)) {
+				if(self.audio.duration > 0) {
+					var bufferTime = 0;
+					for(var i = 0; i < self.audio.buffered.length; i++) {
+						bufferTime += self.audio.buffered.end(i) - self.audio.buffered.start(i);
+						// console.log(i + " | start = " + self.audio.buffered.start(i) + " | end = " + self.audio.buffered.end(i) + " | bufferTime = " + bufferTime + " | duration = " + self.audio.duration);
+					}
+					percent = 100 * bufferTime / self.audio.duration;
+				} // else the Metadata has not been read yet.
+				// console.log("percent = " + percent);
+			} else { // Fallback if buffered not supported
+				// percent = event.jPlayer.status.seekPercent;
+				percent = 0; // Cleans up the inital conditions on all browsers, since seekPercent defaults to 100 when object is undefined.
+			}
+			self._progress(percent); // Problem here at initial condition. Due to the Opera clause above of buffered.length > 0 above... Removing it means Opera's white buffer ring never shows like with polyfill.
+			// Firefox 4 does not always give the final progress event when buffered = 100%
+		});
+
+		this.player.bind($.jPlayer.event.ended + this.eventNamespace, function(event) {
+			self._resetSolution();
+		});
+	},
+	_initSolution: function() {
+		if (this.cssTransforms) {
+			this.jq.progressHolder.show();
+			this.jq.bufferHolder.show();
+		}
+		else {
+			this.jq.progressHolder.addClass(this.cssClass.gt50).show();
+			this.jq.progress1.addClass(this.cssClass.fallback);
+			this.jq.progress2.hide();
+			this.jq.bufferHolder.hide();
+		}
+		this._resetSolution();
+	},
+	_resetSolution: function() {
+		if (this.cssTransforms) {
+			this.jq.progressHolder.removeClass(this.cssClass.gt50);
+			this.jq.progress1.css({'transform': 'rotate(0deg)'});
+			this.jq.progress2.css({'transform': 'rotate(0deg)'}).hide();
+		}
+		else {
+			this.jq.progress1.css('background-position', '0 ' + this.spritePitch + 'px');
+		}
+	},
+	_initCircleControl: function() {
+		var self = this;
+		this.jq.circleControl.grab({
+			onstart: function(){
+				self.dragging = true;
+			}, onmove: function(event){
+				var pc = self._getArcPercent(event.position.x, event.position.y);
+				self.player.jPlayer("playHead", pc).jPlayer("play");
+				self._timeupdate(pc);
+			}, onfinish: function(event){
+				self.dragging = false;
+				var pc = self._getArcPercent(event.position.x, event.position.y);
+				self.player.jPlayer("playHead", pc).jPlayer("play");
+			}
+		});
+	},
+	_timeupdate: function(percent) {
+		var degs = percent * 3.6+"deg";
+
+		var spriteOffset = (Math.floor((Math.round(percent))*this.spriteRatio)-1)*-this.spritePitch;
+
+		if (percent <= 50) {
+			if (this.cssTransforms) {
+				this.jq.progressHolder.removeClass(this.cssClass.gt50);
+				this.jq.progress1.css({'transform': 'rotate(' + degs + ')'});
+				this.jq.progress2.hide();
+			} else { // fall back
+				this.jq.progress1.css('background-position', '0 '+spriteOffset+'px');
+			}
+		} else if (percent <= 100) {
+			if (this.cssTransforms) {
+				this.jq.progressHolder.addClass(this.cssClass.gt50);
+				this.jq.progress1.css({'transform': 'rotate(180deg)'});
+				this.jq.progress2.css({'transform': 'rotate(' + degs + ')'});
+				this.jq.progress2.show();
+			} else { // fall back
+				this.jq.progress1.css('background-position', '0 '+spriteOffset+'px');
+			}
+		}
+	},
+	_progress: function(percent) {
+		var degs = percent * 3.6+"deg";
+
+		if (this.cssTransforms) {
+			if (percent <= 50) {
+				this.jq.bufferHolder.removeClass(this.cssClass.gt50);
+				this.jq.buffer1.css({'transform': 'rotate(' + degs + ')'});
+				this.jq.buffer2.hide();
+			} else if (percent <= 100) {
+				this.jq.bufferHolder.addClass(this.cssClass.gt50);
+				this.jq.buffer1.css({'transform': 'rotate(180deg)'});
+				this.jq.buffer2.show();
+				this.jq.buffer2.css({'transform': 'rotate(' + degs + ')'});
+			}
+		}
+	},
+	_getArcPercent: function(pageX, pageY) {
+		var	offset	= this.jq.circleControl.offset(),
+			x	= pageX - offset.left - this.jq.circleControl.width()/2,
+			y	= pageY - offset.top - this.jq.circleControl.height()/2,
+			theta	= Math.atan2(y,x);
+
+		if (theta > -1 * Math.PI && theta < -0.5 * Math.PI) {
+			theta = 2 * Math.PI + theta;
+		}
+
+		// theta is now value between -0.5PI and 1.5PI
+		// ready to be normalized and applied
+
+		return (theta + Math.PI / 2) / 2 * Math.PI * 10;
+	},
+	setMedia: function(media) {
+		this.media = $.extend({}, media);
+		this.player.jPlayer("setMedia", this.media);
+	},
+	play: function(time) {
+		this.player.jPlayer("play", time);
+	},
+	pause: function(time) {
+		this.player.jPlayer("pause", time);
+	},
+	destroy: function() {
+		this.player.unbind(this.eventNamespace);
+		this.player.jPlayer("destroy");
+	}
+};
diff --git a/server/djrandom/frontend/static/js/djr.min.js b/server/djrandom/frontend/static/js/djr.min.js
index 81fe21552500ec84ba759e3e1175f4d940dbe9cf..3353fb35fa092fc9cf97d67b8632ebced2c871d8 100644
--- a/server/djrandom/frontend/static/js/djr.min.js
+++ b/server/djrandom/frontend/static/js/djr.min.js
@@ -1,22 +1,28 @@
-djr={};var CHARS="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".split("");djr.generateRandomId=function(){var a=[],b=CHARS,c,d=b.length;for(c=0;c<40;c++)a[c]=b[0|Math.random()*d];return a.join("")};djr.Backend=function(){};djr.Backend.prototype.search=function(a,b,c){$.ajax({url:"/json/search",data:{q:a},dataType:"json",type:"GET",context:c||this,success:function(a){b(a.results)}})};djr.Backend.prototype.moreLikeThese=function(a,b,c){$.ajax({url:"/json/morelikethese",data:{h:a.join(",")},dataType:"json",type:"POST",context:c||this,success:function(a){b(a.results)}})};
-djr.Backend.prototype.getPlaylist=function(a,b,c){$.ajax({url:"/json/playlist/get/"+a,dataType:"json",type:"GET",context:c||this,success:function(c){b(new djr.PlaylistChunk(c.songs,a))}})};djr.Backend.prototype.savePlaylist=function(a,b){$.ajax({url:"/json/playlist/save",data:{uuid:a,h:b.join(",")},type:"POST"})};djr.Backend.prototype.streamPlaylist=function(a,b,c,d){$.ajax({url:"/json/playlist/stream",data:{uuid:a,stream:b?"y":"n"},dataType:"json",type:"POST",context:d||this,success:function(a){c(a)}})};
-djr.Backend.prototype.getHtmlForSongs=function(a,b,c){$.ajax({url:"/fragment/songs",data:{h:a.join(",")},dataType:"html",type:"POST",context:c||this,success:b})};djr.Backend.prototype.nowPlaying=function(a,b){$.ajax({url:"/json/playing",data:{cur:a,prev:b.join(",")},type:"POST"})};djr.Backend.prototype.mostPlayedPlaylist=function(a,b,c){$.ajax({url:"/json/most_played",data:{n:a},dataType:"json",type:"GET",context:c||this,success:function(a){b(a.results)}})};
-djr.Backend.prototype.lastPlaylist=function(a,b,c){$.ajax({url:"/json/last_uploaded",data:{n:a},dataType:"json",type:"GET",context:c||this,success:function(a){b(a.results)}})};djr.Backend.prototype.markovPlaylist=function(a,b,c,d){$.ajax({url:"/json/markov",data:{h:b.join(","),n:a},dataType:"json",type:"POST",context:d||this,success:function(a){c(a.results)}})};djr.Backend.prototype.randomPlaylist=function(a,b,c){$.ajax({url:"/json/random",data:{n:a},dataType:"json",type:"GET",context:c||this,success:function(a){b(a.results)}})};djr.PlaylistChunk=function(a,b){this.songs=a||[];this.title=b};djr.PlaylistChunk.prototype.hasSong=function(a){return this.songs.indexOf(a)>=0};djr.PlaylistChunk.prototype.removeSong=function(a){this.songs=$.grep(this.songs,function(b){return b!=a})};djr.controlButtons=function(a){return'<div class="ctlbox" style="display:none"><a id="'+a+'_remove" class="ctl_btn ctl_remove">&nbsp;</a></div>'};
-djr.PlaylistChunk.prototype.wrapHtml=function(a,b){return'<div id="chunk_'+a+'" class="chunk"><div class="chunk_ctl_wrap">'+djr.controlButtons("chunk_ctl_"+a)+'<a class="chunk_title">'+this.title+'</a></div><div class="chunk_inner">'+(b||"")+"</div></div>"};djr.Playlist=function(a){this.uuid=a||djr.generateRandomId();this.chunks=[];this.song_map={};this.chunk_map={};this.next_chunk_id=0};
+djr={loading:function(a){var b=$("#loaderImg");a?b.show():b.hide()}};var CHARS="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".split("");djr.generateRandomId=function(){var a=[],b=CHARS,c,d=b.length;for(c=0;c<40;c++)a[c]=b[0|Math.random()*d];return a.join("")};djr.Backend=function(){};var wrap_with_loader=function(a){djr.loading(!0);var b=a.success;a.success=function(a,d,e){b(a,d,e);djr.loading(!1)};a.error=function(){djr.loading(!1)};return $.ajax(a)};djr.Backend.prototype.search=function(a,b,c){wrap_with_loader({url:"/json/search",data:{q:a},dataType:"json",type:"GET",context:c||this,success:function(a){b(a.results);djr.loading(!1)}})};
+djr.Backend.prototype.moreLikeThese=function(a,b,c){wrap_with_loader({url:"/json/morelikethese",data:{h:a.join(",")},dataType:"json",type:"POST",context:c||this,success:function(a){b(a.results)}})};djr.Backend.prototype.getPlList=function(a,b,c){$.ajax({url:"/json/playlist/list/"+a,dataType:"json",type:"GET",context:c||this,success:function(a){b(a.results)}})};djr.Backend.prototype.getPlaylist=function(a,b,c){$.ajax({url:"/json/playlist/get/"+a,dataType:"json",type:"GET",context:c||this,success:function(a){b(a.results)}})};
+djr.Backend.prototype.savePlaylist=function(a,b,c){$.ajax({url:"/json/playlist/save",data:{uuid:a,title:b,h:c.join(",")},type:"POST"})};djr.Backend.prototype.streamPlaylist=function(a,b,c,d){$.ajax({url:"/json/playlist/stream",data:{uuid:a,stream:b?"y":"n"},dataType:"json",type:"POST",context:d||this,success:function(a){c(a)}})};djr.Backend.prototype.getHtmlForSongs=function(a,b,c){wrap_with_loader({url:"/fragment/songs",data:{h:a.join(",")},dataType:"html",type:"POST",context:c||this,success:b})};
+djr.Backend.prototype.nowPlaying=function(a,b){$.ajax({url:"/json/playing",data:{cur:a,prev:b.join(",")},type:"POST"})};djr.Backend.prototype.neverPlayedPlaylist=function(a,b,c){wrap_with_loader({url:"/json/never_played",data:{n:a},dataType:"json",type:"GET",context:c||this,success:function(a){b(a.results)}})};djr.Backend.prototype.mostPlayedPlaylist=function(a,b,c){wrap_with_loader({url:"/json/most_played",data:{n:a},dataType:"json",type:"GET",context:c||this,success:function(a){b(a.results)}})};
+djr.Backend.prototype.lastPlaylist=function(a,b,c){wrap_with_loader({url:"/json/last_uploaded",data:{n:a},dataType:"json",type:"GET",context:c||this,success:function(a){b(a.results)}})};djr.Backend.prototype.markovPlaylist=function(a,b,c,d){wrap_with_loader({url:"/json/markov",data:{h:b.join(","),n:a},dataType:"json",type:"POST",context:d||this,success:function(a){c(a.results)}})};
+djr.Backend.prototype.randomPlaylist=function(a,b,c){wrap_with_loader({url:"/json/random",data:{n:a},dataType:"json",type:"GET",context:c||this,success:function(a){b(a.results)}})};djr.PlaylistChunk=function(a,b){this.songs=a||[];this.title=b};djr.PlaylistChunk.prototype.hasSong=function(a){return this.songs.indexOf(a)>=0};djr.PlaylistChunk.prototype.removeSong=function(a){this.songs=$.grep(this.songs,function(b){return b!=a})};djr.controlButtons=function(a){return'<div class="ctlbox" style="display:none"><a id="'+a+'_remove" class="ctl_btn ctl_remove">&nbsp;</a></div>'};
+djr.PlaylistChunk.prototype.wrapHtml=function(a,b){return'<div id="chunk_'+a+'" class="chunk"><div class="chunk_ctl_wrap">'+djr.controlButtons("chunk_ctl_"+a)+'<a class="chunk_title">'+this.title+'</a></div><div class="chunk_inner">'+(b||"")+"</div></div>"};djr.Playlist=function(a){this.uuid=a||djr.generateRandomId();this.chunks=[];this.song_map={};this.chunk_map={};this.title="";this.next_chunk_id=0};
 djr.Playlist.prototype.allSongs=function(){var a=[],b,c;for(b=0;b<this.chunks.length;b++)for(c=0;c<this.chunk_map[this.chunks[b]].songs.length;c++)a.push(this.chunk_map[this.chunks[b]].songs[c]);return a};djr.Playlist.prototype.createUniqueChunk=function(a,b){var c=[],d;for(d=0;d<a.length;d++)this.song_map[a[d]]==null&&c.push(a[d]);return c.length>0?new djr.PlaylistChunk(c,b):null};
 djr.Playlist.prototype.addChunk=function(a){djr.debug("adding chunk to playlist "+this.uuid);var b,c=this.next_chunk_id++;for(b=0;b<a.songs.length;b++)this.song_map[a.songs[b]]=c;this.chunk_map[c]=a;this.chunks.push(c);return c};djr.Playlist.prototype.getChunkSongs=function(a){return this.chunk_map[a].songs};
 djr.Playlist.prototype.removeChunk=function(a){djr.debug("removing chunk "+a);var b=this.chunk_map[a].songs,c;for(c=0;c<b.length;c++)delete this.song_map[b[c]];delete this.chunk_map[a];this.chunks=$.grep(this.chunks,function(b){return b!=a})};djr.Playlist.prototype.removeSong=function(a){djr.debug("removing song "+a);var b=this.song_map[a];this.chunk_map[b].removeSong(a);delete this.song_map[a];return this.chunk_map[b].songs.length==0?(this.removeChunk(b),b):-1};
-djr.Playlist.prototype.merge=function(){var a=[],b;for(b=0;b<this.chunks.length;b++)a.push(this.chunk_map[this.chunks[b]].title);a=a.join(" + ");b=new djr.Playlist;b.uuid=this.uuid;b.addChunk(new djr.PlaylistChunk(this.allSongs(),a));return b};djr.Playlist.prototype.getNextSong=function(a){var b=this.song_map[a],c=this.chunk_map[b].songs,b=this.chunks.indexOf(b),a=c.indexOf(a)+1;a>=c.length&&(a=0,b++,b>=this.chunks.length&&(b=0));return this.chunk_map[this.chunks[b]].songs[a]};djr.Player=function(a,b){this.backend=a;this.player=$(b);this.playlist=new djr.Playlist;this.old_songs=[];this.cur_song=null;this.player.jPlayer({swfPath:"/static/js",ready:function(){djr.debug("player ready")}});this.player.bind($.jPlayer.event.ended+".djr",function(){djr.state.player.nextSong()});this.player.bind($.jPlayer.event.error+".djr",function(){djr.state.player.reportError()})};djr.Player.prototype.hideAllChunks=function(){$(".chunk .chunk_inner").hide()};
-djr.Player.prototype.removeChunk=function(a){this.playlist.removeChunk(a);this.savePlaylist();$("#chunk_"+a).remove()};djr.Player.prototype.removeSong=function(a){$("#song_"+a).remove();a=this.playlist.removeSong(a);this.savePlaylist();a>0&&$("#chunk_"+a).remove()};djr.Player.prototype.savePlaylist=function(){this.backend.savePlaylist(this.playlist.uuid,this.playlist.allSongs())};djr.Player.prototype.clearPlaylist=function(){this.playlist=new djr.Playlist;$("#playlistDiv").empty()};
-djr.Player.prototype.mergePlaylistChunks=function(){this.playlist=this.playlist.merge();var a=[];$(".chunk .chunk_inner").each(function(){a.push($(this).html())});$("#playlistDiv").empty();var b=this.playlist.chunks[0];this.setChunkHtml(this.playlist.chunk_map[b],b,a.join(""))};djr.Player.prototype.search=function(a){var b=this;this.backend.search(a,function(c){var d=[];$.each(c,function(a,b){d.push(b.sha1)});d.length==0?djr.debug("No results found."):b.createChunk(d,a)})};
-djr.Player.prototype.lastPlaylist=function(a){var b=this,c="Last "+a+" Songs Uploaded";this.backend.lastPlaylist(a,function(a){a.length==0?djr.debug("No results found."):b.createChunk(a,c)})};djr.Player.prototype.randomPlaylist=function(a){var b=this,c=""+a+" Random Songs ";this.backend.randomPlaylist(a,function(a){a.length==0?djr.debug("No results found."):b.createChunk(a,c)})};
+djr.Playlist.prototype.merge=function(){var a=[],b;for(b=0;b<this.chunks.length;b++)a.push(this.chunk_map[this.chunks[b]].title);a=a.join(" + ");b=new djr.Playlist;b.uuid=this.uuid;b.addChunk(new djr.PlaylistChunk(this.allSongs(),a));return b};djr.Playlist.prototype.getNextSong=function(a){var b=this.song_map[a],c=this.chunk_map[b].songs,b=this.chunks.indexOf(b),a=c.indexOf(a)+1;a>=c.length&&(a=0,b++,b>=this.chunks.length&&(b=0));return this.chunk_map[this.chunks[b]].songs[a]};
+djr.Playlist.prototype.isLastSong=function(a){var b=this.song_map[a],c=this.chunk_map[b].songs,b=this.chunks.indexOf(b),a=c.indexOf(a),c=c.length-1;return b==this.chunks.length-1&&a==c?!0:!1};djr.Player=function(a,b){this.backend=a;this.playlist=new djr.Playlist;this.old_songs=[];this.cur_song=null;this.auto_extend=!0;this.circleplayer=new CirclePlayer(b,{},{supplied:"mp3",swfPath:"/static/js",cssSelectorAncestor:"#cp_container",error:function(a){djr.state.player.reportError(a)}});this.player=this.circleplayer.player;this.player.bind($.jPlayer.event.ended+".djr",function(){djr.state.player.nextSong()})};djr.Player.prototype.hideAllChunks=function(){$(".chunk .chunk_inner").hide()};
+djr.Player.prototype.removeChunk=function(a){this.playlist.removeChunk(a);this.savePlaylist();$("#chunk_"+a).remove()};djr.Player.prototype.removeSong=function(a){$("#song_"+a).remove();a=this.playlist.removeSong(a);this.savePlaylist();a>0&&$("#chunk_"+a).remove()};djr.Player.prototype.savePlaylist=function(){this.backend.savePlaylist(this.playlist.uuid,"",this.playlist.allSongs())};djr.Player.prototype.saveNewPlaylist=function(a){this.backend.savePlaylist(djr.generateRandomId(),a,this.playlist.allSongs())};
+djr.Player.prototype.clearPlaylist=function(){this.playlist=new djr.Playlist;$("#playlistDiv").empty()};djr.Player.prototype.mergePlaylistChunks=function(){this.playlist=this.playlist.merge();var a=[];$(".chunk .chunk_inner").each(function(){a.push($(this).html())});$("#playlistDiv").empty();var b=this.playlist.chunks[0];this.setChunkHtml(this.playlist.chunk_map[b],b,a.join(""))};
+djr.Player.prototype.search=function(a){var b=this;this.backend.search(a,function(c){var d=[];$.each(c,function(a,b){d.push(b.sha1)});d.length==0?djr.debug("No results found."):b.createChunk(d,a)})};djr.Player.prototype.lastPlaylist=function(a){var b=this,c="Last "+a+" Songs Uploaded";this.backend.lastPlaylist(a,function(a){a.length==0?djr.debug("No results found."):b.createChunk(a,c)})};
+djr.Player.prototype.randomPlaylist=function(a){var b=this,c=""+a+" Random Songs ";this.backend.randomPlaylist(a,function(a){a.length==0?djr.debug("No results found."):b.createChunk(a,c)})};djr.Player.prototype.neverPlayedPlaylist=function(a){var b=this,c=""+a+" Never Played Songs";this.backend.neverPlayedPlaylist(a,function(a){a.length==0?djr.debug("No results found."):b.createChunk(a,c)})};
 djr.Player.prototype.mostPlayedPlaylist=function(a){var b=this,c=""+a+" Most Played Songs";this.backend.mostPlayedPlaylist(a,function(a){var e=[];$.each(a,function(a,b){e.push(b.sha1)});e.length==0?djr.debug("No results found."):b.createChunk(e,c)})};djr.Player.prototype.extendCurrentPlaylist=function(){var a=this;this.backend.moreLikeThese(this.playlist.allSongs(),function(b){a.createChunk(b,"suggestions")})};
-djr.Player.prototype.createChunk=function(a,b){var c=this.playlist.createUniqueChunk(a,b);if(c){this.playlist.chunks.length>1&&this.mergePlaylistChunks();var d=this.playlist.addChunk(c);this.savePlaylist();this.backend.getHtmlForSongs(a,function(a){this.hideAllChunks();this.setChunkHtml(c,d,a)},this)}else djr.debug("All the results are already in the playlist")};
+djr.Player.prototype.autoExtendCurrentPlaylist=function(a){var b=this;this.backend.markovPlaylist(10,this.playlist.allSongs(),function(c){b.createChunk(c,"more...");a()})};djr.Player.prototype.createChunk=function(a,b){var c=this.playlist.createUniqueChunk(a,b);if(c){this.playlist.chunks.length>1&&this.mergePlaylistChunks();var d=this.playlist.addChunk(c);this.savePlaylist();var e=this;this.backend.getHtmlForSongs(a,function(a){e.hideAllChunks();e.setChunkHtml(c,d,a)})}else djr.debug("All the results are already in the playlist")};
 djr.Player.prototype.setChunkHtml=function(a,b,c){a=a.wrapHtml(b,c);$("#playlistDiv").append(a);var d=this,e=$("#chunk_"+b);e.find(".song_a").click(function(){d.play($(this).attr("id").substr(5))});e.find(".album_a").click(function(){d.search('(album:"'+$(this).text()+'")')});e.find(".chunk_title").click(function(){e.find(".chunk_inner").toggle()});e.hover(function(){$(this).find(".chunk_ctl_wrap .ctlbox").show()},function(){$(this).find(".chunk_ctl_wrap .ctlbox").hide()});e.find(".chunk_ctl_wrap .ctlbox .ctl_remove").click(function(){djr.debug("removing chunk "+
 b);d.removeChunk(b)});e.find(".chunk_inner .song").hover(function(){$(this).find(".ctlbox").show()},function(){$(this).find(".ctlbox").hide()});e.find(".chunk_inner .ctlbox .ctl_remove").click(function(){var a=$(this).parent().parent().attr("id").substr(5);d.removeSong(a)})};
-djr.Player.prototype.play=function(a){djr.debug("play "+a);this.cur_song&&(this.old_songs.push(this.cur_song),this.old_songs.length>5&&this.old_songs.shift());this.cur_song=a;$(".song").removeClass("playing");$("#song_"+a).addClass("playing");this.player.jPlayer("setMedia",{mp3:"/dl/"+a}).jPlayer("play");var b=$("#song_"+a+" .artist").text(),c=$("#song_"+a+" .album").text();$("#jp_playlist_1").html($("#song_"+a+" .title").text()+"<br>"+b+"<br><small>"+c+"</small>");b="/album_image/"+escape(b)+"/"+
-escape(c);$("#albumart_fs").attr("src",b);$("#albumart_fs").fullBg();$("#albumart_fs").show();this.backend.nowPlaying(a,this.old_songs)};djr.Player.prototype.nextSong=function(){this.play(this.playlist.getNextSong(this.cur_song))};djr.Player.prototype.streamCurrentPlaylist=function(){};djr.state={backend:null,player:null};
-djr.init=function(){djr.state.backend=new djr.Backend;djr.state.player=new djr.Player(djr.state.backend,"#djr_player");$("#playlistClearBtn").click(function(){djr.state.player.clearPlaylist()});$("#playlistStreamBtn").click(function(){djr.state.player.streamCurrentPlaylist()});$("#playlistExtendBtn").click(function(){djr.state.player.extendCurrentPlaylist()});$("#playlistLast25").click(function(){djr.state.player.lastPlaylist(25)});$("#playlistRandom").click(function(){djr.state.player.randomPlaylist(25)});
-$("#playlistMost").click(function(){djr.state.player.mostPlayedPlaylist(25)});$("#wikibtn").click(function(){var a=$("#song_"+djr.state.player.cur_song+" .artist").text();$("#wikipedia").is(":visible")==!1?a!=""&&(a=a.split(" ").join("+"),$("#wikipedia").show("slow"),$("#wikipedia").attr("src","http://en.m.wikipedia.org/w/index.php?search="+a)):$("#wikipedia").hide("slow")});$("#lastfmbtn").click(function(){var a=$("#song_"+djr.state.player.cur_song+" .title").text(),b=$("#song_"+djr.state.player.cur_song+
-" .artist").text();$("#lastfm").is(":visible")==!1?a!=""&&(a=a.split(" ").join("+"),b=b.split(" ").join("+"),$("#lastfm").show("slow"),$("#lastfm").attr("src","http://m.last.fm/search?q="+a+b)):$("#lastfm").hide("slow")});$("#lyricsbtn").click(function(){var a=$("#song_"+djr.state.player.cur_song+" .title").text();$("#lyrics").is(":visible")==!1?a!=""&&(a=a.split(" ").join("+"),$("#lyrics").show("slow"),$("#lyrics").attr("src","http://lyrics.wikia.com/index.php?search="+a+"&fulltext=0")):$("#lyrics").hide("slow")});
-$("#albumart_fs").load(function(){$(this).fullBg();$(this).show()})};djr.player=function(){return djr.state.player};djr.debug=function(a){$("#debug").append(a+"<br>")};
+djr.Player.prototype.play=function(a){djr.debug("play "+a);this.cur_song&&(this.old_songs.push(this.cur_song),this.old_songs.length>5&&this.old_songs.shift());this.cur_song=a;$(".song").removeClass("playing");$("#song_"+a).addClass("playing");this.player.jPlayer("setMedia",{mp3:"/dl/"+a}).jPlayer("play");var b=$("#song_"+a+" .artist").text(),c=$("#song_"+a+" .album").text();$("#songInfoDiv").html($("#song_"+a+" .title").text()+"<br>"+b+"<br><small>"+c+"</small>");b="/album_image/"+escape(b)+"/"+escape(c);
+$("#albumart_fs").attr("src",b);$("#albumart_fs").fullBg();$("#albumart_fs").show();this.backend.nowPlaying(a,this.old_songs)};djr.Player.prototype.nextSong=function(){if(this.auto_extend&&this.playlist.isLastSong(this.cur_song)){var a=this;this.autoExtendCurrentPlaylist(function(){a.nextSong()})}else this.play(this.playlist.getNextSong(this.cur_song))};djr.Player.prototype.streamCurrentPlaylist=function(){};djr.state={backend:null,player:null};
+djr.init=function(){djr.state.backend=new djr.Backend;djr.state.player=new djr.Player(djr.state.backend,"#djr_player");$("#playlistClearBtn").click(function(){djr.state.player.clearPlaylist()});$("#playlistStreamBtn").click(function(){djr.state.player.streamCurrentPlaylist()});$("#playlistExtendBtn").click(function(){djr.state.player.extendCurrentPlaylist()});$("#playlistLast5").click(function(){djr.state.player.lastPlaylist(5)});$("#playlistLast25").click(function(){djr.state.player.lastPlaylist(25)});
+$("#playlistRandom5").click(function(){djr.state.player.randomPlaylist(5)});$("#playlistRandom25").click(function(){djr.state.player.randomPlaylist(25)});$("#playlistMost5").click(function(){djr.state.player.mostPlayedPlaylist(5)});$("#playlistMost25").click(function(){djr.state.player.mostPlayedPlaylist(25)});$("#playlistNever5").click(function(){djr.state.player.neverPlayedPlaylist(5)});$("#playlistNever25").click(function(){djr.state.player.neverPlayedPlaylist(25)});$("#playlistSave").click(function(){$("#saveForm").is(":visible")==
+!1?($("#savetext").val(djr.playlist.title),$("#saveForm").show("slow")):($("#savetext").val(""),$("#saveForm").hide("slow"))});$("#playlistSaveBtn").click(function(){$("#savetext").val()!=""&&djr.player.saveNewPlaylist($("#savetext").val())});$("#wikibtn").click(function(){var a=$("#song_"+djr.state.player.cur_song+" .artist").text();$("#wikipedia").is(":visible")==!1?a!=""&&(a=a.split(" ").join("+"),$("#wikipedia").show("slow"),$("#wikipedia").attr("src","http://en.m.wikipedia.org/w/index.php?search="+
+a)):$("#wikipedia").hide("slow")});$("#lastfmbtn").click(function(){var a=$("#song_"+djr.state.player.cur_song+" .title").text(),b=$("#song_"+djr.state.player.cur_song+" .artist").text();$("#lastfm").is(":visible")==!1?a!=""&&(a=a.split(" ").join("+"),b=b.split(" ").join("+"),$("#lastfm").show("slow"),$("#lastfm").attr("src","http://m.last.fm/search?q="+a+b)):$("#lastfm").hide("slow")});$("#lyricsbtn").click(function(){var a=$("#song_"+djr.state.player.cur_song+" .title").text();$("#lyrics").is(":visible")==
+!1?a!=""&&(a=a.split(" ").join("+"),$("#lyrics").show("slow"),$("#lyrics").attr("src","http://lyrics.wikia.com/index.php?search="+a+"&fulltext=0")):$("#lyrics").hide("slow")});$("#pllistbtn").click(function(){$("#pllist").is(":visible")==!1?($("#pllist").show("slow"),this.backend.getPlList(uid,function(a){if(a.length==0)djr.debug("No results found.");else{for(var b="<ul>",c;c<a.lenght;c++)b+="<il><a id='"+a[c].uuid+"' onclick='djm.player.showPlaylist(\""+a[c].uuid+"\"' >"+a[c].title+"</a></il>";b+=
+"</ul>";$("#pllist").html(b)}})):$("#pllist").hide("slow")});$("#albumart_fs").load(function(){$(this).fullBg();$(this).show()})};djr.player=function(){return djr.state.player};djr.debug=function(a){$("#debug").append(a+"<br>")};djr.Player.prototype.reportError=function(a){console.log(a.jPlayer.error);console.log(a.jPlayer.error.type);switch(a.jPlayer.error.type){case $.jPlayer.error.URL:djr.debug("Error downloading song, skipping...");this.nextSong();break;case $.jPlayer.error.NO_SOLUTION:djr.debug("Error: unexpected error!")}};
diff --git a/server/djrandom/frontend/static/js/djr/Makefile b/server/djrandom/frontend/static/js/djr/Makefile
index 094d7250a7d6013277f8d83f5bb85f7c03be0ba7..7d483dbc1cde3db997329792888b3caf693ae2ba 100644
--- a/server/djrandom/frontend/static/js/djr/Makefile
+++ b/server/djrandom/frontend/static/js/djr/Makefile
@@ -1,5 +1,5 @@
 
-JSCOMPILER_JAR = /usr/bin/compiler.jar
+JSCOMPILER_JAR = /usr/lib/jscompiler/compiler.jar
 JSCOMPILER = java -jar $(JSCOMPILER_JAR)
 
 SOURCES = \
diff --git a/server/djrandom/frontend/static/js/djr/backend.js b/server/djrandom/frontend/static/js/djr/backend.js
index 1921dc102228286e3ec43b720160cb30fdcc1854..21caba4d3d937e613380bd67933018cfae1d4c2d 100644
--- a/server/djrandom/frontend/static/js/djr/backend.js
+++ b/server/djrandom/frontend/static/js/djr/backend.js
@@ -18,6 +18,22 @@ djr.Backend = function() {
   // No state yet.
 };
 
+// Wrap an AJAX call with some UI feedback.
+var wrap_with_loader = function(ajaxData) {
+  djr.loading(true);
+  var old_success_handler = ajaxData.success;
+  ajaxData.success = function(data, status, jqxhr) {
+    old_success_handler(data, status, jqxhr);
+    djr.loading(false);
+  };
+  var old_error_handler = ajaxData.error;
+  ajaxData.error = function(jqXHR, textStatus, errorThrown) {
+    djr.loading(false);
+  };
+  return $.ajax(ajaxData);
+};
+
+
 /**
  * Search.
  *
@@ -29,15 +45,17 @@ djr.Backend = function() {
  * @param {Object} ctx Callback context.
  */
 djr.Backend.prototype.search = function(query, callback, ctx) {
-  $.ajax({url: '/json/search',
-          data: {'q': query},
-          dataType: 'json',
-          type: 'GET',
-          context: ctx || this,
-          success: function(data, status, jqxhr) {
-            callback(data.results);
-          }
-         });
+  wrap_with_loader(
+    {url: '/json/search',
+     data: {'q': query},
+     dataType: 'json',
+     type: 'GET',
+     context: ctx || this,
+     success: function(data, status, jqxhr) {
+       callback(data.results);
+       djr.loading(false);
+     }
+    });
 };
 
 /**
@@ -47,11 +65,54 @@ djr.Backend.prototype.search = function(query, callback, ctx) {
  * @param {function} callback Callback function.
  * @param {Object} ctx Callback context.
  */
-djr.Backend.prototype.moreLikeThese = function(uuids, callback, ctx) {
-  $.ajax({url: '/json/morelikethese',
-          data: {'h': uuids.join(',')},
+djr.Backend.prototype.moreLikeThese = function(n, uuids, callback, ctx) {
+  wrap_with_loader(
+    {url: '/json/morelikethese',
+     data: {'h': uuids.join(','), 'n': n},
+     dataType: 'json',
+     type: 'POST',
+     context: ctx || this,
+     success: function(data, status, jqxhr) {
+       callback(data.results);
+     }
+    });
+};
+
+/**
+ * Autocomplete.
+ *
+ * Autocompletion callback suitable for use with the jQuery UI
+ * autocomplete plugin.
+ *
+ * @param {Object} request request object (contains term).
+ * @param {function} callback Callback function.
+ */
+djr.Backend.prototype.autocomplete = function(request, callback) {
+  $.ajax({url: '/autocomplete', 
+          data: {'term': request.term},
           dataType: 'json',
-          type: 'POST',
+          success: function(data) {
+            callback(data.results);
+          },
+          error: function(xhr, status, errorThrown) {
+            callback();
+          }});  
+};
+
+/**
+ * Get the playlist list for a specified user.
+ *
+ * It will invoke callback(songs) where 'playlist' is an array of uuid
+ * song hashes.
+ *
+ * @param {string} uuid Playlist identifier.
+ * @param {function} callback Callback function.
+ * @param {Object} ctx Callback context.
+ */
+djr.Backend.prototype.getPlList = function(uid, callback, ctx) {
+  $.ajax({url: '/json/playlist/list/' + uid,
+          dataType: 'json',
+          type: 'GET',
           context: ctx || this,
           success: function(data, status, jqxhr) {
             callback(data.results);
@@ -75,7 +136,7 @@ djr.Backend.prototype.getPlaylist = function(uuid, callback, ctx) {
           type: 'GET',
           context: ctx || this,
           success: function(data, status, jqxhr) {
-            callback(new djr.PlaylistChunk(data.songs, uuid));
+            callback(data.results);
           }
          });
 };
@@ -89,9 +150,10 @@ djr.Backend.prototype.getPlaylist = function(uuid, callback, ctx) {
  * @param {string} uuid Playlist identifier.
  * @param {Array[string]} songs SHA1 hashes of the songs.
  */
-djr.Backend.prototype.savePlaylist = function(uuid, songs) {
+djr.Backend.prototype.savePlaylist = function(uuid, title, songs) {
   $.ajax({url: '/json/playlist/save',
           data: {uuid: uuid,
+                 title: title,
                  h: songs.join(',')},
           type: 'POST'
          });
@@ -134,13 +196,14 @@ djr.Backend.prototype.streamPlaylist = function(uuid, streaming, callback, ctx)
  * @param {Object} ctx Callback context.
  */
 djr.Backend.prototype.getHtmlForSongs = function(songs, callback, ctx) {
-  $.ajax({url: '/fragment/songs',
-          data: {'h': songs.join(',')},
-          dataType: 'html',
-          type: 'POST',
-          context: ctx || this,
-          success: callback
-         });
+  wrap_with_loader(
+    {url: '/fragment/songs',
+     data: {'h': songs.join(',')},
+     dataType: 'html',
+     type: 'POST',
+     context: ctx || this,
+     success: callback
+    });
 };
 
 /**
@@ -162,6 +225,25 @@ djr.Backend.prototype.nowPlaying = function(song, old_songs) {
           });
 };
 
+/**
+  * Request N never played songs
+  *
+  * @param {integer} n Number of songs requested
+  *
+  */
+djr.Backend.prototype.neverPlayedPlaylist = function(num, callback ,ctx) {
+  wrap_with_loader(
+    {url: '/json/never_played',
+     data: {n: num },
+     dataType: 'json',
+     type: 'GET',
+     context: ctx || this,
+     success: function(data, status, jqxhr) {
+       callback(data.results);
+     }
+    });
+};
+
 /**
   * Request N most played songs
   *
@@ -169,15 +251,16 @@ djr.Backend.prototype.nowPlaying = function(song, old_songs) {
   *
   */
 djr.Backend.prototype.mostPlayedPlaylist = function(num, callback ,ctx) {
-  $.ajax({url: '/json/most_played',
-          data: {n: num },
-          dataType: 'json',
-          type: 'GET',
-          context: ctx || this,
-          success: function(data, status, jqxhr) {
-            callback(data.results);
-          }
-         });
+  wrap_with_loader(
+    {url: '/json/most_played',
+     data: {n: num },
+     dataType: 'json',
+     type: 'GET',
+     context: ctx || this,
+     success: function(data, status, jqxhr) {
+       callback(data.results);
+     }
+    });
 };
 
 /**
@@ -187,15 +270,16 @@ djr.Backend.prototype.mostPlayedPlaylist = function(num, callback ,ctx) {
   *
   */
 djr.Backend.prototype.lastPlaylist = function(num, callback ,ctx) {
-  $.ajax({url: '/json/last_uploaded',
-          data: {n: num },
-          dataType: 'json',
-          type: 'GET',
-          context: ctx || this,
-          success: function(data, status, jqxhr) {
-            callback(data.results);
-          }
-         });
+  wrap_with_loader(
+    {url: '/json/last_uploaded',
+     data: {n: num },
+     dataType: 'json',
+     type: 'GET',
+     context: ctx || this,
+     success: function(data, status, jqxhr) {
+       callback(data.results);
+     }
+    });
 };
 
 /**
@@ -206,16 +290,17 @@ djr.Backend.prototype.lastPlaylist = function(num, callback ,ctx) {
   *
   */
 djr.Backend.prototype.markovPlaylist = function(num, uuids, callback ,ctx) {
-  $.ajax({url: '/json/markov',
-          data: {'h': uuids.join(','),
-                 'n': num },
-          dataType: 'json',
-          type: 'POST',
-          context: ctx || this,
-          success: function(data, status, jqxhr) {
-            callback(data.results);
-          }
-         });
+  wrap_with_loader(
+    {url: '/json/markov',
+     data: {'h': uuids.join(','),
+            'n': num },
+     dataType: 'json',
+     type: 'POST',
+     context: ctx || this,
+     success: function(data, status, jqxhr) {
+       callback(data.results);
+     }
+    });
 };
 
 /**
@@ -225,14 +310,15 @@ djr.Backend.prototype.markovPlaylist = function(num, uuids, callback ,ctx) {
   *
   */
 djr.Backend.prototype.randomPlaylist = function(num, callback ,ctx) {
-  $.ajax({url: '/json/random',
-          data: {'n': num },
-          dataType: 'json',
-          type: 'GET',
-          context: ctx || this,
-          success: function(data, status, jqxhr) {
-            callback(data.results);
-          }
-         });
+  wrap_with_loader(
+    {url: '/json/random',
+     data: {'n': num },
+     dataType: 'json',
+     type: 'GET',
+     context: ctx || this,
+     success: function(data, status, jqxhr) {
+       callback(data.results);
+     }
+    });
 };
 
diff --git a/server/djrandom/frontend/static/js/djr/djr.js b/server/djrandom/frontend/static/js/djr/djr.js
index b7e70d636698f99f1efbb3b9d59ca2c567caf898..d12da4ad2525e275870552b4ce4bf6ba29dc9740 100644
--- a/server/djrandom/frontend/static/js/djr/djr.js
+++ b/server/djrandom/frontend/static/js/djr/djr.js
@@ -6,6 +6,174 @@
 // Namespace.
 djr = {};
 
+// Global state (with Player instance).
+djr.state = {
+  backend: null,
+  player: null
+};
+
+// Initialize the Player and add all our onclick handlers.
+djr.init = function () {
+  djr.state.backend = new djr.Backend();
+  djr.state.player = new djr.Player(djr.state.backend, '#djr_player');
+
+  // Set autocompletion and search handlers.
+  $('#queryField').autocomplete({
+    source: djr.state.backend.autocomplete,
+    autoFocus: true,
+    delay: 200,
+    minLength: 1
+  });
+  $('#queryField').focus();
+  $('#searchForm').submit(function() {
+    var query_string = $('#queryField').val();
+    djr.state.player.search(query_string);
+    return false;
+  });
+
+  // Add onclick hooks to the playlist controls.
+  $('#playlistClearBtn').click(function() {
+    djr.state.player.clearPlaylist();
+  });
+  $('#playlistStreamBtn').click(function() {
+    djr.state.player.streamCurrentPlaylist();
+  });
+  $('#playlistExtendBtn').click(function() {
+    djr.state.player.extendCurrentPlaylist();
+  });
+  $('#playlistLast5').click(function() {
+    djr.state.player.lastPlaylist(5);
+  });
+  $('#playlistLast25').click(function() {
+    djr.state.player.lastPlaylist(25);
+  });
+  $('#playlistRandom5').click(function() {
+    djr.state.player.randomPlaylist(5);
+  });
+  $('#playlistRandom25').click(function() {
+    djr.state.player.randomPlaylist(25);
+  });
+  $('#playlistMost5').click(function() {
+    djr.state.player.mostPlayedPlaylist(5);
+  });
+  $('#playlistMost25').click(function() {
+    djr.state.player.mostPlayedPlaylist(25);
+  });
+  $('#playlistNever5').click(function() {
+    djr.state.player.neverPlayedPlaylist(5);
+  });
+  $('#playlistNever25').click(function() {
+    djr.state.player.neverPlayedPlaylist(25);
+  });
+  $('#playlistSave').click(function() {
+    if ( $('#saveForm').is(':visible') == false ) {
+        $('#savetext').val(djr.playlist.title);
+        $('#saveForm').show('slow');
+    } else {
+        $('#savetext').val('');
+        $('#saveForm').hide('slow');
+    }
+  });
+  $('#playlistSaveBtn').click(function() {
+    if ( $('#savetext').val() != '' ) {
+      djr.player.saveNewPlaylist($('#savetext').val());
+    }
+  });
+  $('#wikibtn').click(function () {
+    var stitle = $('#song_' + djr.state.player.cur_song + ' .artist').text();
+    if ( $('#wikipedia').is(':visible') == false ) {
+      if ( stitle != "" ) { 
+        stitle = stitle.split(' ').join('+');
+        $('#wikipedia').show('slow');
+        //$('#wikipedia').attr("src", "/ext?url=" + escape("http://en.wikipedia.org/wiki/Special:Search?search=" + stitle + "&go=Go"));
+        $('#wikipedia').attr("src", "http://en.m.wikipedia.org/w/index.php?search=" + stitle);
+      }
+    } else {
+        $('#wikipedia').hide('slow')
+    }
+  });
+  $('#lastfmbtn').click(function () {
+    var stitle = $('#song_' + djr.state.player.cur_song + ' .title').text();
+    var sartist = $('#song_' + djr.state.player.cur_song + ' .artist').text();
+    if ( $('#lastfm').is(':visible') == false ) {
+      if ( stitle != "" ) { 
+        stitle = stitle.split(' ').join('+');
+        sartist = sartist.split(' ').join('+');
+        $('#lastfm').show('slow');
+        //$('#lastfm').attr("src", "/ext?url=" + escape("http://www.lastfm.com/#&q=" + stitle + sartist));
+        $('#lastfm').attr("src", "http://m.last.fm/search?q=" + stitle + sartist );
+      }
+    } else {
+        $('#lastfm').hide('slow')
+    }
+  });
+  $('#lyricsbtn').click(function () {
+    var stitle = $('#song_' + djr.state.player.cur_song + ' .title').text();
+    if ( $('#lyrics').is(':visible') == false ) {
+      if ( stitle != "" ) { 
+        stitle = stitle.split(' ').join('+');
+        $('#lyrics').show('slow');
+        //$('#lyrics').attr("src", "/ext?url=" + escape("http://www.lyrics.com/#&q=" + stitle));
+        $('#lyrics').attr("src", "http://lyrics.wikia.com/index.php?search=" + stitle + "&fulltext=0" );
+      }
+    } else {
+        $('#lyrics').hide('slow')
+    }
+  });
+  $('#pllistbtn').click(function () {
+    if ( $('#pllist').is(':visible') == false ) {
+      $('#pllist').show('slow');
+      this.backend.getPlList(uid, function(results) {
+        var pllist = results;
+        if (pllist.length == 0) {
+         djr.debug('No results found.');
+         return;
+        }
+        var output = "<ul>";
+        for (var i; i < pllist.lenght; i++) {
+          output += "<il><a id='" + pllist[i].uuid + "' onclick='djm.player.showPlaylist(\"" + pllist[i].uuid + "\"' >" + pllist[i].title + "</a></il>";
+          //output += "<il><a id='" + pllist[i].uuid + "' onclick='djr.Playlist.saveNewPlaylist();djr.player.createChunk(djr.Playlist.getPlaylist(\"" + pllist[i].uuid + "\"), \"" + pllist[i].title + "\");' >" + pllist[i].title + "</a></il>";
+            }
+        output += "</ul>";
+        $('#pllist').html(output);
+      });
+    } else {
+        $('#pllist').hide('slow')
+    }
+  });
+
+  // Set the album art image to auto-fullscreen on load.
+  $('#albumart_fs').load(function() {
+    $(this).fullBg();
+    $(this).show();
+  });
+
+  djr.debug('initialization done.');
+};
+
+// Export the player for quick onclick access
+djr.player = function() {
+  return djr.state.player;
+};
+
+// Show/hide the 'loader' animated GIF.
+djr.loading = function(active) {
+  var loader = $('#loaderImg');
+  if (active) {
+    loader.show();
+  } else {
+    loader.hide();
+  }
+};
+
+// Debugging.
+djr.debug = function(msg) {
+  var n_logs = $('#debug p').length;
+  if (n_logs > 7) {
+    $('#debug p:first').remove();
+  }
+  $('#debug').append('<p>' + msg + '</p>');
+};
 
 // Utility functions.
 var CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
@@ -15,3 +183,4 @@ djr.generateRandomId = function() {
   for (i = 0; i < len; i++) uuid[i] = chars[0 | Math.random()*radix];
   return uuid.join('');
 };
+
diff --git a/server/djrandom/frontend/static/js/djr/player.js b/server/djrandom/frontend/static/js/djr/player.js
index c6643c33919a6943aa402a2a715f0f097746183b..11996e3a1a5f859680203ab1604585fc9400ee23 100644
--- a/server/djrandom/frontend/static/js/djr/player.js
+++ b/server/djrandom/frontend/static/js/djr/player.js
@@ -5,22 +5,24 @@
 
 djr.Player = function(backend, selector) {
   this.backend = backend;
-  this.player = $(selector);
   this.playlist = new djr.Playlist();
   this.old_songs = [];
   this.cur_song = null;
+  this.auto_extend = true;
 
   // Setup the jPlayer interface.
-  this.player.jPlayer({
-    swfPath: '/static/js',
-    ready: function() {
-      djr.debug('player ready');
+  this.circleplayer = new CirclePlayer(selector, {}, {
+    supplied: "mp3",
+    swfPath: "/static/js",
+    cssSelectorAncestor: "#cp_container",
+    error: function(e) {
+      djr.state.player.reportError(e);
     }
   });
+  this.player = this.circleplayer.player;
+
   this.player.bind($.jPlayer.event.ended + '.djr', 
                    function () {djr.state.player.nextSong(); });
-  this.player.bind($.jPlayer.event.error + '.djr', 
-                   function() {djr.state.player.reportError(); });
 };
 
 djr.Player.prototype.hideAllChunks = function() {
@@ -46,7 +48,12 @@ djr.Player.prototype.removeSong = function(song) {
 
 // Save the current playlist.
 djr.Player.prototype.savePlaylist = function() {
-  this.backend.savePlaylist(this.playlist.uuid, this.playlist.allSongs());
+  this.backend.savePlaylist(this.playlist.uuid, '', this.playlist.allSongs());
+};
+
+// Save the current playlist.
+djr.Player.prototype.saveNewPlaylist = function(title) {
+  this.backend.savePlaylist(djr.generateRandomId(), title, this.playlist.allSongs());
 };
 
 // Completely clear out the current playlist, and replace it with 
@@ -112,8 +119,6 @@ djr.Player.prototype.lastPlaylist = function(num) {
     // Create a chunk of unique new songs.
     player.createChunk(songs, title);
   });
-
-
 };
 
 djr.Player.prototype.randomPlaylist = function(num) {
@@ -131,6 +136,22 @@ djr.Player.prototype.randomPlaylist = function(num) {
 
 };
 
+djr.Player.prototype.neverPlayedPlaylist = function(num) {
+  var player = this;
+  var title = "" + num + " Never Played Songs";
+  this.backend.neverPlayedPlaylist(num, function(results) {
+    var songs = results;
+    if (songs.length == 0) {
+      djr.debug('No results found.');
+      return;
+    }
+    // Create a chunk of unique new songs.
+    player.createChunk(songs, title);
+  });
+
+};
+
+
 djr.Player.prototype.mostPlayedPlaylist = function(num) {
   var player = this;
   var title = "" + num + " Most Played Songs";
@@ -154,11 +175,34 @@ djr.Player.prototype.extendCurrentPlaylist = function() {
   var player = this;
   var cur_songs = this.playlist.allSongs();
 
-  this.backend.moreLikeThese(cur_songs, function(results) {
+  this.backend.moreLikeThese(5, cur_songs, function(results) {
     player.createChunk(results, 'suggestions');
   });
 };
 
+// Automatically extend the current playlist using Markov-based
+// suggestions. Invokes callback when done.
+djr.Player.prototype.autoExtendCurrentPlaylist = function(callback) {
+  var player = this;
+  var cur_songs = this.playlist.allSongs();
+
+  this.backend.markovPlaylist(10, cur_songs, function(results) {
+    player.createChunk(results, 'more...');
+    callback();
+  });
+};
+
+// Extend the current playlist with the results of a MoreLikeThis
+// search based on a single selected song.
+djr.Player.prototype.moreLikeThis = function(sha1) {
+  var player = this;
+  var songs = [sha1];
+
+  this.backend.moreLikeThese(10, songs, function(results) {
+    player.createChunk(results, 'like that');
+  });
+};
+
 djr.Player.prototype.createChunk = function(songs, chunk_title) {
   // Create the new chunk, with unique songs.
   var chunk = this.playlist.createUniqueChunk(songs, chunk_title);
@@ -181,11 +225,12 @@ djr.Player.prototype.createChunk = function(songs, chunk_title) {
 
   // Load the HTML fragment for the chunk and display it.  Also,
   // minimize all the other chunks.
+  var player = this;
   this.backend.getHtmlForSongs(songs, function(songs_html) {
     var chunk_div = 'chunk_' + chunk_id;
-    this.hideAllChunks();
-    this.setChunkHtml(chunk, chunk_id, songs_html);
-  }, this);
+    player.hideAllChunks();
+    player.setChunkHtml(chunk, chunk_id, songs_html);
+  });
 };
 
 // Set the HTML contents of a chunk object, and the related event hooks.
@@ -229,6 +274,10 @@ djr.Player.prototype.setChunkHtml = function(chunk, chunk_id, songs_html) {
     var sha1 = $(this).parent().parent().attr('id').substr(5);
     player.removeSong(sha1);
   });
+  chunk_div.find('.chunk_inner .ctlbox .ctl_love').click(function() {
+    var sha1 = $(this).parent().parent().attr('id').substr(5);
+    player.moreLikeThis(sha1);
+  });
 };
 
 // Start playing a specific song.
@@ -254,7 +303,7 @@ djr.Player.prototype.play = function(song) {
   // from the existing <div> instead than from the server.
   var artist = $('#song_' + song + ' .artist').text();
   var album = $('#song_' + song + ' .album').text();
-  $('#jp_playlist_1').html(
+  $('#songInfoDiv').html(
     $('#song_' + song + ' .title').text() + '<br>'
       + artist + '<br>'
       + '<small>' + album + '</small>'
@@ -272,7 +321,15 @@ djr.Player.prototype.play = function(song) {
 
 // Start playing the next song.
 djr.Player.prototype.nextSong = function() {
-  this.play(this.playlist.getNextSong(this.cur_song));
+  if (this.auto_extend && this.playlist.isLastSong(this.cur_song)) {
+    var player = this;
+    // auto-extend!
+    this.autoExtendCurrentPlaylist(function() {
+      player.nextSong();
+    });
+  } else {
+    this.play(this.playlist.getNextSong(this.cur_song));
+  }
 };
 
 // Activate streaming of the current playlist.
@@ -280,114 +337,21 @@ djr.Player.prototype.streamCurrentPlaylist = function(enable) {
   // Nothing for now.
 };
 
-
-djr.state = {
-  backend: null,
-  player: null
-};
-
-djr.init = function () {
-  djr.state.backend = new djr.Backend();
-  djr.state.player = new djr.Player(djr.state.backend, '#djr_player');
-
-  // Add onclick hooks to the playlist controls.
-  $('#playlistClearBtn').click(function() {
-    djr.state.player.clearPlaylist();
-  });
-  $('#playlistStreamBtn').click(function() {
-    djr.state.player.streamCurrentPlaylist();
-  });
-  $('#playlistExtendBtn').click(function() {
-    djr.state.player.extendCurrentPlaylist();
-  });
-  $('#playlistLast25').click(function() {
-    djr.state.player.lastPlaylist(25);
-  });
-  $('#playlistRandom').click(function() {
-    djr.state.player.randomPlaylist(25);
-  });
-  $('#playlistMost').click(function() {
-    djr.state.player.mostPlayedPlaylist(25);
-  });
-
-  $('#wikibtn').click(function () {
-    var stitle = $('#song_' + djr.state.player.cur_song + ' .artist').text();
-    if ( $('#wikipedia').is(':visible') == false ) {
-      if ( stitle != "" ) { 
-        stitle = stitle.split(' ').join('+');
-        $('#wikipedia').show('slow');
-        //$('#wikipedia').attr("src", "/ext?url=" + escape("http://en.wikipedia.org/wiki/Special:Search?search=" + stitle + "&go=Go"));
-        $('#wikipedia').attr("src", "http://en.m.wikipedia.org/w/index.php?search=" + stitle);
-      }
-    } else {
-        $('#wikipedia').hide('slow')
-    }
-  });
-  $('#lastfmbtn').click(function () {
-    var stitle = $('#song_' + djr.state.player.cur_song + ' .title').text();
-    var sartist = $('#song_' + djr.state.player.cur_song + ' .artist').text();
-    if ( $('#lastfm').is(':visible') == false ) {
-      if ( stitle != "" ) { 
-        stitle = stitle.split(' ').join('+');
-        sartist = sartist.split(' ').join('+');
-        $('#lastfm').show('slow');
-        //$('#lastfm').attr("src", "/ext?url=" + escape("http://www.lastfm.com/#&q=" + stitle + sartist));
-        $('#lastfm').attr("src", "http://m.last.fm/search?q=" + stitle + sartist );
-      }
-    } else {
-        $('#lastfm').hide('slow')
-    }
-  });
-  $('#lyricsbtn').click(function () {
-    var stitle = $('#song_' + djr.state.player.cur_song + ' .title').text();
-    if ( $('#lyrics').is(':visible') == false ) {
-      if ( stitle != "" ) { 
-        stitle = stitle.split(' ').join('+');
-        $('#lyrics').show('slow');
-        //$('#lyrics').attr("src", "/ext?url=" + escape("http://www.lyrics.com/#&q=" + stitle));
-        $('#lyrics').attr("src", "http://lyrics.wikia.com/index.php?search=" + stitle + "&fulltext=0" );
-      }
-    } else {
-        $('#lyrics').hide('slow')
-    }
-  });
-
-
-  // Set the album art image to auto-fullscreen on load.
-  $('#albumart_fs').load(function() {
-    $(this).fullBg();
-    $(this).show();
-  });
-};
-
-// Export the player for quick onclick access
-djr.player = function() {
-  return djr.state.player;
-};
-
-// Debugging.
-djr.debug = function(msg) {
-  $('#debug').append(msg + '<br>');
-};
-
-
-
-// ------ OLD --------
-
-/**
-
-// An error has occurred in the player.
+// An error has occurred in the player. Report it on the javascript
+// console.
 djr.Player.prototype.reportError = function(event) {
+  console.log(event.jPlayer.error);
+  console.log(event.jPlayer.error.type);
   switch(event.jPlayer.error.type) {
   case $.jPlayer.error.URL:
-    djr.debug('error downloading song, moving on');
+    djr.debug('Error downloading song, skipping...')
     this.nextSong();
     break;
   case $.jPlayer.error.NO_SOLUTION:
-    djr.debug('unexpected error!');
+    djr.debug('Error: unexpected error!');
     break;
   }  
 };
-**/
+
 
 // Fine
diff --git a/server/djrandom/frontend/static/js/djr/playlist.js b/server/djrandom/frontend/static/js/djr/playlist.js
index cc23bd39b78ed5d48689ca92555a465ea9fd1c77..96b029f5b1d582a16600006bf522ede91aa52332 100644
--- a/server/djrandom/frontend/static/js/djr/playlist.js
+++ b/server/djrandom/frontend/static/js/djr/playlist.js
@@ -74,6 +74,7 @@ djr.Playlist = function(uuid) {
   this.chunks = [];
   this.song_map = {};
   this.chunk_map = {};
+  this.title = '';
   this.next_chunk_id = 0;
 };
 
@@ -109,10 +110,14 @@ djr.Playlist.prototype.allSongs = function() {
 djr.Playlist.prototype.createUniqueChunk = function(songs, title) {
   var unique = [], i;
   for (i = 0; i < songs.length; i++) {
-    if (this.song_map[songs[i]] != null) {
+    var sha1 = songs[i];
+    if (!sha1) {
       continue;
     }
-    unique.push(songs[i]);
+    if (this.song_map[sha1] != null) {
+      continue;
+    }
+    unique.push(sha1);
   }
   if (unique.length > 0) {
     return new djr.PlaylistChunk(unique, title);
@@ -234,3 +239,18 @@ djr.Playlist.prototype.getNextSong = function(song) {
 
   return this.chunk_map[this.chunks[chunk_idx]].songs[idx];
 };
+
+djr.Playlist.prototype.isLastSong = function(song) {
+  var cur_chunk = this.song_map[song];
+  var chunk_songs = this.chunk_map[cur_chunk].songs;
+  var chunk_idx = this.chunks.indexOf(cur_chunk);
+  var song_idx = chunk_songs.indexOf(song);
+  var last_chunk = this.chunks.length - 1;
+  var last_song = chunk_songs.length - 1;
+  if (chunk_idx == last_chunk && song_idx == last_song) {
+    return true;
+  } else {
+    return false;
+  }
+};
+
diff --git a/server/djrandom/frontend/static/js/jquery.grab.js b/server/djrandom/frontend/static/js/jquery.grab.js
new file mode 100644
index 0000000000000000000000000000000000000000..d73189912e079e2fe85392ee129791d2adf37e60
--- /dev/null
+++ b/server/djrandom/frontend/static/js/jquery.grab.js
@@ -0,0 +1,201 @@
+/*
+jQuery grab 
+https://github.com/jussi-kalliokoski/jQuery.grab
+Ported from Jin.js::gestures   
+https://github.com/jussi-kalliokoski/jin.js/
+Created by Jussi Kalliokoski
+Licensed under MIT License. 
+
+Includes fix for IE
+*/
+
+
+(function($){
+	var	extend		= $.extend,
+		mousedown	= 'mousedown',
+		mousemove	= 'mousemove',
+		mouseup		= 'mouseup',
+		touchstart	= 'touchstart',
+		touchmove	= 'touchmove',
+		touchend	= 'touchend',
+		touchcancel	= 'touchcancel';
+
+	function unbind(elem, type, func){
+		if (type.substr(0,5) !== 'touch'){ // A temporary fix for IE8 data passing problem in Jin.
+			return $(elem).unbind(type, func);
+		}
+		var fnc, i;
+		for (i=0; i<bind._binds.length; i++){
+			if (bind._binds[i].elem === elem && bind._binds[i].type === type && bind._binds[i].func === func){
+				if (document.addEventListener){
+					elem.removeEventListener(type, bind._binds[i].fnc, false);
+				} else {
+					elem.detachEvent('on'+type, bind._binds[i].fnc);
+				}
+				bind._binds.splice(i--, 1);
+			}
+		}
+	}
+
+	function bind(elem, type, func, pass){
+		if (type.substr(0,5) !== 'touch'){ // A temporary fix for IE8 data passing problem in Jin.
+			return $(elem).bind(type, pass, func);
+		}
+		var fnc, i;
+		if (bind[type]){
+			return bind[type].bind(elem, type, func, pass);
+		}
+		fnc = function(e){
+			if (!e){ // Fix some ie bugs...
+				e = window.event;
+			}
+			if (!e.stopPropagation){
+				e.stopPropagation = function(){ this.cancelBubble = true; };
+			}
+			e.data = pass;
+			func.call(elem, e);
+		};
+		if (document.addEventListener){
+			elem.addEventListener(type, fnc, false);
+		} else {
+			elem.attachEvent('on' + type, fnc);
+		}
+		bind._binds.push({elem: elem, type: type, func: func, fnc: fnc});
+	}
+
+	function grab(elem, options)
+	{
+		var data = {
+			move: {x: 0, y: 0},
+			offset: {x: 0, y: 0},
+			position: {x: 0, y: 0},
+			start: {x: 0, y: 0},
+			affects: document.documentElement,
+			stopPropagation: false,
+			preventDefault: true,
+			touch: true // Implementation unfinished, and doesn't support multitouch
+		};
+		extend(data, options);
+		data.element = elem;
+		bind(elem, mousedown, mouseDown, data);
+		if (data.touch){
+			bind(elem, touchstart, touchStart, data);
+		}
+	}
+	function ungrab(elem){
+		unbind(elem, mousedown, mousedown);
+	}
+	function mouseDown(e){
+		e.data.position.x = e.pageX;
+		e.data.position.y = e.pageY;
+		e.data.start.x = e.pageX;
+		e.data.start.y = e.pageY;
+		e.data.event = e;
+		if (e.data.onstart && e.data.onstart.call(e.data.element, e.data)){
+			return;
+		}
+		if (e.preventDefault && e.data.preventDefault){
+			e.preventDefault();
+		}
+		if (e.stopPropagation && e.data.stopPropagation){
+			e.stopPropagation();
+		}
+		bind(e.data.affects, mousemove, mouseMove, e.data);
+		bind(e.data.affects, mouseup, mouseUp, e.data);
+	}
+	function mouseMove(e){
+		if (e.preventDefault && e.data.preventDefault){
+			e.preventDefault();
+		}
+		if (e.stopPropagation && e.data.preventDefault){
+			e.stopPropagation();
+		}
+		e.data.move.x = e.pageX - e.data.position.x;
+		e.data.move.y = e.pageY - e.data.position.y;
+		e.data.position.x = e.pageX;
+		e.data.position.y = e.pageY;
+		e.data.offset.x = e.pageX - e.data.start.x;
+		e.data.offset.y = e.pageY - e.data.start.y;
+		e.data.event = e;
+		if (e.data.onmove){
+			e.data.onmove.call(e.data.element, e.data);
+		}
+	}
+	function mouseUp(e){
+		if (e.preventDefault && e.data.preventDefault){
+			e.preventDefault();
+		}
+		if (e.stopPropagation && e.data.stopPropagation){
+			e.stopPropagation();
+		}
+		unbind(e.data.affects, mousemove, mouseMove);
+		unbind(e.data.affects, mouseup, mouseUp);
+		e.data.event = e;
+		if (e.data.onfinish){
+			e.data.onfinish.call(e.data.element, e.data);
+		}
+	}
+	function touchStart(e){
+		e.data.position.x = e.touches[0].pageX;
+		e.data.position.y = e.touches[0].pageY;
+		e.data.start.x = e.touches[0].pageX;
+		e.data.start.y = e.touches[0].pageY;
+		e.data.event = e;
+		if (e.data.onstart && e.data.onstart.call(e.data.element, e.data)){
+			return;
+		}
+		if (e.preventDefault && e.data.preventDefault){
+			e.preventDefault();
+		}
+		if (e.stopPropagation && e.data.stopPropagation){
+			e.stopPropagation();
+		}
+		bind(e.data.affects, touchmove, touchMove, e.data);
+		bind(e.data.affects, touchend, touchEnd, e.data);
+	}
+	function touchMove(e){
+		if (e.preventDefault && e.data.preventDefault){
+			e.preventDefault();
+		}
+		if (e.stopPropagation && e.data.stopPropagation){
+			e.stopPropagation();
+		}
+		e.data.move.x = e.touches[0].pageX - e.data.position.x;
+		e.data.move.y = e.touches[0].pageY - e.data.position.y;
+		e.data.position.x = e.touches[0].pageX;
+		e.data.position.y = e.touches[0].pageY;
+		e.data.offset.x = e.touches[0].pageX - e.data.start.x;
+		e.data.offset.y = e.touches[0].pageY - e.data.start.y;
+		e.data.event = e;
+		if (e.data.onmove){
+			e.data.onmove.call(e.data.elem, e.data);
+		}
+	}
+	function touchEnd(e){
+		if (e.preventDefault && e.data.preventDefault){
+			e.preventDefault();
+		}
+		if (e.stopPropagation && e.data.stopPropagation){
+			e.stopPropagation();
+		}
+		unbind(e.data.affects, touchmove, touchMove);
+		unbind(e.data.affects, touchend, touchEnd);
+		e.data.event = e;
+		if (e.data.onfinish){
+			e.data.onfinish.call(e.data.element, e.data);
+		}
+	}
+
+	bind._binds = [];
+
+	$.fn.grab = function(a, b){
+		return this.each(function(){
+			return grab(this, a, b);
+		});
+	};
+	$.fn.ungrab = function(a){
+		return this.each(function(){
+			return ungrab(this, a);
+		});
+	};
+})(jQuery);
\ No newline at end of file
diff --git a/server/djrandom/frontend/static/js/jquery.transform.js b/server/djrandom/frontend/static/js/jquery.transform.js
new file mode 100644
index 0000000000000000000000000000000000000000..db58781289b2f676769889527a2bd16fab6acdad
--- /dev/null
+++ b/server/djrandom/frontend/static/js/jquery.transform.js
@@ -0,0 +1,532 @@
+/*
+ * transform: A jQuery cssHooks adding cross-browser 2d transform capabilities to $.fn.css() and $.fn.animate()
+ *
+ * limitations:
+ * - requires jQuery 1.4.3+
+ * - Should you use the *translate* property, then your elements need to be absolutely positionned in a relatively positionned wrapper **or it will fail in IE678**.
+ * - transformOrigin is not accessible
+ *
+ * latest version and complete README available on Github:
+ * https://github.com/louisremi/jquery.transform.js
+ *
+ * Copyright 2011 @louis_remi
+ * Licensed under the MIT license.
+ *
+ * This saved you an hour of work?
+ * Send me music http://www.amazon.co.uk/wishlist/HNTU0468LQON
+ *
+ */
+(function( $ ) {
+
+/*
+ * Feature tests and global variables
+ */
+var div = document.createElement('div'),
+	divStyle = div.style,
+	propertyName = 'transform',
+	suffix = 'Transform',
+	testProperties = [
+		'O' + suffix,
+		'ms' + suffix,
+		'Webkit' + suffix,
+		'Moz' + suffix,
+		// prefix-less property
+		propertyName
+	],
+	i = testProperties.length,
+	supportProperty,
+	supportMatrixFilter,
+	propertyHook,
+	propertyGet,
+	rMatrix = /Matrix([^)]*)/;
+
+// test different vendor prefixes of this property
+while ( i-- ) {
+	if ( testProperties[i] in divStyle ) {
+		$.support[propertyName] = supportProperty = testProperties[i];
+		continue;
+	}
+}
+// IE678 alternative
+if ( !supportProperty ) {
+	$.support.matrixFilter = supportMatrixFilter = divStyle.filter === '';
+}
+// prevent IE memory leak
+div = divStyle = null;
+
+// px isn't the default unit of this property
+$.cssNumber[propertyName] = true;
+
+/*
+ * fn.css() hooks
+ */
+if ( supportProperty && supportProperty != propertyName ) {
+	// Modern browsers can use jQuery.cssProps as a basic hook
+	$.cssProps[propertyName] = supportProperty;
+	
+	// Firefox needs a complete hook because it stuffs matrix with 'px'
+	if ( supportProperty == 'Moz' + suffix ) {
+		propertyHook = {
+			get: function( elem, computed ) {
+				return (computed ?
+					// remove 'px' from the computed matrix
+					$.css( elem, supportProperty ).split('px').join(''):
+					elem.style[supportProperty]
+				)
+			},
+			set: function( elem, value ) {
+				// remove 'px' from matrices
+				elem.style[supportProperty] = /matrix[^)p]*\)/.test(value) ?
+					value.replace(/matrix((?:[^,]*,){4})([^,]*),([^)]*)/, 'matrix$1$2px,$3px'):
+					value;
+			}
+		}
+	/* Fix two jQuery bugs still present in 1.5.1
+	 * - rupper is incompatible with IE9, see http://jqbug.com/8346
+	 * - jQuery.css is not really jQuery.cssProps aware, see http://jqbug.com/8402
+	 */
+	} else if ( /^1\.[0-5](?:\.|$)/.test($.fn.jquery) ) {
+		propertyHook = {
+			get: function( elem, computed ) {
+				return (computed ?
+					$.css( elem, supportProperty.replace(/^ms/, 'Ms') ):
+					elem.style[supportProperty]
+				)
+			}
+		}
+	}
+	/* TODO: leverage hardware acceleration of 3d transform in Webkit only
+	else if ( supportProperty == 'Webkit' + suffix && support3dTransform ) {
+		propertyHook = {
+			set: function( elem, value ) {
+				elem.style[supportProperty] = 
+					value.replace();
+			}
+		}
+	}*/
+	
+} else if ( supportMatrixFilter ) {
+	propertyHook = {
+		get: function( elem, computed ) {
+			var elemStyle = ( computed && elem.currentStyle ? elem.currentStyle : elem.style ),
+				matrix;
+
+			if ( elemStyle && rMatrix.test( elemStyle.filter ) ) {
+				matrix = RegExp.$1.split(',');
+				matrix = [
+					matrix[0].split('=')[1],
+					matrix[2].split('=')[1],
+					matrix[1].split('=')[1],
+					matrix[3].split('=')[1]
+				];
+			} else {
+				matrix = [1,0,0,1];
+			}
+			matrix[4] = elemStyle ? elemStyle.left : 0;
+			matrix[5] = elemStyle ? elemStyle.top : 0;
+			return "matrix(" + matrix + ")";
+		},
+		set: function( elem, value, animate ) {
+			var elemStyle = elem.style,
+				currentStyle,
+				Matrix,
+				filter;
+
+			if ( !animate ) {
+				elemStyle.zoom = 1;
+			}
+
+			value = matrix(value);
+
+			// rotate, scale and skew
+			if ( !animate || animate.M ) {
+				Matrix = [
+					"Matrix("+
+						"M11="+value[0],
+						"M12="+value[2],
+						"M21="+value[1],
+						"M22="+value[3],
+						"SizingMethod='auto expand'"
+				].join();
+				filter = ( currentStyle = elem.currentStyle ) && currentStyle.filter || elemStyle.filter || "";
+
+				elemStyle.filter = rMatrix.test(filter) ?
+					filter.replace(rMatrix, Matrix) :
+					filter + " progid:DXImageTransform.Microsoft." + Matrix + ")";
+
+				// center the transform origin, from pbakaus's Transformie http://github.com/pbakaus/transformie
+				if ( (centerOrigin = $.transform.centerOrigin) ) {
+					elemStyle[centerOrigin == 'margin' ? 'marginLeft' : 'left'] = -(elem.offsetWidth/2) + (elem.clientWidth/2) + 'px';
+					elemStyle[centerOrigin == 'margin' ? 'marginTop' : 'top'] = -(elem.offsetHeight/2) + (elem.clientHeight/2) + 'px';
+				}
+			}
+
+			// translate
+			if ( !animate || animate.T ) {
+				// We assume that the elements are absolute positionned inside a relative positionned wrapper
+				elemStyle.left = value[4] + 'px';
+				elemStyle.top = value[5] + 'px';
+			}
+		}
+	}
+}
+// populate jQuery.cssHooks with the appropriate hook if necessary
+if ( propertyHook ) {
+	$.cssHooks[propertyName] = propertyHook;
+}
+// we need a unique setter for the animation logic
+propertyGet = propertyHook && propertyHook.get || $.css;
+
+/*
+ * fn.animate() hooks
+ */
+$.fx.step.transform = function( fx ) {
+	var elem = fx.elem,
+		start = fx.start,
+		end = fx.end,
+		split,
+		pos = fx.pos,
+		transform,
+		translate,
+		rotate,
+		scale,
+		skew,
+		T = false,
+		M = false,
+		prop;
+	translate = rotate = scale = skew = '';
+
+	// fx.end and fx.start need to be converted to their translate/rotate/scale/skew components
+	// so that we can interpolate them
+	if ( !start || typeof start === "string" ) {
+		// the following block can be commented out with jQuery 1.5.1+, see #7912
+		if (!start) {
+			start = propertyGet( elem, supportProperty );
+		}
+
+		// force layout only once per animation
+		if ( supportMatrixFilter ) {
+			elem.style.zoom = 1;
+		}
+
+		// if the start computed matrix is in end, we are doing a relative animation
+		split = end.split(start);
+		if ( split.length == 2 ) {
+			// remove the start computed matrix to make animations more accurate
+			end = split.join('');
+			fx.origin = start;
+			start = 'none';
+		}
+
+		// start is either 'none' or a matrix(...) that has to be parsed
+		fx.start = start = start == 'none'?
+			{
+				translate: [0,0],
+				rotate: 0,
+				scale: [1,1],
+				skew: [0,0]
+			}:
+			unmatrix( toArray(start) );
+
+		// fx.end has to be parsed and decomposed
+		fx.end = end = ~end.indexOf('matrix')?
+			// bullet-proof parser
+			unmatrix(matrix(end)):
+			// faster and more precise parser
+			components(end);
+
+		// get rid of properties that do not change
+		for ( prop in start) {
+			if ( prop == 'rotate' ?
+				start[prop] == end[prop]:
+				start[prop][0] == end[prop][0] && start[prop][1] == end[prop][1]
+			) {
+				delete start[prop];
+			}
+		}
+	}
+
+	/*
+	 * We want a fast interpolation algorithm.
+	 * This implies avoiding function calls and sacrifying DRY principle:
+	 * - avoid $.each(function(){})
+	 * - round values using bitewise hacks, see http://jsperf.com/math-round-vs-hack/3
+	 */
+	if ( start.translate ) {
+		// round translate to the closest pixel
+		translate = ' translate('+
+			((start.translate[0] + (end.translate[0] - start.translate[0]) * pos + .5) | 0) +'px,'+
+			((start.translate[1] + (end.translate[1] - start.translate[1]) * pos + .5) | 0) +'px'+
+		')';
+		T = true;
+	}
+	if ( start.rotate != undefined ) {
+		rotate = ' rotate('+ (start.rotate + (end.rotate - start.rotate) * pos) +'rad)';
+		M = true;
+	}
+	if ( start.scale ) {
+		scale = ' scale('+
+			(start.scale[0] + (end.scale[0] - start.scale[0]) * pos) +','+
+			(start.scale[1] + (end.scale[1] - start.scale[1]) * pos) +
+		')';
+		M = true;
+	}
+	if ( start.skew ) {
+		skew = ' skew('+
+			(start.skew[0] + (end.skew[0] - start.skew[0]) * pos) +'rad,'+
+			(start.skew[1] + (end.skew[1] - start.skew[1]) * pos) +'rad'+
+		')';
+		M = true;
+	}
+
+	// In case of relative animation, restore the origin computed matrix here.
+	transform = fx.origin ?
+		fx.origin + translate + skew + scale + rotate:
+		translate + rotate + scale + skew;
+
+	propertyHook && propertyHook.set ?
+		propertyHook.set( elem, transform, {M: M, T: T} ):
+		elem.style[supportProperty] = transform;
+};
+
+/*
+ * Utility functions
+ */
+
+// turns a transform string into its 'matrix(A,B,C,D,X,Y)' form (as an array, though)
+function matrix( transform ) {
+	transform = transform.split(')');
+	var
+			trim = $.trim
+		// last element of the array is an empty string, get rid of it
+		, i = transform.length -1
+		, split, prop, val
+		, A = 1
+		, B = 0
+		, C = 0
+		, D = 1
+		, A_, B_, C_, D_
+		, tmp1, tmp2
+		, X = 0
+		, Y = 0
+		;
+	// Loop through the transform properties, parse and multiply them
+	while (i--) {
+		split = transform[i].split('(');
+		prop = trim(split[0]);
+		val = split[1];
+		A_ = B_ = C_ = D_ = 0;
+
+		switch (prop) {
+			case 'translateX':
+				X += parseInt(val, 10);
+				continue;
+
+			case 'translateY':
+				Y += parseInt(val, 10);
+				continue;
+
+			case 'translate':
+				val = val.split(',');
+				X += parseInt(val[0], 10);
+				Y += parseInt(val[1] || 0, 10);
+				continue;
+
+			case 'rotate':
+				val = toRadian(val);
+				A_ = Math.cos(val);
+				B_ = Math.sin(val);
+				C_ = -Math.sin(val);
+				D_ = Math.cos(val);
+				break;
+
+			case 'scaleX':
+				A_ = val;
+				D_ = 1;
+				break;
+
+			case 'scaleY':
+				A_ = 1;
+				D_ = val;
+				break;
+
+			case 'scale':
+				val = val.split(',');
+				A_ = val[0];
+				D_ = val.length>1 ? val[1] : val[0];
+				break;
+
+			case 'skewX':
+				A_ = D_ = 1;
+				C_ = Math.tan(toRadian(val));
+				break;
+
+			case 'skewY':
+				A_ = D_ = 1;
+				B_ = Math.tan(toRadian(val));
+				break;
+
+			case 'skew':
+				A_ = D_ = 1;
+				val = val.split(',');
+				C_ = Math.tan(toRadian(val[0]));
+				B_ = Math.tan(toRadian(val[1] || 0));
+				break;
+
+			case 'matrix':
+				val = val.split(',');
+				A_ = +val[0];
+				B_ = +val[1];
+				C_ = +val[2];
+				D_ = +val[3];
+				X += parseInt(val[4], 10);
+				Y += parseInt(val[5], 10);
+		}
+		// Matrix product
+		tmp1 = A * A_ + B * C_;
+		B    = A * B_ + B * D_;
+		tmp2 = C * A_ + D * C_;
+		D    = C * B_ + D * D_;
+		A = tmp1;
+		C = tmp2;
+	}
+	return [A,B,C,D,X,Y];
+}
+
+// turns a matrix into its rotate, scale and skew components
+// algorithm from http://hg.mozilla.org/mozilla-central/file/7cb3e9795d04/layout/style/nsStyleAnimation.cpp
+function unmatrix(matrix) {
+	var
+			scaleX
+		, scaleY
+		, skew
+		, A = matrix[0]
+		, B = matrix[1]
+		, C = matrix[2]
+		, D = matrix[3]
+		;
+
+	// Make sure matrix is not singular
+	if ( A * D - B * C ) {
+		// step (3)
+		scaleX = Math.sqrt( A * A + B * B );
+		A /= scaleX;
+		B /= scaleX;
+		// step (4)
+		skew = A * C + B * D;
+		C -= A * skew;
+		D -= B * skew;
+		// step (5)
+		scaleY = Math.sqrt( C * C + D * D );
+		C /= scaleY;
+		D /= scaleY;
+		skew /= scaleY;
+		// step (6)
+		if ( A * D < B * C ) {
+			//scaleY = -scaleY;
+			//skew = -skew;
+			A = -A;
+			B = -B;
+			skew = -skew;
+			scaleX = -scaleX;
+		}
+
+	// matrix is singular and cannot be interpolated
+	} else {
+		rotate = scaleX = scaleY = skew = 0;
+	}
+
+	return {
+		translate: [+matrix[4], +matrix[5]],
+		rotate: Math.atan2(B, A),
+		scale: [scaleX, scaleY],
+		skew: [skew, 0]
+	}
+}
+
+// parse tranform components of a transform string not containing 'matrix(...)'
+function components( transform ) {
+	// split the != transforms
+  transform = transform.split(')');
+
+	var translate = [0,0],
+    rotate = 0,
+    scale = [1,1],
+    skew = [0,0],
+    i = transform.length -1,
+    trim = $.trim,
+    split, name, value;
+
+  // add components
+  while ( i-- ) {
+    split = transform[i].split('(');
+    name = trim(split[0]);
+    value = split[1];
+    
+    if (name == 'translateX') {
+      translate[0] += parseInt(value, 10);
+
+    } else if (name == 'translateY') {
+      translate[1] += parseInt(value, 10);
+
+    } else if (name == 'translate') {
+      value = value.split(',');
+      translate[0] += parseInt(value[0], 10);
+      translate[1] += parseInt(value[1] || 0, 10);
+
+    } else if (name == 'rotate') {
+      rotate += toRadian(value);
+
+    } else if (name == 'scaleX') {
+      scale[0] *= value;
+
+    } else if (name == 'scaleY') {
+      scale[1] *= value;
+
+    } else if (name == 'scale') {
+      value = value.split(',');
+      scale[0] *= value[0];
+      scale[1] *= (value.length>1? value[1] : value[0]);
+
+    } else if (name == 'skewX') {
+      skew[0] += toRadian(value);
+
+    } else if (name == 'skewY') {
+      skew[1] += toRadian(value);
+
+    } else if (name == 'skew') {
+      value = value.split(',');
+      skew[0] += toRadian(value[0]);
+      skew[1] += toRadian(value[1] || '0');
+    }
+	}
+
+  return {
+    translate: translate,
+    rotate: rotate,
+    scale: scale,
+    skew: skew
+  };
+}
+
+// converts an angle string in any unit to a radian Float
+function toRadian(value) {
+	return ~value.indexOf('deg') ?
+		parseInt(value,10) * (Math.PI * 2 / 360):
+		~value.indexOf('grad') ?
+			parseInt(value,10) * (Math.PI/200):
+			parseFloat(value);
+}
+
+// Converts 'matrix(A,B,C,D,X,Y)' to [A,B,C,D,X,Y]
+function toArray(matrix) {
+	// Fremove the unit of X and Y for Firefox
+	matrix = /\(([^,]*),([^,]*),([^,]*),([^,]*),([^,p]*)(?:px)?,([^)p]*)(?:px)?/.exec(matrix);
+	return [matrix[1], matrix[2], matrix[3], matrix[4], matrix[5], matrix[6]];
+}
+
+$.transform = {
+	centerOrigin: 'margin'
+};
+
+})( jQuery );
\ No newline at end of file
diff --git a/server/djrandom/frontend/static/js/mod.csstransforms.min.js b/server/djrandom/frontend/static/js/mod.csstransforms.min.js
new file mode 100644
index 0000000000000000000000000000000000000000..6386d5bf31fd184569d1ed0d44d8a67010529af5
--- /dev/null
+++ b/server/djrandom/frontend/static/js/mod.csstransforms.min.js
@@ -0,0 +1,2 @@
+/* Modernizr custom build of 1.7pre: csstransforms */
+window.Modernizr=function(a,b,c){function G(){}function F(a,b){var c=a.charAt(0).toUpperCase()+a.substr(1),d=(a+" "+p.join(c+" ")+c).split(" ");return!!E(d,b)}function E(a,b){for(var d in a)if(k[a[d]]!==c&&(!b||b(a[d],j)))return!0}function D(a,b){return(""+a).indexOf(b)!==-1}function C(a,b){return typeof a===b}function B(a,b){return A(o.join(a+";")+(b||""))}function A(a){k.cssText=a}var d="1.7pre",e={},f=!0,g=b.documentElement,h=b.head||b.getElementsByTagName("head")[0],i="modernizr",j=b.createElement(i),k=j.style,l=b.createElement("input"),m=":)",n=Object.prototype.toString,o=" -webkit- -moz- -o- -ms- -khtml- ".split(" "),p="Webkit Moz O ms Khtml".split(" "),q={svg:"http://www.w3.org/2000/svg"},r={},s={},t={},u=[],v,w=function(a){var c=b.createElement("style"),d=b.createElement("div"),e;c.textContent=a+"{#modernizr{height:3px}}",h.appendChild(c),d.id="modernizr",g.appendChild(d),e=d.offsetHeight===3,c.parentNode.removeChild(c),d.parentNode.removeChild(d);return!!e},x=function(){function d(d,e){e=e||b.createElement(a[d]||"div");var f=(d="on"+d)in e;f||(e.setAttribute||(e=b.createElement("div")),e.setAttribute&&e.removeAttribute&&(e.setAttribute(d,""),f=C(e[d],"function"),C(e[d],c)||(e[d]=c),e.removeAttribute(d))),e=null;return f}var a={select:"input",change:"input",submit:"form",reset:"form",error:"img",load:"img",abort:"img"};return d}(),y=({}).hasOwnProperty,z;C(y,c)||C(y.call,c)?z=function(a,b){return b in a&&C(a.constructor.prototype[b],c)}:z=function(a,b){return y.call(a,b)},r.csstransforms=function(){return!!E(["transformProperty","WebkitTransform","MozTransform","OTransform","msTransform"])};for(var H in r)z(r,H)&&(v=H.toLowerCase(),e[v]=r[H](),u.push((e[v]?"":"no-")+v));e.input||G(),e.crosswindowmessaging=e.postmessage,e.historymanagement=e.history,e.addTest=function(a,b){a=a.toLowerCase();if(!e[a]){b=!!b(),g.className+=" "+(b?"":"no-")+a,e[a]=b;return e}},A(""),j=l=null,e._enableHTML5=f,e._version=d,g.className=g.className.replace(/\bno-js\b/,"")+" js "+u.join(" ");return e}(this,this.document)
\ No newline at end of file
diff --git a/server/djrandom/frontend/templates/_base.html b/server/djrandom/frontend/templates/_base.html
index d5002416b6deaa75c59c1addf32decb5045f49a0..dafc8c07a6d9856f9dc5205eb0393f31517ebf20 100644
--- a/server/djrandom/frontend/templates/_base.html
+++ b/server/djrandom/frontend/templates/_base.html
@@ -7,15 +7,23 @@
     <link rel="stylesheet" type="text/css"
           href="/static/css/blitzer/jquery-ui-1.8.13.custom.css">
     <link rel="stylesheet" type="text/css"
-          href="/static/jplayer-bluemonday/jplayer.blue.monday.css">
+          href="/static/circle.skin/circle.player.css">
     <script type="text/javascript" 
             src="/static/js/jquery-1.5.1.min.js"></script>
     <script type="text/javascript" 
             src="/static/js/jquery-ui-1.8.13.custom.min.js"></script>
+    <script type="text/javascript"
+            src="/static/js/jquery.fullbg.min.js"></script>
+    <script type="text/javascript"
+            src="/static/js/jquery.transform.js"></script>
+    <script type="text/javascript"
+            src="/static/js/jquery.grab.js"></script>
+    <script type="text/javascript"
+            src="/static/js/mod.csstransforms.min.js"></script>
     <script type="text/javascript"
             src="/static/js/jquery.jplayer.min.js"></script>
     <script type="text/javascript"
-            src="/static/js/jquery.fullbg.min.js"></script>
+            src="/static/js/circle.player.js"></script>
     <script type="text/javascript"
             src="/static/js/djr.min.js"></script>
     {% block head %}{% endblock %}
diff --git a/server/djrandom/frontend/templates/_macros.html b/server/djrandom/frontend/templates/_macros.html
index 1c25e24ed85c51e3482619c22824cbc0ff19c5c4..3557ad5a8cf461de64954bdc5b37a25fa0b8a421 100644
--- a/server/djrandom/frontend/templates/_macros.html
+++ b/server/djrandom/frontend/templates/_macros.html
@@ -1,6 +1,7 @@
 {% macro render_song(mp3) -%}
 <div class="song" id="song_{{ mp3.sha1 }}">
   <div class="ctlbox" style="display:none;">
+    <a id="song_{{ mp3.sha1 }}_mlt" class="ctl_btn ctl_love"></a>
     <a id="song_{{ mp3.sha1 }}_remove" class="ctl_btn ctl_remove"></a>
   </div>
   <a id="play_{{ mp3.sha1 }}" class="song_a">
diff --git a/server/djrandom/frontend/templates/about.html b/server/djrandom/frontend/templates/about.html
index aedc42e9eca58f0f9c1b254169ec5298eedadc17..df3049d9bcdaf2a9ce569aa1ebdbd0bec3114b78 100644
--- a/server/djrandom/frontend/templates/about.html
+++ b/server/djrandom/frontend/templates/about.html
@@ -11,5 +11,10 @@
           <b>{{ num_songs }}</b> songs
           (<b>{{ used_gb }}</b> Gb).
         </p>
+        {% if dup_songs %}
+        <p>
+          <b>{{ dup_songs }}</b> duplicates found.
+        </p>
+        {% endif %}
 
 {% endblock %}
diff --git a/server/djrandom/frontend/templates/index.html b/server/djrandom/frontend/templates/index.html
index e81f857ad16b17069112e52d3d066d364a79b371..a40880a5f41e494cace56d93fda80b521f48968d 100644
--- a/server/djrandom/frontend/templates/index.html
+++ b/server/djrandom/frontend/templates/index.html
@@ -2,36 +2,8 @@
 
 {% block head %}
 <script type="text/javascript">
-do_autocomplete = function(request, response) {
-  $.ajax({url: '/autocomplete', 
-          data: {'term': request.term},
-          dataType: 'json',
-          success: function(data) {
-              var items = data.results;
-              response(data.results);
-            },
-          error: function(xhr, status, errorThrown) {
-              response();
-          }});
-};
-
-do_search = function() {
-  var player = djr.player();
-  player.search($('#queryField').val());
-  return false;
-};
-
 $(document).ready(function() {
-  $('#queryField').autocomplete({
-    source: do_autocomplete,
-    autoFocus: true,
-    delay: 200,
-    minLength: 1,
-    });
-  $('#queryField').focus();
-  $('#searchForm').submit(do_search);
-
-  // Initialize DJRd.
+  // Initialize DJR.
   djr.init();
 });
 </script>
@@ -44,74 +16,82 @@ DJ:Random
 {% block body %}
 
 <div id="rbox">
-  <h1>{{ self.title() }}</h1>
-
-  <div id="djr_player" class="jp-jplayer"></div>
-  <div id="playerintf" class="jp-audio">
-    <div class="jp-type-single">
-      <div id="jp_interface_1" class="jp-interface">
-        <ul class="jp-controls">
-          <li><a href="#" class="jp-play" tabindex="1">play</a></li>
-          <li><a href="#" class="jp-pause" tabindex="1">pause</a></li>
-          <li><a href="#" class="jp-stop" tabindex="1">stop</a></li>
-          <li><a href="#" class="jp-mute" tabindex="1">mute</a></li>
-          <li><a href="#" class="jp-unmute" tabindex="1">unmute</a></li>
-        </ul>
-        <div class="jp-progress">
-          <div class="jp-seek-bar">
-            <div class="jp-play-bar"></div>
-          </div>
-        </div>
-        <div class="jp-volume-bar">
-          <div class="jp-volume-bar-value"></div>
-        </div>
-        <div class="jp-current-time"></div>
-        <div class="jp-duration"></div>
-      </div>
-      <div id="jp_playlist_1" class="jp-playlist">
-        <ul>
-          <li></li>
-        </ul>
-      </div>
-        <div id="lyricsDiv" >
-          <a id="lyricsbtn">Lyrics</a>
-          <iframe id="lyrics" width="100%" height="300" style="display: none" >
-            <p>Your browser does not support iframes.</p>
-          </iframe>
-        </div>
-        <div id="wikiDiv"  >
-          <a id="wikibtn">Wikipedia Author</a>
-          <iframe id="wikipedia" width="100%" height="300" style="display: none" >
-            <p>Your browser does not support iframes.</p>
-          </iframe>
-        </div>
-        <div id="lastfmDiv" >
-          <a id="lastfmbtn">LastFM Song Info</a>
-          <iframe id="lastfm" width="100%" height="300" style="display: none" >
-            <p>Your browser does not support iframes.</p>
-          </iframe>
-        </div>
+  <div id="djr_player" class="cp-jplayer"></div>
+  <div id="cp_container" class="cp-container">
+    <div class="cp-buffer-holder">
+      <div class="cp-buffer-1"></div>
+      <div class="cp-buffer-2"></div>
+    </div>
+    <div class="cp-progress-holder">
+      <div class="cp-progress-1"></div>
+      <div class="cp-progress-2"></div>
     </div>
+    <div class="cp-circle-control"></div>
+    <ul class="cp-controls">
+      <li><a href="#" class="cp-play" tabindex="1">play</a></li>
+      <li><a href="#" class="cp-pause" style="display:none;" tabindex="1">pause</a></li>
+    </ul>
+  </div>
+
+  <div id="songInfoDiv">
+  </div>
+
+  <div id="pllistDiv" >
+    <a id="pllistbtn">Playlists</a>
+    <div id="pllist" style="display: none"></div>
+  </div>
+
+  <div id="lyricsDiv" >
+    <a id="lyricsbtn">Lyrics</a>
+    <iframe id="lyrics" width="100%" height="300" style="display: none">
+      <p>Your browser does not support iframes.</p>
+    </iframe>
+  </div>
+  
+  <div id="wikiDiv"  >
+    <a id="wikibtn">Wikipedia Author</a>
+    <iframe id="wikipedia" width="100%" height="300" style="display: none">
+      <p>Your browser does not support iframes.</p>
+    </iframe>
+  </div>
+  
+  <div id="lastfmDiv" >
+    <a id="lastfmbtn">LastFM Song Info</a>
+    <iframe id="lastfm" width="100%" height="300" style="display: none">
+      <p>Your browser does not support iframes.</p>
+    </iframe>
   </div>
 </div>
 
 <div id="lbox">
-  <form id="searchForm" action="/" method="get">
-    <p id="searchBox">
-      <input type="text" name="q" size="40" id="queryField">
-      <input type="submit" value="-&gt;">
-    </p>
-  </form>
+  <div id="titleDiv">
+    <h1 class="title">DJ:Random</h1>
+  </div>
+
+  <div id="searchDiv">
+    <form id="searchForm" action="/" method="get">
+      <div id="loaderImgDiv">
+        <img id="loaderImg" src="/static/img/loader.gif" style="display:none">
+      </div>
+      <div id="searchBoxDiv">
+        <input type="text" name="q" size="40" id="queryField">
+        <input type="submit" value="-&gt;">
+      </div>
+    </form>
+  </div>
 
-  <div id="playlistButtons" >
-    <a id="playlistLast25">Last 25</a>     
-    | <a id="playlistRandom">Random</a>     
-    | <a id="playlistMost">Most Played</a>     
+  <div id="playlistButtons" class="buttonbox">
+    <a id="playlistLast5">Last 5</a>,&nbsp;<a id="playlistLast25">(25)</a>     
+    | <a id="playlistRandom5">Random 5</a>,&nbsp;<a id="playlistRandom25">(25)</a>     
+    | <a id="playlistMost5">Most 5</a>,&nbsp;<a id="playlistMost25">(25)</a>     
+    | <a id="playlistNever5">Never 5</a>,&nbsp;<a id="playlistNever25">(25)</a>     
+    | <a id="playlistSave">Save as</a><div id="saveForm" style="display:none;" >
+      <input type="text" name="s" size="15" id="savetext" ><a id="playlistSaveBtn" >Save</a></div>     
   </div>
   <div id="playlistDiv">
   </div>
 
-  <div id="playlistControls">
+  <div id="playlistControls" class="buttonbox">
     <a id="playlistExtendBtn">Extend</a>
     | <a id="playlistClearBtn">Clear</a>
     | <a id="playlistStreamBtn">Stream</a> 
diff --git a/server/djrandom/frontend/templates/user_details.html b/server/djrandom/frontend/templates/user_details.html
index d12373a88a078e867b82b68865973f6415176f70..bd840d29a94caa927973bc2f973082e0d16bf964 100644
--- a/server/djrandom/frontend/templates/user_details.html
+++ b/server/djrandom/frontend/templates/user_details.html
@@ -39,9 +39,11 @@
           </form>
         </p>
 
+        {% if user.invites_left > 0 %}
         <p>
           <a href="/user/invite"><b>Invite someone</b></a>
         </p>
+        {% endif %}
 
       </div>
 
diff --git a/server/djrandom/frontend/user_views.py b/server/djrandom/frontend/user_views.py
index c62695926b91af96d797707175cddaf531e8b5d5..d12025bca3d608e64e030d1cf130dc11ea04ad87 100644
--- a/server/djrandom/frontend/user_views.py
+++ b/server/djrandom/frontend/user_views.py
@@ -50,6 +50,7 @@ def login():
         user = User.query.filter_by(email=email).first()
         if user and user.check_password(form.password.data):
             session['userid'] = user.id
+            session.permanent = True
             return redirect('/')
         log.info('failed login for %s' % email)
         login_failed = True
@@ -103,6 +104,10 @@ def user_revoke_api_key(keyid):
 @app.route('/user/invite', methods=['GET', 'POST'])
 @require_auth
 def user_send_invite():
+    user = User.query.get(g.userid)
+    if not user.invites_left:
+        abort(403)
+
     form = InviteForm()
     if form.validate_on_submit():
         user = User.query.get(g.userid)
@@ -113,6 +118,8 @@ def user_send_invite():
         svcs['mailer'].send(email, 'Invitation',
                             new_user.get_activation_email(username))
 
+        user.invites_left -= 1
+        Session.add(user)
         Session.add(new_user)
         Session.commit()
         flash('invitation sent to %s' % email)
diff --git a/server/djrandom/frontend/views.py b/server/djrandom/frontend/views.py
index ec792dd7e8b7defdc867fa880aefc95ea0d5c09c..ca83889bb1055d5657700decf075d98c4db4a416 100644
--- a/server/djrandom/frontend/views.py
+++ b/server/djrandom/frontend/views.py
@@ -38,11 +38,13 @@ def songs_fragment():
 @app.route('/about')
 @require_auth
 def about():
-    num_songs = MP3.query.filter_by(error=False).count()
+    num_songs = MP3.query.filter_by(state=MP3.READY).count()
+    dup_songs = MP3.query.filter_by(state=MP3.DUPLICATE).count()
     used_gb = int(Session.query(func.sum(MP3.size)).first()[0] / (2 << 29))
     num_users = User.query.filter_by(active=True).count()
     return render_template('about.html', num_users=num_users,
-                           num_songs=num_songs, used_gb=used_gb)
+                           num_songs=num_songs, used_gb=used_gb,
+                           dup_songs=dup_songs)
 
 
 @app.route('/')
diff --git a/server/djrandom/metadata_fixer/__init__.py b/server/djrandom/metadata_fixer/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/server/djrandom/metadata_fixer/metadata_fixer.py b/server/djrandom/metadata_fixer/metadata_fixer.py
new file mode 100644
index 0000000000000000000000000000000000000000..c89ed8fed76251b858ad7facef3649fc4ed10da1
--- /dev/null
+++ b/server/djrandom/metadata_fixer/metadata_fixer.py
@@ -0,0 +1,134 @@
+import json
+import os
+import optparse
+import logging
+import socket
+import time
+import traceback
+import urllib2
+from djrandom import daemonize
+from djrandom import utils
+from djrandom.model.mp3 import MP3
+from djrandom.database import Session, init_db
+from djrandom.scanner import metadata
+from djrandom.scanner import indexer
+
+log = logging.getLogger(__name__)
+
+
+class NoMetadataError(Exception):
+    pass
+
+
+class MetadataFixer(object):
+
+    ECHONEST_API_URL = 'http://developer.echonest.com/api/v4/song/identify'
+
+    def __init__(self, solr_url, echonest_api_key):
+        self.api_key = echonest_api_key
+        self.idx = indexer.Indexer(solr_url)
+
+    def identify_song(self, mp3):
+        json_fp = mp3.get_fingerprint()
+        while True:
+
+            req = urllib2.Request(
+                '%s?api_key=%s' % (self.ECHONEST_API_URL, self.api_key),
+                data=json_fp,
+                headers={'Content-Type': 'application/octet-stream'})
+
+            try:
+                result = json.loads(urllib2.urlopen(req).read())
+                response = result['response']
+                logging.debug('response:\n%s' % str(response))
+                if response['status']['code'] != 0:
+                    log.error('EchoNest API replied with code %d: %s' % (
+                            response['status']['code'],
+                            response['status']['message']))
+                    raise NoMetadataError('API Error')
+                if not response['songs']:
+                    log.info('no information found for %s' % mp3.sha1)
+                    raise NoMetadataError('Not found')
+                return response['songs'][0]
+
+            except urllib2.HTTPError, e:
+                # HTTPErrors are fatal only in case of 4xx codes.
+                if e.code >= 400 and e.code < 500:
+                    raise NoMetadataError('HTTP Error %d' % e.code)
+
+            log.debug('retrying...')
+
+    def process(self, mp3):
+        info = self.identify_song(mp3)
+        mp3.title = metadata.normalize_string(info['title'])
+        mp3.artist = metadata.normalize_string(info['artist_name'])
+
+    def scan(self):
+        """Scan the database for new files."""
+        n_bad = n_ok = n_err = 0
+        for mp3 in MP3.get_with_bad_metadata():
+            n_bad += 1
+            log.info('searching metadata for %s' % mp3.sha1)
+            try:
+                self.process(mp3)
+                mp3.state = MP3.READY
+                self.idx.add_mp3(mp3)
+                log.info('found: %s / %s' % (mp3.artist, mp3.title))
+                n_ok += 1
+            except NoMetadataError:
+                mp3.state = MP3.ERROR
+                n_err += 1
+            except Exception, e:
+                log.error(traceback.format_exc())
+                n_err += 1
+                mp3.state = MP3.ERROR
+            Session.add(mp3)
+        Session.commit()
+        self.idx.commit()
+        log.debug('total: %d songs, found: %d' % (n_bad, n_ok))
+
+    def run(self, run_once):
+        while True:
+            self.scan()
+            if run_once:
+                break
+            Session.remove()
+            time.sleep(600)
+
+
+def run_fixer(solr_url, echonest_api_key, db_url, run_once):
+    socket.setdefaulttimeout(300)
+
+    init_db(db_url)
+    fixer = MetadataFixer(solr_url, echonest_api_key)
+    fixer.run(run_once)
+
+
+def main():
+    parser = optparse.OptionParser()
+    parser.add_option('--once', action='store_true')
+    parser.add_option('--solr_url', default='http://localhost:8080/solr')
+    parser.add_option('--echonest_api_key')
+    parser.add_option('--db_url')
+    daemonize.add_standard_options(parser)
+    utils.read_config_defaults(
+        parser, os.getenv('DJRANDOM_CONF', '/etc/djrandom.conf'))
+    opts, args = parser.parse_args()
+    if not opts.db_url:
+        parser.error('Must provide --db_url')
+    if not opts.echonest_api_key:
+        parser.error('Must provide --echonest_api_key')
+    if args:
+        parser.error('Too many arguments')
+
+    if opts.once:
+        opts.foreground = True
+
+    daemonize.daemonize(opts, run_fixer,
+                        (opts.solr_url, opts.echonest_api_key,
+                         opts.db_url, opts.once))
+
+
+if __name__ == '__main__':
+    main()
+        
diff --git a/server/djrandom/model/markov.py b/server/djrandom/model/markov.py
index 11350ea8c193e92ab9af7cb3af7f0d5bb328d797..fa8dfecc6bc35a19c7109320298c8dd01a9cf646 100644
--- a/server/djrandom/model/markov.py
+++ b/server/djrandom/model/markov.py
@@ -72,14 +72,13 @@ class MarkovModel(object):
 
     def suggest(self, prev):
         prev_n = tuple(self._to_i(x) for x in prev)
-        if prev_n not in self._map:
-            return None
-        r = self.random.random()
-        for off, value in self._norm_map[prev_n]:
-            if off > r:
-                return self._i2hash[value]
+        if prev_n in self._map:
+            r = self.random.random()
+            for off, value in self._norm_map[prev_n]:
+                if off > r:
+                    return self._i2hash[value]
         # Can't find anything, get a random song instead.
-        return random.choice(self._ishash())
+        return random.choice(self._i2hash)
 
     def generate_sequence(self, prev, n, count):
         if len(prev) < n:
diff --git a/server/djrandom/model/mp3.py b/server/djrandom/model/mp3.py
index 10bdfc819b2a80119fe6a9dd332a02216c9d757d..3f70280c30549a664682ad58eb00f017b1d78c26 100644
--- a/server/djrandom/model/mp3.py
+++ b/server/djrandom/model/mp3.py
@@ -1,9 +1,22 @@
+import os
 import random
-from sqlalchemy.orm import deferred
+import shutil
+from sqlalchemy.orm import deferred, relationship
 from sqlalchemy import *
 from datetime import datetime, timedelta
 from djrandom.database import Base, Session
 
+# Stage duplicate files to this directory, pending cleanup.
+DUPLICATE_DIR = '/var/tmp/djrandom-duplicates'
+
+
+class Fingerprint(Base):
+
+    __tablename__ = 'fingerprints'
+
+    sha1 = Column(String(40), primary_key=True)
+    echoprint_fp = Column(Text())
+
 
 class MP3(Base):
     """A single MP3.
@@ -17,6 +30,7 @@ class MP3(Base):
     READY = 'R'
     ERROR = 'E'
     DUPLICATE = 'D'
+    BAD_METADATA = 'M'
 
     sha1 = Column(String(40), primary_key=True)
     state = Column(String(1), default=INCOMING, index=True)
@@ -25,12 +39,18 @@ class MP3(Base):
     artist = Column(Unicode(256))
     title = Column(Unicode(256))
     album = Column(Unicode(256))
+    track_num = Column(Integer(3))
     genre = Column(Unicode(64))
     uploaded_at = Column(DateTime())
     play_count = Column(Integer(), default=0)
-    echoprint_fp = deferred(Column(Text()))
     duplicate_of = Column(String(40))
 
+    has_fingerprint = Column(Boolean, default=False)
+    echoprint_fp = relationship(Fingerprint, 
+                                primaryjoin=sha1 == Fingerprint.sha1,
+                                foreign_keys=Fingerprint.sha1,
+                                uselist=False)
+
     def __init__(self, **kw):
         for k, v in kw.items():
             setattr(self, k, v)
@@ -45,8 +65,46 @@ class MP3(Base):
                 'uploaded_at': self.uploaded_at}
         if self.duplicate:
             data['duplicate_of'] = self.duplicate_of
+        if self.track_num:
+            data['track_num'] = self.track_num
         return data
 
+    def mark_as_duplicate(self, duplicate_of):
+        self.state = self.DUPLICATE
+        self.duplicate_of = duplicate_of
+        try:
+            if not os.path.isdir(DUPLICATE_DIR):
+                os.makedirs(DUPLICATE_DIR)
+            shutil.move(self.path,
+                        os.path.join(DUPLICATE_DIR, self.sha1))
+        except:
+            pass
+
+    def get_fingerprint(self):
+        if self.has_fingerprint:
+            return self.echoprint_fp.echoprint_fp
+
+    def set_fingerprint(self, fp):
+        fpobj = Fingerprint.query.get(self.sha1)
+        if not fpobj:
+            fpobj = Fingerprint()
+            fpobj.sha1 = self.sha1
+            self.echoprint_fp = fpobj
+        fpobj.echoprint_fp = fp
+        self.has_fingerprint = True
+        Session.add(fpobj)
+
+    @classmethod
+    def get_with_no_fingerprint(cls):
+        return cls.query.filter(((cls.state == cls.READY)
+                                 | (cls.state == cls.BAD_METADATA))
+                                & (cls.has_fingerprint == 0))
+
+    @classmethod
+    def get_with_bad_metadata(cls):
+        return cls.query.filter_by(state=cls.BAD_METADATA,
+                                   has_fingerprint=1)
+
     @classmethod
     def last_uploaded(cls, n=10):
         """Return the N last uploaded songs."""
@@ -54,13 +112,16 @@ class MP3(Base):
             desc(cls.uploaded_at)).limit(n)
 
     @classmethod
-    def get_random_songs(cls, n=10):
+    def get_random_songs(cls, n=10, where_clause=None):
         """Return N completely random songs."""
         results = []
-        num_songs = cls.query.filter_by(state=cls.READY).count()
+        if where_clause is None:
+            where_clause = (cls.state == cls.READY)
+        num_songs = cls.query.filter(where_clause).count()
         fraction = float(n) / num_songs
+        where_clause = where_clause & (func.rand() < fraction)
         while len(results) < n:
-            tmprows = Session.query(cls.sha1).filter(func.rand() < fraction).limit(n)
+            tmprows = Session.query(cls.sha1).filter(where_clause).limit(n)
             for row in tmprows:
                 results.append(row[0])
         return results
@@ -70,6 +131,11 @@ class MP3(Base):
         return cls.query.filter_by(
             state=cls.READY, artist=artist, album=album)
 
+    @classmethod
+    def never_played(cls, n=10):
+        """Return N random songs that were never played."""
+        return cls.get_random_songs(n, where_clause=(cls.play_count == 0))
+
 
 class PlayLog(Base):
 
diff --git a/server/djrandom/model/playlist.py b/server/djrandom/model/playlist.py
index 93e1bd4dff6ff87b5652895a35f049c298082303..e4fabd98750d3f9f80cee35d8bbdf8cbabfad619 100644
--- a/server/djrandom/model/playlist.py
+++ b/server/djrandom/model/playlist.py
@@ -10,6 +10,7 @@ class Playlist(Base):
     __tablename__ = 'playlists'
 
     uuid = Column(String(40), primary_key=True)
+    title = Column(Unicode(128), index=True)
     userid = Column(String(40), index=True)
     modified_at = Column(DateTime())
     play_count = Column(Integer(), default=0)
@@ -24,7 +25,19 @@ class Playlist(Base):
 
     def to_dict(self):
         return {'uuid': self.uuid,
+                'title': self.title,
                 'songs': self.contents.split(',')}
 
     def stream_url(self):
         return 'http://djrandom.incal.net:8000/%s.mp3' % self.uuid
+
+    @classmethod
+    def get_all_by_user(cls, userid, public=False):
+        where = (cls.userid == userid)
+        if public:
+            where = where & (cls.title != None)
+        return cls.query.filter(where).order_by(asc(cls.modified_at))
+
+    @classmethod
+    def get_by_title(cls, userid, title):
+        return cls.query.filter_by(userid=userid, title=title)
diff --git a/server/djrandom/model/user.py b/server/djrandom/model/user.py
index 27ba32ea2a647a15addb8c05121d988ae3993dd2..3605a3f6cb2a5e8c00454afbcbe7704ac0d50ded 100644
--- a/server/djrandom/model/user.py
+++ b/server/djrandom/model/user.py
@@ -49,6 +49,7 @@ class User(Base):
     password = Column(String(128))
     created_at = Column(DateTime())
     invited_by = Column(Integer())
+    invites_left = Column(Integer(3))
     active = Column(Boolean())
     activation_token = Column(String(40))
 
@@ -59,6 +60,7 @@ class User(Base):
         self.activation_token = utils.random_token()
         self.created_at = datetime.now()
         self.invited_by = invited_by
+        self.invites_left = 3
 
     def set_password(self, password):
         self.password = crypt.crypt(password, _salt())
diff --git a/server/djrandom/scanner/metadata.py b/server/djrandom/scanner/metadata.py
index 4895b39049db2c0342af748253f65f2e29534bf9..2ef62620d1e0be0dfc64cf0250e813d289082372 100644
--- a/server/djrandom/scanner/metadata.py
+++ b/server/djrandom/scanner/metadata.py
@@ -1,9 +1,19 @@
 import eyeD3
+import unicodedata
 import re
 
 
+# Compiling and including 'unicodedata' apparently makes it possible
+# to correctly interpret accented characters in unicode tag metadata.
+_nonalpha_pattern = re.compile(r'\W+', re.UNICODE)
+_spaces_pattern = re.compile(r'\s+', re.UNICODE)
+
 def normalize_string(s):
-    return re.sub(r'\W+', ' ', s, re.UNICODE).lower()
+    s = s.replace('_', ' ')
+    s = _nonalpha_pattern.sub(' ', s)
+    s = _spaces_pattern.sub(' ', s)
+    s = s.lower().strip()
+    return s
 
 
 def analyze_mp3(path):
@@ -16,4 +26,5 @@ def analyze_mp3(path):
     return {'artist': normalize_string(tag.getArtist()),
             'album': normalize_string(tag.getAlbum()),
             'title': normalize_string(tag.getTitle()),
+            'track_num': tag.getTrackNum()[0],
             'genre': unicode(genre)}
diff --git a/server/djrandom/scanner/scanner.py b/server/djrandom/scanner/scanner.py
index 9fdfddd927bf37541b4fbdf76908513708ad24c1..6617a59b831d78b243951acc29495a05349a9168 100644
--- a/server/djrandom/scanner/scanner.py
+++ b/server/djrandom/scanner/scanner.py
@@ -13,6 +13,10 @@ from djrandom.scanner import indexer
 log = logging.getLogger(__name__)
 
 
+class BadMetadataError(Exception):
+    pass
+
+
 class Scanner(object):
 
     def __init__(self, solr_url):
@@ -20,6 +24,8 @@ class Scanner(object):
 
     def process(self, mp3):
         mp3_info = metadata.analyze_mp3(mp3.path)
+        if not mp3_info['artist'] or not mp3_info['title']:
+            raise BadMetadataError()
         for key, value in mp3_info.iteritems():
             setattr(mp3, key, value)
         self.idx.add_mp3(mp3)
@@ -40,6 +46,9 @@ class Scanner(object):
             try:
                 self.process(mp3)
                 mp3.state = MP3.READY
+            except BadMetadataError:
+                log.info('bad metadata for %s' % mp3.sha1)
+                mp3.state = MP3.BAD_METADATA
             except Exception, e:
                 log.error(traceback.format_exc())
                 mp3.state = MP3.ERROR
diff --git a/server/setup.py b/server/setup.py
index 4f8a61aa48a58b5e608dfbf7d297bfa9cdfa7e6d..22f935ba5e49ffc9aab03fadb78a55f8d91e405d 100644
--- a/server/setup.py
+++ b/server/setup.py
@@ -22,6 +22,7 @@ setup(
       "djrandom-streamer = djrandom.stream.stream:main",
       "djrandom-frontend = djrandom.frontend.frontend:main",
       "djrandom-update-markov = djrandom.model.markov:main",
+      "djrandom-metadata-fixer = djrandom.metadata_fixer.metadata_fixer:main",
     ],
   },
   )