PowerShell - Automatically organizing your mp3-collection

Is your mp3-collection perfectly sorted? I know mine wasn't. I thought I'd address that while creating a pretty good demo-script to show some basic filtering, script structures, etc. Since I also wanted the script to extract the metadata from every file in my collection I had to use the Shell.Application Com-object as well. All in all I think it turned out pretty well.

NOTE:

This script is provided completely as is. It is not an official product in any way and I take no responsibility for any harm it may cause.

Please note that certain metadata editors that integrate directly into the Shell may cause some severe confusion in the metadata, so I'd recommend disabling them before trying. Off course I would also recommend making a backup copy of the music library before surrendering it to the mercy of PowerShell.

If you're new to PowerShell and haven't done so already I'd recommend looking at my earlier posts on the subject:
PowerShell - An introduction, Part I
PowerShell - An introduction, Part II

The script

 Param([String] $Folder)
$INVALIDCHARS = [System.IO.Path]::GetInvalidPathChars() + "/", "\", "*", "?", ":"
$MUSICATTRIBS = "*.m4a", "*.m4b", "*.mp3", "*.mp4", "*.wma", "*.flc"
$PLAYLISTATTRIBS = "*.m3u", "*.zpl"
$ALLATTRIBS = $MUSICATTRIBS + $PLAYLISTATTRIBS
$PLAYLISTSFOLDER = "Playlists"
$objShell = New-Object -ComObject Shell.Application
$iTotalFiles = 0
$iCurrentFile = 0

function checkFolderName($FolderName)
{
  # Make sure the folder doesn't contain any invalid characters

    if(!$FolderName) { $FolderName = "Unknown" }
  $INVALIDCHARS | % {$FolderName = $FolderName.replace($_, "")}
 return $FolderName
}



