import eventlet
from eventlet.green.subprocess import call as execute
requests = eventlet.import_patched('requests')
get = requests.get
request_exception = requests.exceptions

from os.path import sep as pathSep
from pathlib import Path
from shlex import quote
import re
import errno

from ssl import SSLError
from os import makedirs
from tempfile import gettempdir

from deemix.app.queueitem import QISingle, QICollection
from deemix.app.track import Track, AlbumDoesntExsists
from deemix.utils import changeCase
from deemix.utils.pathtemplates import generateFilename, generateFilepath, settingsRegexAlbum, settingsRegexArtist, settingsRegexPlaylistFile
from deemix.api.deezer import USER_AGENT_HEADER, TrackFormats
from deemix.utils.taggers import tagID3, tagFLAC
from deemix.app.settings import OverwriteOption, FeaturesOption

from Cryptodome.Cipher import Blowfish
from mutagen.flac import FLACNoHeaderError, error as FLACError
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger('deemix')

TEMPDIR = Path(gettempdir()) / 'deemix-imgs'
if not TEMPDIR.is_dir(): makedirs(TEMPDIR)

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'
}

errorMessages = {
    'notOnDeezer': "Track not available on Deezer!",
    'notEncoded': "Track not yet encoded!",
    'notEncodedNoAlternative': "Track not yet encoded and no alternative found!",
    'wrongBitrate': "Track not found at desired bitrate.",
    'wrongBitrateNoAlternative': "Track not found at desired bitrate and no alternative found!",
    'no360RA': "Track is not available in Reality Audio 360.",
    'notAvailable': "Track not available on deezer's servers!",
    'notAvailableNoAlternative': "Track not available on deezer's servers and no alternative found!",
    'noSpaceLeft': "No space left on target drive, clean up some space for the tracks",
    'albumDoesntExsists': "Track's album does not exsist, failed to gather info"
}

def downloadImage(url, path, overwrite=OverwriteOption.DONT_OVERWRITE):
    if not path.is_file() or overwrite in [OverwriteOption.OVERWRITE, OverwriteOption.ONLY_TAGS, OverwriteOption.KEEP_BOTH]:
        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 request_exception.HTTPError:
            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:
                    logger.warn("Couldn't download "+str(pictureSize)+"x"+str(pictureSize)+" image, falling back to 1200x1200")
                    eventlet.sleep(1)
                    return  downloadImage(urlBase+pictureUrl.replace(str(pictureSize)+"x"+str(pictureSize), '1200x1200'), path, overwrite)
            logger.error("Image not found: "+url)
        except (request_exception.ConnectionError, request_exception.ChunkedEncodingError) as e:
            logger.error("Couldn't download Image, retrying in 5 seconds...: "+url+"\n")
            eventlet.sleep(5)
            return downloadImage(url, path, overwrite)
        except OSError as e:
            if e.errno == errno.ENOSPC:
                raise DownloadFailed("noSpaceLeft")
            else:
                logger.exception(f"Error while downloading an image, you should report this to the developers: {str(e)}")
        except Exception as e:
            logger.exception(f"Error while downloading an image, you should report this to the developers: {str(e)}")
        if path.is_file(): path.unlink()
        return None
    else:
        return path

def formatDate(date, template):
    elements = {
        'year': ['YYYY', 'YY', 'Y'],
        'month': ['MM', 'M'],
        'day': ['DD', 'D']
    }
    for element, placeholders in elements.items():
        for placeholder in placeholders:
            if placeholder in template:
                template = template.replace(placeholder, str(date[element]))
    return template

