Raw Source
saaj / Last.fm Enhanced Song Page

// ==UserScript==
//
// @name         Last.fm Enhanced Song Page
// @description  Injects external song information to the last.fm track page
// @namespace    lastfmenhancedsongpage
//
// @include      http://www.last.fm/*
// @include      http://www.lastfm.*/*
//
// @require      https://bitbucket.org/saaj/lastfm-enhanced-song-page/raw/default/build/base.js
// @require      https://ajax.googleapis.com/ajax/libs/jquery/1.2.6/jquery.min.js
//
// @downloadURL  https://bitbucket.org/saaj/lastfm-enhanced-song-page/raw/default/build/98307.user.js
//
// @noframes
//
// @grant        GM_xmlhttpRequest
// @grant        GM_getResourceURL
//
// @resource     progress http://cdn.last.fm/tageditor/progress_active.gif
//
// @author       saaj <mail@saaj.me>
// @website      https://bitbucket.org/saaj/lastfm-enhanced-song-page
// @license      GPL-2.0+
// @version      0.12.0
//
// ==/UserScript==


/**
 * Formats self in modern Python manner
 */
String.prototype.format = function()
{
  var value = this;
  for(var i = 0; i < arguments.length; i++)
  {
    value = value.split('{' + i + '}').join(String(arguments[i]));
  }

  return value;
};
/**
 * Computes a Levenshtein distance of self and passed argument,
 * based on qx.util.EditDistance
 */
String.prototype.distance = function(value)
{
  var a = this;
  var b = value;
  
  // distance is table with a.length + 1 rows and b.length + 1 columns
  var distance = [];
  // posA and posB are used to iterate over a and b
  var posA, posB, cost;

  for(posA = 0; posA <= a.length; posA++)
  {
    distance[posA]    = [];
    distance[posA][0] = posA;
  }

  for(posB = 1; posB <= b.length; posB++) 
  {
    distance[0][posB] = posB;
  }

  for(posA = 1; posA <= a.length; posA++)
  {
    for(posB = 1; posB <= b.length; posB++)
    {
      cost = a[posA - 1] === b[posB - 1] ? 0 : 1;

      distance[posA][posB] = Math.min(
        distance[posA - 1][posB    ] + 1,     // deletion
        distance[posA    ][posB - 1] + 1,     // insertion
        distance[posA - 1][posB - 1] + cost   // substitution
      );
    }
  }

  return distance[posA - 1][posB - 1];
};

function namespace(ns)
{
  var root = window;
  ns.split('.').forEach(function(part)
  {
    if(typeof root[part] == 'undefined')
    {
      root[part] = {};
    }

    root = root[part];
  });
  return root;
}


namespace('lesp');

/**
 * Event target. Sort of conceptual mix of qx.core.Object and SplObserver/SplSubject.
 */
lesp.Target = Base.extend({

  _listeners : null,


  constructor : function(supportedEvents)
  {
    this._listeners = {};
    supportedEvents.forEach(function(name)
    {
      this._listeners[name] = [];
    }, this);
  },

  attach : function(event, listener)
  {
    this._listeners[event].push(listener);
  },

  detach : function(event, listener)
  {
    var i = this._listeners[event].indexOf(listener);
    if(i != -1)
    {
      this._listeners[event].splice(i, 1);
    }
  },

  notify : function(event, data)
  {
    this._listeners[event].forEach(function(listener)
    {
      listener(this, data);
    }, this);
  }

});

lesp.mangleHtml = function(value)
{
  // the trick allows jquery traverse html correctly
  return value.replace(/<(\/?)(html|head|body|img)/ig, '<$1$2z');
};


namespace('lesp.abstract');

/**
 * Abstract song.
 */
