Fork me on GitHub
Release:
Date:
0.3.2
Aug 10, 2011
Flattr Flacsync

Table Of Contents

Download

Get latest source archive,
flacsync-0.3.2.tar.gz, or install with:

pip install flacsync --upgrade --user

Found a Bug?

Fill out a report on the issue tracker.

Source code for flacsync

#  Copyright 2009, Patrick C. McGinty

#  This program 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.

#  This program 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 <http://www.gnu.org/licenses/>.

"""
   Recursively mirror a directory tree of FLAC audio files to AAC/OGG. Source
   files can be filtered (by sub-directory, or full path) in order to limit the
   files converted. The script will also attempt to retain all meta-data fields
   in the output files.

   At a Glance
   -----------

   * Mirror directory tree of FLAC files audio files to AAC/OGG (re-encoded
     using NeroAacEnc).
   * Filter source tree using one or more sub-directory paths.
   * By default, will only re-encode missing or out-of-date AAC/OGG files.
   * Optionally deletes orphaned output files.
   * Multi-threaded encoding ensures full CPU utilization.
   * Supports transfer of FLAC meta-data including *title*, *artist*, *album*.
   * Converts FLAC replaygain field to Apple iTunes Sound Check.
   * Resizes and embeds album cover art JPEG files to destination files.

   Usage Model
   -----------

   * Hard disk space is cheap, but flash-based media players are still limited
     in capacity.
   * Create a lossy encoded "mirror" of your music files for portability.
   * Setup a daily cron job to always keep your FLAC and AAC/OGG files
     synchronized.
   * Re-encode your FLAC library to different AAC/OGG bit-rates in one command.
"""

import multiprocessing.dummy as mp
import optparse as op
import os
import sys
import textwrap

from . import decoder
from . import encoder
from . import util

__version__ = '0.3.1'
__author__ = 'Patrick C. McGinty'
__email__ = 'flacsync@tuxcoder.com'

DEFAULT_ENCODER = 'aac'

# define a mapping of enocoder-types to implementation class name.
ENCODERS = {'aac':encoder.AacEncoder,
            'ogg':encoder.OggEncoder,
         }
CORES = mp.cpu_count()


