├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── httppost.py ├── lastcat.py ├── lastgui ├── __init__.py ├── admin.py ├── api.py ├── css │ ├── __init__.py │ ├── artist_detail.css │ ├── artist_detail_white.css │ ├── basic_timeline.css │ └── sig1.css ├── data.py ├── export.py ├── fetch.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── add_node.py │ │ ├── fetch_user.py │ │ └── render_poster.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── storage.py ├── templates │ ├── 404.html │ ├── 500.html │ ├── about.html │ ├── about_artist_histories.html │ ├── about_colours.html │ ├── about_posters.html │ ├── base.html │ ├── crossdomain.xml │ ├── fragments │ │ ├── front.html │ │ ├── intro.html │ │ ├── poster.html │ │ └── poster_table.html │ ├── front.html │ ├── index.html │ ├── status.html │ ├── user_artist.html │ ├── user_artists.html │ ├── user_export.html │ ├── user_posters.html │ ├── user_premium.html │ ├── user_premium_paid.html │ ├── user_root.html │ ├── user_sigs.html │ └── user_timeline.html ├── urls.py ├── views.py └── xml.py ├── lastrender ├── __init__.py ├── lastgraph.css ├── renderer.py └── settings.py ├── lastslice ├── __init__.py ├── longslice.css ├── shortslice.css ├── slice.py └── watermark.png ├── manage.py ├── requirements.txt ├── settings.py ├── shortcuts.py ├── static ├── css │ ├── date_input.css │ └── style.css ├── favicon.ico ├── graphs │ ├── graph_1.pdf │ └── graph_1.svgz ├── images │ ├── aera_footer.png │ ├── aera_footer.svg │ ├── artist_graph.png │ ├── artists.png │ ├── artists_64.png │ ├── bar_end.png │ ├── cap_left.png │ ├── colours │ │ ├── blue.png │ │ ├── desert.png │ │ ├── green.png │ │ ├── ocean.png │ │ ├── rainbow.png │ │ └── sunset.png │ ├── contentbg.png │ ├── export_64.png │ ├── footerbg.png │ ├── highlight_left.png │ ├── hotlink.png │ ├── hotlink.svg │ ├── inputbg.png │ ├── inputbg_plain.png │ ├── inputcap.svg │ ├── lastfm_64.png │ ├── loadingbg.png │ ├── logo_large.png │ ├── logo_med.png │ ├── menubg.png │ ├── page.png │ ├── page_excel.png │ ├── page_white_text.png │ ├── poster.png │ ├── poster.svg │ ├── posters_64.png │ ├── premium_64.png │ ├── reset.png │ ├── sigs.svg │ ├── sigs_64.png │ ├── spinner.gif │ ├── splash.png │ ├── splash.svg │ ├── stripbg.png │ ├── timeline.png │ ├── timeline2_64.png │ ├── timeline_64.png │ └── wavegraph.png └── js │ ├── IE7.js │ ├── jquery.date_input.pack.js │ ├── jquery.dimensions.pack.js │ ├── jquery.js │ └── main.js ├── test.py └── urls.py /.gitignore: -------------------------------------------------------------------------------- 1 | settings_local.py 2 | local_settings.py 3 | static/data/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Andrew Godwin 2007 - 2013. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Django nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LastGraph 2 | 3 | This is the LastGraph codebase. It's placed here purely for posterity - the code is pretty terrible (and from 2007), 4 | and just barely ported to modern Django, so please don't use it as an example or somewhere to crib code ideas from. 5 | 6 | ## Installation 7 | 8 | Make a virtualenv, install requirements.txt into it, and have some hope. Make sure your system has Cairo installed with 9 | its python bindings too - that's python-cairo on Ubuntu/Debian. 10 | 11 | ## Support 12 | 13 | There is none. This code is static and unsupported; I will not take pull requests or change it. It's purely archival. 14 | 15 | ## License 16 | 17 | This code is released under the BSD license. A copy is available as the file LICENSE in this repo. 18 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/__init__.py -------------------------------------------------------------------------------- /httppost.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | HTTP POST helper functions. 4 | """ 5 | 6 | import mimetypes 7 | import urlparse 8 | import httplib 9 | 10 | def post_multipart(host, selector, fields, files): 11 | """ 12 | Post fields and files to an http host as multipart/form-data. 13 | fields is a sequence of (name, value) elements for regular form fields. 14 | files is a sequence of (name, filename, value) elements for data to be uploaded as files 15 | Return the server's response page. 16 | """ 17 | content_type, body = encode_multipart_formdata(fields, files) 18 | h = httplib.HTTPConnection(host) 19 | h.putrequest('POST', selector) 20 | h.putheader('content-type', content_type) 21 | h.putheader('content-length', str(len(body))) 22 | h.endheaders() 23 | h.send(body) 24 | response = h.getresponse() 25 | return response.read() 26 | 27 | 28 | def encode_multipart_formdata(fields, files): 29 | """ 30 | fields is a sequence of (name, value) elements for regular form fields. 31 | files is a sequence of (name, filename, value) elements for data to be uploaded as files 32 | Return (content_type, body) ready for httplib.HTTP instance 33 | """ 34 | BOUNDARY = '----------ThIs_Is_tHe_bouNdaRY_$' 35 | CRLF = '\r\n' 36 | L = [] 37 | for (key, value) in fields: 38 | L.append('--' + BOUNDARY) 39 | L.append('Content-Disposition: form-data; name="%s"' % key) 40 | L.append('') 41 | L.append(str(value)) 42 | for (key, filename, value) in files: 43 | L.append('--' + BOUNDARY) 44 | L.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename)) 45 | L.append('Content-Type: %s' % get_content_type(filename)) 46 | L.append('') 47 | L.append(value.read()) 48 | L.append('--' + BOUNDARY + '--') 49 | L.append('') 50 | body = CRLF.join(L) 51 | content_type = 'multipart/form-data; boundary=%s' % BOUNDARY 52 | return content_type, body 53 | 54 | 55 | def posturl(url, fields, files): 56 | urlparts = urlparse.urlsplit(url) 57 | return post_multipart(urlparts[1], urlparts[2], fields,files) 58 | 59 | 60 | def get_content_type(filename): 61 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 62 | 63 | 64 | def extract_django_error(html): 65 | 66 | from BeautifulSoup import BeautifulSoup as BS 67 | 68 | soup = BS(html) 69 | return str(soup.find(id="pastebinTraceback")) -------------------------------------------------------------------------------- /lastcat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | """ 4 | A simple program to list some features of .last files 5 | """ 6 | 7 | from lastgui.storage import UserHistory 8 | 9 | import sys, os 10 | 11 | def usage(): 12 | print >> sys.stderr, "Usage: %s " % sys.argv[0] 13 | 14 | try: 15 | filename = sys.argv[1] 16 | except IndexError: 17 | usage() 18 | sys.exit(1) 19 | 20 | try: 21 | action = sys.argv[2] 22 | except IndexError: 23 | usage() 24 | sys.exit(1) 25 | 26 | uh = UserHistory(None) 27 | uh.load(open(filename)) 28 | 29 | if action == "artists": 30 | print "\n".join(uh.artists.keys()) 31 | elif action == "weeks": 32 | print "\n".join(map(str, uh.weeks.keys())) 33 | elif action == "age": 34 | print uh.data_age() 35 | else: 36 | print >> sys.stderr, "Unknown action" 37 | sys.exit(1) -------------------------------------------------------------------------------- /lastgui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/lastgui/__init__.py -------------------------------------------------------------------------------- /lastgui/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import site 2 | from .models import LastFmUser, Node, Poster 3 | 4 | site.register( 5 | LastFmUser, 6 | list_display = ("id", "username", "requested_update", "last_check"), 7 | list_display_links = ("id", "username"), 8 | search_fields = ("username",), 9 | ordering = ("-last_check",), 10 | ) 11 | 12 | 13 | site.register( 14 | Poster, 15 | list_display = ("id", "user", "requested", "completed", "failed"), 16 | ordering = ("-requested",), 17 | ) 18 | 19 | 20 | site.register( 21 | Node, 22 | list_display = ("nodename", "disabled", "lastseen"), 23 | ) 24 | -------------------------------------------------------------------------------- /lastgui/api.py: -------------------------------------------------------------------------------- 1 | 2 | import datetime, time 3 | 4 | from shortcuts import * 5 | 6 | from lastgui.models import * 7 | from lastgui.storage import UserHistory 8 | 9 | 10 | # Some custom exceptions 11 | 12 | class IncorrectPassword(Exception): pass 13 | 14 | class BadData(Exception): pass 15 | 16 | class MissingXML(Exception): pass 17 | 18 | 19 | 20 | def valid_node(func): 21 | """Decorator, which ensures the requester is a valid node.""" 22 | def inner(request, *args, **kwds): 23 | nodename = request.REQUEST.get("nodename", None) 24 | password = request.REQUEST.get("password", None) 25 | 26 | # If they didn't provide, say so 27 | if not (nodename and password): 28 | return jsonify({"error":"authentication/missing"}) 29 | 30 | # Get the Node and see if they have a match 31 | try: 32 | node = Node.objects.get(nodename=nodename) 33 | if not node.password_matches(password): 34 | raise IncorrectPassword 35 | except (Node.DoesNotExist, IncorrectPassword): 36 | return jsonify({"error":"authentication/invalid"}) 37 | 38 | # Bung the node onto the request object 39 | request.node = node 40 | 41 | # Update it's lastseen 42 | node.lastseen = datetime.datetime.utcnow() 43 | node.save() 44 | 45 | # OK, all seems sensible 46 | return func(request, *args, **kwds) 47 | return inner 48 | 49 | 50 | def datetime_to_epoch(dt): 51 | """Turns a datetime.datetime into a seconds-since-epoch timestamp.""" 52 | return time.mktime(dt.timetuple()) 53 | 54 | ################### 55 | 56 | @valid_node 57 | def index(request): 58 | return jsonify({"server":"lastgraph/3.0"}) 59 | 60 | 61 | def graph_data(poster): 62 | 63 | uh = UserHistory(poster.user.username) 64 | uh.load_if_possible() 65 | 66 | # Get the start/end pairs of week times 67 | weeks = uh.weeks.keys() 68 | 69 | if not weeks: 70 | raise BadData("Empty") 71 | 72 | weeks.sort() 73 | weeks = zip(weeks, weeks[1:]+[weeks[-1]+7*86400]) 74 | 75 | # Limit those to ones in the graph range 76 | pstart = datetime_to_epoch(poster.start) 77 | pend = datetime_to_epoch(poster.end) 78 | 79 | weeks = [(start, end) for start, end in weeks if (end > pstart) and (start < pend)] 80 | 81 | weekdata = [(start, end, uh.weeks[start].items()) for start, end in weeks] 82 | 83 | return weekdata 84 | 85 | 86 | @valid_node 87 | def render_next(request): 88 | 89 | """ 90 | Returns the next set of render data. 91 | """ 92 | 93 | # Get the Posters that needs rendering 94 | try: 95 | poster = Poster.queue()[0] 96 | except IndexError: 97 | return jsonify({"nothing":True}) 98 | 99 | return render_data(request, poster.id) 100 | 101 | 102 | @valid_node 103 | def render_data(request, id=None): 104 | 105 | """ 106 | Returns a set of render data for the specified graph. 107 | """ 108 | 109 | if id is None: 110 | id = request.GET['id'] 111 | 112 | poster = Poster.objects.get(id=id) 113 | 114 | # Compile a week data list 115 | try: 116 | weekdata = graph_data(poster) 117 | except BadData: 118 | poster.set_error("BadData") 119 | return jsonify({"nothing":True, "skipped":"%s/BadData" % poster.id}) 120 | except MissingXML: 121 | poster.set_error("BadData") 122 | return jsonify({"nothing":True, "skipped":"%s/MissingXML" % poster.id}) 123 | 124 | # Check it has data 125 | if len(weekdata) < 1: 126 | poster.set_error("No data (graph would be empty)") 127 | return jsonify({"nothing":True, "skipped":"%s/NoData" % poster.id}) 128 | 129 | # Check it has data 130 | if len(weekdata) == 1: 131 | poster.set_error("Only one week of data (need two or more to graph)") 132 | return jsonify({"nothing":True, "skipped":"%s/OneWeek" % poster.id}) 133 | 134 | poster.started = datetime.datetime.utcnow() 135 | poster.node = request.node 136 | poster.save() 137 | 138 | return jsonify({ 139 | "id": poster.id, 140 | "username": poster.user.username, 141 | "start": int(time.mktime(poster.start.timetuple())), 142 | "end": int(time.mktime(poster.end.timetuple())), 143 | "params": poster.params, 144 | "data": weekdata, 145 | }) 146 | 147 | 148 | @valid_node 149 | def render_links(request): 150 | 151 | id = request.POST['id'] 152 | poster = Poster.objects.get(id=id) 153 | 154 | poster.pdf_url = request.POST['pdf_url'] 155 | poster.svg_url = request.POST['svg_url'] 156 | poster.completed = datetime.datetime.utcnow() 157 | poster.expires = datetime.datetime.utcnow() + datetime.timedelta(7) 158 | poster.save() 159 | 160 | return jsonify({"success":True}) 161 | 162 | 163 | @valid_node 164 | def render_failed(request): 165 | 166 | """ 167 | Recieves downloaded week data. 168 | """ 169 | 170 | id = request.REQUEST['id'] 171 | poster = Poster.objects.get(id=id) 172 | poster.set_error("Renderer error:\n%s" % request.REQUEST.get("traceback", "No traceback")) 173 | 174 | return jsonify({"recorded":True}) -------------------------------------------------------------------------------- /lastgui/css/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/lastgui/css/__init__.py -------------------------------------------------------------------------------- /lastgui/css/artist_detail.css: -------------------------------------------------------------------------------- 1 | canvas.png { 2 | background-color: #1e2a6e00; 3 | } 4 | wavegraph { 5 | vertical-align: bottom; 6 | height: 100%; 7 | } 8 | grid#x.major label { 9 | padding: 10; 10 | font-size: 10; 11 | color: #fff; 12 | } 13 | grid#y.major line { 14 | color: #fff1; 15 | } 16 | grid#y.major label { 17 | padding: -10; 18 | font-size: 12; 19 | color: #fff5; 20 | } 21 | grid#x.major line { 22 | color: #fff2; 23 | } 24 | grid#x.minor line { 25 | color: #fff1; 26 | } -------------------------------------------------------------------------------- /lastgui/css/artist_detail_white.css: -------------------------------------------------------------------------------- 1 | canvas.png { 2 | background-color: #fff0; 3 | } 4 | wavegraph { 5 | vertical-align: bottom; 6 | height: 100%; 7 | } 8 | grid#x.major label { 9 | padding: 10; 10 | font-size: 10; 11 | color: #000b; 12 | } 13 | grid#y.major line { 14 | color: #0002; 15 | } 16 | grid#y.major label { 17 | padding: -10; 18 | font-size: 12; 19 | color: #0004; 20 | } 21 | grid#x.major line { 22 | color: #0000; 23 | } 24 | grid#x.minor line { 25 | color: #0000; 26 | } -------------------------------------------------------------------------------- /lastgui/css/basic_timeline.css: -------------------------------------------------------------------------------- 1 | canvas.png { 2 | background-color: #fff0; 3 | } 4 | wavegraph { 5 | vertical-align: bottom; 6 | height: 100%; 7 | } 8 | grid#x.major label { 9 | padding: 0; 10 | font-size: 0; 11 | color: #0000; 12 | } 13 | grid#x.minor label { 14 | padding: 0; 15 | font-size: 0; 16 | color: #0000; 17 | } 18 | grid#y.major line { 19 | color: #0000; 20 | } 21 | grid#y.major label { 22 | padding: 0; 23 | font-size: 0; 24 | color: #0000; 25 | } 26 | grid#x.major line { 27 | color: #0000; 28 | } 29 | grid#x.minor line { 30 | color: #0000; 31 | } -------------------------------------------------------------------------------- /lastgui/css/sig1.css: -------------------------------------------------------------------------------- 1 | canvas.png { 2 | background-color: #fff0; 3 | } 4 | wavegraph { 5 | vertical-align: bottom; 6 | } 7 | grid#x.major label { 8 | padding: 0; 9 | font-size: 0; 10 | color: #0000; 11 | } 12 | grid#x.minor label { 13 | padding: 0; 14 | font-size: 0; 15 | color: #0000; 16 | } 17 | grid#y.major line { 18 | color: #0000; 19 | } 20 | grid#y.major label { 21 | padding: 0; 22 | font-size: 0; 23 | color: #0000; 24 | } 25 | grid#x.major line { 26 | color: #0000; 27 | } 28 | grid#x.minor line { 29 | color: #0000; 30 | } 31 | label { 32 | font-family: GreyscaleBasic; 33 | color: #888; 34 | height: 0.5; 35 | } -------------------------------------------------------------------------------- /lastgui/data.py: -------------------------------------------------------------------------------- 1 | hotlink_png = '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\x00\x00\x00\x04sBIT\x08\x08\x08\x08|\x08d\x88\x00\x00\x00\tpHYs\x00\x00\na\x00\x00\na\x01\xfc\xccJ%\x00\x00\x00\x19tEXtSoftware\x00www.inkscape.org\x9b\xee<\x1a\x00\x00\x05-IDATh\x81\xed\x98[l\x14e\x18\x86\x9fofK\xa1T\xc1\x9e\xb7\x05b\x88\x1c\xc4\x84\x18\xa3D\xe4\xa2\xb4\xa5R\x12\xa5Z\xba\x8b\xc4h\x08\x17V\x13\xbd\xf1\x82\x84\x0b\xa8\xab\t\xdc\xe95\xc6\x08\x1a\x89\t\x14\xe5\xa0\t\x14\x0c1\xd1\x96\x14\xbc\xc1\x90xK\xdar\xd8\xd2\x1a\x13\xda\xb2\xdb\xdd\xf9\xbc\xd8\x1d2\x9d\xecnwf\xc7P\x82o2\xd9\x9d\xff4\xef\xfb\xbf\xff\xff\xcf7\x9f\xa8*\x8f2\x8c\x87M\xa0T\xfc/\xe0a\xe3\xf1\x15\x10\x13\tL|)c\xf9\xea\xb8_\xe4h\n\xceFEL\xbf\x0f\xb6\x11\x151Spv\xbf\xc8Q?\xfd\xc5\xeb1\xba_\xe4h5\xec(\x07\xe3\x16\\2a{\xaf\xaa\xe5\xe7\xe11\x11#\rg\xc2\xd0\x92\x00k\x1cN~\xa6\xba\xdb\xcb\x18\x9e\x1c8 r\xa4\x1a\xba\x9a\xa0\xb2\x06*\x1a\xa0%\x05\xa7\xfd,\x81\x98\x88\x91\x82\xd3\r\xd0R\x03\x15MPY\r]\x07D\x8ex\x19\xa7\xe8\x07\xc7DB\n\xab\xca\xa1\xdc.\xab\x85\x8a0\xb4z\x15a\x93\x0fCk-T\xd8\xe5\xe5P\xae\xb0*&\x12\n\\@\xafj*\x04\xad7a\xe8.$\x9d"\x1a=\x88\xb0\xc97\xba\xc8\xdf\x85\xe4M\x18\nAk\xafj*p\x01Y\x11\xc9\x10\xb4\xb9E\xd4d\x9d\x98\x81S\x85D\xc4D\x8c\x198\x15\x86\xd6\x9a\xdc\xe4\xdbzU\x93\xf9\xfa\x97,\xa0\x90\x88\xac\x13m\xf9D\xd8\xe4\x1b\xa1-\xcf\xcc{&\x0f>N!\x07\xa1\x05)\xf8\xa5\x11^\xaaq\xec\x8b1\x98\xba\r\x17Mx\xd3>\x9d\xb2\xa7\xcd\x8f\r\xb0\xc5E>q\x13\xae\xf8%_\x92\x80"D\\0\xa1\x0b \r?4@{\xd0\xe4K\x16`\x8bH\xc3\xc50l\xc8%\x02 \x17\xf9[0d\xc2\x96R\xc8C\x00\x02\xa0\xb0\x08\xc8\xec\x0f\xbb,H\xf2\x10\x90\x00\xc8/\xc2\x89\xa0\xc9C\x80\xd1h\xafj\xd2\x84-\xb7`h\x0c\x12\xee\xfa\xb1\xff\x80<\x04\x1cN_\x874p\x8f\xcc\xaf\x1bi\xe0\xde\xf5\xdcu\xbe\x11\x98\x80\xa8\x88\xb9\x06\xce\xd4C\xb3s\xcd\xdb\xa8\x85\x8azh^\x03g\x82\x88bm\x04"\xc0&\xdf\x00\x9b\xeb\x1c\xe4\'\xe0\xfe\x04\xdc\xb7\xef\xeb2\x01\xe0\xe6 E\x94\xbc\x89\xa3"\xe6Z8[\x0f\xcdn\xf2\xa3\xf0;@\x13l\xaa\x82\x85v]\x1c\xa6\xee\xc0\xaf\x7f\xc1\xeb\xc7UKZR%9P\x88\xfc\x08\x0c\x98\xd0aB\xc7\x08\x0c\xb8\x9d\xa8\x87\xe6\xb5\x01|\x14\xf9v \x1f\xf9q\x98\x1e\x85\xc1\x10l\xb5\xa3\xca\x98H(\x05\xe7\x9b`c5,\xb2\xdb\x06\xe1\x84/\x07\xbc\x90\x87\x07\xa1\xf8\xd6Q\x18\x1c\x87i\xbb<\x08\'<;`\x93\xaf\x83\xe6\xfa\xd9\xcbfz\x04\x06\xe3\xd0qXu&W\xdf\x1e\x91\xb2:8\xb7\x0c6V9\x9c\xb8\x03Sq\x9fNxr\xc09\xf3n\xf2\xa3p\xb9\x10y\x80\xc3\xaa3q\xe8\x18\x85\xcb\x13\x0e\'\xeaKp\xa2h\x07\xb2!\xf1O~f\xde\x8d\xb9\x9c0\xe1\xb5b\x13\x05^\xf7\xc0\xac\x10`\x02\xa6\x87\x8b\x98y7l\'\x86]N\xe4z\xc6\\\xf0\xb4\x07\xb2\xdf\xb3\'\xeb\xa0}\x01\x18\xc3py\x0c\xb6z!\xefD\x8fHY-\x9c_\x0e/\'\xc1\x8a\xc3\x85\x10\xec\xf0\x92\xa6\xf1\xbc\x89\xb3K\xa9O\xe1\xa98\xbc\xea\x97\xbc\x8d\xecr\xea\x17\xf8\xdb\x84n\xaf9&_\xef\x81\xec7\xaf\xe1%{0\xc7x!\xc0\xf2\x93 \x0b\xec{\xe0a\xe1\xf1\xcdN\xcf\x17x\x16\xd0)\xf2DTdE1m\xbbE\xd6\x03DE\x96\xb8\xeb\xde\x15Y\xdc-\xb2\xd2\xbe\x8f\x8aT\xfa\xc9\xb1z\xeeP\x06\xcf(\xbc\x9d\xaf>*\xb2""\xd2\x99\x1d\xfc`K&\xa7\xda\xe5n\x97\x84&\x03\xf68\x8a6\xfc\tO{\xe53+\x89\x1a\x11\xd9\x9b\xfdk\x9eP=\x14\x15y^\xe1-`\xf0\x84\xea\xe9l\x87\x19\x0b^\xdc)\xf2E\x1a\x8e\xf5\xa9^\x8d\x8a|\xa0\x10\xb6\xe0\xcb\x10\xbc\x01\xb4\xef\x10\xf9\xc3\x84\xd4%\xd5TD\xa4v\x97Hc\n\xde7`\xa9\xc2w&\xc4\x013"\xb2\r\xf8\x07(7`qD\xa4\x07\xa8\x04\x96T\xc2\xc1iX\xa7\xf0\xa1\xc2$\xf0\xf3q\xd5sN\xce\x86\xebf}\x08\xbe7`mT\xa4\x12\xf8\xb8\x0f\xf6\t\xbc\xb3]\xe4A\xf8\xa00^\x06\xbd&|\x14\x15Y\xa7\xb04\x04_\x87`\x9f\x05W\x15\xfaO\xaa\x8e\xd8\xed\x05^I@B\xe0Y\x85O\x14z\xb2\xe3\xac4\xa0\xf3\x84\xea\x80\xc0j\x85j\x03\x96\x19p\xc5\x00k\x12V\xa5a\x8f\x05\xdf\x02\xed\xcfA\xbf\xdb\x81Y\x02\x14\xac\'\xe1\xb6\x05\x89\x04\x08\xb0T3\xe7\xecx\x19T9\x08\xdd\x18\x85)\x0b\x0c\x85\xb0\x01\x93\x0b\xe1\xb6BC>\xab\x17\x81\x05\x8c\x01\xd3Ffl\x8c\xcc\xacV\xb58\xd2\xe9\n\x96d9X \n\x9f\x0b\x1c\x00\xf6\xe6zO\xcc\xb5\x07\xfa#"\x9f\x02\xf7\x9c3\xea\xc4$\xfcf\xc1\x0bS\x10#\xe3\xc2\xb0\xc0\xce]"\xcb\x013\xd7\x06v\x90\xbdc\xc1W5p(_\x1b\x81\x15\xc05\x85m9\xc7R\xd5\x82\xd7{P6W\x1bw\xbb\xdd\xb0PU\x89\x80YL\xdfBW\x04\x8euCg\x14\xbe\xd9\t\xab\xdd\xf5\xf3\xfeM\x1c\x15\xa9\x12\xd8\x94\x86\x1b}\xaa\xd7\xdc\xf5\xf3^\xc0\\x\xfc\xde\xc4\xf3\r\x8f\xbc\x80\x7f\x01\xc8\xe8\xca\xc4&0\x97\xff\x00\x00\x00\x00IEND\xaeB`\x82' -------------------------------------------------------------------------------- /lastgui/export.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exporting views 3 | """ 4 | 5 | 6 | def as_filetype(data, filetype, *a, **kw): 7 | handler = { 8 | "csv": as_csv, 9 | }[filetype] 10 | return handler(data, *a, **kw) 11 | 12 | 13 | def as_csv(data, sheet_name=None, filename="data"): 14 | """ 15 | Exports the given 'data' as a CSV file. 16 | Data is a list of rows 17 | Rows are lists of cells 18 | Cells are tuples of either value or (value, params) 19 | Ignores its sheet_name parameter, as CSV doesn't support it. 20 | Also ignores any styling. 21 | """ 22 | 23 | from django.http import HttpResponse 24 | httpresponse = HttpResponse(mimetype='text/csv') 25 | httpresponse['Content-Disposition'] = 'attachment; filename="%s.csv"' % filename.encode("ascii", "replace") 26 | 27 | rowstrs = [] 28 | # Cycle through the rows 29 | for row in data: 30 | rowstr = u"" 31 | # And the cells 32 | for cell in row: 33 | if isinstance(cell, (tuple, list)): 34 | rowstr += unicode(cell[0]) + "," 35 | else: 36 | rowstr += unicode(cell) + "," 37 | rowstrs.append(rowstr[:-1]) 38 | 39 | httpresponse.write("\n".join(rowstrs)) 40 | 41 | return httpresponse 42 | -------------------------------------------------------------------------------- /lastgui/fetch.py: -------------------------------------------------------------------------------- 1 | """ 2 | Last.fm data fetcher 3 | """ 4 | 5 | import sys 6 | import time 7 | import socket 8 | import urllib 9 | import random 10 | import eventlet 11 | from lastgui.xml import * 12 | from lastgui.storage import UserHistory 13 | from django.conf import settings 14 | 15 | class LastFmFetcher(object): 16 | 17 | def __init__(self): 18 | self.last = 0.0 19 | self.delay = 1.0 20 | self.debug = True 21 | 22 | 23 | def fetch(self, url): 24 | import time 25 | # Delay to ensure we don't anger the API server 26 | while self.last + self.delay > time.time(): 27 | time.sleep(0.001) 28 | # Nab 29 | if self.debug: 30 | print "Fetching %s" % url 31 | else: 32 | pass #print >> sys.stderr, "Fetching %s" % url 33 | 34 | 35 | try: 36 | socket.settimeout(10) 37 | handle = urllib.urlopen(url) 38 | data = handle.read() 39 | #print >> sys.stderr, dict(handle.headers), "for", url 40 | except (AttributeError, IOError): 41 | try: 42 | time.sleep(0.1) 43 | data = urllib.urlopen(url).read() 44 | except (AttributeError, IOError): 45 | try: 46 | time.sleep(0.2+random.random()*0.2) 47 | data = urllib.urlopen(url).read() 48 | except (AttributeError, IOError): 49 | try: 50 | time.sleep(0.3) 51 | data = urllib.urlopen(url).read() 52 | except (AttributeError, IOError): 53 | try: 54 | time.sleep(0.4) 55 | data = urllib.urlopen(url).read() 56 | except (AttributeError, IOError): 57 | raise IOError("Cannot contact last.fm") 58 | 59 | self.last = time.time() 60 | return data 61 | 62 | 63 | def weeks(self, username): 64 | if username.startswith("tag:"): 65 | return week_list( 66 | self.fetch("http://ws.audioscrobbler.com/2.0/?method=tag.getweeklychartlist&tag=%s&api_key=%s" % (username[4:], settings.API_KEY)) 67 | ) 68 | elif username.startswith("group:"): 69 | return week_list( 70 | self.fetch("http://ws.audioscrobbler.com/2.0/?method=group.getweeklychartlist&group=%s&api_key=%s" % (username[6:], settings.API_KEY)) 71 | ) 72 | else: 73 | return week_list( 74 | self.fetch("http://ws.audioscrobbler.com/2.0/?method=user.getweeklychartlist&user=%s&api_key=%s" % (username, settings.API_KEY)) 75 | ) 76 | 77 | 78 | def weekly_artists(self, username, start, end): 79 | if username.startswith("tag:"): 80 | return weekly_artists( 81 | self.fetch("http://ws.audioscrobbler.com/2.0/?method=tag.getweeklyartistchart&tag=%s&api_key=%s&from=%s&to=%s" % (username[4:], settings.API_KEY, start, end)) 82 | ) 83 | elif username.startswith("group:"): 84 | return weekly_artists( 85 | self.fetch("http://ws.audioscrobbler.com/2.0/?method=group.getweeklyartistchart&group=%s&api_key=%s&from=%s&to=%s" % (username[6:], settings.API_KEY, start, end)) 86 | ) 87 | else: 88 | return weekly_artists( 89 | self.fetch("http://ws.audioscrobbler.com/2.0/?method=user.getweeklyartistchart&user=%s&api_key=%s&from=%s&to=%s" % (username, settings.API_KEY, start, end)) 90 | ) 91 | 92 | 93 | fetcher = LastFmFetcher() 94 | 95 | 96 | def update_user_history(uh): 97 | """Given a UserHistory object, updates it so it is current.""" 98 | 99 | fetcher.delay = settings.LASTFM_DELAY 100 | 101 | for start, end in fetcher.weeks(uh.username): 102 | if not uh.has_week(start): 103 | try: 104 | for artist, plays in fetcher.weekly_artists(uh.username, start, end): 105 | uh.set_plays(artist, start, plays) 106 | except: 107 | # Try once more 108 | try: 109 | for artist, plays in fetcher.weekly_artists(uh.username, start, end): 110 | uh.set_plays(artist, start, plays) 111 | except KeyboardInterrupt: 112 | print "Exiting on user command." 113 | raise SystemExit 114 | except Exception, e: 115 | print "Warning: Invalid data for %s - %s: %s" % (start, end, repr(e)) 116 | 117 | 118 | def update_user(username): 119 | """Returns an up-to-date UserHistory for this username, 120 | perhaps creating it on the way or loading from disk.""" 121 | 122 | uh = UserHistory(username) 123 | 124 | if uh.has_file(): 125 | uh.load_default() 126 | 127 | try: 128 | update_user_history(uh) 129 | uh.set_timestamp() # We assume we got all the data for now. 130 | except KeyboardInterrupt: 131 | pass 132 | 133 | uh.save_default() 134 | 135 | return uh 136 | -------------------------------------------------------------------------------- /lastgui/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/lastgui/management/__init__.py -------------------------------------------------------------------------------- /lastgui/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/lastgui/management/commands/__init__.py -------------------------------------------------------------------------------- /lastgui/management/commands/add_node.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from django.core.management import BaseCommand 3 | from lastgui.models import Node 4 | 5 | 6 | class Command(BaseCommand): 7 | 8 | def handle(self, *args, **kwargs): 9 | # Error if we don't understand 10 | if len(args) != 2: 11 | print "Usage: add_node " 12 | sys.exit(1) 13 | # Add the node 14 | node = Node(nodename=args[0]) 15 | node.set_password(args[1]) 16 | node.save() 17 | print "Node %s added" % node.nodename 18 | -------------------------------------------------------------------------------- /lastgui/management/commands/fetch_user.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from django.core.management import BaseCommand 3 | from lastgui.models import * 4 | from lastgui.fetch import update_user 5 | 6 | 7 | class Command(BaseCommand): 8 | 9 | def handle(self, *args, **kwargs): 10 | # Get the top person in the queue 11 | queue = LastFmUser.queue().filter(fetching=False) 12 | try: 13 | user = queue[0] 14 | except IndexError: 15 | print "No users queued." 16 | sys.exit(2) 17 | 18 | print "Updating user '%s'..." % user.username 19 | user.fetching = True 20 | user.save() 21 | 22 | try: 23 | # Download their data! 24 | update_user(user.username) 25 | 26 | # Mark them as done! 27 | user.requested_update = None 28 | user.fetching = False 29 | user.save() 30 | print "Done!" 31 | except AssertionError, e: 32 | print "Oh well, we'll ignore them." 33 | user.requested_update = None 34 | user.fetching = False 35 | user.save() 36 | raise e 37 | except UnicodeDecodeError, e: 38 | print "Unicode error. Uh-oh." 39 | print user.username 40 | user.fetching = False 41 | user.save() 42 | except Exception,e: 43 | user.fetching = False 44 | user.save() 45 | print "Restored user in queue" 46 | raise e 47 | -------------------------------------------------------------------------------- /lastgui/management/commands/render_poster.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from django.core.management import BaseCommand 3 | from lastrender.renderer import render_poster 4 | 5 | 6 | class Command(BaseCommand): 7 | 8 | def handle(self, *args, **kwargs): 9 | render_poster() 10 | -------------------------------------------------------------------------------- /lastgui/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding model 'LastFmUser' 12 | db.create_table('lastgui_lastfmuser', ( 13 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 14 | ('username', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255)), 15 | ('requested_update', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), 16 | ('last_check', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), 17 | ('external_until', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), 18 | ('fetching', self.gf('django.db.models.fields.BooleanField')(default=False)), 19 | )) 20 | db.send_create_signal('lastgui', ['LastFmUser']) 21 | 22 | # Adding model 'Poster' 23 | db.create_table('lastgui_poster', ( 24 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 25 | ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='posters', to=orm['lastgui.LastFmUser'])), 26 | ('start', self.gf('django.db.models.fields.DateField')()), 27 | ('end', self.gf('django.db.models.fields.DateField')()), 28 | ('params', self.gf('django.db.models.fields.TextField')(blank=True)), 29 | ('requested', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), 30 | ('started', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), 31 | ('completed', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), 32 | ('failed', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), 33 | ('expires', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), 34 | ('email', self.gf('django.db.models.fields.TextField')(blank=True)), 35 | ('error', self.gf('django.db.models.fields.TextField')(blank=True)), 36 | ('node', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['lastgui.Node'], null=True, blank=True)), 37 | ('pdf_url', self.gf('django.db.models.fields.TextField')(blank=True)), 38 | ('svg_url', self.gf('django.db.models.fields.TextField')(blank=True)), 39 | )) 40 | db.send_create_signal('lastgui', ['Poster']) 41 | 42 | # Adding model 'Node' 43 | db.create_table('lastgui_node', ( 44 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 45 | ('nodename', self.gf('django.db.models.fields.CharField')(unique=True, max_length=100)), 46 | ('salt', self.gf('django.db.models.fields.CharField')(max_length=20)), 47 | ('hash', self.gf('django.db.models.fields.CharField')(max_length=100)), 48 | ('disabled', self.gf('django.db.models.fields.BooleanField')(default=False)), 49 | ('lastseen', self.gf('django.db.models.fields.DateTimeField')(default=None, null=True, blank=True)), 50 | )) 51 | db.send_create_signal('lastgui', ['Node']) 52 | 53 | 54 | def backwards(self, orm): 55 | # Deleting model 'LastFmUser' 56 | db.delete_table('lastgui_lastfmuser') 57 | 58 | # Deleting model 'Poster' 59 | db.delete_table('lastgui_poster') 60 | 61 | # Deleting model 'Node' 62 | db.delete_table('lastgui_node') 63 | 64 | 65 | models = { 66 | 'lastgui.lastfmuser': { 67 | 'Meta': {'object_name': 'LastFmUser'}, 68 | 'external_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 69 | 'fetching': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 70 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 71 | 'last_check': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 72 | 'requested_update': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 73 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}) 74 | }, 75 | 'lastgui.node': { 76 | 'Meta': {'object_name': 'Node'}, 77 | 'disabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 78 | 'hash': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 79 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 80 | 'lastseen': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}), 81 | 'nodename': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 82 | 'salt': ('django.db.models.fields.CharField', [], {'max_length': '20'}) 83 | }, 84 | 'lastgui.poster': { 85 | 'Meta': {'object_name': 'Poster'}, 86 | 'completed': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 87 | 'email': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 88 | 'end': ('django.db.models.fields.DateField', [], {}), 89 | 'error': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 90 | 'expires': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 91 | 'failed': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 92 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 93 | 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['lastgui.Node']", 'null': 'True', 'blank': 'True'}), 94 | 'params': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 95 | 'pdf_url': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 96 | 'requested': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 97 | 'start': ('django.db.models.fields.DateField', [], {}), 98 | 'started': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 99 | 'svg_url': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 100 | 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'posters'", 'to': "orm['lastgui.LastFmUser']"}) 101 | } 102 | } 103 | 104 | complete_apps = ['lastgui'] -------------------------------------------------------------------------------- /lastgui/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/lastgui/migrations/__init__.py -------------------------------------------------------------------------------- /lastgui/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | import datetime 3 | import random 4 | import hashlib 5 | 6 | 7 | class LastFmUser(models.Model): 8 | """ 9 | Represents a user from that great site, last.fm. 10 | """ 11 | 12 | # As seen on last.fm 13 | username = models.CharField(max_length=255, unique=True) 14 | 15 | # Null if no update wanted, else used to prioritise 16 | requested_update = models.DateTimeField(blank=True, null=True) 17 | 18 | # The last time this user was checked for updateness 19 | last_check = models.DateTimeField(blank=True, null=True) 20 | 21 | # Is this account enabled for external image access? And when till? 22 | external_until = models.DateTimeField(blank=True, null=True) 23 | 24 | # Is this already being fetched? 25 | fetching = models.BooleanField(default=False) 26 | 27 | def __unicode__(self): 28 | return self.username 29 | 30 | def get_absolute_url(self): 31 | return "/user/%s/" % self.username 32 | 33 | def external_allowed(self): 34 | return True # I've become generous 35 | # if self.external_until: 36 | # return self.external_until > datetime.datetime.utcnow() 37 | # else: 38 | # return False 39 | 40 | @classmethod 41 | def by_username(cls, username): 42 | "Returns the correct object, possibly creating one." 43 | try: 44 | return cls.objects.get(username=username) 45 | except cls.DoesNotExist: 46 | instance = cls(username=username) 47 | instance.save() 48 | return instance 49 | 50 | @classmethod 51 | def queue(cls): 52 | "Returns the current update queue." 53 | return cls.objects.filter(requested_update__isnull=False).order_by("requested_update") 54 | 55 | 56 | # IDs here ended with 27533 57 | class Poster(models.Model): 58 | """ 59 | A large poster. A shiny lastgraph2-esque PDF, like. 60 | """ 61 | 62 | user = models.ForeignKey(LastFmUser, related_name="posters") 63 | 64 | start = models.DateField() 65 | end = models.DateField() 66 | 67 | params = models.TextField(blank=True) 68 | 69 | requested = models.DateTimeField(blank=True, null=True) 70 | started = models.DateTimeField(blank=True, null=True) 71 | completed = models.DateTimeField(blank=True, null=True) 72 | failed = models.DateTimeField(blank=True, null=True) 73 | expires = models.DateTimeField(blank=True, null=True) 74 | 75 | email = models.TextField(blank=True) 76 | 77 | error = models.TextField(blank=True) 78 | 79 | node = models.ForeignKey("Node", blank=True, null=True) 80 | 81 | pdf_url = models.TextField(blank=True) 82 | svg_url = models.TextField(blank=True) 83 | 84 | def __unicode__(self): 85 | return u"%s: %s - %s" % (self.user, self.start, self.end) 86 | 87 | def queue_position(self): 88 | queue = list(Poster.queue()) 89 | try: 90 | return queue.index(self) + 1 91 | except (IndexError, ValueError): 92 | return len(queue) + 1 93 | 94 | def status_string(self): 95 | if self.failed: 96 | return "Failed" 97 | if self.requested: 98 | if self.completed: 99 | if self.pdf_url == "expired": 100 | return "Expired" 101 | else: 102 | return "Complete" 103 | elif self.started: 104 | return "Rendering" 105 | return "Queued, position %i" % self.queue_position() 106 | return "Unrequested" 107 | 108 | def expired(self): 109 | return self.completed and self.pdf_url == "expired" 110 | 111 | def detail_string(self): 112 | detail = int(self.params.split("|")[1]) 113 | return { 114 | 1: "Super", 115 | 2: "High", 116 | 3: "Medium", 117 | 5: "Low", 118 | 10: "Terrible", 119 | 20: "Abysmal", 120 | 30: "Excrutiatingly Bad", 121 | }.get(detail, detail) 122 | 123 | def colorscheme_string(self): 124 | return self.params.split("|")[0].title() 125 | 126 | def set_error(self, error): 127 | self.error = error 128 | self.failed = datetime.datetime.utcnow() 129 | self.save() 130 | 131 | @classmethod 132 | def queue(cls): 133 | return cls.objects.filter(requested__isnull=False, started__isnull=True, failed__isnull=True).order_by("requested") 134 | 135 | 136 | class Node(models.Model): 137 | 138 | """Represents a processing node, i.e. something that either downloads or renders data.""" 139 | 140 | nodename = models.CharField('Node name', max_length=100, unique=True) 141 | salt = models.CharField('Password salt', max_length=20) 142 | hash = models.CharField('Password hash', max_length=100) 143 | 144 | disabled = models.BooleanField(default=False) 145 | lastseen = models.DateTimeField('Last seen', null=True, blank=True, default=None) 146 | 147 | def __unicode__(self): 148 | return "Node '%s'" % self.nodename 149 | 150 | def set_password(self, password): 151 | self.salt = "".join([random.choice("abcdefghijklmnopqrstuvwxyz0123456789") for i in range(8)]) 152 | self.hash = hashlib.sha1(self.salt + password).hexdigest() 153 | 154 | def password_matches(self, password): 155 | return self.hash == hashlib.sha1(self.salt + password).hexdigest() 156 | 157 | @classmethod 158 | def recent(cls): 159 | return cls.objects.filter(lastseen__gte=datetime.datetime.utcnow() - datetime.timedelta(0, 5*60)) 160 | -------------------------------------------------------------------------------- /lastgui/storage.py: -------------------------------------------------------------------------------- 1 | """ 2 | LastGraph data storage stuff. 3 | """ 4 | 5 | import os 6 | import time 7 | 8 | # Get a pickle - the faster the better 9 | try: 10 | import cPickle as pickle 11 | except ImportError: 12 | print "Warning: Cannot find cPickle." 13 | import pickle 14 | 15 | 16 | class UserHistory(object): 17 | 18 | """ 19 | Represents a user's listening history, with artist-week-level granularity. 20 | """ 21 | 22 | def __init__(self, username): 23 | self.username = username 24 | self.artists = {} 25 | self.weeks = {} 26 | self.timestamp = 0 27 | 28 | def set_plays(self, artist, week, plays): 29 | "Set the number of plays for an artist on a week." 30 | if artist not in self.artists: 31 | self.artists[artist] = {} 32 | self.artists[artist][week] = plays 33 | if week not in self.weeks: 34 | self.weeks[week] = {} 35 | self.weeks[week][artist] = plays 36 | 37 | def delete_week(self, week): 38 | "Erases all plays for a week" 39 | if week in self.weeks: 40 | del self.weeks[week] 41 | for artist in self.artists: 42 | if week in self.artists[artist]: 43 | del self.artists[artist][week] 44 | 45 | def get_plays(self, artist, week): 46 | "Gets the number of plays for an artist in a week." 47 | return self.artists.get(artist, {}).get(week, 0) 48 | 49 | def total_artist(self, artist): 50 | return sum(self.artists.get(artist, {}).values()) 51 | 52 | def total_week(self, week): 53 | return sum(self.weeks.get(week, {}).values()) 54 | 55 | def artist_plays(self, artist): 56 | plays = {} 57 | for week in self.weeks: 58 | plays[week] = self.artists.get(artist, {}).get(week, 0) 59 | return plays 60 | 61 | def week_plays(self): 62 | plays = {} 63 | for week, artists in self.weeks.items(): 64 | plays[week] = sum(artists.values()) 65 | return plays 66 | 67 | def has_week(self, week): 68 | return week in self.weeks 69 | 70 | def num_weeks(self): 71 | return len(self.weeks) 72 | 73 | def num_artists(self): 74 | return len(self.artists) 75 | 76 | def save(self, file): 77 | #print "Saving %s..." % self.username 78 | pickle.dump((self.username, self.timestamp, self.artists, self.weeks), file, -1) 79 | 80 | def save_default(self): 81 | self.save(open(self.get_default_path(), "w")) 82 | 83 | def load(self, file): 84 | try: 85 | self.username, self.timestamp, self.artists, self.weeks = pickle.load(file) 86 | except EOFError: 87 | raise ValueError("Invalid pickle file '%s'" % file) 88 | #print "Loading %s..." % self.username 89 | 90 | def load_default(self): 91 | self.load(open(self.get_default_path(), "r")) 92 | 93 | def has_file(self): 94 | return os.path.isfile(self.get_default_path()) 95 | 96 | def set_timestamp(self, ttime=None): 97 | if ttime is None: 98 | ttime = time.time() 99 | self.timestamp = ttime 100 | 101 | def data_age(self): 102 | return time.time() - self.timestamp 103 | 104 | def get_default_path(self): 105 | from django.conf import settings 106 | return os.path.join(settings.USER_DATA_ROOT, "%s.last" % self.username) 107 | 108 | def load_if_possible(self): 109 | if self.has_file(): 110 | try: 111 | self.load_default() 112 | except ValueError: 113 | pass 114 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /lastgui/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Page Not Found{% endblock %} 4 | 5 | {% block content %} 6 | 7 |
8 | 9 |

