class iTunes_Music_Converter( object ): ''' Name: iTunes_Music_Converter Purpose: A function to convert music files in an iTunes library to a given format. the two currently supported formats are MP3 and FLAC. Inputs: None. Outputs: Converted audio files. Keywords: dest_dir : The top level directory to save files in. Converted files will be placed in Artist/Album/ directories within this directory. DEFAULT is to place music in iTunes_Converted directory in the users home music folder. track_id : String of iTunes track ID(s), separated by spaces, for songs to convert. DEFAULT is to convert entire library. bit_rate : Set the bit rate for conversion. This only applies when converting to MP3. MUST include 'k' in bit rate, i.e., 192k. DEFAULT is 320k. codce : The codec to use for encoding. The two supported options are MP3 and FLAC. DEFAULT is MP3. verbose : Increase the verbosity; display progress in command line gui : Display GUI of progress Author and History: Kyle R. Wodzicki Created 24 May 2016 Modified 11 Nov. 2016 By Kyle R. Wodzicki Changed when initial timing for each track is set Added a total timer for entire process ''' def __init__(self, dest_dir=None, bit_rate=None, codec=None, verbose=False, gui=False): if not self._testFFmpeg(): # Test for FFmpeg command return None; # Return None self.verbose = verbose; # Set verbose self.gui = gui; # Set gui, not used yet ncpu = os.cpu_count(); # Number of CPUs available if ncpu <= 2: # If number of CPUs <=2 self.nProc = ncpu; # Set number of concurrent processes allowed to number of CPUs else: # Else, number of CPUs > 2 self.nProc = round(ncpu / 2); # Set to half the number of CPUs rounded up if (dest_dir is None): # If destination directory is None self.dest_dir = os.path.join(data.home_dir, 'Music', 'iTunes_Converted'); # Set default directory else: # Else, use dest_dir self.dest_dir = dest_dir; # Append forward slash to the end of the directory if one is not there if self.gui: # If verbose is set self.root = QApplication(['convert_music']); # Initialize a root window self.progress = progressFrame( self.root, self.nProc, self.dest_dir ); # Set up progress window else: self.root = None; self.progress = None; self.cmdBase = ['ffmpeg', '-y', '-hide_banner', '-loglevel', '32', '-i']; # Set up base ffmpeg command self.cmdOpts = None; # Initialize cmdOpts to None self._codec = 'mp3' if codec is None else codec.lower(); # Set the audio codec self._bitrate = '320k' if bit_rate is None else bit_rate; # Set the audio bit rate self._setCmdOpts(); # Set up ffmpeg command options self.itunes_plst = getLibraryXML(); # Set the path to the iTunes XML file; assumed to be in users Music/iTunes directory self.itunes_data = readPlist( self.itunes_plst ); # Get the top of the XML tree as unicode text self.music_folder = self.itunes_data['Music Folder']; # Get the path to the iTunes music folder self.all_tracks = []; # Set all tracks to empty list for i in self.itunes_data['Tracks']: # Iterate over all tracks if 'audio file' in self.itunes_data['Tracks'][i]['Kind']: # If a track is an audio file self.all_tracks.append( i ); # Append the track id to the all_tracks list self.nTrack = None; # Set nTracks to None self.process = []; # Initialize list of processes self.__cnt = 0; # Set cnt to Counter class self.__Lock = Lock(); # Lock for downloading cover art ############################################################################## def convert(self, track_id = None): if (track_id is None): # If not track list input, track_id = [i for i in self.all_tracks]; # If the track_id variable is NOT set, convert all tracks in iTunes library else: # Else, track list was input if type(track_id) is not str: track_id = str(track_id); # Make sure its a list track_id = [i for i in track_id.split() if i in self.all_tracks]; # Parse track IDs input into function checking that they are in the all_ids list self.nTrack = len(track_id); # Number of tracks to convert if self.nTrack == 0: # If number of tracks is zero if self.gui: self.root.destroy(); # If verbose is set, destroy the root tkinter object return 1; # return 1 if self.gui: self.progress.nTracks( self.nTrack ); # Set number of tracks to convert in the progress bar thread = Thread(target = self._convertMainLoop, args = (track_id,)); # Initialize thread to run _convert method thread.start(); # Start the thread if self.gui: # If verbose is set self.root.exec_(); # Start the tkinter main loop else: # Else thread.join(); # Just join the thread and wait for it to finish ############################################################################## def _convertMainLoop(self, track_id): '''Function that iterates over tracks to convert them.''' t00 = time.time(); # Get time for start of iteration over tracks self.__cnt = 0; # Reset the count for cnt attribute for track in track_id: # Iterate over all tracks info = self.itunes_data['Tracks'][track]; # Get the information about the track proc = self._processChecker(); # Block to ensure there aren't too many process proc = Thread(target = self._convertThread, args = (info,) ); # Initialize thread proc.start(); # Start thread self.process.append( proc ); # Append thread to process list while len(self.process) > 0: # While there are processes left int he process attribute proc = self.process.pop(); # Pop processes off the process attribute list try: # Try to proc.join(); # Join the process except: # On exception proc.communicate(); # Communicate with process if self.verbose: # If verbose print( 'Elapsed: {} {}'.format( round((time.time()-t00)/60),'min' ) ); # Print elapsed time if verbose is true if self.gui: print( 'Waiting for GUI to close...' ); ############################################################################## def _convertThread(self, info): logFmt = self._logFormat(info); # Get logging format if (info['Track Type'].upper() == 'REMOTE'): # If track type is remote if self.verbose: print( logFmt.format('Remote file, skipping!') ); # Print it is being skipped return; # Skip to next iteration src, dest = self._getSrcDest( info ); # Get source and destination file file = unquote(info['Location']).replace( unquote(self.music_folder), '' ); if self.gui: bar = self.progress.getBar( info ); if os.path.isfile(dest): # If destination file already exists if self.verbose: print( logFmt.format('File EXISTS on receiver!') ); # Some verbose output if self.gui: bar.updateStatus('File EXISTS on receiver!', finish = True); # Print it is being skipped self.progress.freeBar(bar); return; # If the destination file already exists, continue past it if self.gui: bar.updateStatus('Fetching artwork...'); # Print it is being skipped if self.verbose: print( logFmt.format('Fetching artwork...') ); # Print info; attempting to get album cover cover = self._getCover( dest, info, logFmt ); # Set path to cover art file if cover is None: if self.gui: bar.updateStatus('Artwork Failed', prog = True); # Print it is being skipped if self.verbose: print(logFmt.format('Artwork Failed!')); else: if self.gui: bar.updateStatus('Artwork Success', prog = True); # Print it is being skipped if self.verbose: print(logFmt.format('Artwork Success!')); t0 = time.time(); # Get the start time status = 1; if 'MPEG' in info['Kind']: # If file is an mp3 if self.gui: bar.updateStatus('Copying file'); if self.verbose: print( logFmt.format('Copying file') ); # If the file is already mp3, just copy try: # Try to shutil.copy( src, dest ); # Copy the file except: # On exception... if self.gui: bar.updateStatus('Failed to copy file!'); self.progress.freeBar(bar); if self.verbose: print( logFmt.format('Failed to copy file!') ); # Log a message if os.path.isfile(dest): os.remove(dest); # Remove the file if it exists else: if self.gui: bar.updateStatus('Copy success!', prog = True); if self.verbose: print( logFmt.format('Copy success!') ); # Log a message status = 0; else: # Else, it is NOT an mp3 if self.gui: bar.updateStatus('Encoding file!'); if self.verbose: print( logFmt.format('Encoding file') ); # Print message cmd = self.cmdBase + [src] + self.cmdOpts + [dest]; # Generate command proc = Popen(cmd, stdout = PIPE, stderr = STDOUT, stdin = DEVNULL); if self.gui: bar.conversion(proc); proc.communicate(); status = proc.returncode if status == 0: # If the return code is zero (0) if self.gui: bar.updateStatus('Writing metadata'); if self.verbose: print( logFmt.format('Writing metadata') ); tagMusic( dest, info, artwork = cover ); # Write metadata if self.gui: bar.finish(); if self.verbose: # If verbose tmp = '{:>13}{:05.1f}{:1}'.format('Finised in: ',time.time()-t0,'s'); # Determine run time print( logFmt.format( tmp ) ); # Print log info if self.gui: # If gui self.progress.freeBar(bar); ############################################################################## def _getSrcDest(self, info): '''A function to parse/generate source and destination directories.''' src = unquote( info['Location'] ); # Get path to source file in unquoted text dest = src.replace(self.music_folder, ''); # Set destination to the source with the music folder replaced by an empty string dest = os.path.join( self.dest_dir, dest ); # Prepend the output directory to the destination path if 'MPEG' not in info['Kind']: # If file is NOT an mp3 dest = '.'.join( dest.split('.')[:-1] ) + '.' + self.codec; # Ensure destination file has correct extension src = src.replace(data.prefix, ''); # Remove the prefix from the source directory with self.__Lock: # Get the __Lock if not os.path.isdir(os.path.dirname(dest)): # If the destination directory does NOT exist os.makedirs(os.path.dirname(dest)); # Create it return src, dest; # Return the source and destination directories ############################################################################## def _getCover(self, dest, info, logFmt): ''' Wrapper function to parse track information a attempt cover art download. ''' self.__Lock.acquire(); # Get the __Lock so that cant try to download same cover art at once cover = os.path.join( os.path.dirname(dest), 'coverart.jpeg' ); # Set path to cover art file if os.path.isfile(cover): # If the cover art file exists cover = None if os.stat(cover).st_size == 0 else cover; # Set cover code to zero if cover art file exists elif ('Artist' in info or 'Album Artist' in info) and ('Album' in info): # Else, if there is enough information in the info dictionary if ('Album Artist' in info): # If the album artist is in the dictionary artist = info['Album Artist']; # Attempt to download album art work for the album IF the elif ('Artist' in info): # Else, if the artist is in the dictionary artist = info['Artist']; # Attempt to download album art work for the album IF the album = info['Album']; # Get album info y = info['Year'] if 'Year' in info else None; # Set y variable to year of album release t = info['Track Count'] if 'Track Count' in info else None; # Set t variable to number of tracks on the album status = get_album_art(artist,album,cover,year=y,tracks=t); # Attempt to download the cover art if status != 0: cover = None; # If the status is NOT zero (0), set cover to None else: # Else, file does NOT exist and not enough information to try to download it open(cover, 'a').close(); # Empty file created so do not attempt to download on subsequent runs. cover = None; # If art work file does NOT exist and there is NOT enough info to try to download, set cover_code to 1 self.__Lock.release(); # Release the lock return cover; # Return cover ############################################################################## def _logFormat( self, info ): '''Set up string format for log messages.''' with self.__Lock: # Acquire lock self.__cnt += 1; # Increment then private counter cnt = self.__cnt; # Set local cnt variable to value of private counter fmt = '{:>6} of {:>6} {:39.38}'; # Initial format tmp = ""; # Set up empty tmp string if ('Album Artist' in info): # If 'Album Artist' is in info tmp += info['Album Artist']+'/'; # Add the 'Album Artist' to the tmp string elif ('Artist' in info): # Else, if 'Artist' in info tmp+= info['Artist']+'/'; # Add the 'Artist' to the tmp string if ('Album' in info): tmp += info['Album'] +'/'; # If 'Album' in info, add album to tmp if ('Track Number' in info): tmp += '{:02} '.format(info['Track Number']); # If 'Track Number' in info, format the track number and add to tmp string if ('Name' in info): tmp += info['Name']; # If 'Name' in info, add name to tmp string return fmt.format( cnt, self.nTrack, tmp ) + ' - {:21.20}'; # Return the format string ############################################################################## def _processChecker(self): '''Stop too many processes from running''' nProc = len(self.process) if nProc >= self.nProc: # If running enough process while all( [proc.is_alive() for proc in self.process] ): # While all the process are alive time.sleep(0.01); # Sleep for a little for i in range(nProc): # One of the process is no longer alive so iterate to find the dead one if not self.process[i].is_alive(): # If the process is not alive proc = self.process.pop(i); # Pop off the process try: # Try to... proc.join(); # Join the process except: # On exception proc.communicate(); # Communicate with process time.sleep(1); return proc; # Return the handle of the finished process return None; # Return None ############################################################################## def _setCmdOpts(self): if self.codec == 'mp3': # If codec is mp3 self.cmdOpts = ['-acodec', 'mp3', '-b:a', self._bitrate]; # Set convert options else: # Else, assume flac self.cmdOpts = ['-acodec', 'flac']; # Set convert options self.cmdOpts.extend( ['-map_metadata', '-1', '-vn'] ); # Turn off metadata and video stream copy ############################################################################## # Check for ffmpeg command def _testFFmpeg(self): '''Test the FFmpeg exists''' try: # Try to call a command ffmpeg = check_output( ['which', 'ffmpeg'] ); # IF found, use it except: # On exception return False; # Return False return True; # If made it here, no exception, return True ############################################################################## def __set_codec(self, value = None): self._codec = 'mp3' if value is None else value.lower(); self._setCmdOpts(); def __get_codec(self): return self._codec; ########## def __set_bitrate(self, value = None): self._bitrate = '320k' if value is None else value; self._setCmdOpts() def __get_bitrate(self): return self._bitrate; ########## codec = property(__get_codec, __set_codec); bit_rate = property(__get_bitrate, __set_bitrate);