class DownloadJob:
    def __init__(self, dz, queueItem, interface=None):
        self.dz = dz
        self.interface = interface
        self.queueItem = queueItem
        self.settings = queueItem.settings
        self.bitrate = queueItem.bitrate
        self.downloadPercentage = 0
        self.lastPercentage = 0
        self.extrasPath = None
        self.playlistCoverName = None
        self.playlistURLs = []

    def start(self):
        if not self.queueItem.cancel:
            if isinstance(self.queueItem, QISingle):
                result = self.downloadWrapper(self.queueItem.single)
                if result: self.singleAfterDownload(result)
            elif isinstance(self.queueItem, QICollection):
                tracks = [None] * len(self.queueItem.collection)
                pool = eventlet.GreenPool(size=self.settings['queueConcurrency'])
                for pos, track in enumerate(self.queueItem.collection, start=0):
                    tracks[pos] = pool.spawn(self.downloadWrapper, track)
                pool.waitall()
                self.collectionAfterDownload(tracks)
        if self.interface:
            if self.queueItem.cancel:
                self.interface.send('currentItemCancelled', self.queueItem.uuid)
                self.interface.send("removedFromQueue", self.queueItem.uuid)
            else:
                self.interface.send("finishDownload", self.queueItem.uuid)
        return self.extrasPath

    def singleAfterDownload(self, result):
        if not self.extrasPath: self.extrasPath = Path(self.settings['downloadLocation'])

        # Save Album Cover
        if self.settings['saveArtwork'] and 'albumPath' in result:
            for image in result['albumURLs']:
                downloadImage(image['url'], result['albumPath'] / f"{result['albumFilename']}.{image['ext']}", self.settings['overwriteFile'])

        # Save Artist Artwork
        if self.settings['saveArtworkArtist'] and 'artistPath' in result:
            for image in result['artistURLs']:
                downloadImage(image['url'], result['artistPath'] / f"{result['artistFilename']}.{image['ext']}", self.settings['overwriteFile'])

        # Create searched logfile
        if self.settings['logSearched'] and 'searched' in result:
            with open(self.extrasPath / 'searched.txt', 'wb+') as f:
                orig = f.read().decode('utf-8')
                if not result['searched'] in orig:
                    if orig != "": orig += "\r\n"
                    orig += result['searched'] + "\r\n"
                f.write(orig.encode('utf-8'))
        # Execute command after download
        if self.settings['executeCommand'] != "":
            execute(self.settings['executeCommand'].replace("%folder%", quote(str(self.extrasPath))).replace("%filename%", quote(result['filename'])), shell=True)

    def collectionAfterDownload(self, tracks):
        if not self.extrasPath: self.extrasPath = Path(self.settings['downloadLocation'])
        playlist = [None] * len(tracks)
        errors = ""
        searched = ""

        for i in range(len(tracks)):
            result = tracks[i].wait()
            if not result: return None # Check if item is cancelled

            # Log errors to file
            if result.get('error'):
                if not result['error'].get('data'): result['error']['data'] = {'id': "0", 'title': 'Unknown', 'artist': 'Unknown'}
                errors += f"{result['error']['data']['id']} | {result['error']['data']['artist']} - {result['error']['data']['title']} | {result['error']['message']}\r\n"

            # Log searched to file
            if 'searched' in result: searched += result['searched'] + "\r\n"

            # Save Album Cover
            if self.settings['saveArtwork'] and 'albumPath' in result:
                for image in result['albumURLs']:
                    downloadImage(image['url'], result['albumPath'] / f"{result['albumFilename']}.{image['ext']}", self.settings['overwriteFile'])

            # Save Artist Artwork
            if self.settings['saveArtworkArtist'] and 'artistPath' in result:
                for image in result['artistURLs']:
                    downloadImage(image['url'], result['artistPath'] / f"{result['artistFilename']}.{image['ext']}", self.settings['overwriteFile'])

            # Save filename for playlist file
            playlist[i] = result.get('filename', "")

        # Create errors logfile
        if self.settings['logErrors'] and errors != "":
            with open(self.extrasPath / 'errors.txt', 'wb') as f:
                f.write(errors.encode('utf-8'))

        # Create searched logfile
        if self.settings['logSearched'] and searched != "":
            with open(self.extrasPath / 'searched.txt', 'wb') as f:
                f.write(searched.encode('utf-8'))

        # Save Playlist Artwork
        if self.settings['saveArtwork'] and self.playlistCoverName and not self.settings['tags']['savePlaylistAsCompilation']:
            for image in self.playlistURLs:
                downloadImage(image['url'], self.extrasPath / f"{self.playlistCoverName}.{image['ext']}", self.settings['overwriteFile'])

        # Create M3U8 File
        if self.settings['createM3U8File']:
            filename = settingsRegexPlaylistFile(self.settings['playlistFilenameTemplate'], self.queueItem, self.settings) or "playlist"
            with open(self.extrasPath / f'{filename}.m3u8', 'wb') as f:
                for line in playlist:
                    f.write((line + "\n").encode('utf-8'))

        # Execute command after download
        if self.settings['executeCommand'] != "":
            execute(self.settings['executeCommand'].replace("%folder%", quote(str(self.extrasPath))), shell=True)

    def download(self, trackAPI_gw, track=None):
        result = {}
        if self.queueItem.cancel: raise DownloadCancelled
        if trackAPI_gw['SNG_ID'] == "0": raise DownloadFailed("notOnDeezer")

        # Create Track object
        if not track:
            logger.info(f"[{trackAPI_gw['ART_NAME']} - {trackAPI_gw['SNG_TITLE']}] Getting the tags")
            try:
                track = Track(self.dz,
                              settings=self.settings,
                              trackAPI_gw=trackAPI_gw,
                              trackAPI=trackAPI_gw['_EXTRA_TRACK'] if '_EXTRA_TRACK' in trackAPI_gw else None,
                              albumAPI=trackAPI_gw['_EXTRA_ALBUM'] if '_EXTRA_ALBUM' in trackAPI_gw else None
                              )
            except AlbumDoesntExsists:
                raise DownloadError('albumDoesntExsists')
            if self.queueItem.cancel: raise DownloadCancelled

        # Check if track not yet encoded
        if track.MD5 == '':
            if track.fallbackId != "0":
                logger.warn(f"[{track.mainArtist['name']} - {track.title}] Track not yet encoded, using fallback id")
                newTrack = self.dz.get_track_gw(track.fallbackId)
                track.parseEssentialData(self.dz, newTrack)
                return self.download(trackAPI_gw, track)
            elif not track.searched and self.settings['fallbackSearch']:
                logger.warn(f"[{track.mainArtist['name']} - {track.title}] Track not yet encoded, searching for alternative")
                searchedId = self.dz.get_track_from_metadata(track.mainArtist['name'], track.title, track.album['title'])
                if searchedId != "0":
                    newTrack = self.dz.get_track_gw(searchedId)
                    track.parseEssentialData(self.dz, newTrack)
                    track.searched = True
                    if self.interface:
                        self.interface.send('queueUpdate', {
                            'uuid': self.queueItem.uuid,
                            'searchFallback': True,
                            'data': {
                                'id': track.id,
                                'title': track.title,
                                'artist': track.mainArtist['name']
                            },
                        })
                    return self.download(trackAPI_gw, track)
                else:
                    raise DownloadFailed("notEncodedNoAlternative")
            else:
                raise DownloadFailed("notEncoded")

        try:
            selectedFormat = self.getPreferredBitrate(track)
        except PreferredBitrateNotFound:
            if track.fallbackId != "0":
                logger.warn(f"[{track.mainArtist['name']} - {track.title}] Track not found at desired bitrate, using fallback id")
                newTrack = self.dz.get_track_gw(track.fallbackId)
                track.parseEssentialData(self.dz, newTrack)
                return self.download(trackAPI_gw, track)
            elif not track.searched and self.settings['fallbackSearch']:
                logger.warn(f"[{track.mainArtist['name']} - {track.title}] Track not found at desired bitrate, searching for alternative")
                searchedId = self.dz.get_track_from_metadata(track.mainArtist['name'], track.title, track.album['title'])
                if searchedId != "0":
                    newTrack = self.dz.get_track_gw(searchedId)
                    track.parseEssentialData(self.dz, newTrack)
                    track.searched = True
                    if self.interface:
                        self.interface.send('queueUpdate', {
                            'uuid': self.queueItem.uuid,
                            'searchFallback': True,
                            'data': {
                                'id': track.id,
                                'title': track.title,
                                'artist': track.mainArtist['name']
                            },
                        })
                    return self.download(trackAPI_gw, track)
                else:
                    raise DownloadFailed("wrongBitrateNoAlternative")
            else:
                raise DownloadFailed("wrongBitrate")
        except TrackNot360:
            raise DownloadFailed("no360RA")
        track.selectedFormat = selectedFormat

        imageQualityJPEG = f'000000-{self.settings["jpegImageQuality"]}-0-0.jpg'
        imageQualityPNG = 'none-100-0-0.png'
        imageQuality = imageQualityJPEG
        if self.settings['embeddedArtworkPNG']: imageQuality = imageQualityPNG

        if self.settings['tags']['savePlaylistAsCompilation'] and track.playlist:
            track.trackNumber = track.position
            track.discNumber = "1"
            track.album = {**track.album, **track.playlist}
            if 'picType' in track.playlist:
                track.playlist['picUrl'] = "https://e-cdns-images.dzcdn.net/images/{}/{}/{}x{}-{}".format(
                    track.playlist['picType'],
                    track.playlist['pic'],
                    self.settings['embeddedArtworkSize'], self.settings['embeddedArtworkSize'],
                    imageQuality
                )
            else:
                track.playlist['picUrl'] = track.playlist['pic']

            ext = track.playlist['picUrl'][-4:]
            if ext[0] != ".": ext = ".jpg"

            track.album['picPath'] = TEMPDIR / f"pl{trackAPI_gw['_EXTRA_PLAYLIST']['id']}_{self.settings['embeddedArtworkSize']}{ext}"
        else:
            if track.album['date']: track.date = track.album['date']
            track.album['picUrl'] = "https://e-cdns-images.dzcdn.net/images/cover/{}/{}x{}-{}".format(
                track.album['pic'],
                self.settings['embeddedArtworkSize'], self.settings['embeddedArtworkSize'],
                imageQuality
            )

            track.album['picPath'] = TEMPDIR / f"alb{track.album['id']}_{self.settings['embeddedArtworkSize']}{track.album['picUrl'][-4:]}"

        track.album['bitrate'] = selectedFormat

        track.dateString = formatDate(track.date, self.settings['dateFormat'])
        track.album['dateString'] = formatDate(track.album['date'], self.settings['dateFormat'])
        if track.playlist: track.playlist['dateString'] = formatDate(track.playlist['date'], self.settings['dateFormat'])

        # Check if user wants the feat in the title
        if str(self.settings['featuredToTitle']) == FeaturesOption.REMOVE_TITLE:
            track.title = track.getCleanTitle()
        elif str(self.settings['featuredToTitle']) == FeaturesOption.MOVE_TITLE:
            track.title = track.getFeatTitle()
        elif str(self.settings['featuredToTitle']) == FeaturesOption.REMOVE_TITLE_ALBUM:
            track.title = track.getCleanTitle()
            track.album['title'] = track.getCleanAlbumTitle()

        # Remove (Album Version) from tracks that have that
        if self.settings['removeAlbumVersion']:
            if "Album Version" in track.title:
                track.title = re.sub(r' ?\(Album Version\)', "", track.title).strip()

        # Change Title and Artists casing if needed
        if self.settings['titleCasing'] != "nothing":
            track.title = changeCase(track.title, self.settings['titleCasing'])
        if self.settings['artistCasing'] != "nothing":
            track.mainArtist['name'] = changeCase(track.mainArtist['name'], self.settings['artistCasing'])
            for i, artist in enumerate(track.artists):
                track.artists[i] = changeCase(artist, self.settings['artistCasing'])
            for type in track.artist:
                for i, artist in enumerate(track.artist[type]):
                    track.artist[type][i] = changeCase(artist, self.settings['artistCasing'])
            track.generateMainFeatStrings()

        # Generate artist tag
        if self.settings['tags']['multiArtistSeparator'] == "default":
            if str(self.settings['featuredToTitle']) == FeaturesOption.MOVE_TITLE:
                track.artistsString = ", ".join(track.artist['Main'])
            else:
                track.artistsString = ", ".join(track.artists)
        elif self.settings['tags']['multiArtistSeparator'] == "andFeat":
            track.artistsString = track.mainArtistsString
            if track.featArtistsString and str(self.settings['featuredToTitle']) != FeaturesOption.MOVE_TITLE:
                track.artistsString += " " + track.featArtistsString
        else:
            separator = self.settings['tags']['multiArtistSeparator']
            if str(self.settings['featuredToTitle']) == FeaturesOption.MOVE_TITLE:
                track.artistsString = separator.join(track.artist['Main'])
            else:
                track.artistsString = separator.join(track.artists)



        # Generate filename and filepath from metadata
        filename = generateFilename(track, self.settings, trackAPI_gw['FILENAME_TEMPLATE'])
        (filepath, artistPath, coverPath, extrasPath) = generateFilepath(track, self.settings)

        if self.queueItem.cancel: raise DownloadCancelled

        # Download and cache coverart
        logger.info(f"[{track.mainArtist['name']} - {track.title}] Getting the album cover")
        track.album['picPath'] = downloadImage(track.album['picUrl'], track.album['picPath'])

        # Save local album art
        if coverPath:
            result['albumURLs'] = []
            for format in self.settings['localArtworkFormat'].split(","):
                if format in ["png","jpg"]:
                    if self.settings['tags']['savePlaylistAsCompilation'] and track.playlist:
                        if track.playlist['picType']:
                            localImageQuality = imageQualityJPEG
                            if format == "png": localImageQuality = imageQualityPNG
                            url = "https://e-cdns-images.dzcdn.net/images/{}/{}/{}x{}-{}".format(
                                track.album['picType'],
                                track.album['pic'],
                                self.settings['localArtworkSize'], self.settings['localArtworkSize'],
                                localImageQuality
                            )
                        else:
                            url = track.album['pic']
                            if format != "jpg": continue
                    else:
                        localImageQuality = imageQualityJPEG
                        if format == "png": localImageQuality = imageQualityPNG
                        url = "https://e-cdns-images.dzcdn.net/images/cover/{}/{}x{}-{}".format(
                            track.album['pic'],
                            self.settings['localArtworkSize'], self.settings['localArtworkSize'],
                            localImageQuality
                        )
                    result['albumURLs'].append({'url': url, 'ext': format})
            result['albumPath'] = coverPath
            result['albumFilename'] = f"{settingsRegexAlbum(self.settings['coverImageTemplate'], track.album, self.settings, track.playlist)}"

        # Save artist art
        if artistPath:
            result['artistURLs'] = []
            for format in self.settings['localArtworkFormat'].split(","):
                if format in ["png","jpg"]:
                    url = ""
                    if track.album['mainArtist']['pic'] != "":
                        localImageQuality = imageQualityJPEG
                        if format == "png": localImageQuality = imageQualityPNG
                        url = "https://e-cdns-images.dzcdn.net/images/artist/{}/{}x{}-{}".format(
                            track.album['mainArtist']['pic'],
                            self.settings['localArtworkSize'], self.settings['localArtworkSize'],
                            localImageQuality
                        )
                    elif format == "jpg": # Blank artist image is not available in PNG
                        url = "https://e-cdns-images.dzcdn.net/images/artist//{}x{}-{}".format(
                            self.settings['localArtworkSize'], self.settings['localArtworkSize'],
                            imageQualityJPEG
                        )
                    if url: result['artistURLs'].append({'url': url, 'ext': format})
            result['artistPath'] = artistPath
            result['artistFilename'] = f"{settingsRegexArtist(self.settings['artistImageTemplate'], track.album['mainArtist'], self.settings)}"

        # Remove subfolders from filename and add it to filepath
        if pathSep in filename:
            tempPath = filename[:filename.rfind(pathSep)]
            filepath = filepath / tempPath
            filename = filename[filename.rfind(pathSep) + len(pathSep):]

        # Make sure the filepath exsists
        makedirs(filepath, exist_ok=True)
        writepath = filepath / f"{filename}{extensions[track.selectedFormat]}"

        # 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", 'wb') as f:
                    f.write(track.lyrics['sync'].encode('utf-8'))

        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)
            i = 1
            currentFilename = baseFilename+' ('+str(i)+')'+ extensions[track.selectedFormat]
            while Path(currentFilename).is_file():
                i += 1
                currentFilename = baseFilename+' ('+str(i)+')'+ extensions[track.selectedFormat]
            trackAlreadyDownloaded = False
            writepath = Path(currentFilename)

        if extrasPath:
            if not self.extrasPath: self.extrasPath = extrasPath
            result['filename'] = str(writepath)[len(str(extrasPath))+ len(pathSep):]

        # Save playlist cover
        if track.playlist:
            if not len(self.playlistURLs):
                if track.playlist['picType']:
                    for format in self.settings['localArtworkFormat'].split(","):
                        if format in ["png","jpg"]:
                            localImageQuality = imageQualityJPEG
                            if format == "png": localImageQuality = imageQualityPNG
                            url = "https://e-cdns-images.dzcdn.net/images/{}/{}/{}x{}-{}".format(
                                track.playlist['picType'],
                                track.playlist['pic'],
                                self.settings['localArtworkSize'], self.settings['localArtworkSize'],
                                localImageQuality
                            )
                            self.playlistURLs.append({'url': url, 'ext': format})
                else:
                    self.playlistURLs.append({'url': track.playlist['pic'], 'ext': 'jpg'})
            if not self.playlistCoverName:
                track.playlist['id'] = "pl_" + str(trackAPI_gw['_EXTRA_PLAYLIST']['id'])
                track.playlist['genre'] = ["Compilation", ]
                track.playlist['bitrate'] = selectedFormat
                track.playlist['dateString'] = formatDate(track.playlist['date'], self.settings['dateFormat'])
                self.playlistCoverName = f"{settingsRegexAlbum(self.settings['coverImageTemplate'], track.playlist, self.settings, track.playlist)}"

        if not trackAlreadyDownloaded or self.settings['overwriteFile'] == OverwriteOption.OVERWRITE:
            logger.info(f"[{track.mainArtist['name']} - {track.title}] Downloading the track")
            track.downloadUrl = self.dz.get_track_stream_url(track.id, track.MD5, track.mediaVersion, track.selectedFormat)

            def downloadMusic(track, trackAPI_gw):
                try:
                    with open(writepath, 'wb') as stream:
                        self.streamTrack(stream, track)
                except DownloadCancelled:
                    if writepath.is_file(): writepath.unlink()
                    raise DownloadCancelled
                except (request_exception.HTTPError, DownloadEmpty):
                    if writepath.is_file(): writepath.unlink()
                    if track.fallbackId != "0":
                        logger.warn(f"[{track.mainArtist['name']} - {track.title}] Track not available, using fallback id")
                        newTrack = self.dz.get_track_gw(track.fallbackId)
                        track.parseEssentialData(self.dz, newTrack)
                        return False
                    elif not track.searched and self.settings['fallbackSearch']:
                        logger.warn(f"[{track.mainArtist['name']} - {track.title}] Track not available, searching for alternative")
                        searchedId = self.dz.get_track_from_metadata(track.mainArtist['name'], track.title, track.album['title'])
                        if searchedId != "0":
                            newTrack = self.dz.get_track_gw(searchedId)
                            track.parseEssentialData(self.dz, newTrack)
                            track.searched = True
                            if self.interface:
                                self.interface.send('queueUpdate', {
                                    'uuid': self.queueItem.uuid,
                                    'searchFallback': True,
                                    'data': {
                                        'id': track.id,
                                        'title': track.title,
                                        'artist': track.mainArtist['name']
                                    },
                                })
                            return False
                        else:
                            raise DownloadFailed("notAvailableNoAlternative")
                    else:
                        raise DownloadFailed("notAvailable")
                except (request_exception.ConnectionError, request_exception.ChunkedEncodingError) as e:
                    if writepath.is_file(): writepath.unlink()
                    logger.warn(f"[{track.mainArtist['name']} - {track.title}] Error while downloading the track, trying again in 5s...")
                    eventlet.sleep(5)
                    return downloadMusic(track, trackAPI_gw)
                except OSError as e:
                    if e.errno == errno.ENOSPC:
                        raise DownloadFailed("noSpaceLeft")
                    else:
                        if writepath.is_file(): writepath.unlink()
                        logger.exception(f"[{track.mainArtist['name']} - {track.title}] Error while downloading the track, you should report this to the developers: {str(e)}")
                        raise e
                except Exception as e:
                    if writepath.is_file(): writepath.unlink()
                    logger.exception(f"[{track.mainArtist['name']} - {track.title}] Error while downloading the track, you should report this to the developers: {str(e)}")
                    raise e
                return True

            try:
                trackDownloaded = downloadMusic(track, trackAPI_gw)
            except Exception as e:
                raise e

            if not trackDownloaded: return self.download(trackAPI_gw, track)
        else:
            logger.info(f"[{track.mainArtist['name']} - {track.title}] Skipping track as it's already downloaded")
            self.completeTrackPercentage()

        # Adding tags
        if (not trackAlreadyDownloaded or self.settings['overwriteFile'] in [OverwriteOption.ONLY_TAGS, OverwriteOption.OVERWRITE]) and not track.localTrack:
            logger.info(f"[{track.mainArtist['name']} - {track.title}] Applying tags to the track")
            if track.selectedFormat in [TrackFormats.MP3_320, TrackFormats.MP3_128, TrackFormats.DEFAULT]:
                tagID3(writepath, track, self.settings['tags'])
            elif track.selectedFormat ==  TrackFormats.FLAC:
                try:
                    tagFLAC(writepath, track, self.settings['tags'])
                except (FLACNoHeaderError, FLACError):
                    if writepath.is_file(): writepath.unlink()
                    logger.warn(f"[{track.mainArtist['name']} - {track.title}] Track not available in FLAC, falling back if necessary")
                    self.removeTrackPercentage()
                    track.filesizes['FILESIZE_FLAC'] = "0"
                    track.filesizes['FILESIZE_FLAC_TESTED'] = True
                    return self.download(trackAPI_gw, track)

        if track.searched: result['searched'] = f"{track.mainArtist['name']} - {track.title}"
        logger.info(f"[{track.mainArtist['name']} - {track.title}] Track download completed\n{str(writepath)}")
        self.queueItem.downloaded += 1
        self.queueItem.files.append(str(writepath))
        self.queueItem.extrasPath = str(self.extrasPath)
        if self.interface:
            self.interface.send("updateQueue", {'uuid': self.queueItem.uuid, 'downloaded': True, 'downloadPath': str(writepath), 'extrasPath': str(self.extrasPath)})
        return result

    def getPreferredBitrate(self, track):
        if track.localTrack: return TrackFormats.LOCAL

        shouldFallback = self.settings['fallbackBitrate']
        falledBack = False

        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 = int(self.bitrate) in formats_360

        if not shouldFallback:
            formats = formats_360
            formats.update(formats_non_360)
        elif is360format:
            formats = formats_360
        else:
            formats = formats_non_360

        for formatNumber, formatName in formats.items():
            if formatNumber <= int(self.bitrate):
                if f"FILESIZE_{formatName}" in track.filesizes:
                    if int(track.filesizes[f"FILESIZE_{formatName}"]) != 0: return formatNumber
                    if not track.filesizes[f"FILESIZE_{formatName}_TESTED"]:
                        request = requests.head(
                            self.dz.get_track_stream_url(track.id, track.MD5, track.mediaVersion, formatNumber),
                            headers={'User-Agent': USER_AGENT_HEADER},
                            timeout=30
                        )
                        try:
                            request.raise_for_status()
                            return formatNumber
                        except request_exception.HTTPError: # if the format is not available, Deezer returns a 403 error
                            pass
                if not shouldFallback:
                    raise PreferredBitrateNotFound
                else:
                    if not falledBack:
                        falledBack = True
                        logger.info(f"[{track.mainArtist['name']} - {track.title}] Fallback to lower bitrate")
                        if self.interface:
                            self.interface.send('queueUpdate', {
                                'uuid': self.queueItem.uuid,
                                'bitrateFallback': True,
                                'data': {
                                    'id': track.id,
                                    'title': track.title,
                                    'artist': track.mainArtist['name']
                                },
                            })
        if is360format: raise TrackNot360
        return TrackFormats.DEFAULT

    def streamTrack(self, stream, track, start=0):
        if self.queueItem.cancel: raise DownloadCancelled

        headers=dict(self.dz.http_headers)
        if range != 0: headers['Range'] = f'bytes={start}-'
        chunkLength = start
        percentage = 0

        itemName = f"[{track.mainArtist['name']} - {track.title}]"

        try:
            with self.dz.session.get(track.downloadUrl, headers=headers, stream=True, timeout=10) as request:
                request.raise_for_status()

                blowfish_key = str.encode(self.dz._get_blowfish_key(str(track.id)))

                complete = int(request.headers["Content-Length"])
                if complete == 0: raise DownloadEmpty
                if start != 0:
                    responseRange = request.headers["Content-Range"]
                    logger.info(f'{itemName} downloading range {responseRange}')
                else:
                    logger.info(f'{itemName} downloading {complete} bytes')

                for chunk in request.iter_content(2048 * 3):
                    if self.queueItem.cancel: raise DownloadCancelled

                    if len(chunk) >= 2048:
                        chunk = Blowfish.new(blowfish_key, Blowfish.MODE_CBC, b"\x00\x01\x02\x03\x04\x05\x06\x07").decrypt(chunk[0:2048]) + chunk[2048:]

                    stream.write(chunk)
                    chunkLength += len(chunk)

                    if isinstance(self.queueItem, QISingle):
                        percentage = (chunkLength / (complete + start)) * 100
                        self.downloadPercentage = percentage
                    else:
                        chunkProgres = (len(chunk) / (complete + start)) / self.queueItem.size * 100
                        self.downloadPercentage += chunkProgres
                    self.updatePercentage()

        except SSLError as e:
            logger.info(f'{itemName} retrying from byte {chunkLength}')
            return self.streamTrack(stream, track, chunkLength)
        except (request_exception.ConnectionError, requests.exceptions.ReadTimeout):
            eventlet.sleep(2)
            return self.streamTrack(stream, track, start)

    def updatePercentage(self):
        if round(self.downloadPercentage) != self.lastPercentage and round(self.downloadPercentage) % 2 == 0:
            self.lastPercentage = round(self.downloadPercentage)
            self.queueItem.progress = self.lastPercentage
            if self.interface: self.interface.send("updateQueue", {'uuid': self.queueItem.uuid, 'progress': self.lastPercentage})

    def completeTrackPercentage(self):
        if isinstance(self.queueItem, QISingle):
            self.downloadPercentage = 100
        else:
            self.downloadPercentage += (1 / self.queueItem.size) * 100
        self.updatePercentage()

    def removeTrackPercentage(self):
        if isinstance(self.queueItem, QISingle):
            self.downloadPercentage = 0
        else:
            self.downloadPercentage -= (1 / self.queueItem.size) * 100
        self.updatePercentage()

    def downloadWrapper(self, trackAPI_gw):
        track = {
            'id': trackAPI_gw['SNG_ID'],
            'title': trackAPI_gw['SNG_TITLE'].strip(),
            'artist': trackAPI_gw['ART_NAME']
        }
        if trackAPI_gw.get('VERSION') and trackAPI_gw['VERSION'] not in trackAPI_gw['SNG_TITLE']:
            track['title'] += f" {trackAPI_gw['VERSION']}".strip()

        try:
            result = self.download(trackAPI_gw)
        except DownloadCancelled:
            return None
        except DownloadFailed as error:
            logger.error(f"[{track['artist']} - {track['title']}] {error.message}")
            result = {'error': {
                        'message': error.message,
                        'errid': error.errid,
                        'data': track
                    }}
        except Exception as e:
            logger.exception(f"[{track['artist']} - {track['title']}] {str(e)}")
            result = {'error': {
                        'message': str(e),
                        'data': track
                    }}

        if 'error' in result:
            self.completeTrackPercentage()
            self.queueItem.failed += 1
            self.queueItem.errors.append(result['error'])
            if self.interface:
                error = result['error']
                self.interface.send("updateQueue", {
                    'uuid': self.queueItem.uuid,
                    'failed': True,
                    'data': error['data'],
                    'error': error['message'],
                    'errid': error['errid'] if 'errid' in error else None
                })
        return result

class DownloadError(Exception):
    """Base class for exceptions in this module."""
    pass

class DownloadFailed(DownloadError):
    def __init__(self, errid):
        self.errid = errid
        self.message = errorMessages[self.errid]

class DownloadCancelled(DownloadError):
    pass

class DownloadEmpty(DownloadError):
    pass

class PreferredBitrateNotFound(DownloadError):
    pass

class TrackNot360(DownloadError):
    pass
