Linking Zune media items with LinQ, Part 1 (Matt Gertz)

Linking Zune media items with LinQ, Part 1 (Matt Gertz)

  • Comments 5

Way back in October 2007, I wrote up a few posts (here and here) on my experiments with the Windows Media Player object model.  The problem I was trying to solve was that, when I had a playlist set to “shuffle,” WMP would break up songs that should always play together (for example, Jackson Browne’s “The Load-Out” and “Stay”, or Pink Floyd’s “Brain Damage” and “Total Eclipse”).  In those posts, I worked around this by building my own shuffler which would emit a new randomized playlist which preserved those links, and then playing that playlist.  A few months later, after I bought my Zune 80, I updated the program to copy the playlist to a ZPL format, since the format was essentially identical to WPL.

Although this was a good investigation into the WMP object model, I’ve never been happy with the program.  First of all, for the program to work, I had to add metadata to the actual music tracks, and anything could come along and blow that metadata away.  Second, the tracks had to already be adjacent in the parent playlist for it to work at all.  Third, I had to bring up the WMP just to shuffle a playlist, which is pretty heavyweight.  Finally, since I mostly care about Zune playlists these days and not WMP at all, it seemed the height of hackery to bring up the WMP to generate something for Zune (since the Zune object model is not public).

So, last week, after seeing my metadata blown away again when I carelessly updated my tracks after upgrading to Windows 7, I decided to work on a more permanent solution.  Why bring up the WMP at all (or Zune, for that matter), when the playlists themselves are just XML, and the track just entries into those? 

After some experiments with parsing the XML by following the parent/child chain, which seemed kludge to me, I came across a post by Avner in which he discusses porting iTunes playlists to Zune, leveraging LinQ to help with the data parsing.  I realized right away that I could do something very similar in this case, and came up with what I think is a pretty elegant solution.

So here’s the plan:  the application will:

(1)    Load in a playlist that the user chooses

(2)    Allow the user to rearrange the playlist

(3)    Allow the user to link certain tracks together (and unlink them)

(4)    Shuffle the playlist on command, preserving the linkages

(5)    Allow the user to change the title of the playlist

(6)    Allow the user to cave the playlist (or a copy of it)

(7)    And, most important, persist the linkage data between sessions in such a way that the information won’t get blown away.

Let’s get started!

The form

Create a Windows Form Application (about 430 x 300) containing the following:

(1)    A ListBox, about 290 x 180 and positioned on the left side, with the SelectionMode property set to “MultiExtended” and the DrawMode set to “OwnerDrawFixed.”

(2)    Above that, a Label and EditBox, with the Label text set to “&Title”.

(3)    To the right of those, five Buttons going down the right side, labeled “Link”, “Unlink”, “Move Up”, “Move Down”, and “Shuffle”.

(4)    A MenuStrip.  Select it, and on the right side of the strip, click the tiny right-pointing triangle icon and choose “Insert Standard Items” from the resulting popup.  Now, go back and remove all of the items except File (Open, Close, Save, Save As, Exit) and Help (About).  The Edit and Tools menu can be completely removed; we won’t be using those.

(5)    An OpenFileDialog and a SaveFileDialog.  The Filter property of both should be set to “Zune playlists|*.zpl” (without the quotes).

In my example, I also disabled the Maximize box in the form, added an icon and title to the form and application, etc., but those aren’t strictly necessary for the exercise.

The Zune playlist

The format of a Zune playlist (version 2.0) looks like this (memorize this, it will be useful later):

<smil>

<head>

<guid> (some guid)</guid>

    <meta name="creatorId" content="(some guid)"  />

    <meta name="Subtitle" />

    <meta name="ContentPartnerName" />

    <meta name="ContentPartnerNameType" />

    <meta name="ContentPartnerListID" />

    <meta name="ItemCount" content="(number of tracks)" />

    <meta name="TotalDuration" content=" (total duration) " />

    <meta name="AverageRating" content="(whatever the average rating is)" />

    <meta name="Generator" content="Zune -- 4.0.740.0" />

<title> (some title) </title>

</head>

<body>

<seq>

<media src="(file path of the track on disk)" serviceId="{BF0A0E00-0100-11DB-89CA-0019B92A3933}" albumTitle="(album title)" albumArtist="(artist)" trackTitle="(name of the track)" trackArtist="(track artist)" duration="(track duration)" />

