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/models/Person.js b/models/Person.js index 9b59edcb..75c8480b 100644 --- a/models/Person.js +++ b/models/Person.js @@ -35,6 +35,12 @@ var PersonSchema = new Schema({ token: String, updated: Date, playlists: [] + }, + changetip: { + id: String, + username: String, + token: String, + updated: Date } } , preferences: { diff --git a/models/Room.js b/models/Room.js index 9c50c940..c182fea7 100644 --- a/models/Room.js +++ b/models/Room.js @@ -35,7 +35,7 @@ RoomSchema.methods.bind = function( soundtrack ) { }; RoomSchema.methods.broadcast = function( msg , GLOBAL ) { if (GLOBAL) return this.soundtrack.broadcast( msg ); - + var room = this; var app = room.soundtrack.app; @@ -52,10 +52,10 @@ RoomSchema.methods.broadcast = function( msg , GLOBAL ) { }; 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 = { @@ -71,27 +71,33 @@ RoomSchema.methods.queueTrack = function( track , curator , callback ) { 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: { - _id: curator._id - , username: curator.username - , slug: curator.slug - } + , curator: curatorObj } ) ); - + room.sortPlaylist(); - + room.savePlaylist(function() { room.broadcast({ type: 'playlist:add', @@ -121,9 +127,9 @@ RoomSchema.methods.savePlaylist = function( saved ) { //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(); }; @@ -136,7 +142,7 @@ RoomSchema.methods.generatePool = function( gain , failpoint , cb ) { var gain = 0; var failpoint = MAXIMUM_PLAY_AGE; } - + if (typeof(failpoint) === 'function') { var cb = failpoint; var failpoint = MAXIMUM_PLAY_AGE; @@ -146,7 +152,7 @@ RoomSchema.methods.generatePool = function( gain , failpoint , cb ) { 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 @@ -174,7 +180,7 @@ RoomSchema.methods.generatePool = function( gain , failpoint , cb ) { 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 , '?'); @@ -187,9 +193,9 @@ RoomSchema.methods.generatePool = function( gain , failpoint , cb ) { if ((!plays) && (gain > failpoint)) return cb('init'); return cb( err , plays , query ); - + }); - + }); }); @@ -221,7 +227,7 @@ RoomSchema.methods.ensureQueue = function(callback) { room.playlist.push( track ); return callback(); }); - + }; RoomSchema.methods.nextSong = function( done ) { if (!done) var done = new Function(); @@ -265,11 +271,11 @@ RoomSchema.methods.startMusic = function( cb ) { 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... + // 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) { @@ -286,14 +292,14 @@ RoomSchema.methods.startMusic = function( cb ) { 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() ); } @@ -303,7 +309,7 @@ RoomSchema.methods.startMusic = function( cb ) { var scrobbleTime = (room.track.duration > FOUR_MINUTES) ? FOUR_MINUTES : room.track.duration / 2; room.permaTimer = setTimeout(function() { - + async.parallel([ insertIntoPlayHistory, scrobbleIfEnabled @@ -311,7 +317,7 @@ RoomSchema.methods.startMusic = function( cb ) { if (err) console.log(err); console.log('play history updated and lastfm scrobbled!'); }); - + function insertIntoPlayHistory( done ) { var play = new Play({ _track: room.track._id, @@ -321,7 +327,7 @@ RoomSchema.methods.startMusic = function( cb ) { }); play.save( done ); } - + function scrobbleIfEnabled( done ) { if (!room.soundtrack.app.lastfm) return done(); room.scrobbleActive( room.track , done ); @@ -331,7 +337,7 @@ RoomSchema.methods.startMusic = function( cb ) { } return cb(); - + }); }); }; diff --git a/package.json b/package.json index 5f45706a..f66c6e03 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "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", diff --git a/public/css/main.css b/public/css/main.css index 107c4a51..3d91a948 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -162,15 +162,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; } @@ -363,42 +363,42 @@ footer { margin-left:0; margin-right:0; } - + footer {margin:0;} } - + @media (max-width:979px) and (min-width:768px){ #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; } } - + @media (max-width: 944px) { .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%; } @@ -419,3 +419,8 @@ footer { opacity: 1 !important; transition-duration: 0s!important; } + +/* pointer patch from @chrisinajar */ +.tooltip { + pointer-events: none; +} 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/js/app.js b/public/js/app.js index 053d183a..20c4d1d2 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -961,6 +961,16 @@ $(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; diff --git a/soundtrack.js b/soundtrack.js index 8f8c6bc8..a66fd091 100644 --- a/soundtrack.js +++ b/soundtrack.js @@ -23,7 +23,9 @@ var flashify = require('flashify'); var passport = require('passport'); var LocalStrategy = require('passport-local').Strategy; var SpotifyStrategy = require('passport-spotify').Strategy; +var ChangeTipStrategy = require('passport-changetip').Strategy; var LastFM = require('lastfmapi'); +var ChangeTip = require('./lib/ChangeTip'); // session management var session = require('express-session'); @@ -100,14 +102,14 @@ app.use(function(req, res, next) { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('X-Powered-By', 'beer.'); - res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); res.locals.config = config; res.locals.user = req.user; res.charset = 'utf-8'; - + res.locals.lang = lang['en']; - + var parts = req.headers.host.split('.'); req.room = parts[0]; @@ -117,9 +119,9 @@ app.use(function(req, res, next) { req.roomObj = room; res.locals.room = room; - + if (!req.user) return next(); - + Playlist.find({ _creator: req.user._id }).sort('name').exec(function(err, playlists) { @@ -127,9 +129,9 @@ app.use(function(req, res, next) { if (req.user && !req.user.username) { return res.redirect('/set-username'); } - + res.locals.user.playlists = playlists; - + var listeningIn = []; for (var name in app.rooms) { listeningIn = _.union( listeningIn , _.toArray(app.rooms[ name ].listeners).map(function(x) { @@ -141,7 +143,11 @@ app.use(function(req, res, next) { }) ); } res.locals.user.rooms = listeningIn; - + + if (req.user.profiles.changetip) { + req.changetip = new ChangeTip( req.user.profiles.changetip.token ); + } + next(); }); }); @@ -159,11 +165,11 @@ function redirectToMainSite(req, res, next) { } else { if (req.headers.host.split(':')[0] !== config.app.host) return res.redirect( ((config.app.safe) ? 'https://' : 'http://') + config.app.host + req.path ); } - + return next(); } -// +// var otherMarked = require('./lib/marked'); otherMarked.setOptions({ sanitize: true @@ -203,7 +209,7 @@ function requireLogin(req, res, next) { // require the user to log in res.status(401).render('login', { next: req.path - }) + }); } } @@ -225,7 +231,7 @@ function authorize(role) { case 'host': return function( req, res, next ) { if (~req.user.roles.indexOf('admin')) return next(); - + if (!app.rooms[ req.room ]) return res.status(404).end(); if (!app.rooms[ req.room ]._owner) return res.status(404).end(); if (app.rooms[ req.room ]._owner.toString() !== req.user._id.toString()) { @@ -236,7 +242,7 @@ function authorize(role) { } else { return next(); } - } + }; break; } } @@ -255,10 +261,50 @@ var Soundtrack = require('./lib/soundtrack'); var soundtrack = new Soundtrack(app); soundtrack.start(); +app.post('/tips', requireLogin , function(req, res, next) { + var room = app.rooms[ req.room ]; + + //- TODO: should tips be allowed to the machine? Machine can spend money? + // alternatively, should tips go to the devs? The host? + // Fabric. + if (!room.track.curator) return res.send({ errors: 'You can\'t tip the machine!' }); + + if (req.changetip && room.track.curator && room.track.curator.changetip) { + req.changetip.postJSON('tip' , { + receiver: room.track.curator._id, + message: '1 bit', + context_uid: Math.random(), + context_url: 'https://soundtrack.io' + }, function(err, results) { + var result = err || results; + + if (result.errors) return res.send(result); + + res.render('partials/announcement', { + message: { + message: req.user.username + ' tipped ' + room.track.curator.username + ' for this track!', + created: new Date(), + track: room.track + } + }, function(err, html) { + room.broadcast({ + type: 'announcement', + data: { + formatted: html, + created: new Date() + } + }); + }); + }); + } else { + return res.send({ errors: room.track.curator.username + ' hasn\'t linked their ChangeTip account. :(' }); + } +}); + app.post('/skip', requireLogin, function(req, res) { console.log('skip received from ' +req.user.username); var room = app.rooms[ req.room ]; - + /* When first starting server, track is undefined, prevent this from erroring */ var title; if (room.track) { @@ -270,8 +316,8 @@ app.post('/skip', requireLogin, function(req, res) { room.nextSong(function() { console.log('skip.nextSong() called'); res.send({ status: 'success' }); - - + + //Announce who skipped this song res.render('partials/announcement', { message: { @@ -290,7 +336,7 @@ app.post('/skip', requireLogin, function(req, res) { ); }); - + }); sock.on('connection', function socketConnectionHandler(conn) { @@ -340,7 +386,7 @@ sock.on('connection', function socketConnectionHandler(conn) { , roles: matches[0].user.roles , avatar: matches[0].user.avatar }; - + connRoom.broadcast({ type: 'join' , data: { @@ -368,7 +414,7 @@ sock.on('connection', function socketConnectionHandler(conn) { if (err) { console.log(err); } if (!track) { return; } - // temporary collect exact matches... + // 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) { @@ -437,7 +483,7 @@ var externalizer = function(req, res, next) { }); } } - + // stub for spotify API auth req.spotify = { get: function( path ) { @@ -448,7 +494,7 @@ var externalizer = function(req, res, next) { }); } } - + return next(); } @@ -487,26 +533,24 @@ if (config.google && config.google.id && config.google.secret) { { _id: (req.user) ? req.user._id : undefined } , { 'profiles.google.id': profile.id } ]}).exec(function(err, person) { - console.log('search result: ', err , person ); - if (!person) var person = new Person({ username: profile.username }); - + person.profiles.google = { id: profile.id, token: accessToken, updated: new Date(), expires: null } - + person.save(function(err) { if (err) console.log('serious error', err ); done(err, person); }); - + }); - + })); - + app.get('/auth/google', redirectSetup , passport.authenticate('google') ); app.get('/auth/google/callback', passport.authenticate('google') , redirectNext ); @@ -527,27 +571,75 @@ if (config.spotify && config.spotify.id && config.spotify.secret) { , { 'profiles.spotify.id': profile.id } ]}).exec(function(err, person) { if (!person) var person = new Person({ username: profile.username }); - + person.profiles.spotify = { id: profile.id, token: accessToken, updated: new Date(), expires: null } - + person.save(function(err) { if (err) console.log('serious error', err ); done(err, person); }); - + }); })); - + app.get('/auth/spotify', redirectSetup , passport.authenticate('spotify') ); app.get('/auth/spotify/callback', passport.authenticate('spotify') , redirectNext ); - + app.get('/sets/sync/spotify', soundtracker , externalizer , playlists.syncAndImport ); - + +} + +if (config.changetip && config.changetip.id && config.changetip.secret) { + passport.use(new ChangeTipStrategy({ + clientID: config.changetip.id, + clientSecret: config.changetip.secret, + //callbackURL: ((config.app.safe) ? 'https://' : 'http://') + config.app.host + '/auth/changetip/callback', + callbackURL: 'https://soundtrack.io/auth/changetip/callback', + //callbackURL: 'http://localhost.localdomain:13000/auth/changetip/callback', + passReqToCallback: true + }, function(req, accessToken, refreshToken, profile, done) { + + Person.findOne({ $or: [ + { _id: (req.user) ? req.user._id : undefined } + , { 'profiles.changetip.id': profile.id } + ]}).exec(function(err, person) { + profile.username = profile.displayName; + + if (!person) var person = new Person({ username: profile.username }); + + person.profiles.changetip = { + id: profile.id, + token: accessToken, + username: profile.username, + updated: new Date(), + expires: null + }; + + person.save(function(err) { + if (err) console.log('serious error', err ); + done(err, person); + }); + + }); + + })); + + app.get('/auth/changetip', redirectSetup , passport.authenticate('changetip') ); + app.get('/auth/changetip/callback', passport.authenticate('changetip') , function(req, res) { + req.changetip.postJSON('verify-channel-user', { + channel_uid: req.user._id.toString() + }, function(err, results) { + console.log('verify:', err , results ); + req.flash('info', 'Congrats! You can now send tips to anyone who has configured their ChangeTip account.'); + res.redirect('/'); + }); + }); + } if (config.lastfm && config.lastfm.key && config.lastfm.secret) { @@ -627,7 +719,7 @@ app.post('/:usernameSlug', people.edit); app.post('/chat', requireLogin, function(req, res) { var room = app.rooms[ req.room ]; if (!room) return next(); - + var chat = new Chat({ _author: req.user._id , message: req.param('message') @@ -683,7 +775,7 @@ app.del('/playlist/:trackID', requireLogin, requireRoom , authorize('host'), fun app.post('/playlist/:trackID', requireLogin, function(req, res, next) { var room = app.rooms[ req.room ]; - + var playlistMap = room.playlist.map(function(x) { return x._id.toString(); }); @@ -705,7 +797,7 @@ app.post('/playlist/:trackID', requireLogin, function(req, res, next) { room.broadcast({ type: 'playlist:update' }); - + res.send({ status: 'success' }); @@ -728,13 +820,13 @@ app.post('/playlist', requireLogin , function(req, res) { console.log(err); return res.send({ status: 'error', message: 'Could not add that track.' }); } - + var queueWasEmpty = false; if (!app.rooms[ req.room ].playlist.length) queueWasEmpty = true; app.rooms[ req.room ].queueTrack(track, req.user, function() { console.log( 'queueTrack() callback executing... '); res.send({ status: 'success', message: 'Track added successfully!' }); - + if (queueWasEmpty) { app.rooms[ req.room ].broadcast({ type: 'track', @@ -791,7 +883,7 @@ app.post('/register', function(req, res) { }); } }); - + }); }); @@ -902,13 +994,13 @@ Room.find().exec(function(err, rooms) { name: 'Coding Soundtrack', slug: 'coding' }); - + async.series([ function(done) { Chat.update({}, { $set: { _room: room._id } }, { multi: true }).exec( done ); }, function(done) { Play.update({}, { $set: { _room: room._id } }, { multi: true }).exec( done ); } ], function(err, results) { if (err) throw new Error( err ); - + room.save(function(err) { room.bind( soundtrack ); // port queue, if any @@ -923,7 +1015,7 @@ Room.find().exec(function(err, rooms) { }); } else { - + // monolithic core for now. app.rooms = {}; var jobs = rooms.map(function(room) { @@ -932,38 +1024,38 @@ Room.find().exec(function(err, rooms) { playlist = JSON.parse(playlist); room.playlist = playlist; //console.log('room playlist:', room.playlist );// process.exit(); - + if (!playlist || !playlist.length) playlist = []; - + app.rooms[ room.slug ] = room; app.rooms[ room.slug ].playlist = playlist; app.rooms[ room.slug ].listeners = {}; - + app.rooms[ room.slug ].bind( soundtrack ); - + function errorHandler(err) { if (err) { return app.rooms[ room.slug ].retryTimer = setTimeout(function() { app.rooms[ room.slug ].startMusic( errorHandler ); }, 5000 ); } - + return done(); } - + //app.rooms[ room.slug ].startMusic( errorHandler ); app.rooms[ room.slug ].startMusic( done ); }); }; }); - + console.log( jobs.length.toString() , 'rooms found, configuring...'); - + async.parallel( jobs , function(err, results) { - + app.locals.rooms = app.rooms; - + server.listen(config.app.port, function(err) { console.log('Listening on port ' + config.app.port + ' for HTTP'); console.log('Must have redis listening on port 6379'); diff --git a/views/index.jade b/views/index.jade index 224cde85..60fe73f3 100644 --- a/views/index.jade +++ b/views/index.jade @@ -32,6 +32,11 @@ block content a.btn.btn-mini(href="#", data-action="skip-track") skip if (user && user.roles.indexOf('editor') >= 0) a.btn.btn-mini(href="#", data-action="launch-track-editor", data-for="current-track-id") edit + if (user) + if (user.profiles.changetip && user.profiles.changetip.token) + a.btn.btn-mini.tooltipped(href="#", data-action="tip-current-curator", title="Give whoever queued this track 1 bit (that's 0.000001 Bitcoin)") tip + else + a.btn.btn-mini.tooltipped(href="/auth/changetip", title="Give whoever queued this track 1 bit (that's 0.000001 Bitcoin)") tip .btn-group.pull-right //-a.btn(href="#playlist-modal", data-toggle="modal") your playlists diff --git a/views/partials/announcement.jade b/views/partials/announcement.jade index 35cfd62a..1baf157a 100644 --- a/views/partials/announcement.jade +++ b/views/partials/announcement.jade @@ -1,3 +1,6 @@ .message - abbr.timestamp.pull-right(title="#{moment(message.created).format()}") #{moment(message.created).format('HH:mm:ss')} - strong#announcement !{message.message} \ No newline at end of file + if (message._track && message._track._id) + abbr.timestamp.pull-right(title="#{moment(message.created).format()}\n\"#{message._track.title}\" by #{message._track._artist.name} was playing.", datetime="#{moment(message.created).format()}") #{moment(message.created).format('HH:mm:ss')} + else + abbr.timestamp.pull-right(title="#{moment(message.created).format()}") #{moment(message.created).format('HH:mm:ss')} + strong#announcement !{message.message} diff --git a/views/people.jade b/views/people.jade index e524445b..6794332e 100644 --- a/views/people.jade +++ b/views/people.jade @@ -22,3 +22,7 @@ block content | a(href="https://play.spotify.com/user/#{person.profiles.spotify.id}") img(src="/img/spotify.png", title="#{person.profiles.spotify.id}") + if (person.profiles && person.profiles.changetip && person.profiles.changetip.id) + | + a(href="https://www.changetip.com/tipme/#{person.profiles.changetip.username}") + img(style="max-height: 16px;", src="/img/changetip.png", title="#{person.profiles.changetip.username}") diff --git a/views/person.jade b/views/person.jade index beceef54..2d222cb3 100644 --- a/views/person.jade +++ b/views/person.jade @@ -17,6 +17,8 @@ block content a.btn(href="/auth/lastfm") add last.fm » if (!person.profiles || !person.profiles.spotify || !person.profiles.spotify.id) a.btn(href="/auth/spotify") add spotify » + if (!person.profiles || !person.profiles.changetip || !person.profiles.changetip.id) + a.btn(href="/auth/changetip") add changetip » if (artist) img.avatar-large.pull-left(src="#{artist.image.url}") @@ -31,6 +33,9 @@ block content if (person.profiles.spotify && person.profiles.spotify.id) a(href="https://play.spotify.com/user/#{person.profiles.spotify.id}") img(src="/img/spotify.png", title="#{person.profiles.spotify.username}") + if (person.profiles.changetip && person.profiles.changetip.id) + a(href="https://www.changetip.com/tipme/#{person.profiles.changetip.username}") + img(style="max-height: 16px;", src="/img/changetip.png", title="#{person.profiles.changetip.username}") if (artist) span.badge artist