#!/usr/bin/python # reshuffle -- refill an ipod shuffle # Copyright 2008 Peter Maydell # # reshuffle is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # reshuffle is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # # iTunes, iPod and Shuffle are trademarks of Apple. # This product is not supported/written/published by Apple! # Summary of operation: # 1. Find (possibly mounting) the shuffle # 2. Find tracks on the shuffle with a playcount > 0 # 3. Delete the tracks from the shuffle # 4. Pick a track at random from the computer from # the list of tracks which haven't been marked 'played' # If we can't find any such track, then remove all # the 'played' marks and try again. # 5. Put it on the ipod, encoding it as we do so, # and including in the track's 'userdata' the # on-computer filename # 6. Mark the track as 'played' on the computer # (we have to do this as we put the track on the ipod # otherwise a subsequent top-up of the ipod might # select it again) # 7. Repeat 4-6 until shuffle is full # 8. Cleanly unmount the shuffle # On disk data: # Each subdirectory containing music files may also # have a file 'reshuffle.played' which is a text file # which lists all the files in that directory which # have been played in this run. If no file exists then # this is the same as having an empty file. # There may also be a file 'reshuffle.ignore'; contents # are unimportant. Any subdirectory containing this file # will be ignored when scanning for tracks to add to # the ipod. (This script doesn't create such files; the # idea is that the user manually creates them to stop # us looking in directories containing things not wanted.) # FIXME it would assist debugging if we set at least some # of the artist/title info in the db # FIXME we should probably have multiple verbosity # levels: debug/verbose/silent # FIXME it would be nice if we didn't cause the shuffle # to forget where it was in the track it was currently playing # FIXME only handling FLAC is fine for us but we could # be more generalised import gpod import optparse import os import os.path import random import re import subprocess import tempfile class Global: """Generic container for shared variables.""" pass IGNORE_FILE="reshuffle.ignore" PLAYED_FILE="reshuffle.played" # log levels: # debugging, if verbose, if verbose or if dry-run, print even if quiet LOG_DEBUG=3 LOG_VERBOSE=2 LOG_DRYRUN=1 LOG_QUIET=0 def log(loglevel,string): if Global.loglevel >= loglevel: print string def mark_played_on_disk(filename): log(LOG_DRYRUN, "Marking %s as played on disk" % filename) if Global.dry_run: return dir = os.path.dirname(filename) f = open(os.path.join(dir, PLAYED_FILE), "a") f.write(os.path.basename(filename) + "\n") f.close() def remove_dirs_to_ignore(root,dirlist): # Update dirlist in-place to remove names of # directories which contain an IGNORE_FILE # The in-placeness is important as this is how # os.walk works. # We go backwards so that one deletion doesn't # mess up the index number for a later deletion. for i in reversed(range(len(dirlist))): if os.path.isfile(os.path.join(root, dirlist[i], IGNORE_FILE)): log(LOG_DEBUG, "Ignoring directory %s" % dirlist[i]) del dirlist[i] def delete_played_file(pfile): log(LOG_DRYRUN, "Deleting file: %s" % pfile) if Global.dry_run: return os.remove(pfile) def clear_all_played_marks(directory): # Clear all the 'played' marks from directory log(LOG_DRYRUN, "Clearing all the 'played' marks") for root, dirs, files in os.walk(directory): remove_dirs_to_ignore(root,dirs) if PLAYED_FILE in files: delete_played_file(os.path.join(root,PLAYED_FILE)) def is_music_file(filename): # Return true if filename is a music file return filename.endswith(".flac") def get_unplayed_tracks(directory): # Return a shuffled list of all the tracks which # haven't been played yet log(LOG_VERBOSE, "Searching for unplayed tracks...") tracks = [] for root, dirs, files in os.walk(directory): remove_dirs_to_ignore(root,dirs) played = [] if PLAYED_FILE in files: f = open(os.path.join(root, PLAYED_FILE)) try: played = [line.rstrip('\n') for line in f.readlines()] log(LOG_DEBUG, "Got played tracks for %s: %r" % (root, played)) finally: f.close() files = [f for f in files if f not in played and is_music_file(f)] tracks.extend([os.path.join(root,f) for f in files]) log(LOG_DEBUG, "Finished scanning for tracks") random.shuffle(tracks) return tracks def convert_file_to_mp3(infile,mp3file): # Convert a FLAC file to mp3 log(LOG_DEBUG, "Converting to MP3...") p1 = subprocess.Popen(['flac', '-d', '-s', '-c', infile], stdout=subprocess.PIPE) p2 = subprocess.Popen(['lame', '--silent', '-V', '0', '-o', '-', mp3file], stdin=p1.stdout) if p2.wait() or p1.wait(): # oops, something went wrong log(LOG_QUIET, "Error in conversion process!") return False return True def set_trackdata(track,filename): # Set any relevant metadata for this track if we can # Just save the original filename into the db # (my music doesn't have FLAC tags and splitting the # filename into artist/album/track seems rather specific # to my personal setup) track['userdata']['reshufflefile'] = filename def add_track_to_ipod(db,filename): # Add the specified track to the ipod. # Return True if we managed this, False if # we failed (probably due to filling the ipod) log(LOG_DRYRUN, "Adding track %s to ipod" % filename) if Global.dry_run: return True mp3file = tempfile.NamedTemporaryFile(prefix='reshuffle', suffix='.mp3') try: if not convert_file_to_mp3(filename, mp3file.name): return False track = db.new_Track(filename=mp3file.name) set_trackdata(track, filename) try: track.copy_to_ipod() except gpod.TrackException, e: # Important that we remove this failed track otherwise # we are left with a duff entry in the db (which causes # complaints later) log(LOG_VERBOSE, "Copy failed, backing it out") db.remove(track) return False return True finally: mp3file.close() def remove_track_from_ipod(db,track): trackstr = "%r" % track if track['userdata']: try: trackstr = track['userdata']['reshufflefile'] except KeyError: pass log(LOG_DRYRUN, "Removing from ipod: %s" % trackstr) if Global.dry_run: return db.remove(track) def remove_old_tracks(db,directory): # Remove any old (played) tracks from the ipod, updating # the on-computer record of played tracks log(LOG_DRYRUN, "Searching ipod for played tracks...") for track in db: if track['playcount'] != 0 or track['skipcount'] != 0: remove_track_from_ipod(db,track) def add_new_tracks(db,directory,addcount): # Pick new tracks at random and add them to the ipod seen_all = False available_tracks = get_unplayed_tracks(directory) while True: if addcount != None: if addcount == 0: break addcount = addcount - 1 if available_tracks == []: if seen_all: log(LOG_DRYRUN, "Completely out of tracks to add") return seen_all = True clear_all_played_marks(directory) available_tracks = get_unplayed_tracks(directory) continue track = available_tracks.pop() if not add_track_to_ipod(db,track): # run out of space on ipod for more tracks break mark_played_on_disk(track) def do_db_close(db): if not Global.dry_run: log(LOG_VERBOSE, "Writing shuffle database file...") if not gpod.itdb_shuffle_write(db._itdb,None): raise gpod.DatabaseException("Unable to save shuffle db") log(LOG_DRYRUN, "Closing database") db.close() def update_and_close_db(db, mount): # We don't do a copy_delayed_files() because # we guarantee to have done the copies one at a time # earlier. # We have to write the shuffle's database file by hand # using the low level API (this is a bug in the python # bindings which is fixed in upstream SVN but has not yet # made it into ubuntu.) log(LOG_DRYRUN, "Writing shuffle database") try: do_db_close(db) except Exception, e: # If we couldn't write the db, try deleting the last track # and trying again. If that fails then just barf. db.remove(db[-1]) do_db_close(db) # Remove the stats db (playcounts have been merged into the full database # and besides track numbers etc will all be out of sync) # (do this even if doing a dry run because libgpod will have updated # playcounts in iTunesDB even if we did nothing to the db ourselves) statsfile = os.path.join(mount, 'iPod_Control', 'iTunes', 'iTunesStats') if os.path.exists(statsfile): os.remove(statsfile) def plausible_mounted_ipod_mountpoint(mount): return os.path.isdir(os.path.join(mount, "iPod_Control")) def mountpoint_in_fstab(mount): pat = re.compile(r'[^#]*\s+' + mount + r'\s+') try: f = open('/etc/fstab', 'r') except Exception, e: return False try: for l in f: if re.search(pat, l): return True; return False finally: f.close() def try_to_mount(mount): subprocess.call(['mount', mount]) def plausible_ipod_mountpoint(mount): # Return true if mountpoint looks plausible. If it looks # like it might be an unmounted filesystem, try mounting it. if plausible_mounted_ipod_mountpoint(mount): return True if not mountpoint_in_fstab(mount): return False try_to_mount(mount) return plausible_mounted_ipod_mountpoint(mount) def guess_ipod_mountpoint(): log(LOG_VERBOSE, "iPod mount point not specified, guessing...") for guess in ["/mnt/ipod", "/media/IPOD"]: log(LOG_VERBOSE, "Trying %s..." % guess) if plausible_ipod_mountpoint(guess): log(LOG_VERBOSE, "Yes, %s looks plausible" % guess) return guess return None def main(): oparser = optparse.OptionParser(usage = 'usage: %prog [options] musicdir') oparser.add_option('-m', '--mount', dest='mount', help='Specify mountpoint of iPod') oparser.add_option('-v', '--verbose', dest='verbose', default=False, action="store_true", help='Be verbose') oparser.add_option('--debug', dest='debug', default=False, action="store_true", help='Print debug progress messages (implies -v)') oparser.add_option('--dry-run', dest='dry_run', default=False, action="store_true", help='Just print what we would do, without writing to ipod or disk') oparser.add_option('--no-delete', dest='no_delete', default=False, action="store_true", help="Don't delete any tracks from the iPod, just try to add new ones") oparser.add_option('--add-count', dest='addcount', type='int', help='Add only this many tracks (default is as many as possible)') oparser.add_option('--no-unmount', dest='unmount', default=True, action='store_false', help="Don't unmount the iPod when done") opts,args = oparser.parse_args() if len(args) > 1: oparser.error("Too many arguments") elif len(args) == 0: oparser.error("Music directory not specified") directory = args[0] Global.dry_run = opts.dry_run Global.loglevel = LOG_QUIET if Global.dry_run: Global.loglevel = LOG_DRYRUN if opts.verbose: Global.loglevel = LOG_VERBOSE if opts.debug: Global.loglevel = LOG_DEBUG if not opts.mount: opts.mount = guess_ipod_mountpoint() if not opts.mount: oparser.error("Mount point not specified and not guessable") if not plausible_ipod_mountpoint(opts.mount): oparser.error("Mount point '%s' doesn't look like it has an ipod mounted" % opts.mount) log(LOG_DRYRUN, "Opening database for iPod mounted at %s" % opts.mount) db = gpod.Database(opts.mount) if not opts.no_delete: remove_old_tracks(db,directory) add_new_tracks(db,directory,opts.addcount) update_and_close_db(db,opts.mount) if opts.unmount: log(LOG_DRYRUN, "Unmounting iPod...") if not Global.dry_run: if subprocess.call(['umount', opts.mount]): print "warning: unmount failed" subprocess.call(['sync']) log(LOG_DRYRUN, "Done.") if __name__ == '__main__': main()