<media … /> (etc… one for each track)

</seq>

</body>

</smil>

Code for the controls

The rest of this post will be organized based on the controls, starting from the top.

The Form

The only code associated with the form itself is the event handler for Load, a few member variables, and a helper class.  Let’s take those in reverse order:

The helper class, xmlMediaEntry

We’ll be reading the Zune playlist using the XDocument class, and then we’ll be using LinQ to retrieve individual <media>…</media> tag pairs representing each track.  These will be handed to us as XElements, and as we will want to add each of the tracks to the listbox, we’ll want to wrap these in an object that will expose the proper information to the ListBox – namely, a human-readable string.  We will also want to have a way to link a track to another track, and we’ll be using a doubly-linked list to do this.  So:

    Class xmlMediaEntry

        Public xmlElem As XElement

        Public PrevLinkedElement As xmlMediaEntry = Nothing

        Public NextLinkedElement As xmlMediaEntry = Nothing

 

That code allows us to cache the XElement and also point to other elements of this class backwards and forward.  The header node for a chain of linked songs will have PrevLinkedElement remaining as Nothing; the tail node will have NextLinkedElement remain as Nothing; songs in the middle of the chain will have both of those set to some other node.

The following constructor allows us to initialize the object with the XElement to be cached.

        Sub New(ByVal xelem As XElement)

            xmlElem = xelem

        End Sub

 

This code, exposed publically as a shared method, allows us to create a canonical (and readable) way to refer to a track in an XElement.  To use one of my previous examples, the resulting value might be “The Load-Out (Jackson Browne)”:

        Public Shared Function CreateMediaString(ByVal elem As XElement) As String

            Return elem.Attribute("trackTitle").Value & _

" (" & elem.Attribute("trackArtist").Value & ")"

        End Function

 

I can then leverage that function to override the ToString for the xmlMediaEntry:

        Public Overrides Function ToString() As String

            Return CreateMediaString(xmlElem)

        End Function

    End Class

And that’s all I need in that class.  Once I add an instance of it to the ListBox, it will show up with nice readable text, and I’ll be able to navigate between it and any other linked songs (if any).

Member Variables

I’ll need a few variables to keep track of the state of the file, and these should all be self-explanatory:

    Private FileLoaded As Boolean = False ' Do we have a file loaded into the listbox?

    Private FileChanged As Boolean = False ' Has the playlist changed since it was last loaded/saved?

    Private FilePath As String ' Where was the file last loaded from/saved to?

    Private FileDirectory As String ' What directory was the file last loaded from/saved to?

 

We’ll also need to keep track of some of the playlist’s metadata.  Each playlist has a GUID associated with it (if you don’t know what a GUID is, don’t worry about it – think of it as a unique identifier); if we do a “Save As” to a different file, we’ll want a different GUID.  The user might also change the title of the playlist, and we should cache our creatorId format since it might be unique to the user’s situation:

    Private playlistGuid As String ' What is the GUID of this playlist?

    Private playlistTitle As String ' What is the title of this playlist?

    Private playlistCreatorId As XElement ' What is the creator information of this playlist?

Events

The Form only has two events to handle that we care about – the Load event, and the FormClosing event.  For the Load event, we’ll want to make sure that our menu items and buttons are enabled appropriately, and that we appropriately generate random numbers later on:

    Private Sub VBShuffleForm_Load(ByVal sender As System.Object, _

ByVal e As System.EventArgs) Handles MyBase.Load

        Microsoft.VisualBasic.Randomize()

        ResetButtons()

        ResetMenus()

    End Sub

 

We will be using those helper functions quite a lot, so it’s worth going over them.  For ResetMenus, the “Open” commands will always be available, so we’ll apply a little overkill and ensure that.  “Save As” and “Close” will be available whenever a file is loaded (otherwise, we’d have nothing to save or close!), and “Save” will be available if a file is loaded and has been changed since the last time that it was saved or loaded:

    Private Sub ResetMenus()

        OpenToolStripMenuItem.Enabled = True

        CloseToolStripMenuItem.Enabled = FileLoaded

        SaveAsToolStripMenuItem.Enabled = FileLoaded

        SaveToolStripMenuItem.Enabled = FileLoaded AndAlso FileChanged

        edtTitle.Enabled = FileLoaded

    End Sub

