Improve your Applescripts: Return to iTunes Scripting

“Four and twenty blackbirds, baked in a pie.”
In my previous column I had to eat my own words where Automator was concerned. This month, apparently, I get to eat a dish of crow yet again, but on a different subject. Stick with me and I’ll show you the error of my ways and perhaps you can profit from my meal of bird feet and black feathers.

In March of last year, in a column entitled Advanced iTunes Scripting with Libraries, I talked about a script that I’d written to help fill my iPod Nano with entire albums by “last played date” instead of just individual songs, something that iTunes’ smart playlists can’t do. The script made use of the priority queue library from my Nite Flite set of library scripts, and it got the job done, albeit a bit slowly. It took almost 2 minutes to generate the list of albums. Since then, I’ve doubled the size of my iTunes library, so it was now taking over 6 minutes!

Over the last year, I’ve become increasingly annoyed at the length of time that this script takes to run. I’ve pulled the script into Script Editor on several occasions, and while I was able to give it a slight tweak from time to time, there was no way I could avoid the Big “O” issues (see my previous column Sorting and The Big “O”) that the script incurred by looping through the raw list of tracks to winnow down to just one instance of an album’s name, then looping through the smaller album list, then looping through all the tracks of the album to get the earliest date of any track on the album within the larger loop, which multiplies the inner loop processing by the outer loop! Beyond that, then enqueuing the album/date data (which required processing to sort the priority list in the separate library script) and subsequently dequeuing the data in the proper date order just couldn’t be done as quickly as I wanted to do it.

No Single Servings, Please
Added to this difficulty are the 89 free iTunes singles I’ve downloaded that don’t really count toward an album quota, various TV shows, movies, podcasts, and other flotsam and jetsam that I don’t need in a first generation iPod Nano (but they will take up room in the playlists that you sync to the Nano, leaving your Nano with a bunch of leftover room). Add the fact that at the time I wrote the script, I had to compute my own average album rating for each album and now iTunes supports album ratings, and you begin to see what I saw, and that is: That this script needed a serious re-write.

In my efforts to tweak the script, at one point I removed the section that pulled all the iTunes’ file tracks and replaced it with a more selective set of code. Here’s the original script:


--load the library
set scriptPath to (path to scripts folder from user domain) & "Script Library:"
set priorityLib to load script ((scriptPath as text) & "priorityLib.scpt") as alias

--create a newpriQueue
copy priorityLib's newPriQueue to albumsPriQueue
set priorityLib's newPQItem to {priority:"", cargo:""}

set kb to 1024
set mb to 1000 * kb
set gb to 1000 * mb
display dialog "How many gigabytes should I gather?" default answer "1.0"
set inputGB to text returned of the result
set NanoThreshold to (inputGB as real) * gb

set albumRatingMin to 3.5 * 20 --iTunes uses 0 to 100 internally, not 1-5 stars, so each star is 20
set prohibitedGenres to {"Country", "video", "TV Shows", "Podcast", "Holiday", "Christmas", "Comedy"}
set allAlbumsList to {}
(*begin*)
tell application "iTunes"
	set albumList to album of file tracks of library playlist 1 whose podcast is false
	set tcount to count albumList
end tell

--remove duplicates
repeat with i from 1 to tcount
	tell my albumList's item i to if it is not in my allAlbumsList and it ≠ "" then set end of my allAlbumsList to it
end repeat
(*end*)
display dialog "Processing " & (count of allAlbumsList) & " Albums" giving up after 5