lesp.abstract.Song = lesp.Target.extend({

  _baseUrl  : null,
  _document : null,
  _pageUrl  : null,
  _exact    : null,
  _name     : null,


  constructor : function(match)
  {
    this.base(['loaded', 'notLoaded']);

    this._pageUrl = this._baseUrl + match.href
      .replace(this._baseUrl, '')
      .replace(this._baseUrl.split(':', 2)[1], '');
    
    this._exact   = match.dist == 0;
    this._name    = match.name;

    GM_xmlhttpRequest({
      'method'  : 'get',
      'url'     : this._pageUrl,
      'onload'  : this._onLoad.bind(this),
      'onerror' : this.notify.bind(this, 'notLoaded')
    });
  },
  
  isExact : function()
  {
    return this._exact;
  },
  
  getName : function()
  {
    return this._name;
  },

  _onLoad : function(response)
  {
    this._document = jQuery(lesp.mangleHtml(response.responseText));
    this.notify('loaded');
  },

  getPageUrl : function()
  {
    return this._pageUrl;
  }

});

/**
 * Base manager.
 */
lesp.abstract.Manager = lesp.Target.extend({

  _track             : null,
  _searchUrlTemplate : null,
  _songUrlQuery      : null,


  constructor : function(artist, track)
  {
    this.base(['found', 'failed', 'loaded']);

    this._track = track;

    GM_xmlhttpRequest({
      'method'  : 'get',
      'url'     : this._searchUrlTemplate.format(encodeURIComponent(artist)),
      'onload'  : this._onLoad.bind(this),
      'onerror' : this.notify.bind(this, 'failed')
    });
  },

  _onLoad : function(response)
  {
    // be overriden
  },

  getTrackCandidates : function(trackAnchorCollection)
  {
    var trackName = this._track.toLowerCase();
    return trackAnchorCollection
      .map(function()
      {
        var item     = jQuery(this);
        var distance = item.text().toLowerCase().distance(trackName);
        
        // jQuery filters nulls and concats returned arrays
        return distance < 8 
          ? {
            'dist' : distance, 
            'href' : item.attr('href'), 
            'name' : item.text()
          } 
          : null;  
      })
      .get()
      .sort(function(a, b)
      {
        return a.dist - b.dist;
      });
  },
  
  getSongUrlQuery : function()
  {
    return this._songUrlQuery;
  }

});

/**
 * Abstract song renderer.
 */
lesp.abstract.Renderer = lesp.Target.extend({

  _manager : null,
  _song    : null,
  _title   : null,


  constructor : function(manager)
  {
    this.base(['completed', 'failed']);

    manager.attach('loaded', (function(target, song)
    {
      this._song = song;
      this.notify('completed');
    }).bind(this));
    manager.attach('failed', this.notify.bind(this, 'failed'));
  },

  getTitle : function()
  {
    return this._title; 
  },

  _createMatchNotice : function()
  {
    if(this._song.isExact())
    {
      return '';  
    }
    
    return [
      '<p style="color: #bbb; padding-top: 1em; text-align: center;">',
      'No exact match is found. Best match is {0}.'.format(this._song.getName()),
      '</p>'
    ].join('');
  },
  
  render : function()
  {
    // be overriden
  }

});


namespace('lesp.songfacts');

/**
 * Songfacts' song page. Retrives facts and comments.
 */
lesp.songfacts.Song = lesp.abstract.Song.extend({

  _baseUrl  : 'http://www.songfacts.com',


  getFacts : function()
  {
    if(!this._document)
    {
      throw new Error('Song document is not yet ready');
    }

    return this._document.find('ul.factsullist-sf li div.inner').map(function()
    {
      var fact = jQuery(this);
      fact.find('.trigger-credits').remove();

      fact.find('.creditsdiv .wrapper b').remove();
      var credit = fact.find('.creditsdiv .wrapper').text().replace(':', '').trim();
           
      fact.find('.creditsdiv').remove();
      var factHtml = fact.html().trim();
      
      return '<p>{0}</p><p style="color: #696969; margin: 0 0 1em 0;">{1}</p>'.format(factHtml, credit);
    }).get(); 
  },
  
  getComments : function()
  {
    if(!this._document)
    {
      throw new Error('Song document is not yet ready');
    }

    return this._document.find('div.sfdetail-comments .comment-holder').map(function()
    {
      return jQuery(this).html();
    }).get();
  }

});

