#!/usr/bin/env python
'''
Copies an M3U playlist file and its tracks to a different location on a local or remote file system.

@author: Rob Hasselbaum
'''
import os
import sys
import optparse
import subprocess

usage = 'Usage: %prog [options] PLAYLIST PLAYLIST_DEST MUSIC_SRC MUSIC_DEST'

help_text = usage + '''\n
Copies an M3U playlist file and its tracks to a different location in a local
or remote file system. Only tracks that are new or changed get copied over, and
original timestamps are preserved.

Required arguments:
  PLAYLIST       The M3U playlist file.
  PLAYLIST_DEST  Target file/directory to which the playlist should be copied.
  MUSIC_SRC      Root of the directory hierarchy containing all of the tracks
                 in the playlist. As much of the directory structure as is
                 needed to hold the copied tracks will be recreated under the
                 music destination location.
  MUSIC_DEST     Target directory to which tracks are copied. Subdirectories
                 from the source are recreated (if necessary) to hold the 
                 copied tracks.
                 
Options:
  -h, --help     Show this message.
  -n, --dry-run  Preview changes without copying files.
                 
The two destination directory arguments (PLAYLIST_DEST and MUSIC_DEST) may
specify a remote host with rsync syntax (i.e. [user@]host:path/to/target).
Also, as with rsync, a trailing slash at the end of the source directory
causes the subdirectories/files under the source to be recreated, but not
the source directory itself.

This program does not modify the playlist file, so you should make sure 
the paths contained within it are valid for both the source and destination,
although many music players are "smart enough" to find the tracks anyway.

EXAMPLE 1:

Suppose you have this local directory structure on a PC:
   + /home/rob
     +----Music
          +----General
          +----Kids
          +----Holiday
          +----Playlists

And suppose you want to replicate part of the contents of "/home/rob/Music"
under "/media/disk/Albums' based on a playlist called "New Trance.m3u". You'd
run a command like this:  

%prog '/home/rob/Music/Playlists/New Trance.m3u' \\
   /media/disk/Albums/Playlists/ /home/rob/Music/ /media/disk/Albums

EXAMPLE 2:

Next, let's say that as a rule, your playlists only include music from the
"General" folder, so you don't want to recreate that folder under
"/media/disk/Albums". Furthermore, you want the playlist file copied to
"/media/disk/Playlists". You'd run this:

%prog '/home/rob/Music/Playlists/New Trance.m3u' \\
   /media/disk/Playlists/ '/home/rob/Music/General/' /media/disk/Albums
  
EXAMPLE 3:

Hosts running SSH can be specified as the destination:

%prog '/home/rob/Music/Playlists/New Trance.m3u' \\
    bill@myserv:/home/bill/Playlists/ '/home/rob/Music/General/' \\
    bill@myserv:/home/bill/Albums
'''

def main():
    '''Main entry point.'''
    # Parse and validate arguments.
    if (sys.argv.count('-h') or sys.argv.count('--help') or len(sys.argv) <= 1):
        print_help()
        exit(0)
        
    # OptionParser used for optional args but not for help text because it doesn't handle positional args very well.
    parser = optparse.OptionParser(usage=usage)
    parser.add_option('-n', '--dry-run', action='store_true', dest='dry_run')
    (options, required_args) = parser.parse_args()        
    (playlist, playlist_dest, music_src, music_dest) = parse_required_args(required_args)
    
    # Compute list of files to be transferred.
    file_list = resolve_file_paths(playlist, music_src)
    if (len(file_list) == 0):
        print >> sys.stderr, 'No tracks found in playlist:', playlist
        exit(1)
    
    # Sync files
    retcode = rsync_xfer(playlist, playlist_dest, None, options.dry_run)
    if (not retcode):
        retcode = rsync_xfer(music_src, music_dest, file_list, options.dry_run)
    exit(retcode)

def rsync_xfer(src, dest, file_list, dry_run):
    '''Call rsync to copy the source to the destination with standard options.
    
    Args:
        src: The source file or directory.
        dest: The destination file or directory.
        file_list: List of files to sync. If not specified, then everything under the source is synced.
        dry_run: If true, changes are only previewed, not applied.
    '''
    # Build up rsync command.
    command = ['rsync', '-rpthvz']
    if (dry_run):
        command += ['--dry-run']
    if (file_list):
        command += ['--files-from=-']
    command += [src, dest]
    # Run command, passing the file list into stdin if it's supplied.
    proc = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=sys.stdout, stderr=sys.stderr)
    if (file_list):
        proc.communicate('\n'.join(file_list))
    return proc.wait()
    
def resolve_file_paths(playlist, music_src):
    '''Parse the playlist file to return a list of track files whose paths are relative to the music source directory.
    
    Args:
      playlist: The playlist filename.
      music_src: The music source root directory.
      
    Returns:
      List of files (paths) whose paths are relative to the music source directory.
    '''
    # Any line in the playlist file that starts with '#' is a comment. Others are file paths.
    result = []    
    with open(playlist, 'r') as playlist_file:
        # Paths in the playlist file may be relative to its own directory, so switch to that directory and resolve
        # the file paths the same way a music player would.
        orig_directory = os.getcwd();
        os.chdir(os.path.dirname(playlist))
        # Now process the track listing.
        for line in playlist_file:
            if (not line.strip().startswith('#')):
                # Line is a file path. Make sure we can get to the file.
                track_file = line.strip()
                if (not os.access(track_file, os.R_OK)):
                    print >> sys.stderr, "Skipping unreadable file:", track_file
                else:
                    # Switch path to make it relative to music source dir and add to result.
                    result += [os.path.relpath(track_file, music_src)]
        # Restore current working directory.
        os.chdir(orig_directory)
    return result
            
    
def parse_required_args(required_args):
    '''Parse arguments and ensure that the source file and directory exists.
    
    Args:
      required_args: The required arguments from the command line.
      
    Returns:
      Tuple of paths: playlist file, playlist dest, music source, and music dest.
    '''
    if (len(required_args) == 4):
        playlist = required_args[0]
        playlist_dest = required_args[1]
        music_src = required_args[2]
        music_dest = required_args[3]
        if (not os.path.isfile(playlist) or not os.access(playlist, os.R_OK)):
            print >> sys.stderr, playlist, 'is not a readable file'
            exit(1)
        if (not os.path.isdir(music_src) or not os.access(music_src, os.R_OK | os.X_OK)):
            print >> sys.stderr, music_src, 'is not an accessible directory'
            exit(1)
        return (playlist, playlist_dest, music_src, music_dest) 
    else:
        print_help()
        exit(1)        
        
def print_help():
    '''Print help and return.'''
    prog = os.path.basename(sys.argv[0])
    print help_text.replace('%prog', prog)
    
if __name__ == '__main__':
    main()
