Mimsy Were the Borogoves

Hacks: Articles about programming in Python, Perl, Swift, BASIC, and whatever else I happen to feel like hacking at.

Using appscript in Python to control GUI applications

Jerry Stratton, October 7, 2006

I have a laptop in one room and my main computer in another. My music is all on the other computer, which is plugged directly into my stereo via an M-Audio FireWire Audiophile. Sometimes, I feel like controlling iTunes from the laptop. There are tools that will do this, but they require opening up remote scripting or similar technologies on the host computer. I prefer a nice, simple interface that I can use via the already-existing SSH command line.

My first attempt at this involved a Perl script that called an AppleScript, waited for the script to finish, and then operated on files that the script left behind. This worked fine for play and for pause, but was very unreliable for finding and playing tracks based on various criteria.

Several weeks ago, I ran across appscript. This is a Python module that brings scriptability down directly into Python. It makes extensive use of Python’s strengths, and it is extremely useful. However, the documentation for it is extraordinarily sparse. It’s great that the help is built in, but the built-in help is also decidedly hit-or-miss. You have to guess where something is in order to get the help for it.

Each object has a .help() method attached to it. For example, after installing appscript, start up pythonw (you need to use pythonw rather than python so that Python can attach to the GUI) and type:

  • pythonw
  • import appscript
  • iTunes = appscript.app("iTunes")
  • iTunes.help()
  • iTunes.play.help()
  • browserWindows = iTunes.browser_windows()
  • browserWindow = browserWindows[0]
  • browserWindow.help()
  • playList = browserWindow.view()
  • playList.help()
  • thirdTrack = playList.tracks[3]
  • thirdTrack.help()

You can also do “help(appscript)”. And you may find “help(appscript.specifier)” useful. There is very little otherwise in the way of documentation. This is very much for Python programmers who don’t like the Applescript way. Those of us who do like Applescript but want a solid command-line scripting environment will have to muddle through.

Methods and properties appear to be the same as in the dictionary that ScriptEditor shows, with spaces replaced with underscores.

If you want a value of a property, you need to append it with “()”: both properties and actions appear to be functions. I suspect that “()” is shorthand for the “.get()” method on the object.

If you don’t want the value, but plan on getting another value off of it later, you probably do not want to use “()” or “.get()”. Doing so may give you a huge, time-consuming list. For example, if you want the third track, you do not want to do:

  • tracks = playList.tracks()
  • thirdTrack = tracks[3]
  • thirdTrack.name()

If there are 10,000 tracks in your playlist, this will grab them all and will be noticeably slow. If you really only want the third track, just do:

  • tracks = playList.tracks
  • thirdTrack = tracks[3]
  • thirdTrack.name()

The help will often look something like this, from iTunes.play.help():

Terminology for play command Command: play(...) -- play the current track or the specified track or file.
[Reference] -- item to play
[once=Boolean] -- If true, play this track once and then stop.

The “Reference” is the object on which this method rests: you don’t have to provide it. The remaining options need to be specified by name. So you can call play as “object.play()”, as “object.play(once=True)’, or as “object.play(once=False)”.

Finally, some of the lists you get back start at 0 and some start at 1.

Command-line iTunes controller

This seemed perfect for implementing my iTunes command line. And, in fact, it was. But it took a lot of work to figure out everything I needed to know.

Before I get into how it works, here are a few examples of what it does:

itunes
SLOW RIDE (8:14)
Foghat (Fool For The City, 1975)
****

itunes --extended
SLOW RIDE (8:14)
Foghat (Fool For The City, 1975)
Genre: Hard Rock
Last Played: 2006-03-06 06:58:55
Playcount: 12
Remaining Time: 7:32
****