Page Not Found

10 | 11 |

The page you're looking for doesn't exist.

12 | 13 |
14 | 15 | {% endblock %} -------------------------------------------------------------------------------- /lastgui/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Server Error{% endblock %} 4 | 5 | {% block content %} 6 | 7 |
8 | 9 |

Server Error

10 | 11 |

An internal server error has occurred; don't worry, this is probably not your fault. Someone has been emailed about it.

12 | 13 |
14 | 15 | {% endblock %} -------------------------------------------------------------------------------- /lastgui/templates/about.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}About LastGraph{% endblock %} 4 | 5 | {% block content %} 6 | 7 |
8 | 9 |

About LastGraph

10 | 11 |

LastGraph is a web-based service that aims to give you a new way to explore your last.fm listening history. We use the last.fm API to transfer across your basic profile data, and then crunch it around in various interesting (and often hilariously inefficient) ways.

12 | 13 |

The site is written and run by Andrew Godwin.

14 | 15 |

Features

16 | 17 |

LastGraph currently offers the following:

18 |
    19 |
  • Live browsing of individual artist histories, as well as your entire artist chart.
  • 20 |
  • Rendered posters, displaying your musical history in a pretty and digestable format.
  • 21 |
  • Data export of your profile data in various formats.
  • 22 |
