![Log extract](https://klassen.digital/wp-content/uploads/2024/03/Sync-iTunes.gif)
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