itunes --upcoming 5
3 - Tuesday Morning by Chris Reed and the Anime Raiders on Something Positive Thanks (4:41)
4 - Overture by Orchestra on George M! (3:35)
5 - Hindustan by Bing Crosby and Rosemary Clooney on fancy meeting you here (2:52)
6 - Take It Anyway You Want It by Pat Benatar on Precious Time (2:49)
7 - Hot And Bothered by Combustible Edison on The Impossible World (3:17)

302: itunes --find lola
Found 2 tracks in A-List
1199 - Whatever Lola Wants by Ella Fitzgerald on Ella Sings Broadway (3:15)
4802 - Whatever Lola Wants by Cashmere Jungle Lords on Southern Barber Supply (2:34)

Whenever the command results in a list, the number on the left is an index. I can play the Ella Fitzgerald tune above, for example, using “itunes --index 1199”.

How does it work?

I couldn’t figure out how to subclass a track, so I ended up writing basically a filter class to it, called “smarterTrack”.

[toggle code]

  • class smarterTrack:
    • def __init__(self, track):
      • self.track = track

This class requires a “track” object when it is created. Then, I added the basic functionality I needed to duplicate the track, such as:

[toggle code]

  • def time(self):
    • return self.track.time()
  • def year(self):
    • return self.track.year()
  • def index(self):
    • return self.track.index()
  • def position(self):
    • return self.track.index()
  • def ID(self):
    • return self.track.database_ID()

Some text items need to be encoded to utf_8 or Python will have trouble displaying them on the terminal. Since they are Python strings, I used their built-in “encode” method:

[toggle code]

  • def name(self):
    • return self.track.name().encode('utf_8', 'replace')
  • def artist(self):
    • return self.track.artist().encode('utf_8', 'replace')
  • def album(self):
    • return self.track.album().encode('utf_8', 'replace')
  • def genre(self):
    • return self.track.genre().encode('utf_8', 'replace')

The appscript module contains some useful constants in appscript.k. For example, If you want to know the current state, you can use:

isPlaying = iTunes.player_state() == appscript.k.playing

For missing values, there is appscript.k.MissingValue. Some tracks don’t yet have values for some fields, such as the last played time if a track has never been played.

[toggle code]

  • def played_date(self):
    • date = self.track.played_date()
    • if date == appscript.k.MissingValue:
      • return "Never"
    • else:
      • return date

One other trick is that iTunes stores a song’s rating as a number from 0 to 100. If you want to display the number of stars in the same manner that iTunes does, you need to divide a song’s rating by 20. Here, I do that division and then take advantage of Python’s ability to multiple strings:

[toggle code]

  • def stars(self):
    • rating = self.track.rating()/20
    • rating = "*"*rating
    • return rating

Show track info

The default action for this script is to show information about the current track. This is handled by the “bug” method on the smarterTrack class.

[toggle code]

  • def bug(self):
    • title, artist, album, year, duration, rating = self.name(), self.artist(), self.album(), self.year(), self.time(), self.stars()
    • print title.upper(), "(" + duration + ")"
    • print "\t", artist, "("+ album + ", " + str(year) + ")"
    • if extendedInfo:
      • playlist = self.playlist()
      • listCount = playlist.count(each=appscript.k.track)
      • listCount = locale.format("%i", listCount, True)
      • print "\tGenre:", self.track.genre()
      • print "\tLast Played:", self.played_date()
      • print "\tPlaycount:", self.track.played_count()
      • if self.isCurrent():
        • print "\tRemaining Time:", self.ToGo()
      • else:
        • print "\tIndex:", self.index()
      • print "\tPlaylist:", playlist.name(), "(" + listCount, "songs)"
    • print "\t", rating

I can then display the bug in a script using this code:

  • iTunes = appscript.app("iTunes")
  • currentTrack = smarterTrack(iTunes.current_track())
  • currentTrack.bug()

Find

