This week I've done a few blog posts (part one and part two) about MP3 and ID3 parsing in PhoneGap/Cordova applications. Today I'm updating the application again - this time to support album art. Let's look at the results in the simulator first and then I'll walk you through the code.

First - I updated my sample music a bit. My 5 year old loves the Daisy Chainsaw track:

Screen Shot 2015-05-01 at 2.25.27 PM

And here is the detail view - now with album art:

Screen Shot 2015-05-01 at 2.26.14 PM

Screen Shot 2015-05-01 at 2.26.24 PM

Ok, so how did I do this? While ID3 data can actually include album art (see the docs for the JavaScript library I use), it didn't seem like any of my files actually had this data. I made the call that - probably - most files do not. I don't have any scientific data to back this up, but I decided to make use of the last.fm API. The API was super easy to use. Like - "Wait, it worked on my first try?" easy. Given that you know an artist and an album, you can use the album.getInfo call to fetch data about the album. This includes multiple different sized images.

Of course, the issue is that each of these API calls is asynchronous. Our MP3 service is already handling the ID3 lookup asynchronously. If you remember, I had to single thread it due to memory issues. But the API calls are jut http calls so running multiple in parallel shouldn't be a problem.

However...

Given that you may have multiple MP3s from the same album, we can improve performance by not requesting the same album cover once we've made one initial request for it.

Ok... so let's take a look at the new services file.

angular.module('starter.services', [])

.factory('MP3Service', function($q,$cordovaFile,$http) {
	
	//root of where my stuff is
	console.log('running service');
	var items = [];

	function getAll() {
		var rootFolder = cordova.file.applicationDirectory;
		var mp3Loc = 'music/';
		//where the music is
		var mp3Folder = rootFolder + 'www/' + mp3Loc;
		console.log(mp3Folder);

		var deferred = $q.defer();

		window.resolveLocalFileSystemURL(mp3Folder, function(dir) {
			var reader = dir.createReader();
			//read it
			reader.readEntries(function(entries) {
					console.log("readEntries");
					console.dir(entries);

					var data = [];

					var process = function(index, cb) {
						var entry = entries[index];
						var name = entry.name;
						entry.file(function(file) {

							ID3.loadTags(entry.name,function() {
								var tags = ID3.getAllTags(name);
								//default to filename
								var title = entry.name;
								if(tags.title) title = tags.title;
								//for now - not optimal to include music here, will change later
								data.push({name:title, tags:tags, url:mp3Loc+entry.name});
								if(index+1 < entries.length) {
									process(++index, cb);
								} else {
									cb(data);
								}
							},{
								dataReader:FileAPIReader(file)
							});

						});

					};

					process(0, function(data) {
						console.log("Done processing");
						console.dir(data[0]);
						items = data;
						// New logic - now we get album art
						var defs = [];
						//remember artist + album
						var albums = {};
						
						for(var i=0;i<items.length;i++) {
							var album = items[i].tags.album;
							var artist = items[i].tags.artist;
							console.log("album="+album+" artist="+artist);
							if(albums[album+" "+artist]) {
								console.log("get from album cache");
								var def =  $q.defer();
								def.resolve({cache:album+" "+artist});
								defs.push(def.promise);
							} else {
								albums[album+" "+artist] = 1;
								defs.push($http.get("http://ws.audioscrobbler.com/2.0/?method=album.getinfo&artist="+encodeURI(artist)+"&album="+encodeURI(album)+"&api_key=5poo53&format=json"));
							}
						}
						$q.all(defs).then(function(res) {
							console.log("in the q all");
							for(var i=0;i<res.length;i++) {
								console.log(i, res[i]);
								//if we marked it as 'from cache', check cache
								if(res[i].cache) {
									console.log('setting from cache '+albums[res[i].cache])
									items[i].image = albums[res[i].cache];
								} else {
									var result = res[i].data;
									//potential match at result.album.image
									if(result.album && result.album.image) {
										items[i].image = result.album.image[3]["#text"];
									} else {
										items[i].image = "";
									}
									albums[items[i].tags.album+" "+items[i].tags.artist] = items[i].image;
								}
							}
							
							deferred.resolve(items);
						});
					});


			});

		}, function(err) {
			deferred.reject(err);
		});


		return deferred.promise;
		
	}

	function getOne(id) {
		var deferred = $q.defer();
		deferred.resolve(items[id]);

		return deferred.promise;
	}

	var media;
	function play(l) {
		if(media) { media.stop(); media.release(); }
		media = new Media(l,function() {}, function(err) { console.dir(err);});
		media.play();
	}
	
	return {
		getAll:getAll,
		getOne:getOne,
		play:play
	};
  
});

Normally I trim out the console.log messages as noise, but I kept them in due to the complexity of this service. The important bits begin in the process(0, function(data)) callback. The general idea is this:

  1. Loop over all the MP3s.
  2. Get the album and artist. (This needs to be improved to see if the tags exist.)
  3. Check the albums object to see if we have already fetched it. Note - at this moment, the cache object is just a flag. The initial request isn't actually done yet. But we want to know that we've already done a request for that album.
  4. If we aren't, hit last.fm. Note that the API key should be stripped out and put into a constants block. I've temporarily changed the key above to a non-legit value.
  5. We've created an array of deferred objects. These represent the async operations (and yes, some aren't async, which is ok, we can still use deferreds for them). I can then use $q.all to say, "Do this crap when ALL of them are done."
  6. In that handler, I see if I've marked this as an item that should use the cache. In theory, this will never be run before an item that used the cache, so I check the albums object, which now has a real value in it, and use that.
  7. If this isn't a "use the cache item", I fetch out the image from the result data from last.fm and store the cache.

And that's it. I then just updated the view to make use of the image. I've updated the GitHub repo with this version: https://github.com/cfjedimaster/Cordova-Examples/tree/master/mp3reader