Compare commits
119 Commits
refactorin
...
main
Author | SHA1 | Date |
---|---|---|
Lukáš Kucharczyk | d26b1cfe46 | |
Lukáš Kucharczyk | 6e10a30c48 | |
Lukáš Kucharczyk | ebff41e016 | |
Lukáš Kucharczyk | c357cfb6a5 | |
Lukáš Kucharczyk | 945c69baf0 | |
Lukáš Kucharczyk | 1dc0d470dd | |
RemixDev | 5f978acec7 | |
RemixDev | 9162b764e7 | |
RemixDev | fc9a205662 | |
RemixDev | 203ec1f10f | |
RemixDev | b524172d5a | |
RemixDev | 463289ac46 | |
omtinez | 7fe430b77f | |
RemixDev | 27219d0698 | |
RemixDev | 1863557f49 | |
RemixDev | b983712086 | |
RemixDev | 3aa1389eed | |
omtinez | a5f4235128 | |
RemixDev | 724a7affa6 | |
RemixDev | 9e992af9c1 | |
RemixDev | e77791fe69 | |
RemixDev | 0c4db05db1 | |
RemixDev | 5107bde5c9 | |
RemixDev | 32a31d8842 | |
RemixDev | e94a3ab28f | |
RemixDev | 9f9433d205 | |
RemixDev | 5d44c971f7 | |
RemixDev | eb84a392ba | |
RemixDev | 6b41e6cb0a | |
RemixDev | ef3c9fbf57 | |
RemixDev | 55383332f8 | |
RemixDev | add5158022 | |
RemixDev | 5ec49663e3 | |
RemixDev | 6a6ec400db | |
RemixDev | 8be934fb42 | |
RemixDev | 13efa2bc90 | |
RemixDev | 133314f481 | |
RemixDev | 09511fb379 | |
RemixDev | 7f1eb0c500 | |
RemixDev | ed22a396c8 | |
RemixDev | 9906043b31 | |
RemixDev | 3b0763eeb1 | |
RemixDev | 7e6202b7f0 | |
RemixDev | 54374c87e2 | |
RemixDev | ce98393683 | |
RemixDev | d2e0450ace | |
RemixDev | 0907586e0d | |
digitalec | bb8bb1b4c4 | |
RemixDev | 45910742c2 | |
RemixDev | 09857b1247 | |
RemixDev | 93fa5fd8a1 | |
RemixDev | 114ce94148 | |
RemixDev | b9da32b2a2 | |
RemixDev | a4b707bd88 | |
RemixDev | 58f2b875fa | |
RemixDev | c2b19eef33 | |
RemixDev | 8b896fe7e7 | |
RemixDev | d11843c733 | |
RemixDev | 8074cf06b7 | |
RemixDev | 4e16f14ffc | |
RemixDev | e61a9aa626 | |
RemixDev | ae5243288d | |
Eddy Hintze | d8580d5d19 | |
RemixDev | c8bda282d1 | |
RemixDev | 2694b05b9d | |
RemixDev | 385cdce2c0 | |
RemixDev | 5d1102c6a7 | |
RemixDev | a705794a91 | |
RemixDev | caee30f37c | |
RemixDev | 242465eb21 | |
J0J0 T | c4f11aef7c | |
TheoD02 | bb00dd218d | |
RemixDev | 01bcd9ce37 | |
RemixDev | 8d84b9f85a | |
RemixDev | 87e83e807f | |
RemixDev | 2d3d6d0699 | |
RemixDev | 09f087484d | |
TheoD02 | 4c119447f5 | |
RemixDev | 44d018a810 | |
RemixDev | 41469cee64 | |
RemixDev | 8cea4289d1 | |
RemixDev | ec3a6de8df | |
RemixDev | 3030140e15 | |
RemixDev | 1859f07842 | |
RemixDev | 2e7c5c4e65 | |
RemixDev | e4f677e6b4 | |
RemixDev | 8d325d5321 | |
RemixDev | 834de5a09c | |
RemixDev | 0b8647845c | |
RemixDev | 43112e28eb | |
RemixDev | 8894ba7862 | |
RemixDev | 49dddea45e | |
RemixDev | 3b3146f220 | |
RemixDev | d0cf20db8f | |
RemixDev | 7536597495 | |
RemixDev | 4d5ef2850e | |
RemixDev | 7efaa6aaf7 | |
RemixDev | aa69439967 | |
RemixDev | 90b3be50e9 | |
RemixDev | 7838d4eefe | |
RemixDev | e11c69101b | |
RemixDev | 092e96f4bd | |
RemixDev | c007d39e15 | |
RemixDev | d80702e949 | |
RemixDev | e194588d39 | |
RemixDev | a7dd659e22 | |
RemixDev | 4119617c6b | |
RemixDev | 263ecd4be0 | |
RemixDev | 11447d606b | |
RemixDev | 536caee401 | |
RemixDev | 07bdca4599 | |
RemixDev | 4e1485f8d6 | |
RemixDev | f0c3152ffa | |
RemixDev | 78804710d1 | |
RemixDev | 82bbb5c2ab | |
RemixDev | 01cc9f5199 | |
RemixDev | b8e8d27357 | |
RemixDev | d8ecb244f5 | |
RemixDev | f530a4e89f |
|
@ -26,10 +26,13 @@ yarn-error.log*
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# Private configs
|
# Private configs
|
||||||
/config.py
|
config.py
|
||||||
/test.py
|
test.py
|
||||||
|
config
|
||||||
|
|
||||||
#build files
|
#build files
|
||||||
/build
|
build
|
||||||
/*egg-info
|
*egg-info
|
||||||
updatePyPi.sh
|
updatePyPi.sh
|
||||||
|
deezer
|
||||||
|
.cache
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
[MESSAGES CONTROL]
|
||||||
|
disable=C0301,C0103,R0902,R0903,C0321,R0911,R0912,R0913,R0914,R0915,R0916
|
|
@ -1,6 +1,76 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
import re
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
__version__ = "2.0.16"
|
from deemix.itemgen import generateTrackItem, \
|
||||||
USER_AGENT_HEADER = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " \
|
generateAlbumItem, \
|
||||||
"Chrome/79.0.3945.130 Safari/537.36"
|
generatePlaylistItem, \
|
||||||
VARIOUS_ARTISTS = "5080"
|
generateArtistItem, \
|
||||||
|
generateArtistDiscographyItem, \
|
||||||
|
generateArtistTopItem
|
||||||
|
from deemix.errors import LinkNotRecognized, LinkNotSupported
|
||||||
|
|
||||||
|
__version__ = "3.6.6"
|
||||||
|
|
||||||
|
# Returns the Resolved URL, the Type and the ID
|
||||||
|
def parseLink(link):
|
||||||
|
if 'deezer.page.link' in link: link = urlopen(link).url # Resolve URL shortner
|
||||||
|
# Remove extra stuff
|
||||||
|
if '?' in link: link = link[:link.find('?')]
|
||||||
|
if '&' in link: link = link[:link.find('&')]
|
||||||
|
if link.endswith('/'): link = link[:-1] # Remove last slash if present
|
||||||
|
|
||||||
|
link_type = None
|
||||||
|
link_id = None
|
||||||
|
|
||||||
|
if not 'deezer' in link: return (link, link_type, link_id) # return if not a deezer link
|
||||||
|
|
||||||
|
if '/track' in link:
|
||||||
|
link_type = 'track'
|
||||||
|
link_id = re.search(r"/track/(.+)", link).group(1)
|
||||||
|
elif '/playlist' in link:
|
||||||
|
link_type = 'playlist'
|
||||||
|
link_id = re.search(r"/playlist/(\d+)", link).group(1)
|
||||||
|
elif '/album' in link:
|
||||||
|
link_type = 'album'
|
||||||
|
link_id = re.search(r"/album/(.+)", link).group(1)
|
||||||
|
elif re.search(r"/artist/(\d+)/top_track", link):
|
||||||
|
link_type = 'artist_top'
|
||||||
|
link_id = re.search(r"/artist/(\d+)/top_track", link).group(1)
|
||||||
|
elif re.search(r"/artist/(\d+)/discography", link):
|
||||||
|
link_type = 'artist_discography'
|
||||||
|
link_id = re.search(r"/artist/(\d+)/discography", link).group(1)
|
||||||
|
elif '/artist' in link:
|
||||||
|
link_type = 'artist'
|
||||||
|
link_id = re.search(r"/artist/(\d+)", link).group(1)
|
||||||
|
|
||||||
|
return (link, link_type, link_id)
|
||||||
|
|
||||||
|
def generateDownloadObject(dz, link, bitrate, plugins=None, listener=None):
|
||||||
|
(link, link_type, link_id) = parseLink(link)
|
||||||
|
|
||||||
|
if link_type is None or link_id is None:
|
||||||
|
if plugins is None: plugins = {}
|
||||||
|
plugin_names = plugins.keys()
|
||||||
|
current_plugin = None
|
||||||
|
item = None
|
||||||
|
for plugin in plugin_names:
|
||||||
|
current_plugin = plugins[plugin]
|
||||||
|
item = current_plugin.generateDownloadObject(dz, link, bitrate, listener)
|
||||||
|
if item: return item
|
||||||
|
raise LinkNotRecognized(link)
|
||||||
|
|
||||||
|
if link_type == "track":
|
||||||
|
return generateTrackItem(dz, link_id, bitrate)
|
||||||
|
if link_type == "album":
|
||||||
|
return generateAlbumItem(dz, link_id, bitrate)
|
||||||
|
if link_type == "playlist":
|
||||||
|
return generatePlaylistItem(dz, link_id, bitrate)
|
||||||
|
if link_type == "artist":
|
||||||
|
return generateArtistItem(dz, link_id, bitrate, listener)
|
||||||
|
if link_type == "artist_discography":
|
||||||
|
return generateArtistDiscographyItem(dz, link_id, bitrate, listener)
|
||||||
|
if link_type == "artist_top":
|
||||||
|
return generateArtistTopItem(dz, link_id, bitrate)
|
||||||
|
|
||||||
|
raise LinkNotSupported(link)
|
||||||
|
|
|
@ -1,37 +1,114 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from deemix.app.cli import cli
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from deezer import Deezer
|
||||||
|
from deezer import TrackFormats
|
||||||
|
|
||||||
|
from deemix import generateDownloadObject
|
||||||
|
from deemix.settings import load as loadSettings
|
||||||
|
from deemix.utils import getBitrateNumberFromText, formatListener
|
||||||
|
import deemix.utils.localpaths as localpaths
|
||||||
|
from deemix.downloader import Downloader
|
||||||
|
from deemix.itemgen import GenerationError
|
||||||
|
try:
|
||||||
|
from deemix.plugins.spotify import Spotify
|
||||||
|
except ImportError:
|
||||||
|
Spotify = None
|
||||||
|
|
||||||
|
class LogListener:
|
||||||
|
@classmethod
|
||||||
|
def send(cls, key, value=None):
|
||||||
|
logString = formatListener(key, value)
|
||||||
|
if logString: print(logString)
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.option('--portable', is_flag=True, help='Creates the config folder in the same directory where the script is launched')
|
@click.option('--portable', is_flag=True, help='Creates the config folder in the same directory where the script is launched')
|
||||||
@click.option('-b', '--bitrate', default=None, help='Overwrites the default bitrate selected')
|
@click.option('-b', '--bitrate', default=None, help='Overwrites the default bitrate selected')
|
||||||
@click.option('-p', '--path', type=str, help='Downloads in the given folder')
|
@click.option('-p', '--path', type=str, help='Downloads in the given folder')
|
||||||
@click.argument('url', nargs=-1, required=True)
|
@click.argument('url', nargs=-1, required=True)
|
||||||
def download(url, bitrate, portable, path):
|
def download(url, bitrate, portable, path):
|
||||||
|
# Check for local configFolder
|
||||||
localpath = Path('.')
|
localpath = Path('.')
|
||||||
configFolder = localpath / 'config' if portable else None
|
configFolder = localpath / 'config' if portable else localpaths.getConfigFolder()
|
||||||
|
|
||||||
|
settings = loadSettings(configFolder)
|
||||||
|
dz = Deezer()
|
||||||
|
listener = LogListener()
|
||||||
|
|
||||||
|
def requestValidArl():
|
||||||
|
while True:
|
||||||
|
arl = input("Paste here your arl:")
|
||||||
|
if dz.login_via_arl(arl.strip()): break
|
||||||
|
return arl
|
||||||
|
|
||||||
|
if (configFolder / '.arl').is_file():
|
||||||
|
with open(configFolder / '.arl', 'r', encoding="utf-8") as f:
|
||||||
|
arl = f.readline().rstrip("\n").strip()
|
||||||
|
if not dz.login_via_arl(arl): arl = requestValidArl()
|
||||||
|
else: arl = requestValidArl()
|
||||||
|
try:
|
||||||
|
with open(configFolder / '.arl', 'w', encoding="utf-8") as f:
|
||||||
|
f.write(arl)
|
||||||
|
except:
|
||||||
|
print(f"Error opening {configFolder / '.arl'}, continuing anyway.")
|
||||||
|
|
||||||
|
plugins = {}
|
||||||
|
if Spotify:
|
||||||
|
plugins = {
|
||||||
|
"spotify": Spotify(configFolder=configFolder)
|
||||||
|
}
|
||||||
|
plugins["spotify"].setup()
|
||||||
|
|
||||||
|
def downloadLinks(url, bitrate=None):
|
||||||
|
if not bitrate: bitrate = settings.get("maxBitrate", TrackFormats.MP3_320)
|
||||||
|
links = []
|
||||||
|
for link in url:
|
||||||
|
if ';' in link:
|
||||||
|
for l in link.split(";"):
|
||||||
|
links.append(l)
|
||||||
|
else:
|
||||||
|
links.append(link)
|
||||||
|
|
||||||
|
downloadObjects = []
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
try:
|
||||||
|
downloadObject = generateDownloadObject(dz, link, bitrate, plugins, listener)
|
||||||
|
except GenerationError as e:
|
||||||
|
print(f"{e.link}: {e.message}")
|
||||||
|
continue
|
||||||
|
if isinstance(downloadObject, list):
|
||||||
|
downloadObjects += downloadObject
|
||||||
|
else:
|
||||||
|
downloadObjects.append(downloadObject)
|
||||||
|
|
||||||
|
for obj in downloadObjects:
|
||||||
|
if obj.__type__ == "Convertable":
|
||||||
|
obj = plugins[obj.plugin].convert(dz, obj, settings, listener)
|
||||||
|
Downloader(dz, obj, settings, listener).start()
|
||||||
|
|
||||||
|
|
||||||
if path is not None:
|
if path is not None:
|
||||||
if path == '': path = '.'
|
if path == '': path = '.'
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
|
settings['downloadLocation'] = str(path)
|
||||||
app = cli(path, configFolder)
|
|
||||||
app.login()
|
|
||||||
url = list(url)
|
url = list(url)
|
||||||
|
if bitrate: bitrate = getBitrateNumberFromText(bitrate)
|
||||||
|
|
||||||
|
# If first url is filepath readfile and use them as URLs
|
||||||
try:
|
try:
|
||||||
isfile = Path(url[0]).is_file()
|
isfile = Path(url[0]).is_file()
|
||||||
except:
|
except Exception:
|
||||||
isfile = False
|
isfile = False
|
||||||
if isfile:
|
if isfile:
|
||||||
filename = url[0]
|
filename = url[0]
|
||||||
with open(filename) as f:
|
with open(filename, encoding="utf-8") as f:
|
||||||
url = f.readlines()
|
url = f.readlines()
|
||||||
|
|
||||||
app.downloadLink(url, bitrate)
|
downloadLinks(url, bitrate)
|
||||||
click.echo("All done!")
|
click.echo("All done!")
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
download()
|
download() # pylint: disable=E1120
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
from deezer import Deezer
|
|
||||||
from deemix.app.settings import Settings
|
|
||||||
from deemix.app.queuemanager import QueueManager
|
|
||||||
from deemix.app.spotifyhelper import SpotifyHelper
|
|
||||||
|
|
||||||
class deemix:
|
|
||||||
def __init__(self, configFolder=None, overwriteDownloadFolder=None):
|
|
||||||
self.set = Settings(configFolder, overwriteDownloadFolder=overwriteDownloadFolder)
|
|
||||||
self.dz = Deezer()
|
|
||||||
self.dz.set_accept_language(self.set.settings.get('tagsLanguage'))
|
|
||||||
self.sp = SpotifyHelper(configFolder)
|
|
||||||
self.qm = QueueManager(self.sp)
|
|
|
@ -1,40 +0,0 @@
|
||||||
from pathlib import Path
|
|
||||||
from os import makedirs
|
|
||||||
|
|
||||||
from deemix.app import deemix
|
|
||||||
from deemix.utils import checkFolder
|
|
||||||
|
|
||||||
class cli(deemix):
|
|
||||||
def __init__(self, downloadpath, configFolder=None):
|
|
||||||
super().__init__(configFolder, overwriteDownloadFolder=downloadpath)
|
|
||||||
if downloadpath:
|
|
||||||
print("Using folder: "+self.set.settings['downloadLocation'])
|
|
||||||
|
|
||||||
def downloadLink(self, url, bitrate=None):
|
|
||||||
for link in url:
|
|
||||||
if ';' in link:
|
|
||||||
for l in link.split(";"):
|
|
||||||
self.qm.addToQueue(self.dz, l, self.set.settings, bitrate)
|
|
||||||
else:
|
|
||||||
self.qm.addToQueue(self.dz, link, self.set.settings, bitrate)
|
|
||||||
|
|
||||||
def requestValidArl(self):
|
|
||||||
while True:
|
|
||||||
arl = input("Paste here your arl:")
|
|
||||||
if self.dz.login_via_arl(arl):
|
|
||||||
break
|
|
||||||
return arl
|
|
||||||
|
|
||||||
def login(self):
|
|
||||||
configFolder = Path(self.set.configFolder)
|
|
||||||
if not configFolder.is_dir():
|
|
||||||
makedirs(configFolder, exist_ok=True)
|
|
||||||
if (configFolder / '.arl').is_file():
|
|
||||||
with open(configFolder / '.arl', 'r') as f:
|
|
||||||
arl = f.readline().rstrip("\n")
|
|
||||||
if not self.dz.login_via_arl(arl):
|
|
||||||
arl = self.requestValidArl()
|
|
||||||
else:
|
|
||||||
arl = self.requestValidArl()
|
|
||||||
with open(configFolder / '.arl', 'w') as f:
|
|
||||||
f.write(arl)
|
|
|
@ -1,767 +0,0 @@
|
||||||
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 urllib3.exceptions import SSLError as u3SSLError
|
|
||||||
|
|
||||||
from deemix.app.queueitem import QISingle, QICollection
|
|
||||||
from deemix.types.Track import Track, AlbumDoesntExists
|
|
||||||
from deemix.utils import changeCase
|
|
||||||
from deemix.utils.pathtemplates import generateFilename, generateFilepath, settingsRegexAlbum, settingsRegexArtist, settingsRegexPlaylistFile
|
|
||||||
from deezer import TrackFormats
|
|
||||||
from deemix import USER_AGENT_HEADER
|
|
||||||
from deemix.utils.taggers import tagID3, tagFLAC
|
|
||||||
from deemix.utils.decryption import generateStreamURL, generateBlowfishKey
|
|
||||||
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",
|
|
||||||
'albumDoesntExists': "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, u3SSLError) 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
|
|
||||||
|
|
||||||
|
|
||||||
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().parseData(
|
|
||||||
dz=self.dz,
|
|
||||||
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,
|
|
||||||
playlistAPI = trackAPI_gw['_EXTRA_PLAYLIST'] if '_EXTRA_PLAYLIST' in trackAPI_gw else None
|
|
||||||
)
|
|
||||||
except AlbumDoesntExists:
|
|
||||||
raise DownloadError('albumDoesntExists')
|
|
||||||
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.gw.get_track_with_fallback(track.fallbackId)
|
|
||||||
track.parseEssentialData(newTrack)
|
|
||||||
track.retriveFilesizes(self.dz)
|
|
||||||
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.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)
|
|
||||||
track.parseEssentialData(newTrack)
|
|
||||||
track.retriveFilesizes(self.dz)
|
|
||||||
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")
|
|
||||||
|
|
||||||
# Choose the target bitrate
|
|
||||||
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.gw.get_track_with_fallback(track.fallbackId)
|
|
||||||
track.parseEssentialData(newTrack)
|
|
||||||
track.retriveFilesizes(self.dz)
|
|
||||||
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.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)
|
|
||||||
track.parseEssentialData(newTrack)
|
|
||||||
track.retriveFilesizes(self.dz)
|
|
||||||
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
|
|
||||||
track.album.bitrate = selectedFormat
|
|
||||||
|
|
||||||
# Generate covers URLs
|
|
||||||
embeddedImageFormat = f'jpg-{self.settings["jpegImageQuality"]}'
|
|
||||||
if self.settings['embeddedArtworkPNG']: imageFormat = 'png'
|
|
||||||
|
|
||||||
if self.settings['tags']['savePlaylistAsCompilation'] and track.playlist:
|
|
||||||
track.trackNumber = track.position
|
|
||||||
track.discNumber = "1"
|
|
||||||
track.album.makePlaylistCompilation(track.playlist)
|
|
||||||
track.album.embeddedCoverURL = track.playlist.pic.generatePictureURL(self.settings['embeddedArtworkSize'], embeddedImageFormat)
|
|
||||||
|
|
||||||
ext = track.album.embeddedCoverURL[-4:]
|
|
||||||
if ext[0] != ".": ext = ".jpg" # Check for Spotify images
|
|
||||||
|
|
||||||
track.album.embeddedCoverPath = TEMPDIR / f"pl{trackAPI_gw['_EXTRA_PLAYLIST']['id']}_{self.settings['embeddedArtworkSize']}{ext}"
|
|
||||||
else:
|
|
||||||
if track.album.date: track.date = track.album.date
|
|
||||||
track.album.embeddedCoverURL = track.album.pic.generatePictureURL(self.settings['embeddedArtworkSize'], embeddedImageFormat)
|
|
||||||
|
|
||||||
ext = track.album.embeddedCoverURL[-4:]
|
|
||||||
track.album.embeddedCoverPath = TEMPDIR / f"alb{track.album.id}_{self.settings['embeddedArtworkSize']}{ext}"
|
|
||||||
|
|
||||||
track.dateString = track.date.format(self.settings['dateFormat'])
|
|
||||||
track.album.dateString = track.album.date.format(self.settings['dateFormat'])
|
|
||||||
if track.playlist: track.playlist.dateString = track.playlist.date.format(self.settings['dateFormat'])
|
|
||||||
|
|
||||||
# Check various artist option
|
|
||||||
if self.settings['albumVariousArtists'] and track.album.variousArtists:
|
|
||||||
artist = track.album.variousArtists
|
|
||||||
isMainArtist = artist.role == "Main"
|
|
||||||
|
|
||||||
if artist.name not in track.album.artists:
|
|
||||||
track.album.artists.insert(0, artist.name)
|
|
||||||
|
|
||||||
if isMainArtist or artist.name not in track.album.artist['Main'] and not isMainArtist:
|
|
||||||
if not artist.role in track.album.artist:
|
|
||||||
track.album.artist[artist.role] = []
|
|
||||||
track.album.artist[artist.role].insert(0, artist.name)
|
|
||||||
track.album.mainArtist.save = not track.album.mainArtist.isVariousArtists() or self.settings['albumVariousArtists'] and track.album.mainArtist.isVariousArtists()
|
|
||||||
|
|
||||||
# Check removeDuplicateArtists
|
|
||||||
if self.settings['removeDuplicateArtists']: track.removeDuplicateArtists()
|
|
||||||
|
|
||||||
# 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.album.getCleanTitle()
|
|
||||||
|
|
||||||
# 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.embeddedCoverPath = downloadImage(track.album.embeddedCoverURL, track.album.embeddedCoverPath)
|
|
||||||
|
|
||||||
# Save local album art
|
|
||||||
if coverPath:
|
|
||||||
result['albumURLs'] = []
|
|
||||||
for format in self.settings['localArtworkFormat'].split(","):
|
|
||||||
if format in ["png","jpg"]:
|
|
||||||
extendedFormat = format
|
|
||||||
if extendedFormat == "jpg": extendedFormat += f"-{self.settings['jpegImageQuality']}"
|
|
||||||
url = track.album.pic.generatePictureURL(self.settings['localArtworkSize'], extendedFormat)
|
|
||||||
if self.settings['tags']['savePlaylistAsCompilation'] \
|
|
||||||
and track.playlist \
|
|
||||||
and track.playlist.pic.url \
|
|
||||||
and not format.startswith("jpg"):
|
|
||||||
continue
|
|
||||||
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"]:
|
|
||||||
extendedFormat = format
|
|
||||||
if extendedFormat == "jpg": extendedFormat += f"-{self.settings['jpegImageQuality']}"
|
|
||||||
url = track.album.mainArtist.pic.generatePictureURL(self.settings['localArtworkSize'], extendedFormat)
|
|
||||||
if track.album.mainArtist.pic.md5 == "" and not format.startswith("jpg"): continue
|
|
||||||
result['artistURLs'].append({'url': url, 'ext': format})
|
|
||||||
result['artistPath'] = artistPath
|
|
||||||
result['artistFilename'] = f"{settingsRegexArtist(self.settings['artistImageTemplate'], track.album.mainArtist, self.settings, rootArtist=track.album.rootArtist)}"
|
|
||||||
|
|
||||||
# Save playlist cover
|
|
||||||
if track.playlist:
|
|
||||||
if not len(self.playlistURLs):
|
|
||||||
for format in self.settings['localArtworkFormat'].split(","):
|
|
||||||
if format in ["png","jpg"]:
|
|
||||||
extendedFormat = format
|
|
||||||
if extendedFormat == "jpg": extendedFormat += f"-{self.settings['jpegImageQuality']}"
|
|
||||||
url = track.playlist.pic.generatePictureURL(self.settings['localArtworkSize'], extendedFormat)
|
|
||||||
if track.playlist.pic.url and not format.startswith("jpg"): continue
|
|
||||||
self.playlistURLs.append({'url': url, 'ext': format})
|
|
||||||
if not self.playlistCoverName:
|
|
||||||
track.playlist.bitrate = selectedFormat
|
|
||||||
track.playlist.dateString = track.playlist.date.format(self.settings['dateFormat'])
|
|
||||||
self.playlistCoverName = f"{settingsRegexAlbum(self.settings['coverImageTemplate'], track.playlist, self.settings, track.playlist)}"
|
|
||||||
|
|
||||||
# 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 exists
|
|
||||||
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):]
|
|
||||||
|
|
||||||
if not trackAlreadyDownloaded or self.settings['overwriteFile'] == OverwriteOption.OVERWRITE:
|
|
||||||
logger.info(f"[{track.mainArtist.name} - {track.title}] Downloading the track")
|
|
||||||
track.downloadUrl = generateStreamURL(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.gw.get_track_with_fallback(track.fallbackId)
|
|
||||||
track.parseEssentialData(newTrack)
|
|
||||||
track.retriveFilesizes(self.dz)
|
|
||||||
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.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)
|
|
||||||
track.parseEssentialData(newTrack)
|
|
||||||
track.retriveFilesizes(self.dz)
|
|
||||||
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(
|
|
||||||
generateStreamURL(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(generateBlowfishKey(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, u3SSLError) 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
|
|
|
@ -1,4 +0,0 @@
|
||||||
class MessageInterface:
|
|
||||||
def send(self, message, value=None):
|
|
||||||
"""Implement this class to process updates and messages from the core"""
|
|
||||||
pass
|
|
|
@ -1,115 +0,0 @@
|
||||||
class QueueItem:
|
|
||||||
def __init__(self, id=None, bitrate=None, title=None, artist=None, cover=None, explicit=False, size=None, type=None, settings=None, queueItemDict=None):
|
|
||||||
if queueItemDict:
|
|
||||||
self.title = queueItemDict['title']
|
|
||||||
self.artist = queueItemDict['artist']
|
|
||||||
self.cover = queueItemDict['cover']
|
|
||||||
self.explicit = queueItemDict.get('explicit', False)
|
|
||||||
self.size = queueItemDict['size']
|
|
||||||
self.type = queueItemDict['type']
|
|
||||||
self.id = queueItemDict['id']
|
|
||||||
self.bitrate = queueItemDict['bitrate']
|
|
||||||
self.extrasPath = queueItemDict.get('extrasPath', '')
|
|
||||||
self.files = queueItemDict['files']
|
|
||||||
self.downloaded = queueItemDict['downloaded']
|
|
||||||
self.failed = queueItemDict['failed']
|
|
||||||
self.errors = queueItemDict['errors']
|
|
||||||
self.progress = queueItemDict['progress']
|
|
||||||
self.settings = queueItemDict.get('settings')
|
|
||||||
else:
|
|
||||||
self.title = title
|
|
||||||
self.artist = artist
|
|
||||||
self.cover = cover
|
|
||||||
self.explicit = explicit
|
|
||||||
self.size = size
|
|
||||||
self.type = type
|
|
||||||
self.id = id
|
|
||||||
self.bitrate = bitrate
|
|
||||||
self.extrasPath = None
|
|
||||||
self.files = []
|
|
||||||
self.settings = settings
|
|
||||||
self.downloaded = 0
|
|
||||||
self.failed = 0
|
|
||||||
self.errors = []
|
|
||||||
self.progress = 0
|
|
||||||
self.uuid = f"{self.type}_{self.id}_{self.bitrate}"
|
|
||||||
self.cancel = False
|
|
||||||
self.ack = None
|
|
||||||
|
|
||||||
def toDict(self):
|
|
||||||
return {
|
|
||||||
'title': self.title,
|
|
||||||
'artist': self.artist,
|
|
||||||
'cover': self.cover,
|
|
||||||
'explicit': self.explicit,
|
|
||||||
'size': self.size,
|
|
||||||
'extrasPath': self.extrasPath,
|
|
||||||
'files': self.files,
|
|
||||||
'downloaded': self.downloaded,
|
|
||||||
'failed': self.failed,
|
|
||||||
'errors': self.errors,
|
|
||||||
'progress': self.progress,
|
|
||||||
'type': self.type,
|
|
||||||
'id': self.id,
|
|
||||||
'bitrate': self.bitrate,
|
|
||||||
'uuid': self.uuid,
|
|
||||||
'ack': self.ack
|
|
||||||
}
|
|
||||||
|
|
||||||
def getResettedItem(self):
|
|
||||||
item = self.toDict()
|
|
||||||
item['downloaded'] = 0
|
|
||||||
item['failed'] = 0
|
|
||||||
item['progress'] = 0
|
|
||||||
item['errors'] = []
|
|
||||||
return item
|
|
||||||
|
|
||||||
def getSlimmedItem(self):
|
|
||||||
light = self.toDict()
|
|
||||||
propertiesToDelete = ['single', 'collection', '_EXTRA', 'settings']
|
|
||||||
for property in propertiesToDelete:
|
|
||||||
if property in light:
|
|
||||||
del light[property]
|
|
||||||
return light
|
|
||||||
|
|
||||||
class QISingle(QueueItem):
|
|
||||||
def __init__(self, id=None, bitrate=None, title=None, artist=None, cover=None, explicit=False, type=None, settings=None, single=None, queueItemDict=None):
|
|
||||||
if queueItemDict:
|
|
||||||
super().__init__(queueItemDict=queueItemDict)
|
|
||||||
self.single = queueItemDict['single']
|
|
||||||
else:
|
|
||||||
super().__init__(id, bitrate, title, artist, cover, explicit, 1, type, settings)
|
|
||||||
self.single = single
|
|
||||||
|
|
||||||
def toDict(self):
|
|
||||||
queueItem = super().toDict()
|
|
||||||
queueItem['single'] = self.single
|
|
||||||
return queueItem
|
|
||||||
|
|
||||||
class QICollection(QueueItem):
|
|
||||||
def __init__(self, id=None, bitrate=None, title=None, artist=None, cover=None, explicit=False, size=None, type=None, settings=None, collection=None, queueItemDict=None):
|
|
||||||
if queueItemDict:
|
|
||||||
super().__init__(queueItemDict=queueItemDict)
|
|
||||||
self.collection = queueItemDict['collection']
|
|
||||||
else:
|
|
||||||
super().__init__(id, bitrate, title, artist, cover, explicit, size, type, settings)
|
|
||||||
self.collection = collection
|
|
||||||
|
|
||||||
def toDict(self):
|
|
||||||
queueItem = super().toDict()
|
|
||||||
queueItem['collection'] = self.collection
|
|
||||||
return queueItem
|
|
||||||
|
|
||||||
class QIConvertable(QICollection):
|
|
||||||
def __init__(self, id=None, bitrate=None, title=None, artist=None, cover=None, explicit=False, size=None, type=None, settings=None, extra=None, queueItemDict=None):
|
|
||||||
if queueItemDict:
|
|
||||||
super().__init__(queueItemDict=queueItemDict)
|
|
||||||
self.extra = queueItemDict['_EXTRA']
|
|
||||||
else:
|
|
||||||
super().__init__(id, bitrate, title, artist, cover, explicit, size, type, settings, [])
|
|
||||||
self.extra = extra
|
|
||||||
|
|
||||||
def toDict(self):
|
|
||||||
queueItem = super().toDict()
|
|
||||||
queueItem['_EXTRA'] = self.extra
|
|
||||||
return queueItem
|
|
|
@ -1,569 +0,0 @@
|
||||||
from deemix.app.downloadjob import DownloadJob
|
|
||||||
from deemix.utils import getIDFromLink, getTypeFromLink, getBitrateInt
|
|
||||||
from deezer.gw import APIError as gwAPIError, LyricsStatus
|
|
||||||
from deezer.api import APIError
|
|
||||||
from deezer.utils import map_user_playlist
|
|
||||||
from spotipy.exceptions import SpotifyException
|
|
||||||
from deemix.app.queueitem import QueueItem, QISingle, QICollection, QIConvertable
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
from os import remove
|
|
||||||
import eventlet
|
|
||||||
import uuid
|
|
||||||
urlopen = eventlet.import_patched('urllib.request').urlopen
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logger = logging.getLogger('deemix')
|
|
||||||
|
|
||||||
class QueueManager:
|
|
||||||
def __init__(self, spotifyHelper=None):
|
|
||||||
self.queue = []
|
|
||||||
self.queueList = {}
|
|
||||||
self.queueComplete = []
|
|
||||||
self.currentItem = ""
|
|
||||||
self.sp = spotifyHelper
|
|
||||||
|
|
||||||
def generateTrackQueueItem(self, dz, id, settings, bitrate, trackAPI=None, albumAPI=None):
|
|
||||||
# Check if is an isrc: url
|
|
||||||
if str(id).startswith("isrc"):
|
|
||||||
try:
|
|
||||||
trackAPI = dz.api.get_track(id)
|
|
||||||
except APIError as e:
|
|
||||||
e = str(e)
|
|
||||||
return QueueError("https://deezer.com/track/"+str(id), f"Wrong URL: {e}")
|
|
||||||
if 'id' in trackAPI and 'title' in trackAPI:
|
|
||||||
id = trackAPI['id']
|
|
||||||
else:
|
|
||||||
return QueueError("https://deezer.com/track/"+str(id), "Track ISRC is not available on deezer", "ISRCnotOnDeezer")
|
|
||||||
|
|
||||||
# Get essential track info
|
|
||||||
try:
|
|
||||||
trackAPI_gw = dz.gw.get_track_with_fallback(id)
|
|
||||||
except gwAPIError as e:
|
|
||||||
e = str(e)
|
|
||||||
message = "Wrong URL"
|
|
||||||
if "DATA_ERROR" in e: message += f": {e['DATA_ERROR']}"
|
|
||||||
return QueueError("https://deezer.com/track/"+str(id), message)
|
|
||||||
|
|
||||||
if albumAPI: trackAPI_gw['_EXTRA_ALBUM'] = albumAPI
|
|
||||||
if trackAPI: trackAPI_gw['_EXTRA_TRACK'] = trackAPI
|
|
||||||
|
|
||||||
if settings['createSingleFolder']:
|
|
||||||
trackAPI_gw['FILENAME_TEMPLATE'] = settings['albumTracknameTemplate']
|
|
||||||
else:
|
|
||||||
trackAPI_gw['FILENAME_TEMPLATE'] = settings['tracknameTemplate']
|
|
||||||
|
|
||||||
trackAPI_gw['SINGLE_TRACK'] = True
|
|
||||||
|
|
||||||
title = trackAPI_gw['SNG_TITLE'].strip()
|
|
||||||
if trackAPI_gw.get('VERSION') and trackAPI_gw['VERSION'] not in trackAPI_gw['SNG_TITLE']:
|
|
||||||
title += f" {trackAPI_gw['VERSION']}".strip()
|
|
||||||
explicit = bool(int(trackAPI_gw.get('EXPLICIT_LYRICS', 0)))
|
|
||||||
|
|
||||||
return QISingle(
|
|
||||||
id=id,
|
|
||||||
bitrate=bitrate,
|
|
||||||
title=title,
|
|
||||||
artist=trackAPI_gw['ART_NAME'],
|
|
||||||
cover=f"https://e-cdns-images.dzcdn.net/images/cover/{trackAPI_gw['ALB_PICTURE']}/75x75-000000-80-0-0.jpg",
|
|
||||||
explicit=explicit,
|
|
||||||
type='track',
|
|
||||||
settings=settings,
|
|
||||||
single=trackAPI_gw,
|
|
||||||
)
|
|
||||||
|
|
||||||
def generateAlbumQueueItem(self, dz, id, settings, bitrate, rootArtist=None):
|
|
||||||
# Get essential album info
|
|
||||||
try:
|
|
||||||
albumAPI = dz.api.get_album(id)
|
|
||||||
except APIError as e:
|
|
||||||
e = str(e)
|
|
||||||
return QueueError("https://deezer.com/album/"+str(id), f"Wrong URL: {e}")
|
|
||||||
|
|
||||||
if str(id).startswith('upc'): id = albumAPI['id']
|
|
||||||
|
|
||||||
# Get extra info about album
|
|
||||||
# This saves extra api calls when downloading
|
|
||||||
albumAPI_gw = dz.gw.get_album(id)
|
|
||||||
albumAPI['nb_disk'] = albumAPI_gw['NUMBER_DISK']
|
|
||||||
albumAPI['copyright'] = albumAPI_gw['COPYRIGHT']
|
|
||||||
albumAPI['root_artist'] = rootArtist
|
|
||||||
|
|
||||||
# If the album is a single download as a track
|
|
||||||
if albumAPI['nb_tracks'] == 1:
|
|
||||||
return self.generateTrackQueueItem(dz, albumAPI['tracks']['data'][0]['id'], settings, bitrate, albumAPI=albumAPI)
|
|
||||||
|
|
||||||
tracksArray = dz.gw.get_album_tracks(id)
|
|
||||||
|
|
||||||
if albumAPI['cover_small'] != None:
|
|
||||||
cover = albumAPI['cover_small'][:-24] + '/75x75-000000-80-0-0.jpg'
|
|
||||||
else:
|
|
||||||
cover = f"https://e-cdns-images.dzcdn.net/images/cover/{albumAPI_gw['ALB_PICTURE']}/75x75-000000-80-0-0.jpg"
|
|
||||||
|
|
||||||
totalSize = len(tracksArray)
|
|
||||||
albumAPI['nb_tracks'] = totalSize
|
|
||||||
collection = []
|
|
||||||
for pos, trackAPI in enumerate(tracksArray, start=1):
|
|
||||||
trackAPI['_EXTRA_ALBUM'] = albumAPI
|
|
||||||
trackAPI['POSITION'] = pos
|
|
||||||
trackAPI['SIZE'] = totalSize
|
|
||||||
trackAPI['FILENAME_TEMPLATE'] = settings['albumTracknameTemplate']
|
|
||||||
collection.append(trackAPI)
|
|
||||||
|
|
||||||
explicit = albumAPI_gw.get('EXPLICIT_ALBUM_CONTENT', {}).get('EXPLICIT_LYRICS_STATUS', LyricsStatus.UNKNOWN) in [LyricsStatus.EXPLICIT, LyricsStatus.PARTIALLY_EXPLICIT]
|
|
||||||
|
|
||||||
return QICollection(
|
|
||||||
id=id,
|
|
||||||
bitrate=bitrate,
|
|
||||||
title=albumAPI['title'],
|
|
||||||
artist=albumAPI['artist']['name'],
|
|
||||||
cover=cover,
|
|
||||||
explicit=explicit,
|
|
||||||
size=totalSize,
|
|
||||||
type='album',
|
|
||||||
settings=settings,
|
|
||||||
collection=collection,
|
|
||||||
)
|
|
||||||
|
|
||||||
def generatePlaylistQueueItem(self, dz, id, settings, bitrate):
|
|
||||||
# Get essential playlist info
|
|
||||||
try:
|
|
||||||
playlistAPI = dz.api.get_playlist(id)
|
|
||||||
except:
|
|
||||||
playlistAPI = None
|
|
||||||
# Fallback to gw api if the playlist is private
|
|
||||||
if not playlistAPI:
|
|
||||||
try:
|
|
||||||
userPlaylist = dz.gw.get_playlist_page(id)
|
|
||||||
playlistAPI = map_user_playlist(userPlaylist['DATA'])
|
|
||||||
except gwAPIError as e:
|
|
||||||
e = str(e)
|
|
||||||
message = "Wrong URL"
|
|
||||||
if "DATA_ERROR" in e:
|
|
||||||
message += f": {e['DATA_ERROR']}"
|
|
||||||
return QueueError("https://deezer.com/playlist/"+str(id), message)
|
|
||||||
|
|
||||||
# Check if private playlist and owner
|
|
||||||
if not playlistAPI.get('public', False) and playlistAPI['creator']['id'] != str(dz.current_user['id']):
|
|
||||||
logger.warning("You can't download others private playlists.")
|
|
||||||
return QueueError("https://deezer.com/playlist/"+str(id), "You can't download others private playlists.", "notYourPrivatePlaylist")
|
|
||||||
|
|
||||||
playlistTracksAPI = dz.gw.get_playlist_tracks(id)
|
|
||||||
playlistAPI['various_artist'] = dz.api.get_artist(5080) # Useful for save as compilation
|
|
||||||
|
|
||||||
totalSize = len(playlistTracksAPI)
|
|
||||||
playlistAPI['nb_tracks'] = totalSize
|
|
||||||
collection = []
|
|
||||||
for pos, trackAPI in enumerate(playlistTracksAPI, start=1):
|
|
||||||
if trackAPI.get('EXPLICIT_TRACK_CONTENT', {}).get('EXPLICIT_LYRICS_STATUS', LyricsStatus.UNKNOWN) in [LyricsStatus.EXPLICIT, LyricsStatus.PARTIALLY_EXPLICIT]:
|
|
||||||
playlistAPI['explicit'] = True
|
|
||||||
trackAPI['_EXTRA_PLAYLIST'] = playlistAPI
|
|
||||||
trackAPI['POSITION'] = pos
|
|
||||||
trackAPI['SIZE'] = totalSize
|
|
||||||
trackAPI['FILENAME_TEMPLATE'] = settings['playlistTracknameTemplate']
|
|
||||||
collection.append(trackAPI)
|
|
||||||
if not 'explicit' in playlistAPI:
|
|
||||||
playlistAPI['explicit'] = False
|
|
||||||
|
|
||||||
return QICollection(
|
|
||||||
id=id,
|
|
||||||
bitrate=bitrate,
|
|
||||||
title=playlistAPI['title'],
|
|
||||||
artist=playlistAPI['creator']['name'],
|
|
||||||
cover=playlistAPI['picture_small'][:-24] + '/75x75-000000-80-0-0.jpg',
|
|
||||||
explicit=playlistAPI['explicit'],
|
|
||||||
size=totalSize,
|
|
||||||
type='playlist',
|
|
||||||
settings=settings,
|
|
||||||
collection=collection,
|
|
||||||
)
|
|
||||||
|
|
||||||
def generateArtistQueueItem(self, dz, id, settings, bitrate, interface=None):
|
|
||||||
# Get essential artist info
|
|
||||||
try:
|
|
||||||
artistAPI = dz.api.get_artist(id)
|
|
||||||
except APIError as e:
|
|
||||||
e = str(e)
|
|
||||||
return QueueError("https://deezer.com/artist/"+str(id), f"Wrong URL: {e}")
|
|
||||||
|
|
||||||
if interface: interface.send("startAddingArtist", {'name': artistAPI['name'], 'id': artistAPI['id']})
|
|
||||||
rootArtist = {
|
|
||||||
'id': artistAPI['id'],
|
|
||||||
'name': artistAPI['name']
|
|
||||||
}
|
|
||||||
|
|
||||||
artistDiscographyAPI = dz.gw.get_artist_discography_tabs(id, 100)
|
|
||||||
allReleases = artistDiscographyAPI.pop('all', [])
|
|
||||||
albumList = []
|
|
||||||
for album in allReleases:
|
|
||||||
albumList.append(self.generateAlbumQueueItem(dz, album['id'], settings, bitrate, rootArtist=rootArtist))
|
|
||||||
|
|
||||||
if interface: interface.send("finishAddingArtist", {'name': artistAPI['name'], 'id': artistAPI['id']})
|
|
||||||
return albumList
|
|
||||||
|
|
||||||
def generateArtistDiscographyQueueItem(self, dz, id, settings, bitrate, interface=None):
|
|
||||||
# Get essential artist info
|
|
||||||
try:
|
|
||||||
artistAPI = dz.api.get_artist(id)
|
|
||||||
except APIError as e:
|
|
||||||
e = str(e)
|
|
||||||
return QueueError("https://deezer.com/artist/"+str(id)+"/discography", f"Wrong URL: {e}")
|
|
||||||
|
|
||||||
if interface: interface.send("startAddingArtist", {'name': artistAPI['name'], 'id': artistAPI['id']})
|
|
||||||
rootArtist = {
|
|
||||||
'id': artistAPI['id'],
|
|
||||||
'name': artistAPI['name']
|
|
||||||
}
|
|
||||||
|
|
||||||
artistDiscographyAPI = dz.gw.get_artist_discography_tabs(id, 100)
|
|
||||||
artistDiscographyAPI.pop('all', None) # all contains albums and singles, so its all duplicates. This removes them
|
|
||||||
albumList = []
|
|
||||||
for type in artistDiscographyAPI:
|
|
||||||
for album in artistDiscographyAPI[type]:
|
|
||||||
albumList.append(self.generateAlbumQueueItem(dz, album['id'], settings, bitrate, rootArtist=rootArtist))
|
|
||||||
|
|
||||||
if interface: interface.send("finishAddingArtist", {'name': artistAPI['name'], 'id': artistAPI['id']})
|
|
||||||
return albumList
|
|
||||||
|
|
||||||
def generateArtistTopQueueItem(self, dz, id, settings, bitrate, interface=None):
|
|
||||||
# Get essential artist info
|
|
||||||
try:
|
|
||||||
artistAPI = dz.api.get_artist(id)
|
|
||||||
except APIError as e:
|
|
||||||
e = str(e)
|
|
||||||
return QueueError("https://deezer.com/artist/"+str(id)+"/top_track", f"Wrong URL: {e}")
|
|
||||||
|
|
||||||
# Emulate the creation of a playlist
|
|
||||||
# Can't use generatePlaylistQueueItem as this is not a real playlist
|
|
||||||
playlistAPI = {
|
|
||||||
'id': str(artistAPI['id'])+"_top_track",
|
|
||||||
'title': artistAPI['name']+" - Top Tracks",
|
|
||||||
'description': "Top Tracks for "+artistAPI['name'],
|
|
||||||
'duration': 0,
|
|
||||||
'public': True,
|
|
||||||
'is_loved_track': False,
|
|
||||||
'collaborative': False,
|
|
||||||
'nb_tracks': 0,
|
|
||||||
'fans': artistAPI['nb_fan'],
|
|
||||||
'link': "https://www.deezer.com/artist/"+str(artistAPI['id'])+"/top_track",
|
|
||||||
'share': None,
|
|
||||||
'picture': artistAPI['picture'],
|
|
||||||
'picture_small': artistAPI['picture_small'],
|
|
||||||
'picture_medium': artistAPI['picture_medium'],
|
|
||||||
'picture_big': artistAPI['picture_big'],
|
|
||||||
'picture_xl': artistAPI['picture_xl'],
|
|
||||||
'checksum': None,
|
|
||||||
'tracklist': "https://api.deezer.com/artist/"+str(artistAPI['id'])+"/top",
|
|
||||||
'creation_date': "XXXX-00-00",
|
|
||||||
'creator': {
|
|
||||||
'id': "art_"+str(artistAPI['id']),
|
|
||||||
'name': artistAPI['name'],
|
|
||||||
'type': "user"
|
|
||||||
},
|
|
||||||
'type': "playlist"
|
|
||||||
}
|
|
||||||
|
|
||||||
artistTopTracksAPI_gw = dz.gw.get_artist_toptracks(id)
|
|
||||||
playlistAPI['various_artist'] = dz.api.get_artist(5080) # Useful for save as compilation
|
|
||||||
|
|
||||||
totalSize = len(artistTopTracksAPI_gw)
|
|
||||||
playlistAPI['nb_tracks'] = totalSize
|
|
||||||
collection = []
|
|
||||||
for pos, trackAPI in enumerate(artistTopTracksAPI_gw, start=1):
|
|
||||||
if trackAPI.get('EXPLICIT_TRACK_CONTENT', {}).get('EXPLICIT_LYRICS_STATUS', LyricsStatus.UNKNOWN) in [LyricsStatus.EXPLICIT, LyricsStatus.PARTIALLY_EXPLICIT]:
|
|
||||||
playlistAPI['explicit'] = True
|
|
||||||
trackAPI['_EXTRA_PLAYLIST'] = playlistAPI
|
|
||||||
trackAPI['POSITION'] = pos
|
|
||||||
trackAPI['SIZE'] = totalSize
|
|
||||||
trackAPI['FILENAME_TEMPLATE'] = settings['playlistTracknameTemplate']
|
|
||||||
collection.append(trackAPI)
|
|
||||||
if not 'explicit' in playlistAPI:
|
|
||||||
playlistAPI['explicit'] = False
|
|
||||||
|
|
||||||
return QICollection(
|
|
||||||
id=id,
|
|
||||||
bitrate=bitrate,
|
|
||||||
title=playlistAPI['title'],
|
|
||||||
artist=playlistAPI['creator']['name'],
|
|
||||||
cover=playlistAPI['picture_small'][:-24] + '/75x75-000000-80-0-0.jpg',
|
|
||||||
explicit=playlistAPI['explicit'],
|
|
||||||
size=totalSize,
|
|
||||||
type='playlist',
|
|
||||||
settings=settings,
|
|
||||||
collection=collection,
|
|
||||||
)
|
|
||||||
|
|
||||||
def generateQueueItem(self, dz, url, settings, bitrate=None, interface=None):
|
|
||||||
bitrate = getBitrateInt(bitrate) or settings['maxBitrate']
|
|
||||||
if 'deezer.page.link' in url: url = urlopen(url).url
|
|
||||||
if 'link.tospotify.com' in url: url = urlopen(url).url
|
|
||||||
|
|
||||||
type = getTypeFromLink(url)
|
|
||||||
id = getIDFromLink(url, type)
|
|
||||||
if type == None or id == None:
|
|
||||||
logger.warn("URL not recognized")
|
|
||||||
return QueueError(url, "URL not recognized", "invalidURL")
|
|
||||||
|
|
||||||
if type == "track":
|
|
||||||
return self.generateTrackQueueItem(dz, id, settings, bitrate)
|
|
||||||
elif type == "album":
|
|
||||||
return self.generateAlbumQueueItem(dz, id, settings, bitrate)
|
|
||||||
elif type == "playlist":
|
|
||||||
return self.generatePlaylistQueueItem(dz, id, settings, bitrate)
|
|
||||||
elif type == "artist":
|
|
||||||
return self.generateArtistQueueItem(dz, id, settings, bitrate, interface=interface)
|
|
||||||
elif type == "artistdiscography":
|
|
||||||
return self.generateArtistDiscographyQueueItem(dz, id, settings, bitrate, interface=interface)
|
|
||||||
elif type == "artisttop":
|
|
||||||
return self.generateArtistTopQueueItem(dz, id, settings, bitrate, interface=interface)
|
|
||||||
elif type.startswith("spotify") and self.sp:
|
|
||||||
if not self.sp.spotifyEnabled:
|
|
||||||
logger.warn("Spotify Features is not setted up correctly.")
|
|
||||||
return QueueError(url, "Spotify Features is not setted up correctly.", "spotifyDisabled")
|
|
||||||
|
|
||||||
if type == "spotifytrack":
|
|
||||||
try:
|
|
||||||
(track_id, trackAPI, _) = self.sp.get_trackid_spotify(dz, id, settings['fallbackSearch'])
|
|
||||||
except SpotifyException as e:
|
|
||||||
return QueueError(url, "Wrong URL: "+e.msg[e.msg.find('\n')+2:])
|
|
||||||
except Exception as e:
|
|
||||||
return QueueError(url, "Something went wrong: "+str(e))
|
|
||||||
|
|
||||||
if track_id != "0":
|
|
||||||
return self.generateTrackQueueItem(dz, track_id, settings, bitrate, trackAPI=trackAPI)
|
|
||||||
else:
|
|
||||||
logger.warn("Track not found on deezer!")
|
|
||||||
return QueueError(url, "Track not found on deezer!", "trackNotOnDeezer")
|
|
||||||
|
|
||||||
elif type == "spotifyalbum":
|
|
||||||
try:
|
|
||||||
album_id = self.sp.get_albumid_spotify(dz, id)
|
|
||||||
except SpotifyException as e:
|
|
||||||
return QueueError(url, "Wrong URL: "+e.msg[e.msg.find('\n')+2:])
|
|
||||||
except Exception as e:
|
|
||||||
return QueueError(url, "Something went wrong: "+str(e))
|
|
||||||
|
|
||||||
if album_id != "0":
|
|
||||||
return self.generateAlbumQueueItem(dz, album_id, settings, bitrate)
|
|
||||||
else:
|
|
||||||
logger.warn("Album not found on deezer!")
|
|
||||||
return QueueError(url, "Album not found on deezer!", "albumNotOnDeezer")
|
|
||||||
|
|
||||||
elif type == "spotifyplaylist":
|
|
||||||
try:
|
|
||||||
return self.sp.generate_playlist_queueitem(dz, id, bitrate, settings)
|
|
||||||
except SpotifyException as e:
|
|
||||||
return QueueError(url, "Wrong URL: "+e.msg[e.msg.find('\n')+2:])
|
|
||||||
except Exception as e:
|
|
||||||
return QueueError(url, "Something went wrong: "+str(e))
|
|
||||||
logger.warn("URL not supported yet")
|
|
||||||
return QueueError(url, "URL not supported yet", "unsupportedURL")
|
|
||||||
|
|
||||||
def addToQueue(self, dz, url, settings, bitrate=None, interface=None, ack=None):
|
|
||||||
if not dz.logged_in:
|
|
||||||
if interface: interface.send("loginNeededToDownload")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def parseLink(link):
|
|
||||||
link = link.strip()
|
|
||||||
if link == "": return False
|
|
||||||
logger.info("Generating queue item for: "+link)
|
|
||||||
item = self.generateQueueItem(dz, link, settings, bitrate, interface=interface)
|
|
||||||
|
|
||||||
# Add ack to all items
|
|
||||||
if type(item) is list:
|
|
||||||
for i in item:
|
|
||||||
if isinstance(i, QueueItem):
|
|
||||||
i.ack = ack
|
|
||||||
elif isinstance(item, QueueItem):
|
|
||||||
item.ack = ack
|
|
||||||
return item
|
|
||||||
|
|
||||||
if type(url) is list:
|
|
||||||
queueItem = []
|
|
||||||
request_uuid = str(uuid.uuid4())
|
|
||||||
if interface: interface.send("startGeneratingItems", {'uuid': request_uuid, 'total': len(url)})
|
|
||||||
for link in url:
|
|
||||||
item = parseLink(link)
|
|
||||||
if not item: continue
|
|
||||||
if type(item) is list:
|
|
||||||
queueItem += item
|
|
||||||
else:
|
|
||||||
queueItem.append(item)
|
|
||||||
if interface: interface.send("finishGeneratingItems", {'uuid': request_uuid, 'total': len(queueItem)})
|
|
||||||
if not len(queueItem):
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
queueItem = parseLink(url)
|
|
||||||
if not queueItem:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def processQueueItem(item, silent=False):
|
|
||||||
if isinstance(item, QueueError):
|
|
||||||
logger.error(f"[{item.link}] {item.message}")
|
|
||||||
if interface: interface.send("queueError", item.toDict())
|
|
||||||
return False
|
|
||||||
if item.uuid in list(self.queueList.keys()):
|
|
||||||
logger.warn(f"[{item.uuid}] Already in queue, will not be added again.")
|
|
||||||
if interface and not silent: interface.send("alreadyInQueue", {'uuid': item.uuid, 'title': item.title})
|
|
||||||
return False
|
|
||||||
self.queue.append(item.uuid)
|
|
||||||
self.queueList[item.uuid] = item
|
|
||||||
logger.info(f"[{item.uuid}] Added to queue.")
|
|
||||||
return True
|
|
||||||
|
|
||||||
if type(queueItem) is list:
|
|
||||||
slimmedItems = []
|
|
||||||
for item in queueItem:
|
|
||||||
if processQueueItem(item, silent=True):
|
|
||||||
slimmedItems.append(item.getSlimmedItem())
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
if not len(slimmedItems):
|
|
||||||
return False
|
|
||||||
if interface: interface.send("addedToQueue", slimmedItems)
|
|
||||||
else:
|
|
||||||
if processQueueItem(queueItem):
|
|
||||||
if interface: interface.send("addedToQueue", queueItem.getSlimmedItem())
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
self.nextItem(dz, interface)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def nextItem(self, dz, interface=None):
|
|
||||||
# Check that nothing is already downloading and
|
|
||||||
# that the queue is not empty
|
|
||||||
if self.currentItem != "": return None
|
|
||||||
if not len(self.queue): return None
|
|
||||||
|
|
||||||
self.currentItem = self.queue.pop(0)
|
|
||||||
|
|
||||||
if isinstance(self.queueList[self.currentItem], QIConvertable) and self.queueList[self.currentItem].extra:
|
|
||||||
logger.info(f"[{self.currentItem}] Converting tracks to deezer.")
|
|
||||||
self.sp.convert_spotify_playlist(dz, self.queueList[self.currentItem], interface=interface)
|
|
||||||
logger.info(f"[{self.currentItem}] Tracks converted.")
|
|
||||||
|
|
||||||
if interface: interface.send("startDownload", self.currentItem)
|
|
||||||
logger.info(f"[{self.currentItem}] Started downloading.")
|
|
||||||
|
|
||||||
DownloadJob(dz, self.queueList[self.currentItem], interface).start()
|
|
||||||
|
|
||||||
if self.queueList[self.currentItem].cancel:
|
|
||||||
del self.queueList[self.currentItem]
|
|
||||||
else:
|
|
||||||
self.queueComplete.append(self.currentItem)
|
|
||||||
logger.info(f"[{self.currentItem}] Finished downloading.")
|
|
||||||
self.currentItem = ""
|
|
||||||
self.nextItem(dz, interface)
|
|
||||||
|
|
||||||
def getQueue(self):
|
|
||||||
return (self.queue, self.queueComplete, self.slimQueueList(), self.currentItem)
|
|
||||||
|
|
||||||
def saveQueue(self, configFolder):
|
|
||||||
if len(self.queueList) > 0:
|
|
||||||
if self.currentItem != "":
|
|
||||||
self.queue.insert(0, self.currentItem)
|
|
||||||
with open(Path(configFolder) / 'queue.json', 'w') as f:
|
|
||||||
json.dump({
|
|
||||||
'queue': self.queue,
|
|
||||||
'queueComplete': self.queueComplete,
|
|
||||||
'queueList': self.exportQueueList()
|
|
||||||
}, f)
|
|
||||||
|
|
||||||
def exportQueueList(self):
|
|
||||||
queueList = {}
|
|
||||||
for uuid in self.queueList:
|
|
||||||
if uuid in self.queue:
|
|
||||||
queueList[uuid] = self.queueList[uuid].getResettedItem()
|
|
||||||
else:
|
|
||||||
queueList[uuid] = self.queueList[uuid].toDict()
|
|
||||||
return queueList
|
|
||||||
|
|
||||||
def slimQueueList(self):
|
|
||||||
queueList = {}
|
|
||||||
for uuid in self.queueList:
|
|
||||||
queueList[uuid] = self.queueList[uuid].getSlimmedItem()
|
|
||||||
return queueList
|
|
||||||
|
|
||||||
def loadQueue(self, configFolder, settings, interface=None):
|
|
||||||
configFolder = Path(configFolder)
|
|
||||||
if (configFolder / 'queue.json').is_file() and not len(self.queue):
|
|
||||||
if interface: interface.send('restoringQueue')
|
|
||||||
with open(configFolder / 'queue.json', 'r') as f:
|
|
||||||
try:
|
|
||||||
qd = json.load(f)
|
|
||||||
except json.decoder.JSONDecodeError:
|
|
||||||
logger.warn("Saved queue is corrupted, resetting it")
|
|
||||||
qd = {
|
|
||||||
'queue': [],
|
|
||||||
'queueComplete': [],
|
|
||||||
'queueList': {}
|
|
||||||
}
|
|
||||||
remove(configFolder / 'queue.json')
|
|
||||||
self.restoreQueue(qd['queue'], qd['queueComplete'], qd['queueList'], settings)
|
|
||||||
if interface:
|
|
||||||
interface.send('init_downloadQueue', {
|
|
||||||
'queue': self.queue,
|
|
||||||
'queueComplete': self.queueComplete,
|
|
||||||
'queueList': self.slimQueueList(),
|
|
||||||
'restored': True
|
|
||||||
})
|
|
||||||
|
|
||||||
def restoreQueue(self, queue, queueComplete, queueList, settings):
|
|
||||||
self.queue = queue
|
|
||||||
self.queueComplete = queueComplete
|
|
||||||
self.queueList = {}
|
|
||||||
for uuid in queueList:
|
|
||||||
if 'single' in queueList[uuid]:
|
|
||||||
self.queueList[uuid] = QISingle(queueItemDict = queueList[uuid])
|
|
||||||
if 'collection' in queueList[uuid]:
|
|
||||||
self.queueList[uuid] = QICollection(queueItemDict = queueList[uuid])
|
|
||||||
if '_EXTRA' in queueList[uuid]:
|
|
||||||
self.queueList[uuid] = QIConvertable(queueItemDict = queueList[uuid])
|
|
||||||
self.queueList[uuid].settings = settings
|
|
||||||
|
|
||||||
def removeFromQueue(self, uuid, interface=None):
|
|
||||||
if uuid == self.currentItem:
|
|
||||||
if interface: interface.send("cancellingCurrentItem", uuid)
|
|
||||||
self.queueList[uuid].cancel = True
|
|
||||||
return
|
|
||||||
if uuid in self.queue:
|
|
||||||
self.queue.remove(uuid)
|
|
||||||
elif uuid in self.queueComplete:
|
|
||||||
self.queueComplete.remove(uuid)
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
del self.queueList[uuid]
|
|
||||||
if interface: interface.send("removedFromQueue", uuid)
|
|
||||||
|
|
||||||
|
|
||||||
def cancelAllDownloads(self, interface=None):
|
|
||||||
self.queue = []
|
|
||||||
self.queueComplete = []
|
|
||||||
if self.currentItem != "":
|
|
||||||
if interface: interface.send("cancellingCurrentItem", self.currentItem)
|
|
||||||
self.queueList[self.currentItem].cancel = True
|
|
||||||
for uuid in list(self.queueList.keys()):
|
|
||||||
if uuid != self.currentItem: del self.queueList[uuid]
|
|
||||||
if interface: interface.send("removedAllDownloads", self.currentItem)
|
|
||||||
|
|
||||||
|
|
||||||
def removeFinishedDownloads(self, interface=None):
|
|
||||||
for uuid in self.queueComplete:
|
|
||||||
del self.queueList[uuid]
|
|
||||||
self.queueComplete = []
|
|
||||||
if interface: interface.send("removedFinishedDownloads")
|
|
||||||
|
|
||||||
class QueueError:
|
|
||||||
def __init__(self, link, message, errid=None):
|
|
||||||
self.link = link
|
|
||||||
self.message = message
|
|
||||||
self.errid = errid
|
|
||||||
|
|
||||||
def toDict(self):
|
|
||||||
return {
|
|
||||||
'link': self.link,
|
|
||||||
'error': self.message,
|
|
||||||
'errid': self.errid
|
|
||||||
}
|
|
|
@ -1,220 +0,0 @@
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from os import makedirs, listdir
|
|
||||||
from deemix import __version__ as deemixVersion
|
|
||||||
from deezer import TrackFormats
|
|
||||||
from deemix.utils import checkFolder
|
|
||||||
import logging
|
|
||||||
import datetime
|
|
||||||
import platform
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logger = logging.getLogger('deemix')
|
|
||||||
|
|
||||||
import deemix.utils.localpaths as localpaths
|
|
||||||
|
|
||||||
class OverwriteOption():
|
|
||||||
"""Should the lib overwrite files?"""
|
|
||||||
|
|
||||||
OVERWRITE = 'y'
|
|
||||||
"""Yes, overwrite the file"""
|
|
||||||
|
|
||||||
DONT_OVERWRITE = 'n'
|
|
||||||
"""No, don't overwrite the file"""
|
|
||||||
|
|
||||||
DONT_CHECK_EXT = 'e'
|
|
||||||
"""No, and don't check for extensions"""
|
|
||||||
|
|
||||||
KEEP_BOTH = 'b'
|
|
||||||
"""No, and keep both files"""
|
|
||||||
|
|
||||||
ONLY_TAGS = 't'
|
|
||||||
"""Overwrite only the tags"""
|
|
||||||
|
|
||||||
class FeaturesOption():
|
|
||||||
"""What should I do with featured artists?"""
|
|
||||||
|
|
||||||
NO_CHANGE = "0"
|
|
||||||
"""Do nothing"""
|
|
||||||
|
|
||||||
REMOVE_TITLE = "1"
|
|
||||||
"""Remove from track title"""
|
|
||||||
|
|
||||||
REMOVE_TITLE_ALBUM = "3"
|
|
||||||
"""Remove from track title and album title"""
|
|
||||||
|
|
||||||
MOVE_TITLE = "2"
|
|
||||||
"""Move to track title"""
|
|
||||||
|
|
||||||
DEFAULT_SETTINGS = {
|
|
||||||
"downloadLocation": str(localpaths.getMusicFolder()),
|
|
||||||
"tracknameTemplate": "%artist% - %title%",
|
|
||||||
"albumTracknameTemplate": "%tracknumber% - %title%",
|
|
||||||
"playlistTracknameTemplate": "%position% - %artist% - %title%",
|
|
||||||
"createPlaylistFolder": True,
|
|
||||||
"playlistNameTemplate": "%playlist%",
|
|
||||||
"createArtistFolder": False,
|
|
||||||
"artistNameTemplate": "%artist%",
|
|
||||||
"createAlbumFolder": True,
|
|
||||||
"albumNameTemplate": "%artist% - %album%",
|
|
||||||
"createCDFolder": True,
|
|
||||||
"createStructurePlaylist": False,
|
|
||||||
"createSingleFolder": False,
|
|
||||||
"padTracks": True,
|
|
||||||
"paddingSize": "0",
|
|
||||||
"illegalCharacterReplacer": "_",
|
|
||||||
"queueConcurrency": 3,
|
|
||||||
"maxBitrate": str(TrackFormats.MP3_320),
|
|
||||||
"fallbackBitrate": True,
|
|
||||||
"fallbackSearch": False,
|
|
||||||
"logErrors": True,
|
|
||||||
"logSearched": False,
|
|
||||||
"saveDownloadQueue": False,
|
|
||||||
"overwriteFile": OverwriteOption.DONT_OVERWRITE,
|
|
||||||
"createM3U8File": False,
|
|
||||||
"playlistFilenameTemplate": "playlist",
|
|
||||||
"syncedLyrics": False,
|
|
||||||
"embeddedArtworkSize": 800,
|
|
||||||
"embeddedArtworkPNG": False,
|
|
||||||
"localArtworkSize": 1400,
|
|
||||||
"localArtworkFormat": "jpg",
|
|
||||||
"saveArtwork": True,
|
|
||||||
"coverImageTemplate": "cover",
|
|
||||||
"saveArtworkArtist": False,
|
|
||||||
"artistImageTemplate": "folder",
|
|
||||||
"jpegImageQuality": 80,
|
|
||||||
"dateFormat": "Y-M-D",
|
|
||||||
"albumVariousArtists": True,
|
|
||||||
"removeAlbumVersion": False,
|
|
||||||
"removeDuplicateArtists": False,
|
|
||||||
"tagsLanguage": "",
|
|
||||||
"featuredToTitle": FeaturesOption.NO_CHANGE,
|
|
||||||
"titleCasing": "nothing",
|
|
||||||
"artistCasing": "nothing",
|
|
||||||
"executeCommand": "",
|
|
||||||
"tags": {
|
|
||||||
"title": True,
|
|
||||||
"artist": True,
|
|
||||||
"album": True,
|
|
||||||
"cover": True,
|
|
||||||
"trackNumber": True,
|
|
||||||
"trackTotal": False,
|
|
||||||
"discNumber": True,
|
|
||||||
"discTotal": False,
|
|
||||||
"albumArtist": True,
|
|
||||||
"genre": True,
|
|
||||||
"year": True,
|
|
||||||
"date": True,
|
|
||||||
"explicit": False,
|
|
||||||
"isrc": True,
|
|
||||||
"length": True,
|
|
||||||
"barcode": True,
|
|
||||||
"bpm": True,
|
|
||||||
"replayGain": False,
|
|
||||||
"label": True,
|
|
||||||
"lyrics": False,
|
|
||||||
"syncedLyrics": False,
|
|
||||||
"copyright": False,
|
|
||||||
"composer": False,
|
|
||||||
"involvedPeople": False,
|
|
||||||
"source": False,
|
|
||||||
"savePlaylistAsCompilation": False,
|
|
||||||
"useNullSeparator": False,
|
|
||||||
"saveID3v1": True,
|
|
||||||
"multiArtistSeparator": "default",
|
|
||||||
"singleAlbumArtist": False,
|
|
||||||
"coverDescriptionUTF8": False
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Settings:
|
|
||||||
def __init__(self, configFolder=None, overwriteDownloadFolder=None):
|
|
||||||
self.settings = {}
|
|
||||||
self.configFolder = Path(configFolder or localpaths.getConfigFolder())
|
|
||||||
|
|
||||||
# Create config folder if it doesn't exsist
|
|
||||||
makedirs(self.configFolder, exist_ok=True)
|
|
||||||
|
|
||||||
# Create config file if it doesn't exsist
|
|
||||||
if not (self.configFolder / 'config.json').is_file():
|
|
||||||
with open(self.configFolder / 'config.json', 'w') as f:
|
|
||||||
json.dump(DEFAULT_SETTINGS, f, indent=2)
|
|
||||||
|
|
||||||
# Read config file
|
|
||||||
with open(self.configFolder / 'config.json', 'r') as configFile:
|
|
||||||
self.settings = json.load(configFile)
|
|
||||||
|
|
||||||
# Check for overwriteDownloadFolder
|
|
||||||
# This prevents the creation of the original download folder when
|
|
||||||
# using overwriteDownloadFolder
|
|
||||||
originalDownloadFolder = self.settings['downloadLocation']
|
|
||||||
if overwriteDownloadFolder:
|
|
||||||
overwriteDownloadFolder = str(overwriteDownloadFolder)
|
|
||||||
self.settings['downloadLocation'] = overwriteDownloadFolder
|
|
||||||
|
|
||||||
# Make sure the download path exsits, fallback to default
|
|
||||||
invalidDownloadFolder = False
|
|
||||||
if self.settings['downloadLocation'] == "" or not checkFolder(self.settings['downloadLocation']):
|
|
||||||
self.settings['downloadLocation'] = DEFAULT_SETTINGS['downloadLocation']
|
|
||||||
originalDownloadFolder = self.settings['downloadLocation']
|
|
||||||
invalidDownloadFolder = True
|
|
||||||
|
|
||||||
# Check the settings and save them if something changed
|
|
||||||
if self.settingsCheck() > 0 or invalidDownloadFolder:
|
|
||||||
makedirs(self.settings['downloadLocation'], exist_ok=True)
|
|
||||||
self.settings['downloadLocation'] = originalDownloadFolder # Prevents the saving of the overwritten path
|
|
||||||
self.saveSettings()
|
|
||||||
self.settings['downloadLocation'] = overwriteDownloadFolder or originalDownloadFolder # Restores the correct path
|
|
||||||
|
|
||||||
# LOGFILES
|
|
||||||
|
|
||||||
# Create logfile name and path
|
|
||||||
logspath = self.configFolder / 'logs'
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
logfile = now.strftime("%Y-%m-%d_%H%M%S")+".log"
|
|
||||||
makedirs(logspath, exist_ok=True)
|
|
||||||
|
|
||||||
# Add handler for logging
|
|
||||||
fh = logging.FileHandler(logspath / logfile, 'w', 'utf-8')
|
|
||||||
fh.setLevel(logging.DEBUG)
|
|
||||||
fh.setFormatter(logging.Formatter('%(asctime)s - [%(levelname)s] %(message)s'))
|
|
||||||
logger.addHandler(fh)
|
|
||||||
logger.info(f"{platform.platform(True, True)} - Python {platform.python_version()}, deemix {deemixVersion}")
|
|
||||||
|
|
||||||
# Only keep last 5 logfiles (to preserve disk space)
|
|
||||||
logslist = listdir(logspath)
|
|
||||||
logslist.sort()
|
|
||||||
if len(logslist)>5:
|
|
||||||
for i in range(len(logslist)-5):
|
|
||||||
(logspath / logslist[i]).unlink()
|
|
||||||
|
|
||||||
# Saves the settings
|
|
||||||
def saveSettings(self, newSettings=None, dz=None):
|
|
||||||
if newSettings:
|
|
||||||
if dz and newSettings.get('tagsLanguage') != self.settings.get('tagsLanguage'): dz.set_accept_language(newSettings.get('tagsLanguage'))
|
|
||||||
if newSettings.get('downloadLocation') != self.settings.get('downloadLocation') and not checkFolder(newSettings.get('downloadLocation')):
|
|
||||||
newSettings['downloadLocation'] = DEFAULT_SETTINGS['downloadLocation']
|
|
||||||
makedirs(newSettings['downloadLocation'], exist_ok=True)
|
|
||||||
self.settings = newSettings
|
|
||||||
with open(self.configFolder / 'config.json', 'w') as configFile:
|
|
||||||
json.dump(self.settings, configFile, indent=2)
|
|
||||||
|
|
||||||
# Checks if the default settings have changed
|
|
||||||
def settingsCheck(self):
|
|
||||||
changes = 0
|
|
||||||
for set in DEFAULT_SETTINGS:
|
|
||||||
if not set in self.settings or type(self.settings[set]) != type(DEFAULT_SETTINGS[set]):
|
|
||||||
self.settings[set] = DEFAULT_SETTINGS[set]
|
|
||||||
changes += 1
|
|
||||||
for set in DEFAULT_SETTINGS['tags']:
|
|
||||||
if not set in self.settings['tags'] or type(self.settings['tags'][set]) != type(DEFAULT_SETTINGS['tags'][set]):
|
|
||||||
self.settings['tags'][set] = DEFAULT_SETTINGS['tags'][set]
|
|
||||||
changes += 1
|
|
||||||
if self.settings['downloadLocation'] == "":
|
|
||||||
self.settings['downloadLocation'] = DEFAULT_SETTINGS['downloadLocation']
|
|
||||||
changes += 1
|
|
||||||
for template in ['tracknameTemplate', 'albumTracknameTemplate', 'playlistTracknameTemplate', 'playlistNameTemplate', 'artistNameTemplate', 'albumNameTemplate', 'playlistFilenameTemplate', 'coverImageTemplate', 'artistImageTemplate', 'paddingSize']:
|
|
||||||
if self.settings[template] == "":
|
|
||||||
self.settings[template] = DEFAULT_SETTINGS[template]
|
|
||||||
changes += 1
|
|
||||||
return changes
|
|
|
@ -1,349 +0,0 @@
|
||||||
import eventlet
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
eventlet.import_patched('requests.adapters')
|
|
||||||
|
|
||||||
spotipy = eventlet.import_patched('spotipy')
|
|
||||||
SpotifyClientCredentials = spotipy.oauth2.SpotifyClientCredentials
|
|
||||||
from deemix.utils.localpaths import getConfigFolder
|
|
||||||
from deemix.app.queueitem import QIConvertable
|
|
||||||
|
|
||||||
emptyPlaylist = {
|
|
||||||
'collaborative': False,
|
|
||||||
'description': "",
|
|
||||||
'external_urls': {'spotify': None},
|
|
||||||
'followers': {'total': 0, 'href': None},
|
|
||||||
'id': None,
|
|
||||||
'images': [],
|
|
||||||
'name': "Something went wrong",
|
|
||||||
'owner': {
|
|
||||||
'display_name': "Error",
|
|
||||||
'id': None
|
|
||||||
},
|
|
||||||
'public': True,
|
|
||||||
'tracks' : [],
|
|
||||||
'type': 'playlist',
|
|
||||||
'uri': None
|
|
||||||
}
|
|
||||||
|
|
||||||
class SpotifyHelper:
|
|
||||||
def __init__(self, configFolder=None):
|
|
||||||
self.credentials = {}
|
|
||||||
self.spotifyEnabled = False
|
|
||||||
self.sp = None
|
|
||||||
self.configFolder = configFolder
|
|
||||||
|
|
||||||
# Make sure config folder exists
|
|
||||||
if not self.configFolder:
|
|
||||||
self.configFolder = getConfigFolder()
|
|
||||||
self.configFolder = Path(self.configFolder)
|
|
||||||
if not self.configFolder.is_dir():
|
|
||||||
self.configFolder.mkdir()
|
|
||||||
|
|
||||||
# Make sure authCredentials exsits
|
|
||||||
if not (self.configFolder / 'authCredentials.json').is_file():
|
|
||||||
with open(self.configFolder / 'authCredentials.json', 'w') as f:
|
|
||||||
json.dump({'clientId': "", 'clientSecret': ""}, f, indent=2)
|
|
||||||
|
|
||||||
# Load spotify id and secret and check if they are usable
|
|
||||||
with open(self.configFolder / 'authCredentials.json', 'r') as credentialsFile:
|
|
||||||
self.credentials = json.load(credentialsFile)
|
|
||||||
self.checkCredentials()
|
|
||||||
self.checkValidCache()
|
|
||||||
|
|
||||||
def checkValidCache(self):
|
|
||||||
if (self.configFolder / 'spotifyCache.json').is_file():
|
|
||||||
with open(self.configFolder / 'spotifyCache.json', 'r') as spotifyCache:
|
|
||||||
try:
|
|
||||||
cache = json.load(spotifyCache)
|
|
||||||
except Exception as e:
|
|
||||||
print(str(e))
|
|
||||||
(self.configFolder / 'spotifyCache.json').unlink()
|
|
||||||
return
|
|
||||||
# Remove old versions of cache
|
|
||||||
if len(cache['tracks'].values()) and isinstance(list(cache['tracks'].values())[0], int) or \
|
|
||||||
len(cache['albums'].values()) and isinstance(list(cache['albums'].values())[0], int):
|
|
||||||
(self.configFolder / 'spotifyCache.json').unlink()
|
|
||||||
|
|
||||||
def checkCredentials(self):
|
|
||||||
if self.credentials['clientId'] == "" or self.credentials['clientSecret'] == "":
|
|
||||||
spotifyEnabled = False
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
client_credentials_manager = SpotifyClientCredentials(client_id=self.credentials['clientId'],
|
|
||||||
client_secret=self.credentials['clientSecret'])
|
|
||||||
self.sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager)
|
|
||||||
self.sp.user_playlists('spotify')
|
|
||||||
self.spotifyEnabled = True
|
|
||||||
except Exception as e:
|
|
||||||
self.spotifyEnabled = False
|
|
||||||
return self.spotifyEnabled
|
|
||||||
|
|
||||||
def getCredentials(self):
|
|
||||||
return self.credentials
|
|
||||||
|
|
||||||
def setCredentials(self, spotifyCredentials):
|
|
||||||
# Remove extra spaces, just to be sure
|
|
||||||
spotifyCredentials['clientId'] = spotifyCredentials['clientId'].strip()
|
|
||||||
spotifyCredentials['clientSecret'] = spotifyCredentials['clientSecret'].strip()
|
|
||||||
|
|
||||||
# Save them to disk
|
|
||||||
with open(self.configFolder / 'authCredentials.json', 'w') as f:
|
|
||||||
json.dump(spotifyCredentials, f, indent=2)
|
|
||||||
|
|
||||||
# Check if they are usable
|
|
||||||
self.credentials = spotifyCredentials
|
|
||||||
self.checkCredentials()
|
|
||||||
|
|
||||||
# Converts spotify API playlist structure to deezer's playlist structure
|
|
||||||
def _convert_playlist_structure(self, spotify_obj):
|
|
||||||
if len(spotify_obj['images']):
|
|
||||||
url = spotify_obj['images'][0]['url']
|
|
||||||
else:
|
|
||||||
url = False
|
|
||||||
deezer_obj = {
|
|
||||||
'checksum': spotify_obj['snapshot_id'],
|
|
||||||
'collaborative': spotify_obj['collaborative'],
|
|
||||||
'creation_date': "XXXX-00-00",
|
|
||||||
'creator': {
|
|
||||||
'id': spotify_obj['owner']['id'],
|
|
||||||
'name': spotify_obj['owner']['display_name'],
|
|
||||||
'tracklist': spotify_obj['owner']['href'],
|
|
||||||
'type': "user"
|
|
||||||
},
|
|
||||||
'description': spotify_obj['description'],
|
|
||||||
'duration': 0,
|
|
||||||
'fans': spotify_obj['followers']['total'] if 'followers' in spotify_obj else 0,
|
|
||||||
'id': spotify_obj['id'],
|
|
||||||
'is_loved_track': False,
|
|
||||||
'link': spotify_obj['external_urls']['spotify'],
|
|
||||||
'nb_tracks': spotify_obj['tracks']['total'],
|
|
||||||
'picture': url,
|
|
||||||
'picture_small': url,
|
|
||||||
'picture_medium': url,
|
|
||||||
'picture_big': url,
|
|
||||||
'picture_xl': url,
|
|
||||||
'public': spotify_obj['public'],
|
|
||||||
'share': spotify_obj['external_urls']['spotify'],
|
|
||||||
'title': spotify_obj['name'],
|
|
||||||
'tracklist': spotify_obj['tracks']['href'],
|
|
||||||
'type': "playlist"
|
|
||||||
}
|
|
||||||
if not url:
|
|
||||||
deezer_obj['picture_small'] = "https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/56x56-000000-80-0-0.jpg"
|
|
||||||
deezer_obj['picture_medium'] = "https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/250x250-000000-80-0-0.jpg"
|
|
||||||
deezer_obj['picture_big'] = "https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/500x500-000000-80-0-0.jpg"
|
|
||||||
deezer_obj['picture_xl'] = "https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/1000x1000-000000-80-0-0.jpg"
|
|
||||||
return deezer_obj
|
|
||||||
|
|
||||||
# Returns deezer song_id from spotify track_id or track dict
|
|
||||||
def get_trackid_spotify(self, dz, track_id, fallbackSearch, spotifyTrack=None):
|
|
||||||
if not self.spotifyEnabled:
|
|
||||||
raise spotifyFeaturesNotEnabled
|
|
||||||
singleTrack = False
|
|
||||||
if not spotifyTrack:
|
|
||||||
if (self.configFolder / 'spotifyCache.json').is_file():
|
|
||||||
with open(self.configFolder / 'spotifyCache.json', 'r') as spotifyCache:
|
|
||||||
cache = json.load(spotifyCache)
|
|
||||||
else:
|
|
||||||
cache = {'tracks': {}, 'albums': {}}
|
|
||||||
if str(track_id) in cache['tracks']:
|
|
||||||
dz_track = None
|
|
||||||
if cache['tracks'][str(track_id)]['isrc']:
|
|
||||||
dz_track = dz.api.get_track_by_ISRC(cache['tracks'][str(track_id)]['isrc'])
|
|
||||||
dz_id = dz_track['id'] if 'id' in dz_track and 'title' in dz_track else "0"
|
|
||||||
cache['tracks'][str(track_id)]['id'] = dz_id
|
|
||||||
return (cache['tracks'][str(track_id)]['id'], dz_track, cache['tracks'][str(track_id)]['isrc'])
|
|
||||||
singleTrack = True
|
|
||||||
spotify_track = self.sp.track(track_id)
|
|
||||||
else:
|
|
||||||
spotify_track = spotifyTrack
|
|
||||||
dz_id = "0"
|
|
||||||
dz_track = None
|
|
||||||
isrc = None
|
|
||||||
if 'external_ids' in spotify_track and 'isrc' in spotify_track['external_ids']:
|
|
||||||
try:
|
|
||||||
dz_track = dz.api.get_track_by_ISRC(spotify_track['external_ids']['isrc'])
|
|
||||||
dz_id = dz_track['id'] if 'id' in dz_track and 'title' in dz_track else "0"
|
|
||||||
isrc = spotify_track['external_ids']['isrc']
|
|
||||||
except:
|
|
||||||
dz_id = dz.api.get_track_id_from_metadata(
|
|
||||||
artist=spotify_track['artists'][0]['name'],
|
|
||||||
track=spotify_track['name'],
|
|
||||||
album=spotify_track['album']['name']
|
|
||||||
) if fallbackSearch else "0"
|
|
||||||
elif fallbackSearch:
|
|
||||||
dz_id = dz.api.get_track_id_from_metadata(
|
|
||||||
artist=spotify_track['artists'][0]['name'],
|
|
||||||
track=spotify_track['name'],
|
|
||||||
album=spotify_track['album']['name']
|
|
||||||
)
|
|
||||||
if singleTrack:
|
|
||||||
cache['tracks'][str(track_id)] = {'id': dz_id, 'isrc': isrc}
|
|
||||||
with open(self.configFolder / 'spotifyCache.json', 'w') as spotifyCache:
|
|
||||||
json.dump(cache, spotifyCache)
|
|
||||||
return (dz_id, dz_track, isrc)
|
|
||||||
|
|
||||||
# Returns deezer album_id from spotify album_id
|
|
||||||
def get_albumid_spotify(self, dz, album_id):
|
|
||||||
if not self.spotifyEnabled:
|
|
||||||
raise spotifyFeaturesNotEnabled
|
|
||||||
if (self.configFolder / 'spotifyCache.json').is_file():
|
|
||||||
with open(self.configFolder / 'spotifyCache.json', 'r') as spotifyCache:
|
|
||||||
cache = json.load(spotifyCache)
|
|
||||||
else:
|
|
||||||
cache = {'tracks': {}, 'albums': {}}
|
|
||||||
if str(album_id) in cache['albums']:
|
|
||||||
return cache['albums'][str(album_id)]['id']
|
|
||||||
spotify_album = self.sp.album(album_id)
|
|
||||||
dz_album = "0"
|
|
||||||
upc = None
|
|
||||||
if 'external_ids' in spotify_album and 'upc' in spotify_album['external_ids']:
|
|
||||||
try:
|
|
||||||
dz_album = dz.api.get_album_by_UPC(spotify_album['external_ids']['upc'])
|
|
||||||
dz_album = dz_album['id'] if 'id' in dz_album else "0"
|
|
||||||
upc = spotify_album['external_ids']['upc']
|
|
||||||
except:
|
|
||||||
try:
|
|
||||||
dz_album = dz.api.get_album_by_UPC(int(spotify_album['external_ids']['upc']))
|
|
||||||
dz_album = dz_album['id'] if 'id' in dz_album else "0"
|
|
||||||
except:
|
|
||||||
dz_album = "0"
|
|
||||||
cache['albums'][str(album_id)] = {'id': dz_album, 'upc': upc}
|
|
||||||
with open(self.configFolder / 'spotifyCache.json', 'w') as spotifyCache:
|
|
||||||
json.dump(cache, spotifyCache)
|
|
||||||
return dz_album
|
|
||||||
|
|
||||||
|
|
||||||
def generate_playlist_queueitem(self, dz, playlist_id, bitrate, settings):
|
|
||||||
if not self.spotifyEnabled:
|
|
||||||
raise spotifyFeaturesNotEnabled
|
|
||||||
spotify_playlist = self.sp.playlist(playlist_id)
|
|
||||||
|
|
||||||
if len(spotify_playlist['images']):
|
|
||||||
cover = spotify_playlist['images'][0]['url']
|
|
||||||
else:
|
|
||||||
cover = "https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/75x75-000000-80-0-0.jpg"
|
|
||||||
|
|
||||||
playlistAPI = self._convert_playlist_structure(spotify_playlist)
|
|
||||||
playlistAPI['various_artist'] = dz.api.get_artist(5080)
|
|
||||||
|
|
||||||
extra = {}
|
|
||||||
extra['unconverted'] = []
|
|
||||||
|
|
||||||
tracklistTmp = spotify_playlist['tracks']['items']
|
|
||||||
while spotify_playlist['tracks']['next']:
|
|
||||||
spotify_playlist['tracks'] = self.sp.next(spotify_playlist['tracks'])
|
|
||||||
tracklistTmp += spotify_playlist['tracks']['items']
|
|
||||||
for item in tracklistTmp:
|
|
||||||
if item['track']:
|
|
||||||
if item['track']['explicit']:
|
|
||||||
playlistAPI['explicit'] = True
|
|
||||||
extra['unconverted'].append(item['track'])
|
|
||||||
|
|
||||||
totalSize = len(extra['unconverted'])
|
|
||||||
if not 'explicit' in playlistAPI:
|
|
||||||
playlistAPI['explicit'] = False
|
|
||||||
extra['playlistAPI'] = playlistAPI
|
|
||||||
return QIConvertable(
|
|
||||||
playlist_id,
|
|
||||||
bitrate,
|
|
||||||
spotify_playlist['name'],
|
|
||||||
spotify_playlist['owner']['display_name'],
|
|
||||||
cover,
|
|
||||||
playlistAPI['explicit'],
|
|
||||||
totalSize,
|
|
||||||
'spotify_playlist',
|
|
||||||
settings,
|
|
||||||
extra,
|
|
||||||
)
|
|
||||||
|
|
||||||
def convert_spotify_playlist(self, dz, queueItem, interface=None):
|
|
||||||
convertPercentage = 0
|
|
||||||
lastPercentage = 0
|
|
||||||
if (self.configFolder / 'spotifyCache.json').is_file():
|
|
||||||
with open(self.configFolder / 'spotifyCache.json', 'r') as spotifyCache:
|
|
||||||
cache = json.load(spotifyCache)
|
|
||||||
else:
|
|
||||||
cache = {'tracks': {}, 'albums': {}}
|
|
||||||
if interface:
|
|
||||||
interface.send("startConversion", queueItem.uuid)
|
|
||||||
collection = []
|
|
||||||
for pos, track in enumerate(queueItem.extra['unconverted'], start=1):
|
|
||||||
if queueItem.cancel:
|
|
||||||
return
|
|
||||||
if str(track['id']) in cache['tracks']:
|
|
||||||
trackID = cache['tracks'][str(track['id'])]['id']
|
|
||||||
trackAPI = None
|
|
||||||
if cache['tracks'][str(track['id'])]['isrc']:
|
|
||||||
trackAPI = dz.api.get_track_by_ISRC(cache['tracks'][str(track['id'])]['isrc'])
|
|
||||||
else:
|
|
||||||
(trackID, trackAPI, isrc) = self.get_trackid_spotify(dz, "0", queueItem.settings['fallbackSearch'], track)
|
|
||||||
cache['tracks'][str(track['id'])] = {
|
|
||||||
'id': trackID,
|
|
||||||
'isrc': isrc
|
|
||||||
}
|
|
||||||
if str(trackID) == "0":
|
|
||||||
deezerTrack = {
|
|
||||||
'SNG_ID': "0",
|
|
||||||
'SNG_TITLE': track['name'],
|
|
||||||
'DURATION': 0,
|
|
||||||
'MD5_ORIGIN': 0,
|
|
||||||
'MEDIA_VERSION': 0,
|
|
||||||
'FILESIZE': 0,
|
|
||||||
'ALB_TITLE': track['album']['name'],
|
|
||||||
'ALB_PICTURE': "",
|
|
||||||
'ART_ID': 0,
|
|
||||||
'ART_NAME': track['artists'][0]['name']
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
deezerTrack = dz.gw.get_track_with_fallback(trackID)
|
|
||||||
deezerTrack['_EXTRA_PLAYLIST'] = queueItem.extra['playlistAPI']
|
|
||||||
if trackAPI:
|
|
||||||
deezerTrack['_EXTRA_TRACK'] = trackAPI
|
|
||||||
deezerTrack['POSITION'] = pos
|
|
||||||
deezerTrack['SIZE'] = queueItem.size
|
|
||||||
deezerTrack['FILENAME_TEMPLATE'] = queueItem.settings['playlistTracknameTemplate']
|
|
||||||
collection.append(deezerTrack)
|
|
||||||
|
|
||||||
convertPercentage = (pos / queueItem.size) * 100
|
|
||||||
if round(convertPercentage) != lastPercentage and round(convertPercentage) % 5 == 0:
|
|
||||||
lastPercentage = round(convertPercentage)
|
|
||||||
if interface:
|
|
||||||
interface.send("updateQueue", {'uuid': queueItem.uuid, 'conversion': lastPercentage})
|
|
||||||
|
|
||||||
queueItem.extra = None
|
|
||||||
queueItem.collection = collection
|
|
||||||
|
|
||||||
with open(self.configFolder / 'spotifyCache.json', 'w') as spotifyCache:
|
|
||||||
json.dump(cache, spotifyCache)
|
|
||||||
|
|
||||||
def get_user_playlists(self, user):
|
|
||||||
if not self.spotifyEnabled:
|
|
||||||
raise spotifyFeaturesNotEnabled
|
|
||||||
result = []
|
|
||||||
playlists = self.sp.user_playlists(user)
|
|
||||||
while playlists:
|
|
||||||
for playlist in playlists['items']:
|
|
||||||
result.append(self._convert_playlist_structure(playlist))
|
|
||||||
if playlists['next']:
|
|
||||||
playlists = self.sp.next(playlists)
|
|
||||||
else:
|
|
||||||
playlists = None
|
|
||||||
return result
|
|
||||||
|
|
||||||
def get_playlist_tracklist(self, id):
|
|
||||||
if not self.spotifyEnabled:
|
|
||||||
raise spotifyFeaturesNotEnabled
|
|
||||||
playlist = self.sp.playlist(id)
|
|
||||||
tracklist = playlist['tracks']['items']
|
|
||||||
while playlist['tracks']['next']:
|
|
||||||
playlist['tracks'] = self.sp.next(playlist['tracks'])
|
|
||||||
tracklist += playlist['tracks']['items']
|
|
||||||
playlist['tracks'] = tracklist
|
|
||||||
return playlist
|
|
||||||
|
|
||||||
|
|
||||||
class spotifyFeaturesNotEnabled(Exception):
|
|
||||||
pass
|
|
|
@ -0,0 +1,111 @@
|
||||||
|
from ssl import SSLError
|
||||||
|
from time import sleep
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from requests import get
|
||||||
|
from requests.exceptions import ConnectionError as RequestsConnectionError, ReadTimeout, ChunkedEncodingError
|
||||||
|
from urllib3.exceptions import SSLError as u3SSLError
|
||||||
|
|
||||||
|
from deemix.utils.crypto import _md5, _ecbCrypt, _ecbDecrypt, generateBlowfishKey, decryptChunk
|
||||||
|
|
||||||
|
from deemix.utils import USER_AGENT_HEADER
|
||||||
|
from deemix.types.DownloadObjects import Single
|
||||||
|
from deemix.errors import DownloadCanceled, DownloadEmpty
|
||||||
|
|
||||||
|
logger = logging.getLogger('deemix')
|
||||||
|
|
||||||
|
def generateStreamPath(sng_id, md5, media_version, media_format):
|
||||||
|
urlPart = b'\xa4'.join(
|
||||||
|
[md5.encode(), str(media_format).encode(), str(sng_id).encode(), str(media_version).encode()])
|
||||||
|
md5val = _md5(urlPart)
|
||||||
|
step2 = md5val.encode() + b'\xa4' + urlPart + b'\xa4'
|
||||||
|
step2 = step2 + (b'.' * (16 - (len(step2) % 16)))
|
||||||
|
urlPart = _ecbCrypt('jo6aey6haid2Teih', step2)
|
||||||
|
return urlPart.decode("utf-8")
|
||||||
|
|
||||||
|
def reverseStreamPath(urlPart):
|
||||||
|
step2 = _ecbDecrypt('jo6aey6haid2Teih', urlPart)
|
||||||
|
(_, md5, media_format, sng_id, media_version, _) = step2.split(b'\xa4')
|
||||||
|
return (sng_id.decode('utf-8'), md5.decode('utf-8'), media_version.decode('utf-8'), media_format.decode('utf-8'))
|
||||||
|
|
||||||
|
def generateCryptedStreamURL(sng_id, md5, media_version, media_format):
|
||||||
|
urlPart = generateStreamPath(sng_id, md5, media_version, media_format)
|
||||||
|
return "https://e-cdns-proxy-" + md5[0] + ".dzcdn.net/mobile/1/" + urlPart
|
||||||
|
|
||||||
|
def generateStreamURL(sng_id, md5, media_version, media_format):
|
||||||
|
urlPart = generateStreamPath(sng_id, md5, media_version, media_format)
|
||||||
|
return "https://e-cdns-proxy-" + md5[0] + ".dzcdn.net/api/1/" + urlPart
|
||||||
|
|
||||||
|
def reverseStreamURL(url):
|
||||||
|
urlPart = url[url.find("/1/")+3:]
|
||||||
|
return reverseStreamPath(urlPart)
|
||||||
|
|
||||||
|
def streamTrack(outputStream, track, start=0, downloadObject=None, listener=None):
|
||||||
|
if downloadObject and downloadObject.isCanceled: raise DownloadCanceled
|
||||||
|
headers= {'User-Agent': USER_AGENT_HEADER}
|
||||||
|
chunkLength = start
|
||||||
|
isCryptedStream = "/mobile/" in track.downloadURL or "/media/" in track.downloadURL
|
||||||
|
|
||||||
|
itemData = {
|
||||||
|
'id': track.id,
|
||||||
|
'title': track.title,
|
||||||
|
'artist': track.mainArtist.name
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get(track.downloadURL, headers=headers, stream=True, timeout=10) as request:
|
||||||
|
request.raise_for_status()
|
||||||
|
if isCryptedStream:
|
||||||
|
blowfish_key = generateBlowfishKey(str(track.id))
|
||||||
|
|
||||||
|
complete = int(request.headers["Content-Length"])
|
||||||
|
if complete == 0: raise DownloadEmpty
|
||||||
|
if start != 0:
|
||||||
|
responseRange = request.headers["Content-Range"]
|
||||||
|
if listener:
|
||||||
|
listener.send('downloadInfo', {
|
||||||
|
'uuid': downloadObject.uuid,
|
||||||
|
'data': itemData,
|
||||||
|
'state': "downloading",
|
||||||
|
'alreadyStarted': True,
|
||||||
|
'value': responseRange
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
if listener:
|
||||||
|
listener.send('downloadInfo', {
|
||||||
|
'uuid': downloadObject.uuid,
|
||||||
|
'data': itemData,
|
||||||
|
'state': "downloading",
|
||||||
|
'alreadyStarted': False,
|
||||||
|
'value': complete
|
||||||
|
})
|
||||||
|
|
||||||
|
isStart = True
|
||||||
|
for chunk in request.iter_content(2048 * 3):
|
||||||
|
if isCryptedStream:
|
||||||
|
if len(chunk) >= 2048:
|
||||||
|
chunk = decryptChunk(blowfish_key, chunk[0:2048]) + chunk[2048:]
|
||||||
|
|
||||||
|
if isStart and chunk[0] == 0 and chunk[4:8].decode('utf-8') != "ftyp":
|
||||||
|
for i, byte in enumerate(chunk):
|
||||||
|
if byte != 0: break
|
||||||
|
chunk = chunk[i:]
|
||||||
|
isStart = False
|
||||||
|
|
||||||
|
outputStream.write(chunk)
|
||||||
|
chunkLength += len(chunk)
|
||||||
|
|
||||||
|
if downloadObject:
|
||||||
|
if isinstance(downloadObject, Single):
|
||||||
|
chunkProgres = (chunkLength / (complete + start)) * 100
|
||||||
|
downloadObject.progressNext = chunkProgres
|
||||||
|
else:
|
||||||
|
chunkProgres = (len(chunk) / (complete + start)) / downloadObject.size * 100
|
||||||
|
downloadObject.progressNext += chunkProgres
|
||||||
|
downloadObject.updateProgress(listener)
|
||||||
|
|
||||||
|
except (SSLError, u3SSLError):
|
||||||
|
streamTrack(outputStream, track, chunkLength, downloadObject, listener)
|
||||||
|
except (RequestsConnectionError, ReadTimeout, ChunkedEncodingError):
|
||||||
|
sleep(2)
|
||||||
|
streamTrack(outputStream, track, start, downloadObject, listener)
|
|
@ -0,0 +1,672 @@
|
||||||
|
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)
|
|
@ -0,0 +1,96 @@
|
||||||
|
class DeemixError(Exception):
|
||||||
|
"""Base exception for this module"""
|
||||||
|
|
||||||
|
class GenerationError(DeemixError):
|
||||||
|
"""Generation related errors"""
|
||||||
|
def __init__(self, link, message, errid=None):
|
||||||
|
super().__init__()
|
||||||
|
self.link = link
|
||||||
|
self.message = message
|
||||||
|
self.errid = errid
|
||||||
|
|
||||||
|
def toDict(self):
|
||||||
|
return {
|
||||||
|
'link': self.link,
|
||||||
|
'error': self.message,
|
||||||
|
'errid': self.errid
|
||||||
|
}
|
||||||
|
|
||||||
|
class ISRCnotOnDeezer(GenerationError):
|
||||||
|
def __init__(self, link):
|
||||||
|
super().__init__(link, "Track ISRC is not available on deezer", "ISRCnotOnDeezer")
|
||||||
|
|
||||||
|
class NotYourPrivatePlaylist(GenerationError):
|
||||||
|
def __init__(self, link):
|
||||||
|
super().__init__(link, "You can't download others private playlists.", "notYourPrivatePlaylist")
|
||||||
|
|
||||||
|
class TrackNotOnDeezer(GenerationError):
|
||||||
|
def __init__(self, link):
|
||||||
|
super().__init__(link, "Track not found on deezer!", "trackNotOnDeezer")
|
||||||
|
|
||||||
|
class AlbumNotOnDeezer(GenerationError):
|
||||||
|
def __init__(self, link):
|
||||||
|
super().__init__(link, "Album not found on deezer!", "albumNotOnDeezer")
|
||||||
|
|
||||||
|
class InvalidID(GenerationError):
|
||||||
|
def __init__(self, link):
|
||||||
|
super().__init__(link, "Link ID is invalid!", "invalidID")
|
||||||
|
|
||||||
|
class LinkNotSupported(GenerationError):
|
||||||
|
def __init__(self, link):
|
||||||
|
super().__init__(link, "Link is not supported.", "unsupportedURL")
|
||||||
|
|
||||||
|
class LinkNotRecognized(GenerationError):
|
||||||
|
def __init__(self, link):
|
||||||
|
super().__init__(link, "Link is not recognized.", "invalidURL")
|
||||||
|
|
||||||
|
class DownloadError(DeemixError):
|
||||||
|
"""Download related errors"""
|
||||||
|
|
||||||
|
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!",
|
||||||
|
'wrongLicense': "Your account can't stream the track at the desired bitrate.",
|
||||||
|
'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",
|
||||||
|
'albumDoesntExists': "Track's album does not exsist, failed to gather info.",
|
||||||
|
'notLoggedIn': "You need to login to download tracks.",
|
||||||
|
'wrongGeolocation': "Your account can't stream the track from your current country.",
|
||||||
|
'wrongGeolocationNoAlternative': "Your account can't stream the track from your current country and no alternative found."
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadFailed(DownloadError):
|
||||||
|
def __init__(self, errid, track=None):
|
||||||
|
super().__init__()
|
||||||
|
self.errid = errid
|
||||||
|
self.message = ErrorMessages[self.errid]
|
||||||
|
self.track = track
|
||||||
|
|
||||||
|
class PreferredBitrateNotFound(DownloadError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TrackNot360(DownloadError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DownloadCanceled(DownloadError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DownloadEmpty(DownloadError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TrackError(DeemixError):
|
||||||
|
"""Track generation related errors"""
|
||||||
|
|
||||||
|
class AlbumDoesntExists(TrackError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MD5NotFound(TrackError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class NoDataToParse(TrackError):
|
||||||
|
pass
|
|
@ -0,0 +1,277 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from deezer.errors import GWAPIError, APIError
|
||||||
|
from deezer.utils import map_user_playlist, map_track, map_album
|
||||||
|
|
||||||
|
from deemix.types.DownloadObjects import Single, Collection
|
||||||
|
from deemix.errors import GenerationError, ISRCnotOnDeezer, InvalidID, NotYourPrivatePlaylist
|
||||||
|
|
||||||
|
logger = logging.getLogger('deemix')
|
||||||
|
|
||||||
|
def generateTrackItem(dz, link_id, bitrate, trackAPI=None, albumAPI=None):
|
||||||
|
# Get essential track info
|
||||||
|
if not trackAPI:
|
||||||
|
if str(link_id).startswith("isrc") or int(link_id) > 0:
|
||||||
|
try:
|
||||||
|
trackAPI = dz.api.get_track(link_id)
|
||||||
|
except APIError as e:
|
||||||
|
raise GenerationError(f"https://deezer.com/track/{link_id}", str(e)) from e
|
||||||
|
|
||||||
|
# Check if is an isrc: url
|
||||||
|
if str(link_id).startswith("isrc"):
|
||||||
|
if 'id' in trackAPI and 'title' in trackAPI:
|
||||||
|
link_id = trackAPI['id']
|
||||||
|
else:
|
||||||
|
raise ISRCnotOnDeezer(f"https://deezer.com/track/{link_id}")
|
||||||
|
else:
|
||||||
|
trackAPI_gw = dz.gw.get_track(link_id)
|
||||||
|
trackAPI = map_track(trackAPI_gw)
|
||||||
|
else:
|
||||||
|
link_id = trackAPI['id']
|
||||||
|
if not str(link_id).strip('-').isdecimal(): raise InvalidID(f"https://deezer.com/track/{link_id}")
|
||||||
|
|
||||||
|
cover = None
|
||||||
|
if trackAPI['album']['cover_small']:
|
||||||
|
cover = trackAPI['album']['cover_small'][:-24] + '/75x75-000000-80-0-0.jpg'
|
||||||
|
else:
|
||||||
|
cover = f"https://e-cdns-images.dzcdn.net/images/cover/{trackAPI['md5_image']}/75x75-000000-80-0-0.jpg"
|
||||||
|
|
||||||
|
if 'track_token' in trackAPI: del trackAPI['track_token']
|
||||||
|
|
||||||
|
return Single({
|
||||||
|
'type': 'track',
|
||||||
|
'id': link_id,
|
||||||
|
'bitrate': bitrate,
|
||||||
|
'title': trackAPI['title'],
|
||||||
|
'artist': trackAPI['artist']['name'],
|
||||||
|
'cover': cover,
|
||||||
|
'explicit': trackAPI['explicit_lyrics'],
|
||||||
|
'single': {
|
||||||
|
'trackAPI': trackAPI,
|
||||||
|
'albumAPI': albumAPI
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
def generateAlbumItem(dz, link_id, bitrate, rootArtist=None):
|
||||||
|
# Get essential album info
|
||||||
|
if str(link_id).startswith('upc'):
|
||||||
|
upcs = [link_id[4:],]
|
||||||
|
upcs.append(int(upcs[0]))
|
||||||
|
lastError = None
|
||||||
|
for upc in upcs:
|
||||||
|
try:
|
||||||
|
albumAPI = dz.api.get_album(f"upc:{upc}")
|
||||||
|
except APIError as e:
|
||||||
|
lastError = e
|
||||||
|
albumAPI = None
|
||||||
|
if not albumAPI:
|
||||||
|
raise GenerationError(f"https://deezer.com/album/{link_id}", str(lastError)) from lastError
|
||||||
|
link_id = albumAPI['id']
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
albumAPI_gw_page = dz.gw.get_album_page(link_id)
|
||||||
|
if 'DATA' in albumAPI_gw_page:
|
||||||
|
albumAPI = map_album(albumAPI_gw_page['DATA'])
|
||||||
|
link_id = albumAPI_gw_page['DATA']['ALB_ID']
|
||||||
|
albumAPI_new = dz.api.get_album(link_id)
|
||||||
|
albumAPI.update(albumAPI_new)
|
||||||
|
else:
|
||||||
|
raise GenerationError(f"https://deezer.com/album/{link_id}", "Can't find the album")
|
||||||
|
except APIError as e:
|
||||||
|
raise GenerationError(f"https://deezer.com/album/{link_id}", str(e)) from e
|
||||||
|
|
||||||
|
if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/album/{link_id}")
|
||||||
|
|
||||||
|
# Get extra info about album
|
||||||
|
# This saves extra api calls when downloading
|
||||||
|
albumAPI_gw = dz.gw.get_album(link_id)
|
||||||
|
albumAPI_gw = map_album(albumAPI_gw)
|
||||||
|
albumAPI_gw.update(albumAPI)
|
||||||
|
albumAPI = albumAPI_gw
|
||||||
|
albumAPI['root_artist'] = rootArtist
|
||||||
|
|
||||||
|
# If the album is a single download as a track
|
||||||
|
if albumAPI['nb_tracks'] == 1:
|
||||||
|
if len(albumAPI['tracks']['data']):
|
||||||
|
return generateTrackItem(dz, albumAPI['tracks']['data'][0]['id'], bitrate, albumAPI=albumAPI)
|
||||||
|
raise GenerationError(f"https://deezer.com/album/{link_id}", "Single has no tracks.")
|
||||||
|
|
||||||
|
tracksArray = dz.gw.get_album_tracks(link_id)
|
||||||
|
|
||||||
|
if albumAPI['cover_small'] is not None:
|
||||||
|
cover = albumAPI['cover_small'][:-24] + '/75x75-000000-80-0-0.jpg'
|
||||||
|
else:
|
||||||
|
cover = f"https://e-cdns-images.dzcdn.net/images/cover/{albumAPI['md5_image']}/75x75-000000-80-0-0.jpg"
|
||||||
|
|
||||||
|
totalSize = len(tracksArray)
|
||||||
|
albumAPI['nb_tracks'] = totalSize
|
||||||
|
collection = []
|
||||||
|
for pos, trackAPI in enumerate(tracksArray, start=1):
|
||||||
|
trackAPI = map_track(trackAPI)
|
||||||
|
if 'track_token' in trackAPI: del trackAPI['track_token']
|
||||||
|
trackAPI['position'] = pos
|
||||||
|
collection.append(trackAPI)
|
||||||
|
|
||||||
|
return Collection({
|
||||||
|
'type': 'album',
|
||||||
|
'id': link_id,
|
||||||
|
'bitrate': bitrate,
|
||||||
|
'title': albumAPI['title'],
|
||||||
|
'artist': albumAPI['artist']['name'],
|
||||||
|
'cover': cover,
|
||||||
|
'explicit': albumAPI['explicit_lyrics'],
|
||||||
|
'size': totalSize,
|
||||||
|
'collection': {
|
||||||
|
'tracks': collection,
|
||||||
|
'albumAPI': albumAPI
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
def generatePlaylistItem(dz, link_id, bitrate, playlistAPI=None, playlistTracksAPI=None):
|
||||||
|
if not playlistAPI:
|
||||||
|
if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/playlist/{link_id}")
|
||||||
|
# Get essential playlist info
|
||||||
|
try:
|
||||||
|
playlistAPI = dz.api.get_playlist(link_id)
|
||||||
|
except APIError:
|
||||||
|
playlistAPI = None
|
||||||
|
# Fallback to gw api if the playlist is private
|
||||||
|
if not playlistAPI:
|
||||||
|
try:
|
||||||
|
userPlaylist = dz.gw.get_playlist_page(link_id)
|
||||||
|
playlistAPI = map_user_playlist(userPlaylist['DATA'])
|
||||||
|
except GWAPIError as e:
|
||||||
|
raise GenerationError(f"https://deezer.com/playlist/{link_id}", str(e)) from e
|
||||||
|
|
||||||
|
# Check if private playlist and owner
|
||||||
|
if not playlistAPI.get('public', False) and playlistAPI['creator']['id'] != str(dz.current_user['id']):
|
||||||
|
logger.warning("You can't download others private playlists.")
|
||||||
|
raise NotYourPrivatePlaylist(f"https://deezer.com/playlist/{link_id}")
|
||||||
|
|
||||||
|
if not playlistTracksAPI:
|
||||||
|
playlistTracksAPI = dz.gw.get_playlist_tracks(link_id)
|
||||||
|
playlistAPI['various_artist'] = dz.api.get_artist(5080) # Useful for save as compilation
|
||||||
|
|
||||||
|
totalSize = len(playlistTracksAPI)
|
||||||
|
playlistAPI['nb_tracks'] = totalSize
|
||||||
|
collection = []
|
||||||
|
for pos, trackAPI in enumerate(playlistTracksAPI, start=1):
|
||||||
|
trackAPI = map_track(trackAPI)
|
||||||
|
if trackAPI['explicit_lyrics']:
|
||||||
|
playlistAPI['explicit'] = True
|
||||||
|
if 'track_token' in trackAPI: del trackAPI['track_token']
|
||||||
|
trackAPI['position'] = pos
|
||||||
|
collection.append(trackAPI)
|
||||||
|
|
||||||
|
if 'explicit' not in playlistAPI: playlistAPI['explicit'] = False
|
||||||
|
|
||||||
|
return Collection({
|
||||||
|
'type': 'playlist',
|
||||||
|
'id': link_id,
|
||||||
|
'bitrate': bitrate,
|
||||||
|
'title': playlistAPI['title'],
|
||||||
|
'artist': playlistAPI['creator']['name'],
|
||||||
|
'cover': playlistAPI['picture_small'][:-24] + '/75x75-000000-80-0-0.jpg',
|
||||||
|
'explicit': playlistAPI['explicit'],
|
||||||
|
'size': totalSize,
|
||||||
|
'collection': {
|
||||||
|
'tracks': collection,
|
||||||
|
'playlistAPI': playlistAPI
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
def generateArtistItem(dz, link_id, bitrate, listener=None):
|
||||||
|
if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/artist/{link_id}")
|
||||||
|
# Get essential artist info
|
||||||
|
try:
|
||||||
|
artistAPI = dz.api.get_artist(link_id)
|
||||||
|
except APIError as e:
|
||||||
|
raise GenerationError(f"https://deezer.com/artist/{link_id}", str(e)) from e
|
||||||
|
|
||||||
|
rootArtist = {
|
||||||
|
'id': artistAPI['id'],
|
||||||
|
'name': artistAPI['name'],
|
||||||
|
'picture_small': artistAPI['picture_small']
|
||||||
|
}
|
||||||
|
if listener: listener.send("startAddingArtist", rootArtist)
|
||||||
|
|
||||||
|
artistDiscographyAPI = dz.gw.get_artist_discography_tabs(link_id, 100)
|
||||||
|
allReleases = artistDiscographyAPI.pop('all', [])
|
||||||
|
albumList = []
|
||||||
|
for album in allReleases:
|
||||||
|
try:
|
||||||
|
albumList.append(generateAlbumItem(dz, album['id'], bitrate, rootArtist=rootArtist))
|
||||||
|
except GenerationError as e:
|
||||||
|
logger.warning("Album %s has no data: %s", str(album['id']), str(e))
|
||||||
|
|
||||||
|
if listener: listener.send("finishAddingArtist", rootArtist)
|
||||||
|
return albumList
|
||||||
|
|
||||||
|
def generateArtistDiscographyItem(dz, link_id, bitrate, listener=None):
|
||||||
|
if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/artist/{link_id}/discography")
|
||||||
|
# Get essential artist info
|
||||||
|
try:
|
||||||
|
artistAPI = dz.api.get_artist(link_id)
|
||||||
|
except APIError as e:
|
||||||
|
raise GenerationError(f"https://deezer.com/artist/{link_id}/discography", str(e)) from e
|
||||||
|
|
||||||
|
rootArtist = {
|
||||||
|
'id': artistAPI['id'],
|
||||||
|
'name': artistAPI['name'],
|
||||||
|
'picture_small': artistAPI['picture_small']
|
||||||
|
}
|
||||||
|
if listener: listener.send("startAddingArtist", rootArtist)
|
||||||
|
|
||||||
|
artistDiscographyAPI = dz.gw.get_artist_discography_tabs(link_id, 100)
|
||||||
|
artistDiscographyAPI.pop('all', None) # all contains albums and singles, so its all duplicates. This removes them
|
||||||
|
albumList = []
|
||||||
|
for releaseType in artistDiscographyAPI:
|
||||||
|
for album in artistDiscographyAPI[releaseType]:
|
||||||
|
try:
|
||||||
|
albumList.append(generateAlbumItem(dz, album['id'], bitrate, rootArtist=rootArtist))
|
||||||
|
except GenerationError as e:
|
||||||
|
logger.warning("Album %s has no data: %s", str(album['id']), str(e))
|
||||||
|
|
||||||
|
if listener: listener.send("finishAddingArtist", rootArtist)
|
||||||
|
return albumList
|
||||||
|
|
||||||
|
def generateArtistTopItem(dz, link_id, bitrate):
|
||||||
|
if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/artist/{link_id}/top_track")
|
||||||
|
# Get essential artist info
|
||||||
|
try:
|
||||||
|
artistAPI = dz.api.get_artist(link_id)
|
||||||
|
except APIError as e:
|
||||||
|
raise GenerationError(f"https://deezer.com/artist/{link_id}/top_track", str(e)) from e
|
||||||
|
|
||||||
|
# Emulate the creation of a playlist
|
||||||
|
# Can't use generatePlaylistItem directly as this is not a real playlist
|
||||||
|
playlistAPI = {
|
||||||
|
'id':f"{artistAPI['id']}_top_track",
|
||||||
|
'title': f"{artistAPI['name']} - Top Tracks",
|
||||||
|
'description': f"Top Tracks for {artistAPI['name']}",
|
||||||
|
'duration': 0,
|
||||||
|
'public': True,
|
||||||
|
'is_loved_track': False,
|
||||||
|
'collaborative': False,
|
||||||
|
'nb_tracks': 0,
|
||||||
|
'fans': artistAPI['nb_fan'],
|
||||||
|
'link': f"https://www.deezer.com/artist/{artistAPI['id']}/top_track",
|
||||||
|
'share': None,
|
||||||
|
'picture': artistAPI['picture'],
|
||||||
|
'picture_small': artistAPI['picture_small'],
|
||||||
|
'picture_medium': artistAPI['picture_medium'],
|
||||||
|
'picture_big': artistAPI['picture_big'],
|
||||||
|
'picture_xl': artistAPI['picture_xl'],
|
||||||
|
'checksum': None,
|
||||||
|
'tracklist': f"https://api.deezer.com/artist/{artistAPI['id']}/top",
|
||||||
|
'creation_date': "XXXX-00-00",
|
||||||
|
'creator': {
|
||||||
|
'id': f"art_{artistAPI['id']}",
|
||||||
|
'name': artistAPI['name'],
|
||||||
|
'type': "user"
|
||||||
|
},
|
||||||
|
'type': "playlist"
|
||||||
|
}
|
||||||
|
|
||||||
|
artistTopTracksAPI_gw = dz.gw.get_artist_toptracks(link_id)
|
||||||
|
return generatePlaylistItem(dz, playlistAPI['id'], bitrate, playlistAPI=playlistAPI, playlistTracksAPI=artistTopTracksAPI_gw)
|
|
@ -0,0 +1,12 @@
|
||||||
|
class Plugin:
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def parseLink(self, link):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def generateDownloadObject(self, dz, link, bitrate, listener):
|
||||||
|
pass
|
|
@ -0,0 +1,385 @@
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from urllib.request import urlopen
|
||||||
|
from deezer.errors import DataException
|
||||||
|
from deemix.plugins import Plugin
|
||||||
|
from deemix.utils.localpaths import getConfigFolder
|
||||||
|
from deemix.itemgen import generateTrackItem, generateAlbumItem
|
||||||
|
from deemix.errors import GenerationError, TrackNotOnDeezer, AlbumNotOnDeezer
|
||||||
|
from deemix.types.DownloadObjects import Convertable, Collection
|
||||||
|
|
||||||
|
import spotipy
|
||||||
|
SpotifyClientCredentials = spotipy.oauth2.SpotifyClientCredentials
|
||||||
|
CacheFileHandler = spotipy.cache_handler.CacheFileHandler
|
||||||
|
|
||||||
|
class Spotify(Plugin):
|
||||||
|
def __init__(self, configFolder=None):
|
||||||
|
super().__init__()
|
||||||
|
self.credentials = {'clientId': "", 'clientSecret': ""}
|
||||||
|
self.settings = {
|
||||||
|
'fallbackSearch': False
|
||||||
|
}
|
||||||
|
self.enabled = False
|
||||||
|
self.sp = None
|
||||||
|
self.configFolder = Path(configFolder or getConfigFolder())
|
||||||
|
self.configFolder /= 'spotify'
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
if not self.configFolder.is_dir(): self.configFolder.mkdir()
|
||||||
|
|
||||||
|
self.loadSettings()
|
||||||
|
return self
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parseLink(cls, link):
|
||||||
|
if 'link.tospotify.com' in link: link = urlopen(link).url
|
||||||
|
# Remove extra stuff
|
||||||
|
if '?' in link: link = link[:link.find('?')]
|
||||||
|
if '&' in link: link = link[:link.find('&')]
|
||||||
|
if link.endswith('/'): link = link[:-1] # Remove last slash if present
|
||||||
|
|
||||||
|
link_type = None
|
||||||
|
link_id = None
|
||||||
|
|
||||||
|
if not 'spotify' in link: return (link, link_type, link_id) # return if not a spotify link
|
||||||
|
|
||||||
|
if re.search(r"[/:]track[/:](.+)", link):
|
||||||
|
link_type = 'track'
|
||||||
|
link_id = re.search(r"[/:]track[/:](.+)", link).group(1)
|
||||||
|
elif re.search(r"[/:]album[/:](.+)", link):
|
||||||
|
link_type = 'album'
|
||||||
|
link_id = re.search(r"[/:]album[/:](.+)", link).group(1)
|
||||||
|
elif re.search(r"[/:]playlist[/:](.+)", link):
|
||||||
|
link_type = 'playlist'
|
||||||
|
link_id = re.search(r"[/:]playlist[/:](.+)", link).group(1)
|
||||||
|
|
||||||
|
return (link, link_type, link_id)
|
||||||
|
|
||||||
|
def generateDownloadObject(self, dz, link, bitrate, listener):
|
||||||
|
(link, link_type, link_id) = self.parseLink(link)
|
||||||
|
|
||||||
|
if link_type is None or link_id is None: return None
|
||||||
|
|
||||||
|
if link_type == "track":
|
||||||
|
return self.generateTrackItem(dz, link_id, bitrate)
|
||||||
|
if link_type == "album":
|
||||||
|
return self.generateAlbumItem(dz, link_id, bitrate)
|
||||||
|
if link_type == "playlist":
|
||||||
|
return self.generatePlaylistItem(dz, link_id, bitrate)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def generateTrackItem(self, dz, link_id, bitrate):
|
||||||
|
cache = self.loadCache()
|
||||||
|
|
||||||
|
if link_id in cache['tracks']:
|
||||||
|
cachedTrack = cache['tracks'][link_id]
|
||||||
|
else:
|
||||||
|
cachedTrack = self.getTrack(link_id)
|
||||||
|
cache['tracks'][link_id] = cachedTrack
|
||||||
|
self.saveCache(cache)
|
||||||
|
|
||||||
|
if 'isrc' in cachedTrack:
|
||||||
|
try: return generateTrackItem(dz, f"isrc:{cachedTrack['isrc']}", bitrate)
|
||||||
|
except GenerationError: pass
|
||||||
|
if self.settings['fallbackSearch']:
|
||||||
|
if 'id' not in cachedTrack or cachedTrack['id'] == "0":
|
||||||
|
trackID = dz.api.get_track_id_from_metadata(
|
||||||
|
cachedTrack['data']['artist'],
|
||||||
|
cachedTrack['data']['title'],
|
||||||
|
cachedTrack['data']['album'],
|
||||||
|
)
|
||||||
|
if trackID != "0":
|
||||||
|
cachedTrack['id'] = trackID
|
||||||
|
cache['tracks'][link_id] = cachedTrack
|
||||||
|
self.saveCache(cache)
|
||||||
|
|
||||||
|
if cachedTrack.get('id', "0") != "0":
|
||||||
|
return generateTrackItem(dz, cachedTrack['id'], bitrate)
|
||||||
|
|
||||||
|
raise TrackNotOnDeezer(f"https://open.spotify.com/track/{link_id}")
|
||||||
|
|
||||||
|
def generateAlbumItem(self, dz, link_id, bitrate):
|
||||||
|
cache = self.loadCache()
|
||||||
|
|
||||||
|
if link_id in cache['albums']:
|
||||||
|
cachedAlbum = cache['albums'][link_id]
|
||||||
|
else:
|
||||||
|
cachedAlbum = self.getAlbum(link_id)
|
||||||
|
cache['albums'][link_id] = cachedAlbum
|
||||||
|
self.saveCache(cache)
|
||||||
|
|
||||||
|
try: return generateAlbumItem(dz, f"upc:{cachedAlbum['upc']}", bitrate)
|
||||||
|
except GenerationError as e: raise AlbumNotOnDeezer(f"https://open.spotify.com/album/{link_id}") from e
|
||||||
|
|
||||||
|
def generatePlaylistItem(self, dz, link_id, bitrate):
|
||||||
|
if not self.enabled: raise Exception("Spotify plugin not enabled")
|
||||||
|
spotifyPlaylist = self.sp.playlist(link_id)
|
||||||
|
|
||||||
|
playlistAPI = self._convertPlaylistStructure(spotifyPlaylist)
|
||||||
|
playlistAPI['various_artist'] = dz.api.get_artist(5080) # Useful for save as compilation
|
||||||
|
|
||||||
|
tracklistTemp = spotifyPlaylist['tracks']['items']
|
||||||
|
while spotifyPlaylist['tracks']['next']:
|
||||||
|
spotifyPlaylist['tracks'] = self.sp.next(spotifyPlaylist['tracks'])
|
||||||
|
tracklistTemp += spotifyPlaylist['tracks']['items']
|
||||||
|
|
||||||
|
tracklist = []
|
||||||
|
for item in tracklistTemp:
|
||||||
|
if item['track']:
|
||||||
|
if item['track']['explicit']:
|
||||||
|
playlistAPI['explicit'] = True
|
||||||
|
tracklist.append(item['track'])
|
||||||
|
if 'explicit' not in playlistAPI: playlistAPI['explicit'] = False
|
||||||
|
|
||||||
|
return Convertable({
|
||||||
|
'type': 'spotify_playlist',
|
||||||
|
'id': link_id,
|
||||||
|
'bitrate': bitrate,
|
||||||
|
'title': spotifyPlaylist['name'],
|
||||||
|
'artist': spotifyPlaylist['owner']['display_name'],
|
||||||
|
'cover': playlistAPI['picture_thumbnail'],
|
||||||
|
'explicit': playlistAPI['explicit'],
|
||||||
|
'size': len(tracklist),
|
||||||
|
'collection': {
|
||||||
|
'tracks': [],
|
||||||
|
'playlistAPI': playlistAPI
|
||||||
|
},
|
||||||
|
'plugin': 'spotify',
|
||||||
|
'conversion_data': tracklist
|
||||||
|
})
|
||||||
|
|
||||||
|
def getTrack(self, track_id, spotifyTrack=None):
|
||||||
|
if not self.enabled: raise Exception("Spotify plugin not enabled")
|
||||||
|
cachedTrack = {
|
||||||
|
'isrc': None,
|
||||||
|
'data': None
|
||||||
|
}
|
||||||
|
|
||||||
|
if not spotifyTrack:
|
||||||
|
spotifyTrack = self.sp.track(track_id)
|
||||||
|
if 'isrc' in spotifyTrack.get('external_ids', {}):
|
||||||
|
cachedTrack['isrc'] = spotifyTrack['external_ids']['isrc']
|
||||||
|
cachedTrack['data'] = {
|
||||||
|
'title': spotifyTrack['name'],
|
||||||
|
'artist': spotifyTrack['artists'][0]['name'],
|
||||||
|
'album': spotifyTrack['album']['name']
|
||||||
|
}
|
||||||
|
return cachedTrack
|
||||||
|
|
||||||
|
def getAlbum(self, album_id, spotifyAlbum=None):
|
||||||
|
if not self.enabled: raise Exception("Spotify plugin not enabled")
|
||||||
|
cachedAlbum = {
|
||||||
|
'upc': None,
|
||||||
|
'data': None
|
||||||
|
}
|
||||||
|
|
||||||
|
if not spotifyAlbum:
|
||||||
|
spotifyAlbum = self.sp.album(album_id)
|
||||||
|
if 'upc' in spotifyAlbum.get('external_ids', {}):
|
||||||
|
cachedAlbum['upc'] = spotifyAlbum['external_ids']['upc']
|
||||||
|
cachedAlbum['data'] = {
|
||||||
|
'title': spotifyAlbum['name'],
|
||||||
|
'artist': spotifyAlbum['artists'][0]['name']
|
||||||
|
}
|
||||||
|
return cachedAlbum
|
||||||
|
|
||||||
|
def convertTrack(self, dz, downloadObject, track, pos, conversion, cache, listener):
|
||||||
|
if downloadObject.isCanceled: return
|
||||||
|
trackAPI = None
|
||||||
|
cachedTrack = None
|
||||||
|
|
||||||
|
if track['id'] in cache['tracks']:
|
||||||
|
cachedTrack = cache['tracks'][track['id']]
|
||||||
|
else:
|
||||||
|
cachedTrack = self.getTrack(track['id'], track)
|
||||||
|
cache['tracks'][track['id']] = cachedTrack
|
||||||
|
self.saveCache(cache)
|
||||||
|
|
||||||
|
if 'isrc' in cachedTrack:
|
||||||
|
try:
|
||||||
|
trackAPI = dz.api.get_track_by_ISRC(cachedTrack['isrc'])
|
||||||
|
if 'id' not in trackAPI or 'title' not in trackAPI: trackAPI = None
|
||||||
|
except DataException: pass
|
||||||
|
if self.settings['fallbackSearch'] and not trackAPI:
|
||||||
|
if 'id' not in cachedTrack or cachedTrack['id'] == "0":
|
||||||
|
trackID = dz.api.get_track_id_from_metadata(
|
||||||
|
cachedTrack['data']['artist'],
|
||||||
|
cachedTrack['data']['title'],
|
||||||
|
cachedTrack['data']['album'],
|
||||||
|
)
|
||||||
|
if trackID != "0":
|
||||||
|
cachedTrack['id'] = trackID
|
||||||
|
cache['tracks'][track['id']] = cachedTrack
|
||||||
|
self.saveCache(cache)
|
||||||
|
|
||||||
|
if cachedTrack.get('id', "0") != "0":
|
||||||
|
trackAPI = dz.api.get_track(cachedTrack['id'])
|
||||||
|
|
||||||
|
if not trackAPI:
|
||||||
|
trackAPI = {
|
||||||
|
'id': "0",
|
||||||
|
'title': track['name'],
|
||||||
|
'duration': 0,
|
||||||
|
'md5_origin': 0,
|
||||||
|
'media_version': 0,
|
||||||
|
'filesizes': {},
|
||||||
|
'album': {
|
||||||
|
'title': track['album']['name'],
|
||||||
|
'md5_image': ""
|
||||||
|
},
|
||||||
|
'artist': {
|
||||||
|
'id': 0,
|
||||||
|
'name': track['artists'][0]['name']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trackAPI['position'] = pos+1
|
||||||
|
|
||||||
|
conversion['next'] += (1 / downloadObject.size) * 100
|
||||||
|
if round(conversion['next']) != conversion['now'] and round(conversion['next']) % 2 == 0:
|
||||||
|
conversion['now'] = round(conversion['next'])
|
||||||
|
if listener: listener.send("updateQueue", {'uuid': downloadObject.uuid, 'conversion': conversion['now']})
|
||||||
|
|
||||||
|
return trackAPI
|
||||||
|
|
||||||
|
def convert(self, dz, downloadObject, settings, listener=None):
|
||||||
|
cache = self.loadCache()
|
||||||
|
|
||||||
|
conversion = { 'now': 0, 'next': 0 }
|
||||||
|
|
||||||
|
collection = [None] * len(downloadObject.conversion_data)
|
||||||
|
if listener: listener.send("startConversion", downloadObject.uuid)
|
||||||
|
with ThreadPoolExecutor(settings['queueConcurrency']) as executor:
|
||||||
|
for pos, track in enumerate(downloadObject.conversion_data, start=0):
|
||||||
|
collection[pos] = executor.submit(self.convertTrack,
|
||||||
|
dz, downloadObject,
|
||||||
|
track, pos,
|
||||||
|
conversion,
|
||||||
|
cache, listener
|
||||||
|
).result()
|
||||||
|
|
||||||
|
downloadObject.collection['tracks'] = collection
|
||||||
|
downloadObject.size = len(collection)
|
||||||
|
downloadObject = Collection(downloadObject.toDict())
|
||||||
|
if listener: listener.send("finishConversion", downloadObject.getSlimmedDict())
|
||||||
|
|
||||||
|
self.saveCache(cache)
|
||||||
|
return downloadObject
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _convertPlaylistStructure(cls, spotifyPlaylist):
|
||||||
|
cover = None
|
||||||
|
if len(spotifyPlaylist['images']): cover = spotifyPlaylist['images'][0]['url']
|
||||||
|
|
||||||
|
deezerPlaylist = {
|
||||||
|
'checksum': spotifyPlaylist['snapshot_id'],
|
||||||
|
'collaborative': spotifyPlaylist['collaborative'],
|
||||||
|
'creation_date': "XXXX-00-00",
|
||||||
|
'creator': {
|
||||||
|
'id': spotifyPlaylist['owner']['id'],
|
||||||
|
'name': spotifyPlaylist['owner']['display_name'],
|
||||||
|
'tracklist': spotifyPlaylist['owner']['href'],
|
||||||
|
'type': "user"
|
||||||
|
},
|
||||||
|
'description': spotifyPlaylist['description'],
|
||||||
|
'duration': 0,
|
||||||
|
'fans': spotifyPlaylist['followers']['total'] if 'followers' in spotifyPlaylist else 0,
|
||||||
|
'id': spotifyPlaylist['id'],
|
||||||
|
'is_loved_track': False,
|
||||||
|
'link': spotifyPlaylist['external_urls']['spotify'],
|
||||||
|
'nb_tracks': spotifyPlaylist['tracks']['total'],
|
||||||
|
'picture': cover,
|
||||||
|
'picture_small': cover or "https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/56x56-000000-80-0-0.jpg",
|
||||||
|
'picture_medium': cover or "https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/250x250-000000-80-0-0.jpg",
|
||||||
|
'picture_big': cover or "https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/500x500-000000-80-0-0.jpg",
|
||||||
|
'picture_xl': cover or "https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/1000x1000-000000-80-0-0.jpg",
|
||||||
|
'picture_thumbnail': cover or "https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/75x75-000000-80-0-0.jpg",
|
||||||
|
'public': spotifyPlaylist['public'],
|
||||||
|
'share': spotifyPlaylist['external_urls']['spotify'],
|
||||||
|
'title': spotifyPlaylist['name'],
|
||||||
|
'tracklist': spotifyPlaylist['tracks']['href'],
|
||||||
|
'type': "playlist"
|
||||||
|
}
|
||||||
|
return deezerPlaylist
|
||||||
|
|
||||||
|
def loadSettings(self):
|
||||||
|
if not (self.configFolder / 'config.json').is_file():
|
||||||
|
with open(self.configFolder / 'config.json', 'w', encoding="utf-8") as f:
|
||||||
|
json.dump({**self.credentials, **self.settings}, f, indent=2)
|
||||||
|
|
||||||
|
with open(self.configFolder / 'config.json', 'r', encoding="utf-8") as settingsFile:
|
||||||
|
try:
|
||||||
|
settings = json.load(settingsFile)
|
||||||
|
except json.decoder.JSONDecodeError:
|
||||||
|
with open(self.configFolder / 'config.json', 'w', encoding="utf-8") as f:
|
||||||
|
json.dump({**self.credentials, **self.settings}, f, indent=2)
|
||||||
|
settings = deepcopy({**self.credentials, **self.settings})
|
||||||
|
except Exception:
|
||||||
|
settings = deepcopy({**self.credentials, **self.settings})
|
||||||
|
|
||||||
|
self.setSettings(settings)
|
||||||
|
self.checkCredentials()
|
||||||
|
|
||||||
|
def saveSettings(self, newSettings=None):
|
||||||
|
if newSettings: self.setSettings(newSettings)
|
||||||
|
self.checkCredentials()
|
||||||
|
with open(self.configFolder / 'config.json', 'w', encoding="utf-8") as f:
|
||||||
|
json.dump({**self.credentials, **self.settings}, f, indent=2)
|
||||||
|
|
||||||
|
def getSettings(self):
|
||||||
|
return {**self.credentials, **self.settings}
|
||||||
|
|
||||||
|
def setSettings(self, newSettings):
|
||||||
|
self.credentials = { 'clientId': newSettings['clientId'], 'clientSecret': newSettings['clientSecret'] }
|
||||||
|
settings = {**newSettings}
|
||||||
|
del settings['clientId']
|
||||||
|
del settings['clientSecret']
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
def loadCache(self):
|
||||||
|
cache = None
|
||||||
|
if (self.configFolder / 'cache.json').is_file():
|
||||||
|
with open(self.configFolder / 'cache.json', 'r', encoding="utf-8") as f:
|
||||||
|
try:
|
||||||
|
cache = json.load(f)
|
||||||
|
except json.decoder.JSONDecodeError:
|
||||||
|
self.saveCache({'tracks': {}, 'albums': {}})
|
||||||
|
cache = None
|
||||||
|
except Exception:
|
||||||
|
cache = None
|
||||||
|
if not cache: cache = {'tracks': {}, 'albums': {}}
|
||||||
|
return cache
|
||||||
|
|
||||||
|
def saveCache(self, newCache):
|
||||||
|
with open(self.configFolder / 'cache.json', 'w', encoding="utf-8") as spotifyCache:
|
||||||
|
json.dump(newCache, spotifyCache)
|
||||||
|
|
||||||
|
def checkCredentials(self):
|
||||||
|
if self.credentials['clientId'] == "" or self.credentials['clientSecret'] == "":
|
||||||
|
self.enabled = False
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache_handler = CacheFileHandler(self.configFolder / ".auth-cache")
|
||||||
|
client_credentials_manager = SpotifyClientCredentials(client_id=self.credentials['clientId'],
|
||||||
|
client_secret=self.credentials['clientSecret'],
|
||||||
|
cache_handler=cache_handler)
|
||||||
|
self.sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager)
|
||||||
|
self.sp.user_playlists('spotify')
|
||||||
|
self.enabled = True
|
||||||
|
except Exception:
|
||||||
|
self.enabled = False
|
||||||
|
|
||||||
|
def getCredentials(self):
|
||||||
|
return self.credentials
|
||||||
|
|
||||||
|
def setCredentials(self, clientId, clientSecret):
|
||||||
|
# Remove extra spaces, just to be sure
|
||||||
|
clientId = clientId.strip()
|
||||||
|
clientSecret = clientSecret.strip()
|
||||||
|
|
||||||
|
# Save them to disk
|
||||||
|
self.credentials = { 'clientId': clientId, 'clientSecret': clientSecret}
|
||||||
|
self.saveSettings()
|
|
@ -0,0 +1,152 @@
|
||||||
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from os import makedirs
|
||||||
|
from deezer import TrackFormats
|
||||||
|
import deemix.utils.localpaths as localpaths
|
||||||
|
|
||||||
|
class OverwriteOption():
|
||||||
|
"""Should the lib overwrite files?"""
|
||||||
|
OVERWRITE = 'y' # Yes, overwrite the file
|
||||||
|
DONT_OVERWRITE = 'n' # No, don't overwrite the file
|
||||||
|
DONT_CHECK_EXT = 'e' # No, and don't check for extensions
|
||||||
|
KEEP_BOTH = 'b' # No, and keep both files
|
||||||
|
ONLY_TAGS = 't' # Overwrite only the tags
|
||||||
|
|
||||||
|
class FeaturesOption():
|
||||||
|
"""What should I do with featured artists?"""
|
||||||
|
NO_CHANGE = "0" # Do nothing
|
||||||
|
REMOVE_TITLE = "1" # Remove from track title
|
||||||
|
REMOVE_TITLE_ALBUM = "3" # Remove from track title and album title
|
||||||
|
MOVE_TITLE = "2" # Move to track title
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
"albumNameTemplate": "%artist% - %album%",
|
||||||
|
"albumTracknameTemplate": "%tracknumber% - %title%",
|
||||||
|
"albumVariousArtists": True,
|
||||||
|
"artistCasing": "nothing",
|
||||||
|
"artistImageTemplate": "folder",
|
||||||
|
"artistNameTemplate": "%artist%",
|
||||||
|
"coverImageTemplate": "cover",
|
||||||
|
"createAlbumFolder": True,
|
||||||
|
"createArtistFolder": False,
|
||||||
|
"createCDFolder": True,
|
||||||
|
"createM3U8File": False,
|
||||||
|
"createPlaylistFolder": True,
|
||||||
|
"createSingleFolder": False,
|
||||||
|
"createStructurePlaylist": False,
|
||||||
|
"dateFormat": "Y-M-D",
|
||||||
|
"downloadLocation": str(localpaths.getMusicFolder()),
|
||||||
|
"embeddedArtworkPNG": False,
|
||||||
|
"embeddedArtworkSize": 800,
|
||||||
|
"executeCommand": "",
|
||||||
|
"fallbackBitrate": False,
|
||||||
|
"fallbackISRC": False,
|
||||||
|
"fallbackSearch": False,
|
||||||
|
"featuredToTitle": FeaturesOption.NO_CHANGE,
|
||||||
|
"feelingLucky": False,
|
||||||
|
"illegalCharacterReplacer": "_",
|
||||||
|
"jpegImageQuality": 90,
|
||||||
|
"localArtworkFormat": "jpg",
|
||||||
|
"localArtworkSize": 1400,
|
||||||
|
"logErrors": True,
|
||||||
|
"logSearched": False,
|
||||||
|
"maxBitrate": TrackFormats.MP3_320,
|
||||||
|
"overwriteFile": OverwriteOption.DONT_OVERWRITE,
|
||||||
|
"paddingSize": "0",
|
||||||
|
"padTracks": True,
|
||||||
|
"playlistFilenameTemplate": "playlist",
|
||||||
|
"playlistNameTemplate": "%playlist%",
|
||||||
|
"playlistTracknameTemplate": "%position% - %artist% - %title%",
|
||||||
|
"queueConcurrency": 3,
|
||||||
|
"removeAlbumVersion": False,
|
||||||
|
"removeDuplicateArtists": True,
|
||||||
|
"saveArtwork": True,
|
||||||
|
"saveArtworkArtist": False,
|
||||||
|
"syncedLyrics": False,
|
||||||
|
"tags": {
|
||||||
|
"album": True,
|
||||||
|
"albumArtist": True,
|
||||||
|
"artist": True,
|
||||||
|
"artists": True,
|
||||||
|
"barcode": True,
|
||||||
|
"bpm": True,
|
||||||
|
"composer": False,
|
||||||
|
"copyright": False,
|
||||||
|
"cover": True,
|
||||||
|
"coverDescriptionUTF8": False,
|
||||||
|
"date": True,
|
||||||
|
"discNumber": True,
|
||||||
|
"discTotal": False,
|
||||||
|
"explicit": False,
|
||||||
|
"genre": True,
|
||||||
|
"involvedPeople": False,
|
||||||
|
"isrc": True,
|
||||||
|
"label": True,
|
||||||
|
"length": True,
|
||||||
|
"lyrics": False,
|
||||||
|
"multiArtistSeparator": "default",
|
||||||
|
"rating": False,
|
||||||
|
"replayGain": False,
|
||||||
|
"saveID3v1": True,
|
||||||
|
"savePlaylistAsCompilation": False,
|
||||||
|
"singleAlbumArtist": False,
|
||||||
|
"source": False,
|
||||||
|
"syncedLyrics": False,
|
||||||
|
"title": True,
|
||||||
|
"trackNumber": True,
|
||||||
|
"trackTotal": False,
|
||||||
|
"useNullSeparator": False,
|
||||||
|
"year": True,
|
||||||
|
},
|
||||||
|
"titleCasing": "nothing",
|
||||||
|
"tracknameTemplate": "%artist% - %title%",
|
||||||
|
}
|
||||||
|
|
||||||
|
def save(settings, configFolder=None):
|
||||||
|
configFolder = Path(configFolder or localpaths.getConfigFolder())
|
||||||
|
makedirs(configFolder, exist_ok=True) # Create config folder if it doesn't exsist
|
||||||
|
|
||||||
|
with open(configFolder / 'config.json', 'w', encoding="utf-8") as configFile:
|
||||||
|
json.dump(settings, configFile, indent=2)
|
||||||
|
|
||||||
|
def load(configFolder=None):
|
||||||
|
configFolder = Path(configFolder or localpaths.getConfigFolder())
|
||||||
|
makedirs(configFolder, exist_ok=True) # Create config folder if it doesn't exsist
|
||||||
|
if not (configFolder / 'config.json').is_file(): save(DEFAULTS, configFolder) # Create config file if it doesn't exsist
|
||||||
|
|
||||||
|
# Read config file
|
||||||
|
with open(configFolder / 'config.json', 'r', encoding="utf-8") as configFile:
|
||||||
|
try:
|
||||||
|
settings = json.load(configFile)
|
||||||
|
except json.decoder.JSONDecodeError:
|
||||||
|
save(DEFAULTS, configFolder)
|
||||||
|
settings = deepcopy(DEFAULTS)
|
||||||
|
except Exception:
|
||||||
|
settings = deepcopy(DEFAULTS)
|
||||||
|
|
||||||
|
if check(settings) > 0:
|
||||||
|
try:
|
||||||
|
save(settings, configFolder) # Check the settings and save them if something changed
|
||||||
|
except:
|
||||||
|
print(f"Error saving config file {configFile.name}, continuing without saving.")
|
||||||
|
return settings
|
||||||
|
|
||||||
|
def check(settings):
|
||||||
|
changes = 0
|
||||||
|
for i_set in DEFAULTS:
|
||||||
|
if not i_set in settings or not type(settings[i_set] is type(DEFAULTS[i_set])):
|
||||||
|
settings[i_set] = DEFAULTS[i_set]
|
||||||
|
changes += 1
|
||||||
|
for i_set in DEFAULTS['tags']:
|
||||||
|
if not i_set in settings['tags'] or not isinstance(settings['tags'][i_set], type(DEFAULTS['tags'][i_set])):
|
||||||
|
settings['tags'][i_set] = DEFAULTS['tags'][i_set]
|
||||||
|
changes += 1
|
||||||
|
if settings['downloadLocation'] == "":
|
||||||
|
settings['downloadLocation'] = DEFAULTS['downloadLocation']
|
||||||
|
changes += 1
|
||||||
|
for template in ['tracknameTemplate', 'albumTracknameTemplate', 'playlistTracknameTemplate', 'playlistNameTemplate', 'artistNameTemplate', 'albumNameTemplate', 'playlistFilenameTemplate', 'coverImageTemplate', 'artistImageTemplate', 'paddingSize']:
|
||||||
|
if settings[template] == "":
|
||||||
|
settings[template] = DEFAULTS[template]
|
||||||
|
changes += 1
|
||||||
|
return changes
|
|
@ -1,13 +1,13 @@
|
||||||
from mutagen.flac import FLAC, Picture
|
from mutagen.flac import FLAC, Picture
|
||||||
from mutagen.id3 import ID3, ID3NoHeaderError, \
|
from mutagen.id3 import ID3, ID3NoHeaderError, \
|
||||||
TXXX, TIT2, TPE1, TALB, TPE2, TRCK, TPOS, TCON, TYER, TDAT, TLEN, TBPM, \
|
TXXX, TIT2, TPE1, TALB, TPE2, TRCK, TPOS, TCON, TYER, TDAT, TLEN, TBPM, \
|
||||||
TPUB, TSRC, USLT, SYLT, APIC, IPLS, TCOM, TCOP, TCMP, Encoding, PictureType
|
TPUB, TSRC, USLT, SYLT, APIC, IPLS, TCOM, TCOP, TCMP, Encoding, PictureType, POPM
|
||||||
|
|
||||||
# Adds tags to a MP3 file
|
# Adds tags to a MP3 file
|
||||||
def tagID3(stream, track, save):
|
def tagID3(path, track, save):
|
||||||
# Delete exsisting tags
|
# Delete exsisting tags
|
||||||
try:
|
try:
|
||||||
tag = ID3(stream)
|
tag = ID3(path)
|
||||||
tag.delete()
|
tag.delete()
|
||||||
except ID3NoHeaderError:
|
except ID3NoHeaderError:
|
||||||
tag = ID3()
|
tag = ID3()
|
||||||
|
@ -25,6 +25,7 @@ def tagID3(stream, track, save):
|
||||||
tag.add(TPE1(text=track.artistsString))
|
tag.add(TPE1(text=track.artistsString))
|
||||||
# Tag ARTISTS is added to keep the multiartist support when using a non standard tagging method
|
# Tag ARTISTS is added to keep the multiartist support when using a non standard tagging method
|
||||||
# https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html#artists
|
# https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html#artists
|
||||||
|
if save['artists']:
|
||||||
tag.add(TXXX(desc="ARTISTS", text=track.artists))
|
tag.add(TXXX(desc="ARTISTS", text=track.artists))
|
||||||
|
|
||||||
if save['album']:
|
if save['album']:
|
||||||
|
@ -58,7 +59,7 @@ def tagID3(stream, track, save):
|
||||||
tag.add(TDAT(text=str(track.date.day) + str(track.date.month)))
|
tag.add(TDAT(text=str(track.date.day) + str(track.date.month)))
|
||||||
if save['length']:
|
if save['length']:
|
||||||
tag.add(TLEN(text=str(int(track.duration)*1000)))
|
tag.add(TLEN(text=str(int(track.duration)*1000)))
|
||||||
if save['bpm']:
|
if save['bpm'] and track.bpm:
|
||||||
tag.add(TBPM(text=str(track.bpm)))
|
tag.add(TBPM(text=str(track.bpm)))
|
||||||
if save['label']:
|
if save['label']:
|
||||||
tag.add(TPUB(text=track.album.label))
|
tag.add(TPUB(text=track.album.label))
|
||||||
|
@ -89,7 +90,7 @@ def tagID3(stream, track, save):
|
||||||
if len(involved_people) > 0 and save['involvedPeople']:
|
if len(involved_people) > 0 and save['involvedPeople']:
|
||||||
tag.add(IPLS(people=involved_people))
|
tag.add(IPLS(people=involved_people))
|
||||||
|
|
||||||
if save['copyright']:
|
if save['copyright'] and track.copyright:
|
||||||
tag.add(TCOP(text=track.copyright))
|
tag.add(TCOP(text=track.copyright))
|
||||||
if save['savePlaylistAsCompilation'] and track.playlist or track.album.recordType == "compile":
|
if save['savePlaylistAsCompilation'] and track.playlist or track.album.recordType == "compile":
|
||||||
tag.add(TCMP(text="1"))
|
tag.add(TCMP(text="1"))
|
||||||
|
@ -98,6 +99,15 @@ def tagID3(stream, track, save):
|
||||||
tag.add(TXXX(desc="SOURCE", text='Deezer'))
|
tag.add(TXXX(desc="SOURCE", text='Deezer'))
|
||||||
tag.add(TXXX(desc="SOURCEID", text=str(track.id)))
|
tag.add(TXXX(desc="SOURCEID", text=str(track.id)))
|
||||||
|
|
||||||
|
if save['rating']:
|
||||||
|
rank = round((int(track.rank) / 10000) * 2.55)
|
||||||
|
if rank > 255 :
|
||||||
|
rank = 255
|
||||||
|
else:
|
||||||
|
rank = round(rank, 0)
|
||||||
|
|
||||||
|
tag.add(POPM(rating=rank))
|
||||||
|
|
||||||
if save['cover'] and track.album.embeddedCoverPath:
|
if save['cover'] and track.album.embeddedCoverPath:
|
||||||
|
|
||||||
descEncoding = Encoding.LATIN1
|
descEncoding = Encoding.LATIN1
|
||||||
|
@ -111,15 +121,15 @@ def tagID3(stream, track, save):
|
||||||
with open(track.album.embeddedCoverPath, 'rb') as f:
|
with open(track.album.embeddedCoverPath, 'rb') as f:
|
||||||
tag.add(APIC(descEncoding, mimeType, PictureType.COVER_FRONT, desc='cover', data=f.read()))
|
tag.add(APIC(descEncoding, mimeType, PictureType.COVER_FRONT, desc='cover', data=f.read()))
|
||||||
|
|
||||||
tag.save( stream,
|
tag.save( path,
|
||||||
v1=2 if save['saveID3v1'] else 0,
|
v1=2 if save['saveID3v1'] else 0,
|
||||||
v2_version=3,
|
v2_version=3,
|
||||||
v23_sep=None if save['useNullSeparator'] else '/' )
|
v23_sep=None if save['useNullSeparator'] else '/' )
|
||||||
|
|
||||||
# Adds tags to a FLAC file
|
# Adds tags to a FLAC file
|
||||||
def tagFLAC(stream, track, save):
|
def tagFLAC(path, track, save):
|
||||||
# Delete exsisting tags
|
# Delete exsisting tags
|
||||||
tag = FLAC(stream)
|
tag = FLAC(path)
|
||||||
tag.delete()
|
tag.delete()
|
||||||
tag.clear_pictures()
|
tag.clear_pictures()
|
||||||
|
|
||||||
|
@ -136,6 +146,7 @@ def tagFLAC(stream, track, save):
|
||||||
tag["ARTIST"] = track.artistsString
|
tag["ARTIST"] = track.artistsString
|
||||||
# Tag ARTISTS is added to keep the multiartist support when using a non standard tagging method
|
# Tag ARTISTS is added to keep the multiartist support when using a non standard tagging method
|
||||||
# https://picard-docs.musicbrainz.org/en/technical/tag_mapping.html#artists
|
# https://picard-docs.musicbrainz.org/en/technical/tag_mapping.html#artists
|
||||||
|
if save['artists']:
|
||||||
tag["ARTISTS"] = track.artists
|
tag["ARTISTS"] = track.artists
|
||||||
|
|
||||||
if save['album']:
|
if save['album']:
|
||||||
|
@ -168,7 +179,7 @@ def tagFLAC(stream, track, save):
|
||||||
|
|
||||||
if save['length']:
|
if save['length']:
|
||||||
tag["LENGTH"] = str(int(track.duration)*1000)
|
tag["LENGTH"] = str(int(track.duration)*1000)
|
||||||
if save['bpm']:
|
if save['bpm'] and track.bpm:
|
||||||
tag["BPM"] = str(track.bpm)
|
tag["BPM"] = str(track.bpm)
|
||||||
if save['label']:
|
if save['label']:
|
||||||
tag["PUBLISHER"] = track.album.label
|
tag["PUBLISHER"] = track.album.label
|
||||||
|
@ -190,7 +201,7 @@ def tagFLAC(stream, track, save):
|
||||||
elif role == 'musicpublisher' and save['involvedPeople']:
|
elif role == 'musicpublisher' and save['involvedPeople']:
|
||||||
tag["ORGANIZATION"] = track.contributors['musicpublisher']
|
tag["ORGANIZATION"] = track.contributors['musicpublisher']
|
||||||
|
|
||||||
if save['copyright']:
|
if save['copyright'] and track.copyright:
|
||||||
tag["COPYRIGHT"] = track.copyright
|
tag["COPYRIGHT"] = track.copyright
|
||||||
if save['savePlaylistAsCompilation'] and track.playlist or track.album.recordType == "compile":
|
if save['savePlaylistAsCompilation'] and track.playlist or track.album.recordType == "compile":
|
||||||
tag["COMPILATION"] = "1"
|
tag["COMPILATION"] = "1"
|
||||||
|
@ -199,6 +210,10 @@ def tagFLAC(stream, track, save):
|
||||||
tag["SOURCE"] = 'Deezer'
|
tag["SOURCE"] = 'Deezer'
|
||||||
tag["SOURCEID"] = str(track.id)
|
tag["SOURCEID"] = str(track.id)
|
||||||
|
|
||||||
|
if save['rating']:
|
||||||
|
rank = round((int(track.rank) / 10000))
|
||||||
|
tag['RATING'] = str(rank)
|
||||||
|
|
||||||
if save['cover'] and track.album.embeddedCoverPath:
|
if save['cover'] and track.album.embeddedCoverPath:
|
||||||
image = Picture()
|
image = Picture()
|
||||||
image.type = PictureType.COVER_FRONT
|
image.type = PictureType.COVER_FRONT
|
|
@ -1,50 +1,59 @@
|
||||||
from deezer.gw import LyricsStatus
|
|
||||||
|
|
||||||
from deemix.utils import removeDuplicateArtists, removeFeatures
|
from deemix.utils import removeDuplicateArtists, removeFeatures
|
||||||
from deemix.types.Artist import Artist
|
from deemix.types.Artist import Artist
|
||||||
from deemix.types.Date import Date
|
from deemix.types.Date import Date
|
||||||
from deemix.types.Picture import Picture
|
from deemix.types.Picture import Picture
|
||||||
from deemix import VARIOUS_ARTISTS
|
from deemix.types import VARIOUS_ARTISTS
|
||||||
|
|
||||||
class Album:
|
class Album:
|
||||||
def __init__(self, id="0", title="", pic_md5=""):
|
def __init__(self, alb_id="0", title="", pic_md5=""):
|
||||||
self.id = id
|
self.id = alb_id
|
||||||
self.title = title
|
self.title = title
|
||||||
self.pic = Picture(md5=pic_md5, type="cover")
|
self.pic = Picture(pic_md5, "cover")
|
||||||
self.artist = {"Main": []}
|
self.artist = {"Main": []}
|
||||||
self.artists = []
|
self.artists = []
|
||||||
self.mainArtist = None
|
self.mainArtist = None
|
||||||
self.dateString = None
|
self.date = Date()
|
||||||
self.barcode = "Unknown"
|
self.dateString = ""
|
||||||
self.date = None
|
self.trackTotal = "0"
|
||||||
self.discTotal = "0"
|
self.discTotal = "0"
|
||||||
self.embeddedCoverPath = None
|
self.embeddedCoverPath = ""
|
||||||
self.embeddedCoverURL = None
|
self.embeddedCoverURL = ""
|
||||||
self.explicit = False
|
self.explicit = False
|
||||||
self.genre = []
|
self.genre = []
|
||||||
|
self.barcode = "Unknown"
|
||||||
self.label = "Unknown"
|
self.label = "Unknown"
|
||||||
|
self.copyright = ""
|
||||||
self.recordType = "album"
|
self.recordType = "album"
|
||||||
self.rootArtist = None
|
|
||||||
self.trackTotal = "0"
|
|
||||||
self.bitrate = 0
|
self.bitrate = 0
|
||||||
|
self.rootArtist = None
|
||||||
self.variousArtists = None
|
self.variousArtists = None
|
||||||
|
|
||||||
|
self.playlistID = None
|
||||||
|
self.owner = None
|
||||||
|
self.isPlaylist = False
|
||||||
|
|
||||||
def parseAlbum(self, albumAPI):
|
def parseAlbum(self, albumAPI):
|
||||||
self.title = albumAPI['title']
|
self.title = albumAPI['title']
|
||||||
|
|
||||||
# Getting artist image ID
|
# Getting artist image ID
|
||||||
# ex: https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/56x56-000000-80-0-0.jpg
|
# ex: https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/56x56-000000-80-0-0.jpg
|
||||||
artistPicture = albumAPI['artist']['picture_small']
|
art_pic = albumAPI['artist'].get('picture_small')
|
||||||
artistPicture = artistPicture[artistPicture.find('artist/') + 7:-24]
|
if art_pic: art_pic = art_pic[art_pic.find('artist/') + 7:-24]
|
||||||
|
else: art_pic = ""
|
||||||
self.mainArtist = Artist(
|
self.mainArtist = Artist(
|
||||||
id = albumAPI['artist']['id'],
|
albumAPI['artist']['id'],
|
||||||
name = albumAPI['artist']['name'],
|
albumAPI['artist']['name'],
|
||||||
pic_md5 = artistPicture
|
"Main",
|
||||||
|
art_pic
|
||||||
)
|
)
|
||||||
if albumAPI.get('root_artist'):
|
if albumAPI.get('root_artist'):
|
||||||
|
art_pic = albumAPI['root_artist']['picture_small']
|
||||||
|
art_pic = art_pic[art_pic.find('artist/') + 7:-24]
|
||||||
self.rootArtist = Artist(
|
self.rootArtist = Artist(
|
||||||
id = albumAPI['root_artist']['id'],
|
albumAPI['root_artist']['id'],
|
||||||
name = albumAPI['root_artist']['name']
|
albumAPI['root_artist']['name'],
|
||||||
|
"Root",
|
||||||
|
art_pic
|
||||||
)
|
)
|
||||||
|
|
||||||
for artist in albumAPI['contributors']:
|
for artist in albumAPI['contributors']:
|
||||||
|
@ -53,7 +62,7 @@ class Album:
|
||||||
|
|
||||||
if isVariousArtists:
|
if isVariousArtists:
|
||||||
self.variousArtists = Artist(
|
self.variousArtists = Artist(
|
||||||
id = artist['id'],
|
art_id = artist['id'],
|
||||||
name = artist['name'],
|
name = artist['name'],
|
||||||
role = artist['role']
|
role = artist['role']
|
||||||
)
|
)
|
||||||
|
@ -68,52 +77,36 @@ class Album:
|
||||||
self.artist[artist['role']].append(artist['name'])
|
self.artist[artist['role']].append(artist['name'])
|
||||||
|
|
||||||
self.trackTotal = albumAPI['nb_tracks']
|
self.trackTotal = albumAPI['nb_tracks']
|
||||||
self.recordType = albumAPI['record_type']
|
self.recordType = albumAPI.get('record_type', self.recordType)
|
||||||
|
|
||||||
self.barcode = albumAPI.get('upc', self.barcode)
|
self.barcode = albumAPI.get('upc', self.barcode)
|
||||||
self.label = albumAPI.get('label', self.label)
|
self.label = albumAPI.get('label', self.label)
|
||||||
self.explicit = bool(albumAPI.get('explicit_lyrics', False))
|
self.explicit = bool(albumAPI.get('explicit_lyrics', False))
|
||||||
if 'release_date' in albumAPI:
|
release_date = albumAPI.get('release_date')
|
||||||
day = albumAPI["release_date"][8:10]
|
if 'physical_release_date' in albumAPI:
|
||||||
month = albumAPI["release_date"][5:7]
|
release_date = albumAPI['physical_release_date']
|
||||||
year = albumAPI["release_date"][0:4]
|
if release_date:
|
||||||
self.date = Date(year, month, day)
|
self.date.day = release_date[8:10]
|
||||||
|
self.date.month = release_date[5:7]
|
||||||
|
self.date.year = release_date[0:4]
|
||||||
|
self.date.fixDayMonth()
|
||||||
|
|
||||||
self.discTotal = albumAPI.get('nb_disk')
|
self.discTotal = albumAPI.get('nb_disk', "1")
|
||||||
self.copyright = albumAPI.get('copyright')
|
self.copyright = albumAPI.get('copyright', "")
|
||||||
|
|
||||||
if not self.pic.md5:
|
if not self.pic.md5 or self.pic.md5 == "":
|
||||||
|
if albumAPI.get('md5_image'):
|
||||||
|
self.pic.md5 = albumAPI['md5_image']
|
||||||
|
elif albumAPI.get('cover_small'):
|
||||||
# Getting album cover MD5
|
# Getting album cover MD5
|
||||||
# ex: https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/56x56-000000-80-0-0.jpg
|
# ex: https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/56x56-000000-80-0-0.jpg
|
||||||
self.pic.md5 = albumAPI['cover_small'][albumAPI['cover_small'].find('cover/') + 6:-24]
|
alb_pic = albumAPI['cover_small']
|
||||||
|
self.pic.md5 = alb_pic[alb_pic.find('cover/') + 6:-24]
|
||||||
|
|
||||||
if albumAPI.get('genres') and len(albumAPI['genres'].get('data', [])) > 0:
|
if albumAPI.get('genres') and len(albumAPI['genres'].get('data', [])) > 0:
|
||||||
for genre in albumAPI['genres']['data']:
|
for genre in albumAPI['genres']['data']:
|
||||||
self.genre.append(genre['name'])
|
self.genre.append(genre['name'])
|
||||||
|
|
||||||
def parseAlbumGW(self, albumAPI_gw):
|
|
||||||
self.title = albumAPI_gw['ALB_TITLE']
|
|
||||||
self.mainArtist = Artist(
|
|
||||||
id = albumAPI_gw['ART_ID'],
|
|
||||||
name = albumAPI_gw['ART_NAME']
|
|
||||||
)
|
|
||||||
|
|
||||||
self.artists = [albumAPI_gw['ART_NAME']]
|
|
||||||
self.trackTotal = albumAPI_gw['NUMBER_TRACK']
|
|
||||||
self.discTotal = albumAPI_gw['NUMBER_DISK']
|
|
||||||
self.label = albumAPI_gw.get('LABEL_NAME', self.label)
|
|
||||||
|
|
||||||
explicitLyricsStatus = albumAPI_gw.get('EXPLICIT_ALBUM_CONTENT', {}).get('EXPLICIT_LYRICS_STATUS', LyricsStatus.UNKNOWN)
|
|
||||||
self.explicit = explicitLyricsStatus in [LyricsStatus.EXPLICIT, LyricsStatus.PARTIALLY_EXPLICIT]
|
|
||||||
|
|
||||||
if not self.pic.md5:
|
|
||||||
self.pic.md5 = albumAPI_gw['ALB_PICTURE']
|
|
||||||
if 'PHYSICAL_RELEASE_DATE' in albumAPI_gw:
|
|
||||||
day = albumAPI_gw["PHYSICAL_RELEASE_DATE"][8:10]
|
|
||||||
month = albumAPI_gw["PHYSICAL_RELEASE_DATE"][5:7]
|
|
||||||
year = albumAPI_gw["PHYSICAL_RELEASE_DATE"][0:4]
|
|
||||||
self.date = Date(year, month, day)
|
|
||||||
|
|
||||||
def makePlaylistCompilation(self, playlist):
|
def makePlaylistCompilation(self, playlist):
|
||||||
self.variousArtists = playlist.variousArtists
|
self.variousArtists = playlist.variousArtists
|
||||||
self.mainArtist = playlist.mainArtist
|
self.mainArtist = playlist.mainArtist
|
||||||
|
@ -128,13 +121,15 @@ class Album:
|
||||||
self.explicit = playlist.explicit
|
self.explicit = playlist.explicit
|
||||||
self.date = playlist.date
|
self.date = playlist.date
|
||||||
self.discTotal = playlist.discTotal
|
self.discTotal = playlist.discTotal
|
||||||
self.playlistId = playlist.playlistId
|
self.playlistID = playlist.playlistID
|
||||||
self.owner = playlist.owner
|
self.owner = playlist.owner
|
||||||
self.pic = playlist.pic
|
self.pic = playlist.pic
|
||||||
|
self.isPlaylist = True
|
||||||
|
|
||||||
def removeDuplicateArtists(self):
|
def removeDuplicateArtists(self):
|
||||||
|
"""Removes duplicate artists for both artist array and artists dict"""
|
||||||
(self.artist, self.artists) = removeDuplicateArtists(self.artist, self.artists)
|
(self.artist, self.artists) = removeDuplicateArtists(self.artist, self.artists)
|
||||||
|
|
||||||
# Removes featuring from the album name
|
|
||||||
def getCleanTitle(self):
|
def getCleanTitle(self):
|
||||||
|
"""Removes featuring from the album name"""
|
||||||
return removeFeatures(self.title)
|
return removeFeatures(self.title)
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
from deemix.types.Picture import Picture
|
from deemix.types.Picture import Picture
|
||||||
from deemix import VARIOUS_ARTISTS
|
from deemix.types import VARIOUS_ARTISTS
|
||||||
|
|
||||||
class Artist:
|
class Artist:
|
||||||
def __init__(self, id="0", name="", pic_md5="", role=""):
|
def __init__(self, art_id="0", name="", role="", pic_md5=""):
|
||||||
self.id = str(id)
|
self.id = str(art_id)
|
||||||
self.name = name
|
self.name = name
|
||||||
self.pic = Picture(md5=pic_md5, type="artist")
|
self.pic = Picture(md5=pic_md5, pic_type="artist")
|
||||||
self.role = ""
|
self.role = role
|
||||||
self.save = True
|
self.save = True
|
||||||
|
|
||||||
def isVariousArtists(self):
|
def isVariousArtists(self):
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
class Date(object):
|
class Date:
|
||||||
def __init__(self, year="XXXX", month="00", day="00"):
|
def __init__(self, day="00", month="00", year="XXXX"):
|
||||||
self.year = year
|
|
||||||
self.month = month
|
|
||||||
self.day = day
|
self.day = day
|
||||||
|
self.month = month
|
||||||
|
self.year = year
|
||||||
self.fixDayMonth()
|
self.fixDayMonth()
|
||||||
|
|
||||||
# Fix incorrect day month when detectable
|
# Fix incorrect day month when detectable
|
||||||
|
|
|
@ -0,0 +1,132 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
class IDownloadObject:
|
||||||
|
"""DownloadObject Interface"""
|
||||||
|
def __init__(self, obj):
|
||||||
|
self.type = obj['type']
|
||||||
|
self.id = obj['id']
|
||||||
|
self.bitrate = obj['bitrate']
|
||||||
|
self.title = obj['title']
|
||||||
|
self.artist = obj['artist']
|
||||||
|
self.cover = obj['cover']
|
||||||
|
self.explicit = obj.get('explicit', False)
|
||||||
|
self.size = obj.get('size', 0)
|
||||||
|
self.downloaded = obj.get('downloaded', 0)
|
||||||
|
self.failed = obj.get('failed', 0)
|
||||||
|
self.progress = obj.get('progress', 0)
|
||||||
|
self.errors = obj.get('errors', [])
|
||||||
|
self.files = obj.get('files', [])
|
||||||
|
self.extrasPath = obj.get('extrasPath', "")
|
||||||
|
if self.extrasPath: self.extrasPath = Path(self.extrasPath)
|
||||||
|
self.progressNext = 0
|
||||||
|
self.uuid = f"{self.type}_{self.id}_{self.bitrate}"
|
||||||
|
self.isCanceled = False
|
||||||
|
self.__type__ = None
|
||||||
|
|
||||||
|
def toDict(self):
|
||||||
|
return {
|
||||||
|
'type': self.type,
|
||||||
|
'id': self.id,
|
||||||
|
'bitrate': self.bitrate,
|
||||||
|
'uuid': self.uuid,
|
||||||
|
'title': self.title,
|
||||||
|
'artist': self.artist,
|
||||||
|
'cover': self.cover,
|
||||||
|
'explicit': self.explicit,
|
||||||
|
'size': self.size,
|
||||||
|
'downloaded': self.downloaded,
|
||||||
|
'failed': self.failed,
|
||||||
|
'progress': self.progress,
|
||||||
|
'errors': self.errors,
|
||||||
|
'files': self.files,
|
||||||
|
'extrasPath': str(self.extrasPath),
|
||||||
|
'__type__': self.__type__
|
||||||
|
}
|
||||||
|
|
||||||
|
def getResettedDict(self):
|
||||||
|
item = self.toDict()
|
||||||
|
item['downloaded'] = 0
|
||||||
|
item['failed'] = 0
|
||||||
|
item['progress'] = 0
|
||||||
|
item['errors'] = []
|
||||||
|
item['files'] = []
|
||||||
|
return item
|
||||||
|
|
||||||
|
def getSlimmedDict(self):
|
||||||
|
light = self.toDict()
|
||||||
|
propertiesToDelete = ['single', 'collection', 'plugin', 'conversion_data']
|
||||||
|
for prop in propertiesToDelete:
|
||||||
|
if prop in light:
|
||||||
|
del light[prop]
|
||||||
|
return light
|
||||||
|
|
||||||
|
def getEssentialDict(self):
|
||||||
|
return {
|
||||||
|
'type': self.type,
|
||||||
|
'id': self.id,
|
||||||
|
'bitrate': self.bitrate,
|
||||||
|
'uuid': self.uuid,
|
||||||
|
'title': self.title,
|
||||||
|
'artist': self.artist,
|
||||||
|
'cover': self.cover,
|
||||||
|
'explicit': self.explicit,
|
||||||
|
'size': self.size,
|
||||||
|
'extrasPath': str(self.extrasPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
def updateProgress(self, listener=None):
|
||||||
|
if round(self.progressNext) != self.progress and round(self.progressNext) % 2 == 0:
|
||||||
|
self.progress = round(self.progressNext)
|
||||||
|
if listener: listener.send("updateQueue", {'uuid': self.uuid, 'progress': self.progress})
|
||||||
|
|
||||||
|
class Single(IDownloadObject):
|
||||||
|
def __init__(self, obj):
|
||||||
|
super().__init__(obj)
|
||||||
|
self.size = 1
|
||||||
|
self.single = obj['single']
|
||||||
|
self.__type__ = "Single"
|
||||||
|
|
||||||
|
def toDict(self):
|
||||||
|
item = super().toDict()
|
||||||
|
item['single'] = self.single
|
||||||
|
return item
|
||||||
|
|
||||||
|
def completeTrackProgress(self, listener=None):
|
||||||
|
self.progressNext = 100
|
||||||
|
self.updateProgress(listener)
|
||||||
|
|
||||||
|
def removeTrackProgress(self, listener=None):
|
||||||
|
self.progressNext = 0
|
||||||
|
self.updateProgress(listener)
|
||||||
|
|
||||||
|
class Collection(IDownloadObject):
|
||||||
|
def __init__(self, obj):
|
||||||
|
super().__init__(obj)
|
||||||
|
self.collection = obj['collection']
|
||||||
|
self.__type__ = "Collection"
|
||||||
|
|
||||||
|
def toDict(self):
|
||||||
|
item = super().toDict()
|
||||||
|
item['collection'] = self.collection
|
||||||
|
return item
|
||||||
|
|
||||||
|
def completeTrackProgress(self, listener=None):
|
||||||
|
self.progressNext += (1 / self.size) * 100
|
||||||
|
self.updateProgress(listener)
|
||||||
|
|
||||||
|
def removeTrackProgress(self, listener=None):
|
||||||
|
self.progressNext -= (1 / self.size) * 100
|
||||||
|
self.updateProgress(listener)
|
||||||
|
|
||||||
|
class Convertable(Collection):
|
||||||
|
def __init__(self, obj):
|
||||||
|
super().__init__(obj)
|
||||||
|
self.plugin = obj['plugin']
|
||||||
|
self.conversion_data = obj['conversion_data']
|
||||||
|
self.__type__ = "Convertable"
|
||||||
|
|
||||||
|
def toDict(self):
|
||||||
|
item = super().toDict()
|
||||||
|
item['plugin'] = self.plugin
|
||||||
|
item['conversion_data'] = self.conversion_data
|
||||||
|
return item
|
|
@ -1,19 +1,17 @@
|
||||||
class Lyrics:
|
class Lyrics:
|
||||||
def __init__(self, id="0"):
|
def __init__(self, lyr_id="0"):
|
||||||
self.id = id
|
self.id = lyr_id
|
||||||
self.sync = None
|
self.sync = ""
|
||||||
self.unsync = None
|
self.unsync = ""
|
||||||
self.syncID3 = None
|
self.syncID3 = []
|
||||||
|
|
||||||
def parseLyrics(self, lyricsAPI):
|
def parseLyrics(self, lyricsAPI):
|
||||||
self.unsync = lyricsAPI.get("LYRICS_TEXT")
|
self.unsync = lyricsAPI.get("LYRICS_TEXT")
|
||||||
if "LYRICS_SYNC_JSON" in lyricsAPI:
|
if "LYRICS_SYNC_JSON" in lyricsAPI:
|
||||||
syncLyricsJson = lyricsAPI["LYRICS_SYNC_JSON"]
|
syncLyricsJson = lyricsAPI["LYRICS_SYNC_JSON"]
|
||||||
self.sync = ""
|
|
||||||
self.syncID3 = []
|
|
||||||
timestamp = ""
|
timestamp = ""
|
||||||
milliseconds = 0
|
milliseconds = 0
|
||||||
for line in range(len(syncLyricsJson)):
|
for line, _ in enumerate(syncLyricsJson):
|
||||||
if syncLyricsJson[line]["line"] != "":
|
if syncLyricsJson[line]["line"] != "":
|
||||||
timestamp = syncLyricsJson[line]["lrc_timestamp"]
|
timestamp = syncLyricsJson[line]["lrc_timestamp"]
|
||||||
milliseconds = int(syncLyricsJson[line]["milliseconds"])
|
milliseconds = int(syncLyricsJson[line]["milliseconds"])
|
||||||
|
@ -21,6 +19,6 @@ class Lyrics:
|
||||||
else:
|
else:
|
||||||
notEmptyLine = line + 1
|
notEmptyLine = line + 1
|
||||||
while syncLyricsJson[notEmptyLine]["line"] == "":
|
while syncLyricsJson[notEmptyLine]["line"] == "":
|
||||||
notEmptyLine = notEmptyLine + 1
|
notEmptyLine += 1
|
||||||
timestamp = syncLyricsJson[notEmptyLine]["lrc_timestamp"]
|
timestamp = syncLyricsJson[notEmptyLine]["lrc_timestamp"]
|
||||||
self.sync += timestamp + syncLyricsJson[line]["line"] + "\r\n"
|
self.sync += timestamp + syncLyricsJson[line]["line"] + "\r\n"
|
||||||
|
|
|
@ -1,27 +1,29 @@
|
||||||
class Picture:
|
class Picture:
|
||||||
def __init__(self, md5="", type=None, url=None):
|
def __init__(self, md5="", pic_type=""):
|
||||||
self.md5 = md5
|
self.md5 = md5
|
||||||
self.type = type
|
self.type = pic_type
|
||||||
self.url = url
|
|
||||||
|
|
||||||
def generatePictureURL(self, size, format):
|
def getURL(self, size, pic_format):
|
||||||
if self.url: return self.url
|
url = "https://e-cdns-images.dzcdn.net/images/{}/{}/{size}x{size}".format(
|
||||||
if format.startswith("jpg"):
|
self.type,
|
||||||
if '-' in format:
|
self.md5,
|
||||||
quality = format[4:]
|
size=size
|
||||||
else:
|
)
|
||||||
|
|
||||||
|
if pic_format.startswith("jpg"):
|
||||||
quality = 80
|
quality = 80
|
||||||
format = 'jpg'
|
if '-' in pic_format:
|
||||||
return "https://e-cdns-images.dzcdn.net/images/{}/{}/{}x{}-{}".format(
|
quality = pic_format[4:]
|
||||||
self.type,
|
pic_format = 'jpg'
|
||||||
self.md5,
|
return url + f'-000000-{quality}-0-0.jpg'
|
||||||
size, size,
|
if pic_format == 'png':
|
||||||
f'000000-{quality}-0-0.jpg'
|
return url + '-none-100-0-0.png'
|
||||||
)
|
|
||||||
if format == 'png':
|
return url+'.jpg'
|
||||||
return "https://e-cdns-images.dzcdn.net/images/{}/{}/{}x{}-{}".format(
|
|
||||||
self.type,
|
class StaticPicture:
|
||||||
self.md5,
|
def __init__(self, url):
|
||||||
size, size,
|
self.staticURL = url
|
||||||
'none-100-0-0.png'
|
|
||||||
)
|
def getURL(self, _, __):
|
||||||
|
return self.staticURL
|
||||||
|
|
|
@ -1,20 +1,9 @@
|
||||||
from deemix.types.Artist import Artist
|
from deemix.types.Artist import Artist
|
||||||
from deemix.types.Date import Date
|
from deemix.types.Date import Date
|
||||||
from deemix.types.Picture import Picture
|
from deemix.types.Picture import Picture, StaticPicture
|
||||||
|
|
||||||
class Playlist:
|
class Playlist:
|
||||||
def __init__(self, playlistAPI):
|
def __init__(self, playlistAPI):
|
||||||
if 'various_artist' in playlistAPI:
|
|
||||||
playlistAPI['various_artist']['role'] = "Main"
|
|
||||||
self.variousArtists = Artist(
|
|
||||||
id = playlistAPI['various_artist']['id'],
|
|
||||||
name = playlistAPI['various_artist']['name'],
|
|
||||||
pic_md5 = playlistAPI['various_artist']['picture_small'][
|
|
||||||
playlistAPI['various_artist']['picture_small'].find('artist/') + 7:-24],
|
|
||||||
role = playlistAPI['various_artist']['role']
|
|
||||||
)
|
|
||||||
self.mainArtist = self.variousArtists
|
|
||||||
|
|
||||||
self.id = "pl_" + str(playlistAPI['id'])
|
self.id = "pl_" + str(playlistAPI['id'])
|
||||||
self.title = playlistAPI['title']
|
self.title = playlistAPI['title']
|
||||||
self.rootArtist = None
|
self.rootArtist = None
|
||||||
|
@ -30,19 +19,28 @@ class Playlist:
|
||||||
year = playlistAPI["creation_date"][0:4]
|
year = playlistAPI["creation_date"][0:4]
|
||||||
month = playlistAPI["creation_date"][5:7]
|
month = playlistAPI["creation_date"][5:7]
|
||||||
day = playlistAPI["creation_date"][8:10]
|
day = playlistAPI["creation_date"][8:10]
|
||||||
self.date = Date(year, month, day)
|
self.date = Date(day, month, year)
|
||||||
|
|
||||||
self.discTotal = "1"
|
self.discTotal = "1"
|
||||||
self.playlistId = playlistAPI['id']
|
self.playlistID = playlistAPI['id']
|
||||||
self.owner = playlistAPI['creator']
|
self.owner = playlistAPI['creator']
|
||||||
|
|
||||||
if 'dzcdn.net' in playlistAPI['picture_small']:
|
if 'dzcdn.net' in playlistAPI['picture_small']:
|
||||||
url = playlistAPI['picture_small']
|
url = playlistAPI['picture_small']
|
||||||
picType = url[url.find('images/')+7:]
|
picType = url[url.find('images/')+7:]
|
||||||
picType = picType[:picType.find('/')]
|
picType = picType[:picType.find('/')]
|
||||||
md5 = url[url.find(picType+'/') + len(picType)+1:-24]
|
md5 = url[url.find(picType+'/') + len(picType)+1:-24]
|
||||||
self.pic = Picture(
|
self.pic = Picture(md5, picType)
|
||||||
md5 = md5,
|
|
||||||
type = picType
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.pic = Picture(url = playlistAPI['picture_xl'])
|
self.pic = StaticPicture(playlistAPI['picture_xl'])
|
||||||
|
|
||||||
|
if 'various_artist' in playlistAPI:
|
||||||
|
pic_md5 = playlistAPI['various_artist']['picture_small']
|
||||||
|
pic_md5 = pic_md5[pic_md5.find('artist/') + 7:-24]
|
||||||
|
self.variousArtists = Artist(
|
||||||
|
playlistAPI['various_artist']['id'],
|
||||||
|
playlistAPI['various_artist']['name'],
|
||||||
|
"Main",
|
||||||
|
pic_md5
|
||||||
|
)
|
||||||
|
self.mainArtist = self.variousArtists
|
||||||
|
|
|
@ -1,38 +1,42 @@
|
||||||
import eventlet
|
import re
|
||||||
requests = eventlet.import_patched('requests')
|
from datetime import datetime
|
||||||
|
|
||||||
import logging
|
from deezer.utils import map_track, map_album
|
||||||
logging.basicConfig(level=logging.INFO)
|
from deezer.errors import APIError, GWAPIError
|
||||||
logger = logging.getLogger('deemix')
|
from deemix.errors import NoDataToParse, AlbumDoesntExists
|
||||||
|
|
||||||
|
from deemix.utils import removeFeatures, andCommaConcat, removeDuplicateArtists, generateReplayGainString, changeCase
|
||||||
|
|
||||||
from deezer.gw import APIError as gwAPIError
|
|
||||||
from deezer.api import APIError
|
|
||||||
from deemix.utils import removeFeatures, andCommaConcat, removeDuplicateArtists, generateReplayGainString
|
|
||||||
from deemix.types.Album import Album
|
from deemix.types.Album import Album
|
||||||
from deemix.types.Artist import Artist
|
from deemix.types.Artist import Artist
|
||||||
from deemix.types.Date import Date
|
from deemix.types.Date import Date
|
||||||
from deemix.types.Picture import Picture
|
from deemix.types.Picture import Picture
|
||||||
from deemix.types.Playlist import Playlist
|
from deemix.types.Playlist import Playlist
|
||||||
from deemix.types.Lyrics import Lyrics
|
from deemix.types.Lyrics import Lyrics
|
||||||
from deemix import VARIOUS_ARTISTS
|
from deemix.types import VARIOUS_ARTISTS
|
||||||
|
|
||||||
|
from deemix.settings import FeaturesOption
|
||||||
|
|
||||||
class Track:
|
class Track:
|
||||||
def __init__(self, id="0", name=""):
|
def __init__(self, sng_id="0", name=""):
|
||||||
self.id = id
|
self.id = sng_id
|
||||||
self.title = name
|
self.title = name
|
||||||
self.MD5 = ""
|
self.MD5 = ""
|
||||||
self.mediaVersion = ""
|
self.mediaVersion = ""
|
||||||
|
self.trackToken = ""
|
||||||
|
self.trackTokenExpiration = 0
|
||||||
self.duration = 0
|
self.duration = 0
|
||||||
self.fallbackId = "0"
|
self.fallbackID = "0"
|
||||||
|
self.albumsFallback = []
|
||||||
self.filesizes = {}
|
self.filesizes = {}
|
||||||
self.localTrack = False
|
self.local = False
|
||||||
self.mainArtist = None
|
self.mainArtist = None
|
||||||
self.artist = {"Main": []}
|
self.artist = {"Main": []}
|
||||||
self.artists = []
|
self.artists = []
|
||||||
self.album = None
|
self.album = None
|
||||||
self.trackNumber = "0"
|
self.trackNumber = "0"
|
||||||
self.discNumber = "0"
|
self.discNumber = "0"
|
||||||
self.date = None
|
self.date = Date()
|
||||||
self.lyrics = None
|
self.lyrics = None
|
||||||
self.bpm = 0
|
self.bpm = 0
|
||||||
self.contributors = {}
|
self.contributors = {}
|
||||||
|
@ -40,88 +44,67 @@ class Track:
|
||||||
self.explicit = False
|
self.explicit = False
|
||||||
self.ISRC = ""
|
self.ISRC = ""
|
||||||
self.replayGain = ""
|
self.replayGain = ""
|
||||||
|
self.rank = 0
|
||||||
self.playlist = None
|
self.playlist = None
|
||||||
self.position = None
|
self.position = None
|
||||||
self.searched = False
|
self.searched = False
|
||||||
self.selectedFormat = 0
|
self.selectedFormat = 0
|
||||||
self.singleDownload = False
|
self.singleDownload = False
|
||||||
self.dateString = None
|
self.dateString = ""
|
||||||
self.artistsString = ""
|
self.artistsString = ""
|
||||||
self.mainArtistsString = ""
|
self.mainArtistsString = ""
|
||||||
self.featArtistsString = ""
|
self.featArtistsString = ""
|
||||||
|
self.urls = {}
|
||||||
|
|
||||||
def parseEssentialData(self, trackAPI_gw, trackAPI=None):
|
def parseEssentialData(self, trackAPI):
|
||||||
self.id = str(trackAPI_gw['SNG_ID'])
|
self.id = str(trackAPI['id'])
|
||||||
self.duration = trackAPI_gw['DURATION']
|
self.duration = trackAPI['duration']
|
||||||
self.MD5 = trackAPI_gw.get('MD5_ORIGIN')
|
self.trackToken = trackAPI['track_token']
|
||||||
if not self.MD5:
|
self.trackTokenExpiration = trackAPI['track_token_expire']
|
||||||
if trackAPI and trackAPI.get('md5_origin'):
|
self.MD5 = trackAPI.get('md5_origin')
|
||||||
self.MD5 = trackAPI['md5_origin']
|
self.mediaVersion = trackAPI['media_version']
|
||||||
else:
|
self.filesizes = trackAPI['filesizes']
|
||||||
raise MD5NotFound
|
self.fallbackID = "0"
|
||||||
self.mediaVersion = trackAPI_gw['MEDIA_VERSION']
|
if 'fallback_id' in trackAPI:
|
||||||
self.fallbackId = "0"
|
self.fallbackID = trackAPI['fallback_id']
|
||||||
if 'FALLBACK' in trackAPI_gw:
|
self.local = int(self.id) < 0
|
||||||
self.fallbackId = trackAPI_gw['FALLBACK']['SNG_ID']
|
self.urls = {}
|
||||||
self.localTrack = int(self.id) < 0
|
|
||||||
|
|
||||||
def retriveFilesizes(self, dz):
|
def parseData(self, dz, track_id=None, trackAPI=None, albumAPI=None, playlistAPI=None):
|
||||||
|
if track_id and (not trackAPI or trackAPI and not trackAPI.get('track_token')):
|
||||||
|
trackAPI_new = dz.gw.get_track_with_fallback(track_id)
|
||||||
|
trackAPI_new = map_track(trackAPI_new)
|
||||||
|
if not trackAPI: trackAPI = {}
|
||||||
|
trackAPI_new.update(trackAPI)
|
||||||
|
trackAPI = trackAPI_new
|
||||||
|
elif not trackAPI: raise NoDataToParse
|
||||||
|
|
||||||
|
self.parseEssentialData(trackAPI)
|
||||||
|
|
||||||
|
# only public api has bpm
|
||||||
|
if not trackAPI.get('bpm') and not self.local:
|
||||||
try:
|
try:
|
||||||
guest_sid = dz.session.cookies.get('sid')
|
trackAPI_new = dz.api.get_track(trackAPI['id'])
|
||||||
site = requests.post(
|
trackAPI_new['release_date'] = trackAPI['release_date']
|
||||||
"https://api.deezer.com/1.0/gateway.php",
|
trackAPI.update(trackAPI_new)
|
||||||
params={
|
except APIError: pass
|
||||||
'api_key': "4VCYIJUCDLOUELGD1V8WBVYBNVDYOXEWSLLZDONGBBDFVXTZJRXPR29JRLQFO6ZE",
|
|
||||||
'sid': guest_sid,
|
|
||||||
'input': '3',
|
|
||||||
'output': '3',
|
|
||||||
'method': 'song_getData'
|
|
||||||
},
|
|
||||||
timeout=30,
|
|
||||||
json={'sng_id': self.id},
|
|
||||||
headers=dz.http_headers
|
|
||||||
)
|
|
||||||
result_json = site.json()
|
|
||||||
except:
|
|
||||||
eventlet.sleep(2)
|
|
||||||
return self.retriveFilesizes(dz)
|
|
||||||
if len(result_json['error']):
|
|
||||||
raise APIError(json.dumps(result_json['error']))
|
|
||||||
response = result_json.get("results")
|
|
||||||
filesizes = {}
|
|
||||||
for key, value in response.items():
|
|
||||||
if key.startswith("FILESIZE_"):
|
|
||||||
filesizes[key] = value
|
|
||||||
filesizes[key+"_TESTED"] = False
|
|
||||||
self.filesizes = filesizes
|
|
||||||
|
|
||||||
def parseData(self, dz, id=None, trackAPI_gw=None, trackAPI=None, albumAPI_gw=None, albumAPI=None, playlistAPI=None):
|
if self.local:
|
||||||
if id:
|
self.parseLocalTrackData(trackAPI)
|
||||||
if not trackAPI_gw: trackAPI_gw = dz.gw.get_track_with_fallback(id)
|
|
||||||
elif not trackAPI_gw: raise NoDataToParse
|
|
||||||
if not trackAPI:
|
|
||||||
try: trackAPI = dz.api.get_track(trackAPI_gw['SNG_ID'])
|
|
||||||
except APIError: trackAPI = None
|
|
||||||
|
|
||||||
self.parseEssentialData(trackAPI_gw, trackAPI)
|
|
||||||
|
|
||||||
if self.localTrack:
|
|
||||||
self.parseLocalTrackData(trackAPI_gw)
|
|
||||||
else:
|
else:
|
||||||
self.retriveFilesizes(dz)
|
self.parseTrack(trackAPI)
|
||||||
|
|
||||||
self.parseTrackGW(trackAPI_gw)
|
|
||||||
# Get Lyrics data
|
# Get Lyrics data
|
||||||
if not "LYRICS" in trackAPI_gw and self.lyrics.id != "0":
|
if not trackAPI.get("lyrics") and self.lyrics.id != "0":
|
||||||
try: trackAPI_gw["LYRICS"] = dz.gw.get_track_lyrics(self.id)
|
try: trackAPI["lyrics"] = dz.gw.get_track_lyrics(self.id)
|
||||||
except gwAPIError: self.lyrics.id = "0"
|
except GWAPIError: self.lyrics.id = "0"
|
||||||
if self.lyrics.id != "0": self.lyrics.parseLyrics(trackAPI_gw["LYRICS"])
|
if self.lyrics.id != "0": self.lyrics.parseLyrics(trackAPI["lyrics"])
|
||||||
|
|
||||||
# Parse Album data
|
# Parse Album Data
|
||||||
self.album = Album(
|
self.album = Album(
|
||||||
id = trackAPI_gw['ALB_ID'],
|
alb_id = trackAPI['album']['id'],
|
||||||
title = trackAPI_gw['ALB_TITLE'],
|
title = trackAPI['album']['title'],
|
||||||
pic_md5 = trackAPI_gw.get('ALB_PICTURE')
|
pic_md5 = trackAPI['album'].get('md5_origin')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get album Data
|
# Get album Data
|
||||||
|
@ -130,38 +113,41 @@ class Track:
|
||||||
except APIError: albumAPI = None
|
except APIError: albumAPI = None
|
||||||
|
|
||||||
# Get album_gw Data
|
# Get album_gw Data
|
||||||
if not albumAPI_gw:
|
# Only gw has disk number
|
||||||
try: albumAPI_gw = dz.gw.get_album(self.album.id)
|
if not albumAPI or albumAPI and not albumAPI.get('nb_disk'):
|
||||||
except gwAPIError: albumAPI_gw = None
|
try:
|
||||||
|
albumAPI_gw = dz.gw.get_album(self.album.id)
|
||||||
|
albumAPI_gw = map_album(albumAPI_gw)
|
||||||
|
except GWAPIError: albumAPI_gw = {}
|
||||||
|
if not albumAPI: albumAPI = {}
|
||||||
|
albumAPI_gw.update(albumAPI)
|
||||||
|
albumAPI = albumAPI_gw
|
||||||
|
|
||||||
|
if not albumAPI: raise AlbumDoesntExists
|
||||||
|
|
||||||
if albumAPI:
|
|
||||||
self.album.parseAlbum(albumAPI)
|
self.album.parseAlbum(albumAPI)
|
||||||
elif albumAPI_gw:
|
|
||||||
self.album.parseAlbumGW(albumAPI_gw)
|
|
||||||
# albumAPI_gw doesn't contain the artist cover
|
# albumAPI_gw doesn't contain the artist cover
|
||||||
# Getting artist image ID
|
# Getting artist image ID
|
||||||
# ex: https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/56x56-000000-80-0-0.jpg
|
# ex: https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/56x56-000000-80-0-0.jpg
|
||||||
|
if not self.album.mainArtist.pic.md5 or self.album.mainArtist.pic.md5 == "":
|
||||||
artistAPI = dz.api.get_artist(self.album.mainArtist.id)
|
artistAPI = dz.api.get_artist(self.album.mainArtist.id)
|
||||||
self.album.mainArtist.pic.md5 = artistAPI['picture_small'][artistAPI['picture_small'].find('artist/') + 7:-24]
|
self.album.mainArtist.pic.md5 = artistAPI['picture_small'][artistAPI['picture_small'].find('artist/') + 7:-24]
|
||||||
else:
|
|
||||||
raise AlbumDoesntExists
|
|
||||||
|
|
||||||
# Fill missing data
|
# Fill missing data
|
||||||
if self.album.date and not self.date: self.date = self.album.date
|
if self.album.date and not self.date: self.date = self.album.date
|
||||||
if not self.album.discTotal: self.album.discTotal = albumAPI_gw.get('NUMBER_DISK', "1")
|
if 'genres' in trackAPI:
|
||||||
if not self.copyright: self.copyright = albumAPI_gw['COPYRIGHT']
|
for genre in trackAPI['genres']:
|
||||||
self.parseTrack(trackAPI)
|
if genre not in self.album.genre: self.album.genre.append(genre)
|
||||||
|
|
||||||
# Remove unwanted charaters in track name
|
# Remove unwanted charaters in track name
|
||||||
# Example: track/127793
|
# Example: track/127793
|
||||||
self.title = ' '.join(self.title.split())
|
self.title = ' '.join(self.title.split())
|
||||||
|
|
||||||
# Make sure there is at least one artist
|
# Make sure there is at least one artist
|
||||||
if not len(self.artist['Main']):
|
if len(self.artist['Main']) == 0:
|
||||||
self.artist['Main'] = [self.mainArtist['name']]
|
self.artist['Main'] = [self.mainArtist.name]
|
||||||
|
|
||||||
self.singleDownload = trackAPI_gw.get('SINGLE_TRACK', False)
|
self.position = trackAPI.get('position')
|
||||||
self.position = trackAPI_gw.get('POSITION')
|
|
||||||
|
|
||||||
# Add playlist data if track is in a playlist
|
# Add playlist data if track is in a playlist
|
||||||
if playlistAPI: self.playlist = Playlist(playlistAPI)
|
if playlistAPI: self.playlist = Playlist(playlistAPI)
|
||||||
|
@ -169,64 +155,54 @@ class Track:
|
||||||
self.generateMainFeatStrings()
|
self.generateMainFeatStrings()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def parseLocalTrackData(self, trackAPI_gw):
|
def parseLocalTrackData(self, trackAPI):
|
||||||
# Local tracks has only the trackAPI_gw page and
|
# Local tracks has only the trackAPI_gw page and
|
||||||
# contains only the tags provided by the file
|
# contains only the tags provided by the file
|
||||||
self.title = trackAPI_gw['SNG_TITLE']
|
self.title = trackAPI['title']
|
||||||
self.album = Album(title=trackAPI_gw['ALB_TITLE'])
|
self.album = Album(title=trackAPI['album']['title'])
|
||||||
self.album.pic = Picture(
|
self.album.pic = Picture(
|
||||||
md5 = trackAPI_gw.get('ALB_PICTURE', ""),
|
md5 = trackAPI.get('md5_image', ""),
|
||||||
type = "cover"
|
pic_type = "cover"
|
||||||
)
|
)
|
||||||
self.mainArtist = Artist(name=trackAPI_gw['ART_NAME'])
|
self.mainArtist = Artist(name=trackAPI['artist']['name'], role="Main")
|
||||||
self.artists = [trackAPI_gw['ART_NAME']]
|
self.artists = [trackAPI['artist']['name']]
|
||||||
self.artist = {
|
self.artist = {
|
||||||
'Main': [trackAPI_gw['ART_NAME']]
|
'Main': [trackAPI['artist']['name']]
|
||||||
}
|
}
|
||||||
self.album.artist = self.artist
|
self.album.artist = self.artist
|
||||||
self.album.artists = self.artists
|
self.album.artists = self.artists
|
||||||
self.album.date = self.date
|
self.album.date = self.date
|
||||||
self.album.mainArtist = self.mainArtist
|
self.album.mainArtist = self.mainArtist
|
||||||
self.date = Date()
|
|
||||||
|
|
||||||
def parseTrackGW(self, trackAPI_gw):
|
|
||||||
self.title = trackAPI_gw['SNG_TITLE'].strip()
|
|
||||||
if trackAPI_gw.get('VERSION') and not trackAPI_gw['VERSION'] in trackAPI_gw['SNG_TITLE']:
|
|
||||||
self.title += " " + trackAPI_gw['VERSION'].strip()
|
|
||||||
|
|
||||||
self.discNumber = trackAPI_gw.get('DISK_NUMBER')
|
|
||||||
self.explicit = bool(int(trackAPI_gw.get('EXPLICIT_LYRICS', "0")))
|
|
||||||
self.copyright = trackAPI_gw.get('COPYRIGHT')
|
|
||||||
if 'GAIN' in trackAPI_gw: self.replayGain = generateReplayGainString(trackAPI_gw['GAIN'])
|
|
||||||
self.ISRC = trackAPI_gw.get('ISRC')
|
|
||||||
self.trackNumber = trackAPI_gw['TRACK_NUMBER']
|
|
||||||
self.contributors = trackAPI_gw['SNG_CONTRIBUTORS']
|
|
||||||
|
|
||||||
self.lyrics = Lyrics(trackAPI_gw.get('LYRICS_ID', "0"))
|
|
||||||
|
|
||||||
self.mainArtist = Artist(
|
|
||||||
id = trackAPI_gw['ART_ID'],
|
|
||||||
name = trackAPI_gw['ART_NAME'],
|
|
||||||
pic_md5 = trackAPI_gw.get('ART_PICTURE')
|
|
||||||
)
|
|
||||||
|
|
||||||
if 'PHYSICAL_RELEASE_DATE' in trackAPI_gw:
|
|
||||||
day = trackAPI_gw["PHYSICAL_RELEASE_DATE"][8:10]
|
|
||||||
month = trackAPI_gw["PHYSICAL_RELEASE_DATE"][5:7]
|
|
||||||
year = trackAPI_gw["PHYSICAL_RELEASE_DATE"][0:4]
|
|
||||||
self.date = Date(year, month, day)
|
|
||||||
|
|
||||||
def parseTrack(self, trackAPI):
|
def parseTrack(self, trackAPI):
|
||||||
|
self.title = trackAPI['title']
|
||||||
|
|
||||||
|
self.discNumber = trackAPI.get('disk_number')
|
||||||
|
self.explicit = trackAPI.get('explicit_lyrics', False)
|
||||||
|
self.copyright = trackAPI.get('copyright')
|
||||||
|
if 'gain' in trackAPI: self.replayGain = generateReplayGainString(trackAPI['gain'])
|
||||||
|
self.ISRC = trackAPI.get('isrc')
|
||||||
|
self.trackNumber = trackAPI['track_position']
|
||||||
|
self.contributors = trackAPI.get('song_contributors')
|
||||||
|
self.rank = trackAPI['rank']
|
||||||
self.bpm = trackAPI['bpm']
|
self.bpm = trackAPI['bpm']
|
||||||
|
|
||||||
if not self.replayGain and 'gain' in trackAPI:
|
self.lyrics = Lyrics(trackAPI.get('lyrics_id', "0"))
|
||||||
self.replayGain = generateReplayGainString(trackAPI['gain'])
|
|
||||||
if not self.explicit:
|
|
||||||
self.explicit = trackAPI['explicit_lyrics']
|
|
||||||
if not self.discNumber:
|
|
||||||
self.discNumber = trackAPI['disk_number']
|
|
||||||
|
|
||||||
for artist in trackAPI['contributors']:
|
self.mainArtist = Artist(
|
||||||
|
art_id = trackAPI['artist']['id'],
|
||||||
|
name = trackAPI['artist']['name'],
|
||||||
|
role = "Main",
|
||||||
|
pic_md5 = trackAPI['artist'].get('md5_image')
|
||||||
|
)
|
||||||
|
|
||||||
|
if trackAPI.get('physical_release_date'):
|
||||||
|
self.date.day = trackAPI["physical_release_date"][8:10]
|
||||||
|
self.date.month = trackAPI["physical_release_date"][5:7]
|
||||||
|
self.date.year = trackAPI["physical_release_date"][0:4]
|
||||||
|
self.date.fixDayMonth()
|
||||||
|
|
||||||
|
for artist in trackAPI.get('contributors', []):
|
||||||
isVariousArtists = str(artist['id']) == VARIOUS_ARTISTS
|
isVariousArtists = str(artist['id']) == VARIOUS_ARTISTS
|
||||||
isMainArtist = artist['role'] == "Main"
|
isMainArtist = artist['role'] == "Main"
|
||||||
|
|
||||||
|
@ -241,6 +217,11 @@ class Track:
|
||||||
self.artist[artist['role']] = []
|
self.artist[artist['role']] = []
|
||||||
self.artist[artist['role']].append(artist['name'])
|
self.artist[artist['role']].append(artist['name'])
|
||||||
|
|
||||||
|
if trackAPI.get('alternative_albums'):
|
||||||
|
for album in trackAPI['alternative_albums']['data']:
|
||||||
|
if 'RIGHTS' in album and album['RIGHTS'].get('STREAM_ADS_AVAILABLE') or album['RIGHTS'].get('STREAM_SUB_AVAILABLE'):
|
||||||
|
self.albumsFallback.append(album['ALB_ID'])
|
||||||
|
|
||||||
def removeDuplicateArtists(self):
|
def removeDuplicateArtists(self):
|
||||||
(self.artist, self.artists) = removeDuplicateArtists(self.artist, self.artists)
|
(self.artist, self.artists) = removeDuplicateArtists(self.artist, self.artists)
|
||||||
|
|
||||||
|
@ -249,8 +230,8 @@ class Track:
|
||||||
return removeFeatures(self.title)
|
return removeFeatures(self.title)
|
||||||
|
|
||||||
def getFeatTitle(self):
|
def getFeatTitle(self):
|
||||||
if self.featArtistsString and not "(feat." in self.title.lower():
|
if self.featArtistsString and "feat." not in self.title.lower():
|
||||||
return self.title + " ({})".format(self.featArtistsString)
|
return f"{self.title} ({self.featArtistsString})"
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
def generateMainFeatStrings(self):
|
def generateMainFeatStrings(self):
|
||||||
|
@ -259,15 +240,83 @@ class Track:
|
||||||
if 'Featured' in self.artist:
|
if 'Featured' in self.artist:
|
||||||
self.featArtistsString = "feat. "+andCommaConcat(self.artist['Featured'])
|
self.featArtistsString = "feat. "+andCommaConcat(self.artist['Featured'])
|
||||||
|
|
||||||
class TrackError(Exception):
|
def checkAndRenewTrackToken(self, dz):
|
||||||
"""Base class for exceptions in this module."""
|
now = datetime.now()
|
||||||
pass
|
expiration = datetime.fromtimestamp(self.trackTokenExpiration)
|
||||||
|
if now > expiration:
|
||||||
|
newTrack = dz.gw.get_track_with_fallback(self.id)
|
||||||
|
self.trackToken = newTrack['TRACK_TOKEN']
|
||||||
|
self.trackTokenExpiration = newTrack['TRACK_TOKEN_EXPIRE']
|
||||||
|
|
||||||
class AlbumDoesntExists(TrackError):
|
def applySettings(self, settings):
|
||||||
pass
|
|
||||||
|
|
||||||
class MD5NotFound(TrackError):
|
# Check if should save the playlist as a compilation
|
||||||
pass
|
if self.playlist and settings['tags']['savePlaylistAsCompilation']:
|
||||||
|
self.trackNumber = self.position
|
||||||
|
self.discNumber = "1"
|
||||||
|
self.album.makePlaylistCompilation(self.playlist)
|
||||||
|
else:
|
||||||
|
if self.album.date: self.date = self.album.date
|
||||||
|
|
||||||
class NoDataToParse(TrackError):
|
self.dateString = self.date.format(settings['dateFormat'])
|
||||||
pass
|
self.album.dateString = self.album.date.format(settings['dateFormat'])
|
||||||
|
if self.playlist: self.playlist.dateString = self.playlist.date.format(settings['dateFormat'])
|
||||||
|
|
||||||
|
# Check various artist option
|
||||||
|
if settings['albumVariousArtists'] and self.album.variousArtists:
|
||||||
|
artist = self.album.variousArtists
|
||||||
|
isMainArtist = artist.role == "Main"
|
||||||
|
|
||||||
|
if artist.name not in self.album.artists:
|
||||||
|
self.album.artists.insert(0, artist.name)
|
||||||
|
|
||||||
|
if isMainArtist or artist.name not in self.album.artist['Main'] and not isMainArtist:
|
||||||
|
if artist.role not in self.album.artist:
|
||||||
|
self.album.artist[artist.role] = []
|
||||||
|
self.album.artist[artist.role].insert(0, artist.name)
|
||||||
|
self.album.mainArtist.save = not self.album.mainArtist.isVariousArtists() or settings['albumVariousArtists'] and self.album.mainArtist.isVariousArtists()
|
||||||
|
|
||||||
|
# Check removeDuplicateArtists
|
||||||
|
if settings['removeDuplicateArtists']: self.removeDuplicateArtists()
|
||||||
|
|
||||||
|
# Check if user wants the feat in the title
|
||||||
|
if str(settings['featuredToTitle']) == FeaturesOption.REMOVE_TITLE:
|
||||||
|
self.title = self.getCleanTitle()
|
||||||
|
elif str(settings['featuredToTitle']) == FeaturesOption.MOVE_TITLE:
|
||||||
|
self.title = self.getFeatTitle()
|
||||||
|
elif str(settings['featuredToTitle']) == FeaturesOption.REMOVE_TITLE_ALBUM:
|
||||||
|
self.title = self.getCleanTitle()
|
||||||
|
self.album.title = self.album.getCleanTitle()
|
||||||
|
|
||||||
|
# Remove (Album Version) from tracks that have that
|
||||||
|
if settings['removeAlbumVersion'] and "Album Version" in self.title:
|
||||||
|
self.title = re.sub(r' ?\(Album Version\)', "", self.title).strip()
|
||||||
|
|
||||||
|
# Change Title and Artists casing if needed
|
||||||
|
if settings['titleCasing'] != "nothing":
|
||||||
|
self.title = changeCase(self.title, settings['titleCasing'])
|
||||||
|
if settings['artistCasing'] != "nothing":
|
||||||
|
self.mainArtist.name = changeCase(self.mainArtist.name, settings['artistCasing'])
|
||||||
|
for i, artist in enumerate(self.artists):
|
||||||
|
self.artists[i] = changeCase(artist, settings['artistCasing'])
|
||||||
|
for art_type in self.artist:
|
||||||
|
for i, artist in enumerate(self.artist[art_type]):
|
||||||
|
self.artist[art_type][i] = changeCase(artist, settings['artistCasing'])
|
||||||
|
self.generateMainFeatStrings()
|
||||||
|
|
||||||
|
# Generate artist tag
|
||||||
|
if settings['tags']['multiArtistSeparator'] == "default":
|
||||||
|
if str(settings['featuredToTitle']) == FeaturesOption.MOVE_TITLE:
|
||||||
|
self.artistsString = ", ".join(self.artist['Main'])
|
||||||
|
else:
|
||||||
|
self.artistsString = ", ".join(self.artists)
|
||||||
|
elif settings['tags']['multiArtistSeparator'] == "andFeat":
|
||||||
|
self.artistsString = self.mainArtistsString
|
||||||
|
if self.featArtistsString and str(settings['featuredToTitle']) != FeaturesOption.MOVE_TITLE:
|
||||||
|
self.artistsString += " " + self.featArtistsString
|
||||||
|
else:
|
||||||
|
separator = settings['tags']['multiArtistSeparator']
|
||||||
|
if str(settings['featuredToTitle']) == FeaturesOption.MOVE_TITLE:
|
||||||
|
self.artistsString = separator.join(self.artist['Main'])
|
||||||
|
else:
|
||||||
|
self.artistsString = separator.join(self.artists)
|
||||||
|
|
|
@ -1,7 +1 @@
|
||||||
from deemix.types.Date import Date
|
VARIOUS_ARTISTS = "5080"
|
||||||
from deemix.types.Picture import Picture
|
|
||||||
from deemix.types.Lyrics import Lyrics
|
|
||||||
from deemix.types.Album import Album
|
|
||||||
from deemix.types.Artist import Artist
|
|
||||||
from deemix.types.Playlist import Playlist
|
|
||||||
from deemix.types.Track import Track
|
|
||||||
|
|
|
@ -1,54 +1,73 @@
|
||||||
import re
|
|
||||||
import string
|
import string
|
||||||
|
import re
|
||||||
from deezer import TrackFormats
|
from deezer import TrackFormats
|
||||||
import os
|
import os
|
||||||
|
from deemix.errors import ErrorMessages
|
||||||
|
|
||||||
|
USER_AGENT_HEADER = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " \
|
||||||
|
"Chrome/79.0.3945.130 Safari/537.36"
|
||||||
|
|
||||||
|
def canWrite(folder):
|
||||||
|
return os.access(folder, os.W_OK)
|
||||||
|
|
||||||
def generateReplayGainString(trackGain):
|
def generateReplayGainString(trackGain):
|
||||||
return "{0:.2f} dB".format((float(trackGain) + 18.4) * -1)
|
return "{0:.2f} dB".format((float(trackGain) + 18.4) * -1)
|
||||||
|
|
||||||
def getBitrateInt(txt):
|
def getBitrateNumberFromText(txt):
|
||||||
txt = str(txt).lower()
|
txt = str(txt).lower()
|
||||||
if txt in ['flac', 'lossless', '9']:
|
if txt in ['flac', 'lossless', '9']:
|
||||||
return TrackFormats.FLAC
|
return TrackFormats.FLAC
|
||||||
elif txt in ['mp3', '320', '3']:
|
if txt in ['mp3', '320', '3']:
|
||||||
return TrackFormats.MP3_320
|
return TrackFormats.MP3_320
|
||||||
elif txt in ['128', '1']:
|
if txt in ['128', '1']:
|
||||||
return TrackFormats.MP3_128
|
return TrackFormats.MP3_128
|
||||||
elif txt in ['360', '360_hq', '15']:
|
if txt in ['360', '360_hq', '15']:
|
||||||
return TrackFormats.MP4_RA3
|
return TrackFormats.MP4_RA3
|
||||||
elif txt in ['360_mq', '14']:
|
if txt in ['360_mq', '14']:
|
||||||
return TrackFormats.MP4_RA2
|
return TrackFormats.MP4_RA2
|
||||||
elif txt in ['360_lq', '13']:
|
if txt in ['360_lq', '13']:
|
||||||
return TrackFormats.MP4_RA1
|
return TrackFormats.MP4_RA1
|
||||||
else:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def changeCase(txt, case_type):
|
||||||
def changeCase(str, type):
|
if case_type == "lower":
|
||||||
if type == "lower":
|
return txt.lower()
|
||||||
return str.lower()
|
if case_type == "upper":
|
||||||
elif type == "upper":
|
return txt.upper()
|
||||||
return str.upper()
|
if case_type == "start":
|
||||||
elif type == "start":
|
txt = txt.strip().split(" ")
|
||||||
return string.capwords(str)
|
for i, word in enumerate(txt):
|
||||||
elif type == "sentence":
|
if word[0] in ['(', '{', '[', "'", '"']:
|
||||||
return str.capitalize()
|
txt[i] = word[0] + word[1:].capitalize()
|
||||||
else:
|
else:
|
||||||
|
txt[i] = word.capitalize()
|
||||||
|
return " ".join(txt)
|
||||||
|
if case_type == "sentence":
|
||||||
|
return txt.capitalize()
|
||||||
return str
|
return str
|
||||||
|
|
||||||
|
|
||||||
def removeFeatures(title):
|
def removeFeatures(title):
|
||||||
clean = title
|
clean = title
|
||||||
if "(feat." in clean.lower():
|
found = False
|
||||||
pos = clean.lower().find("(feat.")
|
pos = -1
|
||||||
|
if re.search(r"[\s(]\(?\s?feat\.?\s", clean):
|
||||||
|
pos = re.search(r"[\s(]\(?\s?feat\.?\s", clean).start(0)
|
||||||
|
found = True
|
||||||
|
if re.search(r"[\s(]\(?\s?ft\.?\s", clean):
|
||||||
|
pos = re.search(r"[\s(]\(?\s?ft\.?\s", clean).start(0)
|
||||||
|
found = True
|
||||||
|
openBracket = clean[pos] == '(' or clean[pos+1] == '('
|
||||||
|
otherBracket = clean.find('(', pos+2)
|
||||||
|
if found:
|
||||||
tempTrack = clean[:pos]
|
tempTrack = clean[:pos]
|
||||||
if ")" in clean:
|
if ")" in clean and openBracket:
|
||||||
tempTrack += clean[clean.find(")", pos + 1) + 1:]
|
tempTrack += clean[clean.find(")", pos+2) + 1:]
|
||||||
|
if not openBracket and otherBracket != -1:
|
||||||
|
tempTrack += f" {clean[otherBracket:]}"
|
||||||
clean = tempTrack.strip()
|
clean = tempTrack.strip()
|
||||||
clean = ' '.join(clean.split())
|
clean = ' '.join(clean.split())
|
||||||
return clean
|
return clean
|
||||||
|
|
||||||
|
|
||||||
def andCommaConcat(lst):
|
def andCommaConcat(lst):
|
||||||
tot = len(lst)
|
tot = len(lst)
|
||||||
result = ""
|
result = ""
|
||||||
|
@ -61,62 +80,6 @@ def andCommaConcat(lst):
|
||||||
result += ", "
|
result += ", "
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def getIDFromLink(link, type):
|
|
||||||
if '?' in link:
|
|
||||||
link = link[:link.find('?')]
|
|
||||||
if link.endswith("/"):
|
|
||||||
link = link[:-1]
|
|
||||||
|
|
||||||
if link.startswith("http") and 'open.spotify.com/' in link:
|
|
||||||
if '&' in link: link = link[:link.find('&')]
|
|
||||||
if type == "spotifyplaylist":
|
|
||||||
return link[link.find("/playlist/") + 10:]
|
|
||||||
if type == "spotifytrack":
|
|
||||||
return link[link.find("/track/") + 7:]
|
|
||||||
if type == "spotifyalbum":
|
|
||||||
return link[link.find("/album/") + 7:]
|
|
||||||
elif link.startswith("spotify:"):
|
|
||||||
if type == "spotifyplaylist":
|
|
||||||
return link[link.find("playlist:") + 9:]
|
|
||||||
if type == "spotifytrack":
|
|
||||||
return link[link.find("track:") + 6:]
|
|
||||||
if type == "spotifyalbum":
|
|
||||||
return link[link.find("album:") + 6:]
|
|
||||||
elif type == "artisttop":
|
|
||||||
return re.search(r"\/artist\/(\d+)\/top_track", link)[1]
|
|
||||||
elif type == "artistdiscography":
|
|
||||||
return re.search(r"\/artist\/(\d+)\/discography", link)[1]
|
|
||||||
else:
|
|
||||||
return link[link.rfind("/") + 1:]
|
|
||||||
|
|
||||||
|
|
||||||
def getTypeFromLink(link):
|
|
||||||
type = ''
|
|
||||||
if 'spotify' in link:
|
|
||||||
type = 'spotify'
|
|
||||||
if 'playlist' in link:
|
|
||||||
type += 'playlist'
|
|
||||||
elif 'track' in link:
|
|
||||||
type += 'track'
|
|
||||||
elif 'album' in link:
|
|
||||||
type += 'album'
|
|
||||||
elif 'deezer' in link:
|
|
||||||
if '/track' in link:
|
|
||||||
type = 'track'
|
|
||||||
elif '/playlist' in link:
|
|
||||||
type = 'playlist'
|
|
||||||
elif '/album' in link:
|
|
||||||
type = 'album'
|
|
||||||
elif re.search("\/artist\/(\d+)\/top_track", link):
|
|
||||||
type = 'artisttop'
|
|
||||||
elif re.search("\/artist\/(\d+)\/discography", link):
|
|
||||||
type = 'artistdiscography'
|
|
||||||
elif '/artist' in link:
|
|
||||||
type = 'artist'
|
|
||||||
return type
|
|
||||||
|
|
||||||
|
|
||||||
def uniqueArray(arr):
|
def uniqueArray(arr):
|
||||||
for iPrinc, namePrinc in enumerate(arr):
|
for iPrinc, namePrinc in enumerate(arr):
|
||||||
for iRest, nRest in enumerate(arr):
|
for iRest, nRest in enumerate(arr):
|
||||||
|
@ -130,10 +93,58 @@ def removeDuplicateArtists(artist, artists):
|
||||||
artist[role] = uniqueArray(artist[role])
|
artist[role] = uniqueArray(artist[role])
|
||||||
return (artist, artists)
|
return (artist, artists)
|
||||||
|
|
||||||
def checkFolder(folder):
|
def formatListener(key, data=None):
|
||||||
try:
|
if key == "startAddingArtist":
|
||||||
os.makedirs(folder, exist_ok=True)
|
return f"Started gathering {data['name']}'s albums ({data['id']})"
|
||||||
except Exception as e:
|
if key == "finishAddingArtist":
|
||||||
print(str(e))
|
return f"Finished gathering {data['name']}'s albums ({data['id']})"
|
||||||
return False
|
if key == "updateQueue":
|
||||||
return os.access(folder, os.W_OK)
|
uuid = f"[{data['uuid']}]"
|
||||||
|
if data.get('downloaded'):
|
||||||
|
shortFilepath = data['downloadPath'][len(data['extrasPath']):]
|
||||||
|
return f"{uuid} Completed download of {shortFilepath}"
|
||||||
|
if data.get('failed'):
|
||||||
|
return f"{uuid} {data['data']['artist']} - {data['data']['title']} :: {data['error']}"
|
||||||
|
if data.get('progress'):
|
||||||
|
return f"{uuid} Download at {data['progress']}%"
|
||||||
|
if data.get('conversion'):
|
||||||
|
return f"{uuid} Conversion at {data['conversion']}%"
|
||||||
|
return uuid
|
||||||
|
if key == "downloadInfo":
|
||||||
|
message = data['state']
|
||||||
|
if data['state'] == "getTags": message = "Getting tags."
|
||||||
|
elif data['state'] == "gotTags": message = "Tags got."
|
||||||
|
elif data['state'] == "getBitrate": message = "Getting download URL."
|
||||||
|
elif data['state'] == "bitrateFallback": message = "Desired bitrate not found, falling back to lower bitrate."
|
||||||
|
elif data['state'] == "searchFallback": message = "This track has been searched for, result might not be 100% exact."
|
||||||
|
elif data['state'] == "gotBitrate": message = "Download URL got."
|
||||||
|
elif data['state'] == "getAlbumArt": message = "Downloading album art."
|
||||||
|
elif data['state'] == "gotAlbumArt": message = "Album art downloaded."
|
||||||
|
elif data['state'] == "downloading":
|
||||||
|
message = "Downloading track."
|
||||||
|
if data['alreadyStarted']:
|
||||||
|
message += f" Recovering download from {data['value']}."
|
||||||
|
else:
|
||||||
|
message += f" Downloading {data['value']} bytes."
|
||||||
|
elif data['state'] == "downloaded": message = "Track downloaded."
|
||||||
|
elif data['state'] == "alreadyDownloaded": message = "Track already downloaded."
|
||||||
|
elif data['state'] == "tagging": message = "Tagging track."
|
||||||
|
elif data['state'] == "tagged": message = "Track tagged."
|
||||||
|
return f"[{data['uuid']}] {data['data']['artist']} - {data['data']['title']} :: {message}"
|
||||||
|
if key == "downloadWarn":
|
||||||
|
errorMessage = ErrorMessages[data['state']]
|
||||||
|
solutionMessage = ""
|
||||||
|
if data['solution'] == 'fallback': solutionMessage = "Using fallback id."
|
||||||
|
if data['solution'] == 'search': solutionMessage = "Searching for alternative."
|
||||||
|
return f"[{data['uuid']}] {data['data']['artist']} - {data['data']['title']} :: {errorMessage} {solutionMessage}"
|
||||||
|
if key == "currentItemCancelled":
|
||||||
|
return f"Current item cancelled ({data})"
|
||||||
|
if key == "removedFromQueue":
|
||||||
|
return f"[{data}] Removed from the queue"
|
||||||
|
if key == "finishDownload":
|
||||||
|
return f"[{data}] Finished downloading"
|
||||||
|
if key == "startConversion":
|
||||||
|
return f"[{data}] Started converting"
|
||||||
|
if key == "finishConversion":
|
||||||
|
return f"[{data['uuid']}] Finished converting"
|
||||||
|
return ""
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import binascii
|
||||||
|
|
||||||
|
from Cryptodome.Cipher import Blowfish, AES
|
||||||
|
from Cryptodome.Hash import MD5
|
||||||
|
|
||||||
|
def _md5(data):
|
||||||
|
h = MD5.new()
|
||||||
|
h.update(data.encode() if isinstance(data, str) else data)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
def _ecbCrypt(key, data):
|
||||||
|
return binascii.hexlify(AES.new(key.encode(), AES.MODE_ECB).encrypt(data))
|
||||||
|
|
||||||
|
def _ecbDecrypt(key, data):
|
||||||
|
return AES.new(key.encode(), AES.MODE_ECB).decrypt(binascii.unhexlify(data.encode("utf-8")))
|
||||||
|
|
||||||
|
def generateBlowfishKey(trackId):
|
||||||
|
SECRET = 'g4el58wc0zvf9na1'
|
||||||
|
idMd5 = _md5(trackId)
|
||||||
|
bfKey = ""
|
||||||
|
for i in range(16):
|
||||||
|
bfKey += chr(ord(idMd5[i]) ^ ord(idMd5[i + 16]) ^ ord(SECRET[i]))
|
||||||
|
return str.encode(bfKey)
|
||||||
|
|
||||||
|
def decryptChunk(key, data):
|
||||||
|
return Blowfish.new(key, Blowfish.MODE_CBC, b"\x00\x01\x02\x03\x04\x05\x06\x07").decrypt(data)
|
|
@ -1,31 +0,0 @@
|
||||||
import binascii
|
|
||||||
from Cryptodome.Cipher import Blowfish, AES
|
|
||||||
from Cryptodome.Hash import MD5
|
|
||||||
|
|
||||||
def _md5(data):
|
|
||||||
h = MD5.new()
|
|
||||||
h.update(str.encode(data) if isinstance(data, str) else data)
|
|
||||||
return h.hexdigest()
|
|
||||||
|
|
||||||
def generateBlowfishKey(trackId):
|
|
||||||
SECRET = 'g4el58wc' + '0zvf9na1'
|
|
||||||
idMd5 = _md5(trackId)
|
|
||||||
bfKey = ""
|
|
||||||
for i in range(16):
|
|
||||||
bfKey += chr(ord(idMd5[i]) ^ ord(idMd5[i + 16]) ^ ord(SECRET[i]))
|
|
||||||
return bfKey
|
|
||||||
|
|
||||||
def generateStreamURL(sng_id, md5, media_version, format):
|
|
||||||
urlPart = b'\xa4'.join(
|
|
||||||
[str.encode(md5), str.encode(str(format)), str.encode(str(sng_id)), str.encode(str(media_version))])
|
|
||||||
md5val = _md5(urlPart)
|
|
||||||
step2 = str.encode(md5val) + b'\xa4' + urlPart + b'\xa4'
|
|
||||||
step2 = step2 + (b'.' * (16 - (len(step2) % 16)))
|
|
||||||
urlPart = binascii.hexlify(AES.new(b'jo6aey6haid2Teih', AES.MODE_ECB).encrypt(step2))
|
|
||||||
return "https://e-cdns-proxy-" + md5[0] + ".dzcdn.net/mobile/1/" + urlPart.decode("utf-8")
|
|
||||||
|
|
||||||
def reverseStreamURL(url):
|
|
||||||
urlPart = url[42:]
|
|
||||||
step2 = AES.new(b'jo6aey6haid2Teih', AES.MODE_ECB).decrypt(binascii.unhexlify(urlPart.encode("utf-8")))
|
|
||||||
(md5val, md5, format, sng_id, media_version, _) = step2.split(b'\xa4')
|
|
||||||
return (sng_id.decode('utf-8'), md5.decode('utf-8'), media_version.decode('utf-8'), format.decode('utf-8'))
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
import requests
|
||||||
|
from deemix.utils.crypto import _md5
|
||||||
|
from deemix.utils import USER_AGENT_HEADER
|
||||||
|
CLIENT_ID = "172365"
|
||||||
|
CLIENT_SECRET = "fb0bec7ccc063dab0417eb7b0d847f34"
|
||||||
|
|
||||||
|
def getAccessToken(email, password):
|
||||||
|
accessToken = None
|
||||||
|
password = _md5(password)
|
||||||
|
request_hash = _md5(''.join([CLIENT_ID, email, password, CLIENT_SECRET]))
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
'https://api.deezer.com/auth/token',
|
||||||
|
params={
|
||||||
|
'app_id': CLIENT_ID,
|
||||||
|
'login': email,
|
||||||
|
'password': password,
|
||||||
|
'hash': request_hash
|
||||||
|
},
|
||||||
|
headers={"User-Agent": USER_AGENT_HEADER}
|
||||||
|
).json()
|
||||||
|
accessToken = response.get('access_token')
|
||||||
|
if accessToken == "undefined": accessToken = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return accessToken
|
||||||
|
|
||||||
|
def getArlFromAccessToken(accessToken):
|
||||||
|
if not accessToken: return None
|
||||||
|
arl = None
|
||||||
|
session = requests.Session()
|
||||||
|
try:
|
||||||
|
session.get(
|
||||||
|
"https://api.deezer.com/platform/generic/track/3135556",
|
||||||
|
headers={"Authorization": f"Bearer {accessToken}", "User-Agent": USER_AGENT_HEADER}
|
||||||
|
)
|
||||||
|
response = session.get(
|
||||||
|
'https://www.deezer.com/ajax/gw-light.php?method=user.getArl&input=3&api_version=1.0&api_token=null',
|
||||||
|
headers={"User-Agent": USER_AGENT_HEADER}
|
||||||
|
).json()
|
||||||
|
arl = response.get('results')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return arl
|
|
@ -1,44 +1,77 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
from deemix.utils import canWrite
|
||||||
|
|
||||||
homedata = Path.home()
|
homedata = Path.home()
|
||||||
userdata = ""
|
userdata = ""
|
||||||
musicdata = ""
|
musicdata = ""
|
||||||
|
|
||||||
|
def checkPath(path):
|
||||||
if os.getenv("DEEMIX_DATA_DIR"):
|
if path == "": return ""
|
||||||
userdata = Path(os.getenv("DEEMIX_DATA_DIR"))
|
if not path.is_dir(): return ""
|
||||||
elif os.getenv("XDG_CONFIG_HOME"):
|
if not canWrite(path): return ""
|
||||||
userdata = Path(os.getenv("XDG_CONFIG_HOME")) / 'deemix'
|
return path
|
||||||
elif os.getenv("APPDATA"):
|
|
||||||
userdata = Path(os.getenv("APPDATA")) / "deemix"
|
|
||||||
elif sys.platform.startswith('darwin'):
|
|
||||||
userdata = homedata / 'Library' / 'Application Support' / 'deemix'
|
|
||||||
else:
|
|
||||||
userdata = homedata / '.config' / 'deemix'
|
|
||||||
|
|
||||||
if os.getenv("DEEMIX_MUSIC_DIR"):
|
|
||||||
musicdata = Path(os.getenv("DEEMIX_MUSIC_DIR"))
|
|
||||||
elif os.getenv("XDG_MUSIC_DIR"):
|
|
||||||
musicdata = Path(os.getenv("XDG_MUSIC_DIR")) / "deemix Music"
|
|
||||||
elif os.name == 'nt':
|
|
||||||
import winreg
|
|
||||||
sub_key = r'SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders'
|
|
||||||
music_guid = '{4BD8D571-6D19-48D3-BE97-422220080E43}'
|
|
||||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, sub_key) as key:
|
|
||||||
location = None
|
|
||||||
try: location = winreg.QueryValueEx(key, music_guid)[0]
|
|
||||||
except: pass
|
|
||||||
try: location = winreg.QueryValueEx(key, 'My Music')[0]
|
|
||||||
except: pass
|
|
||||||
if not location: location = homedata / "Music"
|
|
||||||
musicdata = Path(location) / "deemix Music"
|
|
||||||
else:
|
|
||||||
musicdata = homedata / "Music" / "deemix Music"
|
|
||||||
|
|
||||||
def getConfigFolder():
|
def getConfigFolder():
|
||||||
|
global userdata
|
||||||
|
if userdata != "": return userdata
|
||||||
|
if os.getenv("XDG_CONFIG_HOME") and userdata == "":
|
||||||
|
userdata = Path(os.getenv("XDG_CONFIG_HOME"))
|
||||||
|
userdata = checkPath(userdata)
|
||||||
|
if os.getenv("APPDATA") and userdata == "":
|
||||||
|
userdata = Path(os.getenv("APPDATA"))
|
||||||
|
userdata = checkPath(userdata)
|
||||||
|
if sys.platform.startswith('darwin') and userdata == "":
|
||||||
|
userdata = homedata / 'Library' / 'Application Support'
|
||||||
|
userdata = checkPath(userdata)
|
||||||
|
if userdata == "":
|
||||||
|
userdata = homedata / '.config'
|
||||||
|
userdata = checkPath(userdata)
|
||||||
|
|
||||||
|
if userdata == "": userdata = Path(os.getcwd()) / 'config'
|
||||||
|
else: userdata = userdata / 'deemix'
|
||||||
|
|
||||||
|
if os.getenv("DEEMIX_DATA_DIR"):
|
||||||
|
userdata = Path(os.getenv("DEEMIX_DATA_DIR"))
|
||||||
return userdata
|
return userdata
|
||||||
|
|
||||||
def getMusicFolder():
|
def getMusicFolder():
|
||||||
|
global musicdata
|
||||||
|
if musicdata != "": return musicdata
|
||||||
|
if os.getenv("XDG_MUSIC_DIR") and musicdata == "":
|
||||||
|
musicdata = Path(os.getenv("XDG_MUSIC_DIR"))
|
||||||
|
musicdata = checkPath(musicdata)
|
||||||
|
if (homedata / '.config' / 'user-dirs.dirs').is_file() and musicdata == "":
|
||||||
|
with open(homedata / '.config' / 'user-dirs.dirs', 'r', encoding="utf-8") as f:
|
||||||
|
userDirs = f.read()
|
||||||
|
musicdata_search = re.search(r"XDG_MUSIC_DIR=\"(.*)\"", userDirs)
|
||||||
|
if musicdata_search:
|
||||||
|
musicdata = musicdata_search.group(1)
|
||||||
|
musicdata = Path(os.path.expandvars(musicdata))
|
||||||
|
musicdata = checkPath(musicdata)
|
||||||
|
if os.name == 'nt' and musicdata == "":
|
||||||
|
try:
|
||||||
|
musicKeys = ['My Music', '{4BD8D571-6D19-48D3-BE97-422220080E43}']
|
||||||
|
regData = os.popen(r'reg.exe query "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"').read().split('\r\n')
|
||||||
|
for i, line in enumerate(regData):
|
||||||
|
if line == "": continue
|
||||||
|
if i == 1: continue
|
||||||
|
line = line.split(' ')
|
||||||
|
if line[1] in musicKeys:
|
||||||
|
musicdata = Path(line[3])
|
||||||
|
break
|
||||||
|
musicdata = checkPath(musicdata)
|
||||||
|
except Exception:
|
||||||
|
musicdata = ""
|
||||||
|
if musicdata == "":
|
||||||
|
musicdata = homedata / 'Music'
|
||||||
|
musicdata = checkPath(musicdata)
|
||||||
|
|
||||||
|
if musicdata == "": musicdata = Path(os.getcwd()) / 'music'
|
||||||
|
else: musicdata = musicdata / 'deemix Music'
|
||||||
|
|
||||||
|
if os.getenv("DEEMIX_MUSIC_DIR"):
|
||||||
|
musicdata = Path(os.getenv("DEEMIX_MUSIC_DIR"))
|
||||||
return musicdata
|
return musicdata
|
||||||
|
|
|
@ -21,14 +21,13 @@ def fixName(txt, char='_'):
|
||||||
txt = normalize("NFC", txt)
|
txt = normalize("NFC", txt)
|
||||||
return txt
|
return txt
|
||||||
|
|
||||||
def fixEndOfData(bString):
|
def fixLongName(name):
|
||||||
|
def fixEndOfData(bString):
|
||||||
try:
|
try:
|
||||||
bString.decode()
|
bString.decode()
|
||||||
return True
|
return True
|
||||||
except:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def fixLongName(name):
|
|
||||||
if pathSep in name:
|
if pathSep in name:
|
||||||
sepName = name.split(pathSep)
|
sepName = name.split(pathSep)
|
||||||
name = ""
|
name = ""
|
||||||
|
@ -52,30 +51,40 @@ def antiDot(string):
|
||||||
return string
|
return string
|
||||||
|
|
||||||
|
|
||||||
def pad(num, max, settings):
|
def pad(num, max_val, settings):
|
||||||
if int(settings['paddingSize']) == 0:
|
if int(settings['paddingSize']) == 0:
|
||||||
paddingSize = len(str(max))
|
paddingSize = len(str(max_val))
|
||||||
else:
|
else:
|
||||||
paddingSize = len(str(10 ** (int(settings['paddingSize']) - 1)))
|
paddingSize = len(str(10 ** (int(settings['paddingSize']) - 1)))
|
||||||
if paddingSize == 1:
|
if paddingSize == 1:
|
||||||
paddingSize = 2
|
paddingSize = 2
|
||||||
if settings['padTracks']:
|
if settings['padTracks']:
|
||||||
return str(num).zfill(paddingSize)
|
return str(num).zfill(paddingSize)
|
||||||
else:
|
|
||||||
return str(num)
|
return str(num)
|
||||||
|
|
||||||
def generateFilename(track, settings, template):
|
def generatePath(track, downloadObject, settings):
|
||||||
filename = template or "%artist% - %title%"
|
filenameTemplate = "%artist% - %title%"
|
||||||
return settingsRegex(filename, track, settings)
|
singleTrack = False
|
||||||
|
if downloadObject.type == "track":
|
||||||
|
if settings['createSingleFolder']:
|
||||||
|
filenameTemplate = settings['albumTracknameTemplate']
|
||||||
|
else:
|
||||||
|
filenameTemplate = settings['tracknameTemplate']
|
||||||
|
singleTrack = True
|
||||||
|
elif downloadObject.type == "album":
|
||||||
|
filenameTemplate = settings['albumTracknameTemplate']
|
||||||
|
else:
|
||||||
|
filenameTemplate = settings['playlistTracknameTemplate']
|
||||||
|
|
||||||
def generateFilepath(track, settings):
|
filename = generateTrackName(filenameTemplate, track, settings)
|
||||||
filepath = Path(settings['downloadLocation'])
|
|
||||||
|
filepath = Path(settings['downloadLocation'] or '.')
|
||||||
artistPath = None
|
artistPath = None
|
||||||
coverPath = None
|
coverPath = None
|
||||||
extrasPath = None
|
extrasPath = None
|
||||||
|
|
||||||
if settings['createPlaylistFolder'] and track.playlist and not settings['tags']['savePlaylistAsCompilation']:
|
if settings['createPlaylistFolder'] and track.playlist and not settings['tags']['savePlaylistAsCompilation']:
|
||||||
filepath = filepath / settingsRegexPlaylist(settings['playlistNameTemplate'], track.playlist, settings)
|
filepath = filepath / generatePlaylistName(settings['playlistNameTemplate'], track.playlist, settings)
|
||||||
|
|
||||||
if track.playlist and not settings['tags']['savePlaylistAsCompilation']:
|
if track.playlist and not settings['tags']['savePlaylistAsCompilation']:
|
||||||
extrasPath = filepath
|
extrasPath = filepath
|
||||||
|
@ -85,61 +94,66 @@ def generateFilepath(track, settings):
|
||||||
(settings['createArtistFolder'] and track.playlist and settings['tags']['savePlaylistAsCompilation']) or
|
(settings['createArtistFolder'] and track.playlist and settings['tags']['savePlaylistAsCompilation']) or
|
||||||
(settings['createArtistFolder'] and track.playlist and settings['createStructurePlaylist'])
|
(settings['createArtistFolder'] and track.playlist and settings['createStructurePlaylist'])
|
||||||
):
|
):
|
||||||
filepath = filepath / settingsRegexArtist(settings['artistNameTemplate'], track.album.mainArtist, settings, rootArtist=track.album.rootArtist)
|
filepath = filepath / generateArtistName(settings['artistNameTemplate'], track.album.mainArtist, settings, rootArtist=track.album.rootArtist)
|
||||||
artistPath = filepath
|
artistPath = filepath
|
||||||
|
|
||||||
if (settings['createAlbumFolder'] and
|
if (settings['createAlbumFolder'] and
|
||||||
(not track.singleDownload or (track.singleDownload and settings['createSingleFolder'])) and
|
(not singleTrack or (singleTrack and settings['createSingleFolder'])) and
|
||||||
(not track.playlist or
|
(not track.playlist or
|
||||||
(track.playlist and settings['tags']['savePlaylistAsCompilation']) or
|
(track.playlist and settings['tags']['savePlaylistAsCompilation']) or
|
||||||
(track.playlist and settings['createStructurePlaylist'])
|
(track.playlist and settings['createStructurePlaylist'])
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
filepath = filepath / settingsRegexAlbum(settings['albumNameTemplate'], track.album, settings, track.playlist)
|
filepath = filepath / generateAlbumName(settings['albumNameTemplate'], track.album, settings, track.playlist)
|
||||||
coverPath = filepath
|
coverPath = filepath
|
||||||
|
|
||||||
if not (track.playlist and not settings['tags']['savePlaylistAsCompilation']):
|
if not extrasPath: extrasPath = filepath
|
||||||
extrasPath = filepath
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
int(track.album.discTotal) > 1 and (
|
int(track.album.discTotal) > 1 and (
|
||||||
(settings['createAlbumFolder'] and settings['createCDFolder']) and
|
(settings['createAlbumFolder'] and settings['createCDFolder']) and
|
||||||
(not track.singleDownload or (track.singleDownload and settings['createSingleFolder'])) and
|
(not singleTrack or (singleTrack and settings['createSingleFolder'])) and
|
||||||
(not track.playlist or
|
(not track.playlist or
|
||||||
(track.playlist and settings['tags']['savePlaylistAsCompilation']) or
|
(track.playlist and settings['tags']['savePlaylistAsCompilation']) or
|
||||||
(track.playlist and settings['createStructurePlaylist'])
|
(track.playlist and settings['createStructurePlaylist'])
|
||||||
)
|
)
|
||||||
)):
|
)):
|
||||||
filepath = filepath / f'CD{str(track.discNumber)}'
|
filepath = filepath / f'CD{track.discNumber}'
|
||||||
|
|
||||||
return (filepath, artistPath, coverPath, extrasPath)
|
# 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):]
|
||||||
|
|
||||||
|
return (filename, filepath, artistPath, coverPath, extrasPath)
|
||||||
|
|
||||||
|
|
||||||
def settingsRegex(filename, track, settings):
|
def generateTrackName(filename, track, settings):
|
||||||
filename = filename.replace("%title%", fixName(track.title, settings['illegalCharacterReplacer']))
|
c = settings['illegalCharacterReplacer']
|
||||||
filename = filename.replace("%artist%", fixName(track.mainArtist.name, settings['illegalCharacterReplacer']))
|
filename = filename.replace("%title%", fixName(track.title, c))
|
||||||
filename = filename.replace("%artists%", fixName(", ".join(track.artists), settings['illegalCharacterReplacer']))
|
filename = filename.replace("%artist%", fixName(track.mainArtist.name, c))
|
||||||
filename = filename.replace("%allartists%", fixName(track.artistsString, settings['illegalCharacterReplacer']))
|
filename = filename.replace("%artists%", fixName(", ".join(track.artists), c))
|
||||||
filename = filename.replace("%mainartists%", fixName(track.mainArtistsString, settings['illegalCharacterReplacer']))
|
filename = filename.replace("%allartists%", fixName(track.artistsString, c))
|
||||||
|
filename = filename.replace("%mainartists%", fixName(track.mainArtistsString, c))
|
||||||
if track.featArtistsString:
|
if track.featArtistsString:
|
||||||
filename = filename.replace("%featartists%", fixName('('+track.featArtistsString+')', settings['illegalCharacterReplacer']))
|
filename = filename.replace("%featartists%", fixName('('+track.featArtistsString+')', c))
|
||||||
else:
|
else:
|
||||||
filename = filename.replace("%featartists%", '')
|
filename = filename.replace("%featartists%", '')
|
||||||
filename = filename.replace("%album%", fixName(track.album.title, settings['illegalCharacterReplacer']))
|
filename = filename.replace("%album%", fixName(track.album.title, c))
|
||||||
filename = filename.replace("%albumartist%", fixName(track.album.mainArtist.name, settings['illegalCharacterReplacer']))
|
filename = filename.replace("%albumartist%", fixName(track.album.mainArtist.name, c))
|
||||||
filename = filename.replace("%tracknumber%", pad(track.trackNumber, track.album.trackTotal, settings))
|
filename = filename.replace("%tracknumber%", pad(track.trackNumber, track.album.trackTotal, settings))
|
||||||
filename = filename.replace("%tracktotal%", str(track.album.trackTotal))
|
filename = filename.replace("%tracktotal%", str(track.album.trackTotal))
|
||||||
filename = filename.replace("%discnumber%", str(track.discNumber))
|
filename = filename.replace("%discnumber%", str(track.discNumber))
|
||||||
filename = filename.replace("%disctotal%", str(track.album.discTotal))
|
filename = filename.replace("%disctotal%", str(track.album.discTotal))
|
||||||
if len(track.album.genre) > 0:
|
if len(track.album.genre) > 0:
|
||||||
filename = filename.replace("%genre%",
|
filename = filename.replace("%genre%", fixName(track.album.genre[0], c))
|
||||||
fixName(track.album.genre[0], settings['illegalCharacterReplacer']))
|
|
||||||
else:
|
else:
|
||||||
filename = filename.replace("%genre%", "Unknown")
|
filename = filename.replace("%genre%", "Unknown")
|
||||||
filename = filename.replace("%year%", str(track.date.year))
|
filename = filename.replace("%year%", str(track.date.year))
|
||||||
filename = filename.replace("%date%", track.dateString)
|
filename = filename.replace("%date%", track.dateString)
|
||||||
filename = filename.replace("%bpm%", str(track.bpm))
|
filename = filename.replace("%bpm%", str(track.bpm))
|
||||||
filename = filename.replace("%label%", fixName(track.album.label, settings['illegalCharacterReplacer']))
|
filename = filename.replace("%label%", fixName(track.album.label, c))
|
||||||
filename = filename.replace("%isrc%", track.ISRC)
|
filename = filename.replace("%isrc%", track.ISRC)
|
||||||
filename = filename.replace("%upc%", track.album.barcode)
|
filename = filename.replace("%upc%", track.album.barcode)
|
||||||
filename = filename.replace("%explicit%", "(Explicit)" if track.explicit else "")
|
filename = filename.replace("%explicit%", "(Explicit)" if track.explicit else "")
|
||||||
|
@ -148,40 +162,41 @@ def settingsRegex(filename, track, settings):
|
||||||
filename = filename.replace("%album_id%", str(track.album.id))
|
filename = filename.replace("%album_id%", str(track.album.id))
|
||||||
filename = filename.replace("%artist_id%", str(track.mainArtist.id))
|
filename = filename.replace("%artist_id%", str(track.mainArtist.id))
|
||||||
if track.playlist:
|
if track.playlist:
|
||||||
filename = filename.replace("%playlist_id%", str(track.playlist.playlistId))
|
filename = filename.replace("%playlist_id%", str(track.playlist.playlistID))
|
||||||
filename = filename.replace("%position%", pad(track.position, track.playlist.trackTotal, settings))
|
filename = filename.replace("%position%", pad(track.position, track.playlist.trackTotal, settings))
|
||||||
else:
|
else:
|
||||||
filename = filename.replace("%playlist_id%", '')
|
filename = filename.replace("%playlist_id%", '')
|
||||||
filename = filename.replace("%position%", pad(track.trackNumber, track.album.trackTotal, settings))
|
filename = filename.replace("%position%", pad(track.position, track.album.trackTotal, settings))
|
||||||
filename = filename.replace('\\', pathSep).replace('/', pathSep)
|
filename = filename.replace('\\', pathSep).replace('/', pathSep)
|
||||||
return antiDot(fixLongName(filename))
|
return antiDot(fixLongName(filename))
|
||||||
|
|
||||||
|
|
||||||
def settingsRegexAlbum(foldername, album, settings, playlist=None):
|
def generateAlbumName(foldername, album, settings, playlist=None):
|
||||||
|
c = settings['illegalCharacterReplacer']
|
||||||
if playlist and settings['tags']['savePlaylistAsCompilation']:
|
if playlist and settings['tags']['savePlaylistAsCompilation']:
|
||||||
foldername = foldername.replace("%album_id%", "pl_" + str(playlist.playlistId))
|
foldername = foldername.replace("%album_id%", "pl_" + str(playlist.playlistID))
|
||||||
foldername = foldername.replace("%genre%", "Compile")
|
foldername = foldername.replace("%genre%", "Compile")
|
||||||
else:
|
else:
|
||||||
foldername = foldername.replace("%album_id%", str(album.id))
|
foldername = foldername.replace("%album_id%", str(album.id))
|
||||||
if len(album.genre) > 0:
|
if len(album.genre) > 0:
|
||||||
foldername = foldername.replace("%genre%", fixName(album.genre[0], settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%genre%", fixName(album.genre[0], c))
|
||||||
else:
|
else:
|
||||||
foldername = foldername.replace("%genre%", "Unknown")
|
foldername = foldername.replace("%genre%", "Unknown")
|
||||||
foldername = foldername.replace("%album%", fixName(album.title, settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%album%", fixName(album.title, c))
|
||||||
foldername = foldername.replace("%artist%", fixName(album.mainArtist.name, settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%artist%", fixName(album.mainArtist.name, c))
|
||||||
foldername = foldername.replace("%artist_id%", str(album.mainArtist.id))
|
foldername = foldername.replace("%artist_id%", str(album.mainArtist.id))
|
||||||
if album.rootArtist:
|
if album.rootArtist:
|
||||||
foldername = foldername.replace("%root_artist%", fixName(album.rootArtist.name, settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%root_artist%", fixName(album.rootArtist.name, c))
|
||||||
foldername = foldername.replace("%root_artist_id%", str(album.rootArtist.id))
|
foldername = foldername.replace("%root_artist_id%", str(album.rootArtist.id))
|
||||||
else:
|
else:
|
||||||
foldername = foldername.replace("%root_artist%", fixName(album.mainArtist.name, settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%root_artist%", fixName(album.mainArtist.name, c))
|
||||||
foldername = foldername.replace("%root_artist_id%", str(album.mainArtist.id))
|
foldername = foldername.replace("%root_artist_id%", str(album.mainArtist.id))
|
||||||
foldername = foldername.replace("%tracktotal%", str(album.trackTotal))
|
foldername = foldername.replace("%tracktotal%", str(album.trackTotal))
|
||||||
foldername = foldername.replace("%disctotal%", str(album.discTotal))
|
foldername = foldername.replace("%disctotal%", str(album.discTotal))
|
||||||
foldername = foldername.replace("%type%", fixName(album.recordType.capitalize(), settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%type%", fixName(album.recordType.capitalize(), c))
|
||||||
foldername = foldername.replace("%upc%", album.barcode)
|
foldername = foldername.replace("%upc%", album.barcode)
|
||||||
foldername = foldername.replace("%explicit%", "(Explicit)" if album.explicit else "")
|
foldername = foldername.replace("%explicit%", "(Explicit)" if album.explicit else "")
|
||||||
foldername = foldername.replace("%label%", fixName(album.label, settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%label%", fixName(album.label, c))
|
||||||
foldername = foldername.replace("%year%", str(album.date.year))
|
foldername = foldername.replace("%year%", str(album.date.year))
|
||||||
foldername = foldername.replace("%date%", album.dateString)
|
foldername = foldername.replace("%date%", album.dateString)
|
||||||
foldername = foldername.replace("%bitrate%", bitrateLabels[int(album.bitrate)])
|
foldername = foldername.replace("%bitrate%", bitrateLabels[int(album.bitrate)])
|
||||||
|
@ -190,23 +205,25 @@ def settingsRegexAlbum(foldername, album, settings, playlist=None):
|
||||||
return antiDot(fixLongName(foldername))
|
return antiDot(fixLongName(foldername))
|
||||||
|
|
||||||
|
|
||||||
def settingsRegexArtist(foldername, artist, settings, rootArtist=None):
|
def generateArtistName(foldername, artist, settings, rootArtist=None):
|
||||||
foldername = foldername.replace("%artist%", fixName(artist.name, settings['illegalCharacterReplacer']))
|
c = settings['illegalCharacterReplacer']
|
||||||
|
foldername = foldername.replace("%artist%", fixName(artist.name, c))
|
||||||
foldername = foldername.replace("%artist_id%", str(artist.id))
|
foldername = foldername.replace("%artist_id%", str(artist.id))
|
||||||
if rootArtist:
|
if rootArtist:
|
||||||
foldername = foldername.replace("%root_artist%", fixName(rootArtist.name, settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%root_artist%", fixName(rootArtist.name, c))
|
||||||
foldername = foldername.replace("%root_artist_id%", str(rootArtist.id))
|
foldername = foldername.replace("%root_artist_id%", str(rootArtist.id))
|
||||||
else:
|
else:
|
||||||
foldername = foldername.replace("%root_artist%", fixName(artist.name, settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%root_artist%", fixName(artist.name, c))
|
||||||
foldername = foldername.replace("%root_artist_id%", str(artist.id))
|
foldername = foldername.replace("%root_artist_id%", str(artist.id))
|
||||||
foldername = foldername.replace('\\', pathSep).replace('/', pathSep)
|
foldername = foldername.replace('\\', pathSep).replace('/', pathSep)
|
||||||
return antiDot(fixLongName(foldername))
|
return antiDot(fixLongName(foldername))
|
||||||
|
|
||||||
|
|
||||||
def settingsRegexPlaylist(foldername, playlist, settings):
|
def generatePlaylistName(foldername, playlist, settings):
|
||||||
foldername = foldername.replace("%playlist%", fixName(playlist.title, settings['illegalCharacterReplacer']))
|
c = settings['illegalCharacterReplacer']
|
||||||
foldername = foldername.replace("%playlist_id%", fixName(playlist.playlistId, settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%playlist%", fixName(playlist.title, c))
|
||||||
foldername = foldername.replace("%owner%", fixName(playlist.owner['name'], settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%playlist_id%", fixName(playlist.playlistID, c))
|
||||||
|
foldername = foldername.replace("%owner%", fixName(playlist.owner['name'], c))
|
||||||
foldername = foldername.replace("%owner_id%", str(playlist.owner['id']))
|
foldername = foldername.replace("%owner_id%", str(playlist.owner['id']))
|
||||||
foldername = foldername.replace("%year%", str(playlist.date.year))
|
foldername = foldername.replace("%year%", str(playlist.date.year))
|
||||||
foldername = foldername.replace("%date%", str(playlist.dateString))
|
foldername = foldername.replace("%date%", str(playlist.dateString))
|
||||||
|
@ -214,12 +231,13 @@ def settingsRegexPlaylist(foldername, playlist, settings):
|
||||||
foldername = foldername.replace('\\', pathSep).replace('/', pathSep)
|
foldername = foldername.replace('\\', pathSep).replace('/', pathSep)
|
||||||
return antiDot(fixLongName(foldername))
|
return antiDot(fixLongName(foldername))
|
||||||
|
|
||||||
def settingsRegexPlaylistFile(foldername, queueItem, settings):
|
def generateDownloadObjectName(foldername, queueItem, settings):
|
||||||
foldername = foldername.replace("%title%", fixName(queueItem.title, settings['illegalCharacterReplacer']))
|
c = settings['illegalCharacterReplacer']
|
||||||
foldername = foldername.replace("%artist%", fixName(queueItem.artist, settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%title%", fixName(queueItem.title, c))
|
||||||
|
foldername = foldername.replace("%artist%", fixName(queueItem.artist, c))
|
||||||
foldername = foldername.replace("%size%", str(queueItem.size))
|
foldername = foldername.replace("%size%", str(queueItem.size))
|
||||||
foldername = foldername.replace("%type%", fixName(queueItem.type, settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%type%", fixName(queueItem.type, c))
|
||||||
foldername = foldername.replace("%id%", fixName(queueItem.id, settings['illegalCharacterReplacer']))
|
foldername = foldername.replace("%id%", fixName(queueItem.id, c))
|
||||||
foldername = foldername.replace("%bitrate%", bitrateLabels[int(queueItem.bitrate)])
|
foldername = foldername.replace("%bitrate%", bitrateLabels[int(queueItem.bitrate)])
|
||||||
foldername = foldername.replace('\\', pathSep).replace('/', pathSep).replace(pathSep, settings['illegalCharacterReplacer'])
|
foldername = foldername.replace('\\', pathSep).replace('/', pathSep).replace(pathSep, c)
|
||||||
return antiDot(fixLongName(foldername))
|
return antiDot(fixLongName(foldername))
|
||||||
|
|
|
@ -2,6 +2,5 @@ click
|
||||||
pycryptodomex
|
pycryptodomex
|
||||||
mutagen
|
mutagen
|
||||||
requests
|
requests
|
||||||
spotipy>=2.11.0
|
|
||||||
eventlet
|
|
||||||
deezer-py
|
deezer-py
|
||||||
|
spotipy>=2.11.0
|
||||||
|
|
12
setup.py
12
setup.py
|
@ -7,7 +7,7 @@ README = (HERE / "README.md").read_text()
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="deemix",
|
name="deemix",
|
||||||
version="2.0.16",
|
version="3.6.6",
|
||||||
description="A barebone deezer downloader library",
|
description="A barebone deezer downloader library",
|
||||||
long_description=README,
|
long_description=README,
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
|
@ -16,15 +16,17 @@ setup(
|
||||||
license="GPL3",
|
license="GPL3",
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||||
"Development Status :: 4 - Beta",
|
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: 3.6",
|
"Programming Language :: Python :: 3.7",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
],
|
],
|
||||||
python_requires='>=3.6',
|
python_requires='>=3.7',
|
||||||
packages=find_packages(exclude=("tests",)),
|
packages=find_packages(exclude=("tests",)),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=["click", "pycryptodomex", "mutagen", "requests", "spotipy>=2.11.0", "eventlet", "deezer-py"],
|
install_requires=["click", "pycryptodomex", "mutagen", "requests", "deezer-py>=1.3.0"],
|
||||||
|
extras_require={
|
||||||
|
"spotify": ["spotipy>=2.11.0"]
|
||||||
|
},
|
||||||
entry_points={
|
entry_points={
|
||||||
"console_scripts": [
|
"console_scripts": [
|
||||||
"deemix=deemix.__main__:download",
|
"deemix=deemix.__main__:download",
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
pkgs ? import <nixpkgs> { },
|
||||||
|
}:
|
||||||
|
|
||||||
|
pkgs.mkShell {
|
||||||
|
buildInputs = [
|
||||||
|
pkgs.python312
|
||||||
|
pkgs.python312Packages.virtualenv
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
if [ ! -d .venv ]; then
|
||||||
|
virtualenv .venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -e .
|
||||||
|
else
|
||||||
|
. .venv/bin/activate
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
}
|
|
@ -1,7 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
rm -rd build
|
|
||||||
rm -rd dist
|
|
||||||
python -m bump
|
|
||||||
python -m bump deemix/__init__.py
|
|
||||||
python3 setup.py sdist bdist_wheel
|
|
||||||
python3 -m twine upload dist/*
|
|
Loading…
Reference in New Issue