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();