Skip to content

Instantly share code, notes, and snippets.

@hendriks73
Last active June 10, 2020 19:20
Show Gist options
  • Save hendriks73/6d97016245cd93ca45c57f682a280522 to your computer and use it in GitHub Desktop.
Save hendriks73/6d97016245cd93ca45c57f682a280522 to your computer and use it in GitHub Desktop.
JXA-based JavaScript that creates an XML file for Music.app like iTunes used to create before macOS 10.15.
/***********************************************************************
* JXA-based JavaScript that creates an XML file for Music.app like *
* iTunes used to create before macOS 10.15. *
* *
* NOTE: The XML is not identical, but a best effort is made. *
* Missing are for example all B64-encoded smart data and the *
* protected flag as well as certain distinguished playlists *
* kinds. For a real replacement, use the ITLibrary API . *
* *
* USAGE: *
* *
* To write XML to standard out call: * *
* osascript -l JavaScript musicxml.js * *
* *
* To write XML to a file: * *
* osascript -l JavaScript musicxml.js YOUR_FILE * *
* *
* AUTHOR: Hendrik Schreiber *
* *
* LICENSE: MPL 2.0 *
* *
* This Source Code Form is subject to the terms of the Mozilla Public *
* License, v. 2.0. If a copy of the MPL was not distributed with this *
* file, You can obtain one at http://mozilla.org/MPL/2.0/. *
***********************************************************************/
/**
* Create ISO string without milliseconds.
*/
Date.prototype.toiTunesISOString = function() {
const regularISO = this.toISOString()
// cut off milliseconds, as iTunes does not export those
return regularISO.substring(0, 19) + "Z"
}
/**
* Make sure we escape certain characters for XML.
*/
function escapeXML(unsafe) {
return unsafe.toString().replace(/[<>&'"]/g, function (c) {
switch (c) {
case '<': return '&lt;'
case '>': return '&gt;'
case '&': return '&amp;'
case '\'': return '&apos;'
case '"': return '&quot;'
}
})
}
/**
* Get command line arguments.
*/
function argv() {
const args = $.NSProcessInfo.processInfo.arguments
const argc = args.count
// Build the normal argv/argc
let argv = []
for (let i = 0; i < argc; i++) {
argv.push(ObjC.unwrap(args.objectAtIndex(i)))
}
delete args
return argv
}
/**
* Convert a playlist object to an XML string.
*/
function playlistToXML(playlist) {
const properties = playlist.properties()
let xmlPlaylist = `<dict>\n`
const klass = properties["class"]
if (klass == 'libraryPlaylist') {
xmlPlaylist = xmlPlaylist.concat(`<key>Master</key><true/>\n`)
}
const visible = properties["visible"]
if (!visible) {
xmlPlaylist = xmlPlaylist.concat(`<key>Visible</key><false/>\n`)
}
const playlistID = properties["id"]
xmlPlaylist = xmlPlaylist.concat(`<key>Playlist ID</key><integer>${playlistID}</integer>\n`)
try {
const parent = playlist.parent()
if (parent != null) {
xmlPlaylist = xmlPlaylist.concat(`<key>Parent Persistent ID</key><string>${parent.persistentID()}</string>\n`)
}
} catch (error) {
// ignore - happens when the playlist does not have a parent
}
const persistentID = properties["persistentID"]
xmlPlaylist = xmlPlaylist.concat(`<key>Playlist Persistent ID</key><string>${persistentID}</string>\n`)
const specialKind = properties["specialKind"]
if (specialKind == "folder") {
xmlPlaylist = xmlPlaylist.concat(`<key>Folder</key><true/>\n`)
}
if (specialKind == "Movies") {
xmlPlaylist = xmlPlaylist.concat(`<key>Distinguished Kind</key><integer>2</integer>\n<key>Movies</key><true/>\n`)
} else if (specialKind == "TV Shows") {
xmlPlaylist = xmlPlaylist.concat(`<key>Distinguished Kind</key><integer>3</integer>\n<key>TV Shows</key><true/>\n`)
} else if (specialKind == "Music") {
xmlPlaylist = xmlPlaylist.concat(`<key>Distinguished Kind</key><integer>4</integer>\n<key>Music</key><true/>\n`)
} else if (specialKind == "Books") {
xmlPlaylist = xmlPlaylist.concat(`<key>Distinguished Kind</key><integer>5</integer>\n<key>Audiobooks</key><true/>\n`)
} else if (specialKind == "Podcasts") {
xmlPlaylist = xmlPlaylist.concat(`<key>Distinguished Kind</key><integer>10</integer>\n<key>Podcasts</key><true/>\n`)
} else if (specialKind == "Purchased Music") {
xmlPlaylist = xmlPlaylist.concat(`<key>Distinguished Kind</key><integer>19</integer>\n<key>Purchased Music</key><true/>\n`)
} else if (specialKind == "Genius") {
xmlPlaylist = xmlPlaylist.concat(`<key>Distinguished Kind</key><integer>26</integer>\n<key>Genius</key><true/>\n`)
} else if (specialKind == "iTunes U") {
xmlPlaylist = xmlPlaylist.concat(`<key>Distinguished Kind</key><integer>31</integer>\n<key>iTunes U</key><true/>\n`)
}
// other distinguished kinds: downloaded? voice memos?
const name = properties["name"]
if (name != null && name.length > 0) {
xmlPlaylist = xmlPlaylist.concat(`<key>Name</key><string>${escapeXML(name)}</string>\n`)
}
const description = properties["description"]
if (description != null && description.length > 0) {
xmlPlaylist = xmlPlaylist.concat(`<key>Description</key><string>${escapeXML(description)}</string>\n`)
}
if (klass == 'userPlaylist') {
if (properties["smart"] || properties["genius"]) {
xmlPlaylist = xmlPlaylist.concat(`<key>Smart Info</key><data/>\n`)
xmlPlaylist = xmlPlaylist.concat(`<key>Smart Criteria</key><data/>\n`)
}
}
const loved = properties["loved"]
if (loved) {
xmlPlaylist = xmlPlaylist.concat(`<key>Loved</key><true/>\n`)
}
const disliked = properties["disliked"]
if (disliked) {
xmlPlaylist = xmlPlaylist.concat(`<key>Disliked</key><true/>\n`)
}
// list tracks
const tracks = playlist.tracks
const trackCount = tracks.length
xmlPlaylist = xmlPlaylist.concat(`<key>Playlist Items</key>\n<array>\n`)
for (let j=0; j<trackCount; j++) {
xmlPlaylist = xmlPlaylist.concat(`<dict><key>Track ID</key><integer>${tracks[j].databaseID()}</integer></dict>\n`)
}
xmlPlaylist = xmlPlaylist.concat(`</array>\n`)
if (klass == 'userPlaylist' && properties["genius"] && tracks.length > 0) {
// we are guessing that the genius list is always based on the first track
xmlPlaylist = xmlPlaylist.concat(`<key>Genius Track ID</key><integer>${tracks[0].databaseID()}</integer>\n`)
}
xmlPlaylist = xmlPlaylist.concat(`</dict>\n`)
return xmlPlaylist
}
/**
* Convert a track object to an XML string.
*/
function trackToXML(track) {
const properties = track.properties()
console.log(Object.entries(properties).toString())
const trackID = properties["databaseID"]
console.log(trackID)
console.log(track.persistentID())
let xmlTrack = `<key>${trackID}</key>\n<dict>\n<key>Track ID</key><integer>${trackID}</integer>\n`
const size = properties["size"]
if (size > 0) {
xmlTrack = xmlTrack.concat(`<key>Size</key><integer>${size}</integer>\n`)
}
const totalTime = properties["duration"] * 1000
if (totalTime > 0) {
xmlTrack = xmlTrack.concat(`<key>Total Time</key><integer>${Math.round(totalTime)}</integer>\n`)
}
const startTime = properties["start"] * 1000
if (startTime > 0) {
xmlTrack = xmlTrack.concat(`<key>Start Time</key><integer>${Math.round(startTime)}</integer>\n`)
}
const stopTime = properties["finish"] * 1000
if (stopTime > 0 && stopTime != totalTime) {
xmlTrack = xmlTrack.concat(`<key>Stop Time</key><integer>${Math.round(stopTime)}</integer>\n`)
}
const discNumber = properties["discNumber"]
if (discNumber > 0) {
xmlTrack = xmlTrack.concat(`<key>Disc Number</key><integer>${discNumber}</integer>\n`)
}
const discCount = properties["discCount"]
if (discCount > 0) {
xmlTrack = xmlTrack.concat(`<key>Disc Count</key><integer>${discCount}</integer>\n`)
}
const trackNumber = properties["trackNumber"]
if (trackNumber > 0) {
xmlTrack = xmlTrack.concat(`<key>Track Number</key><integer>${trackNumber}</integer>\n`)
}
const trackCount = properties["trackCount"]
if (trackCount > 0) {
xmlTrack = xmlTrack.concat(`<key>Track Count</key><integer>${trackCount}</integer>\n`)
}
const year = properties["year"]
if (year > 0) {
xmlTrack = xmlTrack.concat(`<key>Year</key><integer>${year}</integer>\n`)
}
const bpm = properties["bpm"]
if (bpm > 0) {
xmlTrack = xmlTrack.concat(`<key>BPM</key><integer>${bpm}</integer>\n`)
}
const dateModified = properties["modificationDate"]
if (dateModified != null) {
xmlTrack = xmlTrack.concat(`<key>Date Modified</key><date>${dateModified.toiTunesISOString()}</date>\n`)
}
const dateAdded = properties["dateAdded"]
if (dateAdded != null) {
xmlTrack = xmlTrack.concat(`<key>Date Added</key><date>${dateAdded.toiTunesISOString()}</date>\n`)
}
const bitRate = properties["bitRate"]
if (bitRate > 0) {
xmlTrack = xmlTrack.concat(`<key>Bit Rate</key><integer>${bitRate}</integer>\n`)
}
const sampleRate = properties["sampleRate"]
if (sampleRate > 0) {
xmlTrack = xmlTrack.concat(`<key>Sample Rate</key><integer>${sampleRate}</integer>\n`)
}
const gapless = properties["gapless"]
if (gapless) {
xmlTrack = xmlTrack.concat(`<key>Part Of Gapless Album</key><true/>\n`)
}
const playCount = properties["playedCount"]
if (playCount > 0) {
xmlTrack = xmlTrack.concat(`<key>Play Count</key><integer>${playCount}</integer>\n`)
}
let playDateUTC = ""
let playDate = 0
const playedDate = properties["playedDate"]
if (playedDate != null) {
xmlTrack = xmlTrack.concat(`<key>Play Date UTC</key><date>${playedDate.toiTunesISOString()}</date>\n`)
playDate = (playedDate.getTime() - epoch) / 1000
xmlTrack = xmlTrack.concat(`<key>Play Date</key><integer>${playDate}</integer>\n`)
}
const skipCount = properties["skippedCount"]
if (skipCount > 0) {
xmlTrack = xmlTrack.concat(`<key>Skip Count</key><integer>${skipCount}</integer>\n`)
}
const skipDate = properties["skippedDate"]
if (skipDate != null) {
xmlTrack = xmlTrack.concat(`<key>Skip Date</key><date>${skipDate.toiTunesISOString()}</date>\n`)
}
const rating = properties["rating"]
if (rating > 0) {
xmlTrack = xmlTrack.concat(`<key>Rating</key><integer>${rating}</integer>\n`)
}
const ratingComputed = properties["ratingKind"] == "computed"
if (ratingComputed) {
xmlTrack = xmlTrack.concat(`<key>Rating Computed</key><true/>\n`)
}
const albumRating = properties["albumRating"]
if (albumRating > 0) {
xmlTrack = xmlTrack.concat(`<key>Album Rating</key><integer>${albumRating}</integer>\n`)
}
const albumRatingComputed = properties["albumRatingKind"] == "computed"
if (albumRatingComputed) {
xmlTrack = xmlTrack.concat(`<key>Album Rating Computed</key><true/>\n`)
}
const persistentID = properties["persistentID"]
xmlTrack = xmlTrack.concat(`<key>Persistent ID</key><string>${persistentID}</string>\n`)
let klass = properties["class"]
let trackType = klass
if (klass == "urlTrack") {
trackType = "URL"
} else if (klass == "fileTrack") {
trackType = "File"
} else if (klass == "sharedTrack") {
trackType = "Remote"
}
xmlTrack = xmlTrack.concat(`<key>Track Type</key><string>${trackType}</string>\n`)
const movementNumber = properties["movementNumber"]
if (movementNumber > 0) {
xmlTrack = xmlTrack.concat(`<key>Movement Number</key><integer>${movementNumber}</integer>\n`)
}
const movementCount = properties["movementCount"]
if (movementCount > 0) {
xmlTrack = xmlTrack.concat(`<key>Movement Count</key><integer>${movementCount}</integer>\n`)
}
const movementName = properties["movement"]
if (movementName != null && movementName.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Movement Name</key><string>${escapeXML(movementName)}</string>\n`)
}
const work = properties["work"]
if (work != null && work.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Work</key><string>${escapeXML(work)}</string>\n`)
}
const grouping = properties["grouping"]
if (grouping != null && grouping.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Grouping</key><string>${escapeXML(grouping)}</string>\n`)
}
const comments = properties["comment"]
if (comments != null && comments.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Comments</key><string>${escapeXML(comments)}</string>\n`)
}
const name = properties["name"]
if (name != null && name.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Name</key><string>${escapeXML(name)}</string>\n`)
}
const artist = properties["artist"]
if (artist != null && artist.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Artist</key><string>${escapeXML(artist)}</string>\n`)
}
const album = properties["album"]
if (album != null && album.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Album</key><string>${escapeXML(album)}</string>\n`)
}
const albumArtist = properties["albumArtist"]
if (albumArtist != null && albumArtist.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Album Artist</key><string>${escapeXML(albumArtist)}</string>\n`)
}
const composer = properties["composer"]
if (composer != null && composer.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Composer</key><string>${escapeXML(composer)}</string>\n`)
}
const genre = properties["genre"]
if (genre != null && genre.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Genre</key><string>${escapeXML(genre)}</string>\n`)
}
const kind = properties["kind"]
if (kind != null && kind.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Kind</key><string>${escapeXML(kind)}</string>\n`)
}
const sortName = properties["sortName"]
if (sortName != null && sortName.length > 0 && sortName != name) {
xmlTrack = xmlTrack.concat(`<key>Sort Name</key><string>${escapeXML(sortName)}</string>\n`)
}
const sortComposer = properties["sortComposer"]
if (sortComposer != null && sortComposer.length > 0 && sortComposer != composer) {
xmlTrack = xmlTrack.concat(`<key>Sort Composer</key><string>${escapeXML(sortComposer)}</string>\n`)
}
const sortArtist = properties["sortArtist"]
if (sortArtist != null && sortArtist.length > 0 && sortArtist != artist) {
xmlTrack = xmlTrack.concat(`<key>Sort Artist</key><string>${escapeXML(sortArtist)}</string>\n`)
}
const sortAlbumArtist = properties["sortAlbumArtist"]
if (sortAlbumArtist != null && sortAlbumArtist.length > 0 && sortAlbumArtist != albumArtist) {
xmlTrack = xmlTrack.concat(`<key>Sort Album Artist</key><string>${escapeXML(sortAlbumArtist)}</string>\n`)
}
const sortAlbum = properties["sortAlbum"]
if (sortAlbum != null && sortAlbum.length > 0 && sortAlbum != album) {
xmlTrack = xmlTrack.concat(`<key>Album</key><string>${escapeXML(sortAlbum)}</string>\n`)
}
const disabled = !properties["enabled"]
if (disabled) {
xmlTrack = xmlTrack.concat(`<key>Disabled</key><true/>\n`)
}
const disliked = properties["disliked"]
if (disliked) {
xmlTrack = xmlTrack.concat(`<key>Disliked</key><true/>\n`)
}
const albumDisliked = properties["albumDisliked"]
if (albumDisliked) {
xmlTrack = xmlTrack.concat(`<key>Album Disliked</key><true/>\n`)
}
const loved = properties["loved"]
if (loved) {
xmlTrack = xmlTrack.concat(`<key>Loved</key><true/>\n`)
}
const albumLoved = properties["albumLoved"]
if (albumLoved) {
xmlTrack = xmlTrack.concat(`<key>Album Loved</key><true/>\n`)
}
const artworkCount = track.artworks.length
if (artworkCount > 0) {
xmlTrack = xmlTrack.concat(`<key>Artwork Count</key><integer>${artworkCount}</integer>\n`)
}
const unplayed = properties["unplayed"]
if (unplayed) {
xmlTrack = xmlTrack.concat(`<key>Unplayed</key><true/>\n`)
}
const mediaKind = properties["mediaKind"]
const podcast = mediaKind === "podcast"
if (podcast) {
xmlTrack = xmlTrack.concat(`<key>Podcast</key><true/>\n`)
}
const movie = mediaKind === "movie"
if (movie) {
xmlTrack = xmlTrack.concat(`<key>Movie</key><true/>\n`)
}
const homeVideo = mediaKind === "home video"
if (homeVideo) {
xmlTrack = xmlTrack.concat(`<key>Home Video</key><true/>\n`)
}
const tvShow = mediaKind === "TV show"
if (tvShow) {
xmlTrack = xmlTrack.concat(`<key>TV Show</key><true/>\n`)
}
const releaseDate = properties["releaseDate"]
if (releaseDate != null) {
xmlTrack = xmlTrack.concat(`<key>Release Date</key><date>${releaseDate.toiTunesISOString()}</date>\n`)
}
const compilation = properties["compilation"]
if (compilation) {
xmlTrack = xmlTrack.concat(`<key>Compilation</key><true/>\n`)
}
const hasVideo = properties["mediaKind"] != "Music Video"
if (hasVideo) {
xmlTrack = xmlTrack.concat(`<key>Has Video</key><true/>\n`)
}
const purchaserName = properties["downloaderName"]
if (purchaserName != null && purchaserName.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Purchased</key><true/>\n`)
}
const season = properties["seasonNumber"]
if (season > 0) {
xmlTrack = xmlTrack.concat(`<key>Season</key><integer>${season}</integer>\n`)
}
const episodeOrder = properties["episodeNumber"]
if (episodeOrder > 0) {
xmlTrack = xmlTrack.concat(`<key>Episode Order</key><integer>${episodeOrder}</integer>\n`)
}
const series = properties["show"]
if (series != null && series.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Series</key><string>${escapeXML(series)}</string>\n`)
}
const sortSeries = properties["sortShow"]
if (sortSeries != null && sortSeries.length > 0 && sortSeries != series) {
xmlTrack = xmlTrack.concat(`<key>Sort Series</key><string>${escapeXML(sortSeries)}</string>\n`)
}
const episode = properties["episodeID"]
if (episode != null && episode.length > 0) {
xmlTrack = xmlTrack.concat(`<key>Episode</key><string>${escapeXML(episode)}</string>\n`)
}
if (trackType === "URL") {
const location = properties["address"]
xmlTrack = xmlTrack.concat(`<key>Location</key><string>${escapeXML(encodeURI(location))}</string>\n`)
}
else if (trackType === "File") {
const location = properties["location"]
xmlTrack = xmlTrack.concat(`<key>Location</key><string>file://${escapeXML(encodeURI(location))}</string>\n`)
}
xmlTrack = xmlTrack.concat(`</dict>\n`)
// Protected?
// Category? (used for podcasts)
return xmlTrack
}
// very simple-minded parameter validation
const args = argv()
if (args.length < 4 || args.length > 5) {
console.log(`Invalid parameter: ${args.toString()}`)
ObjC.import('stdlib')
$.exit(128)
}
const music = Application("Music")
music.fixedIndexing(true)
const date = new Date().toiTunesISOString()
const version = music.version()
const source = music.sources[0]
const libraryId = source.persistentID()
const libraryPlaylist = source.libraryPlaylists[0]
const musicFolder = 'unknown'
const epoch = new Date("1904-01-01T00:00:00Z").getTime()
const header = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Major Version</key><integer>1</integer>
<key>Minor Version</key><integer>1</integer>
<key>Application Version</key><string>${version}</string>
<key>Date</key><date>${date}</date>
<key>Features</key><integer>5</integer>
<key>Show Content Ratings</key><true/>
<key>Library Persistent ID</key><string>${libraryId}</string>
<key>Creator</key><string>musicxml.js</string>
`
const footer = `<key>Music Folder</key><string>${escapeXML(musicFolder)}</string>
</dict>
</plist>
`
let out = ''
out = out.concat(header)
// iterate over tracks
out = out.concat(`<key>Tracks</key>\n<dict>\n`)
const allTracks = libraryPlaylist.tracks
const trackCount = allTracks.length
for (let i=0; i<trackCount; i++) {
out = out.concat(trackToXML(allTracks[i]))
}
out = out.concat(`</dict>\n`)
// iterate over playlists
out = out.concat(`<key>Playlists</key>\n<array>\n`)
const allPlaylists = source.playlists
const playlistCount = allPlaylists.length
for (let i=0; i<playlistCount; i++) {
out = out.concat(playlistToXML(allPlaylists[i]))
}
out = out.concat(`</array>\n`)
out = out.concat(footer)
// output
if (args.length == 5) {
// if file name is passed as argument, write to that file
const file = args[4].toString()
const nsPath = $(file).stringByStandardizingPath
fileStr = $.NSString.alloc.initWithUTF8String(out)
fileStr.writeToFileAtomicallyEncodingError(nsPath, true, $.NSUTF8StringEncoding, $())
const message = `Exported library to: ${file}`
message
} else {
// just dump to standard out, if no file name is given
// how will this be char encoded? UTF-8?
out
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment