diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 00000000..91600595 --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1 @@ +service_name: travis-ci diff --git a/.gitignore b/.gitignore index f356293e..2648647e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ logs results npm-debug.log +config.js + +config.js diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..5d5d073e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: node_js +node_js: +- '0.10' +services: + - mongodb + - redis-server +after_script: istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage diff --git a/README.md b/README.md index de61b680..c3f64dab 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,47 @@ -# soundtrack.io +soundtrack.io +============= +[![Build Status](https://img.shields.io/travis/martindale/soundtrack.io.svg?branch=soundtrack.io&style=flat-square)](https://travis-ci.org/martindale/soundtrack.io) +[![Coverage Status](https://img.shields.io/coveralls/martindale/soundtrack.io/soundtrack.io.svg?style=flat-square)](https://coveralls.io/r/martindale/soundtrack.io) -soundtrack.io is a collaborative online jukebox. +soundtrack.io is a collaborative online jukebox. It is an experimental Internet radio platform. Vote on what plays next, like Reddit for music. Aggregates streams from sources like YouTube and SoundCloud, so when a song is queued, it has multiple locations to play from if any one source fails for any particular reason. ## Getting Started Before you begin you will need to have nodejs, redis, and mongodb installed. Homebrew is recommended for OS X users. - brew install nodejs mongodb redis - redis-server & - mongod & +``` +brew install nodejs mongodb redis +``` -Once you have them installed, go ahead and clone the repository. +In the logged output, you'll find some instructions for starting both mongodb and redis – you can follow those instructions, or execute once with `redis-server & mongod &` - git clone git@github.com:fractaloop/soundtrack.io.git +Once you have them installed (and running!), go ahead and clone the repository. + + git clone git@github.com:martindale/soundtrack.io.git cd soundtrack.io You will need to fetch the dependencies and then you can start up the server. npm install node soundtrack.js + +### Testing Rooms +Now that soundtrack has multiple rooms, you'll need to configure your local hostname lookups to point at the appropriate locations. In `/etc/hosts` (or equivalent for your OS): + +``` +127.0.0.1 localhost.localdomain +127.0.0.1 test.localhost.localdomain +``` + +You'll need to add an entry for each subdomain you want to test. Also, the `.localdomain` component is important for sessions, as some browsers expect a top level domain for cookies to work correctly! + +## API + +Deleting tracks: +`$.ajax('/playlist/520e6bda3cb680003700049c', { type: 'DELETE', data: { index: 1 } });` ## Contributing +Want to help? Claim something in [the "ready" column on our Waffle.io](https://waffle.io/martindale/soundtrack.io) by assigning it to yourself. [Fork. Commit. Pull request.](https://help.github.com/articles/fork-a-repo) diff --git a/addJob.js b/addJob.js new file mode 100644 index 00000000..819bfde2 --- /dev/null +++ b/addJob.js @@ -0,0 +1,11 @@ +var config = require('./config'); + +var Monq = require('monq'); +var monq = Monq('mongodb://localhost:27017/' + config.database.name ); +var jobs = monq.queue( config.database.name ); + +/**/jobs.enqueue('artist:update', { id: '52166226a1d626d547000338' } , function(err, job) { +/*/jobs.enqueue('test', { id: '5390073d1559212f560012b4' } , function(err, job) {/**/ + console.log('enqueued: ' , job ); + process.exit(); +}); \ No newline at end of file diff --git a/config.js b/config.js index da9b35d7..3f0d809e 100644 --- a/config.js +++ b/config.js @@ -1,12 +1,17 @@ module.exports = { app: { - safe: process.env.SOUNDTRACK_APP_SAFE || false + name: 'soundtrack' + , safe: process.env.SOUNDTRACK_APP_SAFE || false , host: process.env.SOUNDTRACK_APP_HOST || 'soundtrack.io' , port: process.env.SOUNDTRACK_APP_PORT || 13000 }, database: { name: process.env.SOUNDTRACK_DB_NAME || 'soundtrack' - , host: 'localhost' + , hosts: ['localhost'] + }, + redis: { + host: process.env.SOUNDTRACK_REDIS_HOST || 'localhost' + , port: process.env.SOUNDTRACK_REDIS_PORT || 6379 }, sessions: { key: 'put yourself a fancy little key here' @@ -22,5 +27,9 @@ module.exports = { soundcloud: { id: process.env.SOUNDTRACK_SOUNDCLOUD_ID || 'id here' , secret: process.env.SOUNDTRACK_SOUNDCLOUD_SECRET || 'secret here' + }, + spotify: { + id: process.env.SOUNDTRACK_SPOTIFY_ID || 'id here', + secret: process.env.SOUNDTRACK_SPOTIFY_SECRET || 'secret here' } -} \ No newline at end of file +} diff --git a/controllers/artists.js b/controllers/artists.js index 820a753f..dd498dde 100644 --- a/controllers/artists.js +++ b/controllers/artists.js @@ -1,38 +1,192 @@ var rest = require('restler') - , _ = require('underscore'); + , _ = require('underscore') + , async = require('async'); module.exports = { list: function(req, res, next) { - Artist.find({}).sort('name').exec(function(err, artists) { - res.render('artists', { - artists: artists + var limit = (req.param('limit')) ? req.param('limit') : 100; + var query = (req.param('q')) ? { name: new RegExp('(.*)'+req.param('q')+'(.*)', 'i') } : undefined; + + async.parallel([ + function(done) { + Artist.count().exec( done ); + }, + function(done) { + Artist.find( query ).sort('name').limit( limit ).exec( function(err, artists) { + async.map( artists , function(x, countComplete) { + Track.count({ $or: [ + { _artist: x._id } + , { _credits: x._id } + ] }).exec( function(err, trackCount) { + x = x.toObject(); + x.tracks = trackCount; + countComplete( err, x ); + }); + }, done ); + }); + } + ], function(err, results) { + res.format({ + json: function() { + res.send( results[1].map(function(x) { + if (typeof(x.toObject) == 'function') { + x = x.toObject(); + } + //x.value = x._id; + x.value = x.name; + return x; + }) ); + }, + html: function() { + res.render('artists', { + count: results[0] + , limit: limit + , artists: results[1] + }); + } }); }); }, - view: function(req, res, next) { + delete: function(req, res, next) { Artist.findOne({ slug: req.param('artistSlug') }).exec(function(err, artist) { if (!artist) { return next(); } + + Track.find({ $or: [ + { _artist: artist._id } + , { _credits: artist._id } + ] }).exec(function(err, tracks) { + + res.send(tracks.length); + + }); + + }); + }, + edit: function(req, res, next) { + Artist.findOne({ $or: [ + { _id: req.param('artistID') } + , { slug: req.param('artistSlug') } + , { slugs: req.param('artistSlug') } + ] }).sort('_id').exec(function(err, artist) { + if (!artist) { return next(); } + + artist.name = req.param('name') || artist.name; + artist.bio = req.param('bio') || artist.bio; + + // reset updated field, for re-crawl + artist.tracking.tracks.updated = undefined; + + Artist.find({ $or: [ + { _id: req.param('artistID') } + , { slug: req.param('artistSlug') } + , { slugs: req.param('artistSlug') } + ] }).exec(function(err, artists) { + + var allArtistIDs = artists.map(function(x) { return x._id; }); + + artists.forEach(function(a) { + artist.slugs = _.union( artist.slugs , a.slugs ); + }); + artist.slugs = _.uniq( artist.slugs ); + + async.parallel([ + function(done) { + Track.update({ _artist: { $in: allArtistIDs } }, { + _artist: artist._id.toString() + }, { multi: true }, done ); + }, + function(done) { + Track.update({ '_credits.$': { $in: allArtistIDs } }, { + $addToSet: { _credits: artist._id } + }, { multi: true }, done ); + } + ], function(err, results) { + if (err) { console.log(err); } + + artist.save(function(err) { + if (err) { console.log(err); } + + res.format({ + json: function() { + res.send({ + status: 'success' + , message: 'artist edited successfully' + }); + }, + html: function() { + res.redirect('/' + artist.slug ); + } + }); + }); + }); + }); + }); + }, + view: function(req, res, next) { + Person.count({ slug: req.param('artistSlug') }).exec(function(err, num) { + if (num) return next(); + + var limit = (req.param('limit')) ? req.param('limit') : 100; - Track.find({ _artist: artist._id }).exec(function(err, tracks) { + Artist.findOne({ $or: [ + { slug: req.param('artistSlug') } + , { slugs: req.param('artistSlug') } + ] }).exec(function(err, artist) { + if (!artist) { return next(); } + + // handle artist renames + if (req.param('artistSlug') !== artist.slug) { + return res.redirect('/' + artist.slug); + } - Play.aggregate([ - { $match: { _track: { $in: tracks.map(function(x) { return x._id; }) } } }, - { $group: { _id: '$_track', count: { $sum: 1 } } }, - { $sort: { 'count': -1 } } - ], function(err, trackScores) { + Track.find({ $or: [ + { _artist: artist._id } + , { _credits: artist._id } + ] }).populate('_artist').exec(function(err, tracks) { - res.render('artist', { - artist: artist - , tracks: tracks.map(function(track) { - var plays = _.find( trackScores , function(x) { return x._id.toString() == track._id.toString() } ); - track.plays = (plays) ? plays.count : 0; - return track; - }) + Play.aggregate([ + { $match: { _track: { $in: tracks.map(function(x) { return x._id; }) } } }, + { $group: { _id: '$_track', count: { $sum: 1 } } }, + { $sort: { 'count': -1 } } + ], function(err, trackScores) { + + tracks = tracks.map(function(track) { + var plays = _.find( trackScores , function(x) { return x._id.toString() == track._id.toString() } ); + track.plays = (plays) ? plays.count : 0; + return track; + }).sort(function(a, b) { + return b.plays - a.plays; + }); + + var trackCount = tracks.length; + + tracks = tracks.slice( 0 , limit - 1 ); + + res.format({ + json: function() { + res.send( artist ); + }, + html: function() { + res.render('artist', { + artist: artist + , tracks: tracks + , trackCount: trackCount + }); + } + }); }); - }); + if (req.app.config.jobs && req.app.config.jobs.enabled) { + req.app.agency.publish('artist:update', { + id: artist._id + , timeout: 3 * 60 * 1000 + }, function(err, job) { + console.log('update artist completed'); + }); + } + }); }); }); } -} \ No newline at end of file +} diff --git a/controllers/chat.js b/controllers/chat.js index 3eaeb0f3..73eeed3f 100644 --- a/controllers/chat.js +++ b/controllers/chat.js @@ -2,9 +2,17 @@ var async = require('async'); module.exports = { view: function(req, res, next) { - Chat.find({}).sort('timestamp').populate('_author').limit(20).exec(function(err, chats) { - res.render('chats', { - chats: chats + var limit = (req.param('limit')) ? req.param('limit') : 100; + + Chat.find({ + _room: req.roomObj._id + }).sort('-_id').populate('_author _track _play').limit( limit ).exec(function(err, chats) { + Artist.populate( chats , { + path: '_track._artist' + }, function(err, chats) { + res.render('chats', { + chats: chats + }); }); }); }, @@ -37,4 +45,4 @@ module.exports = { }); }); } -} \ No newline at end of file +} diff --git a/controllers/pages.js b/controllers/pages.js index defca662..b736d74f 100644 --- a/controllers/pages.js +++ b/controllers/pages.js @@ -1,35 +1,144 @@ +var async = require('async'); +var _ = require('underscore'); + module.exports = { index: function(req, res, next) { - Chat.find({}).limit(10).sort('-created').populate('_author').exec(function(err, messages) { - Playlist.find({ _creator: ((req.user) ? req.user._id : undefined) }).sort('name').exec(function(err, playlists) { - - if (err) { console.log(err); } - console.log(playlists); - - res.render('index', { - messages: messages.reverse() - , backup: [] - , playlists: playlists - , room: req.app.room + if (!req.roomObj) { + var sortedRooms = []; + return Room.find().exec(function(err, rooms) { + rooms.forEach(function( room ) { + var roomName = room.slug; + var cachedRoom = req.app.locals.rooms[ roomName ]; + cachedRoom.description = room.description; + cachedRoom.listenerCount = Object.keys(cachedRoom.listeners).length; + sortedRooms.push(cachedRoom); + }); + + sortedRooms = sortedRooms.sort(function(a, b) { + return b.listenerCount - a.listenerCount; + }); + + return async.map( sortedRooms , function( room , done ) { + Person.populate( room , { + path: '_owner' + }, done ); + } , function(err, finalRooms) { + Person.count({}, function(err, userCount) { + return res.render('rooms', { + rooms: finalRooms, + userCount: userCount + }); + }); }); }); + } + + async.parallel([ + collectChats, + collectPlaylists + ], function(err, results) { + var messages = results[0].reverse(); + var playlists = results[1]; + res.render('index', { + messages: messages + , backup: [] + , playlists: playlists || [] + , room: req.app.rooms[ req.room ] + , page: { + title: req.roomObj.name, + description: req.roomObj.description + } + }); + }); + + function collectChats( done ) { + Chat.find({ + _room: req.roomObj._id + }).limit(10).sort('-created').populate('_author _track _play').exec(function(err, messages) { + Artist.populate( messages, { + path: '_track._artist' + }, done ); + }); + } + function collectPlaylists( done ) { + Playlist.find({ _creator: ((req.user) ? req.user._id : undefined) }).sort('name').exec( done ); + } }, about: function(req, res, next) { res.render('about', { }); }, + help: function(req, res, next) { + res.render('help', { }); + }, history: function(req, res) { - Play.find({}).populate('_track _curator').sort('-timestamp').limit(100).exec(function(err, plays) { + Play.find({ + _room: req.roomObj._id + }).populate('_track _curator _room').sort('-timestamp').limit(100).exec(function(err, plays) { Artist.populate(plays, { path: '_track._artist' }, function(err, plays) { - console.log(plays); - res.render('history', { plays: plays }); }); }); + }, + stats: function(req, res, next) { + var LIMIT = 50; + + var functions = [ + function collectTopTracks( done ) { + Play.aggregate([ + { $match: { + _curator: { $exists: true }, + _room: req.roomObj._id + } }, + { $group: { _id: '$_track', count: { $sum: 1 } } }, + { $sort: { 'count': -1 } }, + { $limit: LIMIT } + ], function(err, collected) { + Track.find({ _id: { $in: collected.map(function(x) { return x._id; }) } }).populate('_artist').exec(function(err, input) { + var output = []; + for (var i = 0; i < collected.length; i++) { + output.push( _.extend( collected[i] , input[i] ) ); + } + done( err , output ); + }); + } ); + }, + function collectTopDJs( done ) { + Play.aggregate([ + { $match: { + _curator: { $exists: true }, + _room: req.roomObj._id + } }, + { $group: { _id: '$_curator', count: { $sum: 1 } } }, + { $sort: { 'count': -1 } }, + { $limit: LIMIT } + ], function(err, collected) { + Person.find({ _id: { $in: collected.map(function(x) { return x._id; }) } }).exec(function(err, input) { + var output = []; + for (var i = 0; i < collected.length; i++) { + output.push( _.extend( collected[i] , input[i] ) ); + } + done( err , output ); + }); + } ); + } + ]; + + async.parallel( functions , function(err, results) { + var stats = { + topTracks: results[0], + topDJs: results[1] + } + + res.format({ + json: function() { res.send(stats); }, + html: function() { res.render('stats', stats ); } + }); + }); } -} \ No newline at end of file +} diff --git a/controllers/people.js b/controllers/people.js index b3ecef8d..589efc90 100644 --- a/controllers/people.js +++ b/controllers/people.js @@ -1,22 +1,229 @@ +var async = require('async'); +var _ = require('underscore'); + module.exports = { profile: function(req, res, next) { Person.findOne({ slug: req.param('usernameSlug') }).exec(function(err, person) { - if (!person) { return next(); } + if (!person) return next(); + + var LIMIT = 50; + + async.parallel([ + collectUserPlaylists, + collectUserPlays, + collectPlayStats, + collectArtistData, + collectRooms + ], function(err, results) { - Playlist.find({ _creator: person._id, public: true }).exec(function(err, playlists) { - res.render('person', { + var playlists = results[0]; + // TODO: use reduce(); + playlists = playlists.map(function(playlist) { + playlist.length = 0; + playlist._tracks.forEach(function(track) { + playlist.length += track.duration; + }); + return playlist; + }); + + return res.render('person', { person: person , playlists: playlists + , plays: results[1] + , favoriteTracks: { + allTime: results[2][0].filter(function(x) { + return x._artist; + }), + past30days: results[2][1].filter(function(x) { + return x._artist; + }) + } + , artist: (results[3]) ? results[3].artist : null + , tracks: (results[3]) ? results[3].tracks : null + , trackCount: (results[3]) ? results[3].trackCount : null + , topRoomsByQueues: results[4] }); + + if (req.app.config.jobs && req.app.config.jobs.enabled) { + req.app.agency.publish('artist:update', { + id: (results[3]) ? results[3].artist._id : null + , timeout: 3 * 60 * 1000 + }, function(err, job) { + console.log('update artist completed'); + }); + } + }); + + function collectRooms(done) { + Play.aggregate([ + { $match: { + _curator: person._id + } }, + { $group: { _id: '$_room', count: { $sum: 1 } } }, + { $sort: { 'count': -1 } }, + { $limit: LIMIT } + ], function(err, collected) { + Room.populate( collected , { + path: '_id' + }, function(err, topRooms) { + topRooms = topRooms.map(function(x) { + return { + _room: x._id, + count: x.count + }; + }); + + done( null , topRooms ); + }); + }); + } + + function collectUserPlays(done) { + Play.find({ _curator: person._id }).sort('-timestamp').limit(20).populate('_track _curator _room').exec(function(err, plays) { + Artist.populate( plays , { + path: '_track._artist _track._credits' + }, done ); + }); + } + + function collectUserPlaylists(done) { + var q = { _creator: person._id }; + + if (!req.user || req.user._id.toString() !== person._id.toString()) { + q.public = true; + } + + Playlist.find( q ).sort('-_id').populate('_tracks').exec(function(err, playlists) { + done( err , playlists ); + }); + } + + function collectPlayStats(done) { + async.parallel([ + function(complete) { + Play.aggregate([ + { $match: { + _curator: person._id + } }, + { $group: { _id: '$_track', count: { $sum: 1 } } }, + { $sort: { 'count': -1 } }, + { $limit: LIMIT } + ], function(err, collected) { + Track.find({ _id: { $in: collected.map(function(x) { return x._id; }) } }).populate('_artist').exec(function(err, input) { + var output = []; + for (var i = 0; i < collected.length; i++) { + output.push( _.extend( collected[i] , input[i] ) ); + } + complete( err , output ); + }); + } ); + }, + function(complete) { + Play.aggregate([ + { $match: { + _curator: person._id, + timestamp: { $gte: new Date((new Date()) - 30 * 24 * 3600 * 1000) } + } }, + { $group: { _id: '$_track', count: { $sum: 1 } } }, + { $sort: { 'count': -1 } }, + { $limit: LIMIT } + ], function(err, collected) { + Track.find({ _id: { $in: collected.map(function(x) { return x._id; }) } }).populate('_artist').exec(function(err, input) { + var output = []; + for (var i = 0; i < collected.length; i++) { + output.push( _.extend( collected[i] , input[i] ) ); + } + complete( err , output ); + }); + } ); + } + ], done ); + } + + function collectArtistData( artistComplete ) { + Artist.findOne({ $or: [ + { slug: req.param('usernameSlug') } + , { slugs: req.param('usernameSlug') } + ] }).exec(function(err, artist) { + if (!artist) return artistComplete(); + + // handle artist renames + if (req.param('usernameSlug') !== artist.slug) { + res.redirect('/' + artist.slug); + return artistComplete('redirected'); + } + + Track.find({ $or: [ + { _artist: artist._id } + , { _credits: artist._id } + ] }).populate('_artist').exec(function(err, tracks) { + + Play.aggregate([ + { $match: { _track: { $in: tracks.map(function(x) { return x._id; }) } } }, + { $group: { _id: '$_track', count: { $sum: 1 } } }, + { $sort: { 'count': -1 } } + ], function(err, trackScores) { + + tracks = tracks.map(function(track) { + var plays = _.find( trackScores , function(x) { return x._id.toString() == track._id.toString() } ); + track.plays = (plays) ? plays.count : 0; + return track; + }).sort(function(a, b) { + return b.plays - a.plays; + }); + + var trackCount = tracks.length; + + tracks = tracks.slice( 0 , LIMIT - 1 ); + + return artistComplete( null , { + artist: artist + , tracks: tracks + , trackCount: trackCount + }); + }); + + /* req.soundtrack._jobs.enqueue('artist:update', { + id: artist._id + , timeout: 3 * 60 * 1000 + }, function(err, job) { + console.log('update artist queued'); + }); */ + }); + }); + } + }); }, - edit: function(req, res, next) { + mentions: function(req, res, next) { Person.findOne({ slug: req.param('usernameSlug') }).exec(function(err, person) { if (!person) { return next(); } - person.bio = (req.param('bio')) ? req.param('bio') : person.bio; + Chat.find({ message: new RegExp( person.username , 'i') }).sort('-created').limit(100).populate('_author _track _play').exec(function(err, chats) { + res.render('chats', { + chats: chats + }); + }); + }); + }, + edit: function(req, res, next) { + if (!req.user) return next(); + Person.findOne({ + _id: req.user._id, + slug: req.param('usernameSlug') + }).exec(function(err, person) { + if (!person) return next(); + + person.bio = (req.param('bio')) ? req.param('bio') : person.bio; + person.email = (req.param('email')) ? req.param('email') : person.email; + + if (typeof(person.email) == 'string') { + var hash = require('crypto').createHash('md5').update( person.email.toLowerCase() ).digest('hex'); + person.avatar.url = 'https://www.gravatar.com/avatar/' + hash + '?d=https://soundtrack.io/img/user-avatar.png'; + } + person.save(function(err) { req.flash('info', 'Profile saved successfully!'); res.redirect('/' + person.slug ); @@ -31,6 +238,49 @@ module.exports = { }); }); }, + listPlays: function(req, res, next) { + var query = {}; + var limit = (req.param('limit')) ? parseInt(req.param('limit')) : 100; + + if (req.roomObj) query['_room'] = req.roomObj._id; + + Person.findOne({ slug: req.param('usernameSlug') }).exec(function(err, person) { + if (!person) return next(); + + query['_curator'] = person._id; + + async.parallel([ + function(done) { + Playlist.find({ _creator: person._id, public: true }).exec( done ); + }, + function(done) { + Play.find( query ).sort('-timestamp').populate('_track _curator _room').limit( limit ).exec(function(err, plays) { + Artist.populate( plays , { + path: '_track._artist _track._credits' + }, done ); + }); + }, + function(done) { + Play.count( query ).exec( done ); + } + ], function(err, results) { + res.format({ + json: function() { + res.send( results[1] ); + }, + html: function() { + res.render('person-plays', { + person: person, + playlists: results[0], + plays: results[1], + count: results[2], + limit: limit + }); + } + }); + }); + }); + }, setUsernameForm: function(req, res, next) { if (!req.user || (req.user && req.user.username)) { return res.redirect('/'); @@ -55,4 +305,4 @@ module.exports = { }); } -} \ No newline at end of file +} diff --git a/controllers/playlists.js b/controllers/playlists.js index 4622ea07..b8de56e7 100644 --- a/controllers/playlists.js +++ b/controllers/playlists.js @@ -1,23 +1,87 @@ -var _ = require('underscore') +var _ = require('underscore'); +var async = require('async'); +var rest = require('restler'); module.exports = { + list: function(req, res, next) { + Playlist.find({ public: true }).sort('-updated').populate('_tracks _creator _owner').exec(function(err, playlists) { + // TODO: use reduce(); + playlists = playlists.map(function(playlist) { + playlist.length = 0; + playlist._tracks.forEach(function(track) { + playlist.length += track.duration; + }); + return playlist; + }); + + res.format({ + json: function() { + res.send( playlists ); + }, + html: function() { + res.render('sets', { + sets: playlists + }); + } + }); + }); + }, + listPerson: function(req, res, next) { + Person.findOne({ slug: req.param('usernameSlug') }).exec(function(err, person) { + if (!person) return next(); + + var q = { _creator: person._id }; + + if (!req.user || req.user._id.toString() !== person._id.toString()) { + q.public = true; + } + + Playlist.find( q ).sort('-_id').populate('_tracks').exec(function(err, playlists) { + // TODO: use reduce(); + playlists = playlists.map(function(playlist) { + playlist.length = 0; + playlist._tracks.forEach(function(track) { + playlist.length += track.duration; + }); + return playlist; + }); + + res.render('playlists', { + person: person + , playlists: playlists + }); + }); + }); + }, view: function(req, res, next) { Person.findOne({ slug: req.param('usernameSlug') }).exec(function(err, person) { if (!person) { return next(); } - // TODO: use $or to allow user to view non-public var slug = req.param('playlistSlug').split('.')[0]; - Playlist.findOne({ $or: [ - { _creator: person._id, public: true } - , { _creator: (req.user) ? req.user._id : person._id } - ], slug: slug }).populate('_tracks _creator').exec(function(err, playlist) { + + var query = { + slug: slug + }; + + if (person) { + query._creator = person._id; + } else if (req.user) { + query._creator = req.user._id; + } + + if (req.user && req.user._id.toString() !== person._id.toString()) { + query.public = true; + } + + console.log('query', query ); + + Playlist.findOne( query ).populate('_tracks _creator _parent').exec(function(err, playlist) { if (!playlist) { return next(); } Artist.populate(playlist, { path: '_tracks._artist' }, function(err, playlist) { - res.format({ json: function() { //var playlist = playlist.toObject(); @@ -29,8 +93,12 @@ module.exports = { res.send( playlist ); }, html: function() { - res.render('playlist', { - playlist: playlist + Person.populate( playlist , { + path: '_parent._owner' + }, function(err, playlist) { + res.render('playlist', { + playlist: playlist + }); }); } }); @@ -40,29 +108,264 @@ module.exports = { }); }, - create: function(req, res, next) { - var playlist = new Playlist({ - name: req.param('name') - , _creator: req.user._id - , public: (req.param('public') == 'true') ? true : false + delete: function(req, res, next) { + Playlist.remove({ + _id: req.param('playlistID'), + _creator: req.user._id + }).exec(function(err, numberRemoved) { + if (err || !numberRemoved) return next(); + return res.send('ok'); }); + }, + removeTrackFromPlaylist: function(req, res, next) { + if (!~(req.param('index'))) return next(); - Track.findOne({ _id: req.param('trackID') }).exec(function(err, track) { - if (!track) { return next(); } - - playlist._tracks.push( track._id ); + Playlist.findOne({ + _id: req.param('playlistID'), + _creator: req.user._id + }).exec(function(err, playlist) { + if (err || !playlist) return next(); + playlist._tracks.splice( req.param('index') , 1 ); playlist.save(function(err) { - res.send({ - status: 'success' - , results: { - _id: playlist._id - , name: playlist.name - , tracks: [ track ] + return res.send('ok'); + }); + + }); + }, + createForm: function(req, res, next) { + if (!req.user) return next(); + res.render('playlists-create'); + }, + create: function(req, res, next) { + Playlist.findOne({ _id: req.param('parentID') , public: true }).exec(function(err, parent) { + + var playlist = new Playlist({ + name: req.param('name') || ((parent) ? parent.name : null) + , description: req.param('description') || ((parent) ? parent.description : null) + , public: (req.param('status') === 'public') ? true : false + , _creator: req.user._id + , _owner: req.user._id + , _parent: (req.param('parentID') && parent) ? parent._id : null + , _tracks: (parent) ? parent._tracks : [] + }); + + Track.findOne({ _id: req.param('trackID') }).exec(function(err, track) { + if (track) playlist._tracks.push( track._id ); + + playlist.save(function(err) { + if (err) { + return res.format({ + json: function() { res.send({ status: 'error', message: err }); }, + html: function() { res.status(500).render('500'); } + }); + } + + res.status(303); + + res.format({ + json: function() { + res.send({ + status: 'success' + , results: { + _id: playlist._id + , name: playlist.name + , tracks: [ track ] + } + }); + }, + html: function() { + req.flash('info', 'Set created successfully!'); + res.redirect('/' + req.user.slug + '/' + playlist.slug ); } + }); }); }); }); + }, + import: function(req, res, next) { + var playlist = new Playlist(); + + switch (req.param('source')) { + default: + return res.status(400).end(); + break; + case 'soundcloud': + self.trackFromSource( 'soundcloud' , item.id , cb ); + break; + } + + }, + import: function() { + + }, + syncAndImport: function(req, res, next) { + if (!req.user.profiles) req.user.profiles = {}; + + var querySources = ['youtube', 'spotify']; + if (req.param('sourceName')) querySources = [ req.param('sourceName') ]; + + if (~querySources.indexOf('youtube') && (!req.user.profiles.google || !req.user.profiles.google.token)) return res.redirect('/auth/google?next=/sets/import'); + if (~querySources.indexOf('spotify') && (!req.user.profiles.spotify || !req.user.profiles.spotify.token)) return res.redirect('/auth/spotify?next=/sets/import'); + + var playlist = req.param('playlist'); + if (playlist) { + try { + playlist = JSON.parse( playlist ); + } catch (e) { + return res.render('500'); + } + + switch (playlist.source) { + default: + req.flash('error', 'Unknown playlist source "'+playlist.source+'"'); + return res.redirect('back'); + break; + case 'youtube': + + var PER_PAGE = 50; + + var pullers = []; + function getSet( pageNum , pageToken , setComplete ) { + if (typeof(pageToken) == 'function') var setComplete = pageToken; + + var path = 'playlistItems?playlistId='+playlist.id+'&part=contentDetails&maxResults=' + PER_PAGE; + + if (typeof(pageToken) == 'string') path += '&pageToken=' + pageToken; + + req.youtube.get( path ).on('complete', function(data) { + data.items.map(function( track ) { + return function( trackComplete ) { + req.soundtrack.trackFromSource('youtube', track.contentDetails.videoId , trackComplete ); + } + }).forEach(function(puller) { + pullers.push( puller ); + }); + + if (data.pageInfo.totalResults > pageNum * PER_PAGE) { + return getSet( ++pageNum , data.nextPageToken , setComplete ); + } else { + return setComplete(); + } + }); + } + + getSet( 1 , function() { + async.series( pullers , function(err, results) { + + var trackIDs = results.map(function(x) { + return x._id; + }).filter(function(x) { + return x; + }); + + var createdPlaylist = new Playlist({ + name: playlist.name, + public: true, + _creator: req.user._id, + _owner: req.user._id, + _tracks: trackIDs + }); + createdPlaylist.save(function(err) { + if (err) console.log(err); + return res.redirect('/' + req.user.slug + '/' + createdPlaylist.slug ); + }); + }); + }); + break; + case 'spotify': + var url = 'users/' + playlist.user + '/playlists/' + playlist.id + '?limit=250'; + req.spotify.get( url ).on('complete', function(spotifyPlaylist , response ) { + if (!spotifyPlaylist || response.statusCode !== 200) { + req.flash('error', 'Could not retrieve list from Spotify. ' + response.statusCode ); + return res.redirect('back'); + } + + var tracks = spotifyPlaylist.tracks.items.map(function(x) { + return { + title: x.track.name, + artist: x.track.artists[0].name, + credits: x.track.artists.map(function(y) { + return y.name + }), + duration: x.track.duration_ms / 1000 + } + }); + + var pushers = []; + tracks.forEach(function(track) { + pushers.push(function(done) { + req.soundtrack.trackFromSource('object', track , done ); + }); + }); + + async.series( pushers , function(err, tracks) { + + tracks.forEach(function(track) { + /* req.app.agency.publish('track:crawl', { + id: track._id + }, function(err) { + console.log('track crawled, doing stuff in initiator'); + }); */ + }); + + var playlist = new Playlist({ + name: spotifyPlaylist.name, + description: spotifyPlaylist.description, + public: spotifyPlaylist.public, + _creator: req.user._id, + _owner: req.user._id, + _tracks: tracks.map(function(x) { return x._id }), + remotes: { + spotify: { + id: spotifyPlaylist.id + } + } + }); + playlist.save(function(err) { + res.redirect('/' + req.user.slug + '/' + playlist.slug ); + }); + }); + }); + break; + } + return; + } + + var stack = {}; + + async.parallel({ + youtube: syncYoutube, + spotify: syncSpotify + }, function(err, results) { + if (~querySources.indexOf('youtube') && !results.youtube) return res.redirect('/auth/google?next=/sets/import'); + if (~querySources.indexOf('spotify') && !results.spotify) return res.redirect('/auth/spotify?next=/sets/import'); + + res.render('sets-import', { + youtube: results.youtube || [], + spotify: results.spotify || [], + }); + + }); + + function syncYoutube( done ) { + req.youtube.get('playlists?part=snippet&mine=true&maxResults=50').on('complete', function(data) { + req.user.profiles.google.playlists = data.items; + req.user.save(function(err) { + done( err , data.items ); + }); + }); + } + + function syncSpotify( done ) { + req.spotify.get('users/' + req.user.profiles.spotify.id + '/playlists').on('complete', function(results, response) { + if (!results || response.statusCode == 401) return done('expired'); + req.user.profiles.spotify.playlists = results.items; + req.user.save(function(err) { + done( err , results.items ); + }); + }); + } }, edit: function(req, res, next) { @@ -70,14 +373,13 @@ module.exports = { if (!playlist) { return next(); } playlist.description = (req.param('description')) ? req.param('description') : playlist.description; - switch (req.param('public')) { - case 'true': - playlist.public = true; - break; - case 'false': - playlist.public = false; - break; + + if (req.param('status')) { + playlist.public = (req.param('status') === 'public') ? true : false; } + + playlist.updated = new Date(); + playlist.save(function(err) { res.send({ status: 'success' @@ -95,6 +397,7 @@ module.exports = { if (!track) { return next(); } playlist._tracks.push( track._id ); + playlist.updated = new Date(); playlist.save(function(err) { res.send({ status: 'success' @@ -110,4 +413,4 @@ module.exports = { }); } -}; \ No newline at end of file +}; diff --git a/controllers/rooms.js b/controllers/rooms.js new file mode 100644 index 00000000..79c756e7 --- /dev/null +++ b/controllers/rooms.js @@ -0,0 +1,72 @@ +var slugify = require('speakingurl'); + +module.exports = { + list: function(req, res, next) { + Room.find().exec(function(err, rooms) { + res.format({ + json: function() { res.send( rooms ); } + }) + }); + }, + create: function(req, res, next) { + var name = req.param('name'); + var slug = req.param('slug'); + var description = req.param('description'); + + if (!name || !slug) { + res.flash('error', 'You must provide a name and a slug!'); + return res.redirect('back'); + } + + slug = slugify( slug ); + + Room.count({ slug: slug }).exec(function(err, count) { + if (count) { + res.flash('error', 'That room already exists.'); + return res.redirect('back'); + } + + var room = new Room({ + name: name, + slug: slug, + description: description, + _creator: req.user._id, + _owner: req.user._id + }); + room.save(function(err) { + if (err) return res.error( err ); + + var playlist = [] + var app = req.app; + + app.rooms[ room.slug ] = room; + app.rooms[ room.slug ].playlist = playlist; + app.rooms[ room.slug ].listeners = {}; + + app.rooms[ room.slug ].bind( req.soundtrack ); + + app.rooms[ room.slug ].startMusic( errorHandler ); + + function done() { + app.locals.rooms = app.rooms; + + var config = req.app.config; + return res.redirect( ((config.app.safe) ? 'https://' : 'http://') + slug + '.' + config.app.host ); + } + + function errorHandler(err) { + if (err) { + return app.rooms[ room.slug ].retryTimer = setTimeout(function() { + app.rooms[ room.slug ].startMusic( errorHandler ); + }, 5000 ); + } + + return done(); + } + + }); + + }); + + } +} diff --git a/controllers/tracks.js b/controllers/tracks.js index 55f64d8b..636ecc7f 100644 --- a/controllers/tracks.js +++ b/controllers/tracks.js @@ -1,32 +1,249 @@ var async = require('async') - , _ = require('underscore'); + , _ = require('underscore') + , util = require('../util'); module.exports = { list: function(req, res, next) { - Play.aggregate([ - { $group: { _id: '$_track', count: { $sum: 1 } } }, - { $sort: { 'count': -1 } }, - { $limit: 100 } - ], function(err, tracks) { - - Track.find({ _id: { $in: tracks.map(function(x) { return x._id; }) }}).populate('_artist').exec(function(err, tracks) { - res.render('tracks', { - tracks: tracks + var LIMIT = parseInt(req.query.limit) || 10; + var roomID = undefined; + if (req.roomObj) roomID = req.roomObj._id; + + var functions = []; + if (!req.param('q')) { + functions.push( function collectAllTime( done ) { + Play.aggregate([ + { $match: { _curator: { $exists: true }, _room: roomID } }, + { $group: { _id: '$_track', count: { $sum: 1 } } }, + { $sort: { 'count': -1 } } + ], function(err, collected) { + var collected = collected.slice(0, LIMIT); + Track.find({ _id: { $in: collected.map(function(x) { return x._id; }) } }).populate('_artist').exec(function(err, input) { + var output = []; + for (var i = 0; i < collected.length; i++) { + output.push( _.extend( collected[i] , input[i] ) ); + } + done( err , output ); + }); + } ); + } ); + + functions.push( function collectThirtyDays( done ) { + Play.aggregate([ + { $match: { + _curator: { $exists: true }, + _room: roomID, + timestamp: { $gte: new Date((new Date()) - 30 * 24 * 3600 * 1000) } + } }, + { $group: { _id: '$_track', count: { $sum: 1 } } }, + { $sort: { 'count': -1 } } + ], function(err, collected) { + var collected = collected.slice(0, LIMIT); + Track.find({ _id: { $in: collected.map(function(x) { return x._id; }) } }).populate('_artist').exec(function(err, input) { + var output = []; + for (var i = 0; i < collected.length; i++) { + output.push( _.extend( collected[i] , input[i] ) ); + } + done( err , output.slice(0, LIMIT) ); + }); + } ); + } ); + + functions.push( function collectSevenDays( done ) { + Play.aggregate([ + { $match: { + _curator: { $exists: true }, + _room: roomID, + timestamp: { $gte: new Date((new Date()) - 7 * 24 * 3600 * 1000) } + } }, + { $group: { _id: '$_track', count: { $sum: 1 } } }, + { $sort: { 'count': -1 } } + ], function(err, collected) { + var collected = collected.slice(0, LIMIT); + Track.find({ _id: { $in: collected.map(function(x) { return x._id; }) } }).populate('_artist').exec(function(err, input) { + var output = []; + for (var i = 0; i < collected.length; i++) { + output.push( _.extend( collected[i] , input[i] ) ); + } + done( err , output.slice(0, LIMIT) ); + }); + } ); + } ); + } + + async.parallel( functions , function(err, statResults) { + + var limit = (req.param('limit')) ? parseInt(req.param('limit')) : 100; + var query = (req.param('q')) ? { name: new RegExp('(.*)'+req.param('q')+'(.*)', 'i') } : {}; + + if (req.param('nsfw') === '✓') { + query.flags = { + nsfw: true + } + } + if (req.param('live') === '✓') { + query.flags = { + live: true + } + } + + Track.find( query ).populate('_artist').limit( limit ).exec(function(err, tracks) { + if (err) console.log(err); + + Track.count( query ).exec(function(err, count) { + if (err) console.log(err); + + res.format({ + json: function() { + res.send( tracks ); + }, + html: function() { + res.render('tracks', { + tracks: tracks + , count: count + , limit: limit + , query: req.param('q') + , topTracksAll: statResults[0] + , topTracks30: statResults[1] + , topTracks7: statResults[2] + }); + } + }); + }); + }); + }); + }, + pool: function(req, res, next) { + req.roomObj.generatePool(function(err, plays, query) { + if (!plays || !plays.length) var plays = []; + Track.find({ _id: { $in: plays.map(function(x) { return x._track; }) } }).populate('_artist _credits').exec(function(err, tracks) { + res.format({ + json: function() { + res.send( tracks ); + }, + html: function() { + res.render('pool', { + tracks: tracks, + query: query + }); + } }); }); - }); }, edit: function(req, res, next) { - Track.findOne({ _id: req.param('trackID') }).exec(function(err, track) { - if (err || !track) { return next(); } - track.title = (req.param('title')) ? req.param('title') : track.title; - track.save(function(err) { + if (!req.param('artistName') || !req.param('title')) { + + if (['true', 'false'].indexOf( req.param('nsfw') ) >= 0 ) { + Track.findOne({ _id: req.param('trackID') }).populate('_artist').exec(function(err, track) { + if (!track) { return next(); } + + track.flags.nsfw = req.param('nsfw'); + track.save(function(err) { + res.send({ + status: 'success' + , message: 'Track edited successfully.' + }); + + req.soundtrack.broadcast({ + type: 'edit' + , track: track + }); + + }); + }); + } else if (['true', 'false'].indexOf( req.param('live') ) >= 0 ) { + Track.findOne({ _id: req.param('trackID') }).populate('_artist').exec(function(err, track) { + if (!track) { return next(); } + + track.flags.live = req.param('live'); + track.save(function(err) { + res.send({ + status: 'success' + , message: 'Track edited successfully.' + }); + + req.soundtrack.broadcast({ + type: 'edit' + , track: track + }); + + }); + }); + } else { res.send({ - status: 'success' - , message: 'Track edited successfully.' + status: 'error' + , message: 'Required parameters not specified.' }); + } + } else { + // parse the submitted values (which may or may not yet exist in the database) + var stringToParse = req.param('artistName') + ' - ' + req.param('title'); + util.parseTitleString( stringToParse , function(parts) { + console.log('edited track, parsed parts into: ') + console.log(parts); + + // find the edited track... + Track.findOne({ _id: req.param('trackID') }).exec(function(err, track) { + if (err || !track) return next(); + + // get the artist parsed from the new artist name... + Artist.findOne({ name: parts.artist }).exec(function(err, artist) { + if (err) console.log(err); + + if (!artist ) var artist = new Artist({ name: req.param('artistName') }); + + // go ahead and issue a save for it (so it exists when we save the track) + artist.save(function(err) { + if (err) { console.log(err); } + + // find a list of artists in the parsed credits... + Artist.find({ name: { $in: parts.credits } }, { _id: 1 }).exec(function(err, credits) { + + // all we want are the IDs... + var creditIDs = credits.map(function(x) { return x._id; }); + + // update the corresponding track to set values... + // new? title + // new? artist + // new? credits + Track.update( + { _id: track._id }, + { + $addToSet: { _credits: { $each: creditIDs } } + , $set: { + _artist: (artist && artist._id.toString() != track._artist.toString()) ? artist._id : track._artist + , title: parts.title || track.title + } + } + ).exec(function(err, numAffected) { + + res.send({ + status: 'success' + , message: 'Track edited successfully.' + }); + + // prepare for over-the-wire broadcast... + track = track.toObject(); + track._artist = artist; + track.title = parts.title || track.title; + + req.soundtrack.broadcast({ + type: 'edit' + , track: track + }); + }); + }); + }); + }); + }); + }); + } + }, + merge: function(req, res, next) { + Track.findOne({ _id: req.param('from') }).exec(function(err, from) { + Track.findOne({ _id: req.param('to') }).exec(function(err, to) { + }); }); }, @@ -35,50 +252,153 @@ module.exports = { Track.findOne({ $or: [ { _id: req.param('trackID') } , { slug: req.param('trackSlug') } - ] }).populate('_artist').exec(function(err, track) { - res.format({ - json: function() { - res.send( track ); - }, - html: function() { - Play.find({ _track: track._id }).sort('-timestamp').populate('_curator').exec(function(err, history) { - if (err) { console.log(err); } - - var queries = []; - for (var d = 29; d >= 0; d--) { - - var start = new Date(); - start.setHours('0'); - start.setMinutes('0'); - start.setSeconds('0'); - start.setMilliseconds('0'); - - var end = new Date( start.getTime() ); - - start = new Date( start - (d * 1000 * 60 * 60 * 24) ); - end = new Date( start.getTime() + 1000 * 60 * 60 * 24 ); - - queries.push({ timestamp: { - $gte: start - , $lt: end - } }); + ] }).populate('_artist _credits').exec(function(err, track) { + if (!track) { return next(); } + + var functions = [ + function(done) { + Chat.find({ _track: track._id }).sort('-_id').limit(20).populate('_author').exec( done ); + } + ]; + + if (!track._artist || (track._artist && track._artist.slug === '')) { + + functions.push(function(done) { + + var stringToParse = ''; + + for (var source in track.sources) { + if (!track.sources[source] || !track.sources[source].length) { + return; } - // TODO: use some sort of map so this can be switched parallel - async.series( - queries.map(function(q) { - return function(done) { Play.count( _.extend({ _track: track._id }, q) ).exec(done); }; - }), function(err, playsPerDay) { - res.render('track', { - track: track - , history: history - , playsPerDay: playsPerDay - }); + for (var i = 0; i < track.sources[source].length; i++) { + if (track.sources[ source ][i].data && track.sources[ source ][i].data.title) { + stringToParse = track.sources[ source ][i].data.title; } - ); + } + } + + console.log('parsing: ' + stringToParse); + util.parseTitleString( stringToParse , function(parts) { + + Artist.findOne({ name: parts.artist }).exec(function(err, artist) { + if (!artist) { var artist = new Artist({ name: parts.artist }); } + + Artist.find({ name: { $in: parts.credits } }, { _id: 1 }).exec(function(err, artists) { + var creditIDs = artists.map(function(x) { return x._id.toString(); }); + + console.log(creditIDs); + + Track.update( + { _id: track._id }, + { + $addToSet: { _credits: { $each: creditIDs } } + , $set: { + _artist: (artist && artist._id.toString() != track._artist.toString()) ? artist._id : track._artist + , title: parts.title || track.title + } + } + ).exec( done ); + }); + }); }); - } + }); + } + + console.log('functions length is ' + functions.length ); + + async.series( functions , function(err, results) { + var chats = results[0]; + + Track.findOne({ $or: [ + { _id: req.param('trackID') } + , { slug: req.param('trackSlug') } + ] }).populate('_artist _credits').exec(function(err, track) { + + /* req.app.agency.publish('track:crawl', { + id: track._id + }, function(err) { + console.log('track crawling completed'); + }); */ + + res.format({ + json: function() { + res.send( track ); + }, + html: function() { + Play.find({ _track: track._id }).sort('-timestamp').populate('_curator _room').exec(function(err, history) { + if (err) { console.log(err); } + + var queries = []; + for (var d = 29; d >= 0; d--) { + + var start = new Date(); + start.setHours('0'); + start.setMinutes('0'); + start.setSeconds('0'); + start.setMilliseconds('0'); + + var end = new Date( start.getTime() ); + + start = new Date( start - (d * 1000 * 60 * 60 * 24) ); + end = new Date( start.getTime() + 1000 * 60 * 60 * 24 ); + + queries.push({ timestamp: { + $gte: start + , $lt: end + } }); + } + + // TODO: use some sort of map so this can be switched parallel + async.series( + queries.map(function(q) { + return function(done) { Play.count( _.extend({ _track: track._id }, q) ).exec(done); }; + }), function(err, playsPerDay) { + + Track.find({ + _artist: track._artist._id + , slug: track.slug + }).exec(function(err, dupes) { + + + Playlist.find({ + _creator: (req.user) ? req.user._id : undefined + }).exec(function(err, playlists) { + + Play.aggregate([ + { $match: { _track: track._id , _curator: { $exists: true } } }, + { $group: { _id: '$_room', count: { $sum: 1 } } }, + { $sort: { 'count': -1 } }, + //{ $limit: LIMIT } + ], function(err, topRooms) { + + Room.populate( topRooms , { + path: '_id' + }, function(err, populatedTopRooms) { + res.render('track', { + track: track + , history: history + , playsPerDay: playsPerDay + , chats: chats + , dupes: dupes + , playlists: playlists + , topRooms: topRooms + }); + }); + + + }); + + }); + }); + } + ); + }); + } + }); + }); }); }); } -} \ No newline at end of file +} diff --git a/db.js b/db.js index 1c6dd5c8..34fd59dd 100644 --- a/db.js +++ b/db.js @@ -1,11 +1,14 @@ var config = require('./config') , mongoose = require('mongoose') - , redis = require("redis") + , redis = require('redis') , client = redis.createClient(); -mongoose.connect(config.database.host, config.database.name); +var hosts = config.database.hosts || []; +var string = 'mongodb://' + hosts.join(',') + '/' + config.database.name; +var source = mongoose.connect( string ); module.exports = { mongoose: mongoose , client: client -}; \ No newline at end of file + , source: source +}; diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..b1f058be --- /dev/null +++ b/docs/api.md @@ -0,0 +1,92 @@ +soundtrack api +============== + +While soundtrack does not yet utilize [the maki framework](http://maki.ericmartindale.com) directly, it was actually one of the first iterations upon which maki was based. Herein we will present the soundtrack API as it would be expectedly served if it were directly powered by Maki. + +**A note on URLs:** soundtrack does not currently provide explicit collections; instead, it attempts to merge all "entity" resources into a single root collection. For example, an Artist and a Person might share a namespace, and subsequently will be rendered as one item. A "claiming" mechanism may be introduced in the future. + +**Content Negotiation:** all endpoints will attempt to serve an HTML representation of that resource by default. To override this, supply the `Accept: application/json` header to perform Content Negotiation and retrieve a JSON version of that resource. + +**Slugs:** slugs are used as the normalization mechanism for potentially complex strings. These use [the `speakingurl` package](https://github.com/pid/speakingurl), which should run in both node and the browser. + +# Resources + +## Queue +`:roomSlug\.yourdomain\.tld/playlist.json` +Deprecated, still functional. Retrieves room's queue. + +`:roomSlug\.yourdomain\.tld/queue` +Retrieves the specified room's queue. Not yet working. + +## Person +Registered users of the soundtrack application. + +`/register` +`POST` to create a user (deprecated!!!) + +`/login` +`POST` to initiate a session (deprecated!!!) + +`/people` +`POST` to create a user, `GET` to retrieve a list of users. + +`/people/:usernameSlug` +`GET` to retrieve a specific user, `PATCH` to update specific fields, `PUT` to create a user. + +`/:usernameSlug` +Retrieves a specific user, if it exists. Falls back wherever possible or 404s. + +### Auths +These will add an external profile to the currently established session's user, or **create a new user if not**. + +`/auth/lastfm` +`/auth/spotify` +`/auth/google` + +## Artist +Artists which have been recognized by the soundtrack server. + +`/artists` +`POST` to create an artist, `GET` to retrieve a list of artists. + +`/artists/:artistSlug` +`GET` to retrieve a specific artist, `PATCH` to update specific fields, `PUT` to create an artist. + +## Chat +Chat messages for a specific room. + +`:roomSlug\.yourdomain\.tld/chat` +`GET` retrives a list of the most recent messages, `POST` to create a message. + +## Play +Moments when a track has been "played" in a Room; there are special rules for when it's considered a play, following last.fm's scrobbling rules. Only if more than 30s of a track has been listened to, and counting as a play when 2 minutes or half the track has been played, whichever happens first. + +`:roomSlug\.yourdomain\.tld/plays` +`GET` retrieves a list of all plays for a room. + +`/:usernameSlug/plays` +`GET` to retrieve a list of all plays by a specific user. + +## Source +Audio sources and their appropriate metadata. Not yet functional. + +## Set +Bundles of `Track` resources that are meant to be played together (also, `Playlists`). + +`/:usernameSlug/sets` +`GET` to retrieve a list of the users' sets, `POST` to create one. + +`/:usernameSlug/:setSlug` +`GET` to retrieve a specific set, `PATCH` to update it. + +`/sets` +`GET` to retrieve a list of all public sets. + +## Track +Tracks which have been recognized by the soundtrack server. + +`/tracks` +`GET` to retrieve a list of tracks, `POST` to create a track. + +`/tracks/:trackID` +`GET` to retrive a specific track by its `_id`, `PATCH` to update specific components of that track (e.g., editing the title) diff --git a/lang/index.js b/lang/index.js new file mode 100644 index 00000000..cb11ce72 --- /dev/null +++ b/lang/index.js @@ -0,0 +1,6 @@ +module.exports = { + en: { + intro: 'Welcome to soundtrack.io!
Thanks for joining. You\'ll notice we didn\'t collect your email address, so make sure to remember your password – we can\'t recover it for you. Maybe add one of our supported services so you can log in without it?

