“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.)
--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!