23 |

It's now over five years old, and so unlikely to gain any improvements, but if it stops working then get in touch with me andrew at aeracode dot org.

24 | 25 |

Speed

26 | 27 |

LastGraph can often be a bit slow to load your profile, as we're limited to pulling data in from last.fm at ten requests a second, so your profile could take up to 20 seconds to be fetched once you reach the head of the fetching queue.

28 | 29 |

Once you've queued up a fetch (or a poster), you can go somewhere else and come back later to see how far we've got with it; you can just type your name in the box again to see.

30 | 31 |

Problems

32 | 33 |

If you find any bugs or problems in the site, then please email/jabber me at andrew at aeracode dot org. I'll try and get back to you (and hopefully fix any problems) as soon as I can, but be aware there are in fact times when I'm really not sitting at my computer.

34 | 35 |

Technology

36 | 37 |

The LastGraph interface, live graphs and queueing/scheduling system are written in Django, and the poster renderer is a plain Python application, using my own Graphication graphing library, developed specifically for LastGraph. Posters are stored and served from Amazon S3.

38 | 39 |

If you'd like to know more about the technology behind LastGraph, then have a look at my project page for it, or sling me an email at andrew at aeracode dot org - I'll be happy to discuss it with you.

40 | 41 |
42 | 43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /lastgui/templates/about_artist_histories.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}About Artist Histories{% endblock %} 4 | 5 | {% block content %} 6 | 7 |
8 | 9 |

About Artist Histories

10 | 11 |

Last.fm lets you see your top artists each week, but doesn't let you get a good overview of your listening history of each artist. Thus, the new LastGraph feature is artist histories.

12 | 13 |

14 | 15 |

You can view a history graph for each artist you have ever listened to (yes, even if you only ever listened once), as well as download aggregated history data for each artist in a variety of formats.

16 | 17 |

It's a nice way to see where your allegiences have been in the past, or when you discovered a certain artist; this kind of information is often visible on the posters, but not as clearly, and without a scale.

18 | 19 |
20 | 21 | {% endblock %} -------------------------------------------------------------------------------- /lastgui/templates/about_colours.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Poster Colours{% endblock %} 4 | 5 | {% block content %} 6 | 7 |
8 | 9 |

Poster Colours

10 | 11 |

The colourschemes are:

12 | 13 |
    14 |
  • 15 | Ocean 16 |
  • 17 |
  • 18 | Blue 19 |
  • 20 |
  • 21 | Desert 22 |
  • 23 |
  • 24 | Rainbow 25 |
  • 26 |
  • 27 | Sunset 28 |
  • 29 |
  • 30 | Green 31 |
  • 32 |
33 | 34 |
35 | 36 | {% endblock %} -------------------------------------------------------------------------------- /lastgui/templates/about_posters.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}About Posters{% endblock %} 4 | 5 | {% block content %} 6 | 7 |
8 | 9 |

About Posters

10 | 11 |

Posters are large, complex representations of your music history. They're the main reason LastGraph exists, and as a bonus they look nice on your wall.

12 | 13 |

14 | 15 |

Unlike the graphs on the artists pages, which we can create on-the-fly, the posters can take upwards of several minutes to create and so we have a queueing system in place. When you request a poster, you'll be put into the rendering queue - usually they're finished inside of half an hour.

16 | 17 |

If you put an email address in, you'll get an email when it's complete, otherwise you should come back and check your posters page every so often. When it's finished, you will be able to download it as a PDF or an SVG. Be warned that the posters are extremely complex pictures; displaying one can bring even a modern computer to its knees for a few seconds.

18 | 19 |

The posters are inspired by Lee Byron's What have I been listening to? project; his look much nicer, but aren't available via an on-demand online system.

20 | 21 |
22 | 23 | {% endblock %} -------------------------------------------------------------------------------- /lastgui/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | LastGraph: {% block title %}Home{% endblock %} 10 | 13 | 16 | 17 | 18 | 19 | 20 | 23 | {% if username %} 24 | 27 | {% endif %} 28 | {% block extrahead %}{% endblock %} 29 | 30 | 31 | 32 | 33 | 34 |
35 | 40 |
41 | 42 | {% if username %} 43 | 53 | {% endif %} 54 | 55 |
56 | Loading... 57 |
58 | 59 |
60 | 61 | 62 | {% block tagline %} 63 | {% if username %} 64 | You're currently looking at {{ username }}'s profile. 65 | {% else %} 66 | Enter a username, and let's get cracking. 67 | {% endif %} 68 | {% endblock %} 69 | 70 |
71 | 72 |
73 | {% block content %} 74 | {% endblock %} 75 |
76 | 77 | 88 | 89 | 90 | 91 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /lastgui/templates/crossdomain.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /lastgui/templates/fragments/front.html: -------------------------------------------------------------------------------- 1 |

