We developers automate highly complex tasks, but when it comes to the smaller repetitive tasks, we tend to do things manually, or fail to do them at all. By combining Ruby with robust and richly functional command line tools such as MPlayer, we can save ourselves lots of time and have fun in the process.

I recently decided that it would be nice to trim my collection of many video files downloaded from my phones over the years. Realizing this would be quite tedious, I asked myself “would this be easier with Ruby?” The answer, of course, was Yes!

Integrating MPlayer and Ruby

MPlayer is a Unix command line multimedia player that can be installed with your favorite package manager (e.g. brew, apt, or yum). By driving MPlayer from Ruby, we can create a workflow that will enable you to view and decide about video files with a minimum of keystrokes, without needing to use the mouse.

Files to process are specified on the command line. Multiple arguments can be specified, either absolute or relative, and either with or without wildcards. All filespecs are normalized to their absolute form so that duplicates can be eliminated.

MPlayer plays each file for the user, responding to cursor keys to move forward and backward in time, change the speed, etc. I recommend viewing the man page (man mplayer), but here are the most relevant options:

keyboard control
      LEFT and RIGHT
           Seek backward/forward 10 seconds.
      UP and DOWN
           Seek forward/backward 1 minute.
      PGUP and PGDWN
           Seek forward/backward 10 minutes.
      [ and ]
           Decrease/increase current playback speed by 10%.
      { and }
           Halve/double current playback speed.
      BACKSPACE
           Reset playback speed to normal.

When the user has seen enough to make a decision, q or [ESC] can be pressed, and MPlayer returns control to the Ruby script, which accepts a one character response to mark it to be saved (s), deleted (d), or marked as undecided (u) for future reprocessing; or q to quit the application.

The High Level View

Here is the highest level method in the script:

def main
  check_presence_of_mplayer
  create_dirs
  puts greeting
  files_to_process.each do |filespec|
    play_file(filespec)
    print disposition_prompt(filespec)
    destination_subdir = get_disposition_from_user
    `mv #{filespec} #{destination_subdir}`
    log(filespec, destination_subdir)
  end
end

Using Subdirectories

For simplicity of implementation and added safety, the application “marks” each multimedia file by moving it to one of the three subdirectories it has created, based on the user’s choice. The user selects d for deletes, s for saves, or u for undecideds. create_dirs creates the three subdirectories:

def create_dirs
  %w{deletes  saves  undecideds}.each { |dir| FileUtils.mkdir_p(dir) }
end

When the user is finished processing all files, they will probably want to move any files that have been moved to ./undecided back to . and run the program again.

Finally, when there are no files left in undecided, one will probably want to do something like this:

rmdir undecideds
rm -rf deletes
mv saves/* .
rmdir saves

An Example

For example, let’s say you run the following command:

organize-av-files 'video/*mp4' 'audio/*mp3'

MPlayer will begin playing the first file. When you are ready to finish viewing it, you will press q or ESC, and be presented with a prompt like this:

/Users/kbennett/android/video/20160102_234426.mp4:
s = save, d = delete, u = undecided, q = quit:

Type your response choice and then [Enter]. The program will move the file as appropriate, and immediately start playing the next file.

Shell vs. Ruby Wildcard Expansion

Be careful when using wildcards. If you enter *mp4 in a directory with 10,000 MP4 files, the shell will try to expand it into 10,000 arguments, which might exceed the maximum command line size and result in an error. You can instead quote the filemask (as '*mp4'), and it will then be passed to Ruby as a single argument, and Ruby will perform the expansion. You can usually use double quotes, but be aware that the single and double quotes behavior differs (see this helpful StackOverflow article).

One case where the shell’s expansion would be preferable is with the use of environment variables in the filespec ($FOO is more concise than ENV['FOO']), and in the case of using ~ for users other than the current user (e.g. ~someoneelse).

Also…

  • This workflow can be used with any multimedia files recognized by MPlayer, and that includes audio files.
  • There are many, many nice-to-have features that have not been implemented, since speed of implementation was a high priority. Feel free to add your own!
  • Although using Ruby probably enables writing the most concise and intention-revealing code, other languages such as Python would do fine as well.
  • The code for this script (“organize-av-files”) is currently at https://gist.github.com/keithrbennett/4d9953e66ea35e2c52abae52650ebb1b.

Conclusion

I hope you can see that with a modest amount of code you can build a highly useful (albeit not fancy) automation tool. The amount of expected use and the benefit per use determines the optimum amount of effort, and you have the freedom to choose any point in that continuum. The notion that all applications need to be feature-rich is not a useful one, and often results in inaction altogether.

Ruby is a great tool for this sort of thing. Why not use it?

— The End —


[Note: This article is occasionally improved. Its commit history is here.]


For your convenience, the script is displayed below:

#!/usr/bin/env ruby

# organize-av-files - Organizes files playable by mplayer
# into 'saves', 'deletes', and 'undecideds' subdirectories
# of the current working directory.
#
# Be careful, if you specify files to process in multiple directories,
# they will all be moved to the same subdirectories, so they will no
# longer be organized by directory, and if there are multiple files
# of the same name, some may be lost if overwritten.
#
# stored at:
# https://gist.github.com/keithrbennett/4d9953e66ea35e2c52abae52650ebb1b


require 'date'
require 'fileutils'
require 'set'

LOG_FILESPEC = 'organize-av-files.log'

def create_dirs
  %w{deletes  saves  undecideds}.each { |dir| FileUtils.mkdir_p(dir) }
end


def check_presence_of_mplayer
  if `which mplayer`.chomp.size == 0
    raise "mplayer not detected. "
        "Please install it (with apt, brew, yum, etc.)"
  end
end


# Takes all ARGV elements, expands any wildcards,
# converts to normalized (absolute) form,
# and eliminates duplicates.
def files_to_process

  # Dir[] does not understand ~, need to process it ourselves.
  # This does *not* handle the `~username` form.
  replace_tilde_if_needed = ->(filespec) do
    filespec.start_with?('~/')                    \
        ? File.join(ENV['HOME'], filespec[2, -1]) \
        : filespec
  end

  # When Dir[] gets a directory it returns no files.
  # Need to add '/*' to it.
  add_star_to_dirspec_if_needed = ->(filespec) do
    File.directory?(filespec)      \
        ? File.join(filespec, '*') \
        : filespec
  end

  # Default to all nonhidden files in current directory
  # but not its subdirectories.
  ARGV[0] ||= '*'

  all_filespecs = ARGV.each_with_object(Set.new) do |filemask, all_filespecs|
    filemask = replace_tilde_if_needed.(filemask)
    filemask = add_star_to_dirspec_if_needed.(filemask)

    Dir[filemask]                          \
        .map { |f| File.absolute_path(f) } \
        .select { |f| File.file?(f) }      \
        .each do |filespec|
      all_filespecs << filespec
    end
  end
  all_filespecs.sort
end


def greeting
  puts <<~GREETING
      organize-av-files

      Enables the vetting of audio and video files. 

      For each file, plays it with mplayer, and prompts for what you would like to do 
      with that file, moving the file to one of the following subdirectories:

      * deletes
      * saves
      * undecideds

      This software uses mplayer to play audio files. Use cursor keys to move forwards/backwards in time.
      Press 'q' or 'ESC' to abort playback and specify disposition of that file.

      Run `man mplayer` for more on mplayer.

      Assumes all files specified are playable by mplayer.
      Creates subdirectories in the current directory: deletes, saves, undecideds.
      Logs to file '#{LOG_FILESPEC}'

  GREETING
end


def play_file(filespec)
  # If you have mplayer problems, remove the redirection ("2> /dev/null")
  # to see any errors.
  `mplayer #{filespec} 2> /dev/null`
end


def disposition_prompt(filespec)
  "\n\n#{filespec}:\ns = save, d = delete, u = undecided, q = quit: "
end


def get_disposition_from_user
  loop do
    response = $stdin.gets.chomp.downcase

    if response == 'q'
      exit
    elsif %w(s d u).include?(response)
      return {
          's' => 'saves',
          'd' => 'deletes',
          'u' => 'undecideds'
      }[response]
    else
      print "s = save, d = delete, u = undecided, q = quit: "
    end
  end
end


def log(filespec, destination_subdir)
  dest_abbrev = destination_subdir[0].upcase # 'S' for saves, etc.
  log_message = "#{dest_abbrev}  #{Time.now}  #{filespec}"
  `echo #{log_message} >> #{LOG_FILESPEC}`
end


def main
  check_presence_of_mplayer
  create_dirs
  puts greeting
  files_to_process.each do |filespec|
    play_file(filespec)
    print disposition_prompt(filespec)
    destination_subdir = get_disposition_from_user
    `mv #{filespec} #{destination_subdir}`
    log(filespec, destination_subdir)
  end
end


main