/**
 * Songfacts' manager. Searches for a song of given artist.
 */
lesp.songfacts.Manager = lesp.abstract.Manager.extend({

  _searchUrlTemplate : 'http://www.songfacts.com/search-artist-1.php?{0}',
  _baseUrl           : 'http://www.songfacts.com',  


  _onLoad : function(response)
  {
    var contents = jQuery(lesp.mangleHtml(response.responseText));
    var artist   = contents.find('.search-holder ul.songullist-blue li a:eq(0)');

    if(artist.length)
    {
      var url = artist.attr('href');
      if(url.indexOf(this._baseUrl) != 0)
      {
        url = this._baseUrl + url;
      }
      
      GM_xmlhttpRequest({
        'method'  : 'get',
        'url'     : url,
        'onload'  : this._onArtistSongsLoad.bind(this),
        'onerror' : this.notify.bind(this, 'failed')
      });
    }
    else
    {
      this.notify('failed');
    }
  },
  
  _onArtistSongsLoad : function(response)
  {
    if(response.finalUrl.indexOf(this._searchUrlTemplate.format('')) == 0)
    {
      this.notify('failed');
    }
    else
    {
      var contents   = jQuery(lesp.mangleHtml(response.responseText));
      var candidates = this.getTrackCandidates(contents.find('ul.songullist-orange li a'));
      if(candidates.length)
      {
        this._songUrlQuery = candidates[0].href;

        var song = new lesp.songfacts.Song(candidates[0]);
        song.attach('loaded',    this.notify.bind(this, 'loaded', song));
        song.attach('notLoaded', this.notify.bind(this, 'failed'));

        this.notify('found');
      }
      else
      {
        this.notify('failed');
      }
    }
  }

});

/**
 * Songfacts' song renderer. Creates ready DOM node of facts and comment.
 */
lesp.songfacts.Renderer = lesp.abstract.Renderer.extend({

  _title : 'SongFacts',


  _createFacts : function()
  {
    var facts = this._song.getFacts().map(function(fact)
    {
      return '<li>{0}</li>'.format(fact);
    }).join('');
    
    return facts 
      ? [
        '<ul style="margin: 1em 2em 1em 0;">',
        facts,
        '</ul>'
      ].join('')
      : '';
  },

  _createMenu : function()
  {
    var comments = this._song.getComments().length;
    var pageUrl  = this._song.getPageUrl();
    var result   = jQuery('<div style="text-align: right; padding: 5px 30px 10px 0;"/>')
      .append(comments 
        ? '<a href="#songfacts-commnets" id="songfacts-commnets" class="show-comments">' +
            'Show comments</a>' 
        : '')
      .append(comments ? '<span style="padding: 10px;">•</span>' : '')
      .append(('<a href="{0}" class="external-link" target="_blank">' +
        'SongFacts page</a>').format(pageUrl));

    result.find('a.show-comments').click(function()
    {
      var anchor = jQuery(this);

      anchor.parent()
        .next().toggle()
        .next().toggle();

      anchor.text(anchor.data('shown') ? 'Show comments' : 'Hide comments');
      anchor.data('shown', !anchor.data('shown'));
    });

    return result;
  },

  _createComments : function()
  {
    var comments = this._song.getComments().map(function(comment)
    {
      comment = jQuery('<div>{0}</div>'.format(comment))
        .find('span:eq(1)').css('color', '#696969')
        .prepend('<br/>').parent()
        .append('<hr/>').parent()
        .html();
        
      return '<li>{0}</li>'.format(comment);
    }).join('');
    
    return comments 
      ? [
        '<ul style="display:none; overflow:auto; height:450px; ',
          'margin-right: 30px; padding-right: 10px;">',
        comments,
        '</ul>'
      ].join('') 
      : '';
  },

  render : function()
  {
    return jQuery('<div/>')
      .append(this._createMatchNotice())
      .append(this._createFacts())
      .append(this._createMenu())
      .append(this._createComments());
  }

});


