PowerShell : Renaming And Sorting All My Music Files

We have a large collection of MP3s and FLACs and most of the time they all work great.  I’ve spent quite a bit of time getting their metadata correct so the various players we have all play them in the correct order as they use their Disc and Track number metadata to sort them.

However my wife’s and I’s cars both can read music from SD cards.  This is great as they’re a lot faster to process than phone libraries over Bluetooth but the downside is that the only sorting they do is via the filename.  This means every album plays it’s tracks in alphabetical order.

Ideally to fix this what I want is a script to rename each music file according to it’s Disc Number, Track Number and Title metadata.  The script I wrote to do exactly that follows, with some explanations after.

[CmdletBinding()]
param
(
    [parameter(Mandatory=$True,ValueFromPipeline=$True)]
    [string[]]$Path
)
BEGIN
{
    Write-Verbose "Importing MP3Tag Library"
    $myDocumentsFolder = [Environment]::GetFolderPath("MyDocuments")
    $scriptsFolder="\docs\scripts"
    $MPTagFolder="\mptag"
    #Import the MPTag module
    Import-Module ($myDocumentsFolder+$scriptsFolder+$MPTagFolder)
    function Set-FileName
    {
        param
        (
            [parameter(Mandatory=$True)]
            [string]$Path
        )
        Write-Verbose "Processing $Path"
        $Object=New-Object PSObject
        $Object | Add-Member NoteProperty OriginalPath $Path
        $Object | Add-Member NoteProperty OriginalName (Split-Path $Path -Leaf)
        $Object | Add-Member NoteProperty Updated $False
        $Object | Add-Member NoteProperty Result $Null
        $Object | Add-Member NoteProperty Error $Null
        $Metadata=Get-MediaInfo $Path
        if ($Metadata -eq $Null)
        {
            $Object.Result = "No Metadata returned"
            Return $Object
        }
        $Metadata=$Metadata.Tag
        if (($Metadata.Track -eq $Null) -or ($Metadata.Track -eq ""))
        {
            $Object.Result = "No Track #"
            Return $Object
        }
        [String]$TrackNumber=$Metadata.Track
        if ($Metadata.Title -match "^\d")
        {
            $Object.Result = "Title starts with a number."
            Return $Object
        }
        $NewFileName=$TrackNumber.PadLeft(2,"0")+"-"+$Metadata.Title+((gci $path).Extension)
        if (!(($Metadata.Disc -eq $Null) -or ($Metadata.Disc -eq "")))
        {
            $NewFileName=([string]$Metadata.Disc).PadLeft(2,"0")+"-"+$NewFileName
        }

        Try
        {
            Rename-Item -Path $Path -NewName $NewFileName -ErrorAction Stop
        }
        Catch [System.Exception]
        {
            $Object.Result = "Unable to rename item."
            $Object.Error=$_.Exception.Message
            Return $Object
        }
        Write-Verbose "Updated file successfully."
        $Object.Updated=$True
        $Object
    }
}
PROCESS
{
    foreach ($PathItem in $Path)
    {
        gci -File $PathItem -Recurse | %{Set-FileName -path $_.FullName}
    }
}

And here’s how it works;

