#!/usr/bin/env ruby
#
# omptagger
# http://github.com/omp/omptagger
# Modify and display metadata of audio files.
#
# Copyright 2007-2010 David Vazgenovich Shakaryan <dvshakaryan@gmail.com>
# Distributed under the terms of the GNU General Public License v3.
# See http://www.gnu.org/licenses/gpl.txt for the full license text.
#
# Dependencies:
#   Ruby            http://www.ruby-lang.org/
#   RubyGems        http://rubygems.org/
#   rtaglib         http://rubygems.org/gems/rtaglib
#   ruby-filemagic  http://rubygems.org/gems/ruby-filemagic
#   TagLib          http://developer.kde.org/~wheeler/taglib.html

require 'getoptlong'
require 'rubygems'
require 'TagLib'
require 'filemagic'

module Output
  @@colour = true

  def self.colour=(boolean)
    @@colour = boolean
  end

  def self.colourise(string, colour)
    return string unless @@colour

    case colour
    when :green
      code = '32'
    when :yellow
      code = '33'
    when :cyan
      code = '36'
    else
      return string
    end

    return "\e[#{code}m#{string}\e[0m"
  end

  def self.file(file)
    puts colourise(file + ':', :yellow)
  end

  def self.action(action)
    puts '  ' + colourise(action + ':', :green)
  end

  def self.field(field, value, padding = 0)
    puts '    ' + colourise(field, :cyan) + ' ' * (2 + padding) + value
  end

  def self.info(info)
    puts '    ' + info
  end

  def self.help_information
    puts <<-end
Usage: omptagger [actions/options] [files]

Actions:
  --view        -v  View all tags
  --view-tag    -t  View a tag
  --add-tag     -a  Add a tag
  --set-tag     -s  Set a tag
  --remove      -r  Remove all tags
  --remove-tag  -d  Remove a tag
  --generate    -g  Generate tags based on filename
  --rename      -m  Rename file based on tags
  --scheme      -n  Change file naming scheme

Options:
  --no-colour   -c  Disable colourisation of output
  --pretend     -p  Disable finalisation of changes
  --help        -h  Display help information
  --info        -i  Display additional information
    end

    exit
  end

  def self.additional_information
    puts <<-end
Actions:
  * Multiple actions can be chained together.
  * Actions are executed in the order they are specified.
  * Example: omptagger --view --set-tag ALBUM='Foobar' --view

Schemes:
  * The default file naming scheme is Track - Artist - Title.
  * Schemes must be specified prior to actions that use them.
  * Example: omptagger --scheme '%a - %t' --generate
  * %a - Artist    %* - Wildcard
    %b - Album
    %d - Date
    %n - Track
    %t - Title

Supported Tags:
  * Vorbis comments: Any field name allowed by specification.
  * ID3v2 frames: Artist, album, title, track, year.

Tag Generation:
  * Underscores in filenames are converted to spaces in tags.
  * Backslashes in filenames are converted to forward slashes in tags.
    end

    exit
  end
end

class Datum
  @@mime = FileMagic.mime
  @@mime.simplified = true

  def initialize(filename)
    raise 'File does not exist.' unless File.exist?(filename)

    Output.file(filename)

    @filename = File.expand_path(filename)

    case @@mime.file(@filename)
    when 'audio/x-flac'
      type = FLAC
    when 'application/ogg'
      type = Vorbis
    when 'audio/mpeg'
      type = MP3
    else
      raise 'File type not recognised.'
    end

    @metadata = type.new(@filename)
  end

  def execute(action)
    Output.action(action.to_s)

    begin
      @metadata.send(*action.arguments.dup.unshift(action.action))
    rescue MetadataError => message
      Output.info(message.to_s)
    end
  end

  def save
    @metadata.save if @metadata.write

    File.rename(@filename, @metadata.filename) unless @filename == @metadata.filename
  end
end

class Action
  attr_reader :action, :arguments

  def initialize(action, *arguments)
    @action = action
    @arguments = arguments
  end

  def to_s
    case @action
    when :view
      str = 'Viewing all fields'
    when :viewtag
      str = 'Viewing %s field'
    when :addtag
      str = 'Adding %s field'
    when :settag
      str = 'Setting %s field'
    when :remove
      str = 'Removing all fields'
    when :removetag
      str = 'Removing %s field'
    when :generate
      str = 'Generating fields'
    when :rename
      str = 'Renaming file'
    end

    return str % @arguments
  end
end

class MetadataError < RuntimeError
  def initialize(error)
    @error = error
  end

  def to_s
    case @error
    when :empty
      str = 'No fields set.'
    when :generate
      str = 'Filename does not match scheme.'
    when :invalid
      str = 'Invalid field name.'
    when :rename
      str = 'Insufficient tags.'
    when :unset
      str = 'Field not set.'
    end

    return str
  end
end

class Array
  def longest_element_length
    self.inject(0) do |longest, key|
      key.length > longest ? key.length : longest
    end
  end
end

class Hash
  def longest_key_length
    self.keys.inject(0) do |longest, key|
      key.length > longest ? key.length : longest
    end
  end
end