namespace('lesp.songmeanings');

/**
 * SongMeanings' manager. Searches for a song of given artist.
 */
lesp.songmeanings.Manager = lesp.abstract.Manager.extend({

  _searchUrlTemplate : 'http://songmeanings.com/query/?q={0}&type=artists',
  _baseUrl           : 'http://songmeanings.com',


  _onLoad : function(response)
  {
    if(response.finalUrl.indexOf(this._baseUrl + '/artist') == 0)
    {
      // exact match redirect
      this._onArtistSongsLoad(response);
    }
    else if(response.finalUrl.indexOf('https://www.google') == 0)
    {
      // google search fallback
      var contents = jQuery(lesp.mangleHtml(response.responseText));
      var search   = '{0}/artist'.format(this._baseUrl.replace('http://', ''));
      var links    = contents.find('cite').get().filter((function(element)
      {
        return jQuery(element).text().indexOf(search) == 0;
      }).bind(this));
      
      if(links.length)
      {
        GM_xmlhttpRequest({
          'method'  : 'get',
          'url'     : 'http://' + jQuery(links[0]).text(),
          'onload'  : this._onArtistSongsLoad.bind(this),
          'onerror' : this.notify.bind(this, 'failed')
        });
      }
      else
      {
        this.notify('failed');
      }
    }
    else
    {
      var contents = jQuery(lesp.mangleHtml(response.responseText));
      var artist   = contents.find('#content-big tr.item td a:eq(0)');
      if(artist.length)
      {
        var url = this._baseUrl + artist.attr('href')
          .replace(this._baseUrl, '')                   // if href was already absolute w/ schema
          .replace(this._baseUrl.split(':', 2)[1], ''); // if href was already abolsute w/o schema
        
        GM_xmlhttpRequest({
          'method'  : 'get',
          'url'     : url,
          'onload'  : this._onArtistSongsLoad.bind(this),
          'onerror' : this.notify.bind(this, 'failed')
        });
      }
      else
      {
        this.notify('failed');
      }
    }
  },

  _onArtistSongsLoad : function(response)
  {
    var contents   = jQuery(lesp.mangleHtml(response.responseText));
    var candidates = this.getTrackCandidates(contents.find('#songslist td:not(.comments) a'));
    if(candidates.length)
    {
      this._songUrlQuery = this._baseUrl + candidates[0].href
        .replace(this._baseUrl, '')                   // if href was already absolute w/ schema
        .replace(this._baseUrl.split(':', 2)[1], ''); // if href was already abolsute w/o schema
          
      var song = new lesp.songmeanings.Song(candidates[0]);
      song.attach('loaded',    this.notify.bind(this, 'loaded', song));
      song.attach('notLoaded', this.notify.bind(this, 'failed'));

      this.notify('found');
    }
    else
    {
      this.notify('failed');
    }
  }

});

/**
 * SongMeanings' song page. Retrives lyrics.
 */
