NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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();