One of the most useful parts of this script is its ability to find and possibly play a specific track. To find a song, you may need to look in the main library. The current library generally will not contain all songs. Finding the main library is not quite what it should be. There ought to be a “library_playlist” property on iTunes. It’s in Applescript, but I can’t find it in appscript. The hack is simply to get the list of all playlists and then get the first one. Currently, the first playlist is the main library. Note that this is one of those lists that starts at 1 rather than 0.

playlist = iTunes.playlists[1]

If you want the current playlist, the easiest way to get it is probably to get the playlist that the current track is playing in:

playlist = self.track.container()

When you search, you have to search within a playlist.

appscript.k contains constants that you’ll need to do things such as search in a specific field, such as the song title, artist name, or album name.

This script takes -f "text to search for" and -w "where to search": album name, track title, or song name. If no “where” is specified, the script searches everywhere.

For example:

itunes -f "having fun tonight" -w "album"

will show me all of the songs on any album whose title contains the phrase “having fun tonight”.

Sending the “where” to iTunes means using appscript.k.artists, appscript.k.albums, appscript.k.songs, or, if searching everywhere, appscript.k.all.

[toggle code]

  • if options.find:
    • showTrackInfo = False
    • if mainLibrary:
      • playlist = iTunes.playlists[1]
    • else:
      • playlist = iTunes.current_playlist()
    • searchText = options.find
    • searchWhere = appscript.k.all
    • if options.where:
      • where = options.where
      • if where == 'artist':
        • searchWhere = appscript.k.artists
      • elif where == 'title':
        • searchWhere = appscript.k.songs
      • elif where == 'album':
        • searchWhere = appscript.k.albums
      • else:
        • print "I don't understand how to search in", where, "so I am going to search everything"

Now that we know where to search, it is time to do the search. That’s pretty easy: it’s just one line of code, a call to iTunes.search(). The search method needs to know the playlist in which to search, the text to search for, and the track fields to search in.

The script will, if no tracks are found in the current playlist, then go ahead and search the main library.

[toggle code]

    • foundList = iTunes.search(playlist, for_=searchText, only=searchWhere)
    • #check the main library if it isn't in the current play list
    • if len(foundList) == 0:
      • print "No tracks found matching", searchText, "in", playlist.name()
      • if playlist.name() != 'Library':
        • #library_playlist doesn't currently work; but the first one in the list is the library
        • playlist = iTunes.playlists[1]
        • #if we find it in a different playlist, do not play it even if playback was requested
        • playFoundTrack = False
        • foundList = iTunes.search(playlist, for_=searchText, only=searchWhere)
        • if len(foundList) == 0:
          • print "No tracks found in", playlist.name(), "either."
        • else:
          • print "But we did find it in", playlist.name()

Now we have either found something or not. If we found only one song and we’ve requested playback (the default), the script calls .play(foundTrack) to play that track. Otherwise, it displays the bug for that track. If the search found multiple songs, a line of information, including the index number, is displayed for each found track.

[toggle code]

    • if len(foundList) == 1:
      • foundTrack = foundList[0]
      • if playFoundTrack:
        • iTunes.play(foundTrack)
        • showTrackInfo = True
      • else:
        • smarterTrack(foundTrack).bug()
    • elif len(foundList) > 1:
      • print "Found", len(foundList), "tracks in", playlist.name()
      • for track in foundList:
        • track = smarterTrack(track)
        • track.line()

Interesting issues

Administrative accounts can run this script and get access to the GUI even if they are not the currently-logged-in account. They’ll need to run the script using sudo, and can then control the currently-running iTunes.

If no one is logged in, however, accessing iTunes from an administrative account will start up iTunes as root. ITunes will set up an iTunes folder in the root home directory (/var/root) and will attempt to run through the setup process that iTunes always goes through the first time you open it. You won’t want to do that. That would open your computer up to some major hacking, as any hack-laden media file that iTunes was convinced into running would run as root on your system.

Update (July 3 2007): I modified the script to use optparse instead of my custom option parser, and have changed the examples to reflect this.

May 22, 2007: appscript AppleScript translator

