Search

August 6, 2012

Ruby Play List Copier, Take 2

I finally got back to my little Ruby project over the weekend. The idea was to write a tool to copy an m3u play list and associated files to my mp3 player since Rhythmbox and Banshee were not up to the task. I used the ruby-taglib library from http://robinst.github.com/taglib-ruby/ to access mp3 tags.

My first attempt was turning out a little too much like an enterprisey Java project so I decided to back up and try to make it a little lighter and more Ruby-esque. I decided on a module for parsing play lists would allow for the best re-use for that functionality while simple classes would represent play lists and play list entries. With the library written the main script became the following:

#!/usr/bin/env ruby
#
require 'fileutils'
require './playlist-parser'

dest_dir = File::expand_path(ARGV[0])
source = PlayList.new(ARGV[1])
dest = PlayList.new(File::join(dest_dir, File::basename(source.to_s)))

source.read_playlist do |entry|
  basename = PlayListEntry::sanitize(entry.artist + ' - ' + entry.album +
    ' - ' + entry.track + ' - ' + entry.title + '.mp3')
  dest_entry = PlayListEntry.new(basename)
  dest.playlist_entries << dest_entry
  dest_file = File::join(dest_dir, dest_entry.to_s)
  if not File::exists?(dest_file) then
    puts "#{entry.source} => #{dest_file}"
    FileUtils.copy_file(entry.source, dest_file)
  else
    puts "#{dest_file} exists"
  end
end

dest.write_playlist

This script is pretty simple. It opens the given play list and iterates over the entries, creating a new play list based on the passed destination directory. The file is copied over as Rhythmbox and Banshee do, using the tag information to determine the file name. Then when we are done we write out the new play list.

The library file is little longer. It includes a module named PlayListParser which had the parsing functionality (such as it is, a play list file is not really very complicated; if you are reading this far open one up in a text editor and you’ll figure it out no problem). Then we have the PlayList class which includes the parser module and provides a write_playlist method. Finally the PlayListEntry which makes tag access convenient.

# http://robinst.github.com/taglib-ruby/
require 'taglib'

module PlayListParser

  attr_accessor :playlist, :playlist_entries

  def parse_playlist(playlist, &block)
    @playlist = playlist
    @playlist_entries = []
    save_dir = Dir::pwd
    Dir::chdir(File::dirname(playlist))
    File.open(playlist) do |file|
      file.readlines.each do |line|
        line = line.strip
        if line.empty? or line[0] == '#' then
          next
        end
        if not File.exists?(line) then
          puts "WARN: File #{line} does not exist in play list #{@playlist}"
        end
        entry =  PlayListEntry.new(line)
        @playlist_entries << entry
        block.call(entry) unless block == nil
      end
    end
    Dir::chdir(save_dir)
  end

end

class PlayList

  include PlayListParser

  def initialize(playlist)
    @playlist = playlist
    @playlist_entries = []
  end

  def read_playlist(&block)
    parse_playlist(playlist, &block)
  end

  def write_playlist
    File::open(playlist, 'w') do |file|
      file.puts('#EXTM3U')
      file.puts(@playlist_entries)
    end
  end

  def to_s
    return @playlist.to_s
  end
end

class PlayListEntry

  def self.pad_track(track)
    return ( track < 10 ? '0' + track.to_s : track.to_s)
  end

  def self.sanitize(source)
    return source.gsub(/[":\?]/, '_')
  end

  attr_accessor :source, :album, :artist, :comment, :genre, :title, :track, :year

  def initialize(source)
    @source = source
    read_tags
  end

  def read_tags
    if File.exists?(source) then
      TagLib::FileRef.open(source) do |fileref|
        tag = fileref.tag
        @album = tag.album
        @artist = tag.artist
        @comment = tag.comment
        @genre = tag.genre
        @title = tag.title
        @track = PlayListEntry::pad_track(tag.track)
        @year = tag.year unless tag.year == 0
      end
    end
  end

  def to_s
    return @source.to_s
  end
end

One drawback of the parser is the use of the current working directory to handle relative paths in the play list file. This construct makes the parse_playlist method not thread-safe. (I can’t help but think about these things after working on servers; but I left it that way since this is supposed to be a simple script.)

In the end I learned a few useful things along the way, like the difference between sub and gsub as well as some of the characters that are escaped by Rhythmbox and Banshee when making file names. Also how to split up a Ruby project into more than one file. And I ended up with something I can actually use. All in all a successful excursion into Ruby.