Month: <span>March 2014</span>

This is part 2 in a series of posts about creating a mobile web app for browsing music databases. Part 1 can be found here.

The first task in building the front end was testing my API to make sure I knew what was being returned by Rovi and that it had everything I wanted. I added some test JavaScript to the default MVC view that would call my API. It was a bit of trial and error going through the data and seeing where I needed to adjust my requests on the back end. My plan was to simply copy the test code into the official script files. The Rovi service itself is easy to use and well documented.

Next was setting up the base AngularJS implementation. I fired up Google to try and find a good online example of how to structure the app. The web site has a tutorial so I started stepping through it. But as I began to have questions on how to do certain things, and what the best practices are, I noticed the code I found online differed from what the tutorial was doing. More searching uncovered tools like angular-seed and angular-enterprise-seed. They were comprehensive but included way too much stuff to absorb for someone just learning the framework. They seem to be more for large scale web applications. I eventually came up with what looked like a good way to set up my module, controller, and service declarations, along with the source file structure to use. I followed suggestions from places like yeoman.io and various others found online. I don’t know if it’s exactly what is considered good by the Angular community but it’s close. My main app script ended up looking like this:

'use strict';

// The name of the module for the main app must match the ng-app attribute of the  tag in the
// startup page. The contents of the array are dependencies for the app:
//   ngRoute:                  Provides routing support
//   ngAnimate:                Provides animation support
//   ngSanitize:               Sanitizes text that contains markup before binding it to a view, needed
//                             for artist bios and album reviews which need to have line breaks and
//                             possibly hyperlinks to other views
//   jmdobry.angular-cache:    Custom caching implementation for use with the $http service
//   musicBrowserControllers:  Module which will hold all controllers for the app
var musicBrowserApp = angular.module('MusicBrowserApp', [
    'ngRoute',
    'ngAnimate',
    'ngSanitize',
    'ui.bootstrap',
    'jmdobry.angular-cache',
    'musicBrowserControllers'
]);

var musicBrowserControllers = angular.module('musicBrowserControllers', []);

// /                        Home page, currently redirected to the search page
// /search                  Page that shows the search UI
// /search/artist/          Shows the results of an artist search for
// /search/album/           Shows the results of an album search for
// /search/song/            Shows the results of a song search for
// /artist/                 Shows the details for the artist represented by
// /artist//full-bio        Shows only the complete bio for the artist represented by
// /album/                  Shows the details for the album represented by
// /album//full-review      Shows only the complete review for the album represented by
// /genre/                  Shows the details for the genre represented by
// /style/                  Shows the details for the style represented by
// /options                 Page for changing app options
musicBrowserApp.config(['$routeProvider', '$provide', function ($routeProvider, $provide) {
    $routeProvider.when('/', { templateUrl: 'views/search.html', controller: 'SearchCtrl', title: "Search" });
    $routeProvider.when('/search', { templateUrl: 'views/search.html', controller: 'SearchCtrl', title: "Search" });
    $routeProvider.when('/search/artist/:searchTerm', { templateUrl: 'views/artistSearch.html', controller: 'ArtistSearchCtrl', title: "Artist Search" });
    $routeProvider.when('/search/album/:searchTerm', { templateUrl: 'views/albumSearch.html', controller: 'AlbumSearchCtrl', title: "Album Search" });
    $routeProvider.when('/search/song/:searchTerm', { templateUrl: 'views/songSearch.html', controller: 'SongSearchCtrl', title: "Song Search" });
    $routeProvider.when('/artist/:id', { templateUrl: 'views/artist.html', controller: 'ArtistLookupCtrl', title: "Artist" });
    $routeProvider.when('/artist/:id/full-bio', { templateUrl: 'views/artistBio.html', controller: 'ArtistLookupCtrl', title: "Artist Bio" });
    $routeProvider.when('/album/:id', { templateUrl: 'views/album.html', controller: 'AlbumLookupCtrl', title: "Album" });
    $routeProvider.when('/album/:id/full-review', { templateUrl: 'views/albumReview.html', controller: 'AlbumLookupCtrl', title: "Album Review" });
    $routeProvider.when('/style/:id', { templateUrl: 'views/style.html', controller: 'StyleLookupCtrl', title: "Style" });
    $routeProvider.when('/genre/:id', { templateUrl: 'views/genre.html', controller: 'GenreLookupCtrl', title: "Genre" });
    $routeProvider.when('/options', { templateUrl: 'views/options.html', controller: 'OptionsCtrl', title: "Options" });
    $routeProvider.otherwise({ redirectTo: '/' });
}]);