--Let's see if the album meets our standards
tell application "iTunes"
	repeat with anAlbum in allAlbumsList
		set albumTracks to (every file track of playlist 1 whose album is anAlbum)
		
		--Get the average rating of the album
		set numTracks to count albumTracks
		set albumRating to 0
		repeat with aTrack in the albumTracks
			set albumRating to albumRating + (rating of aTrack)
		end repeat
		set albumRating to albumRating / numTracks
		
		if albumRating > albumRatingMin and numTracks > 1 then
			
			set lastPlayed to (current date)
			set prohibited to false
			
			repeat with aTrack in albumTracks
				--strip out undesirable tracks
				if genre of aTrack is in prohibitedGenres then set prohibited to true
				if played date of aTrack is missing value then
					set lastPlayed to date added of aTrack
				else if (played date of aTrack) < lastPlayed then
					set lastPlayed to (played date of aTrack)
				end if
			end repeat
			
			--reorder albums by last played date	 	
			if not prohibited then
				-- if we've made it this far, add the album to the queue
				set albumsPriQueue to priorityLib's priEnqueue(albumsPriQueue, anAlbum, lastPlayed)
			end if
		end if
	end repeat
	
	
	if (priorityLib's priQueueIsEmpty(albumsPriQueue)) then
		display dialog "Empty Queue."
	else
		--set up playlist
		if exists user playlist "iPod Albums" then
			delete every file track of user playlist "iPod Albums"
		else
			make new user playlist with properties {name:"iPod Albums"}
		end if
		repeat while ((size of user playlist "iPod Albums") ≤ NanoThreshold) and (not (priorityLib's priQueueIsEmpty(albumsPriQueue)))
			set albumsPriQueue to priorityLib's priDequeue(albumsPriQueue)
			set theResult to priorityLib's priQueueResult
			set theAlbum to cargo of theResult
			display dialog "Adding album " & theAlbum giving up after 1
			duplicate (every file track of library playlist 1 whose album is theAlbum) to user playlist "iPod Albums"
		end repeat
	end if
end tell

Notice the chunk of code between the (begin) and (end) tags. I never liked this code, the second section is very unclear, but it works and it works fairly fast, so I put up with it. But in thinking about reworking the script, I realized that I could limit my choices to:

  • 1 track from an album - why not track 1? (I'll show you why later)
  • Tracks that acknowledged a track count > 1 (eliminates singles)
  • Tracks whose disc number was either 1 or 0 (keeps us from processing more than 1 disc of a mulit-disc set)
  • Tracks whose (new) album rating was greater than or equal to 60%
  • Tracks that were not members of unwanted categories (TV, movies, etc.)
Well, the original script still tested for prohibited genres after the initial selection, so the last item on the above list was pretty much handled - all that was necessary was to limit the tracks' kind to "audio." So revising the section above between the begin and end tags rendered the somewhat improved version of the block:

--This is a code snippet, it won't run by itself!
	(*begin*)
	set allalbumList to album of file tracks of library playlist 1 whose (kind contains "audio" and track count > 1 and track number = 1 and (disc number = 1 or disc number = 0) and comment does not contain "omit")
	set tcount to count allalbumList
	(*end*)

This allowed me to remove the extra repeat that pared the list down to only a single occurrence of a given album. However, this version of the script still takes a long time to execute if you have 100+ albums (about 1500 tracks) in your iTunes library. The main reason is that it still loops through all the albums that are selected, then loops through each track of each album. With an average of 12 tracks per album (and that’s low for some double CD collections) and 100 albums, that requires 1200 iterations to find the lowest “last played date” for the albums in question.

Back to the Future
So, now back to the current rewrite. It occurred to me (finally) that what was needed was iTunes’ smart playlist ability to select tracks based on “last played date,” a function that (obviously) can’t be duplicated in Applescript without a lot of overhead.

And then lightening struck. Why not use a smart playlist to do the first half of the work for me? Once iTunes has selected the tracks with the oldest “last played date,” then I can process that list (as long as it is also sorted by “last played date”).

The idea had a lot of allure. Let the application I’m scripting do what it does best, then use Applescript to augment the application with something that the app can’t do. So I created a smart playlist in iTunes named “Overdue Albums” with the following criteria:

[url=http://files.macscripter.net/unscripted/nw98autoimg/smartlist.png][/url] [i]Smart Playlist info for the "Overdue Albums" playlist[/i]

I then rewrote the script from scratch, using the smart playlist as my source for the list of album tracks that hadn’t been played recently. The end result was a script that whipped together my list of albums for the Nano in less than a minute - about 45 seconds! Yep, you read that right. This is less than 1/10th the time the old script took.

Here are three versions of the script, the old one, the slightly tweaked version, and the new script. Note - I’ve added some code to time each script and show you the time it takes to generate the new playlist. I’ve also edited the parameters of each one to try and get them to pull the same list of albums even though I’ve changed the parameters of my own scripts over the last year for various reasons, mostly personal preference. More on this after the scripts:

Version 1 - The Original (runtime: 6:31)


set beginTime to (current date)
--load the library
set scriptPath to (path to scripts folder from user domain) & "Script Library:"
set priorityLib to load script ((scriptPath as text) & "priorityLib.scpt") as alias

--create a newpriQueue
copy priorityLib's newPriQueue to albumsPriQueue
set priorityLib's newPQItem to {priority:"", cargo:""}

set kb to 1024
set mb to 1000 * kb
set gb to 1000 * mb
display dialog "How many gigabytes should I gather?" default answer "1.0"
set inputGB to text returned of the result
set NanoThreshold to (inputGB as real) * gb

set albumRatingMin to 3.0 * 20 --iTunes uses 0 to 100 internally, not 1-5 stars, so each star is 20
set prohibitedGenres to {"Podcast", "Standup Comedy"}
set allAlbumsList to {}
with timeout of 1000 seconds
	(*begin*)
	tell application "iTunes"
		set albumList to album of file tracks of library playlist 1 whose kind contains "audio" and comment does not contain "omit" and comment does not contain "single" and comment does not contain "christmas"
		set tcount to count albumList
	end tell
	
	--remove duplicates
	repeat with i from 1 to tcount
		tell my albumList's item i to if it is not in my allAlbumsList and it ≠ "" then set end of my allAlbumsList to it
	end repeat
	(*end*)
	display dialog "Processing " & (count of allAlbumsList) & " Albums" giving up after 1
	
	--Let's see if the album meets our standards
	tell application "iTunes"
		set itemCount to 0
		repeat with anAlbum in allAlbumsList
			set albumTracks to (every file track of playlist 1 whose album is anAlbum)
			
			--Get the average rating of the album
			set numTracks to count albumTracks
			set albumRating to 0
			repeat with aTrack in the albumTracks
				set albumRating to albumRating + (rating of aTrack)
			end repeat
			set albumRating to albumRating / numTracks
			
			if albumRating ≥ albumRatingMin and numTracks > 1 then
				
				set lastPlayed to (current date)
				set prohibited to false
				
				repeat with aTrack in albumTracks
					--strip out undesirable tracks
					if genre of aTrack is in prohibitedGenres then set prohibited to true
					if played date of aTrack is missing value then
						set lastPlayed to date "Monday, January 1, 1900 12:00:00 AM"
					else
						if played date of aTrack < lastPlayed then �
							set lastPlayed to played date of aTrack
					end if
				end repeat
				
				--reorder albums by last played date	 	
				if not prohibited then
					-- if we've made it this far, add the album to the queue
					set albumsPriQueue to priorityLib's priEnqueue(albumsPriQueue, anAlbum, lastPlayed)
					set itemCount to itemCount + 1
					if itemCount mod 10 = 0 then display dialog "Processing item " & itemCount buttons {"OK"} default button 1 giving up after 1
					
				end if
			end if
		end repeat
		
		if (priorityLib's priQueueIsEmpty(albumsPriQueue)) then
			display dialog "Empty Queue."
		else
			--set up playlist
			if exists user playlist "1 iPod Albums" then
				delete every file track of user playlist "1 iPod Albums"
			else
				make new user playlist at folder playlist "Test" with properties {name:"1 iPod Albums"}
			end if
			repeat while ((size of user playlist "1 iPod Albums") ≤ NanoThreshold) and (not (priorityLib's priQueueIsEmpty(albumsPriQueue)))
				set albumsPriQueue to priorityLib's priDequeue(albumsPriQueue)
				set theResult to priorityLib's priQueueResult
				set theAlbum to cargo of theResult
				display dialog "Adding album " & theAlbum giving up after 1
				duplicate (every file track of library playlist 1 whose album is theAlbum and kind contains "audio" and comment does not contain "omit") to user playlist "1 iPod Albums"
			end repeat
		end if
		set endTime to (current date)
		set deltaTime to (endTime - beginTime) div minutes
		set modSeconds to (endTime - beginTime) - (deltaTime * minutes)
		display dialog "Run time: " & deltaTime & " minutes " & modSeconds & " seconds."
	end tell
end timeout

Version 2 - No Real Improvement (runtime: 6:31)


set beginTime to (current date)
--load the library
set scriptPath to (path to scripts folder from user domain) & "Script Library:"
set priorityLib to load script ((scriptPath as text) & "priorityLib.scpt") as alias
--create a newpriQueue
copy priorityLib's newPriQueue to albumsPriQueue
set priorityLib's newPQItem to {priority:"", cargo:""}

set kb to 1024
set mb to 1000 * kb
set gb to 1000 * mb
display dialog "How many gigabytes should I gather?" default answer "1.0"
set inputGB to text returned of the result
set NanoThreshold to (inputGB as real) * gb

set albumRatingMin to 3.0 * 20 --iTunes uses 0 to 100 internally, not 1-5 stars, so each star is 20
set prohibitedGenres to {"Podcast", "Standup Comedy"}
set allAlbumsList to {}
--set shortAlbumList to {}

with timeout of 1000 seconds
	--get album list from iTunes
	tell application "iTunes"
		if exists user playlist "2 iPod Albums" then
			delete every file track of user playlist "2 iPod Albums"
		else
			make new user playlist at folder playlist "Test" with properties {name:"2 iPod Albums"}
		end if
		
		(*begin*)
		set allalbumList to album of file tracks of library playlist 1 whose (kind contains "audio" and track count > 1 and track number = 1 and (disc number = 1 or disc number = 0) and comment does not contain "omit")
		set tcount to count allalbumList
		(*end*)
	end tell
	
	display dialog "Processing " & tcount & " Albums" giving up after 1
	
	--Let's see if the album meets our standards
	tell application "iTunes"
		set itemCount to 0
		repeat with anAlbum in allalbumList
			set albumTracks to (every file track of playlist 1 whose album is anAlbum and comment does not contain "omit")
			
			--Get the average rating of the album
			set numTracks to count albumTracks
			set albumRating to 0
			
			repeat with aTrack in the albumTracks
				set albumRating to albumRating + (rating of aTrack)
			end repeat
			set albumRating to albumRating / numTracks
			
			if albumRating ≥ albumRatingMin and numTracks > 1 then
				
				set lastPlayed to (current date)
				set prohibited to false
				
				repeat with aTrack in albumTracks
					--strip out undesirable tracks
					if genre of aTrack is in prohibitedGenres or �
						kind of aTrack does not contain "audio" then set prohibited to true
					if played date of aTrack is missing value then
						set lastPlayed to date "Monday, January 1, 1900 12:00:00 AM"
					else
						if played date of aTrack < lastPlayed then �
							set lastPlayed to played date of aTrack
					end if
				end repeat
				--reorder albums by last played date		
				if not prohibited then
					-- if we've made it this far, add the album to the queue
					set albumsPriQueue to priorityLib's priEnqueue(albumsPriQueue, anAlbum, lastPlayed)
					set itemCount to itemCount + 1
					if itemCount mod 10 = 0 then display dialog "Processing item " & itemCount buttons {"OK"} default button 1 giving up after 1
				end if
			end if
		end repeat
		
		if (priorityLib's priQueueIsEmpty(albumsPriQueue)) then
			display dialog "Empty Queue."
		else
			--set up playlist
			repeat while ((size of user playlist "2 iPod Albums") ≤ NanoThreshold) and (not (priorityLib's priQueueIsEmpty(albumsPriQueue)))
				set albumsPriQueue to priorityLib's priDequeue(albumsPriQueue)
				set theResult to priorityLib's priQueueResult
				set theAlbum to cargo of theResult
				display dialog "Adding album " & theAlbum giving up after 1
				duplicate (every file track of library playlist 1 whose album is theAlbum and kind contains "audio" and comment does not contain "omit") to user playlist "2 iPod Albums"
			end repeat
			set endTime to (current date)
			set deltaTime to (endTime - beginTime) div minutes
			set modSeconds to (endTime - beginTime) - (deltaTime * minutes)
			display dialog "Run time: " & deltaTime & " minutes " & modSeconds & " seconds."
		end if
	end tell
end timeout

Version 3 - Vastly Improved (runtime: 1:22)


(*Make sure the "Overdue Albums" playlist in iTunes is sorted by 
the "Last Played" date column before you run this script!*)

set beginTime to (current date)
set kb to 1024
set mb to 1000 * kb
set gb to 1000 * mb
display dialog "How many gigabytes should I gather?" default answer "1.0"
set inputGB to text returned of the result
set NanoThreshold to (inputGB as real) * gb

with timeout of 1000 seconds
	tell application "iTunes"
		if exists user playlist "3 iPod Albums" then
			delete every file track of user playlist "3 iPod Albums"
		else
			make new user playlist at folder playlist "Test" with properties {name:"3 iPod Albums"}
		end if
		repeat with aTrack in (file tracks of user playlist "Overdue Albums")
			set tempAlbum to album of aTrack
			--Make sure we haven't already added this album
			set testAlbum to count (every file track of user playlist "3 iPod Albums" whose album is tempAlbum)
			if testAlbum = 0 then
				-- Nope, it's not in the playlist
				-- Let's check and see if there's room to add it
				if ((size of user playlist "3 iPod Albums") ≤ NanoThreshold) then
					display dialog "Adding album " & tempAlbum giving up after 1
					duplicate (every file track of library playlist 1 whose album is tempAlbum and comment does not contain "omit") to user playlist "3 iPod Albums"
				else
					exit repeat
				end if
			end if
		end repeat
		set endTime to (current date)
		set deltaTime to (endTime - beginTime) div minutes
		set modSeconds to (endTime - beginTime) - (deltaTime * minutes)
		display dialog "Run time: " & deltaTime & " minutes " & modSeconds & " seconds."
	end tell
end timeout

To test them, create a folder in iTunes’ sidebar called “Test.” Now, if you run the three scripts, they will each create their own playlist in the test folder and fill the playlist with what they think are the least recently played albums. Why do I say, “…what they think are the least recently played?” Because, if you look at the playlist contents for each script you’ll see some differences.

Why is this? The minor differences are due to the way in which the initial set of tracks is generated. While the original script looped through:


file tracks of library playlist 1 whose kind contains "audio" and comment does not contain "omit" and comment does not contain "single" and comment does not contain "christmas"

and the second script pulled an initial set based on these criteria:


file tracks of library playlist 1 whose (kind contains "audio" and track count > 1 and track number = 1 and (disc number = 1 or disc number = 0) and comment does not contain "omit")

the current version pulls tracks much like the second version, but uses the range from 1 to 3 for the track number. Why is this important? Because, if you’re like me, you sometimes don’t rip or buy ALL the tracks from an album. In other words, the second version of the script will pull only albums where there is a track 1. So if you have an album that doesn’t have track one, that album will never make it to the final playlist since the script never sees it.

Further, the first two scripts are generating their own album ratings, while the last script uses the album rating from the track record. But you can see that the majority of albums in the playlists that these three scripts generate are the same.

Washing the Dishes
So, now I’ve eaten my crow for dinner. Some days it just doesn’t pay to have an iPod Nano, or at least to know anything about scripting. (sigh)

My original script wasn’t very efficient and it took me an entire year to find a better alternative. I’m not above admitting that I missed the obvious, or doing a code rewrite when it is necessary.

The whole point of this exercise is this: Never assume that you have to do everything in your scripts. Most applications are written in Cocoa (or C++) and can run circles around an Applescript. So look for ways that you can get the target application to do the majority of the work and write the bits of Applescript that are the minimum you might need to get your task accomplished.

'Nuff said.

Dessert
As a postscript, I wanted to mention something I’ve found really useful in my scripting. The item is Red Sweater Software’s FastScripts. It’s a much better script menu than Apple’s, in my opinion. It allows you to assign keyboard shortcuts and you can edit a script simply by holding down the option key when you select it from the menu. It also gives detailed error messages when a script crashes - that alone makes FastScripts worthwhile!

Another feature I like is that when you have a script running, the menu icon changes color to let you know that the script is still running and the menu becomes “Abort running script” so you can kill a runaway script. Add to that the fact that it’s only $14.95 and that you can try the Lite version for free, and there’s no reason not to go get a copy.

That’s all this time. Have fun scripting - go crunch some code!

Great tutorial, however you’re missing something important when using smart playlists: they can themselves rely upon other smart playlists. So the first thing you should have done, is create a generic “Music Only” smart playlist. Then from that, you can very easily make a “Only Unrated Songs” playlist, a Holiday playlist, your “Least Listened To” playlist (I have one myself), and so on…