April 2007 Archives

WxRuby

I wrote the entire UI for my “Simple Skype” front end in wxRuby. wxRuby’s benefit comes from it’s use of widgets native to the window manager (i.e., Aqua on Mac OS X, etc.). And, while I only had a few problems with it, most of my problems were PEBKACs.

Sadly, the memory leak in my GUI, caused by the use of Bitmaps, was not among my PEBKACs. This is currently tracked as an open bug against Wx. It is unfortunate that this issue has apparently not received much attention since it was noted three years ago…

And so, on to another windowing toolkit… Take 2 will probably be written in Ruby/TK or FXRuby as I understand that both are fairly mature bindings.

In the meantime, below is the code, using rb-skypemac, that I was putting together.

require 'rubygems'
require 'wx'
require 'rb-skypemac'
require 'monitor'
include Wx
include SkypeMac

$BTN_WIDTH = 150
$BTN_HEIGHT = 150
$FONT_SIZE = 14
$IMAGE_DIR = "./images/"
$GREEN_IMG = $IMAGE_DIR + "skype_green2.gif"
$YELLOW_IMG = $IMAGE_DIR + "skype_yellow2.gif"
$RED_IMG = $IMAGE_DIR + "skype_red2.gif"

module SimpleSkypeUtil
  def create_bitmap_text_button(labels, parent, status)
    bitmap = case status
      when "ONLINE": Bitmap.new($GREEN_IMG, BITMAP_TYPE_GIF)
      when "DND": Bitmap.new($RED_IMG, BITMAP_TYPE_GIF)
      else Bitmap.new($YELLOW_IMG, BITMAP_TYPE_GIF)  
    end
    bitmap.draw do |dc|
      dc.set_font Font.new($FONT_SIZE, FONTFAMILY_DEFAULT, FONTSTYLE_NORMAL, FONTWEIGHT_NORMAL)
      dc.set_background_mode TRANSPARENT
      dc.set_text_foreground BLACK
      label_no = 0
      extent = dc.get_text_extent(labels[0])
      extent_y = extent[1] * labels.size
      labels.each do |label|
        extent = dc.get_text_extent(label)
        text_x = ($BTN_WIDTH - extent[0]) / 2
        text_y = ($BTN_HEIGHT - extent_y) / 2 + extent[1] * label_no
        dc.draw_text label, text_x, text_y
        label_no += 1 
      end
    end
    btn = BitmapButton.new(parent, -1, bitmap)
  end
end

module SimpleSkype
  class HangupDialog < Wx::Dialog 
    include SimpleSkypeUtil

    # TODO: replace destroy_children call below to prevent this dialog from being nuked's unduly
    def initialize(parent, id, title, call)
      super(parent, 
            id, 
            title, 
            Point.new(parent.get_position.x + parent.get_size.get_width, 
                      parent.get_position.y),
                    Size.new(150, 150),
                    DIALOG_EX_METAL|STAY_ON_TOP)
      @call = call

      set_size Size.new($BTN_WIDTH, $BTN_HEIGHT)
      set_sizer(sizer = StdDialogButtonSizer.new)
      btn = create_bitmap_text_button("Hang up call".to_a, self, :red)
      btn.set_min_size Size.new($BTN_WIDTH, $BTN_HEIGHT)
      sizer.add btn

      evt_button(btn.get_id) { |e| @call.hangup; self.hide }
    end
  end

  class SkypeApp < App
    include SimpleSkypeUtil
    include MonitorMixin

    attr :frame

    def handle_user_click(event, user)
        call = Skype.call user
      HangupDialog.new(@frame, -1, "Hang up call?", call).show_modal
    end

    def create_button_per_friend
      @friends.map do |f|
        labels = []
        labels << "#{(f.fullname != "") ? f.fullname : f.handle}"
        #labels << "(#{f.onlinestatus})"
        btn = create_bitmap_text_button labels, @frame, f.onlinestatus
        btn.set_min_size Size.new(150,150)
        evt_button(btn.get_id) { |e| handle_user_click(e, f) }
        btn
      end
    end

    def redraw_friends_buttons
      @frame.destroy_children

      @sizer = BoxSizer.new(VERTICAL) if @sizer.nil?

      @friends = Skype.online_friends.sort

      @frame.set_client_size($BTN_WIDTH, $BTN_HEIGHT * @friends.size)   
      @friend_btns = create_button_per_friend
      @friend_btns.each { |b| @sizer.add(b, 1, GROW|ALL, 2) }
      @sizer.layout     

      @frame.set_sizer(@sizer) if @frame.get_sizer.nil?
      @frame.show
    end

    def on_init
      @friends = Skype.online_friends
      @frame = Frame.new(nil, 
                         1, 
                         'Simple Skype', 
                         Point.new(0, 80), 
                         Size.new($BTN_WIDTH,$BTN_HEIGHT * @friends.size), 
                         FRAME_EX_METAL)
      redraw_friends_buttons

      timer = Timer.new @frame, -1
      evt_timer(-1) { redraw_friends_buttons }
      timer.start 5000
    end
  end