Somewhat more complicated is ResetButtons.  Each button reacts to one of three situations – nothing is selected in the listbox, exactly one thing is selected in the listbox, or multiple things are selected in the listbox.  The breakdown is as follows:

·         “Link” is only available if multiple things are selected.  You can’t link if nothing is selected, and you can’t link a file to itself.  It’s also OK to link files that are already linked, since you might be adding more files to the chain of songs.

·         “Unlink” is available if at least one item in the selection has a link.  You don’t have to select an entire link set to unlink it; it’s sufficient just to select one of the members of the link chain.  If the selection contains elements from multiple link chains, then all of the associated chains will be unlinked.  We can examine the “PrevLinkedElement” and “NextLinkedElement” values to determine if an item has a link.

·         “Move Up” or “Move Down” moves one song, so they are only available if exactly one song is selected.

·         “Shuffle” is available whenever a file is loaded.

So:

    Private Sub ResetButtons()

        If ShuffleListBox.SelectedIndices.Count = 0 Then

            LinkBtn.Enabled = False

            UnlinkBtn.Enabled = False

            MoveDownBtn.Enabled = False

            MoveUpBtn.Enabled = False

        ElseIf ShuffleListBox.SelectedIndices.Count = 1 Then

            LinkBtn.Enabled = False

            MoveDownBtn.Enabled = (ShuffleListBox.SelectedIndices(0) <> _

              ShuffleListBox.Items.Count() - 1)

            MoveUpBtn.Enabled = (ShuffleListBox.SelectedIndices(0) <> 0)

            UnlinkBtn.Enabled = (CType(ShuffleListBox.SelectedItems(0), _

              xmlMediaEntry).PrevLinkedElement IsNot Nothing OrElse _

              CType(ShuffleListBox.SelectedItems(0), xmlMediaEntry).NextLinkedElement _

IsNot Nothing)

        Else

            LinkBtn.Enabled = True

            MoveDownBtn.Enabled = False

            MoveUpBtn.Enabled = False

            Dim enableBn As Boolean = True

            For Each item In ShuffleListBox.SelectedItems

                If CType(item, xmlMediaEntry).PrevLinkedElement Is Nothing AndAlso _

CType(item, xmlMediaEntry).NextLinkedElement Is Nothing Then

                    enableBn = False

                    Exit For

                End If

            Next

            UnlinkBtn.Enabled = enableBn

        End If

 

        ShuffleBtn.Enabled = FileLoaded

    End Sub

 

The FormClosing event handler needs to prompt the user if there are unsaved changes before closing the application.  (Note that this is the event generated from the clicking the “X” on the form’s title bar – not the “Close” menu item!  We’ll implement that other one later.)  The user will have an opportunity to save his/her work before closing (if applicable), or else canceling the Close operation altogether. 

First, we’ll define the code which determines if it’s OK to close:

    Private Function OKToUnload() As Boolean

        If Not FileLoaded OrElse Not FileChanged Then

            Return True

        End If

 

        Dim result As MsgBoxResult = _

MsgBox("Do you want to save your changes?", _

MsgBoxStyle.YesNoCancel, "Save changes?")

        If result = MsgBoxResult.Cancel Then

            Return False

        Else

            If result = MsgBoxResult.Yes Then

                Return SaveAs()

            End If

        End If

        Return True

    End Function

If there is no file loaded, or if the file is loaded but hasn’t changed, then it’s of course OK to proceed, and we return True.  Otherwise, we pop up a MessageBox prompting the user if they want to save their changes.  If the user chooses “Cancel,” then we simply return False to indicate that it is not OK to proceed.   If the user chooses “Yes,” then we call SaveAs() (which we’ll define later) and return whatever the results are from that call.  That just leaves “No,” which means the user doesn’t want to save changes, so we return True indicating that it’s OK to proceed.  (The strings for the message box should be stored in the resources so that they can be localized, of course, but that makes the blog less readable. J)

Now, we can use that helper function in the event handler for Close:

    Private Sub VBShuffleForm_FormClosing(ByVal sender As Object, _

ByVal e As System.Windows.Forms.FormClosingEventArgs) Handles Me.FormClosing

        If Not OKToUnload() Then

            e.Cancel = True

        End If

    End Sub

 

