From 8521baa6d909515fbc0e2d5dd47d85e214d45812 Mon Sep 17 00:00:00 2001
From: Jonathan Cremin <jonathan@crem.in>
Date: Fri, 21 Aug 2015 18:33:50 +0100
Subject: [PATCH] Refactor more, fix and design 404

---
 app.js                           | 23 +-------
 lib/error-handler.js             | 48 ++++++++++++++++
 lib/services/xbox/index.js       | 74 ++++++++++++++++---------
 lib/services/xbox/url.js         |  4 +-
 lib/services/youtube/freebase.js |  4 +-
 lib/services/youtube/index.js    | 95 +++++++++++++++++---------------
 public/stylesheets/style.css     | 44 +++++++--------
 routes/search.js                 | 21 ++++---
 routes/share.js                  | 18 +++---
 views/app.js                     |  3 +-
 views/notfound.js                | 27 +++++++++
 11 files changed, 223 insertions(+), 138 deletions(-)
 create mode 100644 lib/error-handler.js
 create mode 100644 views/notfound.js

diff --git a/app.js b/app.js
index 756b95c..6f8898f 100644
--- a/app.js
+++ b/app.js
@@ -16,33 +16,14 @@ import share from './routes/share';
 import itunesProxy from './routes/itunes-proxy';
 import { routes } from './views/app';
 import createHandler from './lib/react-handler';
+import errorHandler from './lib/error-handler';
 
 import debuglog from 'debug';
 const debug = debuglog('match.audio');
 
 const app = koa();
 
-app.use(function* (next) {
-  this.set('Server', 'Nintendo 64');
-  try {
-    yield next;
-  } catch (err) {
-    if (!err.status) {
-      debug('Error: %o', err);
-      throw err;
-    } else if (err.status === 404) {
-      let Handler = yield createHandler(routes, this.request.url);
-
-      let App = React.createFactory(Handler);
-      let content = React.renderToString(new App());
-
-      this.body = '<!doctype html>\n' + content;
-    } else {
-      debug('Error: %o', err);
-      throw err;
-    }
-  }
-});
+app.use(errorHandler(routes));
 
 app.use(bodyparser());
 app.use(compress({flush: zlib.Z_SYNC_FLUSH }));
diff --git a/lib/error-handler.js b/lib/error-handler.js
new file mode 100644
index 0000000..1bad98a
--- /dev/null
+++ b/lib/error-handler.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import createHandler from './react-handler';
+
+import debuglog from 'debug';
+const debug = debuglog('match.audio');
+
+export default function (routes) {
+  return function* (next) {
+    this.set('Server', 'Nintendo 64');
+    try {
+      yield next;
+    } catch (err) {
+      if (err.status === 404) {
+        let Handler = yield createHandler(routes, this.request.url);
+
+        let App = React.createFactory(Handler);
+        let content = React.renderToString(new App());
+
+        this.body = '<!doctype html>\n' + content;
+      } else {
+        debug('Error: %o', err);
+        throw err;
+      }
+    }
+
+    if (404 != this.status) return;
+
+    switch (this.accepts('html', 'json')) {
+      case 'html':
+        this.type = 'html';
+        let Handler = yield createHandler(routes, this.request.url);
+
+        let App = React.createFactory(Handler);
+        let content = React.renderToString(new App());
+
+        this.body = '<!doctype html>\n' + content;
+        break;
+      case 'json':
+        this.body = {
+          message: 'Page Not Found'
+        };
+        break;
+      default:
+        this.type = 'text';
+        this.body = 'Page Not Found';
+    }
+  }
+}
diff --git a/lib/services/xbox/index.js b/lib/services/xbox/index.js
index f95886b..1997fee 100644
--- a/lib/services/xbox/index.js
+++ b/lib/services/xbox/index.js
@@ -4,10 +4,13 @@ import request from 'superagent';
 import 'superagent-bluebird-promise';
 import { match as urlMatch }  from './url';
 