lesp.songmeanings.Song = lesp.abstract.Song.extend({

  _baseUrl         : 'http://songmeanings.com',
  _commentCache    : null,
  _commentDocument : null, 


  getLyrics : function()
  {
    if(!this._document)
    {
      throw new Error('Song document is not yet ready');
    }

    var result = this._document.find('div.lyric-box');
    result.find('div').remove(); // button container
    
    var text = result.text().replace(/�/g, "'"); 

    return text;
  },
  
  _onLoad : function(response)
  {
    this._document = jQuery(lesp.mangleHtml(response.responseText));
    
    GM_xmlhttpRequest({
      'method'  : 'post',
      'url'     : this._pageUrl,
      'data'    : 'command=loadComments',
      'headers' : {
        'X-Requested-With' : 'XMLHttpRequest',
        'Content-Type'     : 'application/x-www-form-urlencoded'
      },
      'onload'  : (function(response)
      {
        this._commentDocument = jQuery('<div>{0}</div>'.format(response.responseText));
        this.notify('loaded');
      }).bind(this),
      'onerror' : this.notify.bind(this, 'notLoaded')
    });
  },
  
  getCommentCount : function()
  {
    var match = /\d+/.exec(this._document.find('#header-comments-counter').text());
    
    return match ? Number(match[0]) : 0; 
  },
  
  getFirstComments : function()
  {
    if(!this._commentDocument)
    {
      throw new Error('Comment document is not yet ready');
    }
    
    if(this._commentCache)
    {
      return this._commentCache; 
    }
    
    return this._commentCache = this._commentDocument.find('li div.text').map(function()
    {
      var comment = jQuery(this);
      
      var user = comment.find('.sign a.author:eq(0)').text();
      var date = comment.find('.sign em.date:eq(0)').text();
      var text = comment.contents().map(function()
      {
        if(this.nodeName == '#text')
        {
          return jQuery.trim(this.wholeText);
        }
        else if(this.nodeName == 'BR')
        {
          return '<br/>';
        }
      }).get().join('');
      
      return {
        'user' : user,
        'date' : date,
        'text' : text
      };
    }).get();
  }

});

/**
 * SongMeanings' song renderer. Creates ready DOM node for lyrics.
 */
lesp.songmeanings.Renderer = lesp.abstract.Renderer.extend({

  _title : 'SongMeanings',


  _createLyrics : function()
  {
    return jQuery('<div/>')
      .append(
        jQuery('<div style="margin-top: 15px" class="lyrics-snippet">')
          .html(this._song.getLyrics()
            .replace(/^[\s]+/g, '<br/>')
            .replace(/[\s]+$/g, '<br/>')
            .replace(/\n/g, '<br/>')
          )
      )
      .html(); // doesn't include outer div
  },

  _createMenu : function()
  {
    var result = jQuery('<div/>').css({
      'text-align' : 'right',
      'padding'    : '5px 30px 10px 0'
    });
    
    var commentsTotal  = this._song.getCommentCount();
    var commentPostfix = ''; 
    if(commentsTotal)
    {
      var commentsLoaded = this._song.getFirstComments().length;
      if(commentsTotal > commentsLoaded)
      {
        commentPostfix = ' ({0}/{1})'.format(commentsLoaded, commentsTotal);
      }
    
      result.append('<a href="#songmeanings-commnets" class="show-comments" ' +
         'id="songmeanings-commnets">Show comments{0}</a>'.format(commentPostfix));
      result.append('<span style="padding: 10px;">•</span>');
    }
    
    result.append(('<a href="{0}" class="external-link" target="_blank">' +
      'SongMeanings page</a>').format(this._song.getPageUrl()));

    result.find('a.show-comments').click(function()
    {
      var anchor = jQuery(this);

      anchor.parent()
        .next().toggle()
        .next().toggle();

      anchor.text((anchor.data('shown') ? 'Show comments' : 'Hide comments') + commentPostfix);
      anchor.data('shown', !anchor.data('shown'));
    });

    return result;
  },

  _createComments : function()
  {
    var comments = this._song.getFirstComments().map(function(comment)
    {
      var template = [
        '<li>',
        '<div>{0}</div>',
        '<span style="color: #696969">- {1} {2}</span>',
        '<hr/>',
        '</li>'
      ].join(''); 
      return template.format(comment.text, comment.user, comment.date);
    }).join('');
    
    return comments 
      ? [
        '<ul style="display:none; overflow:auto; height:450px; ',
          'margin-right: 30px; padding-right: 10px;">',
        comments,
        '</ul>'
      ].join('')
      : '';
  },
  
  render : function()
  {
    return jQuery('<div/>')
      .append(this._createMatchNotice())
      .append(this._createLyrics())
      .append(this._createMenu())
      .append(this._createComments());
  }

});


namespace('lesp.application');

/**
 * Main interface renderer.
 */
