diff --git a/client/djrandom_client/client.py b/client/djrandom_client/client.py
index d99fe036a8afb4786a2962f4edd0bc42b5e297bd..0baee71c3ee486df81479e705b0cdbf2eb6a137c 100644
--- a/client/djrandom_client/client.py
+++ b/client/djrandom_client/client.py
@@ -124,6 +124,10 @@ def main():
     if args:
         parser.error('Too many arguments')
 
+    # Perform a version check.
+    if utils.check_version():
+        print >>sys.stderr, 'A new release is available! Please update.'
+
     # Reading from the configuration file will set this variable to
     # a string, convert it back into boolean.
     do_realtime = not opts.no_realtime_watch
diff --git a/client/djrandom_client/utils.py b/client/djrandom_client/utils.py
index 5efa285596b3b57dc273e73799eb71e1f20631f0..e9fd9d046aa607c899a8afded1be57c6e5d14c81 100644
--- a/client/djrandom_client/utils.py
+++ b/client/djrandom_client/utils.py
@@ -1,7 +1,10 @@
 import hashlib
 import os
+import urllib2
+from djrandom_client import version
 
 NESTING = 2
+VERSION_PY_URL = 'https://git.autistici.org/djrandom/plain/client/djrandom_client/version.py'
 
 
 def generate_path(base_dir, sha1):
@@ -48,3 +51,31 @@ def read_config_defaults(parser, path):
                         path, 1 + linenum))
             var, value = map(lambda x: x.strip(), line.split('=', 1))
             parser.set_default(var, _unquote(value))
+
+
+def _split_version_string(s):
+    def _toint(x):
+        try:
+            return int(x)
+        except:
+            return x
+    return tuple(map(_toint, s.split('.')))
+
+
+def check_version():
+    """Returns True if we need to upgrade."""
+    try:
+        last_version_str = urllib2.urlopen(VERSION_PY_URL).read()
+    except:
+        return False
+
+    match = re.match(r'VERSION\s*=\s*[\'"]([0-9a-z.]+)[\'"]',
+                     last_version_str.strip())
+    if not match:
+        return False
+    last_version_t = _split_version_string(match.group(1))
+    cur_version_t = _split_version_string(version.VERSION)
+
+    return cur_version_t <= last_version_t
+
+
diff --git a/client/djrandom_client/version.py b/client/djrandom_client/version.py
new file mode 100644
index 0000000000000000000000000000000000000000..68c0733348c65f5f45bd043f1e94bc7b1bd30d48
--- /dev/null
+++ b/client/djrandom_client/version.py
@@ -0,0 +1 @@
+VERSION = '0.2'
diff --git a/client/setup.py b/client/setup.py
index aad077744a396a69b9386489b50c30ad581863cb..60cd80bfc16b4648b368844c62d207606ed8baee 100644
--- a/client/setup.py
+++ b/client/setup.py
@@ -1,5 +1,10 @@
 #!/usr/bin/python
 
+# Read version from version.py.
+import sys, os
+sys.path.insert(0, os.path.dirname(__file__))
+from djrandom_client import version
+
 from setuptools import setup, find_packages
 import platform
 if platform.system() == 'Darwin':