In other words, if it’s not OK to close the application, then we set e.Cancel to True indicating that the event should be canceled.

The Menu Strip

Now, we start getting into the meat of the code.  Let’s go through these one by one:

FileàOpen

Open is pretty trivial to implement, as you can see:

    Private Sub OpenToolStripMenuItem_Click(ByVal sender As Object, _

ByVal e As System.EventArgs) Handles OpenToolStripMenuItem.Click

        Open()

    End Sub

 

OK, I’m kidding.  I moved all of the handling for this into a helper function, in case I ever wanted to open the file in some other way than the menu click.  Here’s the actual implementation:

    Private Function Open() As Boolean

        Try

            If Not OKToUnload() Then

                Return False ' User canceled; just return.

            End If

 

As you can see, we first leverage the “OKToUnload” helper function to make sure that the user doesn’t lose any work in progress.  We’ll also wrap the functionality in a Try construct in case something goes wrong with the opening of the file.

Next, we’ll get the name of the playlist to be opened.  If we’ve recently loaded from (or saved to) a directory, we’ll start out there; otherwise, we’ll default to the Music directory as our initial location with no particular file in mind to load:

            If String.IsNullOrEmpty(FileDirectory) Then

                ShuffleOpenFileDialog.InitialDirectory = My.Computer.FileSystem.SpecialDirectories.MyMusic

            Else

                ShuffleOpenFileDialog.InitialDirectory = FileDirectory

            End If

            ShuffleOpenFileDialog.FileName = ""

 

            Dim result As DialogResult = ShuffleOpenFileDialog.ShowDialog

            If result = Windows.Forms.DialogResult.Cancel Then

                Return False ' User canceled; just return

            End If

 

And we’ll need to cache that information for later usage.  (I cache the directory separately, so that I can easily use it to initialize the Save dialog’s initial location later on):

            FilePath = ShuffleOpenFileDialog.FileName

            Dim fileData As FileInfo = My.Computer.FileSystem.GetFileInfo(FilePath)

            FileDirectory = fileData.DirectoryName

 

Next, we’ll close any open playlist.  We’ll use a helper function to do this:

            ClosePlaylist()

 

Where ClosePlaylist() is defined to just flush out our caches, and the listbox:

    Private Sub ClosePlaylist()

        ' Get rid of all items and clear the member variables.

        ShuffleListBox.Items.Clear()

        FileLoaded = False

        FileChanged = False

        edtTitle.Text = ""

        playlistGuid = ""

        playlistTitle = ""

        playlistCreatorId = Nothing

    End Sub

 

Finally, we get to the part where we load the file!  As I mentioned before, we’ll be using XElements to store the items, and reading in the file in such a case is really easy:

            Dim xmlElements As XElement = XElement.Load(FilePath)

 

If you’re debugging this, take a moment to look at the result in the watch window.  You’ll see that this is a fairly deep structure that is difficult to plumb, and so digging though it to find the media items would be quite a chore.  Fortunately, LinQ to XML is here to help us, and we can use it liberally.  First, let’s get and cache the GUID, so that we can spit it out again if we save back to the same file:

            Dim guids = From guidEntry In xmlElements...<guid> _

                        Select guidEntry

            playlistGuid = guids(0).Value

 

Looks trivial, doesn’t it?  But we just saved ourselves about twenty lines of code that we would have had to write to navigate the XElement structure.  Basically, I told link to search for the GUID element, nested somewhere under the root of the document (hence the “…” before the <guid> tag) and return me a list (actually an IEnumeration) of all such tags.  I happen to know that there’s only one in this schema, so I’ll cache the first (and only) one returned to me.

I can do something similar for the title:

            Dim titles = From titleEntry In xmlElements...<title> _

                        Select titleEntry.Value

            playlistTitle = titles(0)

            edtTitle.Text = playlistTitle

But creatorId is tricker – it’s not a tag itself, but an attribute on another tag called “<meta>.”  There are several other <meta> tags which could be in any order, so I can’t simply grad the first and be done with it.  Fortunately, I can use “Where” and the “@” qualifiers to narrow down the field to just the one that I want:

            ' Get the creatorId metatag

            Dim cIds = From metaEntry In xmlElements...<meta> _

                       Where metaEntry.@name = "creatorId" _

                        Select metaEntry

            playlistCreatorId = cIds(0)

 