#############################################################################
[docs]class WorkUnit( object ): def __init__( self, opts, max_work ): self.abort = False self._opts = opts self._max_work = max_work self._count = 0 self._dirs = {} def _log( self, file_ ): """Output progress of encoding to terminal.""" lines = [] dir_ = os.path.dirname(file_) if not dir_ in self._dirs: # print current directory lines.append( '-'*30 ) lines.append( '%s/...' % (dir_[:74],)) self._dirs[dir_] = True # print input file pos = '[%d of %d]' % (self._count,self._max_work) lines.append( '%15s %-60s' % (pos, os.path.basename(file_)[:60],) ) return '\n'.join(lines)
[docs] def do_work( self, encoder ): """Perform all process steps to convert every FLAC file to the defined output format.""" if self.abort: return try: file_ = encoder.src self._count += 1 print self._log( file_ ) sys.stdout.flush() if encoder.encode( self._opts.force ): encoder.tag( decoder.FlacDecoder(file_).tags ) encoder.set_cover(True) # force new cover else: # update cover if newer encoder.set_cover() except KeyboardInterrupt: self.abort = True except Exception as exc: print "ERROR: '%s' !!" % (file_,) print exc
[docs]def get_dest_orphans( dest_dir, base_dir, sources ): """Return a list of destination files that have no matching source file. Only consider files that match paths from source list (if any).""" orphans = [] # walk all destination sub-directories for root, dirs, files in os.walk( dest_dir, followlinks=True ): orphans.extend( os.path.abspath(os.path.join(root,f)) for f in files ) # remove files from destination not found under one (or more) paths from the # source list if sources: # if absolute path, convert src filters to reference dest dir dests = (f.replace( base_dir, dest_dir, 1) for f in sources) orphans = (f for f in orphans for p in dests if f.startswith(p)) # remove all files with valid sources orphans = (f for f in orphans if not os.path.exists( util.fname(f, base=dest_dir, new_base=base_dir, new_ext='.flac')) ) return orphans
[docs]def del_dest_orphans( dest_dir, base_dir, sources ): """Prompt the user to remove all orphaned files located in the destination file path(s).""" # create list of orphans orphans = get_dest_orphans( dest_dir, base_dir, sources ) yes_to_all = False for o in orphans: rm = True if not yes_to_all: while True: val = raw_input( "remove orphan `%s'? [YES,no,all,none]: " % (o,)) val = val.lower() if val == 'none': return elif val in ['a','all']: yes_to_all = True break elif val in ['y','yes','']: break elif val in ['n','no']: rm = False break if rm: os.remove(o) # remove empty directories from 'dest_dir' for root,dirs,files in os.walk(dest_dir, topdown=False): if root != dest_dir: try: os.rmdir(root) # remove dir except OSError: pass
[docs]def get_src_files( base_dir, sources ): """Return a list of source files for transcoding.""" input_files = [] # walk all sub-directories for root, dirs, files in os.walk( base_dir, followlinks=True ): # filter flac files flacs = (f for f in files if os.path.splitext(f)[1] == '.flac') input_files.extend( os.path.abspath(os.path.join(root,f)) for f in flacs ) # remove files not found under one (or more) paths from the source list if sources: input_files = (f for f in input_files for p in sources if f.startswith(p)) return input_files
[docs]def normalize_sources( base_dir, sources ): """Convert all source paths to absolute path, and remove non-existent paths.""" # try to extend sources list using 'base_dir' as root alt_sources = [os.path.join(base_dir,f) for f in sources] sources = zip( sources, alt_sources ) # apply 'os.path.exists' to tuples of dirs is_valid_path = [ map(os.path.exists,x) for x in sources ] # find any False 'is_valid' tuples invalid = [x for x in zip(sources,is_valid_path) if not any(x[1])] if invalid: raise ValueError( "', or '".join(invalid[0][0])) # apply abspath to all items, remove duplicates sources = [inner for outer in sources for inner in outer] return list(set(map(os.path.abspath,sources)))
[docs]def store_once( option, opt_str, value, parser, *args, **kw): """Handle storage of single option, throw error if redfined.""" old_val = getattr(parser.values, option.dest) if not (old_val is None or old_val == value): raise op.OptionValueError( "option '%s' can not redefine '%s' to '%s'" % (opt_str,old_val,value)) else: setattr(parser.values, option.dest, value)
[docs]def store_enc_opt( option, opt_str, value, parser, *args, **kw): """Handle codec options, checks that matching codec is selected.""" # set the default encoder if it has not be defined if not parser.values.enc_type: parser.values.enc_type = DEFAULT_ENCODER # check that encoder type matches the encoder option type enc = parser.values.enc_type if not enc or enc == args[0]: setattr(parser.values, option.dest, value) else: raise op.OptionValueError( "option '%s' is not allowed with '%s' encoder" % (opt_str,enc))
[docs]def get_opts( argv ): usage = """%prog [options] BASE_DIR [SOURCE ...] BASE_DIR Define the root path of a directory hierarchy containing desired input files (FLAC). A mirrored output directory will be created in the deepest path, parallel to BASE_DIR, and named after the selected output file extension. For example, if BASE_DIR is "/data/flac", the output dir will be "/data/aac". SOURCE ... Optional dir/file argument list to select source files for transcoding. If not defined, all files in BASE_DIR will be transcoded. The SOURCE file/dir list must be relative from BASE_DIR or the current working directory. """ parser = op.OptionParser(usage=usage, version="%prog "+__version__) parser.add_option( '-c', '--threads', dest='thread_count', default=CORES, type='int', help="set max number of encoding threads [default:%default]" ) helpstr = """ force re-encode of all files from the source dir; by default source files will be skipped if it is determined that an up-to-date copy exists in the destination path""" parser.add_option( '-f', '--force', dest='force', default=False, action="store_true", help=_help_str(helpstr) ) helpstr = """ select the output transcode format; supported values are 'aac','ogg' [default:%s]""" % (DEFAULT_ENCODER,) # note: the default encoder is enforced manually parser.add_option( '-t', '--type', choices=ENCODERS.keys(), action='callback', callback=store_once, type='choice', dest='enc_type', help=_help_str(helpstr)) helpstr = """ prevent the removal of files and directories in the dest dir that have no corresponding source file""" parser.add_option( '-o', '--ignore-orphans', dest='del_orphans', default=True, action="store_false", help=_help_str(helpstr) ) helpstr = """ define alternate destination output directory to override the default. The standard default destination directory will be created in the same parent directory of BASE_DIR. See BASE_DIR above.""" parser.add_option( '-d', '--destination', dest='dest_dir', help=_help_str(helpstr) ) # AAC only options aac_group = op.OptionGroup( parser, "AAC Encoder Options" ) helpstr = """ set the AAC encoder quality value, must be a float range of 0..1 [default:%default]""" aac_group.add_option( '-q', '--aac-quality', dest='aac_q', default='0.35', action='callback', callback=store_enc_opt, callback_args=('aac',), type='string', help=_help_str(helpstr) ) parser.add_option_group( aac_group ) # OGG only options ogg_group = op.OptionGroup( parser, "OGG Encoder Options" ) helpstr = """ set the Ogg Vorbis encoder quality value, must be a float range of -1..10 [default:%default]""" ogg_group.add_option( '-g', '--ogg-quality', dest='ogg_q', default='5', action='callback', callback=store_enc_opt, callback_args=('ogg',), type='string', help=_help_str(helpstr) ) parser.add_option_group( ogg_group ) # examine input args (opts, args) = parser.parse_args( argv ) if not args: print "ERROR: BASE_DIR not defined !!" sys.exit(-1) # check/set encoder if not opts.enc_type: opts.enc_type = DEFAULT_ENCODER opts.EncClass = ENCODERS[opts.enc_type] # handle positional arguments opts.base_dir = os.path.abspath(args[0]) try: opts.sources = normalize_sources( opts.base_dir, args[1:] ) except ValueError as exc: print "ERROR: '%s' is not a valid path !!" % (exc,) sys.exit(-1) # set default destination directory, if not already defined if not opts.dest_dir: opts.dest_dir = os.path.join(os.path.dirname(opts.base_dir),opts.enc_type) opts.dest_dir = os.path.abspath(opts.dest_dir) return opts
def _help_str( text ): return textwrap.dedent(text).strip()
[docs]def main( argv=None ): opts = get_opts( argv ) # use base dir and input filter to locate all input files flacs = get_src_files( opts.base_dir, opts.sources ) # convert files to encoder objects enc_opts = dict((k,v) for k,v in vars(opts).iteritems() if k.startswith(opts.enc_type)) encoders = (opts.EncClass( src=f, base_dir=opts.base_dir, dest_dir=opts.dest_dir, **enc_opts) for f in flacs) # filter out encoders that are unnecessary if not opts.force: encoders = (e for e in encoders if not e.skip_encode()) encoders = list(encoders) # remove orphans, if defined if opts.del_orphans: del_dest_orphans( opts.dest_dir, opts.base_dir, opts.sources) # exit if no work if not encoders: return # create work pool, and add jobs queue = mp.Pool( processes=opts.thread_count ) work_obj = WorkUnit( opts, len(encoders) ) for e in encoders: queue.apply_async( work_obj.do_work, (e,) ) try: queue.close() queue.join() except KeyboardInterrupt: work_obj.abort = True