@@ -9,7 +14,7 @@ else:
 
 setup(
   name="djrandom-client",
-  version="0.1",
+  version=version.VERSION,
   description="DJ:Random client",
   author="ale",
   author_email="ale@incal.net",
diff --git a/server/djrandom/fingerprint/__init__.py b/server/djrandom/fingerprint/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/server/djrandom/fingerprint/dedup.py b/server/djrandom/fingerprint/dedup.py
new file mode 100644
index 0000000000000000000000000000000000000000..e4e7077c4249952d876c26b5b3aedd8e66673ff9
--- /dev/null
+++ b/server/djrandom/fingerprint/dedup.py
@@ -0,0 +1,87 @@
+import fp
+import os
+import optparse
+import logging
+import json
+from djrandom import daemonize
+from djrandom import utils
+from djrandom.model.mp3 import MP3
+from djrandom.database import Session, init_db
+
+
+# Taken from 'fastingest.py', with minor changes.
+def generate_code_json(jdata, track_id):
+    c = json.loads(jdata)
+    if "code" not in c:
+        return {}
+
+    code = c["code"]
+    m = c["metadata"]
+    length = m["duration"]
+    version = m["version"]
+    artist = m.get("artist", None)
+    title = m.get("title", None)
+    release = m.get("release", None)
+    decoded = fp.decode_code_string(code)
+        
+    data = {"track_id": track_id,
+            "fp": decoded,
+            "length": length,
+            "codever": "%.2f" % version
+            }
+    if artist: data["artist"] = artist
+    if release: data["release"] = release
+    if title: data["track"] = title
+    return data
+
+
+def dedupe_db():
+
+    codes = {}
+
+    # Load all known fingerprints into the db.
+    mp3s = MP3.query.filter(
+        (MP3.ready == True) & (MP3.error == False) 
+        & (MP3.echoprint_fp != None))
+    for mp3 in mp3s:
+        code = generate_code_json(mp3.echoprint_fp, mp3.sha1)
+        if not code:
+            continue
+        codes[mp3.sha1] = code['fp']
+        fp.ingest([code], do_commit=False, local=True)
+
+    # Now dedupe by going through all our codes over again.
+    for sha1, code in codes.iteritems():
+        results = fp.query_fp(code, local=True).results
+        if len(results) < 2:
+            continue
+        print 'SHA1: %s' % sha1
+        for entry in results:
+            if entry['track_id'] == sha1:
+                continue
+            print '  --> %s (%s)' % (entry['track_id'], entry['score'])
+
+
+def run_deduper(db_url):
+    init_db(db_url)
+    dedupe_db()
+
+
+def main():
+    parser = optparse.OptionParser()
+    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 args:
+        parser.error('Too many arguments')
+
+    daemonize.daemonize(opts, run_deduper,
+                        (opts.db_url,))
+
+
+if __name__ == '__main__':
+    main()
diff --git a/server/djrandom/fingerprint/fingerprint.py b/server/djrandom/fingerprint/fingerprint.py
new file mode 100644
index 0000000000000000000000000000000000000000..14e6c44583954164e069b5e015416e74a23190ea
--- /dev/null
+++ b/server/djrandom/fingerprint/fingerprint.py
@@ -0,0 +1,81 @@
+import os
+import optparse
+import logging
+import subprocess
+import time
+import traceback
+from djrandom import daemonize
+from djrandom import utils
+from djrandom.model.mp3 import MP3
+from djrandom.database import Session, init_db
+
+log = logging.getLogger(__name__)
+
+
+class Fingerprinter(object):
+
+    def __init__(self, codegen_path):
+        self.codegen_path = codegen_path
+
+    def process(self, mp3):
+        pipe = subprocess.Popen(
+            [self.codegen_path, mp3.path, '10', '30'],
+            close_fds=False,
+            stdout=subprocess.PIPE)
+        fp_json = pipe.communicate()[0]
+        if fp_json:
+            # Remove the square brackets that make fp_json an array.
+            # (Ugly Hack!)
+            mp3.echoprint_fp = fp_json[2:-2]
+
+    def compute_fingerprints(self, run_once):
+        """Compute fingerprints of new files."""
+        while True:
+            mp3 = MP3.query.filter(MP3.echoprint_fp == None
+                                   ).limit(1).first()
+            if not mp3:
+                if run_once:
+                    break
+                Session.remove()
+                self.idx.commit()
+                time.sleep(60)
+                continue
+            log.info('fingerprinting %s' % mp3.sha1)
+            try:
+                self.process(mp3)
+            except Exception, e:
+                log.error(traceback.format_exc())
+            Session.add(mp3)
+            Session.commit()
+
+
+def run_fingerprinter(db_url, codegen_path, run_once):
+    init_db(db_url)
+    scanner = Fingerprinter(codegen_path)
+    scanner.compute_fingerprints(run_once)
+
+
+def main():
+    parser = optparse.OptionParser()
+    parser.add_option('--once', action='store_true')
+    parser.add_option('--codegen_path',
+                      default='/usr/local/bin/echoprint-codegen')
+    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 args:
+        parser.error('Too many arguments')
+
+    if opts.once:
+        opts.foreground = True
+
+    daemonize.daemonize(opts, run_fingerprinter,
+                        (opts.db_url, opts.codegen_path, opts.once))
+
+
+if __name__ == '__main__':
+    main()
diff --git a/server/djrandom/frontend/api_views.py b/server/djrandom/frontend/api_views.py
index 96f421a4f1f9d57e680f587e26fcf9178d125861..e4cf6aec432a7cfe9466bd68738c235d354ef530 100644
--- a/server/djrandom/frontend/api_views.py
+++ b/server/djrandom/frontend/api_views.py
@@ -151,7 +151,33 @@ def more_like_these_json():
 @app.route('/json/most_played', methods=['GET'])
 @require_auth
 def most_played_json():
+    n = int(request.args.get('n', 20))
     most_played = [{'sha1': sha1, 'count': count}
-                   for sha1, count in PlayLog.most_played(20)]
+                   for sha1, count in PlayLog.most_played(n)]
     return jsonify(results=most_played)
 