function MoveFiles($startDir)
{
   Write-Host "Indexing playlists..."
    
    # Do a recursive DIR in the starting directory, include everything with the attributes of a playlist.
 # Filter the result by excluding everything that is a Container (folder)
 $dirResult = get-childitem -force -path $startDir -recurse -include $PLAYLISTATTRIBS | where{! $_.PSIsContainer}
    if($dirResult)
  {
       Write-Host "Moving playlists..."

      foreach($dirItem in $dirResult)
     {
           move-item $dirItem.FullName ($PLAYLISTSFOLDER + "\" + $dirItem.Name)
      }
   }
   Write-Host "Indexing music..."

    # Do a recursive DIR in the starting directory, include everything with the attributes of a music file.
   # Again we filter the result by excluding everything that is a Container.
    $dirResult = get-childitem -force -path $startDir -recurse -include $MUSICATTRIBS | where{! $_.PSIsContainer}
   
    # Make a note of how many hits we got, so we can show the progress to the client.
  $iTotalFiles = $dirResult.Count
 if($dirResult)
  {
       foreach($dirItem in $dirResult)
     {
           # Up the counter for how many files we've processed so that we can show progress
           $iCurrentFile +=1

           # Get the metadata for the file
            $fileData = getMP3MetaData($dirItem.FullName)
           
            # Find the path where we the song should be stored
         $ArtistPath = $fileData["Album Artist"]
           if (!$ArtistPath) { $ArtistPath = $fileData["Artists"] }
          if (!$ArtistPath) { $ArtistName = "Unknown" }

         # Make shure it's a valid path
            $ArtistPath = checkFolderName($ArtistPath)
          $AlbumPath = checkFolderName($fileData.Album)
           $ArtistPath = join-path $startDir $ArtistPath 
          $AlbumPath = join-path $ArtistPath $AlbumPath

           # Check if the file should be moved
            if($dirItem.DirectoryName -ne $AlbumPath)
           {
               if(!(test-path $ArtistPath)) # If the Artist folder doesn't exist
              {
                   MKDIR $ArtistPath | out-null
                }
               if(!(test-path $AlbumPath)) # If the Album folder doesn't exist
                {
                   MKDIR $AlbumPath | out-null
             }
               move-item $dirItem.FullName ($AlbumPath + "\" + $dirItem.Name)
            }

           # Show progress
            $percentage = ([int](($iCurrentFile / $iTotalFiles)*100))
           cls
         Write-Host "$percentage% ($iCurrentFile files of $iTotalFiles) complete"
      }
   }
}


function removeEmptyFolders($startDir)
{
   # Get all folders and subfolders
   $dirResult = Get-childitem $startDir -recurse | where{$_.PSIsContainer}
 if($dirResult)
  {
       foreach($dirItem in $dirResult)
     {
           # If the folder is empty (Doesn't contain any music or playlists) it should be deleted
           if(isfolderempty($dirItem.FullName))
            {
               # Check if the path is still valid, we may have deleted the folder already recursively.
                if(Test-Path $dirItem.FullName)
             {
                   remove-item -path $dirItem.FullName -force -recurse
             }
           }
       }
   }
}

function removeOtherFiles($startDir)
{
 # Get all non-music files (except *.jpg-files) and remove them 
    $dirResult = Get-childitem $startDir -recurse -exclude ($ALLATTRIBS + "*.jpg") | where{! $_.PSIsContainer}
    if($dirResult)
  {
       foreach($dirItem in $dirResult)
     {
           if(Test-Path $dirItem.FullName)
         {
               # Make sure the file still exists, and hasn't been deleted already
             remove-item -path $dirItem.FullName -force
          }
       }
   }
}

function isFolderEmpty($folderPath)
{
  # Search for any remaining music items in the folder.
  if(Test-Path $folderPath)
   {
       $dirChildren = get-childitem -force -path $folderPath -recurse -include $ALLATTRIBS | where{! $_.PSIsContainer}
     if($dirChildren -ne $null)
      {
           return $false
       }
       else
        {
           return $true
        }
   }
   else
    {
       # No use trying to delete the folder if it doesn't exist
       return $false
   }
}

function getMP3MetaData($path)
{
   # Get the file name, and the folder it exists in
   $file = split-path $path -leaf
  $path = split-path $path
    $objFolder = $objShell.namespace($path)
 $objFile = $objFolder.parsename($file)
  $result = @{}
   0..266 | % {
        if ($objFolder.getDetailsOf($objFile, $_))
      {
           $result[$($objFolder.getDetailsOf($objFolder.items, $_))] = $objFolder.getDetailsOf($objFile, $_)
       }
   }
   return $result
}

# MAIN FUNCTION

# If no argument was passed, see if anything was piped to the function instead.
if (!$Folder)
{
   $input | % { $Folder = $_ }
}

# Check if the path is valid, if not reset it to ""
if ($Folder) { if (!(Test-Path -path $Folder)) { $Folder = "" }}

# Since no valid path was given, prompt the user for a proper path.
while (!$Folder)
{
   "Please enter the full path to the folder where you wish to rearrange your MP3's"
 $Folder = Read-Host
 if ($Folder) { if (!(Test-Path -path $Folder)) { $Folder = "" }}
}

# Make sure we have a folder to store the playlists in.
$PLAYLISTSFOLDER = join-path $Folder $PLAYLISTSFOLDER
if(!(test-path $PLAYLISTSFOLDER)) # If the Playlists folder doesn't exist
{
  MKDIR $PLAYLISTSFOLDER | out-null
}

# Now that everything is ready, begin rearranging the files.
MoveFiles($Folder)
removeEmptyFolders($Folder)
removeOtherFiles($Folder)

 

Running the script

So, how do you run this script?

It's quite easy. The first step would be to copy it into notepad save it with a .ps1 extension and open up PowerShell

Execution policy

If this is the first time you try to execute a script from PowerShell you will most likely have to change the execution policy for PowerShell. I'd recommend setting it to RemoteSigned. This (obviously) means that all remote scripts must be signed or PowerShell will refuse to execute them. To change the execution policy simply type:

Set-ExecutionPolicy RemoteSigned

That's it.

If you're uncertain what your execution policy setting is you simply type:

Get-ExecutionPolicy

Okay, having changed that we can now execute the script. If you saved the script in a folder that is in your system path you only need to type the filename of the .ps1 file. (The .ps1 is optional) So, if you saved it as RearrangeMP3s.ps1 you'd write RearrangeMP3s and press enter

Breakdown

Okay, so let's take a look at the separate parts of the script.

Initial variables and constants

First we declare the parameters we're going to use. In this case it's just one: $Folder.

After that we have some constants. You may want to change $MUSICATTRIBS or $PLAYLISTATTRIBS to include, or remove, extensions based on your needs. $PLAYLISTSFOLDER is a constant which indicates in which subfolder you wish to store your playlists.

Finally we have some variables. $objShell is used for file system operations, and rather than creating a new object all the time I'd rather have a global one. $iTotalFiles and $iCurrentFile are two integers used to create a progress indicator.

Functions

After the initial variables come the sub functions called by the main function. To see what each individual function does I'd recommend looking at the comments. I've prioritized readability over minimum number of keystrokes, so yes, instead of writing:

 foreach($dirItem in $dirResult)
{
    move-item $dirItem.FullName ($PLAYLISTSFOLDER + "\" + $dirItem.Name)
}

I could have written

 $dirResult | % { mv $_.FullName ($PLAYLISTSFOLDER + "\" + $_.Name) }

But in this case I think that would have been counterproductive.

The main function

Finally we have the main function. It is written last in the script. Why is that? Well, actually PowerShell is the most sequential language you'll ever come across. It will not bother to check the entire script for function declarations. Instead it will go through the script one line at a time. This means that the following script will not work:

 SayHello

function SayHello
{
  Write-Host "Hello World"
}

When you call SayHello in the sample above the function has not yet been declared, so PowerShell will throw an exception in your face saying that SayHello is unrecognized.

Footnote

There is actually a supported way of achieving the very same thing, but where's the fun in that?

  • Open up Windows Media Player
  • Go to Options -> Library and tick the checkbox for "Rearrange music in rip folder"
  • Go to the "Rip Music"-tab
  • Make sure the path is correct in the "Rip music to this location"-section
  • Click OK
  • Select all files in the library
  • Right-click and choose the "Advanced Tag Editor"-option
  • Do not change anything. Simply click Apply
  • Windows Media Player will now rearrange all the files using your Rip settings.

/ Johan