end

app = SimpleSkype::SkypeApp.new

app.main_loop

Posted by evan on Apr 29, 2007

rb-skypemac 0.2.0 is up

This one adds a moderate helping of contact management not limited to but include the ability to access lists of online friends, users awaiting authorization, all known users, and others. Also, it adds the ability to access various pieces of information about known users, blocking and unblocking of users, and much more.

0.2.0 may be the last release for a while as it should accomodate most of the features that I will need for my coming Skype project.

Download link and as well as gem install instructions and sample code are here.

Posted by evan on Apr 26, 2007

Neat trick: delaying variable evaluation in Ruby meta-code

Working on rb-skypemac 0.2.0, I found myself in the position of either implementing 40 getter/setter pairs by hand or writing some meta code. I always prefer to write meta code. If you don’t, seek treatment – or else you will soon find yourself in treatment of the straight jacket variety.

Anyhow, as I’m writing this meta code, I realized that I needed to delay the evaluation of a variable inside a String, i.e., delaying the evaluation of #{stuff} within “This is a load of #{stuff}”. Why? Because I didn’t have the variable yet! The variable is an instance variable and my meta code generating method is at the class level. So I began to stress. And then I figured, what the hell; I’ll just escape the ‘#’ because that would just make sense.

You know what? It just worked.

Matz did one fine job designing several portions of this language!