[CmdletBinding()]
param
(
    [parameter(Mandatory=$True,ValueFromPipeline=$True)]
    [string[]]$Path
)
BEGIN
{

This is a pretty standard start.  I want to pass a path as a parameter (or an array of paths) and the script can’t work without it (so I make it mandatory).  I allow the parameters to be passed from the pipeline.

CmdletBinding() is in there to allow me to Write-Verbose.

There’s a few things I want to define just once (rather than repeatedly for each object passed from the pipeline)  so I encase these in a BEGIN block.

    Write-Verbose "Importing MP3Tag Library"
    $myDocumentsFolder = [Environment]::GetFolderPath("MyDocuments")
    $scriptsFolder="\docs\scripts"
    $MPTagFolder="\mptag"
    #Import the MPTag module
    Import-Module ($myDocumentsFolder+$scriptsFolder+$MPTagFolder)

PowerShell doesn’t have any built-in cmdlets for processing tags so I’m importing the library I used here.

    function Set-FileName
    {
        param
        (
            [parameter(Mandatory=$True)]
            [string]$Path
        )
        Write-Verbose "Processing $Path"
        $Object=New-Object PSObject
        $Object | Add-Member NoteProperty OriginalPath $Path
        $Object | Add-Member NoteProperty OriginalName (Split-Path $Path -Leaf)
        $Object | Add-Member NoteProperty Updated $False
        $Object | Add-Member NoteProperty Result $Null
        $Object | Add-Member NoteProperty Error $Null

Here’s the function that actually does the work.  Again I only need a path parameter to the file to be renamed.

After that I create an object which will hold the result of the rename operation.  This will allow me to see which files were successfully renamed and what the errors where.  I’m assuming there’s going to be a few files that can’t be renamed (see below) and so I’d like to be able to process the results afterwards.  The Updated property is the most important and that holds the result on whether the file has been renamed successfully.  It defaults to $False.

        $Metadata=Get-MediaInfo $Path
        if ($Metadata -eq $Null)
        {
            $Object.Result = "No Metadata returned"
            Return $Object
        }
        $Metadata=$Metadata.Tag
        if (($Metadata.Track -eq $Null) -or ($Metadata.Track -eq ""))
        {
            $Object.Result = "No Track #"
            Return $Object
        }
        [String]$TrackNumber=$Metadata.Track
        if ($Metadata.Title -match "^\d")
        {
            $Object.Result = "Title starts with a number."
            Return $Object
        }

Here I check for some common problems.  If any of them occur I update the results object ($Object) and return it;  this exits the function before any changes are made.

Mainly I check if there’s no Track number metadata or if the metadata hasn’t been returned correctly (($Metadata.Track -eq $Null) -or ($Metadata.Track -eq “”)).

One other thing I check though is if the title already has a number at the beginning ($Metadata.Title -match “^\d”).  Some of our MP3s already have a naming system with the track number at the beginning so these should be skipped.  As long as I record why they’re skipped in the return object I can filter for them afterwards and correct them.

        $NewFileName=$TrackNumber.PadLeft(2,"0")+"-"+$Metadata.Title+((gci $path).Extension)
        if (!(($Metadata.Disc -eq $Null) -or ($Metadata.Disc -eq "")))
        {
            $NewFileName=([string]$Metadata.Disc).PadLeft(2,"0")+"-"+$NewFileName
        }

Here I generate the new filename.  The format is [Disc Number]-[Track Number]-[Title].  I only add the Disc Number to the beginning if it’s defined in the metadata.

The other important quirk is to always pad the Track Number with preceding 0’s if necessary;  so a song would be renamed; 01-First Song on the Album.MP3.  This means they all sort correctly and you don’t end up with track 21 playing before track 3.

        Try
        {
            Rename-Item -Path $Path -NewName $NewFileName -ErrorAction Stop
        }
        Catch [System.Exception]
        {
            $Object.Result = "Unable to rename item."
            $Object.Error=$_.Exception.Message
            Return $Object
        }
        Write-Verbose "Updated file successfully."
        $Object.Updated=$True
        $Object
    }
}
PROCESS
{
    foreach ($PathItem in $Path)
    {
        gci -File $PathItem -Recurse | %{Set-FileName -path $_.FullName}
    }
}

The last part of Set-FileName is to actually rename the file.  I wrap this in Try-Catch so that if any errors are generated then these can be caught and added to the return object as a property ($Object.Error=$_.Exception.Message).

Only if the function gets all the way through past this point do I set $Object.Updated to be $True.

Finally there’s the PROCESS block which runs for every object passed to the script.  An object could be a path or an array of paths so we need a ForEach to process them (so the script will still work if I pass a stream of arrays of paths to it).

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s