const CLASSNAME = "SKAS_AudioscrobblerService"; const CONTRACTID = "@skrul.com/audioscrobbler-service;1"; const CID = Components.ID("{bafaaa31-9811-4bc6-9aec-29589152ad18}"); const Cc = Components.classes; const Ci = Components.interfaces; const WS_USER_URL = "http://ws.audioscrobbler.com/1.0/user/"; const DEBUG = false; // Add some queue like methods to the array object so I don't get confused Array.prototype.enqueue = function(o) { return this.unshift(o); }; Array.prototype.dequeue = function() { return this.pop(); }; var audioscrobblerService = { // Queue of pending sbIPlayHistoryItems waiting to be sent to the mothership // Old items must queued before newer items or tracking the last sent item // by item.idx will not work. _queue : [], // The URL that items are posted to, received from the handshake _submitUrl : null, // Holds the computed token used for authorization after the handshake _token : null, // Guards against multiple queue runs happening at the same time _running : false, // Feeds that are pulled from last.fm _userFeeds : [ { name: "recenttracks", url: "recenttracks.xml", xslt: "recenttracks.xslt", transformer: null, data: null, lastUpdate: null } ], // Observers get notified of user feed updates _observers : [] }; function d(s) { if(DEBUG) { dump(">>>>>>>>>>>>> audioscrobblerService: " + s + "\n"); Cc["@mozilla.org/consoleservice;1"] .getService(Ci.nsIConsoleService) .logStringMessage("audioscrobblerService: " + s); } } // nsISupports audioscrobblerService.QueryInterface = function(aIID) { if(!aIID.equals(Ci.nsISupports) && !aIID.equals(Ci.sbIAudioscrobblerService) && !aIID.equals(Ci.sbIPlayHistoryObserver) && !aIID.equals(Ci.nsIObserver)) throw Components.results.NS_ERROR_NO_INTERFACE; return this; } // nsIObserver audioscrobblerService.observe = function(aSubject, aTopic, aData) { if(aTopic == "app-startup") { var os = Cc["@mozilla.org/observer-service;1"] .getService(Ci.nsIObserverService); os.addObserver(this, "profile-after-change", false); os.addObserver(this, "profile-before-change", false); } if(aTopic == "profile-after-change") { this._startup(); } if(aTopic == "profile-before-change") { this._shutdown(); } if(aTopic == "nsPref:changed") { // If the credentials are changed, deauthorize if(aData == "username" || aData == "lastFmUsername" || aData == "password") { this._token = null; this._submitUrl = null; this._updateStatus(); } } } // sbIPlayHistoryObserver audioscrobblerService.onTrackChange = function(aPrevious, aCurrent) { if(aPrevious && this._shouldSend(aPrevious)) { // Enqueue the item to be sent this._queue.enqueue(aPrevious); } // Do a queue run regardless if the track was scrobbler-worthy this._sendNextItem(true); } // sbIAudioscrobblerService audioscrobblerService.hashPassword = function(aPassword) { return MD5(aPassword); } audioscrobblerService.addObserver = function(aObserver) { if(this._observers.some(function(o) { return (o == aObserver); })) { return; } this._observers.push(aObserver); } audioscrobblerService.removeObserver = function(aObserver) { this._observers = this._observers.filter( function(o) { return (o != aObserver); }); } audioscrobblerService.getUserFeedData = function(aName) { for(var i = 0; i < this._userFeeds.length; i++) { var feed = this._userFeeds[i]; if(feed.name = aName) { return feed.data; } } throw Error("User feed '" + aName + "' not found"); } audioscrobblerService._startup = function() { var prefService = Cc["@mozilla.org/preferences-service;1"] .getService(Ci.nsIPrefService); this._preferences = prefService.getBranch("extensions.audioscrobbler."); // Update the status pref before we attach our observer this._updateStatus(); this._preferences.QueryInterface(Ci.nsIPrefBranch2) .addObserver("", this, false); this._phs = Cc["@skrul.com/songbird-play-history-service;1"] .getService(Ci.sbIPlayHistoryService); this._phs.addObserver(this); // Get the install dir so we can find our stylesheets var em = Cc["@mozilla.org/extensions/manager;1"] .getService(Ci.nsIExtensionManager); var il = em.getInstallLocation("audioscrobbler@skrul.com"); this._installLocation = il.location; this._installLocation.append("audioscrobbler@skrul.com"); var parser = Cc["@mozilla.org/xmlextras/domparser;1"] .getService(Ci.nsIDOMParser); // Set up the user feed transformers for(var i = 0; i < this._userFeeds.length; i++) { var feed = this._userFeeds[i]; var path = this._installLocation.clone(); path.append("xslt"); path.append(feed.xslt); var stream = Cc["@mozilla.org/network/file-input-stream;1"] .createInstance(Ci.nsIFileInputStream); stream.init(path, 1, 0, false); var xslt = parser.parseFromStream(stream, null, path.fileSize, "text/xml"); feed.transformer = Cc["@mozilla.org/document-transformer;1?type=xslt"] .createInstance(Ci.nsIXSLTProcessor); feed.transformer.importStylesheet(xslt); stream.close(); } // Get items from the play history service that were not sent in the last // session and put them on the queue. var lastSentIdx = this._preferences.getIntPref("lastSent"); var count = {}; var a = this._phs.getItems("idx > " + lastSentIdx, "idx asc", count); a.forEach(function(item) { if(this._shouldSend(item)) { this._queue.enqueue(item); } }, this); // Send the old stuff this._sendNextItem(true); // Refresh our feeds this._refreshUserFeeds(0); } audioscrobblerService._shutdown = function() { this._preferences.QueryInterface(Ci.nsIPrefBranch2) .removeObserver("", this); this._phs.removeObserver(this); } audioscrobblerService._sendNextItem = function(isNewRun) { // If there is no username, don't bother if(this.username == "") { this._running = false; return; } try { // If the queue run is aready in progress and this is a new run request, // ignore it if(this._running) { if(isNewRun) { return; } else { d("queue run continues.."); } } else { if(isNewRun) { this._running = true; d("queue run started"); } else { throw Error("Not running and not a new run"); } } // If the stauts is BADPASS, we need to wait for the user to enter new // credentials (which changes the status as well) if(this._preferences.getIntPref("status") == Ci.sbIAudioscrobblerService.STATUS_BADPASS) { this._running = false; return; } // Don't bother if the queue is empty if(this._queue.length == 0) { if(!isNewRun) { // If we just finished a queue run, update our feeds this._refreshUserFeeds(0); d("queue run complete"); } else { d("queue empty, run stopped"); } this._running = false; return; } // Attempt to authenticate if we have not done so. if(!this._token) { var url = this._preferences.getCharPref("server"); var parameters = { hs : "true", p : "1.1", c : "tst", v : "1.0", u : this.username } this._sendAsynchRequest(url, "GET", parameters, function(response) { try { if(response.status == "UPTODATE" || response.status == "UPDATE") { this._token = MD5(this.password + response.challenge); this._submitUrl = response.submitUrl; // Now that we're authenticated, send the next item again this._sendNextItem(false); } else { // We got a bad response from the server. Just log it and don't // continue sending anything Components.reportError("Bad status for challenge response: " + response.status + " reason: " + response.reason); this._running = false; } } catch(e) { this._running = false; throw(e); } }); // Don't continue the function, the handler will return; } // Dequeue an item and send it var item = this._queue.dequeue(); var parameters = { u : this.username, s : this._token, "a[0]" : item.metadataArtist, "t[0]" : item.metadataTitle, "b[0]" : item.metadataAlbum, "m[0]" : "", "l[0]" : item.metadataLength / 1000, "i[0]" : FormatDate(new Date(item.startTime)) } this._sendAsynchRequest(this._submitUrl, "POST", parameters, function(response) { try { if(response.status != "OK") { // If we get a bad auth or bad user, put the item back on the end of the // queue and set the new status this._queue.push(item); if(response.status == "BADAUTH" || response.status == "BADUSER") { this._token = null; this._submitUrl = null; this._preferences .setIntPref("status", Ci.sbIAudioscrobblerService.STATUS_BADPASS); } else { // Unexpected response, log it. Since we need to send songs in order, // we can't continue // TODO: Maybe discard an item if it gets too many errors? Components.reportError("Bad status sending song: " + response.status + " reason: " + response.reason + " request: " + response.request + " responseText: " + response.responseText); } // We can't continue, so just return this._running = false; return; } // Update the last sent pref and continue sending items this._preferences.setIntPref("lastSent", item.idx); this._sendNextItem(false); } catch(e) { this._running = false; throw(e); } }); } catch(e) { // Some error happened, change the running status and rethrow this._running = false; throw(e); } } audioscrobblerService._sendAsynchRequest = function(url, type, parameters, responseHandler) { // Prepare the data for the http request var data = ""; for(var p in parameters) { data += p + "=" + encodeURIComponent(parameters[p]) + "&"; } data = data.substr(0, data.length - 1); d("data: " + data); var xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"] .createInstance(Ci.nsIXMLHttpRequest); // Define the response hander for the http request. It will parse the // response and call the supplied responseHandler var outer = this; var response = { request: data, responseText: null }; // Note that we need to QI here becasue without it, it will fail when // this method is invoked at profile startup time xhr.QueryInterface(Ci.nsIJSXMLHttpRequest).onload = function() { var responseText = xhr.responseText; d("response: " + responseText); response.responseText = responseText; var lines = responseText.split("\n"); if(lines.length == 0) { response.status = "Invalid response"; response.reason = responseText; } else { // Read the status and other status specific lines var status = lines.shift().split(" ", 2); response.status = status[0]; switch(status[0]) { case "UPTODATE": response.challenge = lines.shift(); response.submitUrl = lines.shift(); break; case "UPDATE": response.updateUrl = status[1]; response.challenge = lines.shift(); response.submitUrl = lines.shift(); break; case "FAILED": response.reason = status[1]; break; case "BADUSER": case "BADAUTH": case "OK": // No additional info for these cases break; default: response.status = "Invalid response"; response.reason = responseText; } // Process the interval if(lines.length > 0) { var interval = lines.shift().split(" ", 2); if(interval[0] == "INTERVAL") { response.interval = interval[1]; } } // Call the handler responseHandler.apply(outer, [response]); } }; xhr.QueryInterface(Ci.nsIJSXMLHttpRequest).onerror = function() { try { response.status = "ERROR"; response.reason = xhr.status + " " + xhr.statusText; } catch(e) { // If reading the status throws an error, then we never even connected response.status = "CONNERROR"; response.reason = "Connection error"; } responseHandler.apply(outer, [response]); }; try { if(type == "GET") { xhr.open(type, url + "?" + data, true); xhr.send(null); } else { xhr.open(type, url, true); xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); xhr.send(data); } } catch(e) { response.status = "Exception thrown"; response.reason = e; responseHandler.apply(outer, [response]); } } audioscrobblerService._shouldSend = function(item) { // Check to see if the previous track is scrobbler-worthy. The rules are: // - Each song should be posted to the server when it is 50% or 240 seconds // complete, whichever comes first. // - If a user seeks (i.e. manually changes position) within a song before // the song is due to be submitted, do not submit that song. // - Songs with a duration of less than 30 seconds should not be submitted. // - If a MusicBrainz ID is present in the file (as defined here), then you // should send it. // - If a user is playing a stream instead of a regular file, do not submit // that stream/song. // // This isn't perfect because we don't really know if the user seeked or not. // The user could seek through the whole song, pause for 240 seconds, and // skip it and it would be sent :( d("evalating " + item); if(item.metadataUrl.indexOf("http") != 0) { var songLength = item.metadataLength / 1000; // If the song is not less than 30 seconds... if(songLength >= 30) { // The required listen time is half the song length or 240 seconds, // whichever is less. var requiredListenTime = songLength / 2; if(requiredListenTime > 240) { requiredListenTime = 240; } // Has the user listened past the required listen time? if(item.stopPosition / 1000 >= requiredListenTime) { // To prevent songs that were mostly seeked through from being // submitted, make sure the total track time isn't less than required // listen time var totalTrackTime = (item.stopTime - item.startTime) / 1000; if(totalTrackTime >= requiredListenTime) { // You are scrobbler worthy, rejoyce return true; } else { d("not sent: totalTrackTime " + totalTrackTime + " < " + requiredListenTime); } } else { d("not sent: stop position " + item.stopPosition / 1000 + " < " + requiredListenTime); } } else { d("not sent: songlength < 30"); } } else { d("not sent, is a stream"); } return false; } audioscrobblerService._refreshUserFeeds = function(index) { if(this.username == "") { return; } var feed = this._userFeeds[index]; var baseUrl = WS_USER_URL + this.username + "/"; var xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"] .createInstance(Ci.nsIXMLHttpRequest); var outer = this; xhr.QueryInterface(Ci.nsIJSXMLHttpRequest).onload = function() { if(xhr.status == 200 && xhr.responseXML) { feed.data = feed.transformer.transformToDocument(xhr.responseXML); feed.lastUpdate = new Date(); outer._notifyObservers(feed.data, "userfeed-updated", feed.name); index++; if(index < outer._userFeeds.length) { outer._refreshUserFeeds(index); } } else { Components.reportError("Bad feed response, status: " + xhr.status + " response: " + xhr.responseText); } }; xhr.QueryInterface(Ci.nsIJSXMLHttpRequest).onerror = function() { try { Components.reportError("ERROR: " + xhr.status + " " + xhr.statusText); } catch(e) { // If reading the status throws an error, then we never even connected Components.reportError("Connection Error"); } } xhr.open("GET", baseUrl + feed.url, true); xhr.send(null); } audioscrobblerService._updateStatus = function() { var val; if(this.username == "" || this.password == "") { val = Ci.sbIAudioscrobblerService.STATUS_NOCREDENTIALS; } else { val = Ci.sbIAudioscrobblerService.STATUS_READY; } this._preferences.setIntPref("status", val); return val; } audioscrobblerService._notifyObservers = function(aSubject, aTopic, aData) { for(var i = 0; i < this._observers.length; i++) { try { this._observers[i].observe(aSubject, aTopic, aData); } catch(e) { Components.reportError(e); } } } audioscrobblerService.__defineGetter__("username", function() { if(this._preferences.getCharPref("credentialsSource") == "custom") { return this._preferences.getCharPref("username"); } else { return this._preferences.getCharPref("lastFmUsername"); } }); audioscrobblerService.__defineGetter__("password", function() { if(this._preferences.getCharPref("credentialsSource") == "custom") { return this._preferences.getCharPref("password"); } else { // TODO: Look up last.fm password from password manager and hash it return ""; } }); function LOG(s) { dump("audioscrobblerService: " + s + "\n"); } function MD5(s) { var hash = Cc["@mozilla.org/security/hash;1"] .createInstance(Ci.nsICryptoHash); hash.init(Ci.nsICryptoHash.MD5); var ss = Cc["@mozilla.org/io/string-input-stream;1"] .createInstance(Ci.nsIStringInputStream); ss.setData(s, s.length); hash.updateFromStream(ss, s.length); return ToHex(hash.finish(false)).toLowerCase(); } function ToHex(s) { var hexchars = '0123456789ABCDEF'; var hexrep = new Array(s.length * 2); for (var i = 0; i < s.length; ++i) { hexrep[i * 2] = hexchars.charAt((s.charCodeAt(i) >> 4) & 0xf); hexrep[i * 2 + 1] = hexchars.charAt(s.charCodeAt(i) & 0xf); } return hexrep.join(""); } function FormatDate(d) { var s = d.getUTCFullYear() + "-"; s += pad(d.getUTCMonth() + 1) + "-"; s += pad(d.getUTCDate()) + " "; s += pad(d.getUTCHours()) + ":"; s += pad(d.getUTCMinutes()) + ":"; s += pad(d.getUTCSeconds()); return s; function pad(n) { return n < 10 ? "0" + n : n; } } /** * XPCOM Registration */ var Module = new Object(); Module.registerSelf = function(compMgr, fileSpec, location, type) { compMgr = compMgr.QueryInterface(Ci.nsIComponentRegistrar); compMgr.registerFactoryLocation(CID, CLASSNAME, CONTRACTID, fileSpec, location, type); var catMgr = Cc["@mozilla.org/categorymanager;1"] .getService(Ci.nsICategoryManager); catMgr.addCategoryEntry("app-startup", CLASSNAME, "service," + CONTRACTID, true, true); } Module.getClassObject = function(compMgr, cid, iid) { if(!cid.equals(CID)) { throw Components.results.NS_ERROR_NOT_IMPLEMENTED; } if(!iid.equals(Ci.nsIFactory)) { throw Components.results.NS_ERROR_NO_INTERFACE; } return Factory; } Module.canUnload = function(compMgr) { return true; } var Factory = {}; Factory.createInstance = function(outer, iid) { if(outer != null) { throw Components.results.NS_ERROR_NO_AGGREGATION; } return audioscrobblerService; } function NSGetModule(compMgr, fileSpec) { return Module; }