Example below:

  class User
    def User.skype_attr_reader(*attr_sym)
      attr_sym.each do |a|
        module_eval %{def #{a.to_s}
          # The line below is the REALLY cool part
          r = Skype.send_ :command => "get user \\#{@handle} #{a.to_s}"
          r.sub(/^.*#{a.to_s.upcase} /, "")
        end}
      end
    end

    skype_attr_reader :fullname, :birthday, :sex, :language, :country, :province

Posted by evan on Apr 24, 2007

rb-skypemac is up on RubyForge

You can find it here: http://rb-skypemac.rubyforge.org/.

Posted by evan on Apr 22, 2007

Introducing rb-skypemac

Since I discovered rubyosa, I’ve made it one of my side projects to improve the accessibility of my wife’s iMac. She loves to use Skype to video chat with her mother in Maryland and her brother in New York. However, the tiny buttons in Skype cause the UI to be problematic for her.

What to do?

I planned (and still do) to write a simple UI for Skype with larger buttons and fonts. However, in order to do so, I needed an interface that would allow me to do more than merely send commands to Skype. Sadly, rubyosa falls down here.

But then I found rb-appscript or just appscript. Appscript seems to provide a more direct connection to the Applescript Event layer and it provides return values from Applescript unlike the current 0.4.0 version of rubyosa.

All said, I implemented a small subset of the Skype API as a gem. I’ve submitted it to RubyForge for addition as a project. Until then, I give you rb-skypemac-0.1.0.

I have yet to decide if I will take this gem further. If it proves adequate for my needs, I may just move on to my next project: a more feature complete version of rb-itunes than what has been submitted to RubyForge.

[Get it here: rb-skypemac]

Posted by evan on Apr 21, 2007

Adding structured directories of music into iTunes

I wrote this one for my father as he has several audio tapes worth of MP3s to put into iTunes on Windows. It reads down through a directory structure for music files, using the nested directory names as metadata. It expects a nested directory structure as follows:

Root Dir
 |- Artist Dirs
     |- Album Dirs
          |- Music files

Admittedly, this is my first usage of yield in anger. It hurt – but now my brain has been further fortified with vitamin Ruby.

Code follows…

require 'win32ole'

cwd = `cd`
cwd.gsub! "\\\\", "/"

path = ARGV[0] if not ARGV[0].nil?
path = "." if path.nil?

app = WIN32OLE.new 'iTunes.Application'
exit if app.nil?

$music = app.LibrarySource.Playlists.ItemByName 'Music'

def for_each_in_dir(dir, contained_dir_type, meta={})
  dir.each do |dir_name|
    next if not File.directory? dir_name or dir_name =~ /^\\./
    Dir.open dir_name do |nested_dir|
      Dir.chdir dir_name
      meta[contained_dir_type] = dir_name
      yield nested_dir, meta
      Dir.chdir '..'
    end
  end
end

def read_tracks_for(artist, album, album_dir)
  album_dir.each do |t|
    next if File.directory? t or t =~ /^\\./

    title, junk = t.split '.'
    if title =~ /^(\\d+)\\b(.*)/
      track_number = $1.to_i
      title = $2.strip
    end

    puts "Adding Track '#{title}', Album '#{album}', Artist '#{artist}'"
    file_path = `cd`.chop << "\\\\#{t}"
    $music.AddFile file_path

    # AddFile may fork because sometimes no track is 
    # returned from ItemByName unless I sleep as below.
    # You may need a sleep time greater than 1 depending upon
    # your system's performance.
    sleep 1

    track = $music.Tracks.ItemByName title
    if track.nil?
      next
    end

    track.TrackNumber = track_number if track_number
    track.Name = title
    track.Album = album
    track.Artist = artist
  end
end

Dir.open path do |start_dir|
  Dir.chdir path
  for_each_in_dir start_dir, :artist do |artist_dir, artist_meta|
    for_each_in_dir artist_dir, :album, artist_meta do |album_dir, album_meta|
      read_tracks_for album_meta[:artist], album_meta[:album], album_dir
    end
  end
end

Posted by evan on Apr 17, 2007

A simple Rails app for finding missing artwork for your iTunes collection

Ok, I’ve been a bit compulsive about having artwork for my movies and music in iTunes since we got our AppleTV. Ok, maybe more than a bit. ;) Even so, this little Rails application should give you some idea of how simple it is. It uses Amazon’s web service (which, it turns out, I misjudged), to look up candidate artwork for each album and displays them to you. You simply click on the desired image (higher resolution is typically better) and it will insert that artwork into iTunes for each track in your album.

I won’t pretend that this is the cleanest implementation but it gets the job done. If any of you watching at home want to play along, you will also need ruby/amazon installed.

FYI: Amazon’s search engine may not resolve all of your album names. You may want to manually search Amazon, in the case of stubborn albums, to determine what Amazon calls the album. However, more often than not, this shouldn’t be a problem if you ripped using iTunes.

Finally, this program could be easily adapted for hunting down artwork for your movie collection as well.

Code follows:

AlbumController.rb:

    require 'win32ole'
    require 'amazon/search'

    class AlbumController < ApplicationController
      def fetch_file_with_url(url, meta)
        response = Net::HTTP.get_response(URI.parse(url))
        title = meta.product_name
        title.gsub! ':', '-'
        title.gsub! '/', '-'
        art_filename = ""
        art_filename << session[:ART_PATH] << title 
        art_filename.gsub! ' ', ''
        art_filename.gsub! '"', ''    
        art_filename.gsub! '\\'', ''
        art_filename.gsub! '(', ''
        art_filename.gsub! ')', ''        
        if art_filename.length > 60
          # iTunes COM API seems to have issues with long file names
          art_filename = art_filename[0,59]
          puts art_filename
        end
        art_filename << ".jpg"    
        File.open( art_filename, 'wb') do |f| 
          f << response.body
          f.flush
        end
        size = File.size(art_filename)
        art_filename
      end

      def reset
        session[:track_counter] = nil
        session[:skip_hash] = {}
        redirect_to '/album/iterate_through_coverless_albums'
      end

      def next
        session[:skip_hash][session[:current_album]] = 1
        session[:track_counter] += 1
        redirect_to '/album/iterate_through_coverless_albums'
      end

      def process_input
        album_candidates = lookup_album session[:current_album], session[:current_artist]
        update_artwork_with params[:image_url], album_candidates[params[:idx].to_i]
        self.next
      end  

      def update_artwork_with(artwork_url, meta)  
        art_filename = fetch_file_with_url artwork_url, meta
        return if art_filename.nil?

        # Change path from Ruby to Winblows
        art_filename.gsub! /\\//, '\\\\'

        app = WIN32OLE.new 'iTunes.Application'
        music = app.LibrarySource.Playlists.ItemByName 'Music'
        tracks_in_album = music.Search session[:current_album], 0
        i = 1
        puts "==> #{art_filename}, #{tracks_in_album.Count}"
        while i <= tracks_in_album.Count do
          track = tracks_in_album.Item i
          puts "#{track.Album} <=> #{session[:current_album]}"
          if track.Album != session[:current_album]
            i += 1
            next
          end
          puts "*#{track.Name}"

          track.AddArtworkFromFile art_filename
          i += 1
        end
      end

      def lookup_album(title, artist)
        #puts title
        begin
          req = Amazon::Search::Request.new session[:AWS_KEY]
          res = req.keyword_search(title + " " + artist, 'music') 
          return res.products.clone
        rescue
          puts "excepton"
          nil
        end
      end

      def iterate_through_coverless_albums
        if session[:track_counter].nil?
          # First time through.  Setup.
          session[:AWS_KEY] = 'XXXXXXXXXXXXXXXXXXXXX'  # Go get your own AWS key :P
          session[:ART_PATH] = 'f:/music/artwork/'
          session[:track_counter] = 1
          session[:skip_hash] = {}
        end

        app = WIN32OLE.new 'iTunes.Application'
        music = app.LibrarySource.Playlists.ItemByName 'Music'
        total_count = music.Tracks.Count

        while session[:track_counter] <= total_count do
          track = music.Tracks.Item session[:track_counter]
          if track.Artwork.Count == 0 and not session[:skip_hash].has_key? track.Album
            @album_candidates = lookup_album track.Album, track.Artist
            if not @album_candidates.nil?
              # We have our next contestant.  Let's break
              #puts "Got candidates: #{@album_candidates.length}"
              @current_album = session[:current_album] = track.Album
              @current_artist = session[:current_artist] = track.Artist
              break
            end
          end
          session[:track_counter] += 1  
        end
      end

      def index
        iterate_through_coverless_albums
        render :action => 'iterate_through_coverless_albums', :controller => 'album'
      end
    end

iteratethroughcoverless_albums.rhtml:

    script type = "text/javascript">
function submit_form(url, idx)
{
art.image_url.value = url;
art.idx.value = idx;
art.submit();
}
</script> <% form_tag '/album/reset' do %>
<%= submit_tag 'Reset' %>
<% end %>

<% form_tag '/album/next' do %>
<%= submit_tag 'Next' %>
<% end %>

