Resource: Sync iTunes Media Library

Log extract
Log extract of a script run – Image source: private

Script to synchronise iTunes media library to SD card, drive etc.

Description

The media library of iTunes is organised in folders, sorted by artists and albums. All meta data including the playlists is stored in one central XML file, located in the root folder of the folder structure. This makes it difficult or impossible to use the the information on non-Apple devices.

This script parses the XML file to extract information about the playlists, copies all folders and media files to a target folder, and creates playlist files for all iTunes playlists in the target folder. By this, the resulting folder can easily be used on other devices like mobile phones, if you use an SD card as the target.

Possible source and target folders are defined in the head of the script. The script can only handle local drives and network resources, mobile phones that use MTP and don’t get a drive letter assigned cannot be used as a target.

The full header with documentation, examples etc. is missing, but I wanted to make this tool available anyway.

Source code

Set-StrictMode -Off #-Version Latest

# Path where the iTunes media library resides
$SourcePathOptions = @(
    "G:\Backup.NAS\User\Eigene Dateien\Eigene Musik\iTunes",
    "\\nas\Share\User\Eigene Dateien\Eigene Musik\iTunes",
    "I:\Backup.NAS\User\Eigene Dateien\Eigene Musik\iTunes"
)

# Folders containing media files to be synchronized
$MediaFolders = @("\Audiobooks","\Music")

# Path to synchronize iTunes contents and playlists to
# Usually on the SD card from the phone
$TargetPathOptions = @(
    "D:\iTunes",
    "E:\iTunes",
    "F:\iTunes"
)

# Subfolder holding the media files (UNIX notation)
$iTunesMedia = "/iTunes Media"

# Names of playlists to not be synchronized
$ExcludedPlayLists = @("Mediathek","Geladen","Musik","TV-Sendungen","Ohne Cover","Pummis Listen","Vordefiniert","Filme","Podcasts")

# File extensions for playlists
$FileExtensions = @(".m3u",".plb")

# Prefix used in iTunes playlists
$PathPrefix = "file://localhost"

# Get path and name of current script
$scriptDir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent
$CurrentScriptName = $MyInvocation.MyCommand.Name

# For file handling
$LogFileName = "$scriptDir\$CurrentScriptName.log"

# For Error handling
$ErrorPrefix = "Sync-iTunes_Error"

# Display an OK message
function OK-Message {
param(
    [Parameter(Mandatory=$true)]
    [String]$Message,
    [String]$Title = “Fertig!”
)

    Add-Type -AssemblyName PresentationCore,PresentationFramework
    $ButtonType = [System.Windows.MessageBoxButton]::OK
    $MessageboxTitle = $Title
    $Messageboxbody = $Message
    $MessageIcon = [System.Windows.MessageBoxImage]::Information
    return [System.Windows.MessageBox]::Show($Messageboxbody,$MessageboxTitle,$ButtonType,$messageicon)

}

# Display an error message
function Error-Message {
param(
    [Parameter(Mandatory=$true)]
    [String]$Message,
    [String]$Title = “Fehler!”
)

    Add-Type -AssemblyName PresentationCore,PresentationFramework
    $ButtonType = [System.Windows.MessageBoxButton]::OK
    $MessageboxTitle = $Title
    $Messageboxbody = $Message
    $MessageIcon = [System.Windows.MessageBoxImage]::Error
    return [System.Windows.MessageBox]::Show($Messageboxbody,$MessageboxTitle,$ButtonType,$messageicon)

}

# Write log message with timestamp to console
function WriteLog {
param([String]$LogMessage,
      [Switch]$Overwrite)

	$TimeStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    if ($Overwrite) {
        cls
    }
    Write-Host "$TimeStamp - $LogMessage"
	$Error.Clear()
	Try
	{
		if ($Overwrite) {
			Write-Output "$TimeStamp - $LogMessage" | Out-File $LogFileName -Encoding unicode
		} else {
			Write-Output "$TimeStamp - $LogMessage" | Out-File $LogFileName -Encoding unicode -Append
		}
	}
	Catch
	{
		Write-Error "$ErrorPrefix`: Writing to log file [$LogFileName] failed"
	    $Exception = $Error | Select-Object -Property Exception
	    Write-Error $Exception.Exception.ToString()
	}

}

# Expand error message and write to console and log
function ExpandError {
param([String]$ErrorMessage)

    $Exception = $Error | Select-Object -Property Exception
    $ExceptionString = $Exception.Exception.ToString()
	$Error.Clear()
    WriteLog "$ErrorPrefix`: $ErrorMessage"
	WriteLog $ExceptionString

    $tmp = Error-Message -Title $ErrorMessage -Message $ExceptionString

}