But it’s the media entries that we’re most interested in.  We can retrieve those similar to the way we got the title and guid information:, but this time we’ll save the results to a list that we can iterate over.  Once we have that list, we will create an xmlMediaEntry for each XElement in that list, and throw it into the ListBox:

            Dim mediaEntries = From media In xmlElements...<media> _

                               Select media

            Dim mediaList As List(Of XElement) = mediaEntries.ToList

            For Each m In mediaList

                ShuffleListBox.Items.Add(New xmlMediaEntry(m))

            Next

The files are loaded, so we can update our state appropriately and return a “success:”

            FileChanged = False

            FileLoaded = True

            ResetButtons()

            ResetMenus()

            Return True

 

Of course, if something went wrong, we need to tell the user, and return a failure code:

        Catch ex As Exception

            MsgBox("Error opening file: " & ex.Message, MsgBoxStyle.OkOnly)

            Return False ' Oops, problems loading the playlist file.

        End Try

    End Function

FileàSave and FileàSaveAs

Here’s the other half of the file work.  As with Open, we’ll do all of the work in a helper function, which we’ll then be able to leverage in other conditions:

    Private Sub SaveToolStripMenuItem_Click(ByVal sender As Object, _

ByVal e As System.EventArgs) Handles SaveToolStripMenuItem.Click

        Save()

    End Sub

 

This, in turn, will call another function to emit the XML, after which it will clear the “FileChanged” flag and reset the menus, catching any errors that happen:

    Private Function Save() As Boolean

        Try

            EmitXML()

            FileChanged = False

            ResetMenus()

            Return True

        Catch ex As Exception

            MsgBox("Error saving file: " & ex.Message, MsgBoxStyle.OkOnly)

            Return False

        End Try

    End Function

 

EmitXML, as you might guess, is where all of the work happens.  But the work itself is pretty simple.  Rather than opening a file and spitting in XML tags line by line, we’ll leverage an XML literal to build up the document and then we’ll save it wholesale to the file:

    Private Sub EmitXML()

        Dim playlist = <?xml version="1.0"?>

                       <?zpl version="2.0"?><smil>

                           <head>

                               <author/>

                               <guid><%= playlistGuid %></guid>

                               <%= playlistCreatorId %>

                               <meta name="Subtitle"/>

                               <meta name="ContentPartnerName"/>

                               <meta name="ContentPartnerNameType"/>

                               <meta name="ContentPartnerListID"/>

                               <meta name="ItemCount" content="754"/>

                               <meta name="TotalDuration" content="191685"/>

                               <meta name="AverageRating" content="7"/>

                               <meta name="Generator" content="Zune -- 4.0.740.0"/>

                               <title><%= playlistTitle %></title>

                           </head>

                           <body>

                               <seq>

                                   <%= GetMediaElements() %>

                               </seq>

                           </body>

                       </smil>

 

        playlist.Save(FilePath)

    End Sub

 

How cool is that?  The playlistGuid, playlistCreatorId, and playlistTitle will automatically be replaced by whatever we cached, and the media will be generated within the context of the XML by a helper function called GetMediaElements(), the form of which is very simple:

    Private Function GetMediaElements() As IEnumerable(Of XElement)

        Dim media As New List(Of XElement)

            For Each m As xmlMediaEntry In ShuffleListBox.Items

                media.Add(m.xmlElem)

            Next

        Return media

    End Function

 

In other words, we just iterate through the listbox, pull the XElement out of each item, and add it to a list which gets passed back to the XML.  That’s it; that’s all there is to it.

Now, we can leverage this for the Save As command; first the event handler:

    Private Sub SaveAsToolStripMenuItem_Click(ByVal sender As Object, _

ByVal e As System.EventArgs) Handles SaveAsToolStripMenuItem.Click

        SaveAs()

    End Sub

 

And then the helper function.  First, we get the name of the file to save, setting the initial directory to what we cached earlier, and defaulting the filename to the name of the title (instead of the original filename, reasoning that, if the user was saving to the same file, they would have used “Save” and not “SaveAs”):

    Private Function SaveAs() As Boolean

        ShuffleSaveFileDialog.FileName = playlistTitle

        ShuffleSaveFileDialog.InitialDirectory = FileDirectory

        Dim result As DialogResult = ShuffleSaveFileDialog.ShowDialog()

        If result = Windows.Forms.DialogResult.Cancel Then

            Return False

        End If

 