+
+@app.route('/json/last_uploaded', methods=['GET'])
+@require_auth
+def last_uploaded_json():
+    n = int(request.args.get('n', 20))
+    last_uploaded = [x.sha1 for x in MP3.last_uploaded(n)]
+    return jsonify(results=last_uploaded)
+
+
+@app.route('/json/markov', methods=['POST'])
+@require_auth
+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)
+    return jsonify(results=sequence)
+
+
+@app.route('/json/random', methods=['GET'])
+@require_auth
+def random_json():
+    n = int(request.args.get('n', 10))
+    random_songs = [x.sha1 for x in MP3.get_random_songs(n)]
+    return jsonify(results=random_songs)
diff --git a/server/djrandom/frontend/frontend.py b/server/djrandom/frontend/frontend.py
index 925c11e1e3bf04c0a65340dc8d8d99e8c566a924..c0ed117cb9330a5c696a9af812b41df488fc06c4 100644
--- a/server/djrandom/frontend/frontend.py
+++ b/server/djrandom/frontend/frontend.py
@@ -11,6 +11,7 @@ from djrandom.database import init_db
 from djrandom.frontend import app, svcs
 from djrandom.frontend.mailer import Mailer
 from djrandom.frontend.search import Searcher
+from djrandom.model.markov import MarkovModel
 from djrandom.model.external import AlbumImageRetriever
 from gevent.wsgi import WSGIServer
 
@@ -18,12 +19,18 @@ log = logging.getLogger(__name__)
 
 
 def run_frontend(port, solr_url, db_url, lastfm_api_key, album_art_dir,
-                 email_sender):
+                 email_sender, markov_data_file):
     init_db(db_url)
 
     svcs['searcher'] = Searcher(solr_url)
     svcs['album_images'] = AlbumImageRetriever(lastfm_api_key, album_art_dir)
     svcs['mailer'] = Mailer(email_sender)
+    svcs['markov'] = MarkovModel()
+    try:
+        svcs['markov'].load(markov_data_file)
+    except IOError, e:
+        log.error('Could not read Markov data from %s: %s' % (
+                markov_data_file, str(e)))
 
     http_server = WSGIServer(('0.0.0.0', port), app)
     http_server.serve_forever()
@@ -37,6 +44,8 @@ def main():
     parser.add_option('--lastfm_api_key')
     parser.add_option('--email_sender', default='djrandom@localhost')
     parser.add_option('--album_art_dir', default='/var/tmp/album-image-cache')
+    parser.add_option('--markov_data',
+                      default='/var/lib/djrandom/djrandom-markov.dat')
     daemonize.add_standard_options(parser)
     utils.read_config_defaults(
         parser, os.getenv('DJRANDOM_CONF', '/etc/djrandom.conf'))
@@ -49,7 +58,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.email_sender, opts.markov_data),
                         support_gevent=True)
 
 
diff --git a/server/djrandom/frontend/static/css/style.css b/server/djrandom/frontend/static/css/style.css
index 2968a986c11399f3e565e1ae9f4ef36d0ae9d967..c052cd69c7c8df9a5f9e79b5d63bd43ec0f0ee5d 100644
--- a/server/djrandom/frontend/static/css/style.css
+++ b/server/djrandom/frontend/static/css/style.css
@@ -28,10 +28,19 @@ body {
     font-size: 0.9em;
 }
 
-input {
+#queryField {
+	width:75%;
     font-size: 1.1em;
+	border:1px solid red;
 }
 
+a#playlistLast25,a#playlistRandom,a#playlistMost {
+	font-size:0.8em;
+}
+
+a#playlistExtendBtn,a#playlistClearBtn,a#playlistStreamBtn {
+	font-size:0.8em;
+}
 h1 {
     font-size: 66px;
     letter-spacing: -4px;
diff --git a/server/djrandom/frontend/static/js/djr.min.js b/server/djrandom/frontend/static/js/djr.min.js
index a4a83704aeffc42978668c1f898eb0cfffae92f1..81fe21552500ec84ba759e3e1175f4d940dbe9cf 100644
--- a/server/djrandom/frontend/static/js/djr.min.js
+++ b/server/djrandom/frontend/static/js/djr.min.js
@@ -1,6 +1,7 @@
 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.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.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.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};
@@ -8,10 +9,14 @@ djr.Playlist.prototype.removeChunk=function(a){djr.debug("removing chunk "+a);va
 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.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.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.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.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()});$("#albumart_fs").load(function(){$(this).fullBg();$(this).show()})};djr.player=function(){return djr.state.player};
-djr.debug=function(a){$("#debug").append(a+"<br>")};
+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>")};
diff --git a/server/djrandom/frontend/static/js/djr/Makefile b/server/djrandom/frontend/static/js/djr/Makefile
index fc3022305822eaa7bd74300e11eae147e1a27156..094d7250a7d6013277f8d83f5bb85f7c03be0ba7 100644
--- a/server/djrandom/frontend/static/js/djr/Makefile
+++ b/server/djrandom/frontend/static/js/djr/Makefile
@@ -1,5 +1,6 @@
 
-JSCOMPILER = java -jar ~/src/jscompiler/compiler.jar
+JSCOMPILER_JAR = /usr/bin/compiler.jar
+JSCOMPILER = java -jar $(JSCOMPILER_JAR)
 
 SOURCES = \
 	djr.js \
diff --git a/server/djrandom/frontend/static/js/djr/backend.js b/server/djrandom/frontend/static/js/djr/backend.js
index 5c0f45c43bc288f1780e1314e403afb1095948f4..1921dc102228286e3ec43b720160cb30fdcc1854 100644
--- a/server/djrandom/frontend/static/js/djr/backend.js
+++ b/server/djrandom/frontend/static/js/djr/backend.js
@@ -1,6 +1,5 @@
 // backend.js
 
