#!/usr/bin/env ruby Main { version IpodDB::VERSION description <<-__ A couple of tools for working with iPod Shuffle (2nd gen) database from command line. Each subcommand understands -h/--help flag. __ author 'artm ' name 'ipod_db' config ipod_root: "/media/#{ENV['USER']}/IPOD" option('version','v') { description 'show package version and exit' } IgnorePlaybackPosUnder = 10 IgnoreProgressUnder = 0.01 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. __ 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' } 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). __ } def run load_ipod_db rm end } def sync books_path = params['books'].value songs_path = params['songs'].value books = collect_tracks books_path books = riffle(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 {|track,i| list_track i, track} 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] } if agree "Are you sure you want them gone (Y/n)?", true FileUtils.rm tracks.map{|i,path|resolve_ipod_path path} end sync 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 track_length( path ) TagLib::FileRef.open(path){|file| file.audio_properties.length} 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 begin tracks = [] Find.find(resolve_ipod_path path) do |filepath| tracks << ipod_path(filepath) if track? filepath end tracks rescue Errno::ENOENT [] end end def track? path IpodDB::ExtToFileType.include? File.extname(path) end def track_info track info = Map.new info['playcount'] = track.playcount if track.playcount > 0 info['skipcount'] = track.skippedcount if track.skippedcount > 0 if track.bookmarkflag && track.bookmarktime > 0 pos = track.bookmarktime * 0.256 # ipod keeps time in 256 ms increments abs_path = resolve_ipod_path track.filename total_time = track_length(abs_path) if pos > IgnorePlaybackPosUnder && pos / total_time >= IgnoreProgressUnder info['pos'] = Pretty.seconds pos info['total'] = Pretty.seconds total_time # and cache seconds track['pos'] = pos track['total_time'] = total_time end end info end def list_track i, track abs_path = resolve_ipod_path track.filename exists = File.exists?(abs_path) track_color = exists ? :green : :red listing_entry = "%2d: %s" % [i,track.filename.apply_format( color: track_color )] listing_entry = listing_entry.bold if @ipod_db.playback_state.trackno == i puts listing_entry return unless exists info = track_info(track) if track.include? 'pos' progress = ProgressBar.create( format: " [%b #{info.pos.yellow} %P%%%i] #{info.total.yellow}", starting_at: track.pos, total: track.total_time, ) puts info.delete :pos info.delete :total end if info.count > 0 puts " " + info.map{|label,value| "#{label}: #{value.to_s.yellow}"}.join(" ") 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 end end tracks end def riffle paths bins = paths.group_by{|path| File.dirname(path)}.values Spread.spread *bins end } BEGIN { require 'main' require 'find' require 'pathname' require 'smart_colored/extend' require 'map' require 'taglib' require 'ruby-progressbar' require 'highline/import' # our own require 'ipod_db' require 'pretty' require 'spread' }