-export let id = "xbox";
+import debuglog from 'debug';
+const debug = debuglog('match.audio:xbox');
+
+export let id = 'xbox';
 
 if (!process.env.XBOX_CLIENT_ID || !process.env.XBOX_CLIENT_SECRET) {
-  console.warn("XBOX_CLIENT_ID and XBOX_CLIENT_SECRET environment variables not found, deactivating Xbox Music.");
+  console.warn('XBOX_CLIENT_ID and XBOX_CLIENT_SECRET environment variables not found, deactivating Xbox Music.');
 }
 
 const credentials = {
@@ -15,12 +18,12 @@ const credentials = {
   clientSecret: process.env.XBOX_CLIENT_SECRET
 };
 
-const apiRoot = "https://music.xboxlive.com/1/content";
+const apiRoot = 'https://music.xboxlive.com/1/content';
 
 function* getAccessToken() {
-  const authUrl = "https://datamarket.accesscontrol.windows.net/v2/OAuth2-13";
-  const scope = "http://music.xboxlive.com";
-  const grantType = "client_credentials";
+  const authUrl = 'https://datamarket.accesscontrol.windows.net/v2/OAuth2-13';
+  const scope = 'http://music.xboxlive.com';
+  const grantType = 'client_credentials';
 
   const data = {
     client_id: credentials.clientId,
@@ -28,7 +31,7 @@ function* getAccessToken() {
     scope: scope,
     grant_type: grantType
   };
-  const result = yield request.post(authUrl).send(data).set('Content-type', 'application/x-www-form-urlencoded').promise();
+  const result = yield request.post(authUrl).timeout(10000).send(data).set('Content-type', 'application/x-www-form-urlencoded').promise();
   return result.body.access_token;
 }
 
@@ -39,16 +42,16 @@ function formatResponse(res) {
   } else {
     result = res.body.Albums.Items[0];
   }
-  let item = {
-    service: "xbox",
-    type: res.body.Tracks ? "track" : "album",
+  const item = {
+    service: 'xbox',
+    type: res.body.Tracks ? 'track' : 'album',
     id: result.Id,
     name: result.Name,
     streamUrl: result.Link,
     purchaseUrl: null,
     artwork: {
-      small: result.ImageUrl.replace("http://", "https://") + "&w=250&h=250",
-      large: result.ImageUrl.replace("http://", "https://") + "&w=500&h=250"
+      small: result.ImageUrl.replace('http://', 'https://') + '&w=250&h=250',
+      large: result.ImageUrl.replace('http://', 'https://') + '&w=500&h=250'
     },
     artist: {
       name: result.Artists[0].Artist.Name
@@ -60,43 +63,60 @@ function formatResponse(res) {
   return item;
 }
 
+function* apiCall(path) {
+  const access_token = yield getAccessToken();
+  return request.get(apiRoot + path).timeout(10000).set('Authorization', 'Bearer ' + access_token).promise();
+}
+
 export const match = urlMatch;
 
 export function* parseUrl(url) {
   const parsed = parse(url);
-  const parts = parsed.path.split("/");
+  const parts = parsed.path.split('/');
   const type = parts[1];
   const idMatches = parts[4].match(/[\w\-]+/);
   const id = idMatches[0];
   if (!id) {
     return false;
   }
-  return yield lookupId("music." + id, type);
+  return yield lookupId('music.' + id, type);
 }
 
 export function* lookupId(id, type) {
-  const access_token = yield getAccessToken();
-  const path = "/" + id + "/lookup";
-  const result = yield request.get(apiRoot + path).set("Authorization", "Bearer " + access_token).promise();
-  return result ? formatResponse(result) : {service: "xbox"};
+  const path = '/' + id + '/lookup';
+  try {
+    const result = yield apiCall(path);
+    return formatResponse(result);
+  } catch (e) {
+    if (e.status !== 404) {
+      debug(e.body);
+    }
+    return {service: 'xbox'};
+  }
 };
 
 export function* search(data) {
   var cleanParam = function(str) {
-    return str.replace(/[\:\?\&]+/, "");
+    return str.replace(/[\:\?\&\(\)\[\]]+/g, '');
   }
   let query, album;
   const type = data.type;
 
-  if (type == "album") {
-    query = cleanParam(data.artist.name.substring(0, data.artist.name.indexOf('&'))) + " " + cleanParam(data.name);
+  if (type == 'album') {
+    query = cleanParam(data.artist.name.substring(0, data.artist.name.indexOf('&'))) + ' ' + cleanParam(data.name);
     album = data.name;
-  } else if (type == "track") {
-    query = cleanParam(data.artist.name.substring(0, data.artist.name.indexOf('&'))) + " " + cleanParam(data.name);
+  } else if (type == 'track') {
+    query = cleanParam(data.artist.name.substring(0, data.artist.name.indexOf('&'))) + ' ' + cleanParam(data.name);
     album = data.album.name
   }
-  const access_token = yield getAccessToken();
-  const path = "/music/search?q=" + encodeURIComponent(query) + "&filters=" + type + "s";
-  const result = yield request.get(apiRoot + path).set("Authorization", "Bearer " + access_token).promise()
-  return result ? formatResponse(result) : {service: "xbox"};
+  const path = '/music/search?q=' + encodeURIComponent(query) + '&filters=' + type + 's';
+  try {
+    const result = yield apiCall(path);
+    return formatResponse(result);
+  } catch (e) {
+    if (e.status !== 404) {
+      debug(e.body);
+    }
+    return {service: 'xbox'};
+  }
 };
diff --git a/lib/services/xbox/url.js b/lib/services/xbox/url.js
index d116d5d..fb54a36 100644
--- a/lib/services/xbox/url.js
+++ b/lib/services/xbox/url.js
@@ -6,6 +6,6 @@ export function* match(url, type) {
     return false;
   }
 
-  const parts = parsed.path.split("/");
-  return (parts[1] == "album" || parts[1] == "track") && parts[4];
+  const parts = parsed.path.split('/');
+  return (parts[1] == 'album' || parts[1] == 'track') && parts[4];
 };
diff --git a/lib/services/youtube/freebase.js b/lib/services/youtube/freebase.js
index 2dd7793..ef2377d 100644
--- a/lib/services/youtube/freebase.js
+++ b/lib/services/youtube/freebase.js
@@ -7,9 +7,9 @@ const credentials = {
   key: process.env.YOUTUBE_KEY,
 };
 
-const apiRoot = "https://www.googleapis.com/freebase/v1/topic";
+const apiRoot = 'https://www.googleapis.com/freebase/v1/topic';
 
 export function* get(topic) {
-  const result = yield request.get(apiRoot + topic + "?key=" + credentials.key).promise();
+  const result = yield request.get(apiRoot + topic + '?key=' + credentials.key).promise();
   return result.body;
 }
diff --git a/lib/services/youtube/index.js b/lib/services/youtube/index.js
index 1446b09..2aa93c2 100644
--- a/lib/services/youtube/index.js
+++ b/lib/services/youtube/index.js
@@ -6,6 +6,9 @@ import 'superagent-bluebird-promise';
 import { match as urlMatch }  from './url';
 import freebase from './freebase';
 
+import debuglog from 'debug';
+const debug = debuglog('match.audio:youtube');
+
 module.exports.id = 'youtube';
 
 if (!process.env.YOUTUBE_KEY) {
@@ -37,55 +40,58 @@ export function parseUrl(url) {
 export function* lookupId(id, type) {
 
   const path = '/videos?part=snippet%2CtopicDetails%2CcontentDetails&id=' + id + '&key=' + credentials.key;
-
-  const result = yield request.get(apiRoot + path).promise();
-  const item = res.body.items[0];
-  if (!item.topicDetails.topicIds) {
-    return {service: 'youtube'};
-  }
-
-  const promises = [];
-  const match = {
-    id: id,
-    service: 'youtube',
-    name: item.snippet.title,
-    type: 'track',
-    album: {name: ''},
-    streamUrl: 'https://youtu.be/' + id,
-    purchaseUrl: null,
-    artwork: {
-      small: item.snippet.thumbnails.medium.url,
-      large: item.snippet.thumbnails.high.url,
+  try {
+    const result = yield request.get(apiRoot + path).promise();
+    const item = result.body.items[0];
+    if (!item.topicDetails.topicIds) {
+      return {service: 'youtube'};
     }
-  };
 
-  for (let topic of yield freebase.get(topicId)) {
-    const musicalArtist = topic.property['/type/object/type'].values.some((value) => {
-      return value.text == 'Musical Artist';
-    });
-
-    const musicalRecording = topic.property['/type/object/type'].values.some(function(value) {
-      return value.text == 'Musical Recording';
-    });
-
-    const musicalAlbum = topic.property['/type/object/type'].values.some(function(value) {
-      return value.text == 'Musical Album';
-    })
-
-    if (musicalArtist) {
-      match.artist = {name: topic.property['/type/object/name'].values[0].text};
-    } else if (musicalRecording) {
-      match.name = topic.property['/type/object/name'].values[0].text;
-      if (topic.property['/music/recording/releases']) {
-        match.type = 'album';
-        match.album.name = topic.property['/music/recording/releases'].values[0].text;
+    const match = {
+      id: id,
+      service: 'youtube',
+      name: item.snippet.title,
+      type: 'track',
+      album: {name: ''},
+      streamUrl: 'https://youtu.be/' + id,
+      purchaseUrl: null,
+      artwork: {
+        small: item.snippet.thumbnails.medium.url,
+        large: item.snippet.thumbnails.high.url,
+      }
+    };
+
+    for (let topic of yield freebase.get(topicId)) {
+      const musicalArtist = topic.property['/type/object/type'].values.some((value) => {
+        return value.text == 'Musical Artist';
+      });
+
+      const musicalRecording = topic.property['/type/object/type'].values.some(function(value) {
+        return value.text == 'Musical Recording';
+      });
+
+      const musicalAlbum = topic.property['/type/object/type'].values.some(function(value) {
+        return value.text == 'Musical Album';
+      })
+
+      if (musicalArtist) {
+        match.artist = {name: topic.property['/type/object/name'].values[0].text};
+      } else if (musicalRecording) {
+        match.name = topic.property['/type/object/name'].values[0].text;
+        if (topic.property['/music/recording/releases']) {
+          match.type = 'album';
+          match.album.name = topic.property['/music/recording/releases'].values[0].text;
+        }
+      } else if (musicalAlbum) {
+        match.name = topic.property['/type/object/name'].values[0].text;
+        match.type = 'album';
       }
-    } else if (musicalAlbum) {
-      match.name = topic.property['/type/object/name'].values[0].text;
-      match.type = 'album';
     }
+    return match;
+  } catch (e) {
+    debug(e.body);
+    return {'service': 'youtube'};
   }
-  return match;
 };
 
 export function* search(data) {
@@ -101,7 +107,6 @@ export function* search(data) {
   }
 
   const path = '/search?part=snippet&q=' + encodeURIComponent(query) + '&type=video&videoCaption=any&videoCategoryId=10&key=' + credentials.key;
-
   const result = yield request.get(apiRoot + path).promise();
   const item = result.body.items[0];
 
diff --git a/public/stylesheets/style.css b/public/stylesheets/style.css
index 58a44ad..49b9f51 100644
--- a/public/stylesheets/style.css
+++ b/public/stylesheets/style.css
@@ -214,38 +214,36 @@ h3 {
   margin-bottom: 7px;
 }
 
-.js-video {
-  height: 0;
-  padding-top: 25px;
-  padding-bottom: 67.5%;
-  margin-bottom: 10px;
-  position: relative;
-  overflow: hidden;
-}
-
-.js-video.widescreen {
-  padding-bottom: 57.25%;
-}
-
-.js-video embed, .js-video iframe, .js-video object, .js-video video {
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  position: absolute;
+.error {
+  background: #FE4365;
+  color: #febdc9;
 }
 
 .error h1, .error h2 {
-  font-weight: 300;
-  text-align: center;
+  font-weight: 100;
 }
 
 .error .main h1 {
   font-size: 2em;
-  margin-bottom: 50px;
+  margin-bottom: 20px;
+  color: #fff;
 }
 
 .error h2 {
+  color: #ff7c94;
   font-size: 4em;
-  margin-top: 50px;
+  margin-top: 0px;
+  margin-bottom: 20px;
+}
+
+.error a {
+  color: #fff;
+}
+
+.vertical-center {
+  min-height: 100%;  /* Fallback for browsers do NOT support vh unit */
+  min-height: 100vh; /* These two lines are counted as one :-)       */
+
+  display: flex;
+  align-items: center;
 }
diff --git a/routes/search.js b/routes/search.js
index 7092e59..3bd020e 100644
--- a/routes/search.js
+++ b/routes/search.js
@@ -3,6 +3,9 @@ import co from 'co';
 import lookup from '../lib/lookup';
 import services from '../lib/services';
 
+import debuglog from 'debug';
+const debug = debuglog('match.audio:search');
+
 module.exports = function* () {
   const url = parse(this.request.body.url);
   this.assert(url.host, 400, {error: {message: 'You need to submit a url.'}});
@@ -26,17 +29,21 @@ module.exports = function* () {
   yield this.db.matches.save({_id: item.service + '$$' + item.id, 'created_at': new Date(), services: matches});
   this.body = item;
 
-  process.nextTick(co.wrap(function* (){
+  process.nextTick(() => {
     for (let service of services) {
       if (service.id === item.service) {
         continue;
       }
       matches[service.id] = {service: service.id};
-      const match = yield service.search(item);
-      match.matched_at = new Date(); // eslint-disable-line camelcase
-      const update = {};
-      update['services.' + match.service] = match;
-      yield this.db.matches.updateOne({_id: item.service + '$$' + item.id}, {'$set': update});
+      co(function* (){
+        const match = yield service.search(item);
+        match.matched_at = new Date(); // eslint-disable-line camelcase
+        const update = {};
+        update['services.' + match.service] = match;
+        yield this.db.matches.updateOne({_id: item.service + '$$' + item.id}, {'$set': update});
+      }.bind(this)).catch((err) => {
+        debug(err);
+      });
     }
-  }.bind(this)));
+  });
 };
diff --git a/routes/share.js b/routes/share.js
index 1999072..af4593f 100644
--- a/routes/share.js
+++ b/routes/share.js
@@ -31,15 +31,13 @@ export default function* (serviceId, type, itemId, format, next) {
   const shares = formatAndSort(doc.services, serviceId);
 
   if (format === 'json') {
-    this.body = {shares: shares};
-  } else {
-    const Handler = yield createHandler(routes, this.request.url);
-
-    const App = React.createFactory(Handler);
-    let content = React.renderToString(new App({shares: shares}));
-
-    content = content.replace('</body></html>', '<script>var shares = ' + JSON.stringify(shares) + '</script></body></html>');
-
-    this.body = '<!doctype html>\n' + content;
+    return this.body = {shares: shares};
   }
+
+  const Handler = yield createHandler(routes, this.request.url);
+  const App = React.createFactory(Handler);
+  let content = React.renderToString(new App({shares: shares}));
+  content = content.replace('</body></html>', '<script>var shares = ' + JSON.stringify(shares) + '</script></body></html>');
+
+  this.body = '<!doctype html>\n' + content;
 };
diff --git a/views/app.js b/views/app.js
index 25b80b3..9396e89 100644
--- a/views/app.js
+++ b/views/app.js
@@ -5,6 +5,7 @@ import Home from './home';
 import Share from './share';
 import Head from './head';
 import ErrorView from './error';
+import NotFound from './notfound';
 
 const App = React.createClass({
   render: function () {
@@ -27,7 +28,7 @@ const routes = (
   <Route name='home' handler={App} path='/'>
     <DefaultRoute handler={Home} />
     <Route name='share' path=':service/:type/:id' handler={Share}/>
-    <NotFoundRoute handler={ErrorView}/>
+    <NotFoundRoute handler={NotFound}/>
   </Route>
 );
 
diff --git a/views/notfound.js b/views/notfound.js
new file mode 100644
index 0000000..baae22c
--- /dev/null
+++ b/views/notfound.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import Head from './head';
+import Foot from './foot';
+
+export default React.createClass({
+
+  render: function() {
+    return (
+      <html>
+      <Head {...this.props} />
+      <body>
+      <div className='error vertical-center'>
+        <div className='container main'>
+          <div className='row'>
+            <div className='col-md-12'>
+              <h2>404</h2>
+              <h1>Sorry, it looks like the page you asked for is gone.</h1>
+              <a href='/'>Take Me Home</a> or <a href='https://www.youtube.com/watch?v=gnnIrTLlLyA' target='_blank'>Show Me the Wubs</a>
+            </div>
+          </div>
+        </div>
+      </div>
+      </body>
+      </html>
+    );
+  }
+});