├── Procfile ├── screen.png ├── config.json ├── routes └── index.js ├── package.json ├── README.md ├── .gitignore ├── newrelic.js ├── LICENSE ├── views ├── index.jade └── layout.jade ├── app.js ├── helpers └── songify.js └── public ├── javascripts └── app.js └── stylesheets └── style.css /Procfile: -------------------------------------------------------------------------------- 1 | web: node app.js 2 | -------------------------------------------------------------------------------- /screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karan/Singular/master/screen.png -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "twitter": { 3 | "key": "", 4 | "secret": "", 5 | "oauth_token": "", 6 | "oauth_token_secret": "" 7 | }, 8 | "maps": "" 9 | } 10 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * GET home page. 4 | */ 5 | 6 | var config = require('./../config.json'); 7 | 8 | exports.index = function(req, res){ 9 | res.render('index', { api: config.maps }); 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "application-name", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "start": "node app.js" 7 | }, 8 | "dependencies": { 9 | "express": "3.5.0", 10 | "jade": "*", 11 | "request": "^2.39.0", 12 | "socket.io": "", 13 | "twit": "*", 14 | "newrelic": "*" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Singular 2 | ======== 3 | 4 | ![logo](https://github.com/karan/Singular/raw/master/screen.png) 5 | 6 | Maps the location of tweets that contain songs in real time. 7 | 8 | Node.js app with express.js. No db needed. 9 | 10 | ### Heroku 11 | 12 | ```bash 13 | # edit stuff, add keys to config.json 14 | $ git add . 15 | $ git commit -m "helpful commit message" 16 | $ git push heroku master 17 | ``` 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Deployed apps should consider commenting this line out: 24 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 25 | node_modules 26 | -------------------------------------------------------------------------------- /newrelic.js: -------------------------------------------------------------------------------- 1 | /** 2 | * New Relic agent configuration. 3 | * 4 | * See lib/config.defaults.js in the agent distribution for a more complete 5 | * description of configuration variables and their potential values. 6 | */ 7 | exports.config = { 8 | /** 9 | * Array of application names. 10 | */ 11 | app_name : ['Singular'], 12 | /** 13 | * Your New Relic license key. 14 | */ 15 | license_key : '', 16 | logging : { 17 | /** 18 | * Level at which to log. 'trace' is most useful to New Relic when diagnosing 19 | * issues with the agent, 'info' and higher will impose the least overhead on 20 | * production applications. 21 | */ 22 | level : 'info' 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Karan Goel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /views/index.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | 5 | #map-canvas 6 | #allsongs 7 | #footer 8 | #logo 9 | #tagline 10 | span Mapping the World's Sounds 11 | #social 12 | a(href="#") 13 | span.fa.fa-question.share.sbg-help 14 | //- a(href="http://github.com/karan/Singular", target="_blank") 15 | //- span.fa.fa-github.share.sbg-github 16 | a(href="https://twitter.com/intent/tweet?text=Look+at+the+map+of+world's+sounds&url=http://singular.goel.im&via=KaranGoel", target="_blank") 17 | span.fa.fa-twitter.share.sbg-twitter 18 | a(href="", onclick="window.open('https://www.facebook.com/sharer/sharer.php?u='+encodeURIComponent(location.href),'facebook-share-dialog','width=626,height=436');return false;", target="_blank") 19 | span.fa.fa-facebook.share.sbg-facebook 20 | .md-modal.md-effect 21 | .md-content 22 | h3 Singular - Mapping the World's Sounds 23 | p Realtime mapping of songs shared on Twitter. 24 | button.md-close Try Singular now 25 | .modal-footer 26 | | Project by 27 | a(href='http://www.goel.im', target='_blank') Karan Goel 28 | | . 29 | br 30 | a(href='http://eepurl.com/SRIPT', target='_blank') Leave your e-mail here and stay tuned about my projects. 31 | -------------------------------------------------------------------------------- /views/layout.jade: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang="en") 3 | head 4 | title Singular - Mapping the World's Sounds 5 | meta(charset="utf-8") 6 | 7 | // Stylesheets 8 | link(rel='stylesheet', href='/stylesheets/style.css') 9 | link(rel='stylesheet', href='//maxcdn.bootstrapcdn.com/font-awesome/4.1.0/css/font-awesome.min.css') 10 | 11 | // Scripts 12 | script(src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js") 13 | script(src="/socket.io/socket.io.js") 14 | script(src="https://maps.googleapis.com/maps/api/js?sensor=false") 15 | script(src='/javascripts/app.js') 16 | 17 | //- Facebook open graph 18 | meta(property="og:url", content="http://singular.goel.im/") 19 | meta(property="og:title", content="Singular - Mapping the World's Sounds") 20 | meta(property="og:description", content="Singular lets you look at the map of music shared by tweeps. A project by @KaranGoel.") 21 | meta(property="og:image", content="http://singular.goel.im/screen.png") 22 | 23 | meta(name="viewport", content="width=device-width, initial-scale=1.0") 24 | 25 | body 26 | block content 27 | 28 | script. 29 | (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ 30 | (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), 31 | m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) 32 | })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); 33 | 34 | ga('create', 'UA-9509587-32', 'auto'); 35 | ga('send', 'pageview'); 36 | script. 37 | !function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0],p=/^http:/.test(d.location)?'http':'https';if(!d.getElementById(id)){js=d.createElement(s);js.id=id;js.src=p+'://platform.twitter.com/widgets.js';fjs.parentNode.insertBefore(js,fjs);}}(document, 'script', 'twitter-wjs'); 38 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | require('newrelic'); 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var express = require('express'); 8 | var routes = require('./routes'); 9 | var path = require('path'); 10 | var config = require('./config.json'); 11 | 12 | var twit = require('twit'); 13 | 14 | var app = express(); 15 | 16 | var server = require('http').createServer(app), 17 | io = require('socket.io').listen(server); 18 | 19 | 20 | // all environments 21 | app.set('port', process.env.PORT || 8888); 22 | app.set('views', path.join(__dirname, 'views')); 23 | app.set('view engine', 'jade'); 24 | app.use(express.favicon()); 25 | app.use(express.logger('dev')); 26 | app.use(express.json()); 27 | app.use(express.urlencoded()); 28 | app.use(express.methodOverride()); 29 | app.use(app.router); 30 | app.use(express.static(path.join(__dirname, 'public'))); 31 | 32 | 33 | // development only 34 | if ('development' == app.get('env')) { 35 | app.use(express.errorHandler()); 36 | } 37 | 38 | // socket hang up exception 39 | process.on('uncaughtException', function (exception) { 40 | console.log(exception); 41 | }); 42 | 43 | 44 | app.get('/', routes.index); 45 | 46 | 47 | var T = new twit({ 48 | consumer_key: config.twitter.key, 49 | consumer_secret: config.twitter.secret, 50 | access_token: config.twitter.oauth_token, 51 | access_token_secret: config.twitter.oauth_token_secret 52 | }); 53 | 54 | io.sockets.on('connection', function (socket) { 55 | 56 | console.log("client connected"); 57 | 58 | var stream = T.stream('statuses/filter', { 59 | track: ['snd.sc', 'soundcloud', 'listen', 'hear', 'song', 'youtu.be', 60 | 'y2u.be', 'goo.gl', 'youtube', 'music', 'artist', 'playlist', 61 | 'album', 'stream'], 62 | locations: ['-180', '-90', '180', '90'] 63 | }); 64 | 65 | stream.on('tweet', function(tweet) { 66 | require('./helpers/songify').songify(tweet, function(obj) { 67 | if (obj) { 68 | console.log(obj); 69 | io.sockets.emit('newTweet', obj); 70 | } 71 | }); 72 | }); 73 | 74 | }); 75 | 76 | 77 | server.listen(app.get('port')); 78 | console.log("Server starter on port " + app.get('port')); 79 | -------------------------------------------------------------------------------- /helpers/songify.js: -------------------------------------------------------------------------------- 1 | // Takes a tweets object and calls the callback with modified tweet object if 2 | // it's a valid SC/YT tweets, null otherwise. 3 | 4 | var request = require('request'); 5 | 6 | module.exports.songify = function(tweet, callback) { 7 | if (tweet === undefined || tweet.entities.urls.length === 0) { 8 | return callback(null); // no urls 9 | } 10 | 11 | if (tweet.coordinates === null && tweet.place === null) { 12 | return callback(null); 13 | } 14 | 15 | var url = tweet.entities.urls[0].expanded_url.toLowerCase(); 16 | 17 | if ((url.indexOf("soundcloud.com") === -1) && 18 | (url.indexOf("snd.sc") === -1) && 19 | (url.indexOf("youtube.com") === -1) && 20 | (url.indexOf("youtu.be") === -1)) { 21 | return callback(null); 22 | } 23 | 24 | var obj = {}; 25 | obj.tweet_id = tweet.id_str; 26 | obj.tweeted_by = tweet.user.screen_name; 27 | obj.tweeted_by_photo = tweet.user.profile_image_url || ''; 28 | obj.song_url = tweet.entities.urls[0].expanded_url.toLowerCase(); 29 | 30 | if (tweet.coordinates) { 31 | obj.coordinates = tweet.coordinates.coordinates; 32 | } else if (tweet.place) { 33 | obj.coordinates = centerPoint(tweet.place.bounding_box.coordinates[0]); 34 | } else { 35 | return callback(null); 36 | } 37 | 38 | if ((obj.song_url.indexOf("soundcloud.com") === -1) && 39 | (obj.song_url.indexOf("snd.sc") === -1)) { 40 | obj.song_source = 'yt'; 41 | } else { 42 | obj.song_source = 'sc'; 43 | } 44 | 45 | if (obj.song_source === 'sc') { 46 | var url = 'https://api.soundcloud.com/resolve.json?consumer_key=c2c242fd7d60a1165e6a0924f8e70138&url='+obj.song_url; 47 | request({url: url, timeout: 3000, followRedirect: true}, 48 | function(error, response, track) { 49 | if (!error && response.statusCode == 200) { 50 | track = JSON.parse(track); 51 | obj.artwork_url = track.artwork_url; 52 | obj.song_id = track.id; 53 | obj.song_title = track.title; 54 | return callback(obj); 55 | } 56 | }); 57 | } else { 58 | var ytMatches = obj.song_url.match(/(youtu\.be\/|v=)([^&]+)/); 59 | if (ytMatches) { 60 | ytID = ytMatches[2]; 61 | var url = 'http://gdata.youtube.com/feeds/api/videos?q='+ytID+'&max-results=1&v=2&alt=jsonc'; 62 | request({url: url, timeout: 3000, followRedirect: true}, 63 | function(error, response, track) { 64 | if (!error && response.statusCode == 200) { 65 | try { 66 | track = JSON.parse(track); 67 | } catch(err) { 68 | console.log(err); 69 | return callback(null); 70 | } 71 | if (track && track.data && track.data.items && 72 | track.data.items[0].category === 'Music') { 73 | 74 | track = track.data.items[0]; 75 | 76 | obj.artwork_url = track.thumbnail.hqDefault; 77 | obj.song_id = track.id; 78 | obj.song_title = track.title; 79 | return callback(obj); 80 | } 81 | } 82 | }); 83 | } 84 | } 85 | } 86 | 87 | var centerPoint = function(coords) { 88 | var centerPointX, centerPointY, coord, i, _len; 89 | centerPointX = centerPointY = 0; 90 | for (i = 0, _len = coords.length; i < _len; i++) { 91 | coord = coords[i]; 92 | centerPointX += coord[0]; 93 | centerPointY += coord[1]; 94 | } 95 | return [centerPointX / coords.length, centerPointY / coords.length]; 96 | }; 97 | -------------------------------------------------------------------------------- /public/javascripts/app.js: -------------------------------------------------------------------------------- 1 | var map; 2 | 3 | $(document).ready(function() { 4 | 5 | $('.md-modal').addClass('md-show'); 6 | $('.sbg-help').on('click', function(e) { 7 | $('.md-modal').addClass('md-show'); 8 | }); 9 | $('.md-close').on('click', function(e) { 10 | $('.md-modal').removeClass('md-show'); 11 | }); 12 | 13 | var mapOptions; 14 | mapOptions = void 0; 15 | mapOptions = { 16 | zoom: 2, 17 | center: new google.maps.LatLng(5, -30), 18 | mapTypeId: google.maps.MapTypeId.ROADMAP, 19 | zoomControl: true, 20 | disableDefaultUI: true 21 | }; 22 | 23 | map = new google.maps.Map(document.getElementById("map-canvas"), mapOptions); 24 | 25 | // var host = location.origin.replace(/^http/, 'ws'); 26 | var socket = io(); 27 | console.log(socket); 28 | 29 | socket.on('newTweet', function (data) { 30 | console.log(data); 31 | var marker = addToMap(data); 32 | addToSidebar(data, marker); 33 | }); 34 | 35 | }); 36 | 37 | 38 | function addToMap(data) { 39 | var coords = data.coordinates; // lng, lat 40 | var lat = coords[1]; 41 | var lng = coords[0]; 42 | 43 | var zoom = map.getZoom(); 44 | var marker = new google.maps.Marker({ 45 | position: new google.maps.LatLng(lat, lng), 46 | title: data.song_title, 47 | raiseOnDrag: false, 48 | draggable: false, 49 | animation: google.maps.Animation.DROP, 50 | map: map 51 | }); 52 | 53 | marker.setIcon(new google.maps.MarkerImage( 54 | "http://chart.apis.google.com/chart?chst=d_map_pin_letter&chld=%E2%80%A2|FF0000", 55 | null, null, null, new google.maps.Size(15, 24) 56 | )); 57 | 58 | // create the tooltip 59 | createInfoWindow(marker, data); 60 | return marker; 61 | } 62 | 63 | function addToSidebar(data, marker) { 64 | console.log("adding to sidebar"); 65 | var userImg = ''; 66 | var username = '' + data.tweeted_by + ''; 67 | var playerCode; 68 | if (data.song_source === 'sc') { 69 | playerCode = ''; 70 | } else { 71 | playerCode = '