Welcome to LastGraph

2 | 3 |

LastGraph is a service for exploring your musical history on Last.fm. Simply give us your username, and see what you've been listening to!

4 | 5 |

Create colourful posters of your entire musical history, explore the play histories of individual artists, and much more. If you want to know more about how it all works, then you should probably read the technical summary.

-------------------------------------------------------------------------------- /lastgui/templates/fragments/intro.html: -------------------------------------------------------------------------------- 1 |

All systems go!

2 | 3 |

Your data is all loaded, and you're ready to go. Pick one of the options above.

-------------------------------------------------------------------------------- /lastgui/templates/fragments/poster.html: -------------------------------------------------------------------------------- 1 |

2 | This is a list of all the posters you've ordered to be generated recently. If you don't know much about LastGraph posters, you may want to read the short summary. 3 |

4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 18 | 19 | 20 | 27 | 28 |
-------------------------------------------------------------------------------- /lastgui/templates/fragments/poster_table.html: -------------------------------------------------------------------------------- 1 | 2 | Start 3 | End 4 | Status 5 | 6 | {% if posters %} 7 | {% for poster in posters %} 8 | 9 | {{ poster.start|date:"Y/m/d" }} 10 | 11 | {% endfor %} 12 | {% else %} 13 | 14 | You have not created any posters recently.

15 | 16 | {% endif %} -------------------------------------------------------------------------------- /lastgui/templates/front.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 | 7 |

Welcome to LastGraph

8 | 9 |

LastGraph lets you explore your last.fm listening history. Stick your username in the box above (or someone else's, if you feel like snooping), and hit enter.

10 | 11 | 12 |

Recent Users

13 | 14 |

Some of the last profiles visited were {% for user in recent %}{% if forloop.last %} and {% endif %}{{ user.username }}{% if not forloop.last %}, {% endif %}{% endfor %}.

15 | 16 |

Some Features

17 | 18 | 24 | 25 | 31 | 32 |
33 | 34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /lastgui/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block extrahead %} 4 | 5 | 6 | 7 | 10 | {% endblock %} 11 | 12 | {% block content %} 13 | 14 | 15 | 16 | 22 | 23 |
24 | Loading... 25 |
26 | 27 |
28 | 29 | 30 | Enter a username, and let's get cracking. 31 | 32 |
33 | 34 |
35 | {% include "fragments/front.html" %} 36 |
37 | 38 |
39 | {% include "fragments/intro.html" %} 40 |
41 | 42 |
43 | {% include "fragments/poster.html" %} 44 |
45 | 46 |
47 | 48 |
49 | 50 |

51 | 52 | 57 | 58 | {% endblock %} -------------------------------------------------------------------------------- /lastgui/templates/status.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}System Status{% endblock %} 4 | 5 | {% block content %} 6 | 7 |
8 | 9 |

System Status

10 | 11 |

Queues

12 | 13 |

14 | 15 | {% with fetchqueue.count as flen %} 16 | There {{ flen|pluralize:"is, are" }} currently {{ flen }} {{ flen|pluralize:"person,people" }} in the fetch queue, 17 | {% endwith %} 18 | 19 | {% with renderqueue.count as rlen %} 20 | and {{ rlen }} {{ rlen|pluralize:"person,people" }} in the render queue. 21 | {% endwith %} 22 | 23 |

24 | 25 |

Totals

26 | 27 |

There {{ numprofiles|pluralize:"is, are" }} {{ numprofiles }} user profile{{ numprofiles|pluralize }} currently tracked, with a total of {{ numposters }} poster{{ numposters|pluralize }}.

28 | 29 |

Posters

30 | 31 |

The last few posters to be rendered were for {% for poster in recentposters %}{% if forloop.last %} and {% endif %}{{ poster.user.username }}{% if not forloop.last %}, {% endif %}{% endfor %}

32 | 33 |

Nodes

34 | 35 | {% with nodes.count as numnodes %} 36 |

There {{ numnodes|pluralize:"is, are" }} {{ numnodes }} render node{{ numnodes|pluralize }} online.

37 | {% endwith %} 38 | 39 |
40 | 41 | {% endblock %} -------------------------------------------------------------------------------- /lastgui/templates/user_artist.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ username }}: {{ artist }}{% endblock %} 4 | 5 | {% block tagline %}This is {{ username }}'s history for {{ artist }}.{% endblock %} 6 | 7 | {% block content %} 8 | 9 | 10 | 11 |
12 | Export:  13 | 16 | 17 | This data in CSV 18 | 19 | 20 | This data in JSON 21 | 22 |
23 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /lastgui/templates/user_artists.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ username }}'s Artists{% endblock %} 4 | 5 | {% block tagline %}This is {{ username }}'s artists. Click one to see a history.{% endblock %} 6 | 7 | {% block content %} 8 | 9 | 10 | {% for plays, artist, width in artists %} 11 | 12 | 13 | 14 | 15 | {% endfor %} 16 |
{{ artist }}
{{ plays }}
17 | 18 | {% endblock %} -------------------------------------------------------------------------------- /lastgui/templates/user_export.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ username }}'s Data Export{% endblock %} 4 | 5 | {% block tagline %}Here you can export data for {{ username }}.{% endblock %} 6 | 7 | {% block content %} 8 | 9 |

You can download the current data we have for {{ username }} as a variety of formats. Feeds of the entire data set are available below; alternatively, you can get individual artist exports on the artists pages.

10 | 11 | 16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /lastgui/templates/user_posters.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block extrahead %}{% endblock %} 4 | 5 | {% block title %}{{ username }}'s Posters{% endblock %} 6 | 7 | {% block tagline %}Here you can request and download posters.{% endblock %} 8 | 9 | {% block content %} 10 | 11 |

12 | This is a list of all the posters you've ordered to be generated recently. If you don't know much about LastGraph posters, you may want to read the short summary. Note that posters will expire (and disappear from this page) after seven days. 13 |

14 | 15 | {% if flashes %} 16 |
    17 | {% for flash in flashes %} 18 |
  • {{ flash }}
  • 19 | {% endfor %} 20 |
21 | {% endif %} 22 | 23 |
24 | 25 | 26 | {{ form.start }} 27 | {% if form.start.errors %}{{ form.start.errors }}{% endif %} 28 | 29 | 30 | {{ form.end }} 31 | {% if form.end.errors %}{{ form.end.errors }}{% endif %} 32 | 33 | 34 | 35 |
36 | 37 | 38 | {{ form.style }} 39 | [help] 40 | 41 | 42 | {{ form.detail }} 43 |
44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | {% if posters %} 54 | {% for poster in posters %} 55 | 56 | 57 | 58 | 59 | 60 | 67 | 68 | {% endfor %} 69 | {% else %} 70 | 71 | 73 | {% endif %} 74 |
IDDatesDetailColourschemeStatus
{{ poster.id }}{{ poster.start|date:"Y/m/d" }} to
{{ poster.end|date:"Y/m/d" }}
{{ poster.detail_string }}{{ poster.colorscheme_string }} 61 | {% if poster.completed %} 62 | Completed! PDF SVGZ 63 | {% else %} 64 | {{ poster.status_string }} 65 | {% endif %} 66 |
You have not created any posters recently.

72 |
75 | 76 | {% endblock %} -------------------------------------------------------------------------------- /lastgui/templates/user_premium.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Premium{% endblock %} 4 | 5 | {% block tagline %}Spend some money, get some stuff...{% endblock %} 6 | 7 | {% block content %} 8 | 9 |
10 | 11 |

LastGraph Premium

12 | 13 | {% if lfuser.external_allowed %} 14 |
15 | Excellent! {{ username }} has Premium enabled until {{ lfuser.external_until|date:"Y/m/d" }}.
(that's another {{ lfuser.external_until|timeuntil }}) 16 |
17 | {% endif %} 18 | 19 |

All the on-site features of LastGraph are completely free and ad-free, and will hopefully remain so forever. However, money to pay for the bandwidth has to come from somewhere! In the past, we have accepted donations, but it's time there was something in return when you give us some money.

20 | 21 |

So, there is LastGraph Premium. In return for a small (annual) fee, we'll enable hotlinking for your graphs (so you can include them on your own website), and give you access to some small dynamic signature images you can use for forums or other similar things. You also get the priceless feeling of warm fuzziness for helping us out.

22 | 23 |

Premium costs just $10 / £5 a year, and is tied to your last.fm username. You're currently looking at {{ username }}'s profile; if this is you, you can buy Premium below, otherwise find your profile on this site and click the 'Premium' link at the top.

24 | 25 |

26 |

27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
42 |

Don't want to use PayPal? Email me at andrew at aeracode dot org for other payment options.

43 |

44 | 45 |
46 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /lastgui/templates/user_premium_paid.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Premium: Paid{% endblock %} 4 | 5 | {% block tagline %}Spend some money, get some stuff...{% endblock %} 6 | 7 | {% block content %} 8 | 9 |
10 | 11 |

LastGraph Premium: Paid

12 | 13 | Thanks for paying! I'll be in touch shortly when I've processed your payment. 14 | 15 |
16 | 17 | {% endblock %} -------------------------------------------------------------------------------- /lastgui/templates/user_root.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ username }}{% endblock %} 4 | 5 | {% block content %} 6 | 7 |

{{ username }} {{ num_weeks }} weeks of data

8 | 9 | {% if flashes %} 10 |
    11 | {% for flash in flashes %} 12 |
  • {{ flash }}
  • 13 | {% endfor %} 14 |
15 | {% endif %} 16 | 17 | 23 | 24 | 30 | 31 | 37 | 38 | 44 | 45 | {% if lfuser.external_allowed %} 46 | 52 | {% endif %} 53 | 54 | 60 | 61 | 67 | 68 |

69 | 70 |

71 | 72 | {% endblock %} 73 | -------------------------------------------------------------------------------- /lastgui/templates/user_sigs.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Signature Images{% endblock %} 4 | 5 | {% block tagline %}Some signature images for {{ username }}.{% endblock %} 6 | 7 | {% block content %} 8 | 9 |
10 | 11 |

Signature Images

12 | 13 |

These signature images will change to reflect your music history as we get more data.

14 | 15 |

16 | 17 |

18 |
http://lastgraph.aeracode.org/graph/{{username}}/sig1/
19 | 20 |

 

21 | 22 |

23 | 24 |

25 |
http://lastgraph.aeracode.org/graph/{{username}}/sig1/500/50/
26 | 27 |

 

28 | 29 |
30 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /lastgui/templates/user_timeline.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ username }}'s Timeline{% endblock %} 4 | 5 | {% block tagline %}This is {{ username }}'s overall timeline.{% endblock %} 6 | 7 | {% block content %} 8 | 9 | 10 | 11 |
12 | Export:  13 | 16 | 17 | This data in CSV 18 | 19 | 20 | This data in JSON 21 | 22 |
23 | 24 |

If you want to see your timeline bigger, more detailed and prettier, head over to the posters page.

