utility to sync an itunes playlist to a directory (like a mounted sd card/phone)

move common code into classes, add playlist album shuffle script

+251 -69
+53 -6
README.md
··· 1 - rsync the files of an itunes playlist with another directory, most likely a usb 2 - music device. 1 + A couple scripts for maintaining a directory of music files (probably located 2 + on a USB music player or SD card) from an iTunes playlist. 3 + 4 + Requires the `appscript` gem. 5 + 6 + ###itunes-rsync.rb 7 + Query a given iTunes playlist, build a scratch directory symlinking all files 8 + of that playlist into it, then run `rsync` from the scratch directory to the 9 + path given. 10 + 11 + **Note:** `rsync`'s `--delete` option is used, so it will delete any files in 12 + the given path that are not found in the playlist. 13 + 14 + Usage: `ruby itunes-rsync.rb "ipod (auto)" /Volumes/music/iTunes/` 15 + 16 + ###playlist_album_shuffle.rb 17 + Query a given iTunes playlist, organize all tracks into albums, shuffle the 18 + albums, and then print the filename of each track in each album (in its 19 + original track order) to STDOUT, relative to the path given. 20 + 21 + Mimics the album shuffle functionality of iTunes and older iPods and can be 22 + used to create a few album-shuffled `.pls` playlists of a given list of tracks 23 + for players that don't natively support album shuffle. 24 + 25 + **Note:** The path for the second argument is the path relative to all media as 26 + it would be mounted by the player. Nothing is saved to that path and the 27 + output is all done to STDOUT. 28 + 29 + Usage: `ruby playlist_album_shuffle.rb "ipod (auto)" /mnt/SD1/iTunes/ > /Volumes/AK100/album\ shuffle\ 1.pls` 30 + 31 + ###License 3 32 4 - creates symlinks in a scratch directory pointing to the real destination 5 - directory, then uses rsync to actually copy them out to the destination. 33 + Copyright (c) 2009, 2012, 2013 joshua stein <jcs@jcs.org> 34 + 35 + Redistribution and use in source and binary forms, with or without 36 + modification, are permitted provided that the following conditions 37 + are met: 6 38 7 - requires the `appscript` gem. 39 + 1. Redistributions of source code must retain the above copyright 40 + notice, this list of conditions and the following disclaimer. 41 + 2. Redistributions in binary form must reproduce the above copyright 42 + notice, this list of conditions and the following disclaimer in the 43 + documentation and/or other materials provided with the distribution. 44 + 3. The name of the author may not be used to endorse or promote products 45 + derived from this software without specific prior written permission. 8 46 9 - usage: `ruby itunes-rsync.rb "ipod (auto)" /Volumes/music/` 47 + THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR 48 + IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 49 + OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 50 + IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 51 + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 52 + NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 53 + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 54 + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 55 + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 56 + THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
appscript_itunes_fix.rb lib/appscript_itunes_fix.rb
+15 -63
itunes-rsync.rb
··· 1 1 #!/usr/bin/ruby 2 - # $Id: itunes-rsync.rb,v 1.5 2009/01/27 09:11:14 jcs Exp $ 3 - # 4 - # rsync the files of an itunes playlist with another directory, most likely a 5 - # usb music device. 6 - # 7 - # creates symlinks in a scratch directory pointing to the real destination 8 - # directory, then uses rsync to actually copy them out to the destination. 9 - # 10 - # requires the appscript gem 11 - # 12 - # Copyright (c) 2009, 2012 joshua stein <jcs@jcs.org> 2 + # Copyright (c) 2009, 2012, 2013 joshua stein <jcs@jcs.org> 13 3 # 14 4 # Redistribution and use in source and binary forms, with or without 15 5 # modification, are permitted provided that the following conditions ··· 35 25 # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 36 26 # 37 27 38 - require "rubygems" 39 - require "appscript" 40 - $: << File.dirname(__FILE__) 41 - require "appscript_itunes_fix" 28 + require File.dirname(__FILE__) << "/lib/itunes" 42 29 43 30 if !ARGV[1] 44 31 puts "usage: #{$0} <itunes playlist> <destination directory>" ··· 60 47 61 48 puts "querying itunes for playlist \"#{playlist}\"..." 62 49 63 - itunes = Appscript.app.by_name("iTunes", ITunesFix) 64 - begin 65 - if !(itpl = itunes.playlists[playlist]) 66 - raise "no such playlist with that name" 67 - end 50 + it = ITunes.new 51 + pl = it.playlist(playlist) 68 52 69 - tracks = itpl.file_tracks.get.map{|t| t.location.get.path } 70 - rescue => e 71 - puts "could not find playlist #{playlist.inspect}, exiting" 72 - exit 1 73 - end 53 + mbytes = pl.total_bytes.to_f / (1024 * 1024) 74 54 75 - # figure out how much space we're going to take 76 - bytes = 0 77 - tracks.each{|t| bytes += File.size(t) } 78 - mbytes = bytes.to_f / (1024 * 1024) 79 - 80 - puts "found #{tracks.length} track#{tracks.length == 1 ? "" : "s"} with " + 81 - "size #{sprintf("%0.2fMb", mbytes)}" 55 + puts "found #{pl.tracks.length} track#{pl.tracks.length == 1 ? "" : "s"} " << 56 + "with size " << sprintf("%0.2fMb", mbytes) 82 57 83 - # make sure the destination can take it 58 + # make sure the destination can hold this playlist 84 59 df_m = `df -m #{destdir}`.split("\n").last.split(" ")[1].to_i 85 60 if mbytes > df_m 86 61 puts "error: #{destdir} has size of #{df_m}Mb, need #{mbytes.ceil}Mb to sync" 87 62 exit 1 88 63 end 89 64 90 - # figure out where all tracks are stored by checking for the greatest common 91 - # directory of every track 92 - gcd = "" 93 - (1 .. tracks.map{|t| t.length }.max).each do |s| 94 - piece = tracks[0][0 .. s - 1] 95 - 96 - ok = true 97 - tracks.each do |t| 98 - if t[0 .. s - 1] != piece 99 - ok = false 100 - end 101 - end 102 - 103 - if ok 104 - gcd = piece 105 - else 106 - break 107 - end 108 - end 109 - 110 65 td = `mktemp -d /tmp/itunes-rsync.XXXXX`.strip 111 66 112 67 # link each track into the workspace 113 68 print "linking files under #{td}/... " 114 - tracks.each do |t| 115 - shortpath = t[gcd.length .. t.length - 1] 116 - tmppath = "#{td}/#{shortpath}" 117 - 118 - # restrict pathnames to avoid indexing problems on some players/filesystems 119 - tmppath.gsub!(/[^A-Za-z0-9\.\/,' -]/, "_") 69 + pl.tracks.each do |t| 70 + tmppath = td + "/" + t.safe_filename_without_gcd 120 71 121 72 if !Dir[File.dirname(tmppath)].any? 122 73 system("mkdir", "-p", File.dirname(tmppath)) 123 74 end 124 75 125 - File.symlink(t, tmppath) 76 + File.symlink(t.location, tmppath) 126 77 end 127 78 128 79 puts "done." 129 80 130 - # times don't ever seem to match up, so only check size 81 + # file times don't ever seem to match up, so only check size 131 82 puts "rsyncing to #{destdir}... " 132 - system("rsync", "-Lrv", "--size-only", "--progress", "--delete", 133 - "#{td}/", destdir) 83 + system("rsync", "-Lrv", "--size-only", "--progress", "--delete", "#{td}/", 84 + destdir) 134 85 135 86 print "cleaning up... " 136 87 system("rm", "-rf", td) 88 + 137 89 puts "done."
+122
lib/itunes.rb
··· 1 + #!/usr/bin/env ruby 2 + # Copyright (c) 2009, 2012, 2013 joshua stein <jcs@jcs.org> 3 + # 4 + # Redistribution and use in source and binary forms, with or without 5 + # modification, are permitted provided that the following conditions 6 + # are met: 7 + # 8 + # 1. Redistributions of source code must retain the above copyright 9 + # notice, this list of conditions and the following disclaimer. 10 + # 2. Redistributions in binary form must reproduce the above copyright 11 + # notice, this list of conditions and the following disclaimer in the 12 + # documentation and/or other materials provided with the distribution. 13 + # 3. The name of the author may not be used to endorse or promote products 14 + # derived from this software without specific prior written permission. 15 + # 16 + # THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR 17 + # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 18 + # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 19 + # IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 20 + # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 21 + # NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 22 + # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 23 + # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 + # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 25 + # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 + # 27 + 28 + require "rubygems" 29 + require "appscript" 30 + $: << File.dirname(__FILE__) 31 + require "appscript_itunes_fix" 32 + 33 + class ITunes 34 + def initialize 35 + @as = Appscript.app.by_name("iTunes", ITunesFix) 36 + @_playlists = {} 37 + end 38 + 39 + def playlist(name) 40 + @_playlists[name] ||= Playlist.new(self, name) 41 + end 42 + 43 + def as_playlist(name) 44 + @as.playlists[name] 45 + end 46 + end 47 + 48 + class Playlist 49 + def initialize(itunes, name) 50 + @itunes = itunes 51 + @as_playlist = @itunes.as_playlist(name) 52 + end 53 + 54 + def tracks 55 + @tracks ||= @as_playlist.file_tracks.get.map{|t| Track.new(self, t) } 56 + end 57 + 58 + def total_bytes 59 + tracks.map{|t| File.size(t.location) }.inject(:+) 60 + end 61 + 62 + # figure out where all tracks are stored by checking for the greatest common 63 + # directory of every track 64 + @_gcd = nil 65 + def gcd 66 + return @_gcd if @_gcd 67 + 68 + @_gcd = "" 69 + 70 + locs = self.tracks.map{|t| t.location }.sort_by{|p| p.length } 71 + (0 .. locs[0].length).each do |pos| 72 + try = locs[0][0 ... pos] 73 + 74 + ok = true 75 + locs.each do |loc| 76 + if loc[0 ... try.length] != try 77 + ok = false 78 + break 79 + end 80 + end 81 + 82 + if ok 83 + @_gcd = try 84 + else 85 + break 86 + end 87 + end 88 + 89 + @_gcd 90 + end 91 + end 92 + 93 + class Track 94 + attr_accessor :album_artist, :artist, :album, :compilation, :disc_number, 95 + :location, :name, :track_number 96 + 97 + def initialize(playlist, track) 98 + @playlist = playlist 99 + 100 + @album_artist = track.album_artist.get.to_s 101 + @artist = track.artist.get.to_s 102 + @album = track.album.get.to_s 103 + @compilation = !!track.compilation.get 104 + @disc_number = track.disc_number.get.to_i 105 + @location = track.location.get.path.to_s 106 + @name = track.name.get.to_s 107 + @track_number = track.track_number.get.to_i 108 + end 109 + 110 + def compilation? 111 + @compilation 112 + end 113 + 114 + def album_artist_or_artist 115 + @album_artist.strip == "" ? @artist : @album_artist 116 + end 117 + 118 + # restrict pathname to avoid indexing problems on some players/filesystems 119 + def safe_filename_without_gcd 120 + @location[@playlist.gcd.length .. -1].gsub(/[^A-Za-z0-9\.\/,' -]/, "_") 121 + end 122 + end
+61
playlist_album_shuffle.rb
··· 1 + #!/usr/bin/ruby 2 + # Copyright (c) 2013 joshua stein <jcs@jcs.org> 3 + # 4 + # Redistribution and use in source and binary forms, with or without 5 + # modification, are permitted provided that the following conditions 6 + # are met: 7 + # 8 + # 1. Redistributions of source code must retain the above copyright 9 + # notice, this list of conditions and the following disclaimer. 10 + # 2. Redistributions in binary form must reproduce the above copyright 11 + # notice, this list of conditions and the following disclaimer in the 12 + # documentation and/or other materials provided with the distribution. 13 + # 3. The name of the author may not be used to endorse or promote products 14 + # derived from this software without specific prior written permission. 15 + # 16 + # THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR 17 + # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 18 + # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 19 + # IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 20 + # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 21 + # NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 22 + # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 23 + # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 + # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 25 + # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 + # 27 + 28 + require File.dirname(__FILE__) + "/lib/itunes" 29 + 30 + if !ARGV[1] 31 + puts "usage: #{$0} <itunes playlist> <root directory of media>" 32 + exit 33 + end 34 + 35 + playlist = ARGV[0] 36 + destdir = ARGV[1] 37 + if !destdir.match(/\/$/) 38 + destdir << "/" 39 + end 40 + 41 + it = ITunes.new 42 + pl = it.playlist(playlist) 43 + 44 + albums = {} 45 + pl.tracks.each do |track| 46 + artist = (track.compilation? ? "Various" : track.album_artist_or_artist) 47 + album = "#{artist} - #{track.album}" 48 + albums[album] ||= [] 49 + 50 + albums[album].push track 51 + end 52 + 53 + # on ruby 1.9, Array#shuffle is a proper fisher-yates algorithm 54 + albums.keys.shuffle.each do |album| 55 + # sort album tracks by disc number, then track number 56 + albums[album].sort_by{|t| sprintf("%02d", t.disc_number) << "-" << 57 + sprintf("%02d", t.track_number) << "-" << t.name }.each do |t| 58 + # show the track relative to the playlist's gcd 59 + print destdir + t.safe_filename_without_gcd + "\r\n" 60 + end 61 + end