-
 /**
  * Backend API.
  *
@@ -163,3 +162,77 @@ djr.Backend.prototype.nowPlaying = function(song, old_songs) {
           });
 };
 
+/**
+  * Request N most played songs
+  *
+  * @param {integer} n Number of songs requested
+  *
+  */
+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);
+          }
+         });
+};
+
+/**
+  * Request N most played songs
+  *
+  * @param {integer} n Number of songs requested
+  *
+  */
+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);
+          }
+         });
+};
+
+/**
+  * Return N pseudo-random songs based on the current playlist.
+  *
+  * @param {integer} n Number of songs requested
+  * @param {Array[string]} uuids SHA1 hashes of the current playlist
+  *
+  */
+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);
+          }
+         });
+};
+
+/**
+  * Return N completely random songs.
+  *
+  * @param {integer} n Number of songs requested
+  *
+  */
+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);
+          }
+         });
+};
+
diff --git a/server/djrandom/frontend/static/js/djr/player.js b/server/djrandom/frontend/static/js/djr/player.js
index 2bdfa05574a734690e1f4b47d94bde0792e248c7..c6643c33919a6943aa402a2a715f0f097746183b 100644
--- a/server/djrandom/frontend/static/js/djr/player.js
+++ b/server/djrandom/frontend/static/js/djr/player.js
@@ -98,6 +98,57 @@ djr.Player.prototype.search = function(query) {
   });
 };
 