function Get-Path {
param(
    $PathOptions
)

    $OptionsCnt = $PathOptions.Length
    $OptionNum = 0
    $StatusOK = $false
    $Path = $null

    while ($OptionNum -lt $OptionsCnt -and !$StatusOK) {

        WriteLog "- Checking [$($PathOptions[$OptionNum])]"
        if (Test-Path -Path $PathOptions[$OptionNum]) {

            $Path = $PathOptions[$OptionNum]
            $StatusOK = $true

        }

        $OptionNum ++

    }

    return $Path

}

# Save current error handling setting and enable try..catch
$_ErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Stop"

WriteLog -LogMessage "Starting script execution" -Overwrite

# For URI encoding/decoding
# Use [System.Web.HttpUtility]::UrlEncode() and [System.Web.HttpUtility]::UrlDecode()
Add-Type -AssemblyName System.Web

# Look for the iTunes library
WriteLog "Searching for source path"
$StatusOK = $false
$SourcePath = Get-Path $SourcePathOptions

if ($SourcePath) {

    WriteLog "Found source path [$SourcePath]"
    WriteLog "Searching for target path"
    $TargetPath = Get-Path $TargetPathOptions
    
    if ($TargetPath) {

        $StatusOK = $true
        WriteLog "Found target path [$TargetPath]"

    } else {

        WriteLog "No target path found"
        Error-Message -Title "Error getting target path" -Message "No target path found in predefined list, see [$LogFileName] for details."

    }

} else {

    WriteLog "No source path found"
    Error-Message -Title "Error getting source path" -Message "No source path found in predefined list, see [$LogFileName] for details."

}

# Path below the source path where the media folders are located
$MediaPath = $SourcePath + $iTunesMedia

if ($StatusOK) {

    WriteLog "Getting iTunes media library contents"
    try {
        [xml]$iTunesLibrary = Get-Content -Path ($SourcePath + "\iTunes Music Library.xml") -Encoding UTF8
    } 
    catch {
        ExpandError "Failed to get iTunes media library contents"
        $StatusOK = $false
    }

}

if ($StatusOK) {

    WriteLog "Analyzing media library"

    WriteLog "- Extracting raw data"
    
    # All tracks in raw format, read from all nested dict elements within dict.dict
    $AllTracksRaw = $iTunesLibrary.plist.dict.dict.dict
    WriteLog "- - [$($AllTracksRaw.Count)] tracks"

    # All playlists in raw format, read from nested dict elements within dict.array
    $AllPlayListsRaw = $iTunesLibrary.plist.dict.array.dict
    WriteLog "- - [$($AllPlayListsRaw.Count)] playlists"

    # Get all track information
    # For performance reasons, this has been made a hash table with the track ID as key instead of an array
    WriteLog "- Gathering information from all tracks"
    $AllTracks = @{}
    $Cnt = 9
    foreach ($TrackRaw in $AllTracksRaw) {

        # Keys and values are in alternating child nodes
        $IsKey = $true
        $Track = @{}

        foreach ($ChildNode in $TrackRaw.ChildNodes) {

            if ($IsKey) {

                # If child node is a key, get name
                $NodeName = $ChildNode.'#text'

            } else {

                # If child node is a value, add key/value pair to track
                $NodeValue = $ChildNode.'#text'
                $Track.Add($NodeName, $NodeValue)

            }

            # Toggle key/value identifier
            $IsKey = !$IsKey

        }

        # Add track with all properties to track list
        $AllTracks.Add($Track.'Track ID', $Track)

        $Cnt++
        if ($Cnt % 10 -eq 0) {
            Write-Host "." -NoNewline
        }
        if ($Cnt % 1000 -eq 0) {
            Write-Host ""
        }

    }
    Write-Host ""
    WriteLog "- Got [$($AllTracks.Count)] tracks"

    # Get all playlist information
    WriteLog "- Gathering information from all playlists"
    $AllPlayLists = @()
    foreach ($PlayListRaw in $AllPlayListsRaw) {

        # Keys and values are in alternating child nodes
        $IsKey = $true
        $PlayList = @{}

        foreach ($ChildNode in $PlayListRaw.ChildNodes) {

            if ($IsKey) {

                # If child node is a key, get name
                $NodeName = $ChildNode.'#text'

            } else {

                # If child node is a value, add key/value pair to track
                $NodeValue = $ChildNode.'#text'
                $PlayList.Add($NodeName, $NodeValue)

            }

            # Toggle key/value identifier
            $IsKey = !$IsKey

        }

        WriteLog "- - Playlist [$($PlayList.Name)]"

        # Get track list for playlist
        # Keys and values are in alternating child nodes
        $IsKey = $true
        $TrackIDs = @()
        $Cnt = 9

        foreach ($ChildNode in $PlayListRaw.Array.dict.ChildNodes) {

            if ($IsKey) {

                # If child node is a key, get name (irrelevant in this case)
                $NodeName = $ChildNode.'#text'

            } else {

                # If child node is a value, add value to track list
                $NodeValue = $ChildNode.'#text'
                $TrackIDs += $NodeValue

                $Cnt++
                if ($Cnt % 10 -eq 0) {
                    Write-Host "." -NoNewline
                }
                if ($Cnt % 1000 -eq 0) {
                    Write-Host ""
                }

            }

            # Toggle key/value identifier
            $IsKey = !$IsKey

        }

        # Add track list to playlist
        $PlayList.Add("Track IDs", $TrackIDs)

        Write-Host ""
        WriteLog "- - - [$($TrackIDs.Count)] track(s) in [$($PlayList.Name)]"

        # Add playlist to list of playlists
        $AllPlayLists += $PlayList

    }
    WriteLog "- Got [$($AllPlayLists.Count)] playlists in total"

}