// Any startup code needed by the app should go here
musicBrowserApp.run(['$rootScope', '$http', '$angularCacheFactory', function ($rootScope, $http, $angularCacheFactory) {
    $http.defaults.headers.common["Accept-Encoding"] = "gzip,deflate";

    $rootScope.$on("$routeChangeSuccess", function (event, currentRoute, previousRoute) {
        // Change page title based on the current route
        $rootScope.title = currentRoute.title;
    });

    // Create a custom cache for our data, and set the $http service to use it for its caching
    $angularCacheFactory('dataCache', {
        // Items added to this cache expire after 60 minutes
        maxAge: 3600000,
        // This cache will clear itself every two hours
        cacheFlushInterval: 12000000,
        // Items will be deleted from this cache right when they expire
        deleteOnExpire: 'aggressive'
    });

    $http.defaults.cache = $angularCacheFactory.get('dataCache');
}]);

What to say about AngularJS? I like it. The framework strives to make it easy to separate your app logic from your markup from your data access, and largely succeeds. It includes tons of built-in stuff to further that goal. I ended up creating two custom services, one for common code and one for data access. The latter looked like this:

musicBrowserApp.factory('mbData', ['mbCommon', '$http', function (mbCommon, $http) {
    var curInstance = this;

    Object.defineProperty(curInstance, "maxShortDescriptionLength", {
        value: 150,
        writable: false,
        enumerable: true,
        configurable: true
    });

    curInstance.searchForArtist = function (query, size, offset) {
        var url = "api/search/artist/" + encodeURIComponent(query) + "?size=" + size + "&offset=" + offset;

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);

                if (result.searchResponse) {
                    data.searchResult = result.searchResponse.results.map(function (element) {
                        setPrimaryImage(element, mbCommon.placeholderImageMedium);
                        return element;
                    });
                }
            });
    };
    
    curInstance.lookupArtist = function(id) {
        var url = "api/artist/" + encodeURIComponent(id);

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);
                var primaryImage;
                var formattedBioText;
                var mbConfig = mbCommon.getConfiguration();
                var styleIndex;

                for (var i = 0; i < result.name.musicGenres.length; i++) {
                    styleIndex = getIndexOfId(result.name.musicStyles, result.name.musicGenres[i].id);

                    if (styleIndex > -1) {
                        result.name.musicStyles.splice(styleIndex, 1);
                    }
                }

                if (result.name.discography) {
                    if (mbConfig && mbConfig.albumChrono) {
                        result.name.discography.reverse();
                    }

                    for (var i = 0; i < result.name.discography.length; i++) {
                        if (result.name.discography[i].year) {
                            result.name.discography[i].year = mbCommon.formatDate(result.name.discography[i].year, true);
                        }

                        if (result.name.discography[i].images && result.name.discography[i].images.length > 0) {
                            primaryImage = result.name.discography[i].images[0].url;
                        }
                        else {
                            primaryImage = mbCommon.placeholderImageSmall;
                        }

                        result.name.discography[i].primaryImage = primaryImage;
                        result.name.discography[i].formattedType = "Album"

                        if (result.name.discography[i].flags && result.name.discography[i].flags.indexOf("Compilation") > -1) {
                            result.name.discography[i].formattedType = "Compilation";
                        }

                        if (result.name.discography[i].type === "Single" || result.name.discography[i].type === "EP") {
                            result.name.discography[i].formattedType = "SingleOrEP";
                        }
                    }
                }

                if (result.name.isGroup) {
                    result.name.originLabel = "Formed:";
                    result.name.endLabel = "Disbanded:"
                }
                else {
                    result.name.originLabel = "Born:";
                    result.name.endLabel = "Died:"
                }

                if (result.name.active) {
                    for (var i = 0; i < result.name.active.length; i++) {
                        result.name.active[i] = mbCommon.formatDate(result.name.active[i], true);
                    }
                }

                if (result.name.birth) {
                    var newDate = mbCommon.formatDate(result.name.birth.date, false, true);

                    if (newDate === "") {
                        result.name.birth.date = "N/A";
                    }
                    else {
                        result.name.birth.date = newDate;
                    }
                }

                if (result.name.death) {
                    var newDate = mbCommon.formatDate(result.name.death.date, false, true);

                    if (newDate != "") {
                        result.name.death.date = newDate;
                    }
                }

                setPrimaryImage(result, mbCommon.placeholderImageLarge);

                if (result.name.musicBio) {
                    formattedBioText = replaceRoviLinks(result.name.musicBio.text);
                    formattedBioText = formattedBioText.split("\r\n").join("");
                    result.name.musicBioFormatted = formattedBioText;
                }

                if (result.name.headlineBio) {
                    result.name.headlineBioFormatted = replaceRoviLinks(result.name.headlineBio);
                }
                else {
                    if (result.name.musicBio) {
                        result.name.headlineBioFormatted = getShortDescription(result.name.musicBioFormatted);
                    }
                }

                data.lookupResult = result.name;
            })
    };

    curInstance.searchForAlbum = function (query, size, offset) {
        var url = "api/search/album/" + encodeURIComponent(query) + "?size=" + size + "&offset=" + offset;

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);

                if (result.searchResponse) {
                    data.searchResult = result.searchResponse.results.map(function (element) {
                        if (element.album.images && element.album.images.length > 0) {
                            element.album.primaryImage = element.album.images[0].url;
                        }
                        else {
                            element.album.primaryImage = mbCommon.placeholderImageMedium;
                        }

                        return element;
                    });
                }
            })
    };

    curInstance.lookupAlbum = function (id) {
        var url = "api/album/" + encodeURIComponent(id);

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);
                var primaryImage;
                var formattedReviewText;

                if (result.album.originalReleaseDate) {
                    result.album.originalReleaseDate = mbCommon.formatDate(result.album.originalReleaseDate, false, true);
                }

                if (result.album.duration) {
                    result.album.durationFormatted = mbCommon.formatDuration(result.album.duration);
                }

                if (result.album.images && result.album.images.length > 0) {
                    primaryImage = result.album.images[0].url;
                }
                else {
                    primaryImage = mbCommon.placeholderImageLarge;
                }

                result.album.primaryImage = primaryImage;

                if (result.album.primaryReview) {
                    formattedReviewText = replaceRoviLinks(result.album.primaryReview.text);
                    formattedBioText = formattedReviewText.split("\r\n").join("");
                    result.album.primaryReviewFormatted = formattedReviewText;
                }

                if (result.album.headlineReview) {
                    result.album.headlineReviewFormatted = replaceRoviLinks(result.album.headlineReview.text);
                }
                else {
                    if (result.album.primaryReview) {
                        result.album.headlineReviewFormatted = getShortDescription(result.album.primaryReviewFormatted);
                    }
                }

                if (result.album.tracks && result.album.tracks.length > 0) {
                    for (var i = 0; i < result.album.tracks.length; i++) {
                        result.album.tracks[i].durationFormatted = mbCommon.formatDuration(result.album.tracks[i].duration);
                    }
                }

                data.lookupResult = result.album;
            })
    };

    curInstance.searchForSong = function (query, size, offset) {
        var url = "api/search/song/" + encodeURIComponent(query) + "?size=" + size + "&offset=" + offset;

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);

                if (result.searchResponse) {
                    data.searchResult = result.searchResponse.results.map(function (element) {
                        if (element.song.images && element.song.images.length > 0) {
                            element.song.primaryImage = element.song.images[0].url;
                        }
                        else {
                            element.song.primaryImage = mbCommon.placeholderImageMedium;
                        }

                        return element;
                    });
                }
            })
    };

    curInstance.lookupStyle = function (id) {
        var url = "api/style/" + encodeURIComponent(id);

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);
                var items;

                if (result.styles) {
                    items = result.styles;
                }
                else {
                    items = result.subgenres;
                }

                data.lookupResult = items.map(function (element) {
                    element.formattedDescription = replaceRoviLinks(element.description);
                    return element;
                });
            })
    }

    curInstance.lookupGenre = function (id) {
        var url = "api/genre/" + encodeURIComponent(id);

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);

                data.lookupResult = result.genres.map(function (element) {
                    element.formattedDescription = replaceRoviLinks(element.description);
                    return element;
                });
            })
    }

    return curInstance;
}]);

The data service uses the built-in Angular $http service and does any required massaging of the data before handing it off to the controller that called it. The controllers then set various properties of the current scope as needed.

I created several different views based on what needed to be shown; one for artist search results, one for data on a specific artist, etc. Whenever I ran into a case where I needed the markup to be different based on the data, I was pleasantly surprised to find an Angular directive that would allow it to be driven by the model. Things like ng-show and ng-href were invaluable. The general rule in the Angular world is that you shouldn’t make any changes to the DOM in your controllers, and if you find yourself reaching for jQuery you might be doing something wrong. I’m happy to say I didn’t have any need to use jQuery to manipulate the DOM.

Next is incorporating animations for view transitions, adding something to the options page, and filling out some missing features.

Update:
As of Dec 31, 2021, long-term support for AngularJS has ended. And so the code above isn’t useful anymore, and the Music Browser is currently offline while I give it a complete overhaul using a different front end framework (maybe Angular?) and connect it to a different source of music metadata using a new back end built on Python. I greatly enjoyed using the framework for both personal projects and in my job for several years. May it rest in peace.