lesp.application.Renderer = Base.extend({

  add : function(renderer)
  {
    var progress = document.createElement('img');
    progress.id  = new Date().valueOf();
    progress.src = GM_getResourceURL('progress');
    progress.style.padding = '15px'; 
    
    var container = jQuery('<section/>').css({
        'width' : '800px',
        'float' : 'left'
      })
      .append('<h2>{0}</h2>'.format(renderer.getTitle()))
      .append(progress)
      .appendTo('.wiki-section');
      
    renderer.attach('completed', function()
    {
      container.find('#' + progress.id).remove();
      container.append(renderer.render());
    });
    renderer.attach('failed', function()
    {
      container.find('#' + progress.id).remove();
      container.append('<p>Not found</p>');
    });
  }

});

/**
 * Determines whether the page is a track page.
 */
lesp.application.TrackPage = Base.extend({
  
  pathname : null,
  
  _positiveRe : /^\/music\/([^\/]+)\/([^\/]+)\/([^\/]+)$/,
  _negativeRe : /^\/music.*\/\+/,
  
  
  constructor : function(pathname)
  {
    this.pathname = pathname;
  },
  
  isTrackPage : function()
  {
    return !this._negativeRe.test(this.pathname) && this._positiveRe.test(this.pathname);
  },
  
  parseTrackPage : function()
  {
    if(!this.isTrackPage())
    {
      throw Error('Pathname is not of a track page');
    }
    
    var match = this._positiveRe.exec(this.pathname);
    match = match.map(function(item)
    {
      return decodeURIComponent(item.replace(/\+/g, '%20'));
    });
    
    return {
      'artist' : match[1],
      'album'  : match[2],
      'track'  : match[3]
    };
  }
  
});

/**
 * Main manager.
 */
lesp.application.Application = Base.extend({
  
  _isFirstPage : true,
  
  _pageToRender : null,
  
  _mainRenderer : null,
  _pageChangeObserver : null,
  

  _renderTrackPage : function(page)
  {
    var artist = page['artist'];
    var track  = page['track'];
  
    var providerRendererList = [
      new lesp.songfacts.Renderer(new lesp.songfacts.Manager(artist, track)),
      new lesp.songmeanings.Renderer(new lesp.songmeanings.Manager(artist, track))
    ];
    providerRendererList.forEach(this._mainRenderer.add, this._mainRenderer);
    
    if('ga' in unsafeWindow)
    {
      unsafeWindow.ga('lesp.send', 'pageview');
    }
  },
  
  _onRenderTargetPage : function()
  {
    if(this._pageToRender)
    {
      this._renderTrackPage(this._pageToRender);
      this._pageToRender = null;
    }
  },
  
  _onChangeLocation : function()
  {
    var page = new lesp.application.TrackPage(unsafeWindow.location.pathname);
    if(!page.isTrackPage())
    {
      return;
    }
    var track = page.parseTrackPage();
    
    // First page is just synchronous HTML
    if(this._isFirstPage)
    {
      this._isFirstPage = false;
      this._renderTrackPage(track);
    }
    else
    {
      this._pageToRender = track;
    }
  },
  
  _setupUrlPolling : function()
  {
    var previousUrl = null;
    setInterval(function() 
    {
      if(previousUrl != unsafeWindow.location.href) 
      {
          previousUrl = unsafeWindow.location.href;
          this._onChangeLocation();
      }
    }.bind(this), 250);
  },
  
  main : function()
  {
    this._mainRenderer = new lesp.application.Renderer();
    
    this._pageChangeObserver = new MutationObserver(this._onRenderTargetPage.bind(this));
    this._pageChangeObserver.observe(document.querySelector('#content'), {'childList': true});
    
    this._setupUrlPolling();
    
    // purpose is to debug include and exclude regexes, look at usage
    if('ga' in unsafeWindow)
    {
      unsafeWindow.ga('create', 'UA-29858354-3', 'auto', 'lesp');
    }
  }

});


new lesp.application.Application().main();