# Loop over media folders and run robocopy to synchronize target folders with source folders
if ($StatusOK) {

    WriteLog "Synchronizing media folders"
    foreach ($MediaFolder in $MediaFolders) {

        $SourceFolderFull = $MediaPath + $MediaFolder
        $TargetFolderFull = $TargetPath + $MediaFolder

        WriteLog "- Checking [$TargetFolderFull]"
        if (!(Test-Path -Path $TargetFolderFull)) {

            WriteLog "- Folder not found, creating it"
            try {
                New-Item -ItemType Directory -Path $TargetPath -Name ($MediaFolder -replace "\\","")
            }
            catch {
                ExpandError "Error creating media folder [$TargetFolderFull]"
                $StatusOK = $false
            }

        }

        if ($StatusOK) {

            $CommandToRun = "robocopy ""$SourceFolderFull"" ""$TargetFolderFull"" /E /PURGE /DCOPY:DAT /R:10 /FP /NP /BYTES /UNILOG+:""$LogFileName"" /TEE"
            WriteLog "- Running robocopy to synchronise folder contents"
            WriteLog "- Command line: [$CommandToRun]"
            try {
                Invoke-Expression -Command $CommandToRun
            }
            catch {
                ExpandError "Error synchronising [$SourceFolderFull] to [$TargetFolderFull] using robocopy"
                $StatusOK = $false
            }

        }

    }

}

if ($StatusOK) {

    # All existing files are purged first
    WriteLog "Removing old playlist files from [$TargetPath]"
    foreach ($FileExt in $FileExtensions) {

        try {
            WriteLog "- [$FileExt]"
            Remove-Item ($TargetPath + "\*" + $FileExt) -Force
        }
        catch {
            ExpandError "Error purging [$FileExt] files from [$TargetPath]"
            $StatusOK = $false
        }

    }

}

if ($StatusOK) {

    # Now create playlist files based on iTunes playlists, but with our paths and as M3U
    WriteLog "Exporting selected playlists to files in [$TargetPath]"
    $SelectedPlayLists = $AllPlayLists | Where-Object {$ExcludedPlayLists -notcontains $_.Name}

    foreach ($PlayList in $SelectedPlayLists) {

        # Files are UTF-8 Unix (LF only)
        # Dots in playlist names are replaced by underscores
        $FileName = $PlayList.Name -replace "\.","_"
        # File header
        $FileContents = "#EXTM3U`n"

        # Parse all tracks in list
        foreach ($TrackID in $PlayList.'Track IDs') {

            # Get track details for current ID
            $Track = $AllTracks.$TrackID
            $TrackPath = [System.Web.HttpUtility]::UrlDecode($Track.Location) -replace ($PathPrefix + ".+" + $iTunesMedia),"" -replace "\\",""
            $FileContents += $TrackPath + "`n"

        }

        WriteLog "- [$FileName] ([$($PlayList.'Track IDs'.Count)] track(s))"
        foreach ($FileExt in $FileExtensions) {

            $FileNameFull = $TargetPath + "\" + $FileName + $FileExt
            try {
                $FileContents | Out-File -FilePath $FileNameFull -Encoding utf8 -NoNewline -Force
            }
            catch {
                ExpandError "Error writing playlist file [$FileNameFull]"
                $StatusOK = $false
            }

        }

    }

    WriteLog "Exported [$($SelectedPlayLists.Count)] playlists to files in [$TargetPath]"
    WriteLog ("Skipped playlists: [" + ($ExcludedPlayLists -join ";") + "]")

}

WriteLog "Waiting for [OK] in message window..."

if ($StatusOK) {

    $tmp = OK-Message -Title "Success!" -Message "Media files and playlists successfully synchronized to [$TargetPath]"

} else {

    $tmp = Error-Message -Title "Synchronizing ended with errors." -Message "Media files and playlists could not be synchronized to [$TargetPath].`r`nSee [$LogFileName] for details."

}

WriteLog "Finished script execution"

# Restore error handling setting
$ErrorActionPreference = $_ErrorActionPreference 

Leave a Reply

Your email address will not be published. Required fields are marked *

nineteen − 9 =