Shapejam

Trying to follow Buckminster Fuller's example in life, one step at a time to bring you brain scrapings for a new millennium.

Procrastinating With Objective-C

30 Jul 2013 - Nic Ho Chee

One of my TaskFactory procrastination tasks over the last few years involved digitising my small pool of CDs and vinyl and pulling the resulting tracks into iTunes. To reduce the total time cost, part of this involved knocking up a quick and dirty tool to convert tracks with metadata based on naming conventions and directory structure, originally written in C# using the iTunes COM API available from the Apple Developer site. In another burst of procrastination I thought I'd try and craft a version using Objective-C... the original took a couple of hours to research and put together... should be easier with Objective-C right?

No, It Is Actually A Lot More Involved

After a brief spot of research, it appears that iTunes is almost fully controllable using AppleScript. Writing any kind of involved application using a scripting language when you have access to a compiled language with a rich IDE to generate code and debug doesn't float my boat. Using the AppleScript Editor to program with is like attempting to escape captivity from a fortune cookie factory by replacing a fair number of said fortunes with pleas for help and hoping that the general populace can separate them from the vague clairvoyance otherwise contained. Whilst possible, there are easier ways to get things done.

Rather than use AppleScripts directly, any scriptable application can have its interface translated into a header allowing Objective-C to message and control it. In the handful of applications tested a few generate uncompilable rubbish leaving a number, including the iTunes header, programmatically correct (-ish, there are a few issues I've outlined below.)

Apple have provided the scripting definition file tool sdef to dump the iTunes AppleScript API to XML. The output can be piped to sdp, the scripting definition processor, which will transform a scripting definition file into an Objective-C header file and use a tag such as iTunes to prefix any types contained therein. The header can be generated as part of a pre-build step using the command line below:

sdef /Applications/iTunes.app | sdp -fh --basename "iTunes"

The header file gives us the ability, through the Apple Scripting Bridge, to pass events to and from iTunes, allowing us to create a main function similar to that shown below:

#import <Foundation/Foundation.h>
#import <ScriptingBridge/ScriptingBridge.h>
#import "iTunes.h"

int main(int argc, const char* argv[])
{
    // Scoped initialisation/drain of the autorelease pool.
    @autoreleasepool {
        NSError* theError = nil;
        NSURL* rootDirectory = [NSURL fileURLWithPath:@"/Users/<username>/Documents/recording_formatted" isDirectory:YES];
        
        // Scripting bridge to the iTunes application.  Using this will give us an interface to iTunes.
        iTunesApplication* app = [SBApplication applicationWithBundleIdentifier:@"com.apple.iTunes"];
        
        // We only care about the name and type of file/directory returned...
        NSArray* directoryProperties = [NSArray arrayWithObjects: NSURLLocalizedNameKey, NSURLLocalizedTypeDescriptionKey, nil];
        
        // Get the list of genres in the root working directory.
        NSArray* genreDescriptors = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:rootDirectory includingPropertiesForKeys:directoryProperties options:(NSDirectoryEnumerationSkipsHiddenFiles|NSDirectoryEnumerationSkipsSubdirectoryDescendants) error: &amp;theError];
        
        if (theError) {
            NSLog(@"Error: %@\n", [theError localizedDescription]);
            exit(1);
        }
        
        NSNumber* isDirectory;
        
        // Parse the directory hierarchy for files to convert and add to iTunes.
        
        for (NSURL* genreUrl in genreDescriptors) {
            
            // Only parse genre directories.
            if (![genreUrl getResourceValue:&amp;isDirectory forKey:NSURLIsDirectoryKey error: &amp;theError])
                continue;
            
            if (isDirectory.longValue != 1)
                continue;
            
            NSLog(@"Importing %@ \n", [genreUrl lastPathComponent]);
            
            // Get the list of artists in the genre directory.
            NSArray* artistDescriptors = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:genreUrl includingPropertiesForKeys:directoryProperties options:(NSDirectoryEnumerationSkipsHiddenFiles|NSDirectoryEnumerationSkipsSubdirectoryDescendants) error: &amp;theError];
            
            if (theError) {
                NSLog(@"Error: %@\n", [theError localizedDescription]);
                exit(2);
            }
            
            for (NSURL* artistUrl in artistDescriptors) {

                // Only parse artist directories.
                if (![artistUrl getResourceValue:&amp;isDirectory forKey:NSURLIsDirectoryKey error: &amp;theError])
                    continue;
                
                if (isDirectory.longValue != 1)
                    continue;

                NSLog(@"Importing %@ \n", [artistUrl lastPathComponent]);
                
                // Get the list of albums in the artist directory.
                NSArray* albumDescriptors = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:artistUrl includingPropertiesForKeys:directoryProperties options:(NSDirectoryEnumerationSkipsHiddenFiles|NSDirectoryEnumerationSkipsSubdirectoryDescendants) error: &amp;theError];
                
                if (theError){
                    NSLog(@"Error: %@\n", [theError localizedDescription]);
                    exit(3);
                }
                
                for (NSURL* albumUrl in albumDescriptors){
                    
                    // Only parse album directories.
                    if (![albumUrl getResourceValue:&amp;isDirectory forKey:NSURLIsDirectoryKey error: &amp;theError])
                        continue;
                    
                    if (isDirectory.longValue != 1)
                        continue;

                    NSLog(@"Importing %@ \n", [albumUrl lastPathComponent]);
                    
                    // Get all the urls in the directory, we'll pull audio and an image from this later.
                    NSArray* fileUrls = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:albumUrl includingPropertiesForKeys:directoryProperties options:(NSDirectoryEnumerationSkipsHiddenFiles|NSDirectoryEnumerationSkipsSubdirectoryDescendants) error: &amp;theError];
                    
                    if (theError){
                        NSLog(@"Error: %@\n", [theError localizedDescription]);
                        continue;
                    }
                    
                    NSURL* artworkUrl = nil;
                    NSMutableArray* trackUrls = [[NSMutableArray alloc] init];
                    
                    // Get the list of audio and a cover image that we want to convert and import to iTunes.
                    ValidateTracks(fileUrls, trackUrls, &amp;artworkUrl);
                    
                    // Try add the tracks.
                    for (NSURL* trackUrl in trackUrls)
                        AddTrack(app, genreUrl, artistUrl, albumUrl, trackUrl, artworkUrl);
                }
            }
        }
    }
    
    return 0;
}

