{"id":412,"date":"2024-03-17T15:41:42","date_gmt":"2024-03-17T14:41:42","guid":{"rendered":"https:\/\/klassen.digital\/?page_id=412"},"modified":"2024-05-20T12:54:27","modified_gmt":"2024-05-20T10:54:27","slug":"resource-sync-itunes-media-library","status":"publish","type":"page","link":"https:\/\/klassen.digital\/?page_id=412","title":{"rendered":"Resource: Sync iTunes Media Library"},"content":{"rendered":"\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1665\" height=\"1107\" src=\"https:\/\/klassen.digital\/wp-content\/uploads\/2024\/03\/Sync-iTunes.gif\" alt=\"Log extract\" class=\"wp-image-414\"\/><figcaption class=\"wp-element-caption\"><em>Log extract of a script run &#8211; Image source: private<\/em><\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Script to synchronise iTunes media library to SD card, drive etc.<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Description<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;t get a drive letter assigned cannot be used as a target.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The full header with documentation, examples etc. is missing, but I wanted to make this tool available anyway.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Source code<\/h3>\n\n\n\n<div class=\"wp-block-codemirror-blocks-code-block code-block\"><pre class=\"CodeMirror\" data-setting=\"{&quot;showPanel&quot;:true,&quot;languageLabel&quot;:&quot;language&quot;,&quot;fullScreenButton&quot;:true,&quot;copyButton&quot;:true,&quot;mode&quot;:&quot;powershell&quot;,&quot;mime&quot;:&quot;application\/x-powershell&quot;,&quot;theme&quot;:&quot;elegant&quot;,&quot;lineNumbers&quot;:true,&quot;styleActiveLine&quot;:false,&quot;lineWrapping&quot;:true,&quot;readOnly&quot;:true,&quot;fileName&quot;:&quot;&quot;,&quot;language&quot;:&quot;PowerShell&quot;,&quot;maxHeight&quot;:&quot;400px&quot;,&quot;modeName&quot;:&quot;powershell&quot;}\">Set-StrictMode -Off #-Version Latest\n\n# Path where the iTunes media library resides\n$SourcePathOptions = @(\n    &quot;G:\\Backup.NAS\\User\\Eigene Dateien\\Eigene Musik\\iTunes&quot;,\n    &quot;\\\\nas\\Share\\User\\Eigene Dateien\\Eigene Musik\\iTunes&quot;,\n    &quot;I:\\Backup.NAS\\User\\Eigene Dateien\\Eigene Musik\\iTunes&quot;\n)\n\n# Folders containing media files to be synchronized\n$MediaFolders = @(&quot;\\Audiobooks&quot;,&quot;\\Music&quot;)\n\n# Path to synchronize iTunes contents and playlists to\n# Usually on the SD card from the phone\n$TargetPathOptions = @(\n    &quot;D:\\iTunes&quot;,\n    &quot;E:\\iTunes&quot;,\n    &quot;F:\\iTunes&quot;\n)\n\n# Subfolder holding the media files (UNIX notation)\n$iTunesMedia = &quot;\/iTunes Media&quot;\n\n# Names of playlists to not be synchronized\n$ExcludedPlayLists = @(&quot;Mediathek&quot;,&quot;Geladen&quot;,&quot;Musik&quot;,&quot;TV-Sendungen&quot;,&quot;Ohne Cover&quot;,&quot;Pummis Listen&quot;,&quot;Vordefiniert&quot;,&quot;Filme&quot;,&quot;Podcasts&quot;)\n\n# File extensions for playlists\n$FileExtensions = @(&quot;.m3u&quot;,&quot;.plb&quot;)\n\n# Prefix used in iTunes playlists\n$PathPrefix = &quot;file:\/\/localhost&quot;\n\n# Get path and name of current script\n$scriptDir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent\n$CurrentScriptName = $MyInvocation.MyCommand.Name\n\n# For file handling\n$LogFileName = &quot;$scriptDir\\$CurrentScriptName.log&quot;\n\n# For Error handling\n$ErrorPrefix = &quot;Sync-iTunes_Error&quot;\n\n# Display an OK message\nfunction OK-Message {\nparam(\n    [Parameter(Mandatory=$true)]\n    [String]$Message,\n    [String]$Title = \u201cFertig!\u201d\n)\n\n    Add-Type -AssemblyName PresentationCore,PresentationFramework\n    $ButtonType = [System.Windows.MessageBoxButton]::OK\n    $MessageboxTitle = $Title\n    $Messageboxbody = $Message\n    $MessageIcon = [System.Windows.MessageBoxImage]::Information\n    return [System.Windows.MessageBox]::Show($Messageboxbody,$MessageboxTitle,$ButtonType,$messageicon)\n\n}\n\n# Display an error message\nfunction Error-Message {\nparam(\n    [Parameter(Mandatory=$true)]\n    [String]$Message,\n    [String]$Title = \u201cFehler!\u201d\n)\n\n    Add-Type -AssemblyName PresentationCore,PresentationFramework\n    $ButtonType = [System.Windows.MessageBoxButton]::OK\n    $MessageboxTitle = $Title\n    $Messageboxbody = $Message\n    $MessageIcon = [System.Windows.MessageBoxImage]::Error\n    return [System.Windows.MessageBox]::Show($Messageboxbody,$MessageboxTitle,$ButtonType,$messageicon)\n\n}\n\n# Write log message with timestamp to console\nfunction WriteLog {\nparam([String]$LogMessage,\n      [Switch]$Overwrite)\n\n\t$TimeStamp = Get-Date -Format &quot;yyyy-MM-dd HH:mm:ss&quot;\n    if ($Overwrite) {\n        cls\n    }\n    Write-Host &quot;$TimeStamp - $LogMessage&quot;\n\t$Error.Clear()\n\tTry\n\t{\n\t\tif ($Overwrite) {\n\t\t\tWrite-Output &quot;$TimeStamp - $LogMessage&quot; | Out-File $LogFileName -Encoding unicode\n\t\t} else {\n\t\t\tWrite-Output &quot;$TimeStamp - $LogMessage&quot; | Out-File $LogFileName -Encoding unicode -Append\n\t\t}\n\t}\n\tCatch\n\t{\n\t\tWrite-Error &quot;$ErrorPrefix`: Writing to log file [$LogFileName] failed&quot;\n\t    $Exception = $Error | Select-Object -Property Exception\n\t    Write-Error $Exception.Exception.ToString()\n\t}\n\n}\n\n# Expand error message and write to console and log\nfunction ExpandError {\nparam([String]$ErrorMessage)\n\n    $Exception = $Error | Select-Object -Property Exception\n    $ExceptionString = $Exception.Exception.ToString()\n\t$Error.Clear()\n    WriteLog &quot;$ErrorPrefix`: $ErrorMessage&quot;\n\tWriteLog $ExceptionString\n\n    $tmp = Error-Message -Title $ErrorMessage -Message $ExceptionString\n\n}\n\nfunction Get-Path {\nparam(\n    $PathOptions\n)\n\n    $OptionsCnt = $PathOptions.Length\n    $OptionNum = 0\n    $StatusOK = $false\n    $Path = $null\n\n    while ($OptionNum -lt $OptionsCnt -and !$StatusOK) {\n\n        WriteLog &quot;- Checking [$($PathOptions[$OptionNum])]&quot;\n        if (Test-Path -Path $PathOptions[$OptionNum]) {\n\n            $Path = $PathOptions[$OptionNum]\n            $StatusOK = $true\n\n        }\n\n        $OptionNum ++\n\n    }\n\n    return $Path\n\n}\n\n# Save current error handling setting and enable try..catch\n$_ErrorActionPreference = $ErrorActionPreference\n$ErrorActionPreference = &quot;Stop&quot;\n\nWriteLog -LogMessage &quot;Starting script execution&quot; -Overwrite\n\n# For URI encoding\/decoding\n# Use [System.Web.HttpUtility]::UrlEncode() and [System.Web.HttpUtility]::UrlDecode()\nAdd-Type -AssemblyName System.Web\n\n# Look for the iTunes library\nWriteLog &quot;Searching for source path&quot;\n$StatusOK = $false\n$SourcePath = Get-Path $SourcePathOptions\n\nif ($SourcePath) {\n\n    WriteLog &quot;Found source path [$SourcePath]&quot;\n    WriteLog &quot;Searching for target path&quot;\n    $TargetPath = Get-Path $TargetPathOptions\n    \n    if ($TargetPath) {\n\n        $StatusOK = $true\n        WriteLog &quot;Found target path [$TargetPath]&quot;\n\n    } else {\n\n        WriteLog &quot;No target path found&quot;\n        Error-Message -Title &quot;Error getting target path&quot; -Message &quot;No target path found in predefined list, see [$LogFileName] for details.&quot;\n\n    }\n\n} else {\n\n    WriteLog &quot;No source path found&quot;\n    Error-Message -Title &quot;Error getting source path&quot; -Message &quot;No source path found in predefined list, see [$LogFileName] for details.&quot;\n\n}\n\n# Path below the source path where the media folders are located\n$MediaPath = $SourcePath + $iTunesMedia\n\nif ($StatusOK) {\n\n    WriteLog &quot;Getting iTunes media library contents&quot;\n    try {\n        [xml]$iTunesLibrary = Get-Content -Path ($SourcePath + &quot;\\iTunes Music Library.xml&quot;) -Encoding UTF8\n    } \n    catch {\n        ExpandError &quot;Failed to get iTunes media library contents&quot;\n        $StatusOK = $false\n    }\n\n}\n\nif ($StatusOK) {\n\n    WriteLog &quot;Analyzing media library&quot;\n\n    WriteLog &quot;- Extracting raw data&quot;\n    \n    # All tracks in raw format, read from all nested dict elements within dict.dict\n    $AllTracksRaw = $iTunesLibrary.plist.dict.dict.dict\n    WriteLog &quot;- - [$($AllTracksRaw.Count)] tracks&quot;\n\n    # All playlists in raw format, read from nested dict elements within dict.array\n    $AllPlayListsRaw = $iTunesLibrary.plist.dict.array.dict\n    WriteLog &quot;- - [$($AllPlayListsRaw.Count)] playlists&quot;\n\n    # Get all track information\n    # For performance reasons, this has been made a hash table with the track ID as key instead of an array\n    WriteLog &quot;- Gathering information from all tracks&quot;\n    $AllTracks = @{}\n    $Cnt = 9\n    foreach ($TrackRaw in $AllTracksRaw) {\n\n        # Keys and values are in alternating child nodes\n        $IsKey = $true\n        $Track = @{}\n\n        foreach ($ChildNode in $TrackRaw.ChildNodes) {\n\n            if ($IsKey) {\n\n                # If child node is a key, get name\n                $NodeName = $ChildNode.'#text'\n\n            } else {\n\n                # If child node is a value, add key\/value pair to track\n                $NodeValue = $ChildNode.'#text'\n                $Track.Add($NodeName, $NodeValue)\n\n            }\n\n            # Toggle key\/value identifier\n            $IsKey = !$IsKey\n\n        }\n\n        # Add track with all properties to track list\n        $AllTracks.Add($Track.'Track ID', $Track)\n\n        $Cnt++\n        if ($Cnt % 10 -eq 0) {\n            Write-Host &quot;.&quot; -NoNewline\n        }\n        if ($Cnt % 1000 -eq 0) {\n            Write-Host &quot;&quot;\n        }\n\n    }\n    Write-Host &quot;&quot;\n    WriteLog &quot;- Got [$($AllTracks.Count)] tracks&quot;\n\n    # Get all playlist information\n    WriteLog &quot;- Gathering information from all playlists&quot;\n    $AllPlayLists = @()\n    foreach ($PlayListRaw in $AllPlayListsRaw) {\n\n        # Keys and values are in alternating child nodes\n        $IsKey = $true\n        $PlayList = @{}\n\n        foreach ($ChildNode in $PlayListRaw.ChildNodes) {\n\n            if ($IsKey) {\n\n                # If child node is a key, get name\n                $NodeName = $ChildNode.'#text'\n\n            } else {\n\n                # If child node is a value, add key\/value pair to track\n                $NodeValue = $ChildNode.'#text'\n                $PlayList.Add($NodeName, $NodeValue)\n\n            }\n\n            # Toggle key\/value identifier\n            $IsKey = !$IsKey\n\n        }\n\n        WriteLog &quot;- - Playlist [$($PlayList.Name)]&quot;\n\n        # Get track list for playlist\n        # Keys and values are in alternating child nodes\n        $IsKey = $true\n        $TrackIDs = @()\n        $Cnt = 9\n\n        foreach ($ChildNode in $PlayListRaw.Array.dict.ChildNodes) {\n\n            if ($IsKey) {\n\n                # If child node is a key, get name (irrelevant in this case)\n                $NodeName = $ChildNode.'#text'\n\n            } else {\n\n                # If child node is a value, add value to track list\n                $NodeValue = $ChildNode.'#text'\n                $TrackIDs += $NodeValue\n\n                $Cnt++\n                if ($Cnt % 10 -eq 0) {\n                    Write-Host &quot;.&quot; -NoNewline\n                }\n                if ($Cnt % 1000 -eq 0) {\n                    Write-Host &quot;&quot;\n                }\n\n            }\n\n            # Toggle key\/value identifier\n            $IsKey = !$IsKey\n\n        }\n\n        # Add track list to playlist\n        $PlayList.Add(&quot;Track IDs&quot;, $TrackIDs)\n\n        Write-Host &quot;&quot;\n        WriteLog &quot;- - - [$($TrackIDs.Count)] track(s) in [$($PlayList.Name)]&quot;\n\n        # Add playlist to list of playlists\n        $AllPlayLists += $PlayList\n\n    }\n    WriteLog &quot;- Got [$($AllPlayLists.Count)] playlists in total&quot;\n\n}\n\n# Loop over media folders and run robocopy to synchronize target folders with source folders\nif ($StatusOK) {\n\n    WriteLog &quot;Synchronizing media folders&quot;\n    foreach ($MediaFolder in $MediaFolders) {\n\n        $SourceFolderFull = $MediaPath + $MediaFolder\n        $TargetFolderFull = $TargetPath + $MediaFolder\n\n        WriteLog &quot;- Checking [$TargetFolderFull]&quot;\n        if (!(Test-Path -Path $TargetFolderFull)) {\n\n            WriteLog &quot;- Folder not found, creating it&quot;\n            try {\n                New-Item -ItemType Directory -Path $TargetPath -Name ($MediaFolder -replace &quot;\\\\&quot;,&quot;&quot;)\n            }\n            catch {\n                ExpandError &quot;Error creating media folder [$TargetFolderFull]&quot;\n                $StatusOK = $false\n            }\n\n        }\n\n        if ($StatusOK) {\n\n            $CommandToRun = &quot;robocopy &quot;&quot;$SourceFolderFull&quot;&quot; &quot;&quot;$TargetFolderFull&quot;&quot; \/E \/PURGE \/DCOPY:DAT \/R:10 \/FP \/NP \/BYTES \/UNILOG+:&quot;&quot;$LogFileName&quot;&quot; \/TEE&quot;\n            WriteLog &quot;- Running robocopy to synchronise folder contents&quot;\n            WriteLog &quot;- Command line: [$CommandToRun]&quot;\n            try {\n                Invoke-Expression -Command $CommandToRun\n            }\n            catch {\n                ExpandError &quot;Error synchronising [$SourceFolderFull] to [$TargetFolderFull] using robocopy&quot;\n                $StatusOK = $false\n            }\n\n        }\n\n    }\n\n}\n\nif ($StatusOK) {\n\n    # All existing files are purged first\n    WriteLog &quot;Removing old playlist files from [$TargetPath]&quot;\n    foreach ($FileExt in $FileExtensions) {\n\n        try {\n            WriteLog &quot;- [$FileExt]&quot;\n            Remove-Item ($TargetPath + &quot;\\*&quot; + $FileExt) -Force\n        }\n        catch {\n            ExpandError &quot;Error purging [$FileExt] files from [$TargetPath]&quot;\n            $StatusOK = $false\n        }\n\n    }\n\n}\n\nif ($StatusOK) {\n\n    # Now create playlist files based on iTunes playlists, but with our paths and as M3U\n    WriteLog &quot;Exporting selected playlists to files in [$TargetPath]&quot;\n    $SelectedPlayLists = $AllPlayLists | Where-Object {$ExcludedPlayLists -notcontains $_.Name}\n\n    foreach ($PlayList in $SelectedPlayLists) {\n\n        # Files are UTF-8 Unix (LF only)\n        # Dots in playlist names are replaced by underscores\n        $FileName = $PlayList.Name -replace &quot;\\.&quot;,&quot;_&quot;\n        # File header\n        $FileContents = &quot;#EXTM3U`n&quot;\n\n        # Parse all tracks in list\n        foreach ($TrackID in $PlayList.'Track IDs') {\n\n            # Get track details for current ID\n            $Track = $AllTracks.$TrackID\n            $TrackPath = [System.Web.HttpUtility]::UrlDecode($Track.Location) -replace ($PathPrefix + &quot;.+&quot; + $iTunesMedia),&quot;&quot; -replace &quot;\\\\&quot;,&quot;&quot;\n            $FileContents += $TrackPath + &quot;`n&quot;\n\n        }\n\n        WriteLog &quot;- [$FileName] ([$($PlayList.'Track IDs'.Count)] track(s))&quot;\n        foreach ($FileExt in $FileExtensions) {\n\n            $FileNameFull = $TargetPath + &quot;\\&quot; + $FileName + $FileExt\n            try {\n                $FileContents | Out-File -FilePath $FileNameFull -Encoding utf8 -NoNewline -Force\n            }\n            catch {\n                ExpandError &quot;Error writing playlist file [$FileNameFull]&quot;\n                $StatusOK = $false\n            }\n\n        }\n\n    }\n\n    WriteLog &quot;Exported [$($SelectedPlayLists.Count)] playlists to files in [$TargetPath]&quot;\n    WriteLog (&quot;Skipped playlists: [&quot; + ($ExcludedPlayLists -join &quot;;&quot;) + &quot;]&quot;)\n\n}\n\nWriteLog &quot;Waiting for [OK] in message window...&quot;\n\nif ($StatusOK) {\n\n    $tmp = OK-Message -Title &quot;Success!&quot; -Message &quot;Media files and playlists successfully synchronized to [$TargetPath]&quot;\n\n} else {\n\n    $tmp = Error-Message -Title &quot;Synchronizing ended with errors.&quot; -Message &quot;Media files and playlists could not be synchronized to [$TargetPath].`r`nSee [$LogFileName] for details.&quot;\n\n}\n\nWriteLog &quot;Finished script execution&quot;\n\n# Restore error handling setting\n$ErrorActionPreference = $_ErrorActionPreference \n<\/pre><\/div>\n","protected":false},"excerpt":{"rendered":"<p>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&#8230; <a class=\"continue-reading-link\" href=\"https:\/\/klassen.digital\/?page_id=412\">More <span class=\"meta-nav\">&rarr; <\/span><\/a><\/p>\n","protected":false},"author":2,"featured_media":0,"parent":275,"menu_order":0,"comment_status":"open","ping_status":"closed","template":"templates\/template-onecolumn.php","meta":{"pgc_sgb_lightbox_settings":"","footnotes":""},"class_list":["post-412","page","type-page","status-publish","hentry"],"_links":{"self":[{"href":"https:\/\/klassen.digital\/index.php?rest_route=\/wp\/v2\/pages\/412","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/klassen.digital\/index.php?rest_route=\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/klassen.digital\/index.php?rest_route=\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/klassen.digital\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/klassen.digital\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=412"}],"version-history":[{"count":5,"href":"https:\/\/klassen.digital\/index.php?rest_route=\/wp\/v2\/pages\/412\/revisions"}],"predecessor-version":[{"id":425,"href":"https:\/\/klassen.digital\/index.php?rest_route=\/wp\/v2\/pages\/412\/revisions\/425"}],"up":[{"embeddable":true,"href":"https:\/\/klassen.digital\/index.php?rest_route=\/wp\/v2\/pages\/275"}],"wp:attachment":[{"href":"https:\/\/klassen.digital\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=412"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}