<h1><%= @current_artist %>, <%= @current_album %></h1>
<% form_tag '/album/process_input', {:name => "art", :id => "art"} do%>
<%= hidden_field_tag "image_url" %>
<%= hidden_field_tag "idx" %>
<% if not @album_candidates.nil? %>
<table>
<th>Album Name</th>
<th>Small Art</th>
<th>Medium Art</th>
<th>Large Art</th>
<% idx = 0
@album_candidates.each do |album| %>
<tr>
<td><%= album.product_name %></td>
<td><%= image_tag album.image_url_small, :onclick => "submit_form('#{album.image_url_small}', #{idx})" %></td>
<td><%= image_tag album.image_url_medium, :onclick => "submit_form('#{album.image_url_medium}', #{idx})" %></td>
<td><%= image_tag album.image_url_large, :onclick => "submit_form('#{album.image_url_large}', #{idx})" %></td>
</tr>
<% idx += 1
end %>
</table>
<% else %>
Done
<% end %>
<% end %>

<% form_tag '/album/next' do %>
<%= submit_tag 'Next' %>
<% end %>

Posted by evan on Apr 15, 2007

Controlling IM app statuses in one place

For my next trick, I was going to sling together a UI to help my wife manage Skype. But then I found that the Skype API is a little more painful than just RubyOSA.

I discovered that RubyOSA comes with a built in script to generate RDoc for any AppleScript-supporting application that has a defined AppleScript definition. How handy! “rdoc –name Skype” and off I went. Or so I thought. Skype’s AppleScript API is simple: they give you one command that accepts commands using their protocol.

Not entirely dissuaded, I started my campaign to create a script to change the status of my iChat, Adium, and Skype (yes, I need all three – don’t you???) simultaneously.

To automate things further, I deposited the script in ~/Library/Application Support/Quicksilver/Actions. I then input the command (on, off, or away) via Quicksilver using ‘.’ to let me enter a command and then my script shows up as a target. I then created Quicksilver hotkeys for online (CTRL-CMD-UP), offline (CTRL-CMD-DOWN), and away (CTRL-CMD-LEFT or “exit stage left” as I see it). So, finally, my IM status should reasonably reflect when I’m actually on or off.

If you want to use this script:

  • Bear in mind, your Ruby binary is probably installed somewhere other than /usr/local/bin/ruby so you’ll need to tweak that line.
  • You’ll need rubygems installed
  • You’ll need the RubyOSA gem installed

No, Steve, I was not up all night working on this.

[Download] (right click “Save Link As”)

Update (3/12/07): Since made some minor tweaks to support custom away messages for Adium and iTunes. Of course, to make this “sexy” (extensible), I’d scan a subdirectory for “plugins” that would manage the status per IM application allowing for you Mac users who use Fire instead of Adium, for example.

#!/usr/local/bin/ruby

require 'rubygems'
require 'rbosa'

def get_app(app_name)
  app = OSA.app(app_name)
  if app.nil?
    puts "#{app} does not exist"
    exit
  end
  app
end

$Adium = get_app "Adium"
$iChat = get_app "iChat"

$MESSAGE = {
  :on => "Available",
  :away => "Away",
  :off => "" # moot anyway
}

$STATUS = {
  :Skype => {
    :off => "OFFLINE",
    :on => "ONLINE",
    :away => "AWAY"
  },
  :Adium => {
    :off => OSA::Adium::ASST::OFFLINE,
    :on => OSA::Adium::ASST::AVAILABLE,
    :away => OSA::Adium::ASST::AWAY    
  },
  :iChat => {
    :off => OSA::IChat::MSTA::OFFLINE,
    :on => OSA::IChat::MSTA::AVAILABLE,
    :away => OSA::IChat::MSTA::AWAY
  }
}

def is_known_status?(app, status)
  $STATUS[app].has_key? status
end    

def set_Skype_status_to(status)
  if not is_known_status? :Skype, status
    # No custom statuses with Skype, sorry!
    status = :away
  end
  app = get_app "Skype"
  request = "SET USERSTATUS #{$STATUS[:Skype][status]}"
  app.send2 request, ""
end

def set_Adium_status_to(status)
  if is_known_status? :Adium, status
    $Adium.adium_controller.my_status_type = $STATUS[:Adium][status]
    $Adium.adium_controller.my_status_message = $MESSAGE[status]
  else
    $Adium.adium_controller.my_status_type = $STATUS[:Adium][:away]
    $Adium.adium_controller.my_status_message = status.to_s
  end    
  if status == :on
    $Adium.adium_controller.accounts.each { |a| a.connect }
  end