25 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /lastgui/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | urlpatterns = patterns('', 4 | 5 | # Front page 6 | (r'^$', 'lastgui.views.front'), 7 | 8 | # Help text, etc. 9 | (r'^about/$', 'django.views.generic.simple.direct_to_template', {'template': 'about.html'}), 10 | (r'^about/posters/$', 'django.views.generic.simple.direct_to_template', {'template': 'about_posters.html'}), 11 | (r'^about/posters/colours/$', 'django.views.generic.simple.direct_to_template', {'template': 'about_colours.html'}), 12 | (r'^about/artists/$', 'django.views.generic.simple.direct_to_template', {'template': 'about_artist_histories.html'}), 13 | (r'^crossdomain.xml$', 'django.views.generic.simple.direct_to_template', {'template': 'crossdomain.xml'}), 14 | 15 | # System status 16 | (r'^status/$', 'lastgui.views.status'), 17 | (r'^status/nagios/fetch/$', 'lastgui.views.status_nagios_fetch'), 18 | (r'^status/nagios/render/$', 'lastgui.views.status_nagios_render'), 19 | 20 | # User views 21 | (r'^user/([^/]+)/$', 'lastgui.views.user_root'), 22 | (r'^user/([^/]+)/artists/$', 'lastgui.views.user_artists'), 23 | (r'^user/([^/]+)/artist/(.*)/$', 'lastgui.views.user_artist'), 24 | (r'^user/([^/]+)/timeline/$', 'lastgui.views.user_timeline'), 25 | (r'^user/([^/]+)/posters/$', 'lastgui.views.user_posters'), 26 | (r'^user/([^/]+)/export/$', 'lastgui.views.user_export'), 27 | (r'^user/([^/]+)/premium/$', 'lastgui.views.user_premium'), 28 | (r'^user/([^/]+)/premium/paid/$', 'lastgui.views.user_premium_paid'), 29 | (r'^user/([^/]+)/sigs/$', 'lastgui.views.user_sigs'), 30 | 31 | (r'^user/([^/]+)/export/all\.json$', 'lastgui.views.user_export_all_json'), 32 | (r'^user/([^/]+)/export/all\.(csv|xls)$', 'lastgui.views.user_export_all_tabular'), 33 | (r'^user/([^/]+)/export/artist/(.*)\.json$', 'lastgui.views.user_export_artist_json'), 34 | (r'^user/([^/]+)/export/artist/(.*)\.(csv|xls)$', 'lastgui.views.user_export_artist_tabular'), 35 | 36 | # Ajax stuff 37 | (r'^ajax/([^/]+)/ready/$', 'lastgui.views.ajax_user_ready'), 38 | (r'^ajax/([^/]+)/queuepos/$', 'lastgui.views.ajax_user_queuepos'), 39 | 40 | # Graphs 41 | (r'^graph/([^/]+)/artist/([^/]+)/$', 'lastgui.views.graph_artist'), 42 | (r'^graph/([^/]+)/artist/([^/]+)/(\d+)/(\d+)/$', 'lastgui.views.graph_artist'), 43 | (r'^graph/([^/]+)/timeline/(\d+)/(\d+)/$', 'lastgui.views.graph_timeline'), 44 | (r'^graph/([^/]+)/timeline-basic/(\d+)/(\d+)/$', 'lastgui.views.graph_timeline_basic'), 45 | 46 | # Sigs 47 | (r'^graph/([^/]+)/sig1/$', 'lastgui.views.graph_sig1'), 48 | (r'^graph/([^/]+)/sig1/(\d+)/(\d+)/$', 'lastgui.views.graph_sig1'), 49 | 50 | # Render API 51 | (r'^api/$', 'lastgui.api.index'), 52 | (r'^api/render/next/$', 'lastgui.api.render_next'), 53 | (r'^api/render/data/(\d+)/$', 'lastgui.api.render_data'), 54 | (r'^api/render/links/$', 'lastgui.api.render_links'), 55 | (r'^api/render/failed/$', 'lastgui.api.render_failed'), 56 | ) -------------------------------------------------------------------------------- /lastgui/views.py: -------------------------------------------------------------------------------- 1 | 2 | import datetime 3 | from urlparse import urlparse 4 | 5 | from shortcuts import * 6 | 7 | from lastgui.models import * 8 | from lastgui.storage import UserHistory 9 | from lastgui.fetch import fetcher 10 | from lastgui.data import hotlink_png 11 | 12 | from django.conf import settings 13 | from django.http import HttpResponse, HttpResponseRedirect 14 | from django import forms 15 | 16 | from lastgui.export import as_filetype 17 | 18 | 19 | def user_ready(username): 20 | if username.endswith(".php"): 21 | return False 22 | 23 | uh = UserHistory(username) 24 | 25 | # First, check to see if the file is fresh 26 | uh.load_if_possible() 27 | if uh.data_age() < settings.HISTORY_TTL: 28 | return uh.num_weeks() 29 | 30 | # Then, do a quick weeklist fetch to compare 31 | try: 32 | weeks = list(fetcher.weeks(username)) 33 | except AssertionError: # They probably don't exist 34 | return None 35 | 36 | present = True 37 | for start, end in weeks: 38 | if not uh.has_week(start): 39 | present = False 40 | break 41 | 42 | # If all weeks were present, update the timestamp 43 | if present: 44 | uh.set_timestamp() 45 | uh.save_default() 46 | return len(weeks) 47 | else: 48 | return False 49 | 50 | 51 | def referrer_limit(ofunc): 52 | def nfunc(request, username, *a, **kw): 53 | referrer = request.META.get('HTTP_REFERER', None) 54 | if referrer: 55 | protocol, host, path, params, query, frag = urlparse(referrer) 56 | if host != request.META['HTTP_HOST']: 57 | user = LastFmUser.by_username(username) 58 | if not user.external_allowed(): 59 | return HttpResponse(hotlink_png, mimetype="image/png") 60 | return ofunc(request, username, *a, **kw) 61 | return nfunc 62 | 63 | 64 | def ready_or_update(username): 65 | 66 | ready = user_ready(username) 67 | if ready is False: # We need to update their data 68 | lfuser = LastFmUser.by_username(username) 69 | if not lfuser.requested_update: 70 | lfuser.requested_update = datetime.datetime.utcnow() 71 | lfuser.last_check = datetime.datetime.utcnow() 72 | lfuser.save() 73 | 74 | return ready 75 | 76 | 77 | def ajax_user_ready(request, username): 78 | "Returns if the given user's data is in the system ready for lastgraph." 79 | 80 | ready = ready_or_update(username) 81 | 82 | return jsonify(ready) 83 | 84 | 85 | def ajax_user_queuepos(request, username): 86 | "Returns what number the user is in the request queue." 87 | 88 | try: 89 | return jsonify(list(LastFmUser.queue()).index(LastFmUser.by_username(username)) + 1) 90 | except (ValueError, IndexError): 91 | return jsonify(None) 92 | 93 | 94 | ### Graphs ### 95 | 96 | from graphication import FileOutput, Series, SeriesSet, AutoWeekDateScale, Label 97 | from graphication.wavegraph import WaveGraph 98 | from lastgui.css import artist_detail_css, artist_detail_white_css, basic_timeline_css, sig1_css 99 | 100 | 101 | def stream_graph(output): 102 | response = HttpResponse(mimetype="image/png") 103 | response.write(output.stream("png").read()) 104 | return response 105 | 106 | 107 | @referrer_limit 108 | @cache_page(60 * 15) 109 | def graph_artist(request, username, artist, width=800, height=300): 110 | 111 | ready_or_update(username) 112 | 113 | width = int(width) 114 | height = int(height) 115 | 116 | if not width: 117 | width=800 118 | 119 | if not height: 120 | width=300 121 | 122 | uh = UserHistory(username) 123 | uh.load_if_possible() 124 | 125 | series_set = SeriesSet() 126 | series_set.add_series(Series( 127 | artist, 128 | uh.artist_plays(artist), 129 | "#369f", 130 | {0:4}, 131 | )) 132 | 133 | # Create the output 134 | output = FileOutput(padding=0, style=artist_detail_white_css) 135 | 136 | try: 137 | scale = AutoWeekDateScale(series_set, short_labels=True, month_gap=2) 138 | except ValueError: 139 | raise Http404("Bad data (ValueError)") 140 | 141 | # OK, render that. 142 | wg = WaveGraph(series_set, scale, artist_detail_white_css, False, vertical_scale=True) 143 | output.add_item(wg, x=0, y=0, width=width, height=height) 144 | 145 | # Save the images 146 | return stream_graph(output) 147 | 148 | 149 | def graph_timeline_data(username): 150 | 151 | uh = UserHistory(username) 152 | uh.load_if_possible() 153 | 154 | series_set = SeriesSet() 155 | series_set.add_series(Series( 156 | "Plays", 157 | uh.week_plays(), 158 | "#369f", 159 | {0:4}, 160 | )) 161 | 162 | return series_set 163 | 164 | 165 | @referrer_limit 166 | @cache_page(60 * 15) 167 | def graph_timeline(request, username, width=800, height=300): 168 | 169 | ready_or_update(username) 170 | 171 | width = int(width) 172 | height = int(height) 173 | 174 | if not width: 175 | width = 800 176 | 177 | if not height: 178 | height = 300 179 | 180 | series_set = graph_timeline_data(username) 181 | 182 | # Create the output 183 | output = FileOutput(padding=0, style=artist_detail_white_css) 184 | try: 185 | scale = AutoWeekDateScale(series_set, short_labels=True, month_gap=2) 186 | except ValueError: 187 | raise Http404("No data") 188 | 189 | # OK, render that. 190 | wg = WaveGraph(series_set, scale, artist_detail_white_css, False, vertical_scale=True) 191 | output.add_item(wg, x=0, y=0, width=width, height=height) 192 | 193 | # Save the images 194 | try: 195 | return stream_graph(output) 196 | except ValueError: 197 | raise Http404("No data") 198 | 199 | 200 | @referrer_limit 201 | @cache_page(60 * 15) 202 | def graph_timeline_basic(request, username, width=800, height=300): 203 | 204 | ready_or_update(username) 205 | 206 | width = int(width) 207 | height = int(height) 208 | 209 | if not width: 210 | width = 1280 211 | 212 | if not height: 213 | height = 50 214 | 215 | series_set = graph_timeline_data(username) 216 | series_set.get_series(0).color = "3695" 217 | 218 | # Create the output 219 | output = FileOutput(padding=0, style=basic_timeline_css) 220 | try: 221 | scale = AutoWeekDateScale(series_set, short_labels=True) 222 | except ValueError: 223 | raise Http404("No data") 224 | 225 | # OK, render that. 226 | wg = WaveGraph(series_set, scale, basic_timeline_css, False, vertical_scale=False) 227 | output.add_item(wg, x=0, y=0, width=width, height=height) 228 | 229 | # Save the images 230 | try: 231 | return stream_graph(output) 232 | except ValueError: 233 | raise Http404("No data") 234 | 235 | 236 | 237 | @referrer_limit 238 | @cache_page(60 * 15) 239 | def graph_sig1(request, username, width=300, height=100): 240 | 241 | ready_or_update(username) 242 | 243 | width = int(width) 244 | height = int(height) 245 | 246 | if not width: 247 | width = 300 248 | 249 | if not height: 250 | height = 100 251 | 252 | series_set = graph_timeline_data(username) 253 | series_set.get_series(0).color = "3695" 254 | 255 | # Create the output 256 | output = FileOutput(padding=0, style=sig1_css) 257 | try: 258 | scale = AutoWeekDateScale(series_set, short_labels=True) 259 | except ValueError: 260 | raise Http404("No data") 261 | 262 | # OK, render that. 263 | 264 | lb = Label(username, sig1_css) 265 | output.add_item(lb, x=10, y=20-(height/2), width=width, height=height) 266 | wg = WaveGraph(series_set, scale, sig1_css, False, vertical_scale=False) 267 | output.add_item(wg, x=0, y=0, width=width, height=height) 268 | 269 | # Save the images 270 | try: 271 | return stream_graph(output) 272 | except ValueError: 273 | raise Http404("No data") 274 | 275 | 276 | 277 | def front(request): 278 | 279 | return render(request, "front.html", { 280 | "recent": LastFmUser.objects.filter(requested_update__isnull=True).order_by("-last_check")[:5], 281 | }) 282 | 283 | 284 | def status(request): 285 | 286 | return render(request, "status.html", { 287 | "fetchqueue": LastFmUser.queue(), 288 | "renderqueue": Poster.queue(), 289 | "numprofiles": LastFmUser.objects.count(), 290 | "numposters": Poster.objects.count(), 291 | "recentposters": Poster.objects.filter(completed__isnull=False).order_by("-completed")[:10], 292 | "nodes": Node.recent(), 293 | }) 294 | 295 | 296 | def status_nagios_fetch(request): 297 | 298 | return HttpResponse(str(LastFmUser.queue().count())) 299 | 300 | 301 | def status_nagios_render(request): 302 | 303 | return HttpResponse(str(Poster.queue().count())) 304 | 305 | 306 | 307 | def user_root(request, username): 308 | 309 | ready = ready_or_update(username) 310 | if not ready: 311 | flash(request, "This user's data is currently out-of-date, and is being updated.") 312 | 313 | uh = UserHistory(username) 314 | uh.load_if_possible() 315 | 316 | lfuser = LastFmUser.by_username(username) 317 | 318 | return render(request, "user_root.html", {"username": username, "num_weeks": len(uh.weeks), "lfuser": lfuser}) 319 | 320 | 321 | 322 | 323 | def user_sigs(request, username): 324 | 325 | lfuser = LastFmUser.by_username(username) 326 | 327 | if not lfuser.external_allowed(): 328 | raise Http404("No premium account") 329 | 330 | return render(request, "user_sigs.html", {"username": username, "lfuser": lfuser}) 331 | 332 | 333 | 334 | 335 | def user_artists(request, username): 336 | 337 | uh = UserHistory(username) 338 | uh.load_if_possible() 339 | 340 | artists = [(sum(weeks.values()), artist) for artist, weeks in uh.artists.items()] 341 | artists.sort() 342 | artists.reverse() 343 | 344 | try: 345 | max_plays = float(artists[0][0]) 346 | except IndexError: 347 | max_plays = 1000 348 | 349 | artists = [(plays, artist, 100*plays/max_plays) for plays, artist in artists] 350 | 351 | return render(request, "user_artists.html", {"username": username, "artists": artists}) 352 | 353 | 354 | 355 | 356 | def user_artist(request, username, artist): 357 | 358 | return render(request, "user_artist.html", {"username": username, "artist": artist}) 359 | 360 | 361 | 362 | 363 | def user_timeline(request, username): 364 | 365 | return render(request, "user_timeline.html", {"username": username}) 366 | 367 | 368 | 369 | def user_export(request, username): 370 | 371 | return render(request, "user_export.html", {"username": username}) 372 | 373 | 374 | 375 | def user_premium(request, username): 376 | 377 | lfuser = LastFmUser.by_username(username) 378 | 379 | return render(request, "user_premium.html", {"username": username, "lfuser": lfuser}) 380 | 381 | 382 | 383 | def user_premium_paid(request, username): 384 | 385 | lfuser = LastFmUser.by_username(username) 386 | 387 | return render(request, "user_premium_paid.html", {"username": username, "lfuser": lfuser}) 388 | 389 | 390 | 391 | def user_export_all_tabular(request, username, filetype): 392 | 393 | uh = UserHistory(username) 394 | uh.load_if_possible() 395 | 396 | data = [(("Week", {"bold":True}),("Artist", {"bold":True}),("Plays", {"bold":True}))] 397 | for week, artists in uh.weeks.items(): 398 | for artist, plays in artists.items(): 399 | data.append((week, artist, plays)) 400 | 401 | try: 402 | return as_filetype(data, filetype, filename="%s_all" % username) 403 | except KeyError: 404 | raise Http404("No such filetype") 405 | 406 | 407 | def user_export_all_json(request, username): 408 | 409 | uh = UserHistory(username) 410 | uh.load_if_possible() 411 | 412 | return jsonify({"username": username, "weeks":uh.weeks}) 413 | 414 | 415 | def user_export_artist_tabular(request, username, artist, filetype): 416 | 417 | uh = UserHistory(username) 418 | uh.load_if_possible() 419 | 420 | data = [(("Week", {"bold":True}),("Plays", {"bold":True}))] 421 | try: 422 | for week, plays in uh.artists[artist].items(): 423 | data.append((week, plays)) 424 | except KeyError: 425 | raise Http404("No such artist.") 426 | 427 | try: 428 | return as_filetype(data, filetype, filename="%s_%s" % (username, artist)) 429 | except KeyError: 430 | raise Http404("No such filetype") 431 | 432 | 433 | def user_export_artist_json(request, username, artist): 434 | 435 | uh = UserHistory(username) 436 | uh.load_if_possible() 437 | 438 | try: 439 | return jsonify({"username": username, "artist":artist, "weeks":uh.artists[artist]}) 440 | except KeyError: 441 | raise Http404("No such artist.") 442 | 443 | 444 | 445 | class NewPosterForm(forms.Form): 446 | 447 | start = forms.DateField(input_formats=("%Y/%m/%d",)) 448 | end = forms.DateField(input_formats=("%Y/%m/%d",)) 449 | 450 | style = forms.ChoiceField(choices=( 451 | ("ocean", "Ocean"), 452 | ("blue", "Blue"), 453 | ("desert", "Desert"), 454 | ("rainbow", "Rainbow"), 455 | ("sunset", "Sunset"), 456 | ("green", "Green"), 457 | ("eclectic", "Eclectic"), 458 | )) 459 | 460 | detail = forms.ChoiceField(choices=( 461 | ("1", "Super"), 462 | ("2", "High"), 463 | ("3", "Medium"), 464 | ("5", "Low"), 465 | ("10", "Terrible"), 466 | ("20", "Abysmal"), 467 | ("30", "Excrutiatingly Bad"), 468 | )) 469 | 470 | 471 | def user_posters(request, username): 472 | 473 | user = LastFmUser.by_username(username) 474 | 475 | if "style" in request.POST: 476 | form = NewPosterForm(request.POST) 477 | if form.is_valid(): 478 | poster = Poster( 479 | user = user, 480 | start = form.cleaned_data['start'], 481 | end = form.cleaned_data['end'], 482 | params = "%s|%s" % (form.cleaned_data['style'], form.cleaned_data['detail']), 483 | requested = datetime.datetime.now(), 484 | ) 485 | poster.save() 486 | flash(request, "Poster request submitted. It is in queue position %i." % poster.queue_position()) 487 | del form 488 | return HttpResponseRedirect("/user/%s/posters/?added=true" % username) 489 | 490 | if "form" not in locals(): 491 | form = NewPosterForm({ 492 | "start": (datetime.date.today() - datetime.timedelta(365)).strftime("%Y/%m/%d"), 493 | "end": datetime.date.today().strftime("%Y/%m/%d"), 494 | "detail": "3", 495 | "style": "ocean", 496 | }) 497 | 498 | posters = user.posters.exclude(pdf_url="expired").order_by("-requested") 499 | 500 | return render(request, "user_posters.html", { 501 | "username": username, 502 | "posters": posters, 503 | "form": form, 504 | }) 505 | -------------------------------------------------------------------------------- /lastgui/xml.py: -------------------------------------------------------------------------------- 1 | """ 2 | Last.fm XML parser 3 | """ 4 | 5 | import sys 6 | 7 | from BeautifulSoup import BeautifulStoneSoup 8 | import htmllib 9 | 10 | def unescape(s): 11 | p = htmllib.HTMLParser(None) 12 | p.save_bgn() 13 | p.feed(s) 14 | return p.save_end() 15 | 16 | 17 | def week_list(xml): 18 | 19 | soup = BeautifulStoneSoup(xml) 20 | 21 | # Check this is the right thing 22 | assert soup.find("weeklychartlist"), "week_list did not get a Weekly Chart List" 23 | 24 | for tag in soup.findAll("chart"): 25 | yield int(tag['from']), int(tag['to']) 26 | 27 | 28 | def weekly_artists(xml): 29 | soup = BeautifulStoneSoup(xml) 30 | 31 | # Check this is the right thing 32 | try: 33 | assert soup.find("weeklyartistchart"), "weekly_artists did not get a Weekly Artist Chart" 34 | except AssertionError: 35 | print >> sys.stderr, xml 36 | raise AssertionError("weekly_artists did not get a Weekly Artist Chart") 37 | 38 | # Get the artists 39 | for tag in soup.findAll("artist"): 40 | name = str(tag.find("name").string).decode("utf8") 41 | playtag = tag.find("playcount") 42 | if playtag: 43 | plays = long(playtag.string) 44 | else: 45 | plays = float(tag.find("weight").string) 46 | yield unescape(name), plays 47 | 48 | -------------------------------------------------------------------------------- /lastrender/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/lastrender/__init__.py -------------------------------------------------------------------------------- /lastrender/lastgraph.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: GreyscaleBasic; 3 | /* font-family: Georgia;*/ 4 | } 5 | 6 | colourer { 7 | gradient-start: #334489; 8 | gradient-end: #2d8f3c; 9 | } 10 | 11 | grid.major line { 12 | color: #666; 13 | width: 1; 14 | } 15 | 16 | grid.minor line { 17 | color: #ddd; 18 | width: 0.5; 19 | } 20 | 21 | grid.major label { 22 | padding: 17; 23 | } 24 | 25 | wavegraph { 26 | dimming-top: 1.5; 27 | dimming-bottom: 0.2; 28 | } 29 | 30 | wavegraph curve { 31 | color: #fff; 32 | } 33 | -------------------------------------------------------------------------------- /lastrender/renderer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | """ 4 | LastGraph rendering client 5 | """ 6 | 7 | import sys 8 | import os 9 | import json 10 | import urllib 11 | from httppost import posturl, extract_django_error 12 | from graphication import FileOutput, Series, SeriesSet, Label, AutoWeekDateScale, Colourer, css 13 | from graphication.wavegraph import WaveGraph 14 | 15 | 16 | def render_poster(): 17 | from settings import apiurl, local_store, local_store_url, nodename, nodepwd 18 | 19 | DEBUG = "--debug" in sys.argv 20 | GDEBUG = "--gdebug" in sys.argv 21 | TEST = "--test" in sys.argv 22 | PROXYUPLOAD = "--proxyupload" in sys.argv 23 | 24 | if "--" not in sys.argv[-1] and TEST: 25 | SPECIFIC = int(sys.argv[-1]) 26 | else: 27 | SPECIFIC = None 28 | 29 | print "# Welcome to the LastGraph Renderer" 30 | 31 | print "# This is node '%s'." % nodename 32 | print "# Using server '%s'." % apiurl 33 | 34 | def jsonfetch(url): 35 | """Fetches the given URL and parses it as JSON, then returns the result.""" 36 | try: 37 | data = urllib.urlopen(url).read() 38 | except AttributeError: 39 | sys.exit(2001) 40 | if data[0] == "(" and data[-1] == ")": 41 | data = data[1:-1] 42 | try: 43 | return json.loads(data) 44 | except ValueError: 45 | if DEBUG: 46 | print extract_django_error(data) 47 | raise ValueError 48 | 49 | # See if we need to download something to render 50 | try: 51 | if SPECIFIC: 52 | print "~ Rendering only graph %s." % SPECIFIC 53 | status = jsonfetch(apiurl % "render/data/%i/?nodename=%s&password=%s" % (SPECIFIC, nodename, nodepwd)) 54 | else: 55 | status = jsonfetch(apiurl % "render/next/?nodename=%s&password=%s" % (nodename, nodepwd)) 56 | 57 | except ValueError: 58 | print "! Garbled server response to 'next render' query." 59 | sys.exit(0) 60 | 61 | except IOError: 62 | print "! Connection error to server" 63 | sys.exit(0) 64 | 65 | if "error" in status: 66 | print "! Error from server: '%s'" % status['error'] 67 | sys.exit(0) 68 | 69 | elif "id" in status: 70 | 71 | try: 72 | id = status['id'] 73 | username = status['username'] 74 | start = status['start'] 75 | end = status['end'] 76 | data = status['data'] 77 | params = status['params'] 78 | colourscheme, detail = params.split("|") 79 | detail = int(detail) 80 | 81 | print "* Rendering graph #%s for '%s' (%.1f weeks)" % (id, username, (end-start)/(86400.0*7.0)) 82 | 83 | # Gather a list of all artists 84 | artists = {} 85 | for week_start, week_end, plays in data: 86 | for artist, play in plays: 87 | try: 88 | artist.encode("utf-8") 89 | artists[artist] = {} 90 | except (UnicodeDecodeError, UnicodeEncodeError): 91 | print "Bad artist!" 92 | 93 | # Now, get that into a set of series 94 | for week_start, week_end, plays in data: 95 | plays = dict(plays) 96 | for artist in artists: 97 | aplays = plays.get(artist, 0) 98 | if aplays < detail: 99 | aplays = 0 100 | artists[artist][week_end] = aplays 101 | 102 | series_set = SeriesSet() 103 | for artist, plays in artists.items(): 104 | series_set.add_series(Series(artist, plays)) 105 | 106 | # Create the output 107 | output = FileOutput() 108 | 109 | import lastgraph_css as style 110 | 111 | # We'll have major lines every integer, and minor ones every half 112 | scale = AutoWeekDateScale(series_set) 113 | 114 | # Choose an appropriate colourscheme 115 | c1, c2 = { 116 | "ocean": ("#334489", "#2d8f3c"), 117 | "blue": ("#264277", "#338a8c"), 118 | "desert": ("#ee6800", "#fce28d"), 119 | "rainbow": ("#ff3333", "#334489"), 120 | "sunset": ("#aa0000", "#ff8800"), 121 | "green": ("#44ff00", "#264277"), 122 | "eclectic": ("#510F7A", "#FFc308"), 123 | }[colourscheme] 124 | 125 | style = style.merge(css.CssStylesheet.from_css("colourer { gradient-start: %s; gradient-end: %s; }" % (c1, c2))) 126 | 127 | # Colour that set! 128 | cr = Colourer(style) 129 | cr.colour(series_set) 130 | 131 | # OK, render that. 132 | wg = WaveGraph(series_set, scale, style, debug=GDEBUG, textfix=True) 133 | lb = Label(username, style) 134 | 135 | width = 30 * len(series_set.keys()) 136 | output.add_item(lb, x=10, y=10, width=width-20, height=20) 137 | output.add_item(wg, x=0, y=40, width=width, height=300) 138 | 139 | # Save the images 140 | 141 | if TEST: 142 | output.write("pdf", "test.pdf") 143 | print "< Wrote output to test.pdf" 144 | else: 145 | pdf_stream = output.stream("pdf") 146 | print "* Rendered PDF" 147 | svgz_stream = output.stream("svgz") 148 | print "* Rendered SVG" 149 | urls = {} 150 | for format in ('svgz', 'pdf'): 151 | filename = 'graph_%s.%s' % (id, format) 152 | fileh = open(os.path.join(local_store, filename), "w") 153 | fileh.write({'svgz': svgz_stream, 'pdf': pdf_stream}[format].read()) 154 | fileh.close() 155 | urls[format] = "%s/%s" % (local_store_url.rstrip("/"), filename) 156 | 157 | print "< Successful. Telling server..." 158 | response = posturl(apiurl % "render/links/", [ 159 | ("nodename", nodename), ("password", nodepwd), ("id", id), 160 | ("pdf_url", urls['pdf']), ("svg_url", urls['svgz']), 161 | ], []) 162 | if DEBUG: 163 | print extract_django_error(response) 164 | print "* Done." 165 | if "pdf_stream" in locals(): 166 | pdf_stream.close() 167 | if "svgz_stream" in locals(): 168 | svgz_stream.close() 169 | 170 | except: 171 | import traceback 172 | traceback.print_exc() 173 | print "< Telling server about error..." 174 | if "pdf_stream" in locals(): 175 | pdf_stream.close() 176 | if "svgz_stream" in locals(): 177 | svgz_stream.close() 178 | try: 179 | jsonfetch(apiurl % "render/failed/?nodename=%s&password=%s&id=%s" % (nodename, nodepwd, id)) 180 | response = posturl(apiurl % "render/failed/", [ 181 | ("nodename", nodename), ("password", nodepwd), ("id", id), 182 | ("traceback", traceback.format_exc()), 183 | ], []) 184 | print "~ Done." 185 | except: 186 | print "! Server notification failed" 187 | sys.exit(0) 188 | 189 | elif "nothing" in status: 190 | if "skipped" in status: 191 | print "~ Server had to skip: %s." % status['skipped'] 192 | else: 193 | print "- No graphs to render." 194 | 195 | if SPECIFIC: 196 | sys.exit(0) 197 | 198 | 199 | if __name__ == "__main__": 200 | render_poster() 201 | -------------------------------------------------------------------------------- /lastrender/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | static_path = os.path.join(os.path.dirname(__file__), "..", "static") 3 | 4 | apiurl = "http://localhost:8000/api/%s" 5 | 6 | local_store = os.path.join(static_path, "graphs") 7 | local_store_url = "http://localhost:8000/static/graphs" 8 | 9 | nodename = "lg" 10 | nodepwd = "lg@home" 11 | -------------------------------------------------------------------------------- /lastslice/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/lastslice/__init__.py -------------------------------------------------------------------------------- /lastslice/longslice.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: HelveticaNeue LT 55 Roman; 3 | } 4 | 5 | colourer { 6 | gradient-start: #334489; 7 | gradient-end: #2d8f3c; 8 | } 9 | 10 | grid.major line { 11 | color: #666; 12 | width: 1; 13 | } 14 | 15 | grid.minor line { 16 | color: #ddd; 17 | width: 1; 18 | } 19 | 20 | grid.major label { 21 | padding: 17; 22 | } 23 | 24 | wavegraph { 25 | dimming-top: 1.5; 26 | dimming-bottom: 0.2; 27 | } 28 | 29 | wavegraph curve { 30 | color: #fff; 31 | } 32 | -------------------------------------------------------------------------------- /lastslice/shortslice.css: -------------------------------------------------------------------------------- 1 | wavegraph grid#x.major line { 2 | color: #0000; 3 | width: 0; 4 | } 5 | wavegraph grid#x.minor line { 6 | color: #0000; 7 | width: 0; 8 | } 9 | wavegraph grid#x.major label { 10 | color: #0000; 11 | } 12 | wavegraph grid#x.minor label { 13 | color: #0000; 14 | } 15 | 16 | -------------------------------------------------------------------------------- /lastslice/slice.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import os 3 | import sys 4 | import web 5 | import time 6 | import random 7 | import datetime 8 | import threading 9 | from StringIO import StringIO 10 | 11 | FILEROOT = os.path.dirname(__file__) 12 | sys.path.insert(0, os.path.join(FILEROOT, "..")) 13 | sys.path.insert(1, os.path.join(FILEROOT, "..", "lib")) 14 | 15 | os.environ['DJANGO_SETTINGS_MODULE'] = "settings" 16 | 17 | from colorsys import * 18 | 19 | from graphication import * 20 | from graphication.wavegraph import WaveGraph 21 | from graphication.color import hex_to_rgba 22 | 23 | from PIL import Image 24 | 25 | import lastslice.shortslice_css as slice_style 26 | import lastslice.longslice_css as long_style 27 | 28 | from lastgui.fetch import fetcher 29 | 30 | from django.core.cache import cache 31 | 32 | 33 | errfile = open("/tmp/sliceerr.txt", "a") 34 | 35 | urls = ( 36 | "/slice/([^/]+)/", "Slice", 37 | "/slice/([^/]+)/(\d+)/(\d+)/", "Slice", 38 | "/slice/([^/]+)/(\d+)/(\d+)/([^/]+)/", "Slice", 39 | "/longslice/([^/]+)/", "LongSlice", 40 | "/longslice/([^/]+)/.pdf", "LongSlicePDF", 41 | "/longslice/([^/]+)/(\d+)/(\d+)/", "LongSlice", 42 | "/longslice/([^/]+)/(\d+)/(\d+)/([^/]+)/", "LongSlice", 43 | "/colours/([^/]+)/", "Colours", 44 | ) 45 | fetcher.debug = False 46 | 47 | class DataError(StandardError): pass 48 | 49 | 50 | def rgba_to_hex(r, g, b, a): 51 | return "%02x%02x%02x%02x" % (r*255,g*255,b*255,a*255) 52 | 53 | 54 | class ThreadedWeek(threading.Thread): 55 | 56 | def __init__(self, user, start, end): 57 | threading.Thread.__init__(self) 58 | self.user = user 59 | self.range = start, end 60 | 61 | def run(self): 62 | self.data = list(fetcher.weekly_artists(self.user, self.range[0], self.range[1])) 63 | 64 | 65 | def get_data(user, length=4): 66 | 67 | cache_key = 'user_%s:%s' % (length, user.replace(" ","+")) 68 | 69 | data = None #cache.get(cache_key) 70 | while data == "locked": 71 | time.sleep(0.01) 72 | 73 | if not data: 74 | 75 | #cache.set(cache_key, "locked", 5) 76 | try: 77 | weeks = list(fetcher.weeks(user)) 78 | except: 79 | import traceback 80 | try: 81 | errfile.write(traceback.format_exc()) 82 | errfile.flush() 83 | except: 84 | pass 85 | return None, None 86 | threads = [ThreadedWeek(user, start, end) for start, end in weeks[-length:]] 87 | for thread in threads: 88 | thread.start() 89 | for thread in threads: 90 | thread.join() 91 | data = ([thread.data for thread in threads], weeks[-length:]) 92 | 93 | #cache.set(cache_key, data, 30) 94 | 95 | return data 96 | 97 | 98 | def get_series(user, length=4, limit=15): 99 | 100 | if ":" in user: 101 | user = user.replace("_", "+") 102 | 103 | data, weeks = get_data(user, length) 104 | 105 | if not data and not weeks: 106 | data, weeks = get_data(user, length) 107 | if not data and not weeks: 108 | data, weeks = get_data(user, length) 109 | if not data and not weeks: 110 | return None 111 | 112 | artists = {} 113 | 114 | for week in data: 115 | for artist, plays in week: 116 | artists[artist] = [] 117 | 118 | for week in data: 119 | week = dict(week) 120 | for artist in artists: 121 | plays = week.get(artist, 0) 122 | if plays < 2: 123 | plays = 0 124 | artists[artist].append(plays) 125 | 126 | artists = artists.items() 127 | artists.sort(key=lambda (x,y):max(y)) 128 | artists.reverse() 129 | 130 | sh, ss, sv = rgb_to_hsv(*hex_to_rgba("ec2d60")[:3]) 131 | eh, es, ev = rgb_to_hsv(*hex_to_rgba("0c4da2")[:3]) 132 | a = True 133 | ad = 0.3 134 | 135 | th, ts, tv = (eh-sh)/float(limit), (es-ss)/float(limit), (ev-sv)/float(limit) 136 | 137 | series_set = SeriesSet() 138 | for artist, data in artists[:15]: 139 | series_set.add_series(Series( 140 | artist, 141 | dict([(datetime.datetime.fromtimestamp(weeks[i][0]), x) for i, x in enumerate(data)]), 142 | rgba_to_hex(*(hsv_to_rgb(sh, ss, sv) + (a and 1 or 1-ad,))), 143 | )) 144 | sh += th 145 | ss += ts 146 | sv += tv 147 | a = not a 148 | ad += (0.6/limit) 149 | 150 | return series_set 151 | 152 | 153 | class Slice(object): 154 | 155 | def GET(self, username, width=230, height=138, labels=False): 156 | web.header("Content-Type", "image/png") 157 | 158 | width = int(width) 159 | height = int(height) 160 | 161 | series_set = get_series(username) 162 | output = FileOutput(padding=0, style=slice_style) 163 | 164 | if series_set: 165 | # Create the output 166 | scale = AutoWeekDateScale(series_set, short_labels=True) 167 | 168 | # OK, render that. 169 | wg = WaveGraph(series_set, scale, slice_style, bool(labels), vertical_scale=False) 170 | output.add_item(wg, x=0, y=0, width=width, height=height) 171 | else: 172 | output.add_item(Label("invalid username"), x=0, y=0, width=width, height=height) 173 | 174 | print output.stream('png').read() 175 | 176 | 177 | class LongSlice(object): 178 | 179 | def GET(self, username, width=1200, height=400, labels=False): 180 | web.header("Content-Type", "image/png") 181 | 182 | width = int(width) 183 | height = int(height) 184 | 185 | series_set = get_series(username, 12, 25) 186 | 187 | # Create the output 188 | output = FileOutput(padding=0, style=long_style) 189 | 190 | if series_set: 191 | scale = AutoWeekDateScale(series_set, year_once=False) 192 | 193 | # OK, render that. 194 | wg = WaveGraph(series_set, scale, long_style, not bool(labels), textfix=True) 195 | output.add_item(wg, x=0, y=0, width=width, height=height) 196 | else: 197 | output.add_item(Label("invalid username"), x=0, y=0, width=width, height=height) 198 | 199 | 200 | 201 | # Load it into a PIL image 202 | img = Image.open(output.stream('png')) 203 | 204 | # Load the watermark 205 | mark = Image.open(os.path.join(os.path.dirname(__file__), "watermark.png")) 206 | 207 | # Combine them 208 | nw, nh = img.size 209 | nh += 40 210 | out = Image.new("RGB", (nw, nh), "White") 211 | out.paste(img, (0,0)) 212 | out.paste(mark, (width-210, height+10)) 213 | 214 | # Stream the result 215 | outf = StringIO() 216 | out.save(outf, "png") 217 | outf.seek(0) 218 | print outf.read() 219 | 220 | 221 | 222 | class LongSlicePDF(object): 223 | 224 | def GET(self, username, width=1200, height=400, labels=False): 225 | web.header("Content-Type", "application/x-pdf") 226 | 227 | width = int(width) 228 | height = int(height) 229 | 230 | series_set = get_series(username, 12, 25) 231 | 232 | # Create the output 233 | output = FileOutput(padding=0, style=long_style) 234 | scale = AutoWeekDateScale(series_set) 235 | 236 | # OK, render that. 237 | wg = WaveGraph(series_set, scale, long_style, not bool(labels), textfix=True) 238 | output.add_item(wg, x=0, y=0, width=width, height=height) 239 | print output.stream('pdf').read() 240 | 241 | 242 | class Colours: 243 | 244 | def GET(self, username): 245 | 246 | series_set = get_series(username) 247 | 248 | for series in series_set: 249 | print "%s,%s" % (series.title, series.color) 250 | 251 | 252 | #web.webapi.internalerror = web.debugerror 253 | if __name__ == "__main__": web.run(urls, globals()) 254 | -------------------------------------------------------------------------------- /lastslice/watermark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/lastslice/watermark.png -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from django.core.management import execute_manager 3 | try: 4 | import settings # Assumed to be in the same directory. 5 | except ImportError: 6 | import sys 7 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) 8 | sys.exit(1) 9 | 10 | if __name__ == "__main__": 11 | execute_manager(settings) 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.4 2 | South 3 | BeautifulSoup 4 | graphication 5 | celery 6 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for lastgraph3 project. 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = DEBUG 5 | 6 | # Get our root, and make sure we can import from it. 7 | import os 8 | import sys 9 | FILEROOT = os.path.dirname(__file__) 10 | sys.path.insert(0, FILEROOT) 11 | sys.path.insert(1, os.path.join(FILEROOT, "lib")) 12 | 13 | ADMINS = ( 14 | ('Andrew Godwin', 'andrew@aeracode.org'), 15 | ) 16 | 17 | MANAGERS = ADMINS 18 | 19 | # Local time zone for this installation. Choices can be found here: 20 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 21 | # although not all choices may be avilable on all operating systems. 22 | # If running in a Windows environment this must be set to the same as your 23 | # system time zone. 24 | TIME_ZONE = 'UTC' 25 | 26 | # Language code for this installation. All choices can be found here: 27 | # http://www.i18nguy.com/unicode/language-identifiers.html 28 | LANGUAGE_CODE = 'en-us' 29 | 30 | SITE_ID = 1 31 | 32 | # How long until we try to refresh a UserHistory 33 | HISTORY_TTL = 86000 34 | 35 | # If you set this to False, Django will make some optimizations so as not 36 | # to load the internationalization machinery. 37 | USE_I18N = True 38 | 39 | # Absolute path to the directory that holds media. 40 | # Example: "/home/media/media.lawrence.com/" 41 | MEDIA_ROOT = '' 42 | 43 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 44 | # trailing slash if there is a path component (optional in other cases). 45 | # Examples: "http://media.lawrence.com", "http://example.com/media/" 46 | MEDIA_URL = '' 47 | 48 | # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a 49 | # trailing slash. 50 | # Examples: "http://foo.com/media/", "/media/". 51 | ADMIN_MEDIA_PREFIX = '/static/admin/' 52 | 53 | STATIC_URL = '/static/' 54 | 55 | # List of callables that know how to import templates from various sources. 56 | TEMPLATE_LOADERS = ( 57 | 'django.template.loaders.filesystem.Loader', 58 | 'django.template.loaders.app_directories.Loader' 59 | ) 60 | 61 | MIDDLEWARE_CLASSES = ( 62 | 'django.middleware.cache.CacheMiddleware', 63 | 'django.middleware.common.CommonMiddleware', 64 | 'django.contrib.sessions.middleware.SessionMiddleware', 65 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 66 | 'django.middleware.doc.XViewMiddleware', 67 | 'django.contrib.messages.middleware.MessageMiddleware', 68 | ) 69 | 70 | CACHE_MIDDLEWARE_SECONDS = 60 71 | CACHE_MIDDLEWARE_KEY_PREFIX = "lg3" 72 | 73 | ROOT_URLCONF = 'urls' 74 | 75 | TEMPLATE_DIRS = ( 76 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 77 | # Always use forward slashes, even on Windows. 78 | # Don't forget to use absolute paths, not relative paths. 79 | ) 80 | 81 | INSTALLED_APPS = ( 82 | 'django.contrib.auth', 83 | 'django.contrib.contenttypes', 84 | 'django.contrib.sessions', 85 | 'django.contrib.sites', 86 | 'django.contrib.admin', 87 | "south", 88 | 'lastgui', 89 | ) 90 | 91 | TEMPLATE_CONTEXT_PROCESSORS = ( 92 | "django.contrib.auth.context_processors.auth", 93 | "django.core.context_processors.debug", 94 | "django.core.context_processors.i18n", 95 | "django.core.context_processors.media", 96 | "shortcuts.contexter", 97 | ) 98 | 99 | DATABASES = { 100 | "default": { 101 | "ENGINE": "django.db.backends.postgresql_psycopg2", 102 | "NAME": "lastgraph", 103 | "USER": "postgres", 104 | } 105 | } 106 | 107 | USER_DATA_ROOT = os.path.join(FILEROOT, "static", "data") 108 | 109 | LASTFM_DELAY = 0.2 110 | 111 | 112 | try: 113 | import cairo 114 | except ImportError: 115 | print "You must install pycairo." 116 | sys.exit(1) 117 | 118 | 119 | try: 120 | from local_settings import * 121 | except ImportError: 122 | pass 123 | -------------------------------------------------------------------------------- /shortcuts.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.template.loader import select_template 4 | from django.template import RequestContext 5 | from django.http import HttpResponse, HttpResponseRedirect as redirect 6 | from django.shortcuts import get_object_or_404 7 | from django.views.decorators.cache import cache_page 8 | from django.http import Http404 9 | 10 | 11 | def render(request, template, params={}): 12 | ctx = RequestContext(request, params) 13 | 14 | if not isinstance(template, (list, tuple)): 15 | template = [template] 16 | 17 | return HttpResponse(select_template(template).render(ctx)) 18 | 19 | 20 | def jsonify(object): 21 | return HttpResponse("(%s)" % json.dumps(object)) 22 | 23 | 24 | def plaintext(string): 25 | return HttpResponse(unicode(string)) 26 | 27 | 28 | def flash(request, message): 29 | request.session['flashes'] = request.session.get('flashes', []) + [message] 30 | 31 | 32 | def get_flashes(request): 33 | flashes = request.session.get('flashes', []) 34 | request.session['flashes'] = [] 35 | return flashes 36 | 37 | 38 | def contexter(request): 39 | return { 40 | "flashes": get_flashes(request), 41 | } 42 | -------------------------------------------------------------------------------- /static/css/date_input.css: -------------------------------------------------------------------------------- 1 | /* Some resets for compatibility with existing CSS */ 2 | .date_selector, .date_selector * { 3 | width: auto; 4 | height: auto; 5 | border: none; 6 | background: none; 7 | margin: 0; 8 | padding: 0; 9 | text-align: left; 10 | text-decoration: none; 11 | } 12 | .date_selector { 13 | background: #F2F2F2; 14 | border: 1px solid #bbb; 15 | padding: 5px; 16 | margin: -1px 0 0 0; 17 | } 18 | .date_selector .month_nav { 19 | margin: 0 0 5px 0; 20 | padding: 0; 21 | display: block; 22 | } 23 | .date_selector .month_name { 24 | font-weight: bold; 25 | line-height: 20px; 26 | display: block; 27 | text-align: center; 28 | } 29 | .date_selector .month_nav a { 30 | display: block; 31 | position: absolute; 32 | top: 5px; 33 | width: 20px; 34 | height: 20px; 35 | line-height: 17px; 36 | font-weight: bold; 37 | color: #003C78; 38 | text-align: center; 39 | font-size: 120%; 40 | overflow: hidden; 41 | } 42 | .date_selector .month_nav a:hover, .date_selector .month_nav a:focus { 43 | background: none; 44 | color: #003C78; 45 | text-decoration: none; 46 | } 47 | .date_selector .prev { 48 | left: 5px; 49 | } 50 | .date_selector .next { 51 | right: 5px; 52 | } 53 | .date_selector table { 54 | border-spacing: 0; 55 | border-collapse: collapse; 56 | } 57 | .date_selector th, .date_selector td { 58 | width: 2.5em; 59 | height: 2em; 60 | padding: 0; 61 | text-align: center; 62 | } 63 | .date_selector td { 64 | border: 1px solid #ccc; 65 | line-height: 2em; 66 | text-align: center; 67 | white-space: nowrap; 68 | background: white; 69 | } 70 | .date_selector td.today { 71 | background: #FFFED9; 72 | } 73 | .date_selector td.unselected_month { 74 | color: #ccc; 75 | } 76 | .date_selector td a { 77 | display: block; 78 | text-decoration: none !important; 79 | width: 100%; 80 | height: 100%; 81 | line-height: 2em; 82 | color: #003C78; 83 | text-align: center; 84 | } 85 | .date_selector td.today a { 86 | background: #FFFEB3; 87 | } 88 | .date_selector td.selected a { 89 | background: #D8DFE5; 90 | font-weight: bold; 91 | } 92 | .date_selector td a:hover { 93 | background: #003C78; 94 | color: white; 95 | } 96 | -------------------------------------------------------------------------------- /static/css/style.css: -------------------------------------------------------------------------------- 1 | 2 | /* Reset */ 3 | 4 | body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,form,fieldset,input,textarea,p,blockquote,th,td { 5 | margin:0; 6 | padding:0; 7 | } 8 | table { 9 | border-collapse:collapse; 10 | border-spacing:0; 11 | } 12 | fieldset,img { 13 | border:0; 14 | } 15 | address,caption,cite,code,dfn,em,strong,th,var { 16 | font-style:normal; 17 | font-weight:normal; 18 | } 19 | ol,ul { 20 | list-style:none; 21 | } 22 | caption,th { 23 | text-align:left; 24 | } 25 | h1,h2,h3,h4,h5,h6 { 26 | font-size:100%; 27 | font-weight:normal; 28 | } 29 | q:before,q:after { 30 | content:''; 31 | } 32 | abbr,acronym { border:0; 33 | } 34 | 35 | /* Main page */ 36 | 37 | body { 38 | font-family: sans-serif; 39 | background: #fff url(../images/splash.png) repeat-x 50% 0%; /*#1e2a6e*/ 40 | overflow: hidden; 41 | } 42 | 43 | #logo { 44 | position: absolute; 45 | top: 20px; 46 | left: 20px; 47 | } 48 | 49 | div.input { 50 | position: absolute; 51 | top: 85px; 52 | left: 0px; 53 | color: #fff; 54 | background: url(../images/stripbg.png) repeat-x 0 50%; 55 | width: 100%; 56 | height: 30px; 57 | overflow: hidden; 58 | } 59 | input#id_username { 60 | background: #fff repeat-x 0 50%; 61 | color: #111; 62 | font-size: 20px; 63 | padding: 3px; 64 | height: 24px; 65 | border: 0; 66 | width: 300px; 67 | color: #333; 68 | float: left; 69 | } 70 | input#id_username.default { 71 | color: #999; 72 | } 73 | input.cap { 74 | height: 30px; 75 | } 76 | #stats { 77 | background: transparent url(../images/cap_left.png) no-repeat 0 50%; 78 | font-size: 16px; 79 | padding: 0 0 0 25px; 80 | float: left; 81 | line-height: 30px; 82 | } 83 | #stats em { 84 | font-weight: bold; 85 | font-size: 110%; 86 | } 87 | 88 | 89 | .fauxlink { 90 | cursor: pointer; 91 | } 92 | 93 | 94 | #menu { 95 | position: absolute; 96 | top: 55px; 97 | right: 15px; 98 | } 99 | #menu li { 100 | display: inline; 101 | margin-left: 20px; 102 | } 103 | #menu li a { 104 | color: #fff; 105 | text-decoration: none; 106 | font-size: 110%; 107 | } 108 | #menu li a:hover { 109 | border-bottom: 1px solid #fff; 110 | } 111 | 112 | 113 | #minimenu { 114 | position: absolute; 115 | top: 10px; 116 | right: 15px; 117 | } 118 | #minimenu li { 119 | display: inline; 120 | margin-left: 10px; 121 | } 122 | #minimenu li a { 123 | color: #fff; 124 | text-decoration: none; 125 | font-size: 90%; 126 | } 127 | #minimenu li a:hover { 128 | border-bottom: 1px solid #fff; 129 | } 130 | #minimenu li a.beta { 131 | color: #faa; 132 | } 133 | #minimenu li a.beta:hover { 134 | border-bottom: 1px solid #faa; 135 | } 136 | 137 | 138 | #loading { 139 | position: absolute; 140 | right: -110px; 141 | width: 110px; 142 | height: 30px; 143 | top: 85px; 144 | background: transparent url(../images/loadingbg.png) no-repeat 0 50%; 145 | line-height: 30px; 146 | color: #555; 147 | font-size: 13px; 148 | padding-left: 25px; 149 | } 150 | #loading_text { 151 | background: url(../images/spinner.gif) no-repeat 0 50%; 152 | padding-left: 23px; 153 | } 154 | 155 | 156 | #content { 157 | position: absolute; 158 | left: 0px; 159 | width: 100%; 160 | bottom: 50px; 161 | top: 115px; 162 | overflow: auto; 163 | background: #fff url(../images/contentbg.png) repeat-x top left; 164 | color: #333; 165 | } 166 | #content a { 167 | color: #369; 168 | } 169 | #content h1 { 170 | font-size: 180%; 171 | margin-bottom: 0.2em; 172 | color: #777; 173 | } 174 | #content h2 { 175 | font-size: 120%; 176 | margin-bottom: 0.5em; 177 | margin-top: 1em; 178 | color: #555; 179 | } 180 | #content h3 { 181 | font-size: 120%; 182 | font-weight: bold; 183 | color: #666; 184 | margin: 0.4em 0 0.6em 0; 185 | } 186 | #content p { 187 | line-height: 1.5em; 188 | font-size: 90%; 189 | margin: 0 0 1em 0; 190 | } 191 | #content .padded { 192 | padding: 1.5em 2em 1em 2em; 193 | max-width: 650px; 194 | } 195 | #content .padded.wide { 196 | max-width: none; 197 | } 198 | #content div.feature { 199 | float: left; 200 | margin-right: 25px; 201 | } 202 | #content div.feature img { 203 | border: 1px solid #aaa; 204 | border-width: 1px 0; 205 | width: 500px; 206 | } 207 | #content div.feature p { 208 | text-align: right; 209 | font-size: 80%; 210 | color: #777; 211 | width: 500px; 212 | margin-top: 4px; 213 | } 214 | #content div.sfeature { 215 | float: left; 216 | margin: 20px; 217 | text-align: center; 218 | } 219 | #content div.sfeature a { 220 | color: #567; 221 | } 222 | #content .after { 223 | clear: both; 224 | } 225 | #content ul.nice { 226 | padding-left: 3em; 227 | list-style-type: square; 228 | font-size: 90%; 229 | margin: 0 0 1em 0; 230 | } 231 | #content .notice { 232 | background: #eef; 233 | border: 1px solid #dde; 234 | padding: 0.5em; 235 | font-size: 90%; 236 | margin: 1em; 237 | } 238 | #content .undersub { 239 | font-size: 80%; 240 | color: #666; 241 | margin-top: 3px; 242 | } 243 | .nothing { 244 | padding: 0.5em 1em; 245 | font-style: italic; 246 | color: #888; 247 | } 248 | #content ul.colours li { 249 | line-height: 20px; 250 | margin: 10px; 251 | } 252 | #content ul.colours li img { 253 | border: 1px solid #444; 254 | } 255 | #content ul.colours li * { 256 | vertical-align: middle; 257 | } 258 | #content h1.graphed { 259 | font-size: 30px; 260 | padding: 15px 0 5px 7px; 261 | line-height: 40px; 262 | color: #555; 263 | border-bottom: 1px solid #ddd; 264 | background: no-repeat bottom left; 265 | } 266 | #content h1.graphed small { 267 | font-size: 15px; 268 | line-height: 20px; 269 | color: #777; 270 | font-weight: bold; 271 | } 272 | 273 | 274 | #artists { 275 | margin-top: 10px; 276 | width: 85%; 277 | } 278 | #artists td { 279 | padding: 5px 10px; 280 | font-size: 12px; 281 | color: #222; 282 | } 283 | #artists td.name { 284 | text-align: right; 285 | border-right: 1px solid #444; 286 | width: 200px; 287 | cursor: pointer; 288 | padding: 0; 289 | } 290 | #artists td.name a { 291 | display: block; 292 | padding: 5px 10px; 293 | text-decoration: none; 294 | color: #333; 295 | border-top: 1px dotted #aaa; 296 | } 297 | #artists td.name a:hover { 298 | /* background: url(../images/highlight_left.png) repeat-y top right; */ 299 | background: #ddf; 300 | } 301 | #artists td.plays { 302 | padding: 2px 0; 303 | } 304 | #artists td.plays div { 305 | background: transparent url(../images/bar_end.png) no-repeat top right; 306 | color: #eee; 307 | display: block; 308 | padding: 2px 0 2px 0; 309 | height: 15px; 310 | min-width: 40px !important; 311 | } 312 | #artists td.plays div span { 313 | padding: 0 10px; 314 | } 315 | 316 | #posters { 317 | margin: 10px 20px; 318 | } 319 | #posters th { 320 | font-weight: bold; 321 | border-bottom: 1px solid #aaa; 322 | padding: 0.2em 0.5em; 323 | } 324 | #posters th.id { 325 | text-align: center; 326 | } 327 | #posters td { 328 | padding: 0.3em 0.5em; 329 | } 330 | #posters td.id { 331 | font-size: 170%; 332 | } 333 | #posters td.dates { 334 | font-size: 80%; 335 | color: #888; 336 | } 337 | #posters td.dates b { 338 | font-weight: normal; 339 | color: #444; 340 | } 341 | 342 | .flashes { 343 | margin: -0.5em 2em 1em 2em; 344 | font-size: 90%; 345 | background-color: #edd; 346 | padding: 0.2em 0.4em; 347 | } 348 | 349 | #newposterform { 350 | padding: 0.5em 1em 0.5em 0em; 351 | margin: -0.5em 0 0.5em 2em; 352 | line-height: 1.6em; 353 | border-left: 1px solid #aaa; 354 | } 355 | #newposterform label { 356 | margin: 0 0.3em 0 1em; 357 | } 358 | #id_style optgroup { 359 | background: url(../images/spinner.gif) no-repeat 2px 50%; 360 | } 361 | #newposterform .submit { 362 | position: relative; 363 | top: 0.7em; 364 | font-size: 100%; 365 | margin-left: 0.5em; 366 | } 367 | #newposterform ul.errorlist { 368 | display: inline; 369 | } 370 | #newposterform ul.errorlist li { 371 | display: inline; 372 | font-size: 80%; 373 | color: #a00; 374 | } 375 | 376 | ul.files { 377 | margin: 0 0 0 2.5em; 378 | } 379 | ul.files li { 380 | padding: 0.2em 0; 381 | font-size: 90%; 382 | } 383 | ul.files li a { 384 | background: url(../images/page.png) no-repeat 0 50%; 385 | padding-left: 22px; 386 | color: #333; 387 | text-decoration: none; 388 | } 389 | ul.files li.xls a { 390 | background-image: url(../images/page_excel.png); 391 | } 392 | ul.files li.csv a { 393 | background-image: url(../images/page_white_text.png); 394 | } 395 | 396 | div.export { 397 | float: right; 398 | margin: 1em; 399 | color: #888; 400 | font-size: 80%; 401 | line-height: 1em; 402 | } 403 | div.export * { 404 | vertical-align: middle; 405 | padding-left: 2px; 406 | } 407 | 408 | #footer { 409 | background: #204a87 url(../images/footerbg.png) repeat-x top left; 410 | position: absolute; 411 | bottom: 0px; 412 | left: 0px; 413 | width: 100%; 414 | height: 50px; 415 | color: #666; 416 | border-top: 1px solid #036; 417 | } 418 | #footer .aera { 419 | margin: 12px 0 0 15px; 420 | } 421 | #footer .last { 422 | float: right; 423 | clear: right; 424 | margin: 6px 10px 0 20px; 425 | } 426 | #footer a { 427 | color: #929fa9; 428 | text-decoration: none; 429 | font-size: 80%; 430 | } -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/favicon.ico -------------------------------------------------------------------------------- /static/graphs/graph_1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/graphs/graph_1.pdf -------------------------------------------------------------------------------- /static/graphs/graph_1.svgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/graphs/graph_1.svgz -------------------------------------------------------------------------------- /static/images/aera_footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/aera_footer.png -------------------------------------------------------------------------------- /static/images/aera_footer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 23 | 25 | 44 | 46 | 47 | 49 | image/svg+xml 50 | 52 | 53 | 54 | 55 | 60 | 73 | 80 | 87 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /static/images/artist_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/artist_graph.png -------------------------------------------------------------------------------- /static/images/artists.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/artists.png -------------------------------------------------------------------------------- /static/images/artists_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/artists_64.png -------------------------------------------------------------------------------- /static/images/bar_end.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/bar_end.png -------------------------------------------------------------------------------- /static/images/cap_left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/cap_left.png -------------------------------------------------------------------------------- /static/images/colours/blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/colours/blue.png -------------------------------------------------------------------------------- /static/images/colours/desert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/colours/desert.png -------------------------------------------------------------------------------- /static/images/colours/green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/colours/green.png -------------------------------------------------------------------------------- /static/images/colours/ocean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/colours/ocean.png -------------------------------------------------------------------------------- /static/images/colours/rainbow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/colours/rainbow.png -------------------------------------------------------------------------------- /static/images/colours/sunset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/colours/sunset.png -------------------------------------------------------------------------------- /static/images/contentbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/contentbg.png -------------------------------------------------------------------------------- /static/images/export_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/export_64.png -------------------------------------------------------------------------------- /static/images/footerbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/footerbg.png -------------------------------------------------------------------------------- /static/images/highlight_left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/highlight_left.png -------------------------------------------------------------------------------- /static/images/hotlink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/hotlink.png -------------------------------------------------------------------------------- /static/images/hotlink.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 21 | 23 | 30 | 31 | 49 | 51 | 52 | 54 | image/svg+xml 55 | 57 | 58 | 59 | 60 | 64 | 68 | no hotlinking 79 | 80 | 81 | -------------------------------------------------------------------------------- /static/images/inputbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/inputbg.png -------------------------------------------------------------------------------- /static/images/inputbg_plain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/inputbg_plain.png -------------------------------------------------------------------------------- /static/images/inputcap.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | 26 | 28 | 32 | 36 | 37 | 47 | 48 | 70 | 72 | 73 | 75 | image/svg+xml 76 | 78 | 79 | 80 | 81 | 85 | 89 | 96 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /static/images/lastfm_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/lastfm_64.png -------------------------------------------------------------------------------- /static/images/loadingbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/loadingbg.png -------------------------------------------------------------------------------- /static/images/logo_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/logo_large.png -------------------------------------------------------------------------------- /static/images/logo_med.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/logo_med.png -------------------------------------------------------------------------------- /static/images/menubg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/menubg.png -------------------------------------------------------------------------------- /static/images/page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/page.png -------------------------------------------------------------------------------- /static/images/page_excel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/page_excel.png -------------------------------------------------------------------------------- /static/images/page_white_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/page_white_text.png -------------------------------------------------------------------------------- /static/images/poster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/poster.png -------------------------------------------------------------------------------- /static/images/poster.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | 26 | 33 | 44 | 47 | 51 | 55 | 56 | 67 | 69 | 73 | 77 | 81 | 82 | 92 | 94 | 98 | 102 | 103 | 111 | 115 | 119 | 120 | 128 | 132 | 136 | 137 | 139 | 143 | 147 | 148 | 150 | 154 | 158 | 159 | 161 | 165 | 169 | 173 | 174 | 185 | 196 | 207 | 208 | 229 | 231 | 232 | 234 | image/svg+xml 235 | 237 | New Document 238 | 239 | 240 | Jakub Steiner 241 | 242 | 243 | http://jimmac.musichall.cz 244 | 246 | 247 | 249 | 251 | 253 | 255 | 257 | 259 | 261 | 262 | 263 | 264 | 268 | 273 | 278 | 282 | 289 | 294 | 299 | 300 | 309 | 318 | 323 | 328 | 333 | 341 | 349 | 357 | 358 | 359 | -------------------------------------------------------------------------------- /static/images/posters_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/posters_64.png -------------------------------------------------------------------------------- /static/images/premium_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/premium_64.png -------------------------------------------------------------------------------- /static/images/reset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/reset.png -------------------------------------------------------------------------------- /static/images/sigs_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/sigs_64.png -------------------------------------------------------------------------------- /static/images/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/spinner.gif -------------------------------------------------------------------------------- /static/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/splash.png -------------------------------------------------------------------------------- /static/images/splash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | 26 | 28 | 32 | 36 | 37 | 39 | 43 | 47 | 48 | 50 | 54 | 58 | 59 | 70 | 80 | 81 | 105 | 107 | 108 | 110 | image/svg+xml 111 | 113 | 114 | 115 | 116 | 120 | 127 | 130 | last 141 | graph 152 | 153 | 160 | 162 | 166 | 170 | 171 | 174 | last 185 | graph 196 | 3 207 | 208 | 215 | 223 | 232 | 242 | 249 | 256 | 257 | 258 | -------------------------------------------------------------------------------- /static/images/stripbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/stripbg.png -------------------------------------------------------------------------------- /static/images/timeline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/timeline.png -------------------------------------------------------------------------------- /static/images/timeline2_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/timeline2_64.png -------------------------------------------------------------------------------- /static/images/timeline_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/timeline_64.png -------------------------------------------------------------------------------- /static/images/wavegraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/lastgraph/11384ac9e70195b5e0a052f0d52185a90e852612/static/images/wavegraph.png -------------------------------------------------------------------------------- /static/js/jquery.date_input.pack.js: -------------------------------------------------------------------------------- 1 | eval(function(p,a,c,k,e,d){e=function(c){return(c35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--){d[e(c)]=k[c]||e(c)}k=[function(e){return d[e]}];e=function(){return'\\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c])}}return p}('r=(8($){8 r(1t,n){e(1D(n)!="2v")n={};$.2w(5,r.1s,n);5.g=$(1t);5.1X("U","F","W","E","15","11");5.1x();5.E();5.F()};r.1s={1k:["2y","2u","2t","2p","1w","2q","2r","2s","2z","2A","2H","2I"],1b:["2J","2G","2F","2B","1w","2o","2D","2E","2K","2c","2d","2b"],1i:["2l","2f","2k","2j","2g","2i","2m"],A:1};r.2h={1x:8(){5.1n=$(\'<1p u="H">\');b 1J=$(\'

\').K($(\'&2e;\').G(5.15)," ",5.1n," ",$(\'&2S;\').G(5.11));b Z="<1H><1L>";$(5.27(5.1i)).25(8(){Z+="<1u>"+5+""});Z+="";5.o=5.B=$(\'<1G u="38">\').y({S:"17",1E:"1A",1z:39}).K(1J,Z).2L(1a.10);e($.1K.37&&$.1K.34<7){5.R=$(\'<1F u="35" 3a="0" 3c="#">\').y({1E:"1A",S:"17",1z:3f}).3d(5.o);5.B=5.B.36(5.R)};5.m=$("m",5.o);5.g.1y(5.P(8(){5.E()}))},Q:8(6){5.19=6;b v=5.v(6),Y=5.Y(6);b 1C=5.1Z(v,Y);b C="";T(b i=0;i<=1C;i++){b q=k h(v.l(),v.f(),v.t()+i);e(5.22(q))C+="";e(q.f()==6.f()){C+=\'\'+q.t()+\'\'}13{C+=\'\'+q.t()+\'\'};e(5.29(q))C+=""};5.1n.1I().K(5.1R(6)+" "+6.l());5.m.1I().K(C);$("a",5.m).G(5.P(8(j){5.E(5.1m($(j.1M).2P().2M("6")));5.F();9 16}));$("w[6="+5.I(k h())+"]",5.m).1B("2N")},E:8(6){e(1D(6)=="2O"){6=5.1m(5.g.14())};e(6){5.2T=6;5.Q(6);b X=5.I(6);$(\'w[6=\'+X+\']\',5.m).1B("2U");e(5.g.14()!=X){5.g.14(X).1y()}}13{5.Q(k h())}},U:8(){5.B.y("S","31");5.23();5.g.1v("1r",5.U);$([1q,1a.10]).G(5.W)},F:8(){5.B.y("S","17");$([1q,1a.10]).1v("G",5.W);5.g.1r(5.U)},W:8(j){e(j.1M!=5.g[0]&&!5.1S(j)){5.F()}},1m:8(28){b D;e(D=28.2W(/^(\\d{1,2}) ([^\\s]+) (\\d{4,4})$/)){9 k h(D[3],5.1W(D[2]),D[1])}13{9 2V}},I:8(6){9 6.t()+" "+5.1b[6.f()]+" "+6.l()},23:8(){b c=5.g.c();5.B.y({O:c.O+5.g.18(),N:c.N});e(5.R){5.R.y({2Y:5.o.21(),2Z:5.o.18()})}},12:8(20){5.Q(k h(5.19.30(5.19.f()+20)))},15:8(){5.12(-1);9 16},11:8(){5.12(1);9 16},1R:8(6){9 5.1k[6.f()]},1S:8(j){b c=5.o.c();c.1O=c.N+5.o.21();c.1P=c.O+5.o.18();9 j.1Nc.O&&j.1Uc.N},P:8(1d){b 1Y=5;9 8(){9 1d.2Q(1Y,L)}},1X:8(){T(b i=0;i35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--)r[e(c)]=k[c]||e(c);k=[function(e){return r[e]}];e=function(){return'\\w+'};c=1};while(c--)if(k[c])p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c]);return p}('(5($){$.19={P:\'1.2\'};$.u([\'j\',\'w\'],5(i,d){$.q[\'O\'+d]=5(){p(!3[0])6;g a=d==\'j\'?\'s\':\'m\',e=d==\'j\'?\'D\':\'C\';6 3.B(\':y\')?3[0][\'L\'+d]:4(3,d.x())+4(3,\'n\'+a)+4(3,\'n\'+e)};$.q[\'I\'+d]=5(b){p(!3[0])6;g c=d==\'j\'?\'s\':\'m\',e=d==\'j\'?\'D\':\'C\';b=$.F({t:Z},b||{});g a=3.B(\':y\')?3[0][\'8\'+d]:4(3,d.x())+4(3,\'E\'+c+\'w\')+4(3,\'E\'+e+\'w\')+4(3,\'n\'+c)+4(3,\'n\'+e);6 a+(b.t?(4(3,\'t\'+c)+4(3,\'t\'+e)):0)}});$.u([\'m\',\'s\'],5(i,b){$.q[\'l\'+b]=5(a){p(!3[0])6;6 a!=W?3.u(5(){3==h||3==r?h.V(b==\'m\'?a:$(h)[\'U\'](),b==\'s\'?a:$(h)[\'T\']()):3[\'l\'+b]=a}):3[0]==h||3[0]==r?S[(b==\'m\'?\'R\':\'Q\')]||$.N&&r.M[\'l\'+b]||r.A[\'l\'+b]:3[0][\'l\'+b]}});$.q.F({z:5(){g a=0,f=0,o=3[0],8,9,7,v;p(o){7=3.7();8=3.8();9=7.8();8.f-=4(o,\'K\');8.k-=4(o,\'J\');9.f+=4(7,\'H\');9.k+=4(7,\'Y\');v={f:8.f-9.f,k:8.k-9.k}}6 v},7:5(){g a=3[0].7;G(a&&(!/^A|10$/i.16(a.15)&&$.14(a,\'z\')==\'13\'))a=a.7;6 $(a)}});5 4(a,b){6 12($.11(a.17?a[0]:a,b,18))||0}})(X);',62,72,'|||this|num|function|return|offsetParent|offset|parentOffset|||||borr|top|var|window||Height|left|scroll|Left|padding|elem|if|fn|document|Top|margin|each|results|Width|toLowerCase|visible|position|body|is|Right|Bottom|border|extend|while|borderTopWidth|outer|marginLeft|marginTop|client|documentElement|boxModel|inner|version|pageYOffset|pageXOffset|self|scrollTop|scrollLeft|scrollTo|undefined|jQuery|borderLeftWidth|false|html|curCSS|parseInt|static|css|tagName|test|jquery|true|dimensions'.split('|'),0,{})) -------------------------------------------------------------------------------- /static/js/main.js: -------------------------------------------------------------------------------- 1 | 2 | window.lastgraph = { 3 | 4 | displayMessage: function (msg) { 5 | $("#stats").html(msg); 6 | }, 7 | 8 | getMessage: function (msg) { 9 | return $("#stats").html(); 10 | }, 11 | 12 | updateQueue: function () { 13 | $.getJSON("/ajax/" + lastgraph.username + "/queuepos/", function (pos) { 14 | if (pos === null) { 15 | lastgraph.checkUsername(true); 16 | } else { 17 | lastgraph.drawQueue(pos); 18 | lastgraph.updateTimeout = setTimeout(lastgraph.updateQueue, 15000); 19 | } 20 | }); 21 | }, 22 | 23 | drawQueue: function (pos) { 24 | lastgraph.displayMessage("Fetching your data; you're in queue position " + pos + ""); 25 | }, 26 | 27 | checkUsername: function (skipChanges) { 28 | if (!skipChanges) { 29 | lastgraph.displayMessage("Hold on while we check that username..."); 30 | } 31 | lastgraph.showLoading(); 32 | lastgraph.username = $("#id_username").val(); 33 | $.getJSON("/ajax/" + lastgraph.username + "/ready/", function (ok) { 34 | if (!!ok) { 35 | lastgraph.initUser(ok); 36 | } else if (ok === 0) { 37 | lastgraph.hideLoading(); 38 | lastgraph.displayMessage("That username has no played tracks."); 39 | } else if (ok === false) { 40 | lastgraph.showLoading("Fetching..."); 41 | if (!!lastgraph.updateTimeout) { 42 | clearTimeout(lastgraph.updateTimeout); 43 | } 44 | lastgraph.updateQueue(); 45 | } else { 46 | lastgraph.hideLoading(); 47 | lastgraph.displayMessage("That doesn't seem to be a last.fm username."); 48 | } 49 | }); 50 | }, 51 | 52 | initUser: function (numWeeks) { // We have a valid, up to date username - now go! 53 | // Jump to their user page 54 | document.location.href = "/user/" + lastgraph.username + "/"; 55 | }, 56 | 57 | showLoading: function (text) { 58 | if (!text) { text = "Loading..."; } 59 | $("#loading_text").html(text); 60 | $("#loading").animate({right: 0}, 500); 61 | }, 62 | 63 | hideLoading: function () { 64 | $("#loading").animate({right: -110}, 500); 65 | }, 66 | 67 | resizeGraphs: function () { 68 | // Hook up graphed H1s 69 | $("h1.graphed").each(function () { 70 | var self = $(this); 71 | this.style.backgroundImage = "url(/graph/"+lastgraph.username+"/timeline-basic/"+self.innerWidth()+"/"+self.innerHeight()+"/)"; 72 | }) 73 | // Resize resizeable graphs 74 | $("img.resizeable_graph").each(function () { 75 | var self = $(this); 76 | if (!this.base_src) this.base_src = this.src; 77 | this.src = this.base_src + self.parent().innerWidth() + "/" + 300 /*self.parent().innerHeight()*/ + "/"; 78 | }) 79 | } 80 | 81 | } 82 | 83 | $(function () { 84 | 85 | // Make the username box have a default content, 86 | // and hook up its enter functions 87 | $("#id_username").focus(function (e) { 88 | // On focus: clear the box and change the msg if we're 'empty' 89 | if (this.defaulted) { 90 | this.value = ""; 91 | lastgraph.displayMessage("When you're ready, hit return."); 92 | this.defaulted = false; 93 | $(this).removeClass("default"); 94 | } 95 | }). 96 | blur(function (e) { 97 | // On blur: if empty, display a default value, etc. 98 | if (!this.default_value) { 99 | this.default_value = this.value; 100 | this.default_message = lastgraph.getMessage(); 101 | this.value = ""; 102 | } 103 | if (!this.value) { 104 | this.value = this.default_value; 105 | lastgraph.displayMessage(this.default_message); 106 | this.defaulted = true; 107 | $(this).addClass("default"); 108 | } 109 | }). 110 | blur(). // We start off empty, so fill in default 111 | keydown(function (e) { 112 | // If they pressed enter, we want to fetch their status 113 | if(e.keyCode == 13) { 114 | lastgraph.checkUsername(); 115 | } 116 | }); 117 | 118 | // Change DateInput to use YYYY/MM/DD (thanks Jon) 119 | $.extend(DateInput.DEFAULT_OPTS, { 120 | stringToDate: function(string) { 121 | var matches; 122 | if (matches = string.match(/^(\d{4,4})\/(\d{2,2})\/(\d{2,2})$/)) { 123 | return new Date(matches[1], matches[2] - 1, matches[3]); 124 | } else { 125 | return null; 126 | }; 127 | }, 128 | 129 | dateToString: function(date) { 130 | var month = (date.getMonth() + 1).toString(); 131 | var dom = date.getDate().toString(); 132 | if (month.length == 1) month = "0" + month; 133 | if (dom.length == 1) dom = "0" + dom; 134 | return date.getFullYear() + "/" + month + "/" + dom; 135 | } 136 | }); 137 | 138 | lastgraph.resizeGraphs(); 139 | $(window).bind("resize", lastgraph.resizeGraphs); 140 | 141 | $("#id_start, #id_end").date_input(); 142 | 143 | }); 144 | 145 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | 2 | import urllib 3 | from lastgui.fetch import * 4 | 5 | print update_user("andygodwin") -------------------------------------------------------------------------------- /urls.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | 4 | from django.conf.urls.defaults import * 5 | from django.conf import settings 6 | from django.contrib import admin 7 | 8 | admin.autodiscover() 9 | 10 | urlpatterns = patterns('', 11 | (r'^admin/', include(admin.site.urls)), 12 | ) 13 | 14 | if settings.DEBUG: 15 | urlpatterns += patterns('', 16 | # Static content 17 | (r'^static/(.*)$', 'django.views.static.serve', {'document_root': os.path.join(settings.FILEROOT, "static")}), 18 | ) 19 | 20 | urlpatterns += patterns('', 21 | # Main app 22 | (r'^', include('lastgui.urls')), 23 | ) 24 | --------------------------------------------------------------------------------