Skip to content
Permalink
Browse files

Refactor + switch from RequireJS to Browserify

  • Loading branch information
graue committed Mar 31, 2013
1 parent 5c3c3a0 commit 9cb1882ae24de2290e174120e4652e97d9aa5e5d
@@ -4,3 +4,5 @@ venv/
*.pyc
*.db
static/music
static/bundle.js
script/node_modules
@@ -0,0 +1,18 @@
.PHONY: all realAll clean

# The following fake "all" target silences make's
# "Nothing to be done for `all'" message.

all: realAll
@true

realAll: static/bundle.js

TEMPLATES = $(shell find script/template -name "*.hbs")
SCRIPTS = $(shell find script/lib script/app script/*.js -name "*.js")

static/bundle.js: $(TEMPLATES) $(SCRIPTS)
browserify -t hbsfy script/main.js -o static/bundle.js

clean:
rm static/bundle.js
@@ -9,37 +9,81 @@ Very barebones and unfinished.

### Dependencies

#### Server side

* [Flask](http://flask.pocoo.org)
* [Flask-SQLAlchemy](http://packages.python.org/Flask-SQLAlchemy/)
* [Flask-Script](http://flask-script.readthedocs.org/)
* Flask-Login
* Flask-BrowserID
* [Mutagen](https://code.google.com/p/mutagen/) (for reading tags)
* LibAV's [avconv](https://libav.org/avconv.html) (for transcoding)

#### Client side

Pots, fyi uses [Browserify](http://browserify.org/) to bundle together
its client-side dependencies (JQuery, Backbone, Underscore and Handlebars).
This requires [npm](http://npmjs.org/).

### Quick start

sudo apt-get install libav-tools # or equivalent
Install LibAV's avconv. On Ubuntu/Debian:

sudo apt-get install libav-tools

Make sure you have pip, virtualenv and npm. Then:

git clone https://github.com/graue/potsfyi
cd potsfyi
virtualenv venv
. venv/bin/activate
pip install -r requirements.pip
ln -s /some/dir/that/has/music/in/it static/music
./manage.py createdb

Your server is now ready to go.
To build client-side scripts:

(cd script && npm install)
make

Finally, to start a debug server on http://localhost:5000:

DEBUG=True ./potsfyi.py

This will get you a server
at http://localhost:5000
where you can search for songs by artist and title
and play them in your browser.
To search, just start typing.
To queue, click on a search result.
To play, click on a song in the play queue (on the right).

### Running it for real

To start a "production" server, you'll want to leave off the `DEBUG=True`
and pass at least 2 more environment variables:

SECRET_KEY="some long random string"
ADMIN_EMAIL=your.email@example.com

The secret key keeps cookies secure,
and the email you supply is the only one allowed
to log in (via Mozilla Persona, aka BrowserID).
You can also supply:

* `PORT`: port number to listen on, default 5000
* `DB_URI`: database to connect to, default `sqlite:///tracks.db`
* `MUSIC_DIR`: where the music lives, default `static/music`

Flask's default web server only processes one request at a time,
which can result in the rest of the webapp locking up
while songs download.
You can fix this by running the app via [gunicorn](http://gunicorn.org)
rather than directly. This listens on port 8000:
rather than directly. Install gunicorn via pip, then do something like:

SECRET_KEY="..." ADMIN_EMAIL=your.email@example.com \
gunicorn -w 4 --timeout 10000 -b 127.0.0.1:8000 potsfyi:app \
>guni.log 2>&1
pip install gunicorn # one time
DEBUG=True gunicorn --debug -w 4 potsfyi:app # to launch
This spawns 4 worker processes, listens on port 8000, saves output to
guni.log, and sets a long timeout so that worker processes don't timeout
and get shut down while sending audio. (You can partially work around the
timeout issue by proxying your /static/ directory through nginx, but
transcoded audio files will still go through Python.)
@@ -0,0 +1,246 @@
"use strict";

var _ = require('underscore'),
Backbone = require('backbone'),
BackboneLocalStorage = require('./../lib/backbone.localStorage.shim.js');

exports.SongInfo = Backbone.Model.extend({
initialize: function() {
// assign a unique ID (based on Backbone's cid)
// for use in HTML lists
this.set({ htmlId: 'song-' + this.cid });
}
});

exports.SearchResultList = Backbone.Collection.extend({
searchString: '',

initialize: function() {
_.bindAll(this, 'search', 'updateSearchString');
},

model: exports.SongInfo,

// Override because Flask requires an object at top level.
parse: function(resp, xhr) {
return resp.objects;
},

updateSearchString: function(newSearchString) {
// Only update if search string has actually changed.
if (newSearchString !== this.searchString) {
this.searchString = newSearchString;

// Clear the old search-as-you-type timer
if (this.timeout)
clearTimeout(this.timeout);

// Set a timer to search
// after a short interval (unless the string changes again).
this.timeout = setTimeout(this.search, 200);
}
},

search: function() {
if (this.searchString === '') {
// empty search string: display no results
this.reset();
} else {
this.url = '/search?q=' + encodeURIComponent(this.searchString);
this.fetch({reset: true});
}
}
});

var SongCollection = Backbone.Collection.extend({
model: exports.SongInfo,

// Override because Flask requires an object at top level.
// XXX code duplication: Also done for search results
parse: function(resp, xhr) {
return resp.objects;
},

addAlbum: function(albumId) {
this.url = '/album/' + albumId;
var options = {}, coll = this;
options.parse = true;
options.success = function(resp, status, xhr) {
options.remove = false;
coll.set(resp, options);
};
Backbone.sync('read', this, options);
},

initialize: function() {
_.bindAll(this, 'addAlbum');
}
});

var Playlist = Backbone.Model.extend({
defaults: {
songCollection: new SongCollection(),
position: -1,
id: 'playlist'
},

localStorage: new Backbone.LocalStorage('playlist'),

getPlaylistFromLocalStorage: function() {
this.fetch({
error: function(model, resp, options) {
// Reading the playlist from local storage failed.
// This probably means no playlist is saved there to
// begin with, so save the current (empty) playlist.
Backbone.sync('create', model, {});
},
syncingFromLS: true
});
},

syncToLocalStorage: function() {
Backbone.sync('update', this, {
error: function(xhr, status, error) {
alert('Sync failed with status: ' + status);
}
});
},

initialize: function() {
_.bindAll(this, 'getPlaylistFromLocalStorage', 'parse',
'syncToLocalStorage');

// Resync to localStorage when the playlist changes.
var syncMethod = this.syncToLocalStorage;
this.attributes.songCollection.on('add remove',
function(model, collection, options) {
options.syncingFromLS || syncMethod();
}
);

// When the position changes, play the newly active song
// and resync to localStorage.
this.on('change:position', function(model, value, options) {
var songColl = this.attributes.songCollection;

if (value >= songColl.length) {
alert('Error: Position ' + value + ' out of range; ' +
'last song is ' + (songColl.length - 1));
return;
}

options.syncingFromLS || syncMethod();

if (value == -1) {
exports.PlayingSong.changeSong(null);
return;
}

var newSong = songColl.at(value);
exports.PlayingSong.changeSong(newSong);
});
},

parse: function(resp, options) {
// Called when we load the saved playlist out of LocalStorage.

if (resp.songCollection) {
// songCollection here is not actually a Backbone collection,
// just the serialized form of its models' attributes.

// We need to create a SongInfo model for each element,
// then add the SongInfo models to this model's existing
// SongCollection.

// The playlist view is listening to this.songCollection,
// so we can't simply replace this.songCollection with a
// whole new collection.

// Work around lack of 'this' in callback
var songColl = this.attributes.songCollection;

options.parse = false; // avoid recursion!

_.each(resp.songCollection, function(songAttrs) {
songColl.add(new exports.SongInfo(songAttrs), options);
});

delete resp.songCollection;
}
return resp;
},

seekToSong: function(cid) {
// cid refers to the cid of a model in the Playlist.
var newSong = this.get('songCollection').get(cid);
this.set('position',
this.get('songCollection').indexOf(newSong));
},

nextSong: function() {
var oldPos = this.get('position');

// is there a next song?
if (oldPos + 1 >= this.get('songCollection').size())
return false; // no next song

this.seekToSong(this.get('songCollection').at(oldPos + 1).cid);
return true; // success
},

prevSong: function() {
var oldPos = this.get('position');

// is there a previous song?
if (oldPos <= 0)
return false; // no previous song

this.seekToSong(this.get('songCollection').at(oldPos - 1).cid);
return true; // success
},

addSong: function(spec) {
this.get('songCollection').add(spec);
},

addAlbum: function(albumId) {
this.get('songCollection').addAlbum(albumId);
},

removeSong: function(song) {
var removedIndex = this.get('songCollection').indexOf(song);
if (removedIndex == -1) {
console.log('tried to remove song not in playlist!');
return;
}
if (removedIndex === this.get('position')) {
// removing currently playing song,
// skip to next
if (!this.nextSong()) {
// already on last song
this.seekToSong(-1);
}
}
this.get('songCollection').remove(song);
if (removedIndex < this.get('position')) {
// update position index, since removed song was before
// (or at) current
this.set('position', this.get('position') - 1);
}
}
});

var PlayingSongInfo = exports.SongInfo.extend({
changeSong: function(newSong) {
if (!newSong) {
this.set('id', '-1');
} else {
this.set(newSong.attributes); // copy all attributes
}
// view should listen for the filename change and re-render
}
});

// XXX these should probably be created in a central controller
exports.PlayingSong = new PlayingSongInfo();
exports.Playlist = new Playlist();

0 comments on commit 9cb1882

Please sign in to comment.
You can’t perform that action at this time.