Some First Steps:

' + } + +} diff --git a/lib/ChangeTip.js b/lib/ChangeTip.js new file mode 100644 index 00000000..ffc09c88 --- /dev/null +++ b/lib/ChangeTip.js @@ -0,0 +1,67 @@ +var rest = require('restler'); +var moment = require('moment'); + +var ChangeTip = function(token) { + this.token = token; + this.base = 'https://api.changetip.com/v2/'; +}; + +ChangeTip.prototype.get = function(url, params, cb) { + var self = this; + + if (typeof params === 'function') { + cb = params; + params = {}; + } + + var qs = Object.keys( params ).map(function(k) { + return k + '=' + params[k]; + }); + + rest.get( self.base + url + '?' + qs.join('&') , { + headers: { + 'Authorization': 'Bearer ' + self.token + } + }).on('complete', function(data) { + cb( null , data ); + }); +}; + +ChangeTip.prototype.post = function(url, params, cb) { + var self = this; + + if (typeof params === 'function') { + cb = params; + params = {}; + } + + rest.post( self.base + url , { + headers: { + 'Authorization': 'Bearer ' + self.token + }, + data: params + }).on('complete', function(data) { + cb( null , data ); + }).on('error', cb ); +}; + +ChangeTip.prototype.postJSON = function(url, params, cb) { + var self = this; + + if (typeof params === 'function') { + cb = params; + params = {}; + } + + rest.post( self.base + url , { + headers: { + 'Authorization': 'Bearer ' + self.token, + 'Content-Type': 'application/json' + }, + data: JSON.stringify(params) + }).on('complete', function(data) { + cb( null , data ); + }).on('error', cb ); +}; + +module.exports = ChangeTip; diff --git a/lib/Queue.js b/lib/Queue.js new file mode 100644 index 00000000..82d468cf --- /dev/null +++ b/lib/Queue.js @@ -0,0 +1,163 @@ +require('debug-trace')({ always: true }); + +var util = require('util'); +var EventEmitter = require('events').EventEmitter; + +var async = require('async'); +var redis = require('redis'); + +var Queue = function(config) { + var self = this; + + self._database = config.app.name || 'maki'; + self._redis = redis.createClient( config.redis.port , config.redis.host ); + + self._kue = require('kue'); + self._kue.redis.createClient = function() { + console.log('createClient()'); + return self._redis; + } + + self._jobs = self._kue.createQueue({ + prefix: config.app.name + ':q' + }); + + self.jobs = []; + +} +var Job = function( job ) { + // pull things from the internal job + this.id = job._job.id; + this.name = job.name; + this.type = job._job.data.type; + this.date = (new Date()).getTime(); + this.data = job._job.data.data; + this._job = job._job; +}; +var Worker = function( queue , type ) { + this.queue = queue; + this.type = type; +}; + +util.inherits( Queue , EventEmitter ); +util.inherits( Job , EventEmitter ); +util.inherits( Worker , EventEmitter ); + +Queue.prototype.get = function( jobID , cb ) { + var self = this; + + self._redis.get('jobs:' + jobID, function(err, reply) { + if (err || !reply) throw new Error('something went wrong:' + err); + + try { + var job = JSON.parse(reply); + } catch(e) { + return cb(e); + } + + if (!job) { + return self.cleanup( jobID , function() { + cb('no such entry in redis') + }); + } + + // kue-specific + self._kue.Job.get( jobID , function(err , thisKueJob ) { + if (err) return cb( err ); + job._job = thisKueJob; + var fullJob = new Job( job ); + return cb( null , fullJob ); + }); + }); +} + +Queue.prototype.register = function( kueJob ) { + +}; + +Queue.prototype.cleanup = function( jobID , cleanupComplete ) { + var self = this; + async.series([ + function(d) { self._redis.zrem('queue', jobID , d ); }, + function(d) { self._redis.del('job:' + jobID, jobID , d ); } + ], function(err) { + cleanupComplete( err ); + }); +} + +Queue.prototype.push = function( name , data , callback ) { + var self = this; + + var kueJob = self._jobs.create( self._database , data ); + + // now actually save and emit + kueJob.save(function(err) { + console.log('job saved to kue'); + + // our internal version of the job + var queueJob = new Job({ + title: data.name, + _job: kueJob + }); + + self.jobs.push( queueJob ); + + // save the data for the job + function addJob( j , d ) { + var metadata = j; + delete metadata._job; + self._redis.set('jobs:' + j.id , JSON.stringify( metadata ), d ); + } + + // take the job ID and insert it into the queue + function addJobToQueue( jobID , d ) { + self._redis.zadd( 'queue' , jobID , jobID , d ); + } + + async.waterfall([ + function(done) { addJob( queueJob , done ); }, + function(arg1 , done) { addJobToQueue( queueJob.id , done ); } + ], function(err, results) { + console.log('supppppppp') + console.log(err, results); + + self.emit('job', queueJob ); + + callback( err , queueJob ); + + }); + }); +} + +Queue.prototype.process = function( type , evaluator ) { + var self = this; + // get the list of previous jobs + self._redis.zrange('queue', 0, -1, function(e, jobs ) { + // populate them with their corresponding internals: + // - get the metadata (specific to Queue) + // - get the specific engine's version of the job (Kue) + async.map( jobs , function( j , c ) { + self.get( j , c ); + }, function(err, realJobs) { + if (err) console.log(err); + + self.jobs = realJobs; + console.log('queue strapped.', self.jobs.length + ' jobs available'); + + self._jobs.process( self._database , function( kueJob , kueComplete ) { + + console.log('inside kue job... id ' + kueJob.id ); + + self.get( kueJob.id , function(err, job) { + evaluator( job , function(err, result) { + self.cleanup( kueJob.id , function() { + kueComplete( err , result ); + }); + }); + }); + }); + }); + }); + +} +module.exports = Queue; \ No newline at end of file diff --git a/lib/YouTube.js b/lib/YouTube.js new file mode 100644 index 00000000..bf4c393b --- /dev/null +++ b/lib/YouTube.js @@ -0,0 +1,38 @@ +var rest = require('restler'); +var moment = require('moment'); + +var YouTube = function(key) { + this.key = key; + this.base = 'https://www.googleapis.com/youtube/v3/'; +} + +YouTube.prototype.get = function(url, params, cb) { + var self = this; + + params.key = self.key; + + var qs = Object.keys( params ).map(function(k) { + return k + '=' + params[k]; + }); + + rest.get( self.base + url + '?' + qs.join('&') ).on('complete', function(data) { + cb( null , data ); + }); +} + +YouTube.prototype._getVideo = function( id , cb ) { + var self = this; + self.get('videos', { + id: id, + part: 'contentDetails,snippet' + }, function(err, result) { + if (err || !result || !result.items || !result.items.length) return cb(err || 'no result'); + var video = result.items[0]; + video.title = video.snippet.title; + video.duration = moment.duration(video.contentDetails.duration).as('seconds'); + video.images = video.snippet.thumbnails; + return cb( null , video ); + }); +} + +module.exports = YouTube; diff --git a/lib/last.fm.js b/lib/last.fm.js new file mode 100644 index 00000000..d7a25731 --- /dev/null +++ b/lib/last.fm.js @@ -0,0 +1,85 @@ +var _ = require('underscore'); + +module.exports = { + authSetup: function(req, res) { + //var authUrl = lastfm.getAuthenticationUrl({ cb: ((config.app.safe) ? 'http://' : 'http://') + config.app.host + '/auth/lastfm/callback' }); + var authUrl = lastfm.getAuthenticationUrl({ cb: (( app.config.app.safe) ? 'http://' : 'http://') + 'soundtrack.io/auth/lastfm/callback' }); + res.redirect(authUrl); + }, + authCallback: function(req, res) { + lastfm.authenticate( req.param('token') , function(err, session) { + console.log(session); + + if (err) { + console.log(err); + req.flash('error', 'Something went wrong with authentication.'); + return res.redirect('/'); + } + + Person.findOne({ $or: [ + { _id: (req.user) ? req.user._id : undefined } + , { 'profiles.lastfm.username': session.username } + ]}).exec(function(err, person) { + + if (!person) { + var person = new Person({ username: 'reset this later ' }); + } + + person.profiles.lastfm = { + username: session.username + , key: session.key + , updated: new Date() + }; + + person.save(function(err) { + if (err) { console.log(err); } + req.session.passport.user = person._id; + res.redirect('/'); + }); + + }); + + }); + }, + scrobbleActive: function(requestedTrack, cb) { + console.log('scrobbling to active listeners...'); + + Track.findOne({ _id: requestedTrack._id }).populate('_artist').exec(function(err, track) { + if (!track || track._artist.name && track._artist.name.toLowerCase() == 'gobbly') { return false; } + + Person.find({ _id: { $in: _.toArray(app.room.listeners).map(function(x) { return x._id; }) } }).exec(function(err, people) { + _.filter( people , function(x) { + console.log('evaluating listener:'); + console.log(x); + return (x.profiles && x.profiles.lastfm && x.profiles.lastfm.username && x.preferences.scrobble); + } ).forEach(function(user) { + console.log('listener available:'); + console.log(user); + + var lastfm = new app.LastFM({ + api_key: app.config.lastfm.key + , secret: app.config.lastfm.secret + }); + + var creds = { + username: user.profiles.lastfm.username + , key: user.profiles.lastfm.key + }; + + lastfm.setSessionCredentials( creds.username , creds.key ); + lastfm.track.scrobble({ + artist: track._artist.name + , track: track.title + , timestamp: Math.floor((new Date()).getTime() / 1000) - 300 + }, function(err, scrobbles) { + if (err) { return console.log('le fail...', err); } + + console.log(scrobbles); + cb(); + }); + }); + }); + }); + + } +} \ No newline at end of file diff --git a/lib/soundtrack.js b/lib/soundtrack.js new file mode 100644 index 00000000..e9138a21 --- /dev/null +++ b/lib/soundtrack.js @@ -0,0 +1,675 @@ +var _ = require('underscore'); +var util = require('../util'); +var rest = require('restler'); +var async = require('async'); +var slug = require('speakingurl'); + +var YouTube = require('./YouTube'); + +var Soundtrack = function(app) { + var self = this; + + this.app = app; + this.app.rooms = {}; + this.backupTracks = []; + this.timers = { + scrobble: {} + }; + + this.DEBUG = false; + + this.youtube = new YouTube('AIzaSyBnCN68b8W5oGgBKKkM2cSQhSygnLPApEs'); + +}; + +Soundtrack.prototype.start = function() { + var self = this; + // periodically check for idle sockets + setInterval(function() { + self.markAndSweep(); + }, self.app.config.connection.checkInterval ); +}; + +Soundtrack.prototype.sortPlaylist = function() { + var self = this; + var app = this.app; + app.room.playlist = _.union( [ app.room.playlist[0] ] , app.room.playlist.slice(1).sort(function(a, b) { + if (b.score === a.score) { + return a.timestamp - b.timestamp; + } else { + return b.score - a.score; + } + }) ); +} +Soundtrack.prototype.broadcast = function(msg) { + var self = this; + var app = this.app; + switch (msg.type) { + // special handling for edits + // this allows us to simply update the in-memory version of a track, + // rather than querying mongoDB several more times for data. + case 'edit': + for (var roomName in app.rooms) { + var room = app.rooms[ roomName ]; + for (var i = 0; i < room.playlist.length; i++) { + if (!room.playlist[ i ]._id || !msg.track._id) return console.error('no id somewhere...'); + if ( room.playlist[ i ]._id.toString() == msg.track._id.toString() ) { + room.playlist[ i ].title = msg.track.title; + room.playlist[ i ].slug = msg.track.slug; + room.playlist[ i ].flags = msg.track.flags; + room.playlist[ i ]._artist.name = msg.track._artist.name; + room.playlist[ i ]._artist.slug = msg.track._artist.slug; + } + } + } + break; + } + + //app.redis.set(app.config.database.name + ':playlist', JSON.stringify( app.room.playlist ) ); + + var json = JSON.stringify(msg); + for (var id in app.clients) { + app.clients[id].write(json); + } +}; +Soundtrack.prototype.whisper = function(id, msg) { + var self = this; + var app = this.app; + var json = JSON.stringify(msg); + app.clients[id].write(json); +}; +Soundtrack.prototype.markAndSweep = function(){ + var self = this; + var app = this.app; + + self.broadcast({type: 'ping'}); // we should probably not do this globally... instead, start interval after client connect? + + var time = (new Date()).getTime(); + self.forEachClient(function(client, id){ + if (client.pongTime < time - app.config.connection.clientTimeout) { + client.close('', 'Timeout'); + + for (var roomName in app.rooms) { + var room = app.rooms[ roomName ]; + + if (client.user) { + if (!room.listeners[ client.user._id ]) { + room.listeners[ client.user._id ] = { ids: [] }; + } + + room.listeners[ client.user._id ].ids = _.reject( room.listeners[ client.user._id ].ids , function(x) { + return x == client.id; + }); + } + + for (var userID in room.listeners) { + if (room.listeners[ userID ].ids.length === 0) { + delete room.listeners[ userID ]; + self.broadcast({ + type: 'part' + , data: { + _id: (app.clients[id] && app.clients[id].user) ? app.clients[id].user._id : undefined + } + }); + } + } + } + + delete app.clients[id]; + } + }); +}; +Soundtrack.prototype.forEachClient = function(fn) { + var self = this; + var app = this.app; + for (var id in app.clients) { + fn(app.clients[id], id) + } +}; + +Soundtrack.prototype.trackFromSource = function(source, id, sourceCallback) { + var self = this; + var app = self.app; + + if (self.DEBUG) console.log('trackFromSource() : ' + source + ' ' + id ); + + switch (source) { + default: + sourceCallback('Unknown source: ' + source); + break; + case 'soundtrack': + Track.findOne({ _id: id }).populate('_artist').exec( sourceCallback ); + break; + case 'bandcamp': + var obj = id; + Artist.findOne({ slug: slug(obj.artist) }).exec(function(err, artist) { + if (err || !artist) var artist = new Artist({ name: obj.artist }); + + Track.findOne({ + slug: slug(obj.title), + _artist: artist._id + }).exec(function(err, track) { + if (err || !track) var track = new Track({ title: obj.title, sources: obj.sources }); + + track._artist = artist._id; + track.duration = obj.duration; + + if (obj.thumbnail) { + track.images.thumbnail = obj.thumbnail; + } + track.sources.bandcamp = [ { + id: obj.id, + + data: { + track: obj.id, + url: obj.url, + artwork_url: obj.artwork_url, + size: 'large', + bgcol: 'ffffff', + linkcol: '0687f5', + tracklist: false, + transparent: true, + baseUrl: 'http://bandcamp.com/EmbeddedPlayer/' + } + } ] + + artist.save(function(err) { + if (err) console.log(err); + track.save(function(err) { + if (err) console.log(err); + track._artist = artist; + sourceCallback( err , track ); + }); + }); + + }); + + }); + break; + case 'object': + var obj = id; + Artist.findOne({ slug: slug(obj.artist) }).exec(function(err, artist) { + if (err || !artist) var artist = new Artist({ name: obj.artist }); + + Track.findOne({ + slug: slug(obj.title), + _artist: artist._id + }).exec(function(err, track) { + if (err || !track) var track = new Track({ title: obj.title }); + + track._artist = artist._id; + track.duration = obj.duration; + + artist.save(function(err) { + if (err) console.log(err); + track.save(function(err) { + if (err) console.log(err); + + track._artist = artist; + sourceCallback( err , track ); + + if (app.config.jobs && app.config.jobs.enabled) { + app.agency.publish('track:crawl', { + id: track._id + }, function(err) { + console.log('track crawling completed'); + }); + } + + }); + }); + + }); + + }); + break; + case 'lastfm': + // TODO: make this work at top level of this function + var data = id; + + if (!data.url) { return sourceCallback('no url (id used for lastfm)'); } + if (!data.name) { return sourceCallback('no title'); } + + Track.findOne({ 'sources.lastfm.id': data.url }).exec(function(err, track) { + if (err) { return sourceCallback(err); } + + Artist.findOne({ $or: [ + { slug: slug( data.artist.name ) } + , { name: data.artist.name } + ] }).exec(function(err, artist) { + if (err) { return sourceCallback(err); } + + if (!artist) { + var artist = new Artist({ name: data.artist.name }); + } + + artist.tracking.tracks.updated = new Date(); + + artist.save(function(err) { + if (err) { return sourceCallback(err); } + + if (!track) { + var track = new Track({ + title: data.name + , _artist: artist._id + , duration: data.duration + , sources: { + lastfm: [ { + id: data.url + , duration: data.duration + , data: data + } ] + } + }); + } + + self.gatherSources( track , function(err, gatheredTrack) { + // if there was an error, do NOT save! + // this prevents tracks with no playable sources from saving to + // the database. + if (err) { + console.log( err ); + return sourceCallback(err); + } + + track.save(function(err) { + return sourceCallback( err , track ); + }); + }); + + }); + }); + }); + break; + case 'soundcloud': + rest.get('https://api.soundcloud.com/tracks/'+parseInt(id)+'.json?client_id='+app.config.soundcloud.id).on('complete', function(data, response) { + console.log(response); + if (!data.title) return sourceCallback('No Soundcloud track found in ' , data ); + + var TRACK_SEPARATOR = ' - '; + var stringToParse = (data.title.split( TRACK_SEPARATOR ).length > 1) ? data.title : data.user.username + ' - ' + data.title; + + util.parseTitleString( stringToParse , function(parts) { + + //console.log('parts: ' + JSON.stringify(parts) ); + + // does the track already exist? + Track.findOne({ $or: [ + { 'sources.soundcloud.id': data.id } + ] }).exec(function(err, track) { + if (!track) { var track = new Track({}); } // no? create a new one. + + // does the artist already exist? + Artist.findOne({ $or: [ + { _id: track._artist } + , { slug: slug( parts.artist ) } + ] }).exec(function(err, artist) { + if (err) { console.log(err); } + if (!artist) { var artist = new Artist({}); } // no? create a new one. + + artist.name = artist.name || parts.artist; + + artist.save(function(err) { + if (err) { console.log(err); } + + track.title = track.title || parts.title; + track._artist = track._artist || artist._id; + track.duration = track.duration || data.duration / 1000; + + var sourceIDs = track.sources[ source ].map(function(x) { return x.id; }); + var index = sourceIDs.indexOf( data.id ); + if (index == -1) { + track.sources[ source ].push({ + id: data.id + , data: data + }); + } else { + track.sources[ source ][ index ].data = data; + } + + track.save(function(err) { + Artist.populate(track, { + path: '_artist' + }, function(err, track) { + sourceCallback(err, track); + }); + }); + + }); + + }); + + }); + }); + }); + break; + case 'youtube': + self.getYoutubeVideo( id , function(track) { + if (track) { + return sourceCallback(null, track); + } else { + return sourceCallback('No track returned.'); + } + }); + break; + } +}; + +Soundtrack.prototype.getYoutubeVideo = function(videoID, internalCallback) { + var self = this; + var app = self.app; + + console.log('getYoutubeVideo() : ' + videoID ); + + self.youtube._getVideo( videoID , function(err, video) { + if (err || !video) return internalCallback('error retrieving video from youtube, ' + (err || video)); + + Track.findOne({ + 'sources.youtube.id': video.id + }).exec(function(err, track) { + if (err) console.error( err ); + if (track) { + return Artist.populate( track , { + path: '_artist' + }, function(err, track) { + internalCallback( track ); + }); + } + + var track = new Track({ title: video.title }); + + util.parseTitleString( video.title , function(parts) { + + if (self.DEBUG) console.log( video.title + ' was parsed into:'); + if (self.DEBUG) console.log(parts); + + async.mapSeries( parts.credits , function( artistName , artistCollector ) { + Artist.findOne({ $or: [ + { slug: slug( artistName ) } + , { name: artistName } + ] }).exec( function(err, artist) { + if (!artist) var artist = new Artist({ name: artistName }); + + artist.save(function(err) { + if (err) { console.log(err); } + artistCollector(err, artist); + }); + }); + }, function(err, results) { + + Artist.findOne({ $or: [ + { _id: track._artist } + , { slug: slug( parts.artist ) } + , { name: parts.artist } + ] }).exec(function(err, artist) { + if (!artist) var artist = new Artist({ name: parts.artist }); + artist.save(function(err) { + if (err) console.log(err); + + // only use parsed version if original title is unchanged + track.title = (track.title == video.title) ? parts.title : track.title; + track._artist = artist._id; + track._credits = results.map(function(x) { return x._id; }); + console.log(video); + track.duration = (track.duration) ? track.duration : video.duration; + track.images.thumbnail.url = (track.images.thumbnail.url) ? track.images.thumbnail.url : video.images.default.url; + + var youtubeVideoIDs = track.sources.youtube.map(function(x) { return x.id; }); + var index = youtubeVideoIDs.indexOf( video.id ); + if (index == -1) { + track.sources.youtube.push({ + id: video.id + , data: video + }); + } else { + track.sources.youtube[ index ].data = video; + } + + track.save(function(err) { + if (err) console.log(err); + + // begin cleanup + //track = track.toObject(); + track._artist = { + _id: artist._id + , name: artist.name + , slug: artist.slug + }; + + for (var source in track.sources.toObject()) { + for (var i = 0; i < track.sources[ source ].length; i++) { + delete track.sources[ source ].data; + } + } + // end cleanup + + internalCallback( track ); + }); + }); + }); + }); + }); + }); + }); +}; + +Soundtrack.prototype.gatherSources = function(track , callback) { + var self = this; + + var now = new Date(); + + Artist.populate( track , { + path: '_artist' + }, function(err, track) { + if (err) return callback(err); + if (!track._artist) return callback('no artist provided'); + + var fullTitle = track._artist.name + ' - ' + track.title; + var query = encodeURIComponent( fullTitle ); + var lenMax = track.duration + (track.duration * 0.05); + var lenMin = track.duration - (track.duration * 0.05); + + var maxTracks = 5; + + async.parallel([ + function(done) { + rest.get('https://gdata.youtube.com/feeds/api/videos?max-results='+maxTracks+'&v=2&alt=jsonc&q=' + query ).on('complete', function(data) { + var functions = []; + + if (data.data && data.data.items) { + + console.log('youtube videos gathered, %d videos', data.data.items.length); + + for (var i = 0; i < data.data.items.length; i++) { + if (self.DEBUG) console.log('trying youtube %d', i ); + + var item = data.data.items[ i ]; + if (item.title != fullTitle) { continue; } + + if (item.duration > lenMin && item.duration < lenMax) { + functions.push( function(cb) { + self.trackFromSource( 'youtube' , item.id , cb ); + } ); + } + } + + console.log('%d items, %e functions', data.data.items.length , functions.length ); + + } + + async.parallel( functions , function( err , youtubeTracks ) { + youtubeTracks.forEach(function(singleTrack) { + if (!singleTrack.sources) { + console.log('wattttttttttttttt', singleTrack); + return; + } + singleTrack.sources.youtube.forEach(function(youtubeSource) { + track.sources.youtube.push( youtubeSource ); + }); + }); + + console.log('youtube complete!'); + done( err , youtubeTracks ); + }); + + }); + }, + function(done) { + rest.get('https://api.soundcloud.com/tracks.json?limit='+maxTracks+'&client_id=7fbc3f4099d3390415d4c95f16f639ae&q='+query).on('complete', function(data) { + var functions = []; + + if (data instanceof Array) { + + console.log('soundcloud gathered, %d tracks', data.length); + for (var i = 0; i < data.length; i++) { + if (self.DEBUG) console.log('trying soundcloud %d', i ); + + var item = data[ i ]; + item.duration = item.duration / 1000; + if (item.duration > lenMin && item.duration < lenMax) { + if (item.title != fullTitle) { continue; } + + functions.push( function(cb) { + self.trackFromSource( 'soundcloud' , item.id , cb ); + } ); + } + } + + console.log('%d items, %e functions', data.length , functions.length ); + } + + async.parallel( functions , function( err , soundcloudTracks ) { + + soundcloudTracks.forEach(function(singleTrack) { + if (!singleTrack) return; + singleTrack.sources.soundcloud.forEach(function(soundcloudSource) { + track.sources.soundcloud.push( soundcloudSource ); + }); + }); + + console.log('soundcloud complete!'); + done( err , soundcloudTracks ); + }); + + }); + } + ], function(err, results) { + console.log('all sources complete!'); + + track.updated = new Date(); + + var playableSources = 0; + for (var source in track.sources) { + if (!track.sources[ source ]) continue; + for (var i = 0; i < track.sources[ source ].length; i++) { + if (['soundcloud', 'youtube'].indexOf( source ) >= 0) playableSources += 1; + } + } + + if (!playableSources) { + if (self.DEBUG) console.log('HEYYYYYYYYY NONE'); + return callback('No playable sources.'); + } + + if (self.DEBUG) console.log('saving...'); + + track.save(function(err) { + console.log('saved!', err); + callback(err, results); + }); + + }); + }); +} + + +Soundtrack.prototype.lastfmAuthSetup = function(req, res) { + var self = this; + var app = this.app; + + //var authUrl = lastfm.getAuthenticationUrl({ cb: ((config.app.safe) ? 'http://' : 'http://') + config.app.host + '/auth/lastfm/callback' }); + var authUrl = app.lastfm.getAuthenticationUrl({ cb: 'https://soundtrack.io/auth/lastfm/callback' }); + res.redirect(authUrl); +}; +Soundtrack.prototype.lastfmAuthCallback = function(req, res) { + var self = this; + var app = this.app; + + lastfm.authenticate( req.param('token') , function(err, session) { + console.log(session); + + if (err) { + console.log(err); + req.flash('error', 'Something went wrong with authentication.'); + return res.redirect('/'); + } + + Person.findOne({ $or: [ + { _id: (req.user) ? req.user._id : undefined } + , { 'profiles.lastfm.username': session.username } + ]}).exec(function(err, person) { + + if (!person) { + var person = new Person({ username: 'reset this later ' }); + } + + person.profiles.lastfm = { + username: session.username + , key: session.key + , updated: new Date() + }; + + person.save(function(err) { + if (err) { console.log(err); } + req.session.passport.user = person._id; + res.redirect('/'); + }); + + }); + + }); +}; +Soundtrack.prototype.scrobbleActive = function(requestedTrack, cb) { + var self = this; + var app = this.app; + + console.log('scrobbling to active listeners...'); + + Track.findOne({ _id: requestedTrack._id }).populate('_artist').exec(function(err, track) { + if (!track || track._artist.name && track._artist.name.toLowerCase() == 'gobbly') { return false; } + + Person.find({ _id: { $in: _.toArray(app.room.listeners).map(function(x) { return x._id; }) } }).exec(function(err, people) { + _.filter( people , function(x) { + console.log('evaluating listener:'); + console.log(x); + return (x.profiles && x.profiles.lastfm && x.profiles.lastfm.username && x.preferences.scrobble); + } ).forEach(function(user) { + console.log('listener available:' + user._id + ' ' + user.username ); + + var lastfm = new app.LastFM({ + api_key: app.config.lastfm.key + , secret: app.config.lastfm.secret + }); + + var creds = { + username: user.profiles.lastfm.username + , key: user.profiles.lastfm.key + }; + + lastfm.setSessionCredentials( creds.username , creds.key ); + lastfm.track.scrobble({ + artist: track._artist.name + , track: track.title + , timestamp: Math.floor((new Date()).getTime() / 1000) - 300 + }, function(err, scrobbles) { + if (err) { return console.log('le fail...', err); } + + console.log(scrobbles); + cb(); + }); + }); + }); + }); +} + +module.exports = Soundtrack; diff --git a/models/Artist.js b/models/Artist.js index 4eb0a9df..9966dae9 100644 --- a/models/Artist.js +++ b/models/Artist.js @@ -8,28 +8,46 @@ var mongoose = require('mongoose') // this defines the fields associated with the model, // and moreover, their type. var ArtistSchema = new Schema({ - name: { type: String, required: true, unique: true } + name: { type: String, required: true, unique: true } // canonical name + , names: [ { type: String } ] // known names , image: { url: { type: String, default: 'http://coursefork.org/img/user-avatar.png' } } , bio: String + , tracking: { + tracks: { + updated: { type: Date , default: 0 } + } + } }); -ArtistSchema.post('init', function() { +ArtistSchema.pre('save', function(next) { var self = this; if (!self.bio || !self.image.url) { - rest.get('http://ws.audioscrobbler.com/2.0/?method=artist.getinfo&artist=Cher&format=json&api_key=89a54d8c58f533944fee0196aa227341').on('complete', function(data) { + rest.get('http://ws.audioscrobbler.com/2.0/?method=artist.getinfo&artist='+encodeURIComponent(self.name)+'&format=json&api_key=89a54d8c58f533944fee0196aa227341').on('complete', function(data) { if (data.artist) { - self.bio = strip_tags(data.artist.bio.summary).replace(/Read more about (.*) on Last.fm./, ''); - self.image.url = data.artist.image[3]['#text']; + if (data.artist.bio) { + self.bio = strip_tags(data.artist.bio.summary).replace(/Read more about (.*) on Last.fm./, ''); + } + if (data.artist.image[3]) { + self.image.url = data.artist.image[3]['#text']; + } } + + next(); }); + } else { + next(); } }); -ArtistSchema.plugin( slug('name') ); +ArtistSchema.plugin( slug('name', { + track: true + , lang: false +}) ); ArtistSchema.index({ slug: 1 }); +ArtistSchema.index({ slugs: 1 }); var Artist = mongoose.model('Artist', ArtistSchema); @@ -77,4 +95,4 @@ function strip_tags (input, allowed) { return input.replace(commentsAndPhpTags, '').replace(tags, function ($0, $1) { return allowed.indexOf('<' + $1.toLowerCase() + '>') > -1 ? $0 : ''; }); -} \ No newline at end of file +} diff --git a/models/Chat.js b/models/Chat.js index b842714f..37da2b30 100644 --- a/models/Chat.js +++ b/models/Chat.js @@ -6,15 +6,20 @@ var ChatSchema = new Schema({ _author: { type: ObjectId, required: true, ref: 'Person' } , created: { type: Date, default: Date.now } , message: { type: String } + , _track: { type: ObjectId, ref: 'Track', index: true } + , _play: { type: ObjectId, ref: 'Play' , index: true } + , _room: { type: ObjectId, ref: 'Room' , index: true } }); ChatSchema.virtual('isoDate').get(function() { return this.created.toISOString(); }); +ChatSchema.index({ _room: 1 , created: -1 }); + var Chat = mongoose.model('Chat', ChatSchema); // export the model to anything requiring it. module.exports = { Chat: Chat -}; \ No newline at end of file +}; diff --git a/models/Person.js b/models/Person.js index e83c0592..75c8480b 100644 --- a/models/Person.js +++ b/models/Person.js @@ -8,7 +8,8 @@ var mongoose = require('mongoose') // and moreover, their type. var PersonSchema = new Schema({ email: { type: String, unique: true, sparse: true } - , roles: [ { type: String, enum: ['editor'] } ] + , roles: [ { type: String, enum: ['editor', 'moderator'] } ] + , created: { type: Date , default: Date.now , required: true } , avatar: { url: { type: String, default: '/img/user-avatar.png' } } @@ -19,11 +20,33 @@ var PersonSchema = new Schema({ , username: String , key: String , updated: Date + }, + spotify: { + id: String, + username: String, + token: String, + updated: Date, + expires: Number, + playlists: [] + }, + google: { + id: String, + username: String, + token: String, + updated: Date, + playlists: [] + }, + changetip: { + id: String, + username: String, + token: String, + updated: Date } } , preferences: { scrobble: { type: Boolean, default: true } } + , _playlists: [ { type: ObjectId , ref: 'Playlist' } ] }); PersonSchema.plugin(passportLocalMongoose); diff --git a/models/Play.js b/models/Play.js index 9808c698..aaf05d96 100644 --- a/models/Play.js +++ b/models/Play.js @@ -5,15 +5,23 @@ var mongoose = require('mongoose') // this defines the fields associated with the model, // and moreover, their type. var PlaySchema = new Schema({ - _track: { type: ObjectId, ref: 'Track' } - , _curator: { type: ObjectId, ref: 'Person' } - , timestamp: { type: Date, default: Date.now } + _track: { type: ObjectId, ref: 'Track' , index: true } + , _artist: { type: ObjectId, ref: 'Artist' } + , _artists: [ { type: ObjectId, ref: 'Artist' } ] + , _curator: { type: ObjectId, ref: 'Person', index: true } + , _room: { type: ObjectId, ref: 'Room', required: true , index: true } + , timestamp: { type: Date, default: Date.now, index: true } + , length: { type: Number } + , played: { type: Number } }); PlaySchema.virtual('isoDate').get(function() { return this.timestamp.toISOString(); }); +PlaySchema.index({ timestamp: 1 , _room: 1 }); +PlaySchema.index({ timestamp: 1 , _room: 1 }); + var Play = mongoose.model('Play', PlaySchema); // export the model to anything requiring it. diff --git a/models/Playlist.js b/models/Playlist.js index a4087ff9..84c1caf7 100644 --- a/models/Playlist.js +++ b/models/Playlist.js @@ -12,14 +12,27 @@ var PlaylistSchema = new Schema({ , created: { type: Date, default: Date.now } , updated: { type: Date } , _creator: { type: ObjectId, ref: 'Person' } + , _owner: { type: ObjectId, ref: 'Person', index: true } + , _parent: { type: ObjectId, ref: 'Playlist' } , _tracks: [ { type: ObjectId, ref: 'Track' } ] , _subscribers: [ { type: ObjectId, ref: 'Person' } ] + , remotes: { + spotify: { + id: String, + updated: { type: Date , default: Date.now } + } + } }); PlaylistSchema.virtual('isoDate').get(function() { return this.timestamp.toISOString(); }); +PlaylistSchema.post('init', function() { + if (!this._owner) this._owner = this._creator; + if (!this.updated) this.updated = this.created; +}); + PlaylistSchema.plugin( slug('name') ); PlaylistSchema.index({ slug: 1 }); diff --git a/models/Room.js b/models/Room.js new file mode 100644 index 00000000..c182fea7 --- /dev/null +++ b/models/Room.js @@ -0,0 +1,429 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var ObjectId = mongoose.SchemaTypes.ObjectId; +var slug = require('mongoose-slug'); + +var _ = require('underscore'); +var async = require('async'); +var util = require('../util'); + +var config = require('../config'); + +// this defines the fields associated with the model, +// and moreover, their type. +var RoomSchema = new Schema({ + name: { type: String , required: true } + , description: { type: String } + , _creator: { type: ObjectId, ref: 'Person' } + , _owner: { type: ObjectId, ref: 'Person' } + , created: { type: Date, default: Date.now } + , _moderators: [ { type: ObjectId , ref: 'Person' } ] +}); + +RoomSchema.plugin( slug('name'), { + required: true +} ); +RoomSchema.index({ slug: 1 }); + +RoomSchema.virtual('index').get(function() { + var protocol = (config.app.safe) ? 'https' : 'http'; + return protocol + '://' + this.slug + '.' + config.app.host; +}); + +RoomSchema.methods.bind = function( soundtrack ) { + this.soundtrack = soundtrack; +}; +RoomSchema.methods.broadcast = function( msg , GLOBAL ) { + if (GLOBAL) return this.soundtrack.broadcast( msg ); + + var room = this; + var app = room.soundtrack.app; + + var myClients = _.flatten( _.toArray( room.listeners ).map(function(l) { + return l.ids; + }) ); + + var json = JSON.stringify(msg); + for (var id in app.clients) { + if (app.clients[id].room === room._id.toString()) { + app.clients[id].write(json); + } + } +}; +RoomSchema.methods.queueTrack = function( track , curator , callback ) { + var room = this; + + Track.findOne({ _id: track._id }).populate('_artist _credits').exec(function(err, realTrack) { + if (err || !realTrack) return callback('Could not acquire that track.'); + + var playlistItem = realTrack.toObject(); + + playlistItem._artist = { + _id: playlistItem._artist._id + , name: playlistItem._artist.name + , slug: playlistItem._artist.slug + }; + + var playableSources = 0; + for (var source in playlistItem.sources) { + for (var i = 0; i < playlistItem.sources[ source ].length; i++) { + if (['soundcloud', 'youtube', 'bandcamp'].indexOf( source ) >= 0) playableSources += 1; + delete playlistItem.sources[ source ][ i ].data; + } + } + + if (!playableSources) { + return callback({ + status: 'error' + , message: 'No playable sources.' + }); + } + + var curatorObj = { + _id: curator._id + , username: curator.username + , slug: curator.slug + }; + + if (curator.profiles && curator.profiles.changetip && curator.profiles.changetip.username) { + curatorObj.changetip = curator.profiles.changetip.username; + } + + room.playlist.push( _.extend( playlistItem , { + score: 0 + , votes: {} // TODO: auto-upvote? + , timestamp: new Date() + , curator: curatorObj + } ) ); + + room.sortPlaylist(); + + room.savePlaylist(function() { + room.broadcast({ + type: 'playlist:add', + data: track + }); + return callback(); + }); + }); +}; +RoomSchema.methods.sortPlaylist = function() { + var room = this; + room.playlist = _.union( [ room.playlist[0] ] , room.playlist.slice(1).sort(function(a, b) { + if (b.score === a.score) { + return a.timestamp - b.timestamp; + } else { + return b.score - a.score; + } + }) ); +}; +RoomSchema.methods.savePlaylist = function( saved ) { + if (!saved) var saved = new Function(); + var self = this; + var app = self.soundtrack.app; + + //console.log('saving playlist'); + //console.log('as exists', self.playlist ); + //console.log('as stringified', JSON.stringify( self.playlist )); + + app.redis.set( app.config.database.name + ':rooms:' + self.slug + ':playlist', JSON.stringify( self.playlist ) ); + + app.rooms[ self.slug ] = self; + + saved(); +}; + +RoomSchema.methods.generatePool = function( gain , failpoint , cb ) { + var room = this; + var MAXIMUM_PLAY_AGE = 180; + + if (typeof(gain) === 'function') { + var cb = gain; + var gain = 0; + var failpoint = MAXIMUM_PLAY_AGE; + } + + if (typeof(failpoint) === 'function') { + var cb = failpoint; + var failpoint = MAXIMUM_PLAY_AGE; + } + + if (!gain) var gain = 0; + if (!failpoint) var failpoint = MAXIMUM_PLAY_AGE; + + var query = {}; + + // must be queued by a real person + query._curator = { $exists: true }; + // must have been played in this room + query._room = room._id; + // must have been queued within the past 7 days + query = _.extend( query , { + $or: util.timeSeries('timestamp', 3600*3*1000, 24*60*1000*60, 7 + gain ), + //timestamp: { $lt: (new Date()) - 3600 * 3 * 1000 } + }); + + // but not if it's been played recently! + // TODO: one level of callbacks to collect this! + Play.count({ _room: room._id }).exec(function(err, totalPlays) { + if (!totalPlays) { + // no tracks have ever been played. full query. + query = {}; + } else if (gain > failpoint) { + // just query the whole damned room. + query = { _room: room._id }; + } + + Play.find( query ).limit( 4096 ).sort('timestamp').exec(function(err, plays) { + Play.find({ + _room: room._id, + timestamp: { $gte: (new Date()) - 3600 * 3 * 1000 } + }).exec(function(err, exclusions) { + query.exclusionIDs = exclusions.map(function(x) { return x._track.toString(); }); + + plays = plays.filter(function(x) { + //console.log('filtering ', x ); + //console.log('exclusions checker,', x._track.toString() , 'in' , query.exclusionIDs , '?'); + //console.log(!~query.exclusionIDs.indexOf( x._track.toString() )); + return !~query.exclusionIDs.indexOf( x._track.toString() ); + }); + + if (err) console.error( err ); + if ((!plays || plays.length < 10) && (gain <= failpoint)) return room.generatePool( gain + 7 , failpoint , cb ); + if ((!plays) && (gain > failpoint)) return cb('init'); + + return cb( err , plays , query ); + + }); + + }); + }); + +}; +RoomSchema.methods.selectTrack = function( cb ) { + var room = this; + + room.generatePool(function(err, plays) { + if (err || !plays || plays.length === 0) { + console.log('room ' + room.slug + ' has no pool (POOL\'S CLOSED!)'); + return room.soundtrack.trackFromSource('youtube', 'wZThMWK9GxA', function(err, track) { + Artist.populate( track , '_artist' , cb ); + }); + } + + var randomSelection = plays[ _.random(0, plays.length - 1 ) ]; + Track.findOne({ _id: randomSelection._track }).populate('_artist').exec( cb ); + }); + +}; +RoomSchema.methods.ensureQueue = function(callback) { + var room = this; + if (room.playlist.length > 0) return callback(); + + room.selectTrack(function(err, track) { + if (err || !track) return callback( err ); + track.startTime = Date.now(); + // TODO: add score: 0 and votes: {}? + room.playlist.push( track ); + return callback(); + }); + +}; +RoomSchema.methods.nextSong = function( done ) { + if (!done) var done = new Function(); + var room = this; + var app = room.soundtrack.app; + + //console.log('old playlist length', room.playlist.length); + var lastTrack = room.playlist.shift(); + //console.log('lastTrack was', lastTrack); + //console.log('new playlist length', room.playlist.length); + + room.ensureQueue(function() { + room.savePlaylist(function() { + //console.log('saved, ', err ); + room.startMusic(function() { + console.log('nextSong() started music'); + done(); + }); + }); + }); +}; + +RoomSchema.methods.startMusic = function( cb ) { + var room = this; + if (!room.playlist[0]) { + console.log('no playlist'); + return Track.count(function(err, count) { + if (!count) return cb('no tracks. new install? TODO: base set.'); + var rand = Math.floor(Math.random() * count); + Track.findOne().skip( rand ).exec(function(err, track) { + room.playlist.push( track ); + room.savePlaylist(function(err) { + return cb('zero-length playlist. inserting random'); + }); + }); + }); + } + + room.track = room.playlist[0]; + if (!room.track.startTime) room.track.startTime = Date.now(); + + var now = Date.now(); + var seekTo = (now - room.playlist[0].startTime) / 1000; + + Track.findOne({ _id: room.track._id }).populate('_artist _artists').lean().exec(function(err, track) { + if (err || !track) return cb('no such track (severe error)'); + + // temporary collect exact matches... + // testing for future merging of track data for advances + var query = { _artist: track._artist._id , title: track.title, _id: { $ne: track._id } }; + Track.find( query ).lean().exec(function(err, tracks) { + var sources = track.sources; + tracks.forEach(function(t) { + for (var source in t.sources) { + sources[ source ] = _.union( sources[ source ] , t.sources[ source ] ); + } + }); + + room.broadcast({ + type: 'track', + data: _.extend( room.track , track ), + sources: sources, + seekTo: seekTo + }); + + clearTimeout( room.trackTimer ); + clearTimeout( room.permaTimer ); + + room.trackTimer = setTimeout(function() { + room.nextSong(); + }, (room.track.duration - seekTo) * 1000 ); + + if (room.soundtrack.app.lastfm) { + room.setListeningActive( room.track , new Function() ); + } + + if (room.track.duration > 30) { + var FOUR_MINUTES = 4 * 60; + var scrobbleTime = (room.track.duration > FOUR_MINUTES) ? FOUR_MINUTES : room.track.duration / 2; + + room.permaTimer = setTimeout(function() { + + async.parallel([ + insertIntoPlayHistory, + scrobbleIfEnabled + ], function(err, results) { + if (err) console.log(err); + console.log('play history updated and lastfm scrobbled!'); + }); + + function insertIntoPlayHistory( done ) { + var play = new Play({ + _track: room.track._id, + _curator: (room.track.curator) ? room.track.curator._id : undefined, + _room: room._id, + timestamp: now + }); + play.save( done ); + } + + function scrobbleIfEnabled( done ) { + if (!room.soundtrack.app.lastfm) return done(); + room.scrobbleActive( room.track , done ); + } + + }, scrobbleTime * 1000 ); + } + + return cb(); + + }); + }); +}; + +RoomSchema.methods.scrobbleActive = function(requestedTrack, cb) { + var room = this; + var app = room.soundtrack.app; + + console.log('scrobbling to active listeners...'); + + Track.findOne({ _id: requestedTrack._id }).populate('_artist').exec(function(err, track) { + if (!track || track._artist.name && track._artist.name.toLowerCase() == 'gobbly') { return false; } + + Person.find({ _id: { $in: _.toArray( room.listeners ).map(function(x) { return x._id; }) } }).exec(function(err, people) { + _.filter( people , function(x) { + return (x.profiles && x.profiles.lastfm && x.profiles.lastfm.username && x.preferences.scrobble); + } ).forEach(function(user) { + console.log('listener available:' + user._id + ' ' + user.username ); + + var lastfm = new app.LastFM({ + api_key: app.config.lastfm.key + , secret: app.config.lastfm.secret + }); + + var creds = { + username: user.profiles.lastfm.username + , key: user.profiles.lastfm.key + }; + + lastfm.setSessionCredentials( creds.username , creds.key ); + lastfm.track.scrobble({ + artist: track._artist.name + , track: track.title + , duration: Math.floor(track.duration) + , timestamp: Math.floor((new Date()).getTime() / 1000) + }, function(err, scrobbles) { + if (err) { return console.log('le fail...', err); } + cb(); + }); + }); + }); + }); +} + +RoomSchema.methods.setListeningActive = function(requestedTrack, cb) { + var room = this; + var app = room.soundtrack.app; + + console.log('setting "listening to" for active listeners...'); + + Track.findOne({ _id: requestedTrack._id }).populate('_artist').exec(function(err, track) { + if (!track || track._artist.name && track._artist.name.toLowerCase() == 'gobbly') { return false; } + + Person.find({ _id: { $in: _.toArray( room.listeners ).map(function(x) { return x._id; }) } }).exec(function(err, people) { + _.filter( people , function(x) { + return (x.profiles && x.profiles.lastfm && x.profiles.lastfm.username && x.preferences.scrobble); + } ).forEach(function(user) { + console.log('listener available:' + user._id + ' ' + user.username ); + + var lastfm = new app.LastFM({ + api_key: app.config.lastfm.key + , secret: app.config.lastfm.secret + }); + + var creds = { + username: user.profiles.lastfm.username + , key: user.profiles.lastfm.key + }; + + lastfm.setSessionCredentials( creds.username , creds.key ); + lastfm.track.updateNowPlaying({ + artist: track._artist.name + , track: track.title + , duration: Math.floor(track.duration) + }, function(err, scrobbles) { + if (err) return console.log('le fail...', err); + cb(); + }); + }); + }); + }); +} + +var Room = mongoose.model('Room', RoomSchema); + +// export the model to anything requiring it. +module.exports = { + Room: Room +}; diff --git a/models/Source.js b/models/Source.js new file mode 100644 index 00000000..e7a4b47c --- /dev/null +++ b/models/Source.js @@ -0,0 +1,46 @@ +var mongoose = require('mongoose') + , Schema = mongoose.Schema + , ObjectId = mongoose.SchemaTypes.ObjectId; + +var config = require('../config'); + +// this defines the fields associated with the model, +// and moreover, their type. +var SourceSchema = new Schema({ + id: { type: String, required: true, unique: true, id: 1 } + , type: { type: String, enum: ['audio/mp3', 'video/youtube', 'video/mp4'] } + , start: { type: Number, default: 0 } + , end: { type: Number } + , flags: { + live: { type: Boolean, default: false } // ~bad audio + , nsfw: { type: Boolean, default: false } // ~bad video + , down: { type: Boolean, default: false } // offline + , restricted: { type: Boolean, default: false } // non-free (libre) + } + , stats: { + created: { type: Date, default: Date.now } + , updated: { type: Date } + } +}); + +SourceSchema.virtual('uri').get(function() { + var parts = id.split(':'); + switch (source) { + case 'youtube': + return 'https://www.youtube.com/watch?v=' + parts[1]; + break; + case 'soundcloud': + return 'https://api.soundcloud.com/tracks/' + parts[1] + '?clientID=' + config.soundcloud.id; + break; + default: + return undefined; + break; + } +}); + +var Source = mongoose.model('Source', SourceSchema); + +// export the model to anything requiring it. +module.exports = { + Source: Source +}; diff --git a/models/Track.js b/models/Track.js index 7b93c334..e6243017 100644 --- a/models/Track.js +++ b/models/Track.js @@ -2,29 +2,52 @@ var mongoose = require('mongoose') , Schema = mongoose.Schema , ObjectId = mongoose.SchemaTypes.ObjectId , slug = require('mongoose-slug') - , slugify = require('mongoose-slug'); + , slugify = require('mongoose-slug') + , rest = require('restler') + , async = require('async') + , _ = require('underscore'); // this defines the fields associated with the model, // and moreover, their type. var TrackSchema = new Schema({ title: { type: String, required: true } - , _artist: { type: ObjectId, ref: 'Artist' } - , _credits: [ { type: ObjectId, ref: 'Artist' } ] - , duration: { type: Number } + , titles: [ { type: String } ] + , _artist: { type: ObjectId, ref: 'Artist', index: true } + , _credits: [ { type: ObjectId, ref: 'Artist', index: true } ] + , duration: { type: Number } // time in seconds + , flags: { + nsfw: { type: Boolean, default: false } + , live: { type: Boolean, default: false } + } + , description: { type: String } , images: { thumbnail: { url: { type: String } } } + , updated: { type: Date, default: 0 } + , _sources: [ { type: ObjectId, ref: 'Source' } ] , sources: { + lastfm: [ new Schema({ + id: { type: String , required: true , index: true } + , duration: { type: Number } + , data: {} + }) ], youtube: [ new Schema({ - id: { type: String, required: true } + id: { type: String, required: true , index: true } , start: { type: Number, default: 0 } , duration: { type: Number } + , data: {} })], soundcloud: [ new Schema({ - id: { type: String, required: true } + id: { type: String, required: true , index: true } + , data: {} + })], + bandcamp: [ new Schema({ + id: { type: String, required: true } + , data: {} })], vimeo: [ new Schema({ - id: { type: String, required: true } + id: { type: String, required: true } + , data: {} })] } , _remixes: [ new Schema({ @@ -33,6 +56,57 @@ var TrackSchema = new Schema({ }) ] }); +TrackSchema.pre('save', function(next) { + var self = this; + + /** for (var source in self.sources) { + self._sources.push({ }); + } **/ + + // de-dupe sources + var tempTrack = self.toObject(); + for (var source in tempTrack.sources) { + var sourceMap = {}; + + var sourcesForProvider = tempTrack.sources[ source ] || []; + sourcesForProvider.forEach(function( s ) { + sourceMap[ s.id ] = s; + }); + + self.sources[ source ] = Object.keys( sourceMap ).map(function( k ) { + return sourceMap[ k ]; + }); + } + + ['youtube', 'soundcloud'].forEach(function(source) { + + switch (source) { + case 'youtube': var type = 'video/youtube'; break; + case 'soundcloud': var type = 'audio/mp3'; break; + } + + self.sources[ source ].forEach(function(s) { + Source.findOne({ uri: s.id }).exec(function(err, storedSource) { + if (err) { console.log(err); } + if (!storedSource && type) { + var storedSource = new Source({ + id: [ source , s.id ].join(':') + , type: type + }); + storedSource.save(function(err) { + if (err) { console.log(err); } + }); + } + }); + }); + }); + + // de-dupe credits + self._credits = _.uniq( self._credits ); + + next(); +}); + TrackSchema.post('init', function() { var self = this; @@ -70,7 +144,9 @@ TrackSchema.statics.random = function(callback) { }.bind(this)); }; -TrackSchema.plugin( slug('title') ); +TrackSchema.plugin( slug('title', { + track: true +}) ); TrackSchema.index({ slug: 1 }); var Track = mongoose.model('Track', TrackSchema); diff --git a/monitor.js b/monitor.js new file mode 100644 index 00000000..d73c097e --- /dev/null +++ b/monitor.js @@ -0,0 +1,7 @@ +var config = require('./config'); +var db = require('./db'); + +var Queue = require('./lib/Queue'); +var jobs = new Queue( config ); + +jobs._kue.app.listen(3000); \ No newline at end of file diff --git a/package.json b/package.json index ddd42b65..7b9ebc62 100644 --- a/package.json +++ b/package.json @@ -1,36 +1,48 @@ { "name": "soundtrack.io", "version": "0.0.0", - "description": "ERROR: No README.md file found!", + "description": "soundtrack.io is a collaborative online jukebox.", "main": "soundtrack.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "mocha", + "coverage": "NODE_ENV=test istanbul cover _mocha -- --recursive", + "start": "node ./soundtrack.js" }, - "repository": "", - "author": "", - "license": "BSD", + "repository": "git@github.com:martindale/soundtrack.io.git", + "author": "Eric Martindale", + "license": "MIT", "dependencies": { - "passport": "~0.1.17", - "passport-local-mongoose": "~0.2.4", - "express": "~3.3.3", - "jade": "~0.32.0", - "mongoose": "~3.6.14", "async": "~0.2.9", - "underscore": "~1.4.4", - "mongoose-redis-cache": "0.0.3", - "connect-redis": "~1.4.5", - "mongoose-slug": "~1.1.1", - "sockjs": "~0.3.7", - "restler": "git+http://github.com/danwrong/restler.git", + "body-parser": "^1.11.0", + "connect-redis": "^2.2.0", + "escape-html": "^1.0.3", + "express": "^4.11.2", + "express-session": "^1.10.2", "flashify": "~0.1.2", - "pkgcloud": "~0.8.2", - "passport-local": "~0.1.6", + "heapdump": "^0.3.5", + "jade": "~0.32.0", + "lastfmapi": "git://github.com/martindale/node-lastfmapi#https-redirect-fix", "marked": "~0.2.9", - "moment": "~2.0.0", - "redis": "~0.8.4", - "slug-component": "~1.1.0", - "validator": "~1.3.0", - "connect-cachify": "0.0.15", - "lastfmapi": "0.0.4" + "moment": "^2.10.2", + "mongoose": "~3.6.14", + "mongoose-agency": "0.0.0", + "mongoose-slug": "~1.3.0", + "passport": "~0.1.17", + "passport-changetip": "0.0.1", + "passport-google-oauth": "^0.2.0", + "passport-local": "~0.1.6", + "passport-local-mongoose": "~0.2.4", + "passport-spotify": "^0.1.0", + "redis": "^0.12.1", + "restler": "git+http://github.com/danwrong/restler.git", + "sockjs": "^0.3.12", + "speakingurl": "~0.9.0", + "underscore": "~1.4.4", + "validator": "~1.3.0" + }, + "devDependencies": { + "coveralls": "^2.11.2", + "istanbul": "^0.3.5", + "mocha": "^2.1.0" } } diff --git a/public/css/bootstrap-slider.min.css b/public/css/bootstrap-slider.min.css new file mode 100644 index 00000000..02b4cc26 --- /dev/null +++ b/public/css/bootstrap-slider.min.css @@ -0,0 +1,6 @@ +/*! + * Slider for Bootstrap + * + * Licensed under the Apache License v2.0 + * + */.slider{display:inline-block;vertical-align:middle;position:relative}.slider.slider-horizontal{width:210px;height:20px}.slider.slider-horizontal .slider-track{height:10px;width:100%;margin-top:-5px;top:50%;left:0}.slider.slider-horizontal .slider-selection{height:100%;top:0;bottom:0}.slider.slider-horizontal .slider-handle{margin-left:-10px;margin-top:-5px}.slider.slider-horizontal .slider-handle.triangle{border-width:0 10px 10px 10px;width:0;height:0;border-bottom-color:#0480be;margin-top:0}.slider.slider-vertical{height:210px;width:20px}.slider.slider-vertical .slider-track{width:10px;height:100%;margin-left:-5px;left:50%;top:0}.slider.slider-vertical .slider-selection{width:100%;left:0;top:0;bottom:0}.slider.slider-vertical .slider-handle{margin-left:-5px;margin-top:-10px}.slider.slider-vertical .slider-handle.triangle{border-width:10px 0 10px 10px;width:1px;height:1px;border-left-color:#0480be;margin-left:0}.slider.slider-disabled .slider-handle{background-image:-webkit-linear-gradient(top,#dfdfdf 0,#bebebe 100%);background-image:linear-gradient(to bottom,#dfdfdf 0,#bebebe 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdfdfdf',endColorstr='#ffbebebe',GradientType=0)}.slider.slider-disabled .slider-track{background-image:-webkit-linear-gradient(top,#e5e5e5 0,#e9e9e9 100%);background-image:linear-gradient(to bottom,#e5e5e5 0,#e9e9e9 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe5e5e5',endColorstr='#ffe9e9e9',GradientType=0);cursor:not-allowed}.slider input{display:none}.slider .tooltip-inner{white-space:nowrap}.slider-track{position:absolute;cursor:pointer;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#f9f9f9 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#f9f9f9 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5',endColorstr='#fff9f9f9',GradientType=0);-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,0.1);box-shadow:inset 0 1px 2px rgba(0,0,0,0.1);border-radius:4px}.slider-selection{position:absolute;background-image:-webkit-linear-gradient(top,#f9f9f9 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#f9f9f9 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff9f9f9',endColorstr='#fff5f5f5',GradientType=0);-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,0.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,0.15);-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;border-radius:4px}.slider-handle{position:absolute;width:20px;height:20px;background-color:#3a94a5;background-image:-webkit-linear-gradient(top,#149bdf 0,#0480be 100%);background-image:linear-gradient(to bottom,#149bdf 0,#0480be 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff149bdf',endColorstr='#ff0480be',GradientType=0);filter:none;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.2),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.2),0 1px 2px rgba(0,0,0,.05);opacity:.8;border:0 solid transparent}.slider-handle.round{border-radius:50%}.slider-handle.triangle{background:transparent none} \ No newline at end of file diff --git a/public/css/font-awesome.min.css b/public/css/font-awesome.min.css new file mode 100644 index 00000000..449d6ac5 --- /dev/null +++ b/public/css/font-awesome.min.css @@ -0,0 +1,4 @@ +/*! + * Font Awesome 4.0.3 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.0.3');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.0.3') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff?v=4.0.3') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.0.3') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.0.3#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font-family:FontAwesome;font-style:normal;font-weight:normal;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.3333333333333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.2857142857142858em;text-align:center}.fa-ul{padding-left:0;margin-left:2.142857142857143em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.142857142857143em;width:2.142857142857143em;top:.14285714285714285em;text-align:center}.fa-li.fa-lg{left:-1.8571428571428572em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:spin 2s infinite linear;-moz-animation:spin 2s infinite linear;-o-animation:spin 2s infinite linear;animation:spin 2s infinite linear}@-moz-keyframes spin{0%{-moz-transform:rotate(0deg)}100%{-moz-transform:rotate(359deg)}}@-webkit-keyframes spin{0%{-webkit-transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg)}}@-o-keyframes spin{0%{-o-transform:rotate(0deg)}100%{-o-transform:rotate(359deg)}}@-ms-keyframes spin{0%{-ms-transform:rotate(0deg)}100%{-ms-transform:rotate(359deg)}}@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);-o-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-moz-transform:rotate(270deg);-ms-transform:rotate(270deg);-o-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0,mirror=1);-webkit-transform:scale(-1,1);-moz-transform:scale(-1,1);-ms-transform:scale(-1,1);-o-transform:scale(-1,1);transform:scale(-1,1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2,mirror=1);-webkit-transform:scale(1,-1);-moz-transform:scale(1,-1);-ms-transform:scale(1,-1);-o-transform:scale(1,-1);transform:scale(1,-1)}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-asc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-desc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-reply-all:before{content:"\f122"}.fa-mail-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"} \ No newline at end of file diff --git a/public/css/main.css b/public/css/main.css index 2b149109..e8775018 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -107,6 +107,9 @@ footer { border-bottom-left-radius: 3px; margin: -5px; } +.navbar .brand { + padding: 10px 0px 10px; +} .navbar a.brand { padding-left: 12px; margin-left: 0; @@ -114,14 +117,41 @@ footer { } .navbar a.brand img { max-height: 1em; - padding-right: 0.5em; + /* padding-right: 0.5em; */ margin-bottom: -0.2em; vertical-align: top; } +.navbar a.brand img:hover { + -webkit-animation-name: spin; + -webkit-animation-duration: 1300ms; + -webkit-animation-iteration-count: infinite; + -webkit-animation-timing-function: linear; + -moz-animation-name: spin; + -moz-animation-duration: 1300ms; + -moz-animation-iteration-count: infinite; + -moz-animation-timing-function: linear; + -ms-animation-name: spin; + -ms-animation-duration: 1300ms; + -ms-animation-iteration-count: infinite; + -ms-animation-timing-function: linear; +} .content-well { margin: 0 12px 12px; } +@-moz-keyframes spin { + from { -moz-transform: rotate(0deg); } + to { -moz-transform: rotate(360deg); } +} +@-webkit-keyframes spin { + from { -webkit-transform: rotate(0deg); } + to { -webkit-transform: rotate(360deg); } +} +@keyframes spin { + from {transform:rotate(0deg);} + to {transform:rotate(360deg);} +} + #chat-form .input-block-level { width: 464px; } @@ -159,15 +189,15 @@ footer { @media (max-width: 979px) { body {padding:0;} - + .container { width: 100%; margin-left:0; margin-right:0; } - + footer {margin:0;} - + #chat-form .input-block-level { width: 275px; } @@ -226,13 +256,19 @@ footer { #userlist { height: 508px; } -#user-playlists-dropdown { +#user-playlists-dropdown, .user-playlists-dropdown { max-height: 300px; overflow-y: scroll; } -#user-playlists-dropdown a { +#user-playlists-dropdown a, .user-playlists-dropdown a { cursor: pointer; } +.dropdown-menu>li>a { + color: lightgrey; +} +.dropdown-menu.user-playlists-dropdown>li>a:hover, .dropdown-menu.user-playlists-dropdown>li>a:focus, .dropdown-submenu.user-playlists-dropdown:hover>a, .dropdown-submenu.user-playlists-dropdown:focus>a,.dropdown-submenu.user-playlists-dropdown>a { + color: white; +} #announcement{ color: #08C; @@ -289,8 +325,7 @@ footer { .playlist-controls { float:left; - padding-right: 5px; - padding-left: 5px; + margin-right: 5px; } .active .playlist-controls { visibility: hidden; @@ -314,6 +349,7 @@ footer { text-transform: uppercase; font-weight: bold; font-size: 0.8em; + padding-left: 0.2em; } #playlist-list li.active { display: none; @@ -336,55 +372,91 @@ footer { box-shadow: inset 0 20px 20px -20px #000000; /* background-image: -webkit-gradient(linear, left top, left bottom, from(rgba(255, 255, 255, 1)), to(rgba(0, 0, 0, 0))); */ } + /* ######################################### */ /* #### Responsive Tweaks by jDizzy on 140311 #### */ /* ######################################### */ - @media (max-width:1199px) and (min-width:980px){ - #chat-form .input-block-level { - width: 536px; - } +/* begin le Dizzcode */ +@media (max-width:1199px) and (min-width:980px){ + #chat-form .input-block-level { + width: 536px; + } + } + +@media (max-width: 979px) { + body {padding:0;} + .container { + width: 100%; + margin-left:0; + margin-right:0; + } + + footer {margin:0;} + } - + @media (max-width:979px) and (min-width:768px){ - #chat-form .input-block-level { - width: 85%; - } + #chat-form .input-block-level { + width: 85%; + } } - + @media (max-width: 979px) and (min-width: 945px) { - .row-fluid [class*="span"].padded-left { - padding-left:0; - width:290px; - } + .row-fluid [class*="span"].padded-left { + padding-left:0; + width:290px; + } } - + @media (max-width: 944px) { - .row-fluid [class*="span"].padded-left { - width:100%; - padding-left:12px; - padding-right:12px; + .row-fluid [class*="span"].padded-left { + width:100%; + padding-left:12px; + padding-right:12px; } - - #playlist { - height:auto; - max-height:200px; - } - - .row-fluid [class*="span"].unpadded { - width:100%; - padding:0 12px; - margin:0; - } - - #search-form .input-block-level { - width: 90%; - } + + #playlist { + height:auto; + max-height:200px; + } + + .row-fluid [class*="span"].unpadded { + width:100%; + padding:0 12px; + margin:0; + } + + #search-form .input-block-level { + width: 90%; + } } @media (max-width:767px){ - .navbar-fixed-top, .navbar-fixed-bottom, .navbar-static-top { - margin-right: 0; - margin-left: 0; - } + .navbar-fixed-top, .navbar-fixed-bottom, .navbar-static-top { + margin-right: 0; + margin-left: 0; + } +} +/* end le Dizzcode */ + + +/* videoJS stuff */ +.vjs-fade-in,.vjs-fade-out { + visibility: visible !important; + opacity: 1 !important; + transition-duration: 0s!important; } +.vjs-default-skin .vjs-progress-holder { + height: 100%; + pointer-events: none; +} + +.vjs-default-skin .vjs-play-control { + display:none; +} + +/* pointer patch from @chrisinajar */ +.tooltip { + pointer-events: none; +} diff --git a/public/css/slider.css b/public/css/slider.css deleted file mode 100644 index a7f8370b..00000000 --- a/public/css/slider.css +++ /dev/null @@ -1,138 +0,0 @@ -/*! - * Slider for Bootstrap - * - * Copyright 2012 Stefan Petre - * Licensed under the Apache License v2.0 - * http://www.apache.org/licenses/LICENSE-2.0 - * - */ -.slider { - display: inline-block; - vertical-align: middle; - position: relative; -} -.slider.slider-horizontal { - width: 100%; - height: 20px; -} -.slider.slider-horizontal .slider-track { - height: 10px; - width: 100%; - margin-top: -5px; - top: 50%; - left: 0; -} -.slider.slider-horizontal .slider-selection { - height: 100%; - top: 0; - bottom: 0; -} -.slider.slider-horizontal .slider-handle { - margin-left: -10px; - margin-top: -5px; -} -.slider.slider-horizontal .slider-handle.triangle { - border-width: 0 10px 10px 10px; - width: 0; - height: 0; - border-bottom-color: #0480be; - margin-top: 0; -} -.slider.slider-vertical { - height: 210px; - width: 20px; -} -.slider.slider-vertical .slider-track { - width: 10px; - height: 100%; - margin-left: -5px; - left: 50%; - top: 0; -} -.slider.slider-vertical .slider-selection { - width: 100%; - left: 0; - top: 0; - bottom: 0; -} -.slider.slider-vertical .slider-handle { - margin-left: -5px; - margin-top: -10px; -} -.slider.slider-vertical .slider-handle.triangle { - border-width: 10px 0 10px 10px; - width: 1px; - height: 1px; - border-left-color: #0480be; - margin-left: 0; -} -.slider input { - display: none; -} -.slider .tooltip-inner { - white-space: nowrap; -} -.slider-track { - position: absolute; - cursor: pointer; - background-color: #f7f7f7; - background-image: -moz-linear-gradient(top, #f5f5f5, #f9f9f9); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#f5f5f5), to(#f9f9f9)); - background-image: -webkit-linear-gradient(top, #f5f5f5, #f9f9f9); - background-image: -o-linear-gradient(top, #f5f5f5, #f9f9f9); - background-image: linear-gradient(to bottom, #f5f5f5, #f9f9f9); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#fff9f9f9', GradientType=0); - -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); - -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; -} -.slider-selection { - position: absolute; - background-color: #f7f7f7; - background-image: -moz-linear-gradient(top, #f9f9f9, #f5f5f5); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#f9f9f9), to(#f5f5f5)); - background-image: -webkit-linear-gradient(top, #f9f9f9, #f5f5f5); - background-image: -o-linear-gradient(top, #f9f9f9, #f5f5f5); - background-image: linear-gradient(to bottom, #f9f9f9, #f5f5f5); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff9f9f9', endColorstr='#fff5f5f5', GradientType=0); - -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); - -moz-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); - box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; -} -.slider-handle { - position: absolute; - width: 20px; - height: 20px; - background-color: #0e90d2; - background-image: -moz-linear-gradient(top, #149bdf, #0480be); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#149bdf), to(#0480be)); - background-image: -webkit-linear-gradient(top, #149bdf, #0480be); - background-image: -o-linear-gradient(top, #149bdf, #0480be); - background-image: linear-gradient(to bottom, #149bdf, #0480be); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff149bdf', endColorstr='#ff0480be', GradientType=0); - -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05); - -moz-box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05); - box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05); - opacity: 0.8; - border: 0px solid transparent; -} -.slider-handle.round { - -webkit-border-radius: 20px; - -moz-border-radius: 20px; - border-radius: 20px; -} -.slider-handle.triangle { - background: transparent none; -} \ No newline at end of file diff --git a/public/css/typeahead.js-bootstrap.css b/public/css/typeahead.js-bootstrap.css new file mode 100644 index 00000000..e44b6739 --- /dev/null +++ b/public/css/typeahead.js-bootstrap.css @@ -0,0 +1,49 @@ +.twitter-typeahead .tt-query, +.twitter-typeahead .tt-hint { + margin-bottom: 0; +} + +.tt-dropdown-menu { + min-width: 160px; + margin-top: 2px; + padding: 5px 0; + background-color: #fff; + border: 1px solid #ccc; + border: 1px solid rgba(0,0,0,.2); + *border-right-width: 2px; + *border-bottom-width: 2px; + -webkit-border-radius: 6px; + -moz-border-radius: 6px; + border-radius: 6px; + -webkit-box-shadow: 0 5px 10px rgba(0,0,0,.2); + -moz-box-shadow: 0 5px 10px rgba(0,0,0,.2); + box-shadow: 0 5px 10px rgba(0,0,0,.2); + -webkit-background-clip: padding-box; + -moz-background-clip: padding; + background-clip: padding-box; +} + +.tt-suggestion { + display: block; + padding: 3px 20px; +} + +.tt-suggestion.tt-is-under-cursor { + color: #fff; + background-color: #0081c2; + background-image: -moz-linear-gradient(top, #0088cc, #0077b3); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0077b3)); + background-image: -webkit-linear-gradient(top, #0088cc, #0077b3); + background-image: -o-linear-gradient(top, #0088cc, #0077b3); + background-image: linear-gradient(to bottom, #0088cc, #0077b3); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0077b3', GradientType=0) +} + +.tt-suggestion.tt-is-under-cursor a { + color: #fff; +} + +.tt-suggestion p { + margin: 0; +} diff --git a/public/fonts/FontAwesome.otf b/public/fonts/FontAwesome.otf new file mode 100644 index 00000000..8b0f54e4 Binary files /dev/null and b/public/fonts/FontAwesome.otf differ diff --git a/public/fonts/fontawesome-webfont.eot b/public/fonts/fontawesome-webfont.eot new file mode 100755 index 00000000..7c79c6a6 Binary files /dev/null and b/public/fonts/fontawesome-webfont.eot differ diff --git a/public/fonts/fontawesome-webfont.svg b/public/fonts/fontawesome-webfont.svg new file mode 100755 index 00000000..45fdf338 --- /dev/null +++ b/public/fonts/fontawesome-webfont.svg @@ -0,0 +1,414 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/fonts/fontawesome-webfont.ttf b/public/fonts/fontawesome-webfont.ttf new file mode 100755 index 00000000..e89738de Binary files /dev/null and b/public/fonts/fontawesome-webfont.ttf differ diff --git a/public/fonts/fontawesome-webfont.woff b/public/fonts/fontawesome-webfont.woff new file mode 100755 index 00000000..8c1748aa Binary files /dev/null and b/public/fonts/fontawesome-webfont.woff differ diff --git a/public/img/changetip.png b/public/img/changetip.png new file mode 100644 index 00000000..46a01d14 Binary files /dev/null and b/public/img/changetip.png differ diff --git a/public/img/last.fm.ico b/public/img/last.fm.ico new file mode 100644 index 00000000..80cf74ce Binary files /dev/null and b/public/img/last.fm.ico differ diff --git a/public/img/soundtrack.gif b/public/img/soundtrack.gif new file mode 100644 index 00000000..286d07e7 Binary files /dev/null and b/public/img/soundtrack.gif differ diff --git a/public/img/soundtrack.jpg b/public/img/soundtrack.jpg new file mode 100644 index 00000000..7a18f860 Binary files /dev/null and b/public/img/soundtrack.jpg differ diff --git a/public/img/soundtrack.png b/public/img/soundtrack.png new file mode 100644 index 00000000..8d29ab6d Binary files /dev/null and b/public/img/soundtrack.png differ diff --git a/public/img/spotify.png b/public/img/spotify.png new file mode 100644 index 00000000..2c9b6519 Binary files /dev/null and b/public/img/spotify.png differ diff --git a/public/js/angular.min.js b/public/js/angular.min.js index 2b220688..72a0970f 100644 --- a/public/js/angular.min.js +++ b/public/js/angular.min.js @@ -1,163 +1,219 @@ /* - AngularJS v1.0.7 - (c) 2010-2012 Google, Inc. http://angularjs.org + AngularJS v1.3.0-beta.7 + (c) 2010-2014 Google, Inc. http://angularjs.org License: MIT */ -(function(P,T,q){'use strict';function m(b,a,c){var d;if(b)if(H(b))for(d in b)d!="prototype"&&d!="length"&&d!="name"&&b.hasOwnProperty(d)&&a.call(c,b[d],d);else if(b.forEach&&b.forEach!==m)b.forEach(a,c);else if(!b||typeof b.length!=="number"?0:typeof b.hasOwnProperty!="function"&&typeof b.constructor!="function"||b instanceof K||ca&&b instanceof ca||wa.call(b)!=="[object Object]"||typeof b.callee==="function")for(d=0;d=0&&b.splice(c,1);return a}function U(b,a){if(oa(b)||b&&b.$evalAsync&&b.$watch)throw Error("Can't copy Window or Scope");if(a){if(b===a)throw Error("Can't copy equivalent objects or arrays");if(E(b))for(var c=a.length=0;c2?ha.call(arguments,2):[];return H(a)&&!(a instanceof RegExp)?c.length?function(){return arguments.length?a.apply(b,c.concat(ha.call(arguments,0))):a.apply(b,c)}:function(){return arguments.length?a.apply(b,arguments):a.call(b)}:a}function ic(b,a){var c=a;/^\$+/.test(b)?c=q:oa(a)?c="$WINDOW":a&&T===a?c="$DOCUMENT":a&&a.$evalAsync&&a.$watch&&(c="$SCOPE");return c}function da(b,a){return JSON.stringify(b, -ic,a?" ":null)}function pb(b){return B(b)?JSON.parse(b):b}function Ua(b){b&&b.length!==0?(b=z(""+b),b=!(b=="f"||b=="0"||b=="false"||b=="no"||b=="n"||b=="[]")):b=!1;return b}function pa(b){b=u(b).clone();try{b.html("")}catch(a){}var c=u("
").append(b).html();try{return b[0].nodeType===3?z(c):c.match(/^(<[^>]+>)/)[1].replace(/^<([\w\-]+)/,function(a,b){return"<"+z(b)})}catch(d){return z(c)}}function Va(b){var a={},c,d;m((b||"").split("&"),function(b){b&&(c=b.split("="),d=decodeURIComponent(c[0]), -a[d]=y(c[1])?decodeURIComponent(c[1]):!0)});return a}function qb(b){var a=[];m(b,function(b,d){a.push(Wa(d,!0)+(b===!0?"":"="+Wa(b,!0)))});return a.length?a.join("&"):""}function Xa(b){return Wa(b,!0).replace(/%26/gi,"&").replace(/%3D/gi,"=").replace(/%2B/gi,"+")}function Wa(b,a){return encodeURIComponent(b).replace(/%40/gi,"@").replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,a?"%20":"+")}function jc(b,a){function c(a){a&&d.push(a)}var d=[b],e,g,h=["ng:app","ng-app","x-ng-app", -"data-ng-app"],f=/\sng[:\-]app(:\s*([\w\d_]+);?)?\s/;m(h,function(a){h[a]=!0;c(T.getElementById(a));a=a.replace(":","\\:");b.querySelectorAll&&(m(b.querySelectorAll("."+a),c),m(b.querySelectorAll("."+a+"\\:"),c),m(b.querySelectorAll("["+a+"]"),c))});m(d,function(a){if(!e){var b=f.exec(" "+a.className+" ");b?(e=a,g=(b[2]||"").replace(/\s+/g,",")):m(a.attributes,function(b){if(!e&&h[b.name])e=a,g=b.value})}});e&&a(e,g?[g]:[])}function rb(b,a){var c=function(){b=u(b);a=a||[];a.unshift(["$provide",function(a){a.value("$rootElement", -b)}]);a.unshift("ng");var c=sb(a);c.invoke(["$rootScope","$rootElement","$compile","$injector",function(a,b,c,d){a.$apply(function(){b.data("$injector",d);c(b)(a)})}]);return c},d=/^NG_DEFER_BOOTSTRAP!/;if(P&&!d.test(P.name))return c();P.name=P.name.replace(d,"");Ya.resumeBootstrap=function(b){m(b,function(b){a.push(b)});c()}}function Za(b,a){a=a||"_";return b.replace(kc,function(b,d){return(d?a:"")+b.toLowerCase()})}function $a(b,a,c){if(!b)throw Error("Argument '"+(a||"?")+"' is "+(c||"required")); -return b}function qa(b,a,c){c&&E(b)&&(b=b[b.length-1]);$a(H(b),a,"not a function, got "+(b&&typeof b=="object"?b.constructor.name||"Object":typeof b));return b}function lc(b){function a(a,b,e){return a[b]||(a[b]=e())}return a(a(b,"angular",Object),"module",function(){var b={};return function(d,e,g){e&&b.hasOwnProperty(d)&&(b[d]=null);return a(b,d,function(){function a(c,d,e){return function(){b[e||"push"]([c,d,arguments]);return k}}if(!e)throw Error("No module: "+d);var b=[],c=[],j=a("$injector", -"invoke"),k={_invokeQueue:b,_runBlocks:c,requires:e,name:d,provider:a("$provide","provider"),factory:a("$provide","factory"),service:a("$provide","service"),value:a("$provide","value"),constant:a("$provide","constant","unshift"),filter:a("$filterProvider","register"),controller:a("$controllerProvider","register"),directive:a("$compileProvider","directive"),config:j,run:function(a){c.push(a);return this}};g&&j(g);return k})}})}function tb(b){return b.replace(mc,function(a,b,d,e){return e?d.toUpperCase(): -d}).replace(nc,"Moz$1")}function ab(b,a){function c(){var e;for(var b=[this],c=a,h,f,i,j,k,l;b.length;){h=b.shift();f=0;for(i=h.length;f 
"+b;a.removeChild(a.firstChild);bb(this,a.childNodes);this.remove()}else bb(this,b)}function cb(b){return b.cloneNode(!0)}function ra(b){ub(b);for(var a=0,b=b.childNodes||[];a-1}function xb(b,a){a&&m(a.split(" "),function(a){b.className=Q((" "+b.className+" ").replace(/[\n\t]/g," ").replace(" "+Q(a)+" "," "))})} -function yb(b,a){a&&m(a.split(" "),function(a){if(!Ca(b,a))b.className=Q(b.className+" "+Q(a))})}function bb(b,a){if(a)for(var a=!a.nodeName&&y(a.length)&&!oa(a)?a:[a],c=0;c4096&&c.warn("Cookie '"+a+"' possibly not set or overflowed because it was too large ("+d+" > 4096 bytes)!")}else{if(i.cookie!==$){$=i.cookie;d=$.split("; ");r={};for(f=0;f0&&(a=unescape(e.substring(0,j)),r[a]===q&&(r[a]=unescape(e.substring(j+1))))}return r}};f.defer=function(a,b){var c; -p++;c=l(function(){delete o[c];e(a)},b||0);o[c]=!0;return c};f.defer.cancel=function(a){return o[a]?(delete o[a],n(a),e(C),!0):!1}}function wc(){this.$get=["$window","$log","$sniffer","$document",function(b,a,c,d){return new vc(b,d,a,c)}]}function xc(){this.$get=function(){function b(b,d){function e(a){if(a!=l){if(n){if(n==a)n=a.n}else n=a;g(a.n,a.p);g(a,l);l=a;l.n=null}}function g(a,b){if(a!=b){if(a)a.p=b;if(b)b.n=a}}if(b in a)throw Error("cacheId "+b+" taken");var h=0,f=v({},d,{id:b}),i={},j=d&& -d.capacity||Number.MAX_VALUE,k={},l=null,n=null;return a[b]={put:function(a,b){var c=k[a]||(k[a]={key:a});e(c);w(b)||(a in i||h++,i[a]=b,h>j&&this.remove(n.key))},get:function(a){var b=k[a];if(b)return e(b),i[a]},remove:function(a){var b=k[a];if(b){if(b==l)l=b.p;if(b==n)n=b.n;g(b.n,b.p);delete k[a];delete i[a];h--}},removeAll:function(){i={};h=0;k={};l=n=null},destroy:function(){k=f=i=null;delete a[b]},info:function(){return v({},f,{size:h})}}}var a={};b.info=function(){var b={};m(a,function(a,e){b[e]= -a.info()});return b};b.get=function(b){return a[b]};return b}}function yc(){this.$get=["$cacheFactory",function(b){return b("templates")}]}function Db(b){var a={},c="Directive",d=/^\s*directive\:\s*([\d\w\-_]+)\s+(.*)$/,e=/(([\d\w\-_]+)(?:\:([^;]+))?;?)/,g="Template must have exactly one root element. was: ",h=/^\s*(https?|ftp|mailto|file):/;this.directive=function i(d,e){B(d)?($a(e,"directive"),a.hasOwnProperty(d)||(a[d]=[],b.factory(d+c,["$injector","$exceptionHandler",function(b,c){var e=[];m(a[d], -function(a){try{var g=b.invoke(a);if(H(g))g={compile:I(g)};else if(!g.compile&&g.link)g.compile=I(g.link);g.priority=g.priority||0;g.name=g.name||d;g.require=g.require||g.controller&&g.name;g.restrict=g.restrict||"A";e.push(g)}catch(h){c(h)}});return e}])),a[d].push(e)):m(d,nb(i));return this};this.urlSanitizationWhitelist=function(a){return y(a)?(h=a,this):h};this.$get=["$injector","$interpolate","$exceptionHandler","$http","$templateCache","$parse","$controller","$rootScope","$document",function(b, -j,k,l,n,o,p,s,t){function x(a,b,c){a instanceof u||(a=u(a));m(a,function(b,c){b.nodeType==3&&b.nodeValue.match(/\S+/)&&(a[c]=u(b).wrap("").parent()[0])});var d=A(a,b,a,c);return function(b,c){$a(b,"scope");for(var e=c?ua.clone.call(a):a,j=0,g=e.length;jr.priority)break;if(Y=r.scope)ta("isolated scope",J,r,D),L(Y)&&(M(D,"ng-isolate-scope"),J=r),M(D,"ng-scope"),s=s||r;F=r.name;if(Y=r.controller)y=y||{},ta("'"+F+"' controller",y[F],r,D),y[F]=r;if(Y=r.transclude)ta("transclusion",ja,r,D),ja=r,l=r.priority,Y=="element"?(W=u(b),D=c.$$element=u(T.createComment(" "+ -F+": "+c[F]+" ")),b=D[0],C(e,u(W[0]),b),V=x(W,d,l)):(W=u(cb(b)).contents(),D.html(""),V=x(W,d));if(Y=r.template)if(ta("template",A,r,D),A=r,Y=Fb(Y),r.replace){W=u("
"+Q(Y)+"
").contents();b=W[0];if(W.length!=1||b.nodeType!==1)throw Error(g+Y);C(e,D,b);F={$attr:{}};a=a.concat(N(b,a.splice(v+1,a.length-(v+1)),F));$(c,F);z=a.length}else D.html(Y);if(r.templateUrl)ta("template",A,r,D),A=r,i=R(a.splice(v,a.length-v),i,D,c,e,r.replace,V),z=a.length;else if(r.compile)try{w=r.compile(D,c,V),H(w)? -j(null,w):w&&j(w.pre,w.post)}catch(G){k(G,pa(D))}if(r.terminal)i.terminal=!0,l=Math.max(l,r.priority)}i.scope=s&&s.scope;i.transclude=ja&&V;return i}function r(d,e,g,j){var h=!1;if(a.hasOwnProperty(e))for(var o,e=b.get(e+c),l=0,p=e.length;lo.priority)&&o.restrict.indexOf(g)!=-1)d.push(o),h=!0}catch(n){k(n)}return h}function $(a,b){var c=b.$attr,d=a.$attr,e=a.$$element;m(a,function(d,e){e.charAt(0)!="$"&&(b[e]&&(d+=(e==="style"?";":" ")+b[e]),a.$set(e,d,!0,c[e]))});m(b, -function(b,g){g=="class"?(M(e,b),a["class"]=(a["class"]?a["class"]+" ":"")+b):g=="style"?e.attr("style",e.attr("style")+";"+b):g.charAt(0)!="$"&&!a.hasOwnProperty(g)&&(a[g]=b,d[g]=c[g])})}function R(a,b,c,d,e,j,h){var i=[],k,o,p=c[0],t=a.shift(),s=v({},t,{controller:null,templateUrl:null,transclude:null,scope:null});c.html("");l.get(t.templateUrl,{cache:n}).success(function(l){var n,t,l=Fb(l);if(j){t=u("
"+Q(l)+"
").contents();n=t[0];if(t.length!=1||n.nodeType!==1)throw Error(g+l);l={$attr:{}}; -C(e,c,n);N(n,a,l);$(d,l)}else n=p,c.html(l);a.unshift(s);k=J(a,n,d,h);for(o=A(c[0].childNodes,h);i.length;){var r=i.pop(),l=i.pop();t=i.pop();var ia=i.pop(),D=n;t!==p&&(D=cb(n),C(l,u(t),D));k(function(){b(o,ia,D,e,r)},ia,D,e,r)}i=null}).error(function(a,b,c,d){throw Error("Failed to load template: "+d.url);});return function(a,c,d,e,g){i?(i.push(c),i.push(d),i.push(e),i.push(g)):k(function(){b(o,c,d,e,g)},c,d,e,g)}}function F(a,b){return b.priority-a.priority}function ta(a,b,c,d){if(b)throw Error("Multiple directives ["+ -b.name+", "+c.name+"] asking for "+a+" on: "+pa(d));}function y(a,b){var c=j(b,!0);c&&a.push({priority:0,compile:I(function(a,b){var d=b.parent(),e=d.data("$binding")||[];e.push(c);M(d.data("$binding",e),"ng-binding");a.$watch(c,function(a){b[0].nodeValue=a})})})}function V(a,b,c,d){var e=j(c,!0);e&&b.push({priority:100,compile:I(function(a,b,c){b=c.$$observers||(c.$$observers={});d==="class"&&(e=j(c[d],!0));c[d]=q;(b[d]||(b[d]=[])).$$inter=!0;(c.$$observers&&c.$$observers[d].$$scope||a).$watch(e, -function(a){c.$set(d,a)})})})}function C(a,b,c){var d=b[0],e=d.parentNode,g,j;if(a){g=0;for(j=a.length;g -0){var e=R[0],f=e.text;if(f==a||f==b||f==c||f==d||!a&&!b&&!c&&!d)return e}return!1}function f(b,c,d,f){return(b=h(b,c,d,f))?(a&&!b.json&&e("is not valid json",b),R.shift(),b):!1}function i(a){f(a)||e("is unexpected, expecting ["+a+"]",h())}function j(a,b){return function(c,d){return a(c,d,b)}}function k(a,b,c){return function(d,e){return b(d,e,a,c)}}function l(){for(var a=[];;)if(R.length>0&&!h("}",")",";","]")&&a.push(w()),!f(";"))return a.length==1?a[0]:function(b,c){for(var d,e=0;e","<=",">="))a=k(a,b.fn,t());return a}function x(){for(var a=m(),b;b=f("*","/","%");)a=k(a,b.fn,m());return a}function m(){var a;return f("+")?A():(a=f("-"))?k(r,a.fn,m()):(a=f("!"))?j(a.fn,m()):A()}function A(){var a;if(f("("))a=w(),i(")");else if(f("["))a=N();else if(f("{"))a=J();else{var b=f();(a=b.fn)||e("not a primary expression",b)}for(var c;b=f("(","[",".");)b.text==="("?(a=y(a,c),c=null):b.text==="["?(c=a,a=V(a)):b.text==="."?(c=a,a=u(a)):e("IMPOSSIBLE");return a}function N(){var a= -[];if(g().text!="]"){do a.push(F());while(f(","))}i("]");return function(b,c){for(var d=[],e=0;e1;d++){var e=a.shift(),g=b[e];g||(g={},b[e]=g);b=g}return b[a.shift()]= -c}function gb(b,a,c){if(!a)return b;for(var a=a.split("."),d,e=b,g=a.length,h=0;h7),hasEvent:function(c){if(c=="input"&&Z==9)return!1;if(w(a[c])){var e=b.document.createElement("div");a[c]="on"+c in e}return a[c]},csp:!1}}]}function Vc(){this.$get=I(P)}function Ob(b){var a={},c,d,e;if(!b)return a;m(b.split("\n"),function(b){e=b.indexOf(":");c=z(Q(b.substr(0, -e)));d=Q(b.substr(e+1));c&&(a[c]?a[c]+=", "+d:a[c]=d)});return a}function Pb(b){var a=L(b)?b:q;return function(c){a||(a=Ob(b));return c?a[z(c)]||null:a}}function Qb(b,a,c){if(H(c))return c(b,a);m(c,function(c){b=c(b,a)});return b}function Wc(){var b=/^\s*(\[|\{[^\{])/,a=/[\}\]]\s*$/,c=/^\)\]\}',?\n/,d=this.defaults={transformResponse:[function(d){B(d)&&(d=d.replace(c,""),b.test(d)&&a.test(d)&&(d=pb(d,!0)));return d}],transformRequest:[function(a){return L(a)&&wa.apply(a)!=="[object File]"?da(a):a}], -headers:{common:{Accept:"application/json, text/plain, */*","X-Requested-With":"XMLHttpRequest"},post:{"Content-Type":"application/json;charset=utf-8"},put:{"Content-Type":"application/json;charset=utf-8"}}},e=this.responseInterceptors=[];this.$get=["$httpBackend","$browser","$cacheFactory","$rootScope","$q","$injector",function(a,b,c,i,j,k){function l(a){function c(a){var b=v({},a,{data:Qb(a.data,a.headers,f)});return 200<=a.status&&a.status<300?b:j.reject(b)}a.method=la(a.method);var e=a.transformRequest|| -d.transformRequest,f=a.transformResponse||d.transformResponse,g=d.headers,g=v({"X-XSRF-TOKEN":b.cookies()["XSRF-TOKEN"]},g.common,g[z(a.method)],a.headers),e=Qb(a.data,Pb(g),e),i;w(a.data)&&delete g["Content-Type"];i=n(a,e,g);i=i.then(c,c);m(s,function(a){i=a(i)});i.success=function(b){i.then(function(c){b(c.data,c.status,c.headers,a)});return i};i.error=function(b){i.then(null,function(c){b(c.data,c.status,c.headers,a)});return i};return i}function n(b,c,d){function e(a,b,c){m&&(200<=a&&a<300?m.put(q, -[a,b,Ob(c)]):m.remove(q));f(b,a,c);i.$apply()}function f(a,c,d){c=Math.max(c,0);(200<=c&&c<300?k.resolve:k.reject)({data:a,status:c,headers:Pb(d),config:b})}function h(){var a=za(l.pendingRequests,b);a!==-1&&l.pendingRequests.splice(a,1)}var k=j.defer(),n=k.promise,m,s,q=o(b.url,b.params);l.pendingRequests.push(b);n.then(h,h);b.cache&&b.method=="GET"&&(m=L(b.cache)?b.cache:p);if(m)if(s=m.get(q))if(s.then)return s.then(h,h),s;else E(s)?f(s[1],s[0],U(s[2])):f(s,200,{});else m.put(q,n);s||a(b.method, -q,c,e,d,b.timeout,b.withCredentials);return n}function o(a,b){if(!b)return a;var c=[];fc(b,function(a,b){a==null||a==q||(L(a)&&(a=da(a)),c.push(encodeURIComponent(b)+"="+encodeURIComponent(a)))});return a+(a.indexOf("?")==-1?"?":"&")+c.join("&")}var p=c("$http"),s=[];m(e,function(a){s.push(B(a)?k.get(a):k.invoke(a))});l.pendingRequests=[];(function(a){m(arguments,function(a){l[a]=function(b,c){return l(v(c||{},{method:a,url:b}))}})})("get","delete","head","jsonp");(function(a){m(arguments,function(a){l[a]= -function(b,c,d){return l(v(d||{},{method:a,url:b,data:c}))}})})("post","put");l.defaults=d;return l}]}function Xc(){this.$get=["$browser","$window","$document",function(b,a,c){return Yc(b,Zc,b.defer,a.angular.callbacks,c[0],a.location.protocol.replace(":",""))}]}function Yc(b,a,c,d,e,g){function h(a,b){var c=e.createElement("script"),d=function(){e.body.removeChild(c);b&&b()};c.type="text/javascript";c.src=a;Z?c.onreadystatechange=function(){/loaded|complete/.test(c.readyState)&&d()}:c.onload=c.onerror= -d;e.body.appendChild(c)}return function(e,i,j,k,l,n,o){function p(a,c,d,e){c=(i.match(Hb)||["",g])[1]=="file"?d?200:404:c;a(c==1223?204:c,d,e);b.$$completeOutstandingRequest(C)}b.$$incOutstandingRequestCount();i=i||b.url();if(z(e)=="jsonp"){var s="_"+(d.counter++).toString(36);d[s]=function(a){d[s].data=a};h(i.replace("JSON_CALLBACK","angular.callbacks."+s),function(){d[s].data?p(k,200,d[s].data):p(k,-2);delete d[s]})}else{var t=new a;t.open(e,i,!0);m(l,function(a,b){a&&t.setRequestHeader(b,a)}); -var q;t.onreadystatechange=function(){if(t.readyState==4){var a=t.getAllResponseHeaders(),b=["Cache-Control","Content-Language","Content-Type","Expires","Last-Modified","Pragma"];a||(a="",m(b,function(b){var c=t.getResponseHeader(b);c&&(a+=b+": "+c+"\n")}));p(k,q||t.status,t.responseText,a)}};if(o)t.withCredentials=!0;t.send(j||"");n>0&&c(function(){q=-1;t.abort()},n)}}}function $c(){this.$get=function(){return{id:"en-us",NUMBER_FORMATS:{DECIMAL_SEP:".",GROUP_SEP:",",PATTERNS:[{minInt:1,minFrac:0, -maxFrac:3,posPre:"",posSuf:"",negPre:"-",negSuf:"",gSize:3,lgSize:3},{minInt:1,minFrac:2,maxFrac:2,posPre:"\u00a4",posSuf:"",negPre:"(\u00a4",negSuf:")",gSize:3,lgSize:3}],CURRENCY_SYM:"$"},DATETIME_FORMATS:{MONTH:"January,February,March,April,May,June,July,August,September,October,November,December".split(","),SHORTMONTH:"Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec".split(","),DAY:"Sunday,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday".split(","),SHORTDAY:"Sun,Mon,Tue,Wed,Thu,Fri,Sat".split(","), -AMPMS:["AM","PM"],medium:"MMM d, y h:mm:ss a","short":"M/d/yy h:mm a",fullDate:"EEEE, MMMM d, y",longDate:"MMMM d, y",mediumDate:"MMM d, y",shortDate:"M/d/yy",mediumTime:"h:mm:ss a",shortTime:"h:mm a"},pluralCat:function(b){return b===1?"one":"other"}}}}function ad(){this.$get=["$rootScope","$browser","$q","$exceptionHandler",function(b,a,c,d){function e(e,f,i){var j=c.defer(),k=j.promise,l=y(i)&&!i,f=a.defer(function(){try{j.resolve(e())}catch(a){j.reject(a),d(a)}l||b.$apply()},f),i=function(){delete g[k.$$timeoutId]}; -k.$$timeoutId=f;g[f]=j;k.then(i,i);return k}var g={};e.cancel=function(b){return b&&b.$$timeoutId in g?(g[b.$$timeoutId].reject("canceled"),a.defer.cancel(b.$$timeoutId)):!1};return e}]}function Rb(b){function a(a,e){return b.factory(a+c,e)}var c="Filter";this.register=a;this.$get=["$injector",function(a){return function(b){return a.get(b+c)}}];a("currency",Sb);a("date",Tb);a("filter",bd);a("json",cd);a("limitTo",dd);a("lowercase",ed);a("number",Ub);a("orderBy",Vb);a("uppercase",fd)}function bd(){return function(b, -a){if(!E(b))return b;var c=[];c.check=function(a){for(var b=0;b-1;case "object":for(var c in a)if(c.charAt(0)!=="$"&&d(a[c],b))return!0;return!1;case "array":for(c=0;ce+1?h="0":(f=h,j=!0)}if(!j){h=(h.split(Xb)[1]||"").length;w(e)&&(e=Math.min(Math.max(a.minFrac,h),a.maxFrac));var h=Math.pow(10,e),b=Math.round(b*h)/h,b=(""+b).split(Xb),h=b[0],b=b[1]||"",j=0,k=a.lgSize, -l=a.gSize;if(h.length>=k+l)for(var j=h.length-k,n=0;n0||e> --c)e+=c;e===0&&c==-12&&(e=12);return jb(e,a,d)}}function Ja(b,a){return function(c,d){var e=c["get"+b](),g=la(a?"SHORT"+b:b);return d[g][e]}}function Tb(b){function a(a){var b;if(b=a.match(c)){var a=new Date(0),g=0,h=0;b[9]&&(g=G(b[9]+b[10]),h=G(b[9]+b[11]));a.setUTCFullYear(G(b[1]),G(b[2])-1,G(b[3]));a.setUTCHours(G(b[4]||0)-g,G(b[5]||0)-h,G(b[6]||0),G(b[7]||0))}return a}var c=/^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/;return function(c, -e){var g="",h=[],f,i,e=e||"mediumDate",e=b.DATETIME_FORMATS[e]||e;B(c)&&(c=gd.test(c)?G(c):a(c));Qa(c)&&(c=new Date(c));if(!na(c))return c;for(;e;)(i=hd.exec(e))?(h=h.concat(ha.call(i,1)),e=h.pop()):(h.push(e),e=null);m(h,function(a){f=id[a];g+=f?f(c,b.DATETIME_FORMATS):a.replace(/(^'|'$)/g,"").replace(/''/g,"'")});return g}}function cd(){return function(b){return da(b,!0)}}function dd(){return function(b,a){if(!(b instanceof Array))return b;var a=G(a),c=[],d,e;if(!b||!(b instanceof Array))return c; -a>b.length?a=b.length:a<-b.length&&(a=-b.length);a>0?(d=0,e=a):(d=b.length+a,e=b.length);for(;dn?(d.$setValidity("maxlength",!1),q):(d.$setValidity("maxlength",!0),a)};d.$parsers.push(c);d.$formatters.push(c)}}function kb(b,a){b="ngClass"+b;return S(function(c,d,e){function g(b){if(a===!0||c.$index%2===a)i&&!fa(b,i)&&h(i),f(b);i=U(b)}function h(a){L(a)&& -!E(a)&&(a=Ra(a,function(a,b){if(a)return b}));d.removeClass(E(a)?a.join(" "):a)}function f(a){L(a)&&!E(a)&&(a=Ra(a,function(a,b){if(a)return b}));a&&d.addClass(E(a)?a.join(" "):a)}var i=q;c.$watch(e[b],g,!0);e.$observe("class",function(){var a=c.$eval(e[b]);g(a,a)});b!=="ngClass"&&c.$watch("$index",function(d,g){var i=d&1;i!==g&1&&(i===a?f(c.$eval(e[b])):h(c.$eval(e[b])))})})}var z=function(b){return B(b)?b.toLowerCase():b},la=function(b){return B(b)?b.toUpperCase():b},Z=G((/msie (\d+)/.exec(z(navigator.userAgent))|| -[])[1]),u,ca,ha=[].slice,Pa=[].push,wa=Object.prototype.toString,Ya=P.angular||(P.angular={}),sa,fb,aa=["0","0","0"];C.$inject=[];ma.$inject=[];fb=Z<9?function(b){b=b.nodeName?b:b[0];return b.scopeName&&b.scopeName!="HTML"?la(b.scopeName+":"+b.nodeName):b.nodeName}:function(b){return b.nodeName?b.nodeName:b[0].nodeName};var kc=/[A-Z]/g,jd={full:"1.0.7",major:1,minor:0,dot:7,codeName:"monochromatic-rainbow"},Ba=K.cache={},Aa=K.expando="ng-"+(new Date).getTime(),oc=1,$b=P.document.addEventListener? -function(b,a,c){b.addEventListener(a,c,!1)}:function(b,a,c){b.attachEvent("on"+a,c)},db=P.document.removeEventListener?function(b,a,c){b.removeEventListener(a,c,!1)}:function(b,a,c){b.detachEvent("on"+a,c)},mc=/([\:\-\_]+(.))/g,nc=/^moz([A-Z])/,ua=K.prototype={ready:function(b){function a(){c||(c=!0,b())}var c=!1;this.bind("DOMContentLoaded",a);K(P).bind("load",a)},toString:function(){var b=[];m(this,function(a){b.push(""+a)});return"["+b.join(", ")+"]"},eq:function(b){return b>=0?u(this[b]):u(this[this.length+ -b])},length:0,push:Pa,sort:[].sort,splice:[].splice},Ea={};m("multiple,selected,checked,disabled,readOnly,required".split(","),function(b){Ea[z(b)]=b});var Bb={};m("input,select,option,textarea,button,form".split(","),function(b){Bb[la(b)]=!0});m({data:wb,inheritedData:Da,scope:function(b){return Da(b,"$scope")},controller:zb,injector:function(b){return Da(b,"$injector")},removeAttr:function(b,a){b.removeAttribute(a)},hasClass:Ca,css:function(b,a,c){a=tb(a);if(y(c))b.style[a]=c;else{var d;Z<=8&&(d= -b.currentStyle&&b.currentStyle[a],d===""&&(d="auto"));d=d||b.style[a];Z<=8&&(d=d===""?q:d);return d}},attr:function(b,a,c){var d=z(a);if(Ea[d])if(y(c))c?(b[a]=!0,b.setAttribute(a,d)):(b[a]=!1,b.removeAttribute(d));else return b[a]||(b.attributes.getNamedItem(a)||C).specified?d:q;else if(y(c))b.setAttribute(a,c);else if(b.getAttribute)return b=b.getAttribute(a,2),b===null?q:b},prop:function(b,a,c){if(y(c))b[a]=c;else return b[a]},text:v(Z<9?function(b,a){if(b.nodeType==1){if(w(a))return b.innerText; -b.innerText=a}else{if(w(a))return b.nodeValue;b.nodeValue=a}}:function(b,a){if(w(a))return b.textContent;b.textContent=a},{$dv:""}),val:function(b,a){if(w(a))return b.value;b.value=a},html:function(b,a){if(w(a))return b.innerHTML;for(var c=0,d=b.childNodes;c":function(a,c,d,e){return d(a,c)>e(a,c)},"<=":function(a,c,d,e){return d(a,c)<=e(a,c)},">=":function(a,c,d,e){return d(a,c)>=e(a,c)},"&&":function(a,c,d,e){return d(a,c)&&e(a,c)},"||":function(a,c,d,e){return d(a,c)||e(a,c)},"&":function(a,c,d,e){return d(a,c)&e(a,c)},"|":function(a,c,d,e){return e(a,c)(a,c,d(a,c))},"!":function(a,c,d){return!d(a,c)}},Mc={n:"\n",f:"\u000c",r:"\r",t:"\t",v:"\u000b","'":"'",'"':'"'},ib={},Zc=P.XMLHttpRequest||function(){try{return new ActiveXObject("Msxml2.XMLHTTP.6.0")}catch(a){}try{return new ActiveXObject("Msxml2.XMLHTTP.3.0")}catch(c){}try{return new ActiveXObject("Msxml2.XMLHTTP")}catch(d){}throw Error("This browser does not support XMLHttpRequest."); -};Rb.$inject=["$provide"];Sb.$inject=["$locale"];Ub.$inject=["$locale"];var Xb=".",id={yyyy:O("FullYear",4),yy:O("FullYear",2,0,!0),y:O("FullYear",1),MMMM:Ja("Month"),MMM:Ja("Month",!0),MM:O("Month",2,1),M:O("Month",1,1),dd:O("Date",2),d:O("Date",1),HH:O("Hours",2),H:O("Hours",1),hh:O("Hours",2,-12),h:O("Hours",1,-12),mm:O("Minutes",2),m:O("Minutes",1),ss:O("Seconds",2),s:O("Seconds",1),EEEE:Ja("Day"),EEE:Ja("Day",!0),a:function(a,c){return a.getHours()<12?c.AMPMS[0]:c.AMPMS[1]},Z:function(a){var a= --1*a.getTimezoneOffset(),c=a>=0?"+":"";c+=jb(Math[a>0?"floor":"ceil"](a/60),2)+jb(Math.abs(a%60),2);return c}},hd=/((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+|H+|h+|m+|s+|a|Z))(.*)/,gd=/^\d+$/;Tb.$inject=["$locale"];var ed=I(z),fd=I(la);Vb.$inject=["$parse"];var kd=I({restrict:"E",compile:function(a,c){Z<=8&&(!c.href&&!c.name&&c.$set("href",""),a.append(T.createComment("IE fix")));return function(a,c){c.bind("click",function(a){c.attr("href")||a.preventDefault()})}}}),lb={};m(Ea,function(a, -c){var d=ea("ng-"+c);lb[d]=function(){return{priority:100,compile:function(){return function(a,g,h){a.$watch(h[d],function(a){h.$set(c,!!a)})}}}}});m(["src","href"],function(a){var c=ea("ng-"+a);lb[c]=function(){return{priority:99,link:function(d,e,g){g.$observe(c,function(c){c&&(g.$set(a,c),Z&&e.prop(a,g[a]))})}}}});var Ma={$addControl:C,$removeControl:C,$setValidity:C,$setDirty:C};Yb.$inject=["$element","$attrs","$scope"];var Pa=function(a){return["$timeout",function(c){var d={name:"form",restrict:"E", -controller:Yb,compile:function(){return{pre:function(a,d,h,f){if(!h.action){var i=function(a){a.preventDefault?a.preventDefault():a.returnValue=!1};$b(d[0],"submit",i);d.bind("$destroy",function(){c(function(){db(d[0],"submit",i)},0,!1)})}var j=d.parent().controller("form"),k=h.name||h.ngForm;k&&(a[k]=f);j&&d.bind("$destroy",function(){j.$removeControl(f);k&&(a[k]=q);v(f,Ma)})}}}};return a?v(U(d),{restrict:"EAC"}):d}]},ld=Pa(),md=Pa(!0),nd=/^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/, -od=/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/,pd=/^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/,bc={text:Oa,number:function(a,c,d,e,g,h){Oa(a,c,d,e,g,h);e.$parsers.push(function(a){var c=X(a);return c||pd.test(a)?(e.$setValidity("number",!0),a===""?null:c?a:parseFloat(a)):(e.$setValidity("number",!1),q)});e.$formatters.push(function(a){return X(a)?"":""+a});if(d.min){var f=parseFloat(d.min),a=function(a){return!X(a)&&ai?(e.$setValidity("max",!1),q):(e.$setValidity("max",!0),a)};e.$parsers.push(d);e.$formatters.push(d)}e.$formatters.push(function(a){return X(a)||Qa(a)?(e.$setValidity("number",!0),a):(e.$setValidity("number",!1),q)})},url:function(a,c,d,e,g,h){Oa(a,c,d,e,g,h);a=function(a){return X(a)||nd.test(a)?(e.$setValidity("url",!0),a):(e.$setValidity("url",!1),q)};e.$formatters.push(a);e.$parsers.push(a)},email:function(a, -c,d,e,g,h){Oa(a,c,d,e,g,h);a=function(a){return X(a)||od.test(a)?(e.$setValidity("email",!0),a):(e.$setValidity("email",!1),q)};e.$formatters.push(a);e.$parsers.push(a)},radio:function(a,c,d,e){w(d.name)&&c.attr("name",xa());c.bind("click",function(){c[0].checked&&a.$apply(function(){e.$setViewValue(d.value)})});e.$render=function(){c[0].checked=d.value==e.$viewValue};d.$observe("value",e.$render)},checkbox:function(a,c,d,e){var g=d.ngTrueValue,h=d.ngFalseValue;B(g)||(g=!0);B(h)||(h=!1);c.bind("click", -function(){a.$apply(function(){e.$setViewValue(c[0].checked)})});e.$render=function(){c[0].checked=e.$viewValue};e.$formatters.push(function(a){return a===g});e.$parsers.push(function(a){return a?g:h})},hidden:C,button:C,submit:C,reset:C},cc=["$browser","$sniffer",function(a,c){return{restrict:"E",require:"?ngModel",link:function(d,e,g,h){h&&(bc[z(g.type)]||bc.text)(d,e,g,h,c,a)}}}],La="ng-valid",Ka="ng-invalid",Na="ng-pristine",Zb="ng-dirty",qd=["$scope","$exceptionHandler","$attrs","$element","$parse", -function(a,c,d,e,g){function h(a,c){c=c?"-"+Za(c,"-"):"";e.removeClass((a?Ka:La)+c).addClass((a?La:Ka)+c)}this.$modelValue=this.$viewValue=Number.NaN;this.$parsers=[];this.$formatters=[];this.$viewChangeListeners=[];this.$pristine=!0;this.$dirty=!1;this.$valid=!0;this.$invalid=!1;this.$name=d.name;var f=g(d.ngModel),i=f.assign;if(!i)throw Error(Eb+d.ngModel+" ("+pa(e)+")");this.$render=C;var j=e.inheritedData("$formController")||Ma,k=0,l=this.$error={};e.addClass(Na);h(!0);this.$setValidity=function(a, -c){if(l[a]!==!c){if(c){if(l[a]&&k--,!k)h(!0),this.$valid=!0,this.$invalid=!1}else h(!1),this.$invalid=!0,this.$valid=!1,k++;l[a]=!c;h(c,a);j.$setValidity(a,c,this)}};this.$setViewValue=function(d){this.$viewValue=d;if(this.$pristine)this.$dirty=!0,this.$pristine=!1,e.removeClass(Na).addClass(Zb),j.$setDirty();m(this.$parsers,function(a){d=a(d)});if(this.$modelValue!==d)this.$modelValue=d,i(a,d),m(this.$viewChangeListeners,function(a){try{a()}catch(d){c(d)}})};var n=this;a.$watch(function(){var c= -f(a);if(n.$modelValue!==c){var d=n.$formatters,e=d.length;for(n.$modelValue=c;e--;)c=d[e](c);if(n.$viewValue!==c)n.$viewValue=c,n.$render()}})}],rd=function(){return{require:["ngModel","^?form"],controller:qd,link:function(a,c,d,e){var g=e[0],h=e[1]||Ma;h.$addControl(g);c.bind("$destroy",function(){h.$removeControl(g)})}}},sd=I({require:"ngModel",link:function(a,c,d,e){e.$viewChangeListeners.push(function(){a.$eval(d.ngChange)})}}),dc=function(){return{require:"?ngModel",link:function(a,c,d,e){if(e){d.required= -!0;var g=function(a){if(d.required&&(X(a)||a===!1))e.$setValidity("required",!1);else return e.$setValidity("required",!0),a};e.$formatters.push(g);e.$parsers.unshift(g);d.$observe("required",function(){g(e.$viewValue)})}}}},td=function(){return{require:"ngModel",link:function(a,c,d,e){var g=(a=/\/(.*)\//.exec(d.ngList))&&RegExp(a[1])||d.ngList||",";e.$parsers.push(function(a){var c=[];a&&m(a.split(g),function(a){a&&c.push(Q(a))});return c});e.$formatters.push(function(a){return E(a)?a.join(", "): -q})}}},ud=/^(true|false|\d+)$/,vd=function(){return{priority:100,compile:function(a,c){return ud.test(c.ngValue)?function(a,c,g){g.$set("value",a.$eval(g.ngValue))}:function(a,c,g){a.$watch(g.ngValue,function(a){g.$set("value",a,!1)})}}}},wd=S(function(a,c,d){c.addClass("ng-binding").data("$binding",d.ngBind);a.$watch(d.ngBind,function(a){c.text(a==q?"":a)})}),xd=["$interpolate",function(a){return function(c,d,e){c=a(d.attr(e.$attr.ngBindTemplate));d.addClass("ng-binding").data("$binding",c);e.$observe("ngBindTemplate", -function(a){d.text(a)})}}],yd=[function(){return function(a,c,d){c.addClass("ng-binding").data("$binding",d.ngBindHtmlUnsafe);a.$watch(d.ngBindHtmlUnsafe,function(a){c.html(a||"")})}}],zd=kb("",!0),Ad=kb("Odd",0),Bd=kb("Even",1),Cd=S({compile:function(a,c){c.$set("ngCloak",q);a.removeClass("ng-cloak")}}),Dd=[function(){return{scope:!0,controller:"@"}}],Ed=["$sniffer",function(a){return{priority:1E3,compile:function(){a.csp=!0}}}],ec={};m("click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave".split(" "), -function(a){var c=ea("ng-"+a);ec[c]=["$parse",function(d){return function(e,g,h){var f=d(h[c]);g.bind(z(a),function(a){e.$apply(function(){f(e,{$event:a})})})}}]});var Fd=S(function(a,c,d){c.bind("submit",function(){a.$apply(d.ngSubmit)})}),Gd=["$http","$templateCache","$anchorScroll","$compile",function(a,c,d,e){return{restrict:"ECA",terminal:!0,compile:function(g,h){var f=h.ngInclude||h.src,i=h.onload||"",j=h.autoscroll;return function(g,h){var n=0,o,p=function(){o&&(o.$destroy(),o=null);h.html("")}; -g.$watch(f,function(f){var m=++n;f?a.get(f,{cache:c}).success(function(a){m===n&&(o&&o.$destroy(),o=g.$new(),h.html(a),e(h.contents())(o),y(j)&&(!j||g.$eval(j))&&d(),o.$emit("$includeContentLoaded"),g.$eval(i))}).error(function(){m===n&&p()}):p()})}}}}],Hd=S({compile:function(){return{pre:function(a,c,d){a.$eval(d.ngInit)}}}}),Id=S({terminal:!0,priority:1E3}),Jd=["$locale","$interpolate",function(a,c){var d=/{}/g;return{restrict:"EA",link:function(e,g,h){var f=h.count,i=g.attr(h.$attr.when),j=h.offset|| -0,k=e.$eval(i),l={},n=c.startSymbol(),o=c.endSymbol();m(k,function(a,e){l[e]=c(a.replace(d,n+f+"-"+j+o))});e.$watch(function(){var c=parseFloat(e.$eval(f));return isNaN(c)?"":(c in k||(c=a.pluralCat(c-j)),l[c](e,g,!0))},function(a){g.text(a)})}}}],Kd=S({transclude:"element",priority:1E3,terminal:!0,compile:function(a,c,d){return function(a,c,h){var f=h.ngRepeat,h=f.match(/^\s*(.+)\s+in\s+(.*)\s*$/),i,j,k;if(!h)throw Error("Expected ngRepeat in form of '_item_ in _collection_' but got '"+f+"'.");f= -h[1];i=h[2];h=f.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/);if(!h)throw Error("'item' in 'item in collection' should be identifier or (key, value) but got '"+f+"'.");j=h[3]||h[1];k=h[2];var l=new eb;a.$watch(function(a){var e,f,h=a.$eval(i),m=c,q=new eb,y,A,u,w,r,v;if(E(h))r=h||[];else{r=[];for(u in h)h.hasOwnProperty(u)&&u.charAt(0)!="$"&&r.push(u);r.sort()}y=r.length-1;e=0;for(f=r.length;ez;)u.pop().element.remove()}for(;r.length> -x;)r.pop()[0].element.remove()}var i;if(!(i=s.match(d)))throw Error("Expected ngOptions in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_' but got '"+s+"'.");var j=c(i[2]||i[1]),k=i[4]||i[6],l=i[5],m=c(i[3]||""),n=c(i[2]?i[1]:k),o=c(i[7]),r=[[{element:f,label:""}]];t&&(a(t)(e),t.removeClass("ng-scope"),t.remove());f.html("");f.bind("change",function(){e.$apply(function(){var a,c=o(e)||[],d={},h,i,j,m,s,t;if(p){i=[];m=0;for(t=r.length;m@charset "UTF-8";[ng\\:cloak],[ng-cloak],[data-ng-cloak],[x-ng-cloak],.ng-cloak,.x-ng-cloak{display:none;}ng\\:form{display:block;}'); +(function(N,S,s){'use strict';function I(b){return function(){var a=arguments[0],c,a="["+(b?b+":":"")+a+"] http://errors.angularjs.org/1.3.0-beta.7/"+(b?b+"/":"")+a;for(c=1;c").append(b).html();try{return 3===b[0].nodeType?u(c):c.match(/^(<[^>]+>)/)[1].replace(/^<([\w\-]+)/, +function(a,b){return"<"+u(b)})}catch(d){return u(c)}}function fc(b){try{return decodeURIComponent(b)}catch(a){}}function gc(b){var a={},c,d;q((b||"").split("&"),function(b){b&&(c=b.split("="),d=fc(c[0]),F(d)&&(b=F(c[1])?fc(c[1]):!0,a[d]?M(a[d])?a[d].push(b):a[d]=[a[d],b]:a[d]=b))});return a}function Db(b){var a=[];q(b,function(b,d){M(b)?q(b,function(b){a.push(za(d,!0)+(!0===b?"":"="+za(b,!0)))}):a.push(za(d,!0)+(!0===b?"":"="+za(b,!0)))});return a.length?a.join("&"):""}function hb(b){return za(b, +!0).replace(/%26/gi,"&").replace(/%3D/gi,"=").replace(/%2B/gi,"+")}function za(b,a){return encodeURIComponent(b).replace(/%40/gi,"@").replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,a?"%20":"+")}function hd(b,a){var c,d,e=hc.length;b=y(b);for(d=0;d")+ +d[2];for(d=d[0];d--;)c=c.lastChild;f=f.concat(ra.call(c.childNodes,void 0));c=e.firstChild;c.textContent=""}else f.push(a.createTextNode(b));e.textContent="";e.innerHTML="";q(f,function(a){e.appendChild(a)});return e}function U(b){if(b instanceof U)return b;x(b)&&(b=Y(b));if(!(this instanceof U)){if(x(b)&&"<"!=b.charAt(0))throw Kb("nosel");return new U(b)}if(x(b)){var a;a=S;var c;b=(c=Ae.exec(b))?[a.createElement(c[1])]:(c=xe(b,a))?c.childNodes:[]}pc(this,b)}function Lb(b){return b.cloneNode(!0)} +function Ha(b){qc(b);var a=0;for(b=b.childNodes||[];a=P?(c.preventDefault=null,c.stopPropagation=null,c.isDefaultPrevented=null):(delete c.preventDefault,delete c.stopPropagation,delete c.isDefaultPrevented)};c.elem=b;return c}function Ia(b){var a=typeof b,c;"object"==a&&null!==b?"function"==typeof(c=b.$$hashKey)?c= +b.$$hashKey():c===s&&(c=b.$$hashKey=db()):c=b;return a+":"+c}function Wa(b){q(b,this.put,this)}function De(b){return(b=b.toString().replace(xc,"").match(yc))?"function("+(b[1]||"").replace(/[\s\r\n]+/," ")+")":"fn"}function Nb(b,a,c){var d;if("function"==typeof b){if(!(d=b.$inject)){d=[];if(b.length){if(a)throw x(c)&&c||(c=b.name||De(b)),Ja("strictdi",c);a=b.toString().replace(xc,"");a=a.match(yc);q(a[1].split(Ee),function(a){a.replace(Fe,function(a,b,c){d.push(c)})})}b.$inject=d}}else M(b)?(a=b.length- +1,Ra(b[a],"fn"),d=b.slice(0,a)):Ra(b,"fn",!0);return d}function Eb(b,a){function c(a){return function(b,c){if(R(b))q(b,$b(a));else return a(b,c)}}function d(a,b){Aa(a,"service");if(O(b)||M(b))b=n.instantiate(b);if(!b.$get)throw Ja("pget",a);return p[a+m]=b}function e(a,b){return d(a,{$get:b})}function f(a){var b=[],c,d,e,h;q(a,function(a){if(!l.get(a)){l.put(a,!0);try{if(x(a))for(c=Sa(a),b=b.concat(f(c.requires)).concat(c._runBlocks),d=c._invokeQueue,e=0,h=d.length;e 4096 bytes)!"));else{if(m.cookie!==Z)for(Z=m.cookie,d=Z.split("; "),L={},f=0;fk&&this.remove(n.key),b},get:function(a){if(k").parent()[0])});var f=K(a,b,a,c,d,e);ma(a,"ng-scope");return function(b,c,d){Fb(b,"scope");var e=c?Ka.clone.call(a):a;q(d,function(a,b){e.data("$"+b+"Controller",a)});d=0;for(var g=e.length;darguments.length&& +(b=a,a=s);w&&(c=Z);return n(a,b,c)}var z,v,Xa,C,K,G,Z={},pb;z=c===f?d:cc(d,new Ob(y(f),d.$attr));v=z.$$element;if(L){var ba=/^\s*([@=&])(\??)\s*(\w*)\s*$/;g=y(f);G=e.$new(!0);ha&&ha===L.$$originalDirective?g.data("$isolateScope",G):g.data("$isolateScopeNoTemplate",G);ma(g,"ng-isolate-scope");q(L.scope,function(a,c){var d=a.match(ba)||[],f=d[3]||c,g="?"==d[2],d=d[1],m,l,p,n;G.$$isolateBindings[c]=d+f;switch(d){case "@":z.$observe(f,function(a){G[c]=a});z.$$observers[f].$$scope=e;z[f]&&(G[c]=b(z[f])(e)); +break;case "=":if(g&&!z[f])break;l=r(z[f]);n=l.literal?ya:function(a,b){return a===b};p=l.assign||function(){m=G[c]=l(e);throw ia("nonassign",z[f],L.name);};m=G[c]=l(e);G.$watch(function(){var a=l(e);n(a,G[c])||(n(a,m)?p(e,a=G[c]):G[c]=a);return m=a},null,l.literal);break;case "&":l=r(z[f]);G[c]=function(a){return l(e,a)};break;default:throw ia("iscp",L.name,c,a);}})}pb=n&&E;W&&q(W,function(a){var b={$scope:a===L||a.$$isolateScope?G:e,$element:v,$attrs:z,$transclude:pb},c;K=a.controller;"@"==K&&(K= +z[a.name]);c=t(K,b);Z[a.name]=c;w||v.data("$"+a.name+"Controller",c);a.controllerAs&&(b.$scope[a.controllerAs]=c)});g=0;for(Xa=m.length;gH.priority)break;if(T=H.scope)K=K||H,H.templateUrl||(A("new/isolated scope",L,H,u),R(T)&&(L=H));ca=H.name;!H.templateUrl&&H.controller&&(T=H.controller,W=W||{},A("'"+ca+"' controller",W[ca],H,u),W[ca]=H);if(T=H.transclude)qb=!0,H.$$tlb||(A("transclusion",ba,H, +u),ba=H),"element"==T?(w=!0,z=H.priority,T=G(c,Q,V),u=d.$$element=y(S.createComment(" "+ca+": "+d[ca]+" ")),c=u[0],rb(f,y(ra.call(T,0)),c),I=v(T,e,z,g&&g.name,{nonTlbTranscludeDirective:ba})):(T=y(Lb(c)).contents(),u.empty(),I=v(T,e));if(H.template)if(A("template",ha,H,u),ha=H,T=O(H.template)?H.template(u,d):H.template,T=U(T),H.replace){g=H;T=Jb.test(T)?y(Y(T)):[];c=T[0];if(1!=T.length||1!==c.nodeType)throw ia("tplrt",ca,"");rb(f,u,c);P={$attr:{}};T=Z(c,[],P);var X=a.splice(ja+1,a.length-(ja+1)); +L&&ob(T);a=a.concat(T).concat(X);F(d,P);P=a.length}else u.html(T);if(H.templateUrl)A("template",ha,H,u),ha=H,H.replace&&(g=H),J=D(a.splice(ja,a.length-ja),u,d,f,I,m,p,{controllerDirectives:W,newIsolateScopeDirective:L,templateDirective:ha,nonTlbTranscludeDirective:ba}),P=a.length;else if(H.compile)try{N=H.compile(u,d,I),O(N)?E(null,N,Q,V):N&&E(N.pre,N.post,Q,V)}catch(He){l(He,ga(u))}H.terminal&&(J.terminal=!0,z=Math.max(z,H.priority))}J.scope=K&&!0===K.scope;J.transclude=qb&&I;n.hasElementTranscludeDirective= +w;return J}function ob(a){for(var b=0,c=a.length;bn.priority)&&-1!=n.restrict.indexOf(f)&&(p&&(n=bc(n,{$$start:p,$$end:r})),b.push(n),k=n)}catch(z){l(z)}}return k}function F(a,b){var c=b.$attr,d=a.$attr,e=a.$$element;q(a,function(d,e){"$"!=e.charAt(0)&&(b[e]&&(d+=("style"===e?";":" ")+b[e]),a.$set(e,d,!0,c[e]))}); +q(b,function(b,f){"class"==f?(ma(e,b),a["class"]=(a["class"]?a["class"]+" ":"")+b):"style"==f?(e.attr("style",e.attr("style")+";"+b),a.style=(a.style?a.style+";":"")+b):"$"==f.charAt(0)||a.hasOwnProperty(f)||(a[f]=b,d[f]=c[f])})}function D(a,b,c,d,e,f,g,k){var m=[],l,r,E=b[0],t=a.shift(),z=w({},t,{templateUrl:null,transclude:null,replace:null,$$originalDirective:t}),J=O(t.templateUrl)?t.templateUrl(b,c):t.templateUrl;b.empty();p.get(B.getTrustedResourceUrl(J),{cache:n}).success(function(p){var n, +B;p=U(p);if(t.replace){p=Jb.test(p)?y(Y(p)):[];n=p[0];if(1!=p.length||1!==n.nodeType)throw ia("tplrt",t.name,J);p={$attr:{}};rb(d,b,n);var v=Z(n,[],p);R(t.scope)&&ob(v);a=v.concat(a);F(c,p)}else n=E,b.html(p);a.unshift(z);l=ha(a,n,c,e,b,t,f,g,k);q(d,function(a,c){a==n&&(d[c]=b[0])});for(r=K(b[0].childNodes,e);m.length;){p=m.shift();B=m.shift();var C=m.shift(),G=m.shift(),v=b[0];if(B!==E){var W=B.className;k.hasElementTranscludeDirective&&t.replace||(v=Lb(n));rb(C,y(B),v);ma(y(v),W)}B=l.transclude? +L(p,l.transclude):G;l(r,p,v,d,B)}m=null}).error(function(a,b,c,d){throw ia("tpload",d.url);});return function(a,b,c,d,e){m?(m.push(b),m.push(c),m.push(d),m.push(e)):l(r,b,c,d,e)}}function qb(a,b){var c=b.priority-a.priority;return 0!==c?c:a.name!==b.name?a.namea.status?b:p.reject(b)}var d={method:"get",transformRequest:e.transformRequest,transformResponse:e.transformResponse},f=function(a){function b(a){var c;q(a,function(b,d){O(b)&&(c=b(),null!=c?a[d]=c:delete a[d])})}var c=e.headers,d=w({},a.headers),f,g,c=w({},c.common,c[u(a.method)]);b(c);b(d);a:for(f in c){a=u(f);for(g in d)if(u(g)===a)continue a;d[f]=c[f]}return d}(a);w(d,a);d.headers=f;d.method=Fa(d.method);(a=Pb(d.url)? +b.cookies()[d.xsrfCookieName||e.xsrfCookieName]:s)&&(f[d.xsrfHeaderName||e.xsrfHeaderName]=a);var g=[function(a){f=a.headers;var b=Dc(a.data,Cc(f),a.transformRequest);A(a.data)&&q(f,function(a,b){"content-type"===u(b)&&delete f[b]});A(a.withCredentials)&&!A(e.withCredentials)&&(a.withCredentials=e.withCredentials);return t(a,b,f).then(c,c)},s],h=p.when(d);for(q(B,function(a){(a.request||a.requestError)&&g.unshift(a.request,a.requestError);(a.response||a.responseError)&&g.push(a.response,a.responseError)});g.length;){a= +g.shift();var k=g.shift(),h=h.then(a,k)}h.success=function(a){h.then(function(b){a(b.data,b.status,b.headers,d)});return h};h.error=function(a){h.then(null,function(b){a(b.data,b.status,b.headers,d)});return h};return h}function t(b,c,f){function g(a,b,c,e){B&&(200<=a&&300>a?B.put(s,[a,b,Bc(c),e]):B.remove(s));m(b,a,c,e);d.$$phase||d.$apply()}function m(a,c,d,e){c=Math.max(c,0);(200<=c&&300>c?n.resolve:n.reject)({data:a,status:c,headers:Cc(d),config:b,statusText:e})}function k(){var a=fb(r.pendingRequests, +b);-1!==a&&r.pendingRequests.splice(a,1)}var n=p.defer(),t=n.promise,B,q,s=J(b.url,b.params);r.pendingRequests.push(b);t.then(k,k);(b.cache||e.cache)&&(!1!==b.cache&&"GET"==b.method)&&(B=R(b.cache)?b.cache:R(e.cache)?e.cache:E);if(B)if(q=B.get(s),F(q)){if(q.then)return q.then(k,k),q;M(q)?m(q[1],q[0],aa(q[2]),q[3]):m(q,200,{},"OK")}else B.put(s,t);A(q)&&a(b.method,s,c,g,f,b.timeout,b.withCredentials,b.responseType);return t}function J(a,b){if(!b)return a;var c=[];dd(b,function(a,b){null===a||A(a)|| +(M(a)||(a=[a]),q(a,function(a){R(a)&&(a=sa(a));c.push(za(b)+"="+za(a))}))});0=P&&(!b.match(/^(get|post|head|put|delete|options)$/i)||!N.XMLHttpRequest))return new N.ActiveXObject("Microsoft.XMLHTTP");if(N.XMLHttpRequest)return new N.XMLHttpRequest;throw I("$httpBackend")("noxhr");}function he(){this.$get=["$browser","$window","$document",function(b,a,c){return Ke(b,Je,b.defer,a.angular.callbacks, +c[0])}]}function Ke(b,a,c,d,e){function f(a,b,c){var f=e.createElement("script"),g=null;f.type="text/javascript";f.src=a;f.async=!0;g=function(a){Ua(f,"load",g);Ua(f,"error",g);e.body.removeChild(f);f=null;var h=-1,t="unknown";a&&("load"!==a.type||d[b].called||(a={type:"error"}),t=a.type,h="error"===a.type?404:200);c&&c(h,t)};sb(f,"load",g);sb(f,"error",g);e.body.appendChild(f);return g}var g=-1;return function(e,m,k,l,p,n,r,t){function J(){B=g;W&&W();v&&v.abort()}function E(a,d,e,f,g){K&&c.cancel(K); +W=v=null;0===d&&(d=e?200:"file"==ta(m).protocol?404:0);a(1223===d?204:d,e,f,g||"");b.$$completeOutstandingRequest(D)}var B;b.$$incOutstandingRequestCount();m=m||b.url();if("jsonp"==u(e)){var z="_"+(d.counter++).toString(36);d[z]=function(a){d[z].data=a;d[z].called=!0};var W=f(m.replace("JSON_CALLBACK","angular.callbacks."+z),z,function(a,b){E(l,a,d[z].data,"",b);d[z]=D})}else{var v=a(e);v.open(e,m,!0);q(p,function(a,b){F(a)&&v.setRequestHeader(b,a)});v.onreadystatechange=function(){if(v&&4==v.readyState){var a= +null,b=null;B!==g&&(a=v.getAllResponseHeaders(),b="response"in v?v.response:v.responseText);E(l,B||v.status,b,a,v.statusText||"")}};r&&(v.withCredentials=!0);if(t)try{v.responseType=t}catch(s){if("json"!==t)throw s;}v.send(k||null)}if(0=h&&(p.resolve(r),l(n.$$intervalId),delete e[n.$$intervalId]);t||b.$apply()},g);e[n.$$intervalId]=p;return n}var e={};d.cancel=function(a){return a&&a.$$intervalId in e?(e[a.$$intervalId].reject("canceled"),clearInterval(a.$$intervalId),delete e[a.$$intervalId], +!0):!1};return d}]}function nd(){this.$get=function(){return{id:"en-us",NUMBER_FORMATS:{DECIMAL_SEP:".",GROUP_SEP:",",PATTERNS:[{minInt:1,minFrac:0,maxFrac:3,posPre:"",posSuf:"",negPre:"-",negSuf:"",gSize:3,lgSize:3},{minInt:1,minFrac:2,maxFrac:2,posPre:"\u00a4",posSuf:"",negPre:"(\u00a4",negSuf:")",gSize:3,lgSize:3}],CURRENCY_SYM:"$"},DATETIME_FORMATS:{MONTH:"January February March April May June July August September October November December".split(" "),SHORTMONTH:"Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "), +DAY:"Sunday Monday Tuesday Wednesday Thursday Friday Saturday".split(" "),SHORTDAY:"Sun Mon Tue Wed Thu Fri Sat".split(" "),AMPMS:["AM","PM"],medium:"MMM d, y h:mm:ss a","short":"M/d/yy h:mm a",fullDate:"EEEE, MMMM d, y",longDate:"MMMM d, y",mediumDate:"MMM d, y",shortDate:"M/d/yy",mediumTime:"h:mm:ss a",shortTime:"h:mm a"},pluralCat:function(b){return 1===b?"one":"other"}}}}function Qb(b){b=b.split("/");for(var a=b.length;a--;)b[a]=hb(b[a]);return b.join("/")}function Fc(b,a,c){b=ta(b,c);a.$$protocol= +b.protocol;a.$$host=b.hostname;a.$$port=Q(b.port)||Le[b.protocol]||null}function Gc(b,a,c){var d="/"!==b.charAt(0);d&&(b="/"+b);b=ta(b,c);a.$$path=decodeURIComponent(d&&"/"===b.pathname.charAt(0)?b.pathname.substring(1):b.pathname);a.$$search=gc(b.search);a.$$hash=decodeURIComponent(b.hash);a.$$path&&"/"!=a.$$path.charAt(0)&&(a.$$path="/"+a.$$path)}function oa(b,a){if(0===a.indexOf(b))return a.substr(b.length)}function Ya(b){var a=b.indexOf("#");return-1==a?b:b.substr(0,a)}function Rb(b){return b.substr(0, +Ya(b).lastIndexOf("/")+1)}function Hc(b,a){this.$$html5=!0;a=a||"";var c=Rb(b);Fc(b,this,b);this.$$parse=function(a){var e=oa(c,a);if(!x(e))throw Sb("ipthprfx",a,c);Gc(e,this,b);this.$$path||(this.$$path="/");this.$$compose()};this.$$compose=function(){var a=Db(this.$$search),b=this.$$hash?"#"+hb(this.$$hash):"";this.$$url=Qb(this.$$path)+(a?"?"+a:"")+b;this.$$absUrl=c+this.$$url.substr(1)};this.$$rewrite=function(d){var e;if((e=oa(b,d))!==s)return d=e,(e=oa(a,e))!==s?c+(oa("/",e)||e):b+d;if((e=oa(c, +d))!==s)return c+e;if(c==d+"/")return c}}function Tb(b,a){var c=Rb(b);Fc(b,this,b);this.$$parse=function(d){var e=oa(b,d)||oa(c,d),e="#"==e.charAt(0)?oa(a,e):this.$$html5?e:"";if(!x(e))throw Sb("ihshprfx",d,a);Gc(e,this,b);d=this.$$path;var f=/^\/[A-Z]:(\/.*)/;0===e.indexOf(b)&&(e=e.replace(b,""));f.exec(e)||(d=(e=f.exec(d))?e[1]:d);this.$$path=d;this.$$compose()};this.$$compose=function(){var c=Db(this.$$search),e=this.$$hash?"#"+hb(this.$$hash):"";this.$$url=Qb(this.$$path)+(c?"?"+c:"")+e;this.$$absUrl= +b+(this.$$url?a+this.$$url:"")};this.$$rewrite=function(a){if(Ya(b)==Ya(a))return a}}function Ub(b,a){this.$$html5=!0;Tb.apply(this,arguments);var c=Rb(b);this.$$rewrite=function(d){var e;if(b==Ya(d))return d;if(e=oa(c,d))return b+a+e;if(c===d+"/")return c};this.$$compose=function(){var c=Db(this.$$search),e=this.$$hash?"#"+hb(this.$$hash):"";this.$$url=Qb(this.$$path)+(c?"?"+c:"")+e;this.$$absUrl=b+a+this.$$url}}function tb(b){return function(){return this[b]}}function Ic(b,a){return function(c){if(A(c))return this[b]; +this[b]=a(c);this.$$compose();return this}}function ie(){var b="",a=!1;this.hashPrefix=function(a){return F(a)?(b=a,this):b};this.html5Mode=function(b){return F(b)?(a=b,this):a};this.$get=["$rootScope","$browser","$sniffer","$rootElement",function(c,d,e,f){function g(a){c.$broadcast("$locationChangeSuccess",h.absUrl(),a)}var h,m,k=d.baseHref(),l=d.url(),p;a?(p=l.substring(0,l.indexOf("/",l.indexOf("//")+2))+(k||"/"),m=e.history?Hc:Ub):(p=Ya(l),m=Tb);h=new m(p,"#"+b);h.$$parse(h.$$rewrite(l));f.on("click", +function(a){if(!a.ctrlKey&&!a.metaKey&&2!=a.which){for(var e=y(a.target);"a"!==u(e[0].nodeName);)if(e[0]===f[0]||!(e=e.parent())[0])return;var g=e.prop("href");R(g)&&"[object SVGAnimatedString]"===g.toString()&&(g=ta(g.animVal).href);if(m===Ub){var k=e.attr("href")||e.attr("xlink:href");if(0>k.indexOf("://"))if(g="#"+b,"/"==k[0])g=p+g+k;else if("#"==k[0])g=p+g+(h.path()||"/")+k;else{for(var l=h.path().split("/"),k=k.split("/"),n=0;ne?Jc(d[0],d[1],d[2],d[3],d[4],c,a):function(b,f){var g=0,h;do h=Jc(d[g++],d[g++],d[g++],d[g++],d[g++],c,a)(b,f),f=s,b=h;while(ga)for(b in k++,e)e.hasOwnProperty(b)&&!d.hasOwnProperty(b)&&(q--, +delete e[b])}else e!==d&&(e=d,k++);return k},function(){p?(p=!1,b(d,d,c)):b(d,g,c);if(h)if(R(d))if(cb(d)){g=Array(d.length);for(var a=0;as&&(y=4-s,L[y]||(L[y]=[]),G=O(d.exp)?"fn: "+(d.exp.name||d.exp.toString()):d.exp,G+="; newVal: "+sa(f)+"; oldVal: "+sa(g),L[y].push(G));else if(d===c){v=!1;break a}}catch(u){n.$$phase=null,e(u)}if(!(h=K.$$childHead||K!==this&&K.$$nextSibling))for(;K!==this&&!(h=K.$$nextSibling);)K=K.$parent}while(K=h);if((v||k.length)&&!s--)throw n.$$phase=null,a("infdig", +b,sa(L));}while(v||k.length);for(n.$$phase=null;l.length;)try{l.shift()()}catch(x){e(x)}},$destroy:function(){if(!this.$$destroyed){var a=this.$parent;this.$broadcast("$destroy");this.$$destroyed=!0;this!==n&&(q(this.$$listenerCount,gb(null,l,this)),a.$$childHead==this&&(a.$$childHead=this.$$nextSibling),a.$$childTail==this&&(a.$$childTail=this.$$prevSibling),this.$$prevSibling&&(this.$$prevSibling.$$nextSibling=this.$$nextSibling),this.$$nextSibling&&(this.$$nextSibling.$$prevSibling=this.$$prevSibling), +this.$parent=this.$$nextSibling=this.$$prevSibling=this.$$childHead=this.$$childTail=this.$root=null,this.$$listeners={},this.$$watchers=this.$$asyncQueue=this.$$postDigestQueue=[],this.$destroy=this.$digest=this.$apply=D,this.$on=this.$watch=this.$watchGroup=function(){return D})}},$eval:function(a,b){return f(a)(this,b)},$evalAsync:function(a){n.$$phase||n.$$asyncQueue.length||g.defer(function(){n.$$asyncQueue.length&&n.$digest()});this.$$asyncQueue.push({scope:this,expression:a})},$$postDigest:function(a){this.$$postDigestQueue.push(a)}, +$apply:function(a){try{return m("$apply"),this.$eval(a)}catch(b){e(b)}finally{n.$$phase=null;try{n.$digest()}catch(c){throw e(c),c;}}},$on:function(a,b){var c=this.$$listeners[a];c||(this.$$listeners[a]=c=[]);c.push(b);var d=this;do d.$$listenerCount[a]||(d.$$listenerCount[a]=0),d.$$listenerCount[a]++;while(d=d.$parent);var e=this;return function(){c[fb(c,b)]=null;l(e,1,a)}},$emit:function(a,b){var c=[],d,f=this,g=!1,h={name:a,targetScope:f,stopPropagation:function(){g=!0},preventDefault:function(){h.defaultPrevented= +!0},defaultPrevented:!1},k=[h].concat(ra.call(arguments,1)),m,l;do{d=f.$$listeners[a]||c;h.currentScope=f;m=0;for(l=d.length;mc.msieDocumentMode)throw va("iequirks");var e=aa(fa);e.isEnabled=function(){return b};e.trustAs=d.trustAs;e.getTrusted=d.getTrusted;e.valueOf=d.valueOf;b||(e.trustAs=e.getTrusted=function(a,b){return b},e.valueOf=Da);e.parseAs=function(b,c){var d=a(c);return d.literal&&d.constant?d:function(a,c){return e.getTrusted(b,d(a,c))}};var f=e.parseAs,g=e.getTrusted,h=e.trustAs;q(fa,function(a,b){var c=u(b);e[Ta("parse_as_"+c)]=function(b){return f(a,b)};e[Ta("get_trusted_"+c)]=function(b){return g(a, +b)};e[Ta("trust_as_"+c)]=function(b){return h(a,b)}});return e}]}function pe(){this.$get=["$window","$document",function(b,a){var c={},d=Q((/android (\d+)/.exec(u((b.navigator||{}).userAgent))||[])[1]),e=/Boxee/i.test((b.navigator||{}).userAgent),f=a[0]||{},g=f.documentMode,h,m=/^(Moz|webkit|O|ms)(?=[A-Z])/,k=f.body&&f.body.style,l=!1,p=!1;if(k){for(var n in k)if(l=m.exec(n)){h=l[0];h=h.substr(0,1).toUpperCase()+h.substr(1);break}h||(h="WebkitOpacity"in k&&"webkit");l=!!("transition"in k||h+"Transition"in +k);p=!!("animation"in k||h+"Animation"in k);!d||l&&p||(l=x(f.body.style.webkitTransition),p=x(f.body.style.webkitAnimation))}return{history:!(!b.history||!b.history.pushState||4>d||e),hashchange:"onhashchange"in b&&(!g||7b;b=Math.abs(b);var g=b+"",h="",m=[],k=!1;if(-1!==g.indexOf("e")){var l=g.match(/([\d\.]+)e(-?)(\d+)/);l&&"-"==l[2]&&l[3]>e+1?g="0":(h=g,k=!0)}if(k)0b)&&(h=b.toFixed(e));else{g=(g.split(Uc)[1]||"").length;A(e)&&(e=Math.min(Math.max(a.minFrac,g),a.maxFrac));g=Math.pow(10,e);b=Math.round(b*g)/g;b=(""+b).split(Uc);g=b[0];b=b[1]||"";var l=0,p=a.lgSize,n=a.gSize;if(g.length>=p+n)for(l=g.length- +p,k=0;kb&&(d="-",b=-b);for(b=""+b;b.length-c)e+=c;0===e&&-12==c&&(e=12);return vb(e,a,d)}} +function wb(b,a){return function(c,d){var e=c["get"+b](),f=Fa(a?"SHORT"+b:b);return d[f][e]}}function Vc(b){var a=(new Date(b,0,1)).getDay();return new Date(b,0,(4>=a?5:12)-a)}function Wc(b){return function(a){var c=Vc(a.getFullYear());a=+new Date(a.getFullYear(),a.getMonth(),a.getDate()+(4-a.getDay()))-+c;a=1+Math.round(a/6048E5);return vb(a,b)}}function Qc(b){function a(a){var b;if(b=a.match(c)){a=new Date(0);var f=0,g=0,h=b[8]?a.setUTCFullYear:a.setFullYear,m=b[8]?a.setUTCHours:a.setHours;b[9]&& +(f=Q(b[9]+b[10]),g=Q(b[9]+b[11]));h.call(a,Q(b[1]),Q(b[2])-1,Q(b[3]));f=Q(b[4]||0)-f;g=Q(b[5]||0)-g;h=Q(b[6]||0);b=Math.round(1E3*parseFloat("0."+(b[7]||0)));m.call(a,f,g,h,b)}return a}var c=/^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/;return function(c,e){var f="",g=[],h,m;e=e||"mediumDate";e=b.DATETIME_FORMATS[e]||e;x(c)&&(c=Ve.test(c)?Q(c):a(c));Cb(c)&&(c=new Date(c));if(!qa(c))return c;for(;e;)(m=We.exec(e))?(g=g.concat(ra.call(m,1)),e= +g.pop()):(g.push(e),e=null);q(g,function(a){h=Xe[a];f+=h?h(c,b.DATETIME_FORMATS):a.replace(/(^'|'$)/g,"").replace(/''/g,"'")});return f}}function Re(){return function(b){return sa(b,!0)}}function Se(){return function(b,a){if(!M(b)&&!x(b))return b;a=Infinity===Math.abs(Number(a))?Number(a):Q(a);if(x(b))return a?0<=a?b.slice(0,a):b.slice(a,b.length):"";var c=[],d,e;a>b.length?a=b.length:a<-b.length&&(a=-b.length);0b||37<=b&&40>=b)||n(a)});if(e.hasEvent("paste"))a.on("paste cut",n)}a.on("change",l)}d.$render=function(){a.val(d.$isEmpty(d.$viewValue)?"":d.$viewValue)};var r=c.ngPattern;r&&((e=r.match(/^\/(.*)\/([gim]*)$/))?(r=RegExp(e[1],e[2]),e=function(a){return pa(d,"pattern",d.$isEmpty(a)||r.test(a),a)}):e=function(c){var e=b.$eval(r);if(!e||!e.test)throw I("ngPattern")("noregexp", +r,e,ga(a));return pa(d,"pattern",d.$isEmpty(c)||e.test(c),c)},d.$formatters.push(e),d.$parsers.push(e));if(c.ngMinlength){var q=Q(c.ngMinlength);e=function(a){return pa(d,"minlength",d.$isEmpty(a)||a.length>=q,a)};d.$parsers.push(e);d.$formatters.push(e)}if(c.ngMaxlength){var J=Q(c.ngMaxlength);e=function(a){return pa(d,"maxlength",d.$isEmpty(a)||a.length<=J,a)};d.$parsers.push(e);d.$formatters.push(e)}}function Bb(b,a){return function(c){var d;return qa(c)?c:x(c)&&(b.lastIndex=0,c=b.exec(c))?(c.shift(), +d={yyyy:0,MM:1,dd:1,HH:0,mm:0},q(c,function(b,c){c=c(g.min);h.$setValidity("min",b);return b?a: +s},h.$parsers.push(e),h.$formatters.push(e));g.max&&(e=function(a){var b=h.$isEmpty(a)||c(a)<=c(g.max);h.$setValidity("max",b);return b?a:s},h.$parsers.push(e),h.$formatters.push(e))}}function Xb(b,a){b="ngClass"+b;return["$animate",function(c){function d(a,b){var c=[],d=0;a:for(;dP?function(b){b=b.nodeName?b:b[0];return b.scopeName&&"HTML"!=b.scopeName?Fa(b.scopeName+":"+b.nodeName):b.nodeName}:function(b){return b.nodeName?b.nodeName:b[0].nodeName};var hc=["ng-","data-ng-","ng:","x-ng-"],jd=/[A-Z]/g,md={full:"1.3.0-beta.7",major:1,minor:3,dot:0,codeName:"proper-attribution"},Va= +U.cache={},jb=U.expando="ng-"+(new Date).getTime(),Be=1,sb=N.document.addEventListener?function(b,a,c){b.addEventListener(a,c,!1)}:function(b,a,c){b.attachEvent("on"+a,c)},Ua=N.document.removeEventListener?function(b,a,c){b.removeEventListener(a,c,!1)}:function(b,a,c){b.detachEvent("on"+a,c)};U._data=function(b){return this.cache[b[this.expando]]||{}};var ve=/([\:\-\_]+(.))/g,we=/^moz([A-Z])/,Kb=I("jqLite"),Ae=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,Jb=/<|&#?\w+;/,ye=/<([\w:]+)/,ze=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi, +da={option:[1,'"],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};da.optgroup=da.option;da.tbody=da.tfoot=da.colgroup=da.caption=da.thead;da.th=da.td;var Ka=U.prototype={ready:function(b){function a(){c||(c=!0,b())}var c=!1;"complete"===S.readyState?setTimeout(a):(this.on("DOMContentLoaded",a),U(N).on("load",a))}, +toString:function(){var b=[];q(this,function(a){b.push(""+a)});return"["+b.join(", ")+"]"},eq:function(b){return 0<=b?y(this[b]):y(this[this.length+b])},length:0,push:Ze,sort:[].sort,splice:[].splice},nb={};q("multiple selected checked disabled readOnly required open".split(" "),function(b){nb[u(b)]=b});var wc={};q("input select option textarea button form details".split(" "),function(b){wc[Fa(b)]=!0});q({data:sc,inheritedData:mb,scope:function(b){return y(b).data("$scope")||mb(b.parentNode||b,["$isolateScope", +"$scope"])},isolateScope:function(b){return y(b).data("$isolateScope")||y(b).data("$isolateScopeNoTemplate")},controller:tc,injector:function(b){return mb(b,"$injector")},removeAttr:function(b,a){b.removeAttribute(a)},hasClass:Mb,css:function(b,a,c){a=Ta(a);if(F(c))b.style[a]=c;else{var d;8>=P&&(d=b.currentStyle&&b.currentStyle[a],""===d&&(d="auto"));d=d||b.style[a];8>=P&&(d=""===d?s:d);return d}},attr:function(b,a,c){var d=u(a);if(nb[d])if(F(c))c?(b[a]=!0,b.setAttribute(a,d)):(b[a]=!1,b.removeAttribute(d)); +else return b[a]||(b.attributes.getNamedItem(a)||D).specified?d:s;else if(F(c))b.setAttribute(a,c);else if(b.getAttribute)return b=b.getAttribute(a,2),null===b?s:b},prop:function(b,a,c){if(F(c))b[a]=c;else return b[a]},text:function(){function b(b,d){var e=a[b.nodeType];if(A(d))return e?b[e]:"";b[e]=d}var a=[];9>P?(a[1]="innerText",a[3]="nodeValue"):a[1]=a[3]="textContent";b.$dv="";return b}(),val:function(b,a){if(A(a)){if("SELECT"===La(b)&&b.multiple){var c=[];q(b.options,function(a){a.selected&& +c.push(a.value||a.text)});return 0===c.length?null:c}return b.value}b.value=a},html:function(b,a){if(A(a))return b.innerHTML;for(var c=0,d=b.childNodes;c":function(a,c,d,e){return d(a,c)>e(a,c)},"<=":function(a,c,d,e){return d(a,c)<=e(a,c)},">=":function(a,c,d,e){return d(a,c)>=e(a,c)},"&&":function(a,c,d,e){return d(a,c)&&e(a,c)},"||":function(a,c,d,e){return d(a,c)||e(a,c)},"&":function(a,c,d,e){return d(a,c)&e(a,c)},"|":function(a,c,d,e){return e(a,c)(a,c,d(a,c))},"!":function(a,c,d){return!d(a,c)}},bf={n:"\n",f:"\f",r:"\r",t:"\t",v:"\v","'":"'",'"':'"'}, +Wb=function(a){this.options=a};Wb.prototype={constructor:Wb,lex:function(a){this.text=a;this.index=0;this.ch=s;this.lastCh=":";this.tokens=[];var c;for(a=[];this.index=a},isWhitespace:function(a){return" "===a||"\r"===a||"\t"===a||"\n"===a||"\v"===a||"\u00a0"=== +a},isIdent:function(a){return"a"<=a&&"z">=a||"A"<=a&&"Z">=a||"_"===a||"$"===a},isExpOperator:function(a){return"-"===a||"+"===a||this.isNumber(a)},throwError:function(a,c,d){d=d||this.index;c=F(c)?"s "+c+"-"+this.index+" ["+this.text.substring(c,d)+"]":" "+d;throw Ba("lexerr",a,c,this.text);},readNumber:function(){for(var a="",c=this.index;this.index","<=",">="))a=this.binaryFn(a,c.fn,this.relational());return a},additive:function(){for(var a=this.multiplicative(),c;c=this.expect("+","-");)a=this.binaryFn(a,c.fn,this.multiplicative());return a},multiplicative:function(){for(var a=this.unary(),c;c=this.expect("*","/","%");)a=this.binaryFn(a,c.fn,this.unary());return a},unary:function(){var a;return this.expect("+")?this.primary():(a=this.expect("-"))?this.binaryFn($a.ZERO,a.fn, +this.unary()):(a=this.expect("!"))?this.unaryFn(a.fn,this.unary()):this.primary()},fieldAccess:function(a){var c=this,d=this.expect().text,e=Kc(d,this.options,this.text);return w(function(c,d,h){return e(h||a(c,d))},{assign:function(e,g,h){return ub(a(e,h),d,g,c.text,c.options)}})},objectIndex:function(a){var c=this,d=this.expression();this.consume("]");return w(function(e,f){var g=a(e,f),h=d(e,f),m;if(!g)return s;(g=Za(g[h],c.text))&&(g.then&&c.options.unwrapPromises)&&(m=g,"$$v"in g||(m.$$v=s,m.then(function(a){m.$$v= +a})),g=g.$$v);return g},{assign:function(e,f,g){var h=d(e,g);return Za(a(e,g),c.text)[h]=f}})},functionCall:function(a,c){var d=[];if(")"!==this.peekToken().text){do d.push(this.expression());while(this.expect(","))}this.consume(")");var e=this;return function(f,g){for(var h=[],m=c?c(f,g):f,k=0;ka.getHours()?c.AMPMS[0]:c.AMPMS[1]},Z:function(a){a=-1*a.getTimezoneOffset();return a=(0<=a?"+":"")+(vb(Math[0=P&&(c.href||c.name||c.$set("href",""),a.append(S.createComment("IE fix")));if(!c.href&&!c.xlinkHref&&!c.name)return function(a,c){var f="[object SVGAnimatedString]"===xa.call(c.prop("href"))?"xlink:href":"href";c.on("click",function(a){c.attr(f)||a.preventDefault()})}}}),Hb={};q(nb,function(a,c){if("multiple"!=a){var d=na("ng-"+c);Hb[d]=function(){return{priority:100,link:function(a,f,g){a.$watch(g[d],function(a){g.$set(c, +!!a)})}}}}});q(["src","srcset","href"],function(a){var c=na("ng-"+a);Hb[c]=function(){return{priority:99,link:function(d,e,f){var g=a,h=a;"href"===a&&"[object SVGAnimatedString]"===xa.call(e.prop("href"))&&(h="xlinkHref",f.$attr[h]="xlink:href",g=null);f.$observe(c,function(a){a&&(f.$set(h,a),P&&g&&e.prop(g,f[h]))})}}}});var zb={$addControl:D,$removeControl:D,$setValidity:D,$setDirty:D,$setPristine:D};Xc.$inject=["$element","$attrs","$scope","$animate"];var Yc=function(a){return["$timeout",function(c){return{name:"form", +restrict:a?"EAC":"E",controller:Xc,compile:function(){return{pre:function(a,e,f,g){if(!f.action){var h=function(a){a.preventDefault?a.preventDefault():a.returnValue=!1};sb(e[0],"submit",h);e.on("$destroy",function(){c(function(){Ua(e[0],"submit",h)},0,!1)})}var m=e.parent().controller("form"),k=f.name||f.ngForm;k&&ub(a,k,g,k);if(m)e.on("$destroy",function(){m.$removeControl(g);k&&ub(a,k,s,k);w(g,zb)})}}}}}]},qd=Yc(),Dd=Yc(!0),cf=/^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/, +df=/^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(\.[a-z0-9-]+)*$/i,ef=/^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/,Zc=/^(\d{4})-(\d{2})-(\d{2})$/,$c=/^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)$/,Yb=/^(\d{4})-W(\d\d)$/,ad=/^(\d{4})-(\d\d)$/,bd=/^(\d\d):(\d\d)$/,ff=/(\b|^)default(\b|$)/,cd={text:ab,date:bb("date",Zc,Bb(Zc,["yyyy","MM","dd"]),"yyyy-MM-dd"),"datetime-local":bb("datetimelocal",$c,Bb($c,["yyyy","MM","dd","HH","mm"]),"yyyy-MM-ddTHH:mm"),time:bb("time",bd,Bb(bd,["HH","mm"]),"HH:mm"),week:bb("week",Yb,function(a){if(qa(a))return a; +if(x(a)){Yb.lastIndex=0;var c=Yb.exec(a);if(c){a=+c[1];var d=+c[2],c=Vc(a),d=7*(d-1);return new Date(a,0,c.getDate()+d)}}return NaN},"yyyy-Www"),month:bb("month",ad,Bb(ad,["yyyy","MM"]),"yyyy-MM"),number:function(a,c,d,e,f,g){ab(a,c,d,e,f,g);e.$parsers.push(function(a){var c=e.$isEmpty(a);if(c||ef.test(a))return e.$setValidity("number",!0),""===a?null:c?a:parseFloat(a);e.$setValidity("number",!1);return s});Ye(e,"number",c);e.$formatters.push(function(a){return e.$isEmpty(a)?"":""+a});d.min&&(a=function(a){var c= +parseFloat(d.min);return pa(e,"min",e.$isEmpty(a)||a>=c,a)},e.$parsers.push(a),e.$formatters.push(a));d.max&&(a=function(a){var c=parseFloat(d.max);return pa(e,"max",e.$isEmpty(a)||a<=c,a)},e.$parsers.push(a),e.$formatters.push(a));e.$formatters.push(function(a){return pa(e,"number",e.$isEmpty(a)||Cb(a),a)})},url:function(a,c,d,e,f,g){ab(a,c,d,e,f,g);a=function(a){return pa(e,"url",e.$isEmpty(a)||cf.test(a),a)};e.$formatters.push(a);e.$parsers.push(a)},email:function(a,c,d,e,f,g){ab(a,c,d,e,f,g); +a=function(a){return pa(e,"email",e.$isEmpty(a)||df.test(a),a)};e.$formatters.push(a);e.$parsers.push(a)},radio:function(a,c,d,e){A(d.name)&&c.attr("name",db());var f=function(f){c[0].checked&&a.$apply(function(){e.$setViewValue(d.value,f&&f.type)})};if(e.$options&&e.$options.updateOn)c.on(e.$options.updateOn,f);if(!e.$options||e.$options.updateOnDefault)c.on("click",f);e.$render=function(){c[0].checked=d.value==e.$viewValue};d.$observe("value",e.$render)},checkbox:function(a,c,d,e){var f=d.ngTrueValue, +g=d.ngFalseValue;x(f)||(f=!0);x(g)||(g=!1);d=function(d){a.$apply(function(){e.$setViewValue(c[0].checked,d&&d.type)})};if(e.$options&&e.$options.updateOn)c.on(e.$options.updateOn,d);if(!e.$options||e.$options.updateOnDefault)c.on("click",d);e.$render=function(){c[0].checked=e.$viewValue};e.$isEmpty=function(a){return a!==f};e.$formatters.push(function(a){return a===f});e.$parsers.push(function(a){return a?f:g})},hidden:D,button:D,submit:D,reset:D,file:D},lc=["$browser","$sniffer","$filter",function(a, +c,d){return{restrict:"E",require:["?ngModel"],link:function(e,f,g,h){h[0]&&(cd[u(g.type)]||cd.text)(e,f,g,h[0],c,a,d)}}}],yb="ng-valid",xb="ng-invalid",Ma="ng-pristine",Ab="ng-dirty",gf=["$scope","$exceptionHandler","$attrs","$element","$parse","$animate","$timeout",function(a,c,d,e,f,g,h){function m(a,c){c=c?"-"+ib(c,"-"):"";g.removeClass(e,(a?xb:yb)+c);g.addClass(e,(a?yb:xb)+c)}this.$modelValue=this.$viewValue=Number.NaN;this.$parsers=[];this.$formatters=[];this.$viewChangeListeners=[];this.$pristine= +!0;this.$dirty=!1;this.$valid=!0;this.$invalid=!1;this.$name=d.name;var k=f(d.ngModel),l=k.assign,p=null,n=this;if(!l)throw I("ngModel")("nonassign",d.ngModel,ga(e));this.$render=D;this.$isEmpty=function(a){return A(a)||""===a||null===a||a!==a};var r=e.inheritedData("$formController")||zb,t=0,s=this.$error={};e.addClass(Ma);m(!0);this.$setValidity=function(a,c){s[a]!==!c&&(c?(s[a]&&t--,t||(m(!0),n.$valid=!0,n.$invalid=!1)):(m(!1),n.$invalid=!0,n.$valid=!1,t++),s[a]=!c,m(c,a),r.$setValidity(a,c,n))}; +this.$setPristine=function(){n.$dirty=!1;n.$pristine=!0;g.removeClass(e,Ab);g.addClass(e,Ma)};this.$cancelUpdate=function(){h.cancel(p);n.$render()};this.$$realSetViewValue=function(d){n.$viewValue=d;n.$pristine&&(n.$dirty=!0,n.$pristine=!1,g.removeClass(e,Ma),g.addClass(e,Ab),r.$setDirty());q(n.$parsers,function(a){d=a(d)});n.$modelValue!==d&&(n.$modelValue=d,l(a,d),q(n.$viewChangeListeners,function(a){try{a()}catch(d){c(d)}}))};this.$setViewValue=function(a,c){var d=n.$options&&(R(n.$options.debounce)? +n.$options.debounce[c]||n.$options.debounce["default"]||0:n.$options.debounce)||0;h.cancel(p);d?p=h(function(){n.$$realSetViewValue(a)},d):n.$$realSetViewValue(a)};a.$watch(function(){var c=k(a);if(n.$modelValue!==c){var d=n.$formatters,e=d.length;for(n.$modelValue=c;e--;)c=d[e](c);n.$viewValue!==c&&(n.$viewValue=c,n.$render())}return c})}],Sd=function(){return{require:["ngModel","^?form","^?ngModelOptions"],controller:gf,link:function(a,c,d,e){var f=e[0],g=e[1]||zb;g.$addControl(f);e[2]&&(f.$options= +e[2].$options);a.$on("$destroy",function(){g.$removeControl(f)})}}},Ud=$({require:"ngModel",link:function(a,c,d,e){e.$viewChangeListeners.push(function(){a.$eval(d.ngChange)})}}),mc=function(){return{require:"?ngModel",link:function(a,c,d,e){if(e){d.required=!0;var f=function(a){if(d.required&&e.$isEmpty(a))e.$setValidity("required",!1);else return e.$setValidity("required",!0),a};e.$formatters.push(f);e.$parsers.unshift(f);d.$observe("required",function(){f(e.$viewValue)})}}}},Td=function(){return{require:"ngModel", +link:function(a,c,d,e){var f=(a=/\/(.*)\//.exec(d.ngList))&&RegExp(a[1])||d.ngList||",";e.$parsers.push(function(a){if(!A(a)){var c=[];a&&q(a.split(f),function(a){a&&c.push(Y(a))});return c}});e.$formatters.push(function(a){return M(a)?a.join(", "):s});e.$isEmpty=function(a){return!a||!a.length}}}},hf=/^(true|false|\d+)$/,Vd=function(){return{priority:100,compile:function(a,c){return hf.test(c.ngValue)?function(a,c,f){f.$set("value",a.$eval(f.ngValue))}:function(a,c,f){a.$watch(f.ngValue,function(a){f.$set("value", +a)})}}}},Wd=function(){return{controller:["$scope","$attrs",function(a,c){var d=this;this.$options=a.$eval(c.ngModelOptions);this.$options.updateOn?(this.$options.updateOnDefault=!1,this.$options.updateOn=this.$options.updateOn.replace(ff,function(){d.$options.updateOnDefault=!0;return" "})):this.$options.updateOnDefault=!0}]}},vd=wa(function(a,c,d){c.addClass("ng-binding").data("$binding",d.ngBind);a.$watch(d.ngBind,function(a){c.text(a==s?"":a)})}),xd=["$interpolate",function(a){return function(c, +d,e){c=a(d.attr(e.$attr.ngBindTemplate));d.addClass("ng-binding").data("$binding",c);e.$observe("ngBindTemplate",function(a){d.text(a)})}}],wd=["$sce","$parse",function(a,c){return function(d,e,f){e.addClass("ng-binding").data("$binding",f.ngBindHtml);var g=c(f.ngBindHtml);d.$watch(function(){return(g(d)||"").toString()},function(c){e.html(a.getTrustedHtml(g(d))||"")})}}],yd=Xb("",!0),Ad=Xb("Odd",0),zd=Xb("Even",1),Bd=wa({compile:function(a,c){c.$set("ngCloak",s);a.removeClass("ng-cloak")}}),Cd=[function(){return{scope:!0, +controller:"@",priority:500}}],nc={};q("click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste".split(" "),function(a){var c=na("ng-"+a);nc[c]=["$parse",function(d){return{compile:function(e,f){var g=d(f[c]);return function(c,d,e){d.on(u(a),function(a){c.$apply(function(){g(c,{$event:a})})})}}}}]});var Fd=["$animate",function(a){return{transclude:"element",priority:600,terminal:!0,restrict:"A",$$tlb:!0,link:function(c, +d,e,f,g){var h,m,k;c.$watch(e.ngIf,function(f){Pa(f)?m||(m=c.$new(),g(m,function(c){c[c.length++]=S.createComment(" end ngIf: "+e.ngIf+" ");h={clone:c};a.enter(c,d.parent(),d)})):(k&&(k.remove(),k=null),m&&(m.$destroy(),m=null),h&&(k=Gb(h.clone),a.leave(k,function(){k=null}),h=null))})}}}],Gd=["$http","$templateCache","$anchorScroll","$animate","$sce",function(a,c,d,e,f){return{restrict:"ECA",priority:400,terminal:!0,transclude:"element",controller:Qa.noop,compile:function(g,h){var m=h.ngInclude|| +h.src,k=h.onload||"",l=h.autoscroll;return function(g,h,q,t,s){var E=0,B,z,y,v=function(){z&&(z.remove(),z=null);B&&(B.$destroy(),B=null);y&&(e.leave(y,function(){z=null}),z=y,y=null)};g.$watch(f.parseAsResourceUrl(m),function(f){var m=function(){!F(l)||l&&!g.$eval(l)||d()},q=++E;f?(a.get(f,{cache:c}).success(function(a){if(q===E){var c=g.$new();t.template=a;a=s(c,function(a){v();e.enter(a,null,h,m)});B=c;y=a;B.$emit("$includeContentLoaded");g.$eval(k)}}).error(function(){q===E&&v()}),g.$emit("$includeContentRequested")): +(v(),t.template=null)})}}}}],Xd=["$compile",function(a){return{restrict:"ECA",priority:-400,require:"ngInclude",link:function(c,d,e,f){d.html(f.template);a(d.contents())(c)}}}],Hd=wa({priority:450,compile:function(){return{pre:function(a,c,d){a.$eval(d.ngInit)}}}}),Id=wa({terminal:!0,priority:1E3}),Jd=["$locale","$interpolate",function(a,c){var d=/{}/g;return{restrict:"EA",link:function(e,f,g){var h=g.count,m=g.$attr.when&&f.attr(g.$attr.when),k=g.offset||0,l=e.$eval(m)||{},p={},n=c.startSymbol(), +r=c.endSymbol(),t=/^when(Minus)?(.+)$/;q(g,function(a,c){t.test(c)&&(l[u(c.replace("when","").replace("Minus","-"))]=f.attr(g.$attr[c]))});q(l,function(a,e){p[e]=c(a.replace(d,n+h+"-"+k+r))});e.$watch(function(){var c=parseFloat(e.$eval(h));if(isNaN(c))return"";c in l||(c=a.pluralCat(c-k));return p[c](e)},function(a){f.text(a)})}}}],Kd=["$parse","$animate",function(a,c){var d=I("ngRepeat");return{transclude:"element",priority:1E3,terminal:!0,$$tlb:!0,link:function(e,f,g,h,m){var k=g.ngRepeat,l=k.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?\s*$/), +p,n,r,t,s,E,B={$id:Ia};if(!l)throw d("iexp",k);g=l[1];h=l[2];(l=l[3])?(p=a(l),n=function(a,c,d){E&&(B[E]=a);B[s]=c;B.$index=d;return p(e,B)}):(r=function(a,c){return Ia(c)},t=function(a){return a});l=g.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/);if(!l)throw d("iidexp",g);s=l[3]||l[1];E=l[2];var z={};e.$watchCollection(h,function(a){var g,h,l=f[0],p,B={},F,C,u,x,D,w,A=[];if(cb(a))D=a,p=n||r;else{p=n||t;D=[];for(u in a)a.hasOwnProperty(u)&&"$"!=u.charAt(0)&&D.push(u);D.sort()}F=D.length; +h=A.length=D.length;for(g=0;gC;)w.pop().element.remove()}for(;x.length>A;)x.pop()[0].element.remove()}var k;if(!(k=t.match(d)))throw jf("iexp",t,ga(f));var l=c(k[2]||k[1]),m=k[4]||k[6],n=k[5],p=c(k[3]||""),q=c(k[2]?k[1]:m),y=c(k[7]),v=k[8]?c(k[8]):null,x=[[{element:f,label:""}]];u&&(a(u)(e),u.removeClass("ng-scope"),u.remove()); +f.empty();f.on("change",function(){e.$apply(function(){var a,c=y(e)||[],d={},h,k,l,p,t,w,u;if(r)for(k=[],p=0,w=x.length;p@charset "UTF-8";[ng\\:cloak],[ng-cloak],[data-ng-cloak],[x-ng-cloak],.ng-cloak,.x-ng-cloak,.ng-hide{display:none !important;}ng\\:form{display:block;}'); +//# sourceMappingURL=angular.min.js.map diff --git a/public/js/app.js b/public/js/app.js index e1ff667f..31161209 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,29 +1,41 @@ +var DEFAULT_MAX_SOURCE_TIME = 5000; +var DEFAULT_VOLUME = 80; +var COOKIE_EXPIRES = 604800; + // Begin actual class implementation... var Soundtrack = function() { this.settings = { - notifications: $.cookie('notificationsEnabled') + notifications: $.cookie('notificationsEnabled') + , streaming: $.cookie('streaming') !== 'false' + , avoidVideo: $.cookie('avoidVideo') === 'true' + , maxTimeToPlaySource: $.cookie('maxTimeToPlaySource', Number) || DEFAULT_MAX_SOURCE_TIME }; this.user = { username: $('a[data-for=user-model]').data('username') }; this.room = { - name: '' - , track: { - title: '' - , artist: '' - } + name: '', + track: { + title: '', + artist: '' + } }; this.controls = { volume: {} - } + }; + // stub out the player, since sometimes we don't load it. this.player = { - ready: function(callback) { - callback(); - }, - src: function(src) { - return src; - } - } + ready: function( callback ) { return callback(); }, + src: function( src ) { return src; }, + pause: function() { return this; }, + play: function() { return this; }, + on: function( event , cb ) { return this; }, + one: function( event , cb ) { return this; }, + volume: function( level ) { return this; }, + currentTime: function( t ) { return 0; }, + duration: function( t ) { return 0; }, + error: function( e ) { return this; }, + }; }; Soundtrack.prototype.checkNotificationPermissions = function(callback) { if (window.webkitNotifications.checkPermission() != 0) { @@ -31,7 +43,7 @@ Soundtrack.prototype.checkNotificationPermissions = function(callback) { console.log(e); }); } -} +}; Soundtrack.prototype.notify = function(img, title, content, callback) { if (!this.settings.notifications) { return false; } @@ -41,86 +53,152 @@ Soundtrack.prototype.notify = function(img, title, content, callback) { setTimeout(function() { e.currentTarget.cancel(); }, 15000); - } + }; notification.onclick = function() { window.focus(); this.cancel(); - } + }; notification.show(); }; Soundtrack.prototype.editTrackID = function( trackID ) { $editor = $('form[data-for=edit-track]'); - $.getJSON('/tracks/'+encodeURIComponent(trackID), function(track) { + $.getJSON('/tracks/' + encodeURIComponent(trackID), function(track) { if (!track || !track._id) { return alert('No such track.'); } $editor.data('track-id', track._id ); $editor.data('artist-slug', track._artist.slug ); + $editor.data('artist-id', track._artist._id ); $editor.data('track-slug', track.slug ); + $editor.find('input[name=trackArtistID]').val( track._artist._id ); $editor.find('input[name=artist]').val( track._artist.name ); $editor.find('input[name=title]').val( track.title ); + $editor.find('*[data-context=track]').data('track-id', track._id); + + $editor.find('*[data-context=track][data-action=track-flag-nsfw]').prop('checked', track.flags.nsfw ); + $editor.find('*[data-context=track][data-action=track-flag-live]').prop('checked', track.flags.live ); + + var titles = track.titles; + if (titles.indexOf( track.title ) == -1) { + titles.push( track.title ); + } + + track.sources.youtube.forEach(function(video) { + if (video.data && titles.indexOf( video.data.title ) == -1) { + titles.push( video.data.title ); + } + }); + + track.sources.soundcloud.forEach(function(track) { + if (track.data && titles.indexOf( track.data.title ) == -1) { + titles.push( track.data.title ); + } + }); + + $editor.find('pre[data-for=titles]').html( titles.join('
') ); + + /* $editor.find('input.typeahead').typeahead({ + name: 'artists' + , remote: '/artists?q=%QUERY' + }); */ + $editor.modal(); }); +}; + +var YouTube = function(key) { + this.key = key; + this.base = 'https://www.googleapis.com/youtube/v3/'; } -function volumeChangeHandler(e) { +YouTube.prototype.get = function(url, params, cb) { var self = this; - console.log('Handling volume change... ' + $(self).val() + ' => ' + $(self).val() / 100 ); - console.log( typeof($(self).val()) ) + params.key = self.key; - soundtrack.player.volume( $(self).val() / 100 ); - $.cookie('lastVolume', $(self).val() , { expires: COOKIE_EXPIRES }); + var qs = Object.keys( params ).map(function(k) { + return k + '=' + params[k]; + }); + + $.getJSON( self.base + url + '?' + qs.join('&') , cb ); }; -function mutePlayer() { +function HTMLescape(html) { + return document.createElement('div') + .appendChild(document.createTextNode(html)) + .parentNode + .innerHTML; +}; + +function volumeChangeHandler(e) { + var vol = Number( e.value ); + + soundtrack.player.volume( vol / 100 ); + $.cookie('lastVolume' , vol, { expires: COOKIE_EXPIRES }); +}; + +function mutePlayer(saveState) { // TODO: why doesn't this work with just 0? Why does it only work with 0.001? soundtrack.player.volume( 0.00001 ); - $.cookie('lastVolume', '0'); + $.cookie('lastVolume', 0); $('.slider[data-for=volume]').slider('setValue', 0).val(0); -} +}; + function unmutePlayer() { - if (parseInt($.cookie('lastVolume'))) { - soundtrack.player.volume( $.cookie('lastVolume') / 100 ); - $('.slider[data-for=volume]').slider('setValue', $.cookie('lastVolume')).val($.cookie('lastVolume')); + var lastVol = $.cookie('lastVolume', Number); + + if (lastVol) { + soundtrack.player.volume(lastVol / 100); + $('.slider[data-for=volume]').slider('setValue', lastVol).val(lastVol); } else { soundtrack.player.volume( 0.8 ); - $('.slider[data-for=volume]').slider('setValue', 80).val(80); - $.cookie('lastVolume', '80', { expires: COOKIE_EXPIRES }); + $('.slider[data-for=volume]').slider('setValue', DEFAULT_VOLUME).val( DEFAULT_VOLUME ); + $.cookie('lastVolume', DEFAULT_VOLUME, { expires: COOKIE_EXPIRES }); } -} +}; + function ensureVolumeCorrect() { - if (registered) { - console.log('last volume... ' + $.cookie('lastVolume') + ' => ' + ($.cookie('lastVolume') / 100) ); - // TODO: why doesn't this work with just 0? Why does it only work with 0.001? - soundtrack.player.volume( ($.cookie('lastVolume') + 0.001) / 100 ); - $('.slider[data-for=volume]').slider('setValue', $.cookie('lastVolume')).val( $.cookie('lastVolume') ); - } else { - mutePlayer(); + var lastVol = $.cookie('lastVolume', Number); + if (soundtrack.debug) console.log('setting volume to ', lastVol / 100); + + soundtrack.player.volume( lastVol / 100 ); + + if ($('.slider[data-for=volume]')[0]) { + $('.slider[data-for=volume]').slider('setValue', lastVol ).val( lastVol ); } -} +}; + function updateUserlist() { $.get('/listeners.json', function(data) { $('#userlist').html(''); - $('.user-count').html(''+data.length+' online'); + $('.user-count').html('' + data.length + ' online'); data.forEach(function(user) { + user.username = HTMLescape(user.username); + // TODO: use template (Blade?) - $('
  • '+user.username+'
  • ').appendTo('#userlist'); + if (user.role != 'listener') { + $('
  • ' + user.username + ' ' + user.role + '
  • ').appendTo('#userlist'); + } else { + $('
  • ' + user.username + '
  • ').appendTo('#userlist'); + } + }); }); -} +}; + var videoToggled = false; //TODO: Should this use Cookie? function toggleVideo() { - if(videoToggled) + if (videoToggled) toggleVideoOn(); else toggleVideoOff(); } function toggleVideoOn() { - $('#screen-one *').css('height', '300px'); $('#messages').css('height', '256px'); + $('#screen-one *').css('height', '300px'); + $('#messages').css('height', '256px'); $(this).children('i').replaceWith($('')); $("#messages").scrollTop($("#messages")[0].scrollHeight); @@ -128,36 +206,92 @@ function toggleVideoOn() { } function toggleVideoOff() { - $('#screen-one *').css('height', '0px'); $('#messages').css('height', '541px'); + $('#screen-one *').css('height', '0px'); + $('#messages').css('height', '541px'); $(this).children('i').replaceWith($('')); - + videoToggled = true; } + +angular.module('timeFilters', []). + filter('toHHMMSS', function() { + return function(input) { + var sec_num = parseInt(input, 10); // don't forget the second parm + var hours = Math.floor(sec_num / 3600); + var minutes = Math.floor((sec_num - (hours * 3600)) / 60); + var seconds = sec_num - (hours * 3600) - (minutes * 60); + + if (hours < 10) { + hours = "0" + hours; + } + if (minutes < 10) { + minutes = "0" + minutes; + } + if (seconds < 10) { + seconds = "0" + seconds; + } + + if (hours != '00') { + var time = hours + ':' + minutes + ':' + seconds; + } else { + var time = minutes + ':' + seconds; + } + return time; + } + }); + +angular.module('soundtrack-io', ['timeFilters']); + function AppController($scope, $http) { - window.updatePlaylist = function(){ - $http.get('/playlist.json').success(function(data){ - $scope.tracks = data; - soundtrack.room.track = data[0]; + window.updatePlaylist = function() { + $http.get('/playlist.json').success(function(data) { + if (!data) var data = []; + + $scope.tracks = data.map(function(t) { + if (t.images && t.images.thumbnail && t.images.thumbnail.url) { + // strip hardcoded http urls + t.images.thumbnail.url = t.images.thumbnail.url.replace('http:', ''); + } + return t; + }); + + if (data.length) { + $scope.playlistLength = data.map(function(x) { + return x.duration; + }).reduce(function(prev, now) { + return prev + now; + }); + } + + if (typeof(soundtrack) != 'undefined') { + soundtrack.room.track = data[0]; + } }); } updatePlaylist(); } -String.prototype.toHHMMSS = function () { +String.prototype.toHHMMSS = function() { var sec_num = parseInt(this, 10); // don't forget the second parm - var hours = Math.floor(sec_num / 3600); + var hours = Math.floor(sec_num / 3600); var minutes = Math.floor((sec_num - (hours * 3600)) / 60); var seconds = sec_num - (hours * 3600) - (minutes * 60); - if (hours < 10) {hours = "0"+hours;} - if (minutes < 10) {minutes = "0"+minutes;} - if (seconds < 10) {seconds = "0"+seconds;} + if (hours < 10) { + hours = "0" + hours; + } + if (minutes < 10) { + minutes = "0" + minutes; + } + if (seconds < 10) { + seconds = "0" + seconds; + } if (hours != '00') { - var time = hours+':'+minutes+':'+seconds; + var time = hours + ':' + minutes + ':' + seconds; } else { - var time = minutes+':'+seconds; + var time = minutes + ':' + seconds; } return time; } @@ -168,134 +302,256 @@ promise = deferred.promise(); promise.done(function() { - soundtrack.controls.volume = $('.slider[data-for=volume]').slider(); - soundtrack.controls.volume.on('slide', volumeChangeHandler); - soundtrack.controls.volume.on('click', volumeChangeHandler); - - if (!registered) { introJs().start(); } + if ($('.slider[data-for=volume]')[0]) { + soundtrack.controls.volume = $('.slider[data-for=volume]').slider(); + soundtrack.controls.volume.on('slide slideStart', volumeChangeHandler); + if (!$.cookie('lastVolume', Number)) { + $.cookie('lastVolume', DEFAULT_VOLUME); + } + } ensureVolumeCorrect(); setInterval(function() { // TODO: use angularJS for this + var duration = soundtrack.player.duration() || 0; + var time = soundtrack.player.currentTime().toString().toHHMMSS(); - var total = soundtrack.player.duration().toString().toHHMMSS(); - $('#current-track #time').html( time + '/' + total); + var total = duration.toString().toHHMMSS(); + $('#current-track #time').html( time + '/' + total ); var progress = ((soundtrack.player.currentTime() / soundtrack.player.duration()) * 100); $('#track-progress .bar').css('width', progress + '%'); $('#track-progress').attr('title', progress + '%'); + + $('.timestamp').each(function(i , el) { + var $el = $(el); + $el.html( moment( $el.attr('datetime') ).fromNow() ); + }); + }, 1000); }); -COOKIE_EXPIRES = 604800; - -$(window).load(function(){ +$(window).load(function() { var sockjs = null; - var retryTimes = [1000, 5000, 10000, 30000, 60000, 120000, 300000, 600000]; //in ms + var retryTimes = [100, 1000, 2500, 5000, 10000, 30000, 60000, 120000, 300000, 600000, 86400000]; //in ms var retryIdx = 0; // must be after DOM loads so we have access to the user-model soundtrack = new Soundtrack(); + youtube = new YouTube('AIzaSyBnCN68b8W5oGgBKKkM2cSQhSygnLPApEs'); if ($('#main-player').length) { soundtrack.player = videojs('#main-player', { - techOrder: ['html5', 'flash', 'youtube'] - }); - } else { - soundtrack.player = videojs('#secondary-player', { - techOrder: ['html5', 'flash', 'youtube'] + techOrder: ['html5', 'youtube', 'flash'] }); + soundtrack.player.controls( true ); + } //else { + // soundtrack.player = videojs('#secondary-player', { + // techOrder: ['html5', 'youtube'] + // }); + // mutePlayer( false ); + //} + + if (!$.cookie('maxTimeToPlaySource')) { + $.cookie('maxTimeToPlaySource', soundtrack.settings.maxTimeToPlaySource ); } + $('*[data-for=max-source-load-time]').val( $.cookie('maxTimeToPlaySource', Number) ); + soundtrack.player.ready(function() { - console.log('player loaded. :)'); + if (soundtrack.debug) console.log('player loaded. :)'); - startSockJs = function(){ - sockjs = new SockJS('/stream'); + soundtrack.startSockJs = function() { + soundtrack.sockjs = new SockJS('/stream'); - sockjs.onopen = function(){ + soundtrack.sockjs.onopen = function() { //sockjs connection has been opened! - $.post('/socket-auth', {}, function(data){ - sockjs.send(JSON.stringify({type: 'auth', authData: data.authData})); + $.post('/socket-auth', {}, function(data) { + soundtrack.sockjs.send(JSON.stringify({ + type: 'auth', + authData: data.authData + })); }); } - sockjs.onmessage = function(e) { + soundtrack.sockjs.onmessage = function(e) { retryIdx = 0; //reset our retry timeouts var received = new Date(); var msg = JSON.parse(e.data); - console.log(msg); + if (soundtrack.debug) console.log(msg); switch (msg.type) { - default: console.log('unhandled message: ' + msg); break; + default: console.warn('unhandled message: ' + msg); + break; + case 'edit': + updatePlaylist(); + break; case 'track': updatePlaylist(); + // TODO: replace with proper 2-way databinding if (msg.data._artist) { - $('#track-title').attr('href', '/'+msg.data._artist.slug+'/'+msg.data.slug+'/'+msg.data._id); + $('#track-title').attr('href', '/' + msg.data._artist.slug + '/' + msg.data.slug + '/' + msg.data._id); - $('#track-artist').attr('href', '/'+msg.data._artist.slug); - $('#track-artist').html( msg.data._artist.name ); + $('#track-artist').attr('href', '/' + msg.data._artist.slug); + $('#track-artist').html(msg.data._artist.name); } else { - $('#track-artist').html( 'unknown' ); + $('#track-artist').html('unknown'); } - + $('#track-title').html( msg.data.title ); - $('input[name=current-track-id]').val( msg.data._id ); + $('input[name=current-track-id]').val(msg.data._id); + $('*[data-for=current-track-id]').data('track-id', msg.data._id); + if (msg.data.curator) { - $('#track-curator').html(''+msg.data.curator.username+''); - + $('#track-curator').html('' + msg.data.curator.username + ''); + $('#userlist li').removeClass('current-curator'); - $('#userlist li[data-user-id='+msg.data.curator._id+']').addClass('current-curator'); + $('#userlist li[data-user-id=' + msg.data.curator._id + ']').addClass('current-curator'); } else { $('#track-curator').html('the machine'); } - var sources = []; + if (soundtrack.settings.streaming) { + var sources = []; + + // use the new sources array if available + if (msg.sources) { + msg.data.sources = msg.sources + }; + + if (soundtrack.settings.avoidVideo) { + msg.data.sources.soundcloud.forEach(function(item) { + sources.push({ + type: 'audio/mp3', + src: 'https://api.soundcloud.com/tracks/' + item.id + '/stream?client_id=7fbc3f4099d3390415d4c95f16f639ae', + poster: (item.data) ? item.data.artwork_url : undefined + }); + }); + msg.data.sources.youtube.forEach(function(item) { + sources.push({ + type: 'video/youtube', + src: 'https://www.youtube.com/watch?v=' + item.id + }); + }); + } else { + msg.data.sources.youtube.forEach(function(item) { + sources.push({ + type: 'video/youtube', + src: 'https://www.youtube.com/watch?v=' + item.id + }); + }); + msg.data.sources.soundcloud.forEach(function(item) { + sources.push({ + type: 'audio/mp3', + src: 'https://api.soundcloud.com/tracks/' + item.id + '/stream?client_id=7fbc3f4099d3390415d4c95f16f639ae', + poster: (item.data) ? item.data.artwork_url : undefined + }); + }); + } - msg.data.sources.youtube.forEach(function( item ) { - sources.push( { type:'video/youtube', src: 'https://www.youtube.com/watch?v=' + item.id } ); - }); + if (msg.data.sources.bandcamp) { + msg.data.sources.bandcamp.forEach(function(item) { + if (!item || !item.data) return console.log('bandcamp shit breaking. totally chrisinajar\'s fault. deets: ' , item ); + sources.push({ + type: 'audio/mp3', + src: item.data.url, + poster: (item.data) ? item.data.artwork_url : undefined + }); + }); + } - msg.data.sources.soundcloud.forEach(function( item ) { - sources.push( { type:'audio/mp3', src: 'https://api.soundcloud.com/tracks/' + item.id + '/stream?client_id=7fbc3f4099d3390415d4c95f16f639ae' } ); - }); + if (!sources.length) { + return $.ajax({ + url: '/tracks/' + msg.data._id, + method: 'PUT', + data: { + flags: { + lackingSources: true + } + } + }, function(data) { + if (soundtrack.debug) console.log('submitted the track as needing more sources: ', data); + }); + } - soundtrack.player.src( sources ); + function rollTrack() { + if (soundtrack.debug) console.log('rollTrack()', sources ); + if (soundtrack.debug) console.log('current source:', soundtrack.player.src() ); + if (!sources[0]) return; + + // this is an egregious and terrifying hack + // TODO: not use this hack + soundtrack.player.dispose(); + $('').appendTo('#screen-one'); + soundtrack.player = videojs('#main-player', { + techOrder: ['html5', 'youtube', 'flash'] + }); + soundtrack.player.controls( true ); - // YouTube doesn't behave well without these two lines... - soundtrack.player.pause(); - soundtrack.player.currentTime( msg.seekTo ); - soundtrack.player.play(); + soundtrack.player.error( null ); + soundtrack.player.poster( sources[0].poster ); - // ...and SoundCloud doesn't behave well without these. :/ - var bufferEvaluator = function() { - console.log('evaluating buffer...'); + soundtrack.player.pause(); + soundtrack.player.src( sources[0] ); + soundtrack.player.load(); - var now = new Date(); - var estimatedSeekTo = (msg.seekTo * 1000) + (now - received); - var estimatedProgress = estimatedSeekTo / (msg.data.duration * 1000); + soundtrack.player.one('playing', function() { + if (soundtrack.debug) console.log('playing event'); + clearInterval( ensureTrackPlaying ); + jumpIfNecessary(); + }); - if (soundtrack.player.bufferedPercent() > estimatedProgress) { - soundtrack.player.off('progress', bufferEvaluator); - soundtrack.player.off('loadeddata', bufferEvaluator); - console.log('jumping to ' + msg.seekTo + '...'); - soundtrack.player.pause(); // this breaks soundcloud, wat? - soundtrack.player.currentTime( msg.seekTo ); - soundtrack.player.play(); - } else { - console.log('estimated progress: ' + estimatedProgress ); - console.log( soundtrack.player.bufferedPercent() ) + // TODO: find a better event to listen for! this is terrible. + soundtrack.player.one('durationchange', function() { + if (soundtrack.debug) console.log('durationchange event'); + if (soundtrack.debug) console.log('setting current time 0 and src...'); + if (soundtrack.debug) console.log('source is now', soundtrack.player.src() ); + soundtrack.player.currentTime( 0 ); + soundtrack.player.play(); + }); + } + + function verifyTrackPlaying() { + if (!sources.length) { + if (soundtrack.debug) console.log('sources length is zero. sad day. failing out.'); + clearInterval( ensureTrackPlaying ); + } else if (soundtrack.player.currentTime() > 0) { + if (soundtrack.debug) console.log('track is playing (yay!). clearing interval.'); + clearInterval( ensureTrackPlaying ) + } else { + if (soundtrack.debug) console.log('track is NOT playing after %dms... advancing to next source', maxTimeToPlayTrack); + if (soundtrack.debug) console.log('failed to load: ', sources[0] ); + + sources.shift(); + rollTrack(); + } } - }; - //soundtrack.player.off('progress', bufferEvaluator); - soundtrack.player.on('progress', bufferEvaluator); - soundtrack.player.on('loadeddata', bufferEvaluator); - ensureVolumeCorrect(); + function jumpIfNecessary() { + if (soundtrack.debug) console.log( 'now calling jumpIfNecessary()' ); + + var now = new Date(); + var estimatedSeekTo = (msg.seekTo * 1000) + (now - received); + var estimatedProgress = estimatedSeekTo / (msg.data.duration * 1000); + + if (estimatedSeekTo / 1000 > 5) { + soundtrack.player.currentTime( estimatedSeekTo / 1000 ); + } + + ensureVolumeCorrect(); + } + + rollTrack(); + + var maxTimeToPlayTrack = soundtrack.settings.maxTimeToPlaySource; + var ensureTrackPlaying = setInterval( verifyTrackPlaying , maxTimeToPlayTrack ); + + + } if ($('#playlist-list li:first').data('track-id') == msg.data._id) { $('#playlist-list li:first').slideUp('slow', function() { @@ -317,32 +573,39 @@ $(window).load(function(){ updateUserlist(); break; case 'part': - $('#userlist li[data-user-id='+msg.data._id+']').remove(); + $('#userlist li[data-user-id=' + msg.data._id + ']').slideUp(); + updateUserlist(); break; case 'chat': $( msg.data.formatted ).appendTo('#messages'); - $("#messages").scrollTop($("#messages")[0].scrollHeight); - $('.message .message-content').filter(':contains("'+ $('a[data-for=user-model]').data('username') + '")').parent().addClass('highlight'); - - if ( msg.data.message.toLowerCase().indexOf( '@'+ soundtrack.user.username.toLowerCase() ) >= 0 ) { - soundtrack.notify( 'https://soundtrack.io/favicon.ico', 'New Mention in Chat', msg.data.message ); + + setTimeout(function() { + $("#messages").scrollTop($("#messages")[0].scrollHeight); + }, 100); + + $('.message .message-content').filter(':contains("' + $('a[data-for=user-model]').data('username') + '")').parent().addClass('highlight'); + + if (msg.data.message.toLowerCase().indexOf('@' + soundtrack.user.username.toLowerCase()) >= 0) { + soundtrack.notify('https://soundtrack.io/favicon.ico', 'New Mention in Chat', msg.data.message); } break; case 'ping': - sockjs.send(JSON.stringify({type: 'pong'})); - console.log("Ping Pong\'d"); + soundtrack.sockjs.send(JSON.stringify({ + type: 'pong' + })); + if (soundtrack.debug) console.log("Ping Pong\'d"); break; case 'announcement': - $( msg.data.formatted ).appendTo('#messages'); + $( unescape( msg.data.formatted ) ).appendTo('#messages'); $("#messages").scrollTop($("#messages")[0].scrollHeight); break; } }; - sockjs.onclose = function() { - console.log('Lost our connection, lets retry!'); + soundtrack.sockjs.onclose = function() { + if (soundtrack.debug) console.log('Lost our connection, lets retry!'); if (retryIdx < retryTimes.length) { - console.log("Retrying connection in " + retryTimes[retryIdx] + 'ms'); + if (soundtrack.debug) console.log("Retrying connection in " + retryTimes[retryIdx] + 'ms'); setTimeout(restartSockJs, retryTimes[retryIdx++]); } else { alert('Bummer. We lost connection.'); @@ -350,9 +613,9 @@ $(window).load(function(){ }; } - restartSockJs = function(){ - sockjs = null; - startSockJs(); + restartSockJs = function() { + soundtrack.sockjs = null; + soundtrack.startSockJs(); } restartSockJs(); @@ -361,20 +624,18 @@ $(window).load(function(){ }); - $('.message .message-content').filter('.message-content:contains("'+ $('a[data-for=user-model]').data('username') + '")').parent().addClass('highlight'); + $('.message .message-content').filter('.message-content:contains("' + $('a[data-for=user-model]').data('username') + '")').parent().addClass('highlight'); updatePlaylist(); updateUserlist(); // breaks javascript if page doesn't have #messages //$("#messages").scrollTop($("#messages")[0].scrollHeight); - + $('.tablesorter').tablesorter(); $('*[data-action=toggle-volume]').click(function(e) { e.preventDefault(); var self = this; - var currentVolume = parseInt( $('.slider[data-for=volume]').slider('getValue').val() ); - - console.log('currentVolume is a ' + typeof(currentVolume) + ' and is ' + currentVolume); + var currentVolume = Number( $('.slider[data-for=volume]').slider('getValue') ); if (currentVolume) { mutePlayer(); @@ -387,7 +648,7 @@ $(window).load(function(){ return false; }); - OutgoingChatHandler = (function(){ + OutgoingChatHandler = (function() { var listeners = {}; var triggerWord = /^\/(\w+)/i; var CHAT_DEFAULT = '$DEFAULT$'; @@ -401,21 +662,21 @@ $(window).load(function(){ } function notify(key, msg) { - if (listeners[key]) { - listeners[key].forEach(function(l){ - l(msg); - }); + if (listeners[key]) { + listeners[key].forEach(function(l) { + l(msg); + }); - return true; - } + return true; + } - return false; + return false; } function chatSubmit(msg) { var matches = msg.match(triggerWord); if (matches) { - if (notify(matches[1])) { + if (notify(matches[1], msg )) { return; } } @@ -424,8 +685,11 @@ $(window).load(function(){ } //add our default chat handler that actually sends the messages - addListener(CHAT_DEFAULT, function (msg) { - $.post('/chat', { message: msg }, function(data){}); + addListener(CHAT_DEFAULT, function(msg) { + $.post('/chat', { + message: msg + }, function(data) {}); + $("#messages").scrollTop($("#messages")[0].scrollHeight); }); return { @@ -436,16 +700,43 @@ $(window).load(function(){ })(); // /reset -> reset the video player - OutgoingChatHandler.addListener('reset', function(msg){ + OutgoingChatHandler.addListener('reset', function(msg) { var cur = soundtrack.player.currentTime(); - soundtrack.player.stop(); + soundtrack.player.pause(); soundtrack.player.currentTime(cur); soundtrack.player.play(); }); + OutgoingChatHandler.addListener('stream', function(msg) { + if (!msg) return true; + switch ((msg.split(' ')[1] || '').toLowerCase()) { + case 'on': + $('
    Streaming turned on.
    ').appendTo('#messages'); + $("#messages").scrollTop($("#messages")[0].scrollHeight); + + $.cookie('streaming', true); + soundtrack.settings.streaming = true; + soundtrack.sockjs.close(); + soundtrack.startSockJs(); + break; + case 'off': + $.cookie('streaming', false); + soundtrack.settings.streaming = false; + soundtrack.player.pause(); + $('
    Streaming turned off.
    ').appendTo('#messages'); + $("#messages").scrollTop($("#messages")[0].scrollHeight); + break; + default: + var status = (soundtrack.settings.streaming) ? 'on' : 'off'; + $('
    Stream is ' + status + '.
    ').appendTo('#messages'); + $("#messages").scrollTop($("#messages")[0].scrollHeight); + break; + } + }); + // /video -> toggle video OutgoingChatHandler.addListener('video', function(msg) { - switch((msg.split(' ')[1] || '').toLowerCase()) { + switch ((msg.split(' ')[1] || '').toLowerCase()) { case 'on': toggleVideoOn(); break; @@ -463,27 +754,27 @@ $(window).load(function(){ if (partying) { $('.partying').removeClass('partying'); } else { - var d = function () { + var d = function() { var b = Math.floor(Math.random() * 255), a = Math.floor(Math.random() * 255), c = Math.floor(Math.random() * 255); return "rgb(" + b + "," + a + "," + c + ")" - }, h = function () { - jQuery("p, li, h1, h2, h3, div, span, a, input").each(function (b, a) { + }, h = function() { + jQuery("p, li, h1, h2, h3, div, span, a, input").each(function(b, a) { if (jQuery(a).children().size() == 0 && !jQuery(a).hasClass("partying")) { var c = jQuery(a).text().split(" "), - c = jQuery.map(jQuery.makeArray(c), function (a) { + c = jQuery.map(jQuery.makeArray(c), function(a) { return "" + a + "" }); jQuery(a).html(c.join(" ")) } }); - jQuery(".partying").each(function (b, a) { + jQuery(".partying").each(function(b, a) { jQuery(a).css("color", d()) }); partying = true; - }, g = function () { - setTimeout(function () { + }, g = function() { + setTimeout(function() { h(); g(); jQuery("body").css("background-color", d()) @@ -494,7 +785,13 @@ $(window).load(function(){ }); OutgoingChatHandler.addListener('katamari', function(msg) { - var i,s,ss=['http://kathack.com/js/kh.js','http://ajax.googleapis.com/ajax/libs/jquery/1.5.1/jquery.min.js'];for(i=0;i!=ss.length;i++){s=document.createElement('script');s.src=ss[i];document.body.appendChild(s);}void(0); + var i, s, ss = ['http://kathack.com/js/kh.js', 'http://ajax.googleapis.com/ajax/libs/jquery/1.5.1/jquery.min.js']; + for (i = 0; i != ss.length; i++) { + s = document.createElement('script'); + s.src = ss[i]; + document.body.appendChild(s); + } + void(0); }); OutgoingChatHandler.addListener('doge', function(msg) { @@ -504,7 +801,7 @@ $(window).load(function(){ }); OutgoingChatHandler.addListener('dance', function(msg) { - (function () { + (function() { function c() { var e = document.createElement("link"); e.setAttribute("type", "text/css"); @@ -525,7 +822,7 @@ $(window).load(function(){ var e = document.createElement("div"); e.setAttribute("class", a); document.body.appendChild(e); - setTimeout(function () { + setTimeout(function() { document.body.removeChild(e) }, 100) } @@ -579,17 +876,17 @@ $(window).load(function(){ e.setAttribute("class", l); e.src = i; e.loop = false; - setTimeout(function () { + setTimeout(function() { x(k) }, 500); - setTimeout(function () { + setTimeout(function() { N(); p(); for (var e = 0; e < O.length; e++) { T(O[e]) } }, 500); - setTimeout(function(){ + setTimeout(function() { N(); h() }, 30000); @@ -638,7 +935,7 @@ $(window).load(function(){ } } if (A === null) { - console.warn("Could not find a node of the right size. Please try a different page."); + if (soundtrack.debug) console.warn("Could not find a node of the right size. Please try a different page."); return } c(); @@ -674,11 +971,40 @@ $(window).load(function(){ return false; }); + $(document).on('click', '*[data-action=tip-current-curator]', function(e) { + e.preventDefault(); + + $.post('/tips', function(data) { + if (data.errors) alert( data.errors ); + }); + + return false; + }); + + $(document).on('click', '*[data-action=remove-queued-track]', function(e) { + e.preventDefault(); + var self = this; + + $('#playlist-list li[data-track-id=' + $(self).data('track-id') + ']').slideUp(); + + $.ajax({ + url: '/playlist/' + $(self).data('track-id'), + method: 'DELETE', + data: { + index: $(self).data('track-index') + } + }, function(data) { + if (soundtrack.debug) console.log(data); + }); + + return false; + }); + $(document).on('click', '*[data-for=track-search-reset]', function(e) { e.preventDefault(); $('*[data-for=track-search-results]').html(''); $('*[data-for=track-search-query]').val(''); - $('#search-modal *[data-for=track-search-query]').focus(); + $('#search-modal').find('*[data-for=track-search-query]').focus(); $('*[data-for=track-search-select-source]').removeClass('btn-primary'); return false; }); @@ -688,14 +1014,13 @@ $(window).load(function(){ var self = this; $('*[data-for=track-search-select-source]').removeClass('btn-primary'); - $( self ).addClass('btn-primary'); + $(self).addClass('btn-primary'); - if ($(self).data('data') == 'all') { - $('*[data-for=track-search-results] li').slideDown(); + if ($(self).data('data') === 'all') { + $('*[data-for=track-search-results] tr').slideDown(); } else { - - $('*[data-for=track-search-results] li:not(*[data-source='+ $(self).data('data') +'])').slideUp(); - $('*[data-for=track-search-results] li[data-source='+ $(self).data('data') +']').slideDown(); + $('*[data-for=track-search-results] tr:not(*[data-source=' + $(self).data('data') + '])').slideUp(); + $('*[data-for=track-search-results] tr[data-source=' + $(self).data('data') + ']').slideDown(); } return false; @@ -705,55 +1030,188 @@ $(window).load(function(){ e.preventDefault(); var self = this; - $( self ).slideUp(function() { - $( this ).remove(); + $(self).slideUp(function() { + $(this).remove(); }); $.post('/playlist', { - source: $(self).data('source') - , id: $(self).data('id') + source: $(self).data('source'), + id: $(self).data('id') }, function(response) { - console.log(response); + if (soundtrack.debug) console.log(response); + }); + + return false; + }, 200, true); + + var selectSet = _.debounce(function(e) { + e.preventDefault(); + var $self = $(this); + + $self.slideUp(function() { + $(this).remove(); + }); + + $.getJSON('/' + $self.data('set-slug') , function(set) { + set._tracks.forEach(function(track) { + $.post('/playlist', { + source: 'soundtrack', + id: track._id + }, function(response) { + if (soundtrack.debug) console.log(response); + }); + }); }); return false; }, 200, true); + $(document).on('click', '*[data-action=queue-track]', selectTrack); + + $(document).on('click', '*[data-action=queue-set]', selectSet ); + + $(document).on('click', '*[data-action=launch-playlist-editor]', function(e) { + e.preventDefault(); + $('#playlist-modal').modal('show'); + return false; + }); + + $(document).on('click', '*[data-action=launch-playlist-creator]', function(e) { + e.preventDefault(); + var $self = $(this); + + $('#create-playlist-modal').modal('show'); + $('#create-playlist-form').children('input[name=trackID]').val( $self.data('track') ); + $('#create-playlist-form').children('input[name=current-track-id]').val( $self.data('track') ); + + // TODO: replace with local data cache / Maki datastore + $.getJSON('/tracks/'+$self.data('track'), function(track) { + var $track = $('*[data-for=track-name]'); + $track.children('.track-artist').html( track._artist.name ); + $track.children('.track-title').html( track.title ); + + $track.children('*[data-for=track-preview]').html( soundtrack._templates.preview( track ) ); + + }); + + return false; + }); + + soundtrack._templates = { + preview: function( track ) { + if (track.sources && track.sources.youtube && track.sources.youtube.length) { + var video = track.sources.youtube[0]; + return '