+// Request N last uploaded songs 
+
+djr.Player.prototype.lastPlaylist = function(num) {
+  var player = this;
+  var title = "Last " + num + " Songs Uploaded";
+  this.backend.lastPlaylist(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.randomPlaylist = function(num) {
+  var player = this;
+  var title = "" + num + " Random Songs ";
+  this.backend.randomPlaylist(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";
+  this.backend.mostPlayedPlaylist(num, function(results) {
+    var songs = [];
+    $.each(results, function(idx, item) {
+      songs.push(item.sha1);
+    });
+    if (songs.length == 0) {
+      djr.debug('No results found.');
+      return;
+    }
+    // Create a chunk of unique new songs.
+    player.createChunk(songs, title);
+  });
+
+};
+
 // Extend the current playlist with suggestions.
 djr.Player.prototype.extendCurrentPlaylist = function() {
   var player = this;
@@ -249,6 +300,58 @@ djr.init = function () {
   $('#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() {
diff --git a/server/djrandom/frontend/templates/_std_base.html b/server/djrandom/frontend/templates/_std_base.html
new file mode 100644
index 0000000000000000000000000000000000000000..eef79cf7d907e9963a66c3b4f25464903642e1a8
--- /dev/null
+++ b/server/djrandom/frontend/templates/_std_base.html
@@ -0,0 +1,17 @@
+<!doctype html>
+<html>
+  <head>
+    <title>DJ RANDOM :: {% block title %}{% endblock %}</title>
+    <link href="/static/favicon.ico" type="image/x-icon" rel="shortcut icon">
+    <link rel="stylesheet" type="text/css" href="/static/css/login.css">
+  </head>
+  <body>
+
+    <div id="main">
+      <div id="form">
+      {% block main %}
+      {% endblock %}
+      </div>
+    </div>
+  </body>
+</html>
diff --git a/server/djrandom/frontend/templates/about.html b/server/djrandom/frontend/templates/about.html
index 5c737152e9022947036f2c600a6e57e729f73682..aedc42e9eca58f0f9c1b254169ec5298eedadc17 100644
--- a/server/djrandom/frontend/templates/about.html
+++ b/server/djrandom/frontend/templates/about.html
@@ -1,14 +1,7 @@
-<!doctype html>
-<html>
-  <head>
-    <title>DJ RANDOM :: About</title>
-    <link href="/static/favicon.ico" type="image/x-icon" rel="shortcut icon">
-    <link rel="stylesheet" type="text/css" href="/static/css/login.css">
-  </head>
-  <body>
+{% extends "_std_base.html" %}
+{% block title %}About{% endblock %}
+{% block main %}
 
-    <div id="main">
-      <div id="form">
         <h3>DJ:Random</h3>
 
         <p>
@@ -19,9 +12,4 @@
           (<b>{{ used_gb }}</b> Gb).
         </p>
 
-      </div>
-
-    </div>
-
-  </body>
-</html>
+{% endblock %}
diff --git a/server/djrandom/frontend/templates/index.html b/server/djrandom/frontend/templates/index.html
index baab6bfc9e1adcf8ca63f03c9db239e2284bd905..e81f857ad16b17069112e52d3d066d364a79b371 100644
--- a/server/djrandom/frontend/templates/index.html
+++ b/server/djrandom/frontend/templates/index.html
@@ -73,6 +73,24 @@ DJ:Random
           <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>
   </div>
 </div>
@@ -85,6 +103,11 @@ DJ:Random
     </p>
   </form>
 
+  <div id="playlistButtons" >
+    <a id="playlistLast25">Last 25</a>     
+    | <a id="playlistRandom">Random</a>     
+    | <a id="playlistMost">Most Played</a>     
+  </div>
   <div id="playlistDiv">
   </div>
 
diff --git a/server/djrandom/frontend/templates/redirect.html b/server/djrandom/frontend/templates/redirect.html
new file mode 100644
index 0000000000000000000000000000000000000000..a99041f8d0dbd8c4c6da4f7b034d247a94388af9
--- /dev/null
+++ b/server/djrandom/frontend/templates/redirect.html
@@ -0,0 +1,13 @@
+{% extends "_std_base.html" %}
+{% block title %}About{% endblock %}
+{% block main %}
+
+<p>
+  Redirecting to <a href="{{ url | e }}">{{ url | e }}</a> ...
+</p>
+
+<script type="text/javascript">
+  window.location = '{{ url | e }}';
+</script>
+
+{% endblock %}
diff --git a/server/djrandom/frontend/templates/user_details.html b/server/djrandom/frontend/templates/user_details.html
index 5afee9535746294fe59a30edbbc477f02703055a..d12373a88a078e867b82b68865973f6415176f70 100644
--- a/server/djrandom/frontend/templates/user_details.html
+++ b/server/djrandom/frontend/templates/user_details.html
@@ -1,14 +1,6 @@
-<!doctype html>
-<html>
-  <head>
-    <title>DJ RANDOM</title>
-    <link href="/static/favicon.ico" type="image/x-icon" rel="shortcut icon">
-    <link rel="stylesheet" type="text/css" href="/static/css/login.css">
-  </head>
-  <body>
-
-    <div id="main">
-      <div id="form">
+{% extends "_std_base.html" %}
+{% block title %}User Details{% endblock %}
+{% block main %}
 
         {%- for msg in get_flashed_messages() -%}
         <p class="flash">{{ msg | e }}</p>
@@ -53,7 +45,4 @@
 
       </div>
 
-    </div>
-
-  </body>
-</html>
+{% endblock %}
diff --git a/server/djrandom/frontend/templates/user_invite.html b/server/djrandom/frontend/templates/user_invite.html
index 2cc3afa87234d1a98592291b67108262b56a2a47..ee7625b4e39ef270fb1db98e02efb9abb7da611b 100644
--- a/server/djrandom/frontend/templates/user_invite.html
+++ b/server/djrandom/frontend/templates/user_invite.html
@@ -1,14 +1,7 @@
-<!doctype html>
-<html>
-  <head>
-    <title>DJ RANDOM</title>
-    <link href="/static/favicon.ico" type="image/x-icon" rel="shortcut icon">
-    <link rel="stylesheet" type="text/css" href="/static/css/login.css">
-  </head>
-  <body>
+{% extends "_std_base.html" %}
+{% block title %}Invites{% endblock %}
+{% block main %}
 
-    <div id="main">
-      <div id="form">
         <form action="/user/invite" method="post">
           {{ form.hidden_tag() | safe }}
 
@@ -27,9 +20,5 @@
             <input type="submit" class="f_submit" value=" Invite ">
           </p>
         </form>
-      </div>
 
-    </div>
-
-  </body>
-</html>
+{% endblock %}
diff --git a/server/djrandom/frontend/views.py b/server/djrandom/frontend/views.py
index 662b70170e82ddf89c282faf4c014075a084dbcc..ec792dd7e8b7defdc867fa880aefc95ea0d5c09c 100644
--- a/server/djrandom/frontend/views.py
+++ b/server/djrandom/frontend/views.py
@@ -52,6 +52,15 @@ def homepage():
     return render_template('index.html', user=user)
 
 
+@app.route('/ext')
+@require_auth
+def redirect_to_external_url():
+    url = request.args.get('url')
+    if not url:
+        abort(400)
+    return render_template('redirect.html', url=url)
+
+
 def fileiter(path, pos, end):
     with open(path, 'r') as fd:
         fd.seek(pos)
diff --git a/server/djrandom/model/markov.py b/server/djrandom/model/markov.py
index ac2f7da58ac770993fdb9dee8309e16d435653bf..11350ea8c193e92ab9af7cb3af7f0d5bb328d797 100644
--- a/server/djrandom/model/markov.py
+++ b/server/djrandom/model/markov.py
@@ -78,6 +78,8 @@ class MarkovModel(object):
         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())
 
     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 12a5b539d3364c812c325c54aecf73aba93d50fa..68657c7686cea86ead20af9d0bcbf72708937b0f 100644
--- a/server/djrandom/model/mp3.py
+++ b/server/djrandom/model/mp3.py
@@ -1,6 +1,8 @@
+import random
+from sqlalchemy.orm import deferred
 from sqlalchemy import *
 from datetime import datetime, timedelta
-from djrandom.database import Base
+from djrandom.database import Base, Session
 
 
 class MP3(Base):
@@ -22,6 +24,7 @@ class MP3(Base):
     genre = Column(Unicode(64))
     uploaded_at = Column(DateTime())
     play_count = Column(Integer(), default=0)
+    echoprint_fp = deferred(Column(Text()))
 
     def __init__(self, **kw):
         for k, v in kw.items():
@@ -36,6 +39,23 @@ class MP3(Base):
                 'size': self.size,
                 'uploaded_at': self.uploaded_at}
 
+    @classmethod
+    def last_uploaded(cls, n=10):
+        """Return the N last uploaded songs."""
+        return cls.query.filter_by(ready=True).order_by(
+            desc(cls.uploaded_at)).limit(n)
+
+    @classmethod
+    def get_random_songs(cls, n=10):
+        """Return N completely random songs."""
+        results = []
+        num_songs = cls.query.filter_by(ready=True).count()
+        for idx in xrange(n):
+            song = cls.query.filter_by(ready=True).limit(1).offset(
+                random.randint(0, num_songs - 1)).one()
+            results.append(song)
+        return results
+
 
 class PlayLog(Base):
 
@@ -76,6 +96,7 @@ class SearchLog(Base):
 
     __tablename__ = 'searchlog'
 
+    id = Column(Integer(), primary_key=True)
     userid = Column(String(40), index=True)
     query = Column(String(256))
     stamp = Column(DateTime())
diff --git a/server/setup.py b/server/setup.py
index 56ad7c33bcec2b2e5935d844ce7ebe9d3d787a6e..4f8a61aa48a58b5e608dfbf7d297bfa9cdfa7e6d 100644
--- a/server/setup.py
+++ b/server/setup.py
@@ -9,7 +9,7 @@ setup(
   author="ale",
   author_email="ale@incal.net",
   url="http://git.autistici.org/git/djrandom.git",
-  install_requires=["Flask", "Flask-WTF", "gevent", "eyeD3"],
+  install_requires=["Flask", "Flask-WTF", "gevent", "eyeD3", "solrpy"],
   setup_requires=[],
   zip_safe=False,
   packages=find_packages(),
@@ -17,8 +17,11 @@ setup(
     "console_scripts": [
       "djrandom-receiver = djrandom.receiver.receiver:main",
       "djrandom-scanner = djrandom.scanner.scanner:main",
+      "djrandom-fingerprinter = djrandom.fingerprint.fingerprint:main",
+      "djrandom-dedup = djrandom.fingerprint.dedup:main",
       "djrandom-streamer = djrandom.stream.stream:main",
       "djrandom-frontend = djrandom.frontend.frontend:main",
+      "djrandom-update-markov = djrandom.model.markov:main",
     ],
   },
   )