end

def set_iChat_status_to(status)
  if is_known_status? :iChat, status 
    $iChat.status = $STATUS[:iChat][status]    
    $iChat.status_message = $MESSAGE[status]
  else
    $iChat.status = $STATUS[:iChat][:away]
    $iChat.status_message = status
  end
end

status = ARGV[0].intern
set_Skype_status_to status
set_Adium_status_to status
set_iChat_status_to status  

Posted by evan on Apr 10, 2007

Adding metadata to added iTunes videos

I have a problem. Or rather my wife does. She loves the AppleTV but it’s difficult for her to find content for her to watch. Why? Because our video content, other than genre metadata (added by hand – blech!), contains no metadata! Once again, coding to the rescue.

The ingredients:

  • Ruby (my language of choice du jour)
  • ruby-amazon - Clients itself to Amazon’s web service to perform keyword searches. Major caveat: The search results are nowhere near as accurate as those provided on the Amazon web site. I found this surprising. Why this has not been made into a Winblows XP Media Center Edition 2005 so that the wifey can have her relatively stable Windows Media Center as opposed to our previously slightly less stable, although somewhat more hacker friendly, SageTV.

At the moment, the program fetches descriptions, years (from the DVD release date, sadly, and not the original movie release, and DVD cover artwork from Amazon.com). As Amazon’s web service provides spurious accuracy, the program prompts me to select from one of ten candidate Amazon products to use as a source of metadata for any given movie.

The obnoxious part? I have yet to figure out how to programatically add artwork to a movie (IITTrack.AddArtworkFromFile barfs all over me) even though I am fetching it from Amazon.

require 'amazon/search'
require 'pp'
require 'win32ole'
#require 'net/http'
require 'uri'

$AWS_KEY = 'XXXXXXXXXXXXXXXXXXXX'  # bleeped out to protect the innocent
$ART_PATH = 'f:/movies/artwork/'

$app = WIN32OLE.new 'iTunes.Application'
exit if $app.nil?
$movies = $app.LibrarySource.Playlists.ItemByName 'Movies'



def find_dvd(title)
  retval = []
  req = Amazon::Search::Request.new $AWS_KEY
  res = req.keyword_search(title, 'dvd') 
  i=1
  res.products.each do |p|
    puts "#{i}. #{p.product_name} (#{p.release_date})"
    i+=1
  end
  print ">> "
  input = gets
  if input.match /s/
    return nil
  end
  input.match /(\\d+)/
  res.products[$1.to_i - 1]
end

def get_artwork(movie_track, movie_meta)
  response = Net::HTTP.get_response(URI.parse(movie_meta.image_url_large))
  title = movie_track.Name
  title.gsub! ':', '-'
  art_filename = ""
  art_filename << $ART_PATH << title << ".jpg"
  File.open( art_filename, 'wb') do |f| 
    f << response.body
    f.flush
  end
  art_filename
end

def update_metadata_for(movie_track)
  track = movie_track

  meta = find_dvd movie_track.Name
  if meta.nil?
    return
  end

  if meta.release_date
    meta.release_date.match /, (\\d+)$/
    track.Year = $1
  end

  art_filename = get_artwork track, meta
  if track.Artwork
    puts track.Artwork
    track.AddArtworkFromFile art_filename
  end

  if meta.product_description 
    meta.product_description.gsub!( /<(\\/?)(\\w+)>/, "")
    meta.product_description.gsub!( /\\s\\s.*/, "")
    puts meta.product_description
    track.Description = meta.product_description
  end

end

idx = 1
count = $movies.Tracks.Count
while idx <= count do
  movie = $movies.Tracks.Item idx
  puts movie.Name
  if movie.Description == ""
    update_metadata_for movie
  end
  idx += 1
end

Posted by evan on Apr 08, 2007