By exercising the Scripting Bridge API we can return an iTunesApplication interface which gives us direct access to iTunes without needing to know what events to pass through the Apple eventing framework.

The code below allows us to parse the URLs of the leaf directories in a structure and add them to worklists for later use if they reference sound or image files. Any sound files will be converted and the last image file found in the leaf directory will be used as album cover artwork:

// Pull valid tracks and artwork from a list of files.
void ValidateTracks(NSArray* files, NSMutableArray* tracks, NSURL** artwork)
{
    if (!files || !tracks || !(*artwork))
        return;
    
    NSError* theError = nil;
    
    for (NSURL* file in files) {
        
        NSNumber* isFile;
        if (![file getResourceValue:&isFile forKey:NSURLIsRegularFileKey error: nil])
            continue;
        
        if (isFile.longValue != 1)
            continue;
        
        NSString* fileType;
        if ([file getResourceValue:&fileType forKey:NSURLTypeIdentifierKey error: &theError]) {
            
            if ([[NSWorkspace sharedWorkspace] type:fileType conformsToType:(NSString*)kUTTypeAudio]) {
                NSLog(@"Found audio file %@ with type %@\n", [file lastPathComponent], fileType);
                [tracks addObject:file];
            }
            else if ([[NSWorkspace sharedWorkspace] type:fileType conformsToType:(NSString*)kUTTypeImage]) {
                NSLog(@"Found image file %@ with type %@\n", [file lastPathComponent], fileType);
                *artwork = file;
            }
        }
        
        if (theError)
            NSLog(@"Error: %@\n", [theError localizedDescription]);
    }
}

Thus far putting the script together was relatively painless. This changed during the generation of the function below which allowed us to convert a track using the default import properties currently in use in iTunes:

// Add a track to iTunes, convert and update some of its metadata.
void AddTrack(iTunesApplication* iTunesApp, NSURL* genreUrl, NSURL* artistUrl, NSURL* albumUrl, NSURL* trackUrl, NSURL* imageUrl)
{
    if (!genreUrl || !artistUrl || !albumUrl || !trackUrl)
        return;
    
    bool isCompilation = false;
    
    NSString* rawName = [[trackUrl lastPathComponent] stringByDeletingPathExtension];
    NSArray* tokens = [rawName componentsSeparatedByString:@"!"];
    
    NSString* genreName= [genreUrl lastPathComponent];
    NSString* artistName = [artistUrl lastPathComponent];
    NSString* albumName = [albumUrl lastPathComponent];
    
    NSString* trackNumber = tokens[0];
    NSString* trackName = tokens[2];
    
    if ([tokens[1] localizedCompare:@""] != NSOrderedSame) {
        isCompilation = true;
        artistName = tokens[1];
    }
    
    NSArray* trackContainer = [[NSArray alloc] initWithObjects:trackUrl, nil];
    NSArray* addedTracks = [iTunesApp convert: trackContainer]; //Interface incorrectly states iTunesTrack return type.
    
    // There are problems with getting the class for the interface nothing to link against! In our case
    //  the object should always be an iTunesFileTrack...
    iTunesFileTrack* track = nil;
    if (addedTracks != nil &amp;&amp; addedTracks.count == 1)
        track = addedTracks[0];
    
    if (track != nil) {
        track.compilation = isCompilation;
        track.name = trackName;
        track.album = albumName;
        track.genre = genreName;
        track.artist = artistName;
        track.trackNumber = [trackNumber intValue];
        track.compilation = isCompilation;
        AddArtwork(track, imageUrl);
    }
}

...there were a few oddities which turned what should have been a smooth conversion from C# to Objective-C into something a bit more scruffy:

After converting and adding the tracks to iTunes we need to import any relevant cover art. This can be accomplished using something that looks similar to the code below:

// Add artwork from the given imageUrl to a track...
void AddArtwork(iTunesFileTrack* track, NSURL* imageUrl)
{
    if (!track || !imageUrl)
        return;
    
    NSError* theError = nil;
    
    // Using AppleScript to add artwork.  Crazy, but there is no way through the ScriptingBridge API
    // to add cover art, so resigned to needing to do this through the NSAppleScript object.
    NSString* trackWithoutCover = [track.location path];
    NSMutableString* addArtworkScript = [NSMutableString stringWithString:@"set theFile to \""];
    [addArtworkScript appendString:trackWithoutCover];
    [addArtworkScript appendString:@"\"\n"];
    [addArtworkScript appendString:@"tell application \"iTunes\"\n"];
    
    [addArtworkScript appendString:@"set theTrack to (add (POSIX file theFile) as alias)\n"];
    [addArtworkScript appendString:@"set data of artwork 1 of theTrack to (read (POSIX file \""];
    [addArtworkScript appendString:[imageUrl path]];
    [addArtworkScript appendString:@"\") as picture)\n"];
    
    [addArtworkScript appendString:@"end tell\n"];
    
    NSAppleScript* theScript = [[NSAppleScript alloc] initWithSource:addArtworkScript];
    [theScript executeAndReturnError: &amp;theError];
    
    if (theError)
        NSLog(@"Error, %@\n", [theError localizedDescription]);
}

There is no mechanism for adding artwork to an iTunesFile that does not already have artwork which proved to be a problem for the newly imported audio files used in this project. After a lot of research and various dead-end prototypes, the only mechanism with the required functionality exercised the eventing framework directly. This resulted in the code shown above which created a script referenced in an NSAppleScript* object. Something to take note is that AppleScript paths are POSIX compliant and the directory separators are colons rather than the slash separators used for standard NSURL paths.

Next...

It took over twice as long to create this tool than the original C# version after fixing various inconsistent types in the autogenerated header file and working around a lack of functionality for importing artwork through the iTunesApplication interface. After my brief procrastination experience with the Scripting Bridge I will be sticking to C# for similar tasks, the COM APIs are more fully featured as far as I can see.

Next we'll be looking at more Test Driven Development and some C++ hints and tips.