class Metadata
  attr_reader :filename, :write

  def initialize(file)
    @filename = File.expand_path(file)

    @file = open(file)
    @metadata = read
    @write = false
  end

  def view
    raise MetadataError, :empty if @metadata.empty?

    @metadata.sort.each do |field, value|
      value.each do |value|
        Output.field(field, value, @metadata.longest_key_length - field.length)
      end
    end
  end

  def viewtag(field)
    field.upcase!

    raise MetadataError, :unset unless @metadata.has_key?(field)

    @metadata[field].each do |value|
      Output.field(field, value)
    end
  end

  def addtag(field, value, padding = 0)
    field.upcase!

    raise MetadataError, :invalid unless valid_field?(field)

    if @metadata.has_key?(field)
      @metadata[field] << value
    else
      @metadata[field] = value
    end

    Output.field(field, value, padding)

    @write = true
  end

  def settag(field, value, padding = 0)
    field.upcase!

    raise MetadataError, :invalid unless valid_field?(field)

    @metadata[field] = [value]
    Output.field(field, value, padding)

    @write = true
  end

  def remove
    raise MetadataError, :empty if @metadata.empty?

    @metadata.clear
    Output.info('Removed.')

    @write = true
  end

  def removetag(field)
    field.upcase!

    raise MetadataError, :unset unless @metadata.has_key?(field)

    @metadata.delete(field)
    Output.info('Removed.')

    @write = true
  end

  def generate(scheme)
    regexp = Regexp.escape(scheme)
    regexp = regexp.gsub(/%([#{keys.keys.join}]|\\\*)/, '([^/]*?)')
    regexp = /#{regexp}\Z/

    fields = scheme.scan(regexp).flatten
    values = @filename.chomp(File.extname(@filename)).scan(regexp).flatten

    raise MetadataError, :generate unless fields.length == values.length

    fields.collect! do |field|
      keys[field[1,1]]
    end

    longest = fields.compact.longest_element_length

    fields.zip(values).each do |field, value|
      unless field.nil?
        addtag(field, value, longest - field.length)
      end
    end
  end

  def rename(scheme)
    scheme.scan(/%([#{keys.keys.join}])/).flatten.uniq.each do |field|
      raise MetadataError, :rename unless @metadata[keys[field]]
      scheme = scheme.gsub('%' + field, @metadata[keys[field]].first)
    end

    scheme << File.extname(@filename)
    @filename = File.join(File.dirname(@filename), scheme)

    Output.info(@filename)
  end
end

class VorbisComment < Metadata
  def save
    read.each_key do |field|
      metadata.removeField(TagLib::String.new(field, TagLib::String::UTF8))
    end

    @metadata.each do |field, value|
      field = TagLib::String.new(field, TagLib::String::UTF8)

      value.each do |value|
        value = TagLib::String.new(value, TagLib::String::UTF8)
        metadata.addField(field, value, false)
      end
    end

    @file.save
  end

  private

  def keys
    Hash['a' => 'ARTIST',
         'b' => 'ALBUM',
         'd' => 'DATE',
         'n' => 'TRACKNUMBER',
         't' => 'TITLE']
  end

  def read
    metadata.fieldListMap.hash
  end

  def valid_field?(field)
    valid = (32..125).collect do |character|
      character.chr
    end

    valid.delete('=')

    field.scan(/./).each do |character|
      return false unless valid.include?(character)
    end

    return true
  end
end

class FLAC < VorbisComment
  private

  def open(file)
    TagLib::FLAC::File.new(file)
  end

  def metadata
    @file.xiphComment
  end
end

class Vorbis < VorbisComment
  private

  def open(file)
    TagLib::Vorbis::File.new(file)
  end

  def metadata
    @file.tag
  end
end

class MP3 < Metadata
  private

  def open(file)
    TagLib::MPEG::File.new(file)
  end

  def metadata
    @file.ID3v2Tag
  end

  def read
    Hash[metadata.frameListMap.hash.collect do |field, value|
      [field, value.collect do |frame|
        frame.to_s
      end]
    end]
  end
end

actions = Array.new
options = Hash[:scheme => '%n - %a - %t']

GetoptLong.new(
  ['--view',       '-v', GetoptLong::NO_ARGUMENT],
  ['--view-tag',   '-t', GetoptLong::REQUIRED_ARGUMENT],
  ['--add-tag',    '-a', GetoptLong::REQUIRED_ARGUMENT],
  ['--set-tag',    '-s', GetoptLong::REQUIRED_ARGUMENT],
  ['--remove',     '-r', GetoptLong::NO_ARGUMENT],
  ['--remove-tag', '-d', GetoptLong::REQUIRED_ARGUMENT],
  ['--generate',   '-g', GetoptLong::NO_ARGUMENT],
  ['--rename',     '-m', GetoptLong::NO_ARGUMENT],
  ['--scheme',     '-n', GetoptLong::REQUIRED_ARGUMENT],
  ['--no-colour',  '-c', GetoptLong::NO_ARGUMENT],
  ['--pretend',    '-p', GetoptLong::NO_ARGUMENT],
  ['--help',       '-h', GetoptLong::NO_ARGUMENT],
  ['--info',       '-i', GetoptLong::NO_ARGUMENT]
).each do |option, argument|
  option = option.delete('-').intern

  case option
  when :help
    Output.help_information
  when :info
    Output.additional_information
  when :nocolour, :pretend, :scheme
    options[option] = argument
  when :viewtag, :removetag
    actions << Action.new(option, argument.dup)
  when :addtag, :settag
    raise 'Insufficient argument.' unless argument.include?('=')
    actions << Action.new(option, *argument.split('=', 2))
  when :generate, :rename
    actions << Action.new(option, options[:scheme])
  else
    actions << Action.new(option)
  end
end

if actions.empty?
  puts 'No actions specified. See the --help option.'
  exit
elsif ARGV.empty?
  puts 'No files specified.'
  exit
end

Output.colour = false if options[:nocolour]

ARGV.each do |filename|
  begin
    datum = Datum.new(filename)

    actions.each do |action|
      datum.execute(action)
    end

    datum.save unless options[:pretend]
  rescue RuntimeError => message
    $stderr.puts $0 + ': ' + filename + ': ' + message
  end
end