Building a Web Site with ASP .NET 2.0 to Navigate Your Music Library
| |
This article focuses on a common solution for managing shared music files. It is accomplished by keeping shared music files on a single computer and have other computers' media libraries point to, and stay current with, the files on that computer. |
|
Jeff Key
Difficulty: Intermediate
Time Required: 1-3 hours
Cost: Free
Hardware:
|
Introduction
It's easy to assume that most people with computers have media libraries. As computers become cheaper, easier to network and easier to use, people are more likely to create home networks. Since servers aren't practical in a home setting, information is distributed amongst the computers in the network, which makes managing and finding formation difficult. This is probably most evident in music libraries.
Most media players maintain a private database containing all of the songs they're aware of. Keeping these databases up-to-date can be a chore on a single computer, and keeping databases current on several computers often seems impossible.
A common solution is to keep all music files on a single computer and have other computers' media libraries point to, and stay current with, the files on that computer. Anyone that has tried this in practice knows that it's an adequate solution, but not an ideal one. Personally, I just want to listen to music, not manage it.
My home setup consists of a Windows Media Center computer that drives my TV and has all of my music on it, my workstation, and occasionally a laptop. Keeping the workstation current with the music on the Media Center has always been a hassle, and doing so on the laptop just isn't practical. I wanted a simple, clean way to listen to music anywhere in my apartment, from any computer, without having to worry about the accuracy of the library. I chose to build a little website whose only purpose would be to get me to the music I wanted to listen to as painlessly as possible. I used Microsoft Visual Web Developer 2005 Express Edition Beta 2 to make this happen.
COM: A friend from the past
Back in the old days we had a technology called COM that let us use libraries written in any language from our code, regardless of the langauge we were using (as long as it supported COM), much like .NET does today. COM did a great job solving some problems, but introduced a few others that became more apparent over time. .NET is, among other things, a solution to many of these problems.
Years ago, Microsoft started exposing operating system and application services via COM. This let common folk write code against Microsoft Word, Windows Scripting Host, Windows Media Player, and countless others. Due to the number of languages, frameworks, etc. that support COM, it's still a major player in the Microsoft world and it's common to find COM support in Microsoft applications before .NET support. Luckily, .NET does a great job talking to COM (most of the time).
This history lesson is relevant because the site uses the Windows Media Player COM component to access its media library. Adding a reference to a COM component is just as easy as adding a reference to a .NET assembly. Click the Website menu and select Add Reference, then click the COM tab on the Add Reference dialog.
Figure 1: Adding a reference to the Windows Media Player COM component
Database
Quick access to a subset of the WMP library's information is required, so an in-memory database consisting of Artist, Album, Database and Track classes is required. The Database class has a static (or shared in Visual Basic) constructor that populates the database with a call to the Refresh method:
Visual C#
private static void Refresh()
{
WindowsMediaPlayer wmp = new WindowsMediaPlayer();
IWMPPlaylist playlist = wmp.mediaCollection.getAll();
Dictionary<string, Artist> artistDictionary =
new Dictionary<string, Artist>();
for (int i = 0; i < playlist.count; i++)
{
IWMPMedia media = (IWMPMedia)playlist.get_Item(i);
string albumArtistName = media.getItemInfo("AlbumArtist");
string albumName = media.getItemInfo("Album");
string trackName = media.getItemInfo("Title");
string trackLocation = media.getItemInfo("SourceUrl");
string trackNumberString = media.getItemInfo("OriginalIndex");
Artist artist;
string artistSortName = Artist.GetSortName(albumArtistName);
if (!artistDictionary.TryGetValue(artistSortName, out artist))
{
artist = new Artist(albumArtistName);
artistDictionary.Add(artistSortName, artist);
}
Album album;
if (!artist.Albums.TryGetValue(albumName, out album))
{
album = new Album(albumName, artist);
artist.Albums.Add(albumName, album);
}
Track track;
if (!album.Tracks.TryGetValue(trackName, out track))
{
int trackNumber;
if (int.TryParse(trackNumberString, out trackNumber))
{
track = new Track(trackNumber, trackName, trackLocation);
}
else
{
track = new Track(trackName, trackLocation);
}
track.Album = album;
album.Tracks.Add(trackName, track);
}
}
ArtistList.AddRange(artistDictionary.Values);
ArtistList.Sort();
}
Visual Basic
Private Shared Sub Refresh()
Dim wmp As WindowsMediaPlayer = New WindowsMediaPlayer
Dim playlist As IWMPPlaylist = wmp.mediaCollection.getAll()
Dim artistDictionary As Dictionary(Of String, Artist) = _
New Dictionary(Of String, Artist)
For i As Integer = 0 To playlist.count - 1
Dim media As IWMPMedia = playlist.Item(i)
Dim albumArtistName As String = media.getItemInfo("AlbumArtist")
Dim albumName As String = media.getItemInfo("Album")
Dim trackName As String = media.getItemInfo("Title")
Dim trackLocation As String = media.getItemInfo("SourceUrl")
Dim trackNumberString As String = _
media.getItemInfo("OriginalIndex")
Dim theArtist As Artist
Dim artistSortName As String = _
Artist.GetSortName(albumArtistName)
If Not artistDictionary.TryGetValue(artistSortName, theArtist) Then
theArtist = New Artist(albumArtistName)
artistDictionary.Add(artistSortName, theArtist)
End If
Dim theAlbum As Album
If Not theArtist.Albums.TryGetValue(albumName, theAlbum) Then
theAlbum = New Album(albumName, theArtist)
theArtist.Albums.Add(albumName, theAlbum)
End If
Dim theTrack As Track
If Not theAlbum.Tracks.TryGetValue(trackName, theTrack) Then
Dim trackNumber As Integer
If Integer.TryParse(trackNumberString, trackNumber) Then
theTrack = New Track(trackNumber, trackName, trackLocation)
Else
theTrack = New Track(trackName, trackLocation)
End If
theTrack.Album = theAlbum
theAlbum.Tracks.Add(trackName, theTrack)
End If
Next
ArtistList.AddRange(artistDictionary.Values)
ArtistList.Sort()
End Sub
The WMP library is a flat structure of media items, so the Artist -> Albums -> Tracks hierarchy must be created manually while populating the database. Accessing the WMP library is simple; calling the getAll method off of a WindowsMediaPlayer object's mediaCollection property returns a playlist of all items in the library, represented by the IWMPPlaylist interface. Collecting the information needed from each media item (represented by the IWMPMedia interface) is just as easy, via a call to IWMPMedia's getItemInfo method. Once all of the relevant information for the media item is collected, the artist, album and track objects must be retrieved or created.
Note that I've used a generic Dictionary object to store the artists while we're iterating through the playlist. Some media collections consist of tens of thousands of items, and having to do a brute-force search through the ArtistList for every media item would further slow down an already slow operation. (Why the Dictionary object is quicker is beyond the scope of this article. Please see Scott Mitchell's examination of the Hashtable class, Dictionary's non-generic counterpart, in an
article from his fantastic series on data structures.)
Designing the website
To keep things as simple as possible, we'll make three pages: Artists, Artist's Albums, and Album.
Figure 2: The Artists page
The Artists page (Figure 2) shows ten groups of artists at a time. Each time a group is clicked, that group is shown and so on until the user gets to the artist she's looking for.
Figure 3: The Albums page
The Albums page (Figure 3) shows all of the albums, including artwork, for an artist. Clicking the album name brings the user to the Album page, and clicking the play button will launch a playlist containing the tracks in the album in Windows Media Player.
Figure 4: The Album page
The Album page shows the large album artwork and tracks. The user can play the entire album or individual tracks.
Each page uses object data binding and shares a common master page. ASP.NET has a new feature called Master Pages, which allows you to create a consistent layout for the Web site and share common functionality without having to duplicate it for every page. The Master Page in this website is used to share Cascading Style Sheet (CSS) information and host the "breadcrumb bar," or SiteMapPath, as it's called in ASP.NET. I won't be covering the SiteMapPath control, but the implementation should be interesting to anyone that needs to drive one programmatically and not through a static definition. More information about master pages can be found in Fritz Onion's Master Your Site Design with Visual Inheritance and Page Templates.
The Albums and Album page also have images and play buttons that launch Windows Media Player playlist files. Album images are cached in a Web-accessible directory, and WMP playlist files are generated on the fly in an HTTP handler. Both of these topics are covered below.
Binding objects to grids
The new ObjectDataSource is the middleman between the grid and objects. It must be configured to work with a class to return data requested by the grid. We'll step through creating the data source for the Albums page.
Figure 5: The ObjectDataSource wizard, first page
The "Binder" class is selected as the business object. The objects used to represent the artists, et al, are simple and primarily represent data, and therefore don't have much in the way of persistence methods. The Binder class provides the functionality that may otherwise be handled by the objects themselves.
Figure 6: The ObjectDataSource wizard, second page
The next page is where we choose which method we would like to use to get the data, so we pick GetAlbums. Update, Insert and Delete methods can also be specified, but are not required.
Figure 7: The ObjectDataSource wizard, third page
The final page specifies where the parameter values will come from. The value can come from one of the following: Cookie, Control, Form, Profile, QueryString and Session.
Once the data source is configured, setting it as the DataSource of the grid is all that's required to make the magic happen.
Generating custom grid values
All of the grids use template columns and custom code to generate their hyperlinks. For example, the artists page (figure 2) consists of a single GridView that's bound to a generic List of RangeListItems. RangeListItem is a custom class that represents the first and last artist IDs in the range, as well as the text that is displayed in the grid. The hyperlinks are created by using a template field in the grid and calling a method in the page's server-side script section.
<asp:TemplateField ItemStyle-HorizontalAlign=Center>
<ItemTemplate>
<asp:HyperLink ID="HyperLink1" runat="server" NavigateURL='<%# CreateNavigateURL(GetDataItem() %>'
Text='<%# Eval("Text") %>'></asp:HyperLink>
</ItemTemplate>
</asp:TemplateField>
The NavigateUrl attribute is populated by a call to the CreateNavigateUrl method:
Visual C#
private string CreateNavigateUrl(object o)
{
RangeListItem listItem = (RangeListItem)o;
if (listItem.StartIndex == listItem.EndIndex)
{
return "albums.aspx?artist=" + listItem.StartIndex;
}
else
{
return string.Format("default.aspx?start={0}&end={1}", listItem.StartIndex, listItem.EndIndex);
}
}
Visual Basic
Private Function CreateNavigateUrl(ByVal o As Object) As String
Dim listItem As RangeListItem = CType(o, RangeListItem)
If (listItem.StartIndex = listItem.EndIndex) Then
Return "albums.aspx?artist=" & listItem.StartIndex
Else
Return String.Format("default.aspx?start={0}&end={1}", listItem.StartIndex, listItem.EndIndex)
End If
End Function
More information about templates in ASP.NET 2.0 can be found in Dino Esposito's
Move Over DataGrid, There's a New Grid in Town!.
Displaying Album Art
Windows Media Player downloads album art, both large and small versions, when possible. These image files are stored in the same folders as the album tracks, but are hidden by default. These images must be copied to a child folder of the Web site so the browser can access them. This is done by first finding the correct image, then copying that file to a subfolder of the Web site and giving it a unique name to so it can be more easily retrieved the next time it's required. If album art isn't found, a small transparent image is used instead.
Visual C#
public string GetAlbumArtUrl(AlbumArtSize size)
{
string albumArtFileName = GetCustomAlbumArtFileName(size);
string albumArtFullFilename = Path.Combine(_albumArtDirectory,
albumArtFileName);
bool fileExists = File.Exists(albumArtFullFilename);
if (!fileExists)
{
string dir = Path.GetDirectoryName(Tracks[0].Location);
string filename;
if (Directory.Exists(dir))
{
filename = GetRealAlbumArtFileName(dir, size);
if (filename != null)
{
File.Copy(filename, albumArtFullFilename);
File.SetAttributes(albumArtFullFilename,
FileAttributes.Normal);
fileExists = true;
}
}
}
string url;
if (fileExists)
{
url = "Images/AlbumArt/" + albumArtFileName;
}
else
{
url = "Images/dot.gif";
}
return url;
}
Visual Basic
Public Function GetAlbumArtUrl(ByVal size As AlbumArtSize) As String
Dim albumArtFileName As String = GetCustomAlbumArtFileName(size)
Dim albumArtFullFilename As String = _
Path.Combine(_albumArtDirectory, albumArtFileName)
Dim fileExists As Boolean = File.Exists(albumArtFullFilename)
If Not fileExists Then
Dim dir As String = Path.GetDirectoryName(Tracks(0).Location)
Dim filename As String
If Directory.Exists(dir) Then
filename = GetRealAlbumArtFileName(dir, size)
If Not filename Is Nothing Then
File.Copy(filename, albumArtFullFilename)
File.SetAttributes(albumArtFullFilename, _
FileAttributes.Normal)
fileExists = True
End If
End If
End If
Dim url As String
If fileExists Then
url = "Images/AlbumArt/" & albumArtFileName
Else
url = "Images/dot.gif"
End If
Return url
End Function
The GetCustomAlbumArtFileName method creates a consistent name that is used to save the copy of the image and more easily retrieve it in subsequent requests:
Visual C#
private string GetCustomAlbumArtFileName(AlbumArtSize size)
{
return string.Format("{0}-{1}-{2}.jpg",
GetAlphanumericString(_artist.Name),
GetAlphanumericString(Name),
size.ToString());
}
private string GetAlphanumericString(string s)
{
StringBuilder sb = new StringBuilder();
foreach (char c in s)
{
if (char.IsLetterOrDigit(c))
{
sb.Append(c);
}
}
return sb.ToString();
}
Visual Basic
Private Function GetCustomAlbumArtFileName(ByVal size As AlbumArtSize) As String
Return String.Format("{0}-{1}-{2}.jpg", _
GetAlphanumericString(_artist.Name), _
GetAlphanumericString(Name), size.ToString)
End Function
Private Function GetAlphanumericString(ByVal s As String) As String
Dim sb As StringBuilder = New StringBuilder
For Each c As Char In s
If Char.IsLetterOrDigit(c) Then
sb.Append(c)
End If
Next
Return sb.ToString()
End Function
To make things even more interesting, album art filenames can match any of the following patterns:
Large
AlbumArt_{FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF}_Large.jpg
AlbumArt__Large.jpg
Folder.jpg
Small
AlbumArt_{FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF}_Small.jpg
AlbumArt__Small.jpg
AlbumArtSmall.jpg
GetRealAlbumArtFileName gets the filename for the album art, if it exists, by searching for the patterns above:
Visual C#
private string GetRealAlbumArtFileName(string dir, AlbumArtSize size)
{
string filename = null;
string[] filenames =
Directory.GetFiles(dir, "AlbumArt*" + size + ".jpg");
if (filenames.Length > 0)
{
filename = filenames[0];
}
else if (size == AlbumArtSize.Large)
{
FileInfo file = new FileInfo(Path.Combine(dir, "Folder.jpg"));
if (file.Exists)
{
filename = file.FullName;
}
}
return filename;
}
Visual Basic
Private Function GetRealAlbumArtFileName(ByVal dir As String, ByVal size As AlbumArtSize) As String
Dim filename As String = Nothing
Dim filenames() As String = _
Directory.GetFiles(dir, "AlbumArt*" & size.ToString() & ".jpg")
If (filenames.Length > 0) Then
filename = filenames(0)
ElseIf (size = AlbumArtSize.Large) Then
Dim file As FileInfo = _
New FileInfo(Path.Combine(dir, "Folder.jpg"))
If file.Exists Then
filename = file.FullName
End If
End If
Return filename
End Function
Creating a Playlist
Playlists are created by the PlaylistCreator class, which is a lightweight web request handler that implements the
IHttpHandler interface. The handler is quite simple: It writes out a header, notifying the browser that the type of content it will be returning is "video/x-ms-asf", then creates and returns an XML playlist. The content type header helps the browser determine what to do with the content, and is a very important step since we want to play the playlist, not show it in the browser.
PlaylistCreator writes directly to the response stream with an XmlTextWriter:
Visual C#
public void ProcessRequest(HttpContext context)
{
_trackIndex = QueryStringHelper.TrackIndex;
_artistIndex = QueryStringHelper.ArtistIndex;
_albumIndex = QueryStringHelper.AlbumIndex;
context.Response.ContentType = "video/x-ms-asf";
StreamWriter streamWriter =
new StreamWriter(context.Response.OutputStream);
_writer = new XmlTextWriter(streamWriter);
_writer.WriteProcessingInstruction("wpl", "version=\"1.0\"");
_writer.WriteStartElement("smil");
CreateHead();
_writer.WriteStartElement("body");
_writer.WriteStartElement("seq");
if (_trackIndex.HasValue)
{
CreateTrackEntry();
}
else if (_albumIndex.HasValue)
{
CreateAlbumEntries();
}
_writer.WriteEndElement();
_writer.WriteEndElement();
_writer.WriteEndElement();
_writer.Close();
}
Visual Basic
Public Sub ProcessRequest(ByVal context As HttpContext) Implements IHttpHandler.ProcessRequest
_trackIndex = QueryStringHelper.TrackIndex
_artistIndex = QueryStringHelper.ArtistIndex
_albumIndex = QueryStringHelper.AlbumIndex
context.Response.ContentType = "video/x-ms-asf"
Dim streamWriter As StreamWriter = _
New StreamWriter(context.Response.OutputStream)
_writer = New XmlTextWriter(streamWriter)
_writer.WriteProcessingInstruction("wpl", "version=\""1.0\""")
_writer.WriteStartElement("smil")
CreateHead()
_writer.WriteStartElement("body")
_writer.WriteStartElement("seq")
If _trackIndex.HasValue Then
CreateTrackEntry()
ElseIf _albumIndex.HasValue Then
CreateAlbumEntries()
End If
_writer.WriteEndElement()
_writer.WriteEndElement()
_writer.WriteEndElement()
_writer.Close()
End Sub
Registering the Playlist Creator
ASP.NET knows how to handle requests to ASPX pages: it finds the page with the same name and executes it. HTTP handlers are handled a bit differently. An entry must be made in the web.config file associating a file name or pattern with the type of the object to use to handle the request. The XML fragment below is from the configuration/system.web section of the web.config file. It's instructing ASP.NET to direct any request with an extention of "wpl" to an object of type "PlaylistCreator".
<httpHandlers>
<add path="*.wpl" type="PlaylistCreator" verb="*" validate="false" />
</httpHandlers>