from concurrent.futures import ThreadPoolExecutor from time import sleep import traceback from os.path import sep as pathSep from os import makedirs, system as execute from pathlib import Path from shlex import quote import errno import logging from tempfile import gettempdir import requests from requests import get from urllib3.exceptions import SSLError as u3SSLError from mutagen.flac import FLACNoHeaderError, error as FLACError from deezer import TrackFormats from deezer.errors import WrongLicense, WrongGeolocation from deezer.utils import map_track from deemix.types.DownloadObjects import Single, Collection from deemix.types.Track import Track from deemix.types.Picture import StaticPicture from deemix.utils import USER_AGENT_HEADER from deemix.utils.pathtemplates import generatePath, generateAlbumName, generateArtistName, generateDownloadObjectName from deemix.tagger import tagID3, tagFLAC from deemix.decryption import generateCryptedStreamURL, streamTrack from deemix.settings import OverwriteOption from deemix.errors import DownloadFailed, MD5NotFound, DownloadCanceled, PreferredBitrateNotFound, TrackNot360, AlbumDoesntExists, DownloadError, ErrorMessages logger = logging.getLogger('deemix') extensions = { TrackFormats.FLAC: '.flac', TrackFormats.LOCAL: '.mp3', TrackFormats.MP3_320: '.mp3', TrackFormats.MP3_128: '.mp3', TrackFormats.DEFAULT: '.mp3', TrackFormats.MP4_RA3: '.mp4', TrackFormats.MP4_RA2: '.mp4', TrackFormats.MP4_RA1: '.mp4' } formatsName = { TrackFormats.FLAC: 'FLAC', TrackFormats.LOCAL: 'MP3_MISC', TrackFormats.MP3_320: 'MP3_320', TrackFormats.MP3_128: 'MP3_128', TrackFormats.DEFAULT: 'MP3_MISC', TrackFormats.MP4_RA3: 'MP4_RA3', TrackFormats.MP4_RA2: 'MP4_RA2', TrackFormats.MP4_RA1: 'MP4_RA1' } TEMPDIR = Path(gettempdir()) / 'deemix-imgs' if not TEMPDIR.is_dir(): makedirs(TEMPDIR) def downloadImage(url, path, overwrite=OverwriteOption.DONT_OVERWRITE): if path.is_file() and overwrite not in [OverwriteOption.OVERWRITE, OverwriteOption.ONLY_TAGS, OverwriteOption.KEEP_BOTH]: return path try: image = get(url, headers={'User-Agent': USER_AGENT_HEADER}, timeout=30) image.raise_for_status() with open(path, 'wb') as f: f.write(image.content) return path except requests.exceptions.HTTPError: if path.is_file(): path.unlink() if 'cdns-images.dzcdn.net' in url: urlBase = url[:url.rfind("/")+1] pictureUrl = url[len(urlBase):] pictureSize = int(pictureUrl[:pictureUrl.find("x")]) if pictureSize > 1200: return downloadImage(urlBase+pictureUrl.replace(f"{pictureSize}x{pictureSize}", '1200x1200'), path, overwrite) except (requests.exceptions.ConnectionError, requests.exceptions.ChunkedEncodingError, u3SSLError): if path.is_file(): path.unlink() sleep(5) return downloadImage(url, path, overwrite) except OSError as e: if path.is_file(): path.unlink() if e.errno == errno.ENOSPC: raise DownloadFailed("noSpaceLeft") from e logger.exception("Error while downloading an image, you should report this to the developers: %s", e) return None def getPreferredBitrate(dz, track, preferredBitrate, shouldFallback, feelingLucky, uuid=None, listener=None): preferredBitrate = int(preferredBitrate) falledBack = False hasAlternative = track.fallbackID != "0" isGeolocked = False wrongLicense = False def testURL(track, url, formatName): if not url: return False request = requests.head( url, headers={'User-Agent': USER_AGENT_HEADER}, timeout=30 ) try: request.raise_for_status() track.filesizes[f"{formatName.lower()}"] = int(request.headers["Content-Length"]) track.filesizes[f"{formatName.lower()}_TESTED"] = True return track.filesizes[f"{formatName.lower()}"] != 0 except requests.exceptions.HTTPError: # if the format is not available, Deezer returns a 403 error return False def getCorrectURL(track, formatName, formatNumber, feelingLucky): nonlocal wrongLicense, isGeolocked url = None # Check the track with the legit method wrongLicense = ( (formatName == "FLAC" or formatName.startswith("MP4_RA")) and not dz.current_user.get('can_stream_lossless') or \ formatName == "MP3_320" and not dz.current_user.get('can_stream_hq') ) if track.filesizes.get(formatName.lower()) and track.filesizes[formatName.lower()] != "0": try: url = dz.get_track_url(track.trackToken, formatName) except (WrongLicense, WrongGeolocation) as e: wrongLicense = isinstance(e, WrongLicense) isGeolocked = isinstance(e, WrongGeolocation) # Fallback to old method if not url and feelingLucky: url = generateCryptedStreamURL(track.id, track.MD5, track.mediaVersion, formatNumber) if testURL(track, url, formatName): return url url = None return url if track.local: url = getCorrectURL(track, "MP3_MISC", TrackFormats.LOCAL, feelingLucky) track.urls["MP3_MISC"] = url return TrackFormats.LOCAL formats_non_360 = { TrackFormats.FLAC: "FLAC", TrackFormats.MP3_320: "MP3_320", TrackFormats.MP3_128: "MP3_128", } formats_360 = { TrackFormats.MP4_RA3: "MP4_RA3", TrackFormats.MP4_RA2: "MP4_RA2", TrackFormats.MP4_RA1: "MP4_RA1", } is360format = preferredBitrate in formats_360.keys() if not shouldFallback: formats = formats_360 formats.update(formats_non_360) elif is360format: formats = formats_360 else: formats = formats_non_360 # check and renew trackToken before starting the check track.checkAndRenewTrackToken(dz) for formatNumber, formatName in formats.items(): # Current bitrate is higher than preferred bitrate; skip if formatNumber > preferredBitrate: continue currentTrack = track url = getCorrectURL(currentTrack, formatName, formatNumber, feelingLucky) newTrack = None while True: if not url and hasAlternative: newTrack = dz.gw.get_track_with_fallback(currentTrack.fallbackID) newTrack = map_track(newTrack) currentTrack = Track() currentTrack.parseEssentialData(newTrack) hasAlternative = currentTrack.fallbackID != "0" if not url: getCorrectURL(currentTrack, formatName, formatNumber, feelingLucky) if (url or not hasAlternative): break if url: if newTrack: track.parseEssentialData(newTrack) track.urls[formatName] = url return formatNumber if not shouldFallback: if wrongLicense: raise WrongLicense(formatName) if isGeolocked: raise WrongGeolocation(dz.current_user['country']) raise PreferredBitrateNotFound if not falledBack: falledBack = True logger.info("%s Fallback to lower bitrate", f"[{track.mainArtist.name} - {track.title}]") if listener and uuid: listener.send('downloadInfo', { 'uuid': uuid, 'state': 'bitrateFallback', 'data': { 'id': track.id, 'title': track.title, 'artist': track.mainArtist.name }, }) if is360format: raise TrackNot360 url = getCorrectURL(track, "MP3_MISC", TrackFormats.DEFAULT, feelingLucky) track.urls["MP3_MISC"] = url return TrackFormats.DEFAULT class Downloader: def __init__(self, dz, downloadObject, settings, listener=None): self.dz = dz self.downloadObject = downloadObject self.settings = settings self.bitrate = downloadObject.bitrate self.listener = listener self.playlistCoverName = None self.playlistURLs = [] def start(self): if not self.downloadObject.isCanceled: if isinstance(self.downloadObject, Single): track = self.downloadWrapper({ 'trackAPI': self.downloadObject.single.get('trackAPI'), 'albumAPI': self.downloadObject.single.get('albumAPI') }) if track: self.afterDownloadSingle(track) elif isinstance(self.downloadObject, Collection): tracks = [None] * len(self.downloadObject.collection['tracks']) with ThreadPoolExecutor(self.settings['queueConcurrency']) as executor: for pos, track in enumerate(self.downloadObject.collection['tracks'], start=0): tracks[pos] = executor.submit(self.downloadWrapper, { 'trackAPI': track, 'albumAPI': self.downloadObject.collection.get('albumAPI'), 'playlistAPI': self.downloadObject.collection.get('playlistAPI') }) self.afterDownloadCollection(tracks) if self.listener: if self.downloadObject.isCanceled: self.listener.send('currentItemCancelled', self.downloadObject.uuid) self.listener.send("removedFromQueue", self.downloadObject.uuid) else: self.listener.send("finishDownload", self.downloadObject.uuid) def log(self, data, state): if self.listener: self.listener.send('downloadInfo', {'uuid': self.downloadObject.uuid, 'data': data, 'state': state}) def warn(self, data, state, solution): if self.listener: self.listener.send('downloadWarn', {'uuid': self.downloadObject.uuid, 'data': data, 'state': state, 'solution': solution}) def download(self, extraData, track=None): returnData = {} trackAPI = extraData.get('trackAPI') albumAPI = extraData.get('albumAPI') playlistAPI = extraData.get('playlistAPI') trackAPI['size'] = self.downloadObject.size if self.downloadObject.isCanceled: raise DownloadCanceled if int(trackAPI['id']) == 0: raise DownloadFailed("notOnDeezer") itemData = { 'id': trackAPI['id'], 'title': trackAPI['title'], 'artist': trackAPI['artist']['name'] } # Create Track object if not track: self.log(itemData, "getTags") try: track = Track().parseData( dz=self.dz, track_id=trackAPI['id'], trackAPI=trackAPI, albumAPI=albumAPI, playlistAPI=playlistAPI ) except AlbumDoesntExists as e: raise DownloadError('albumDoesntExists') from e except MD5NotFound as e: raise DownloadError('notLoggedIn') from e self.log(itemData, "gotTags") itemData = { 'id': track.id, 'title': track.title, 'artist': track.mainArtist.name } # Check if track not yet encoded if track.MD5 == '': raise DownloadFailed("notEncoded", track) # Choose the target bitrate self.log(itemData, "getBitrate") try: selectedFormat = getPreferredBitrate( self.dz, track, self.bitrate, self.settings['fallbackBitrate'], self.settings['feelingLucky'], self.downloadObject.uuid, self.listener ) except WrongLicense as e: raise DownloadFailed("wrongLicense") from e except WrongGeolocation as e: raise DownloadFailed("wrongGeolocation", track) from e except PreferredBitrateNotFound as e: raise DownloadFailed("wrongBitrate", track) from e except TrackNot360 as e: raise DownloadFailed("no360RA") from e track.bitrate = selectedFormat track.album.bitrate = selectedFormat self.log(itemData, "gotBitrate") # Apply settings track.applySettings(self.settings) # Generate filename and filepath from metadata (filename, filepath, artistPath, coverPath, extrasPath) = generatePath(track, self.downloadObject, self.settings) # Make sure the filepath exists makedirs(filepath, exist_ok=True) extension = extensions[track.bitrate] writepath = filepath / f"{filename}{extension}" # Save extrasPath if extrasPath and not self.downloadObject.extrasPath: self.downloadObject.extrasPath = extrasPath # Generate covers URLs embeddedImageFormat = f'jpg-{self.settings["jpegImageQuality"]}' if self.settings['embeddedArtworkPNG']: embeddedImageFormat = 'png' track.album.embeddedCoverURL = track.album.pic.getURL(self.settings['embeddedArtworkSize'], embeddedImageFormat) ext = track.album.embeddedCoverURL[-4:] if ext[0] != ".": ext = ".jpg" # Check for Spotify images track.album.embeddedCoverPath = TEMPDIR / ((f"pl{track.playlist.id}" if track.album.isPlaylist else f"alb{track.album.id}") + f"_{self.settings['embeddedArtworkSize']}{ext}") # Download and cache coverart self.log(itemData, "getAlbumArt") track.album.embeddedCoverPath = downloadImage(track.album.embeddedCoverURL, track.album.embeddedCoverPath) self.log(itemData, "gotAlbumArt") # Save local album art if coverPath: returnData['albumURLs'] = [] for pic_format in self.settings['localArtworkFormat'].split(","): if pic_format in ["png","jpg"]: extendedFormat = pic_format if extendedFormat == "jpg": extendedFormat += f"-{self.settings['jpegImageQuality']}" url = track.album.pic.getURL(self.settings['localArtworkSize'], extendedFormat) # Skip non deezer pictures at the wrong format if isinstance(track.album.pic, StaticPicture) and pic_format != "jpg": continue returnData['albumURLs'].append({'url': url, 'ext': pic_format}) returnData['albumPath'] = coverPath returnData['albumFilename'] = generateAlbumName(self.settings['coverImageTemplate'], track.album, self.settings, track.playlist) # Save artist art if artistPath: returnData['artistURLs'] = [] for pic_format in self.settings['localArtworkFormat'].split(","): # Deezer doesn't support png artist images if pic_format == "jpg": extendedFormat = f"{pic_format}-{self.settings['jpegImageQuality']}" url = track.album.mainArtist.pic.getURL(self.settings['localArtworkSize'], extendedFormat) if track.album.mainArtist.pic.md5 == "": continue returnData['artistURLs'].append({'url': url, 'ext': pic_format}) returnData['artistPath'] = artistPath returnData['artistFilename'] = generateArtistName(self.settings['artistImageTemplate'], track.album.mainArtist, self.settings, rootArtist=track.album.rootArtist) # Save playlist art if track.playlist: if len(self.playlistURLs) == 0: for pic_format in self.settings['localArtworkFormat'].split(","): if pic_format in ["png","jpg"]: extendedFormat = pic_format if extendedFormat == "jpg": extendedFormat += f"-{self.settings['jpegImageQuality']}" url = track.playlist.pic.getURL(self.settings['localArtworkSize'], extendedFormat) if isinstance(track.playlist.pic, StaticPicture) and pic_format != "jpg": continue self.playlistURLs.append({'url': url, 'ext': pic_format}) if not self.playlistCoverName: track.playlist.bitrate = selectedFormat track.playlist.dateString = track.playlist.date.format(self.settings['dateFormat']) self.playlistCoverName = generateAlbumName(self.settings['coverImageTemplate'], track.playlist, self.settings, track.playlist) # Save lyrics in lrc file if self.settings['syncedLyrics'] and track.lyrics.sync: if not (filepath / f"{filename}.lrc").is_file() or self.settings['overwriteFile'] in [OverwriteOption.OVERWRITE, OverwriteOption.ONLY_TAGS]: with open(filepath / f"{filename}.lrc", 'w', encoding="utf-8") as f: f.write(track.lyrics.sync) # Check for overwrite settings trackAlreadyDownloaded = writepath.is_file() # Don't overwrite and don't mind extension if not trackAlreadyDownloaded and self.settings['overwriteFile'] == OverwriteOption.DONT_CHECK_EXT: exts = ['.mp3', '.flac', '.opus', '.m4a'] baseFilename = str(filepath / filename) for ext in exts: trackAlreadyDownloaded = Path(baseFilename+ext).is_file() if trackAlreadyDownloaded: break # Don't overwrite and keep both files if trackAlreadyDownloaded and self.settings['overwriteFile'] == OverwriteOption.KEEP_BOTH: baseFilename = str(filepath / filename) c = 1 currentFilename = baseFilename+' ('+str(c)+')'+ extension while Path(currentFilename).is_file(): c += 1 currentFilename = baseFilename+' ('+str(c)+')'+ extension trackAlreadyDownloaded = False writepath = Path(currentFilename) if not trackAlreadyDownloaded or self.settings['overwriteFile'] == OverwriteOption.OVERWRITE: track.downloadURL = track.urls[formatsName[track.bitrate]] if not track.downloadURL: raise DownloadFailed('notAvailable', track) try: with open(writepath, 'wb') as stream: streamTrack(stream, track, downloadObject=self.downloadObject, listener=self.listener) except requests.exceptions.HTTPError as e: if writepath.is_file(): writepath.unlink() raise DownloadFailed('notAvailable', track) from e except OSError as e: if writepath.is_file(): writepath.unlink() if e.errno == errno.ENOSPC: raise DownloadFailed("noSpaceLeft") from e raise e self.log(itemData, "downloaded") else: self.log(itemData, "alreadyDownloaded") self.downloadObject.completeTrackProgress(self.listener) # Adding tags if (not trackAlreadyDownloaded or self.settings['overwriteFile'] in [OverwriteOption.ONLY_TAGS, OverwriteOption.OVERWRITE]) and not track.local: self.log(itemData, "tagging") if extension == '.mp3': tagID3(writepath, track, self.settings['tags']) elif extension == '.flac': try: tagFLAC(writepath, track, self.settings['tags']) except (FLACNoHeaderError, FLACError): writepath.unlink() logger.warning("%s Track not available in FLAC, falling back if necessary", f"{itemData['artist']} - {itemData['title']}") self.downloadObject.removeTrackProgress(self.listener) track.filesizes['FILESIZE_FLAC'] = "0" track.filesizes['FILESIZE_FLAC_TESTED'] = True return self.download(extraData, track=track) self.log(itemData, "tagged") if track.searched: returnData['searched'] = True self.downloadObject.downloaded += 1 if self.listener: self.listener.send("updateQueue", { 'uuid': self.downloadObject.uuid, 'downloaded': True, 'downloadPath': str(writepath), 'extrasPath': str(self.downloadObject.extrasPath) }) returnData['filename'] = str(writepath)[len(str(extrasPath))+ len(pathSep):] returnData['data'] = itemData returnData['path'] = str(writepath) self.downloadObject.files.append(returnData) return returnData def downloadWrapper(self, extraData, track=None): trackAPI = extraData['trackAPI'] # Temp metadata to generate logs itemData = { 'id': trackAPI['id'], 'title': trackAPI['title'], 'artist': trackAPI['artist']['name'] } try: result = self.download(extraData, track) except DownloadFailed as error: if error.track: track = error.track if track.fallbackID != "0": self.warn(itemData, error.errid, 'fallback') newTrack = self.dz.gw.get_track_with_fallback(track.fallbackID) newTrack = map_track(newTrack) track.parseEssentialData(newTrack) return self.downloadWrapper(extraData, track) if len(track.albumsFallback) != 0 and self.settings['fallbackISRC']: newAlbumID = track.albumsFallback.pop() newAlbum = self.dz.gw.get_album_page(newAlbumID) fallbackID = 0 for newTrack in newAlbum['SONGS']['data']: if newTrack['ISRC'] == track.ISRC: fallbackID = newTrack['SNG_ID'] break if fallbackID != 0: self.warn(itemData, error.errid, 'fallback') newTrack = self.dz.gw.get_track_with_fallback(fallbackID) newTrack = map_track(newTrack) track.parseEssentialData(newTrack) return self.downloadWrapper(extraData, track) if not track.searched and self.settings['fallbackSearch']: self.warn(itemData, error.errid, 'search') searchedId = self.dz.api.get_track_id_from_metadata(track.mainArtist.name, track.title, track.album.title) if searchedId != "0": newTrack = self.dz.gw.get_track_with_fallback(searchedId) newTrack = map_track(newTrack) track.parseEssentialData(newTrack) track.searched = True self.log(itemData, "searchFallback") return self.downloadWrapper(extraData, track) error.errid += "NoAlternative" error.message = ErrorMessages[error.errid] result = {'error': { 'message': error.message, 'errid': error.errid, 'data': itemData, 'type': "track" }} except Exception as e: logger.exception("%s %s", f"{itemData['artist']} - {itemData['title']}", e) result = {'error': { 'message': str(e), 'data': itemData, 'stack': traceback.format_exc(), 'type': "track" }} if 'error' in result: self.downloadObject.completeTrackProgress(self.listener) self.downloadObject.failed += 1 self.downloadObject.errors.append(result['error']) if self.listener: error = result['error'] self.listener.send("updateQueue", { 'uuid': self.downloadObject.uuid, 'failed': True, 'data': error['data'], 'error': error['message'], 'errid': error.get('errid'), 'stack': error.get('stack'), 'type': error['type'] }) return result def afterDownloadErrorReport(self, position, error, itemData=None): if not itemData: itemData = {} data = {'position': position } data.update(itemData) logger.exception("%s %s", position, error) self.downloadObject.errors.append({ 'message': str(error), 'stack': traceback.format_exc(), 'data': data, 'type': "post" }) if self.listener: self.listener.send("updateQueue", { 'uuid': self.downloadObject.uuid, 'postFailed': True, 'data': data, 'error': str(error), 'stack': traceback.format_exc(), 'type': "post" }) def afterDownloadSingle(self, track): if not self.downloadObject.extrasPath: self.downloadObject.extrasPath = Path(self.settings['downloadLocation']) # Save Album Cover try: if self.settings['saveArtwork'] and 'albumPath' in track: for image in track['albumURLs']: downloadImage(image['url'], track['albumPath'] / f"{track['albumFilename']}.{image['ext']}", self.settings['overwriteFile']) except Exception as e: self.afterDownloadErrorReport("SaveLocalAlbumArt", e) # Save Artist Artwork try: if self.settings['saveArtworkArtist'] and 'artistPath' in track: for image in track['artistURLs']: downloadImage(image['url'], track['artistPath'] / f"{track['artistFilename']}.{image['ext']}", self.settings['overwriteFile']) except Exception as e: self.afterDownloadErrorReport("SaveLocalArtistArt", e) # Create searched logfile try: if self.settings['logSearched'] and 'searched' in track: filename = f"{track.data.artist} - {track.data.title}" with open(self.downloadObject.extrasPath / 'searched.txt', 'w+', encoding="utf-8") as f: searchedFile = f.read() if not filename in searchedFile: if searchedFile != "": searchedFile += "\r\n" searchedFile += filename + "\r\n" f.write(searchedFile) except Exception as e: self.afterDownloadErrorReport("CreateSearchedLog", e) # Execute command after download try: if self.settings['executeCommand'] != "": execute(self.settings['executeCommand'].replace("%folder%", quote(str(self.downloadObject.extrasPath))).replace("%filename%", quote(track['filename']))) except Exception as e: self.afterDownloadErrorReport("ExecuteCommand", e) def afterDownloadCollection(self, tracks): if not self.downloadObject.extrasPath: self.downloadObject.extrasPath = Path(self.settings['downloadLocation']) playlist = [None] * len(tracks) errors = "" searched = "" for i, track in enumerate(tracks): track = track.result() if not track: return # Check if item is cancelled # Log errors to file if track.get('error'): if not track['error'].get('data'): track['error']['data'] = {'id': "0", 'title': 'Unknown', 'artist': 'Unknown'} errors += f"{track['error']['data']['id']} | {track['error']['data']['artist']} - {track['error']['data']['title']} | {track['error']['message']}\r\n" # Log searched to file if 'searched' in track: searched += track['searched'] + "\r\n" # Save Album Cover try: if self.settings['saveArtwork'] and 'albumPath' in track: for image in track['albumURLs']: downloadImage(image['url'], track['albumPath'] / f"{track['albumFilename']}.{image['ext']}", self.settings['overwriteFile']) except Exception as e: self.afterDownloadErrorReport("SaveLocalAlbumArt", e, track['data']) # Save Artist Artwork try: if self.settings['saveArtworkArtist'] and 'artistPath' in track: for image in track['artistURLs']: downloadImage(image['url'], track['artistPath'] / f"{track['artistFilename']}.{image['ext']}", self.settings['overwriteFile']) except Exception as e: self.afterDownloadErrorReport("SaveLocalArtistArt", e, track['data']) # Save filename for playlist file playlist[i] = track.get('filename', "") # Create errors logfile try: if self.settings['logErrors'] and errors != "": with open(self.downloadObject.extrasPath / 'errors.txt', 'w', encoding="utf-8") as f: f.write(errors) except Exception as e: self.afterDownloadErrorReport("CreateErrorLog", e) # Create searched logfile try: if self.settings['logSearched'] and searched != "": with open(self.downloadObject.extrasPath / 'searched.txt', 'w', encoding="utf-8") as f: f.write(searched) except Exception as e: self.afterDownloadErrorReport("CreateSearchedLog", e) # Save Playlist Artwork try: if self.settings['saveArtwork'] and self.playlistCoverName and not self.settings['tags']['savePlaylistAsCompilation']: for image in self.playlistURLs: downloadImage(image['url'], self.downloadObject.extrasPath / f"{self.playlistCoverName}.{image['ext']}", self.settings['overwriteFile']) except Exception as e: self.afterDownloadErrorReport("SavePlaylistArt", e) # Create M3U8 File try: if self.settings['createM3U8File']: filename = generateDownloadObjectName(self.settings['playlistFilenameTemplate'], self.downloadObject, self.settings) or "playlist" with open(self.downloadObject.extrasPath / f'{filename}.m3u8', 'w', encoding="utf-8") as f: for line in playlist: f.write(line + "\n") except Exception as e: self.afterDownloadErrorReport("CreatePlaylistFile", e) # Execute command after download try: if self.settings['executeCommand'] != "": execute(self.settings['executeCommand'].replace("%folder%", quote(str(self.downloadObject.extrasPath)))) except Exception as e: self.afterDownloadErrorReport("ExecuteCommand", e)