I’m about to convert my Dining After Midnight site from FileMaker Pro to Django/SQLite. I decided to write a Python/appscript script to do the data transfer. While there aren’t that many restaurants in the list (San Diego is not a city that never sleeps), I have several other FileMaker databases I’d like to convert either to Django/SQLite or just SQLite, and this seemed like a good time to come up with an easy transfer process.

As I wrote in my earlier article about using appscript to control iTunes, the appscript help is really for Python programmers who already know appscript. If you’re comfortable with AppleScript, you will probably find ASTranslate (on the appscript download page) invaluable. It will take AppleScript “tell” blocks and convert them to appscript code.

For example:

[toggle code]

  • tell application "FileMaker Pro"
    • tell database "After Midnight"
      • show every record
    • end tell
  • end tell

Paste this into ASTranslate and it will become:

  • app(u'/Applications/Apps/FileMaker Pro 6 Folder/FileMaker Pro.app').databases['After Midnight'].records.show()

It’s still up to you to turn this into more readable Python, but that’s a much easier task than trying to guess at what appscript is expecting.

  • import appscript
  • fm = appscript.app(u'/Applications/Apps/FileMaker Pro 6 Folder/FileMaker Pro.app')
  • db = fm.databases['After Midnight']
  • records = db.records.show()

And finally, the better choice for opening FileMaker is probably to use the ID, in case you move it later:

  • fm = appscript.app(id="com.filemaker.filemakerpro")

If you’re using a more modern version of FileMaker Pro, you’ll also use a different ID.

With ASTranslate, as long as you can construct the “tell” block(s) in AppleScript, you can quickly get the necessary appscript calls.

December 25, 2006: Python command-line option parser

Just a note about the command-line parser in my iTunes python script. Back in Python 2.2 (Mac OS X 10.2) I needed a simpler and more versatile command-line parser than getopt, so I wrote commandline.py. Erik Osheim points out that I don’t need to use it any more: as of Python 2.3 there is a new command-line options parser called optparse that does everything I need, and it comes standard with Python 2.3 and greater.

It’s also easier to use and works better. Last weekend I took a close look at it and will be replacing commandline.py in my other scripts when I have time. Here’s a simple example of how it works, from a more recent script:

[toggle code]

  • from optparse import OptionParser
  • #set up command-line options
  • parser = OptionParser()
  • parser.add_option("-s", "--slug", help="start with page matching slug", metavar="SLUG", dest="pageslug")
  • parser.add_option("-l", "--local", help="do not upload", default=True, action="store_false", dest="upload")
  • parser.add_option("-r", "--no-recurse", help="do not publish child pages", default=True, action="store_false", dest="recurse")
  • #grab options
  • (options, args) = parser.parse_args()
  • #do web pages
    • if options.pageslug:
      • page = getPageFromSlug(options.pageslug)
    • else:
      • page = getPageFromPath("", host="www.hoboes.com")
    • if page:
      • makePage(page, upload=options.upload, recurse=options.recurse)
    • else:
      • print "Could not find a base page"

This is from the publish script that I use to publish the on-line version of Negative Space from my local Django-managed database. It sets up three options:

  • a “slug” option that tells the script to start with a specific page (identified by its “slug”);
  • a “local only” flag that tells the script not to upload files to the server;
  • a “do not recurse” flag that tells the script to do only the specified page and not any of its subpages.

Most of it is self-explanatory, but the “metavar” is the name used in the help for that option’s required text, and “dest” is the name of the property you’ll use to access the value of that option. You can see that I check “options.pageslug” to decide whether to start from a specific page or start from the home page, and I use options.upload and options.recurse to set those arguments in the “makePage” function.

The parser automatically sets up a “--help” option, and the “parse_args()” method automatically stops the script if --help is requested or if the user specifies invalid options.

Here is the help generated by the above script:

  1. <- Troubleshooting Phobia
  2. Smultron text editor ->