artm/ipod_db

View on GitHub
bin/ipod

Summary

Maintainability
Test Coverage
#!/usr/bin/env ruby

Main {
  version IpodDB::VERSION
  author 'artm <femistofel@gmail.com>'
  name 'ipod_db'

  description <<-__
  A couple of tools for working with iPod Shuffle (2nd gen) database from
  command line. Each subcommand understands -h/--help flag.
  __

  option('books','b') {
    argument_required
    default 'books'
    description 'subdirectory of ipod with bookmarkable media'
  }

  option('songs','s') {
    argument_required
    default 'songs'
    description 'subdirectory of ipod with non-bookmarkable media'
  }

  option('version','v') {
    description 'show package version and exit'
  }

  def run
    if param['version'].given?
      puts IpodDB::VERSION
    else
      help!
    end
  end

  mode('sync') {
    description <<-__
    Update the iPod database. Given directories of bookmarkable and non-bookmarkable
    media '#{program}' will find all supported tracks add them to the iPod database so
    the device is aware of their existance.

    The tracks under 'books' folder will get "riffled" - tracks from the same folder
    are spread out in the list so they don't follow each other if possible. IN THE
    FUTURE it is planned to allow configuring of track groups which are treated like
    single folders - e.g. to spread out all SciAm's "60 second somthing" podcasts along
    the playlist.

    It is perfectly possible to have other directories full of tracks in device's
    subconscious - e.g. when time-sharing the device among members of a poor
    family. Just make sure you update the database using your directories when
    receiving it from a relation.

    iPod remembers playback position on bookmarkable media and the '#{program}' goes
    out of its way to preserve the bookmarks. It also removes bookmarkable files
    from shuffle list.

    I configure gpodder to place podcast files inside IPOD/books directory and delete
    them after syncing. Having copied podcasts I run 'ipod sync' to update the
    database on the device and it's ready for consumption.
    __

    def run
      load_ipod_db
      sync
    end
  }

  mode('ls') {
    description 'produce a colorful listing of the tracks in the ipod database'

    def run
      load_ipod_db
      ls
    end

  }

  mode('rm') {
    description <<-__
    Remove tracks from the device by their numbers (that's why ls
    displays numbers: so it's easier to select them for rm).
    __
    argument('track') {
      arity -2
      description <<-__
      track numbers to delete from device (ranges like
      2-5 are accepted too). As a special case, the word
      'done' means all finished tracks, i.e. with play count
      above zero or progress above
      #{ (100*Ipod::Track::FinishedProgress).floor }%
      __
    }
    option('pretend','n') {
      description "don't delete, just list what would be deleted"
    }
    def run
      @pretend = params['pretend'].value
      load_ipod_db
      rm
    end
  }

  def sync
    books_path = params['books'].value
    songs_path = params['songs'].value
    sanitize_filenames books_path
    sanitize_filenames songs_path
    books = collect_tracks books_path
    books = spread(books)
    songs = collect_tracks songs_path
    @ipod_db.update books: books, songs: songs
    @ipod_db.save
    ls
  end

  def ls
    @ipod_db.each_track_with_index do |track,i|
      list_track i, wrapped_track(track)
    end
  end

  def rm
    tracks = parse_track_list(params['track'].values).map{|i,t| [i,t.filename]}
    puts "The following tracks are selected for removal:"
    tracks.each { |i,path| puts "  %2d. %s" % [i,path.green] }
    unless @pretend
      if agree "Are you sure you want them gone (Y/n)?", true
        FileUtils.rm tracks.map{|i,path|resolve_ipod_path path}
        sync
      end
    end
  end

  def ipod_path path
    '/' + Pathname.new(path).relative_path_from(Pathname.new @ipod_root).to_s
  end

  def resolve_ipod_path ipath
    File.join @ipod_root, ipath
  end

  def load_ipod_db
    @ipod_root = config['ipod_root']
    unless IpodDB.looks_like_ipod? @ipod_root
      fatal { "#{@ipod_root} does not appear to be a mounted ipod" }
      exit exit_failure
    end
    @ipod_db = IpodDB.new @ipod_root
  end

  def collect_tracks path
    tracks = []
    Find.find(resolve_ipod_path path) do |filepath|
      tracks << ipod_path(filepath) if track? filepath
    end
    tracks
  rescue Errno::ENOENT
    []
  end

  def sanitize_filenames path
    rename_us = []
    Find.find(resolve_ipod_path path) do |filepath|
      base_name = File.basename filepath
      sane_name = IpodDB.sanitize_filename base_name
      unless base_name == sane_name
        dir = File.dirname filepath
        rename_us << [ filepath, "#{dir}/#{sane_name}" ]
      end
    end
    rename_us.reverse_each do |from,to|
      if Dir.exists? to
        info "moving #{from}/* to #{to}/"
        FileUtils.mv Dir["#{from}/*"], to
        FileUtils.rm_rf from
      else
        info "renaming #{from} to #{to}"
        File.rename from, to
      end
    end
  end

  def track? path
    IpodDB::ExtToFileType.include? File.extname(path)
  end

  def list_track i, track
    listing_entry = "%2d: %s" % [i,track.filename.apply_format( color: track_color(track))]
    listing_entry = listing_entry.bold if @ipod_db.playback_state.trackno == i
    puts listing_entry
    display_progress track
  end

  def track_color track
    if track.exists? then
      if track.finished?
        :cyan
      else
        :green
      end
    else
      :red
    end
  end

  def display_progress track
    if track.exists? and track.pos >= IgnoreProgressUnder and
      track.progress.between?(IgnoreProgressUnder,Ipod::Track::FinishedProgress)

      progress = ProgressBar.create(
        format: "    [%b #{Pretty.seconds(track.pos).yellow} %P%%%i] #{Pretty.seconds(track.length).yellow}",
        starting_at: (100*track.progress),
      )
      puts
    end
  end

  def parse_track_list args
    tracks = Map.new
    args.each do |arg|
      case arg
      when /^\d+$/
        n = arg.to_i
        tracks[n] = @ipod_db[n]
      when /^(\d+)-(\d+)$/
        (Regexp.last_match(1)..Regexp.last_match(2)).each do |n_str|
        n = n_str.to_i
        tracks[n] = @ipod_db[n]
        end
      when 'done'
        @ipod_db.each_track_with_index do |track,n|
          tracks[n] = track if wrapped_track(track).finished?
        end
      end
    end
    tracks
  end

  def wrapped_track record
    @wrapped_tracks ||= Map.new
    @wrapped_tracks[record[:filename]] ||= Ipod::Track.new( record.merge(ipod_root: @ipod_root) )
  end

  def spread paths, group_lambda = lambda {|path| track_group(path)}
    bins = paths.group_by &group_lambda
    bins.each do |key, bin|
      bins[key] = spread(bins[key], lambda{|path| File.dirname(path)}) if key.is_a? Integer
    end
    Spread.spread *bins.values
  end

  def track_group track
    config['group_together'].each_with_index do |patterns,group|
      patterns.each do |pattern|
        return group if track.downcase.index pattern.downcase
      end
    end
    File.dirname(track)
  end

  IgnorePlaybackPosUnder = 10
  IgnoreProgressUnder = 0.01

  config({
    ipod_root: "/media/#{ENV['USER']}/IPOD",
    group_together: [
      # Just an example
      [ "60-Second", "Science Talk", "This Week in Science" ],
      [ "StarShipSofa", "Tales To Terrify" ],
    ]})
}

BEGIN {
  require 'main'
  require 'find'
  require 'pathname'
  require 'smart_colored/extend'
  require 'map'
  require 'ruby-progressbar'
  require 'highline/import'
  require 'fileutils'
  require 'pp'

  # our own
  require 'ipod_db'
  require 'ipod/track'
  require 'pretty'
  require 'spread'
  require 'fuzzy_locale'
  I18n.locale = :fuzzy
}