We should supply a new GUID if the filename is going to change:

        If FilePath <> ShuffleSaveFileDialog.FileName Then

            playlistGuid = "{" & System.Guid.NewGuid.ToString() & "}"

        End If

 

Then we cache the name and directory:

        FilePath = ShuffleSaveFileDialog.FileName

        Dim fileData As FileInfo = My.Computer.FileSystem.GetFileInfo(FilePath)

        FileDirectory = fileData.DirectoryName

 

And having done that, we can just call Save() to do the rest of the work:

        Return Save()

    End Function

 

That’s it for the file work – for now.  We’ll revisit Open and Save later when we persist the linkage info.

FileàClose and File->Exit

In the case of Close, we’re closing the playlist, not the application; for Exit, we close the application as well.  The code for this is pretty trivial, as we have already written both of the helper functions involved:

    Private Sub CloseToolStripMenuItem_Click(ByVal sender As System.Object, _

ByVal e As System.EventArgs) Handles CloseToolStripMenuItem.Click

        If OKToUnload() Then

            ClosePlaylist()

        End If

    End Sub

 

    Private Sub ExitToolStripMenuItem_Click(ByVal sender As Object, _

ByVal e As System.EventArgs) Handles ExitToolStripMenuItem.Click

        If OKToUnload() Then

            Close()' This is Form.Close, of course.

        End If

    End Sub

 

HelpàAbout

This is pretty trivial to handle, and I’ve described it before in earlier posts, but here goes:  right-click on the project, choose “Add New Item,” and add an About Dialog.  Then, right-click the project again and choose “Properties.”  In the resulting properties document, select the Application tab (if it isn’t already selected) and click the “Assembly Information…” button.  Fill in the information however you prefer.  Then, we can hook up the dialog to the command pretty easily as follows:

    Private Sub AboutToolStripMenuItem_Click(ByVal sender As System.Object, _

ByVal e As System.EventArgs) Handles AboutToolStripMenuItem.Click

        Dim dlg As New VBSmartShuffleAbout

        dlg.ShowDialog()

    End Sub

 

That’s all I’m going to cover in this post… it’s getting too long, and I’ve got nine more pages to go!  I’ll post the next set in a separate post, and we’ll wrap this up then.

‘ Til next time,

  --Matt--*

Leave a Comment
  • Please add 8 and 4 and type the answer here:
  • Post
  • Thanks for the post, this is pretty cool! (All this slick code and not a single bleeping comment!?!?!)

  • Thanks for the compliment, Eric.  Actually, my code projects generally have too many comments!  I strip nearly all of the comments out in the blog posts, because the blog text acts as a comment for each section and my blogs are already way too long as it is.  :-) But if you download any of the corresponding code projects from my Temple of VB site, you'll find lots of comments in the actual project.

    --Matt--*

  • In this section, I was constantly having problems, but this description techniques and information I have I can say thanks

  • Why not write:

    Return elem.Attribute("trackTitle").Value & _

    " (" & elem.Attribute("trackArtist").Value & ")"

    As:

    Return elem.@trackTitle & " (" & elem.@trackArtist & ")"

  • (For those who are wondering what James is referring to, check out http://msdn.microsoft.com/en-us/library/bb387086.aspx  -- "@" is a shortcut for retrieving a named attribute from an XElement, available to Visual Basic users.)

    Very good point, James, and that will absolutely work.  However, my personal programming style is that I tend to not use the shortcuts where a method call is involved.  For example, you may nave noticed from other blog entries that when accessing lists, I *mostly* tend to write out "foo.Items(1)" rather than "foo(1)" even if the latter will also work.  It's just a personal hang-up of mine; I prefer to keep the method calls "in my face."  Ultimately, when compiled, it all resolves down to the same thing.  I should have mentioned the shortcut in the blog, though, since I suspect that people will see lots of other examples that use it and thereby might become confused.  

    Thanks for pointing this out!!!   Great catch.

    --Matt--*

Page 1 of 1 (5 items)