Compare commits

..

114 Commits

Author SHA1 Message Date
1dc0d470dd Sort defaults alphabetically 2024-07-29 17:53:37 +02:00
5f978acec7 v3.6.6 2022-01-11 17:03:53 +01:00
9162b764e7 fixed dot notation in fallbackISRC 2022-01-11 17:03:25 +01:00
fc9a205662 added error logging after download 2022-01-11 17:02:19 +01:00
203ec1f10f v3.6.5 2022-01-04 22:13:54 +01:00
b524172d5a fixed download for 360RA files 2022-01-04 22:12:00 +01:00
463289ac46 Merge pull request 'Handle None values in track parser' (#33) from omtinez/deemix-py:main into main
Reviewed-on: https://git.freezerapp.xyz/RemixDev/deemix-py/pulls/33
2022-01-04 15:23:51 -05:00
7fe430b77f Merge pull request 'Rebase with upstream' (#1) from RemixDev/deemix-py:main into main
Reviewed-on: https://git.freezerapp.xyz/omtinez/deemix-py/pulls/1
2022-01-04 15:17:01 -05:00
27219d0698 fixed changeCase for strings with trailing whitespace 2022-01-02 14:14:57 +01:00
1863557f49 fixed feat removal issue 2022-01-02 13:53:00 +01:00
b983712086 v3.6.4 2021-12-29 17:48:14 +01:00
3aa1389eed fixed album art not downloading 2021-12-29 17:42:45 +01:00
a5f4235128 Handle None values in track parser 2021-12-28 09:43:56 -05:00
724a7affa6 v3.6.3 2021-12-28 09:31:33 +01:00
9e992af9c1 stop creating .cache files wherever the script is run 2021-12-28 09:30:52 +01:00
e77791fe69 fixed AttributeError when downloading single 2021-12-28 09:11:44 +01:00
0c4db05db1 fixed KeyError issue when downloading single 2021-12-28 09:10:31 +01:00
5107bde5c9 v3.6.2 2021-12-28 08:46:47 +01:00
32a31d8842 fixed mistype
#32
2021-12-28 08:46:01 +01:00
e94a3ab28f v3.6.1 2021-12-23 23:23:30 +01:00
9f9433d205 Fixed default data missing 2021-12-23 23:12:50 +01:00
5d44c971f7 v3.6.0 2021-12-23 19:28:54 +01:00
eb84a392ba fixed issues from version parity 2021-12-23 19:25:45 +01:00
6b41e6cb0a version parity 2021-12-23 19:02:33 +01:00
ef3c9fbf57 fixed deezer helper 2021-12-23 18:45:55 +01:00
55383332f8 v3.5.7 2021-12-21 15:41:27 +01:00
add5158022 renamed spotify/settings.json into spotify/config.json 2021-12-21 15:35:12 +01:00
5ec49663e3 try catch loading spotify cache 2021-12-21 15:14:11 +01:00
6a6ec400db try catch getting music folder from reg registry 2021-12-21 15:13:27 +01:00
8be934fb42 Fixed conversion logging 2021-12-21 12:50:39 +01:00
13efa2bc90 Fixed encoding issues 2021-12-21 12:40:35 +01:00
133314f481 Fixed playlistID mistype 2021-12-19 18:09:05 +01:00
09511fb379 Add more cases for feat. removal 2021-12-19 17:40:00 +01:00
7f1eb0c500 Made ARTISTS tag optional 2021-12-19 17:28:56 +01:00
ed22a396c8 Updated default settings 2021-12-19 17:27:01 +01:00
9906043b31 v3.5.5 2021-10-07 16:19:59 +02:00
3b0763eeb1 Handle settings corruption issue 2021-10-07 16:18:50 +02:00
7e6202b7f0 Made downloadObject optional in streamTrack 2021-10-07 14:57:09 +02:00
54374c87e2 Fix append (not push) on list 2021-10-07 12:57:39 +02:00
ce98393683 Don't save BPM to tags if not found 2021-09-30 19:40:46 +02:00
d2e0450ace v.3.5.3 2021-09-24 16:24:55 +02:00
0907586e0d Merge pull request 'Remove language arg to match change in deezer-py 1.2.3' (#27) from digitalec/deemix-py:main into main
Reviewed-on: https://git.freezer.life/RemixDev/deemix-py/pulls/27
2021-09-24 03:58:35 -04:00
bb8bb1b4c4 Remove language arg to match change in deezer-py 1.2.3 2021-09-23 17:15:30 -04:00
45910742c2 v3.5.2 2021-09-23 14:30:08 +02:00
09857b1247 Fixed BAD STARTING SYNC issue when downloading some MP3s 2021-09-23 14:16:32 +02:00
93fa5fd8a1 Updated deezer-py lib version 2021-09-23 13:29:02 +02:00
114ce94148 v3.5.1 2021-09-22 08:22:15 +02:00
b9da32b2a2 Fixed strip position 2021-09-22 08:17:59 +02:00
a4b707bd88 v3.5.0 2021-09-21 18:48:18 +02:00
58f2b875fa Made spotipy an optional dependency 2021-09-21 18:32:20 +02:00
c2b19eef33 Fixed #23 2021-09-21 14:33:54 +02:00
8b896fe7e7 Fixed isdecimal check 2021-09-21 14:23:51 +02:00
d11843c733 Merge pull request 'Pass correct config folder to the spotify plugin from cli' (#24) from xtream1101/deemix-py:main into main
Reviewed-on: https://git.freezer.life/RemixDev/deemix-py/pulls/24
2021-09-21 05:20:46 -04:00
8074cf06b7 Correctly capitalize after opening bracket 2021-09-21 09:12:15 +02:00
4e16f14ffc API: Added option to inject genre tag from external sources for tracks 2021-09-21 09:01:30 +02:00
e61a9aa626 Add stack to unknown error report 2021-09-21 08:57:16 +02:00
ae5243288d Rank is not essential data 2021-09-21 08:53:05 +02:00
d8580d5d19 Pass correct config folder to the spotify plugin from cli 2021-09-12 12:52:17 +00:00
c8bda282d1 v3.4.4 2021-09-03 00:05:11 +02:00
2694b05b9d Fixed misspell in utils/deezer 2021-09-03 00:04:43 +02:00
385cdce2c0 Added more info to files in downloadObject 2021-09-03 00:04:17 +02:00
5d1102c6a7 v3.4.3 2021-08-11 12:26:04 +02:00
a705794a91 Merge code cleanup 2021-08-11 12:25:11 +02:00
caee30f37c Merge pull request 'fix KeyError in convertTrack and generateTrackItem' (#20) from jojo/deemix-py:spotify_fix into main
Reviewed-on: https://git.freezer.life/RemixDev/deemix-py/pulls/20
2021-08-11 06:22:36 -04:00
242465eb21 Merge pull request 'Fix error "AttributeError: 'dict' object has no attribute 'name'" when attempt to downloading an artist' (#16) from AllsGamingHD/deemix-py-fork:main into main
Reviewed-on: https://git.freezer.life/RemixDev/deemix-py/pulls/16
2021-08-11 06:10:26 -04:00
c4f11aef7c fix KeyError in convertTrack and generateTrackItem
in Spotify class
2021-08-08 22:41:40 +02:00
bb00dd218d Fix error "AttributeError: 'dict' object has no attribute 'name'" when attempt to downloading an artist 2021-08-05 19:27:28 +02:00
01bcd9ce37 v3.4.2 2021-08-04 21:41:00 +02:00
8d84b9f85a Fixed #12 2021-08-04 21:36:55 +02:00
87e83e807f Fixed download oversight 2021-08-04 21:36:34 +02:00
2d3d6d0699 Made rating an optional tag 2021-08-04 20:39:51 +02:00
09f087484d Merge pull request 'add POPM on ID3 and RATING on Flac' (#14) from TheoD02/deemix-py-fork:feature/id3_popm into main
Reviewed-on: https://git.freezer.life/RemixDev/deemix-py/pulls/14
2021-08-04 14:37:06 -04:00
4c119447f5 add POPM on ID3 and RATING on Flac 2021-08-03 23:08:06 +02:00
44d018a810 v3.4.1 2021-08-03 18:53:34 +02:00
41469cee64 Fixed url test when url is None 2021-08-03 18:53:15 +02:00
8cea4289d1 v3.4.0 2021-08-02 23:55:30 +02:00
ec3a6de8df Logging strings changed 2021-08-02 23:54:15 +02:00
3030140e15 Implemented formatListener 2021-08-02 23:45:08 +02:00
1859f07842 Added missing error imports 2021-08-02 23:44:52 +02:00
2e7c5c4e65 Code parity between libraries 2021-08-02 23:08:01 +02:00
e4f677e6b4 Implemented new getPreferredBitrate function 2021-08-02 22:42:03 +02:00
8d325d5321 Move extrasPath to DownloadObject 2021-08-02 22:09:28 +02:00
834de5a09c Moved errors in dedicated file 2021-08-02 21:55:22 +02:00
0b8647845c Updated setup.py 2021-07-25 15:27:29 +02:00
43112e28eb v3.3.3 2021-07-25 13:59:26 +02:00
8894ba7862 Fixed mistype in downloader.py 2021-07-25 13:56:34 +02:00
49dddea45e Version bump to 3.3.2 2021-07-25 13:09:49 +02:00
3b3146f220 Changes to make the lib work with deezer-py 1.0.2 2021-07-25 13:09:12 +02:00
d0cf20db8f Fixed lib not decrypting tracks 2021-07-25 12:51:46 +02:00
7536597495 v3.3.0 2021-07-25 11:40:33 +02:00
4d5ef2850e Fixed #10 2021-07-25 11:38:08 +02:00
7efaa6aaf7 Fixed #11 2021-07-25 11:35:37 +02:00
aa69439967 Added error handling for new url system 2021-07-25 11:34:20 +02:00
90b3be50e9 Implemented new url system 2021-07-25 11:33:59 +02:00
7838d4eefe v3.2.2 2021-07-20 15:21:52 +02:00
e11c69101b Fixed #9 2021-07-20 15:20:32 +02:00
092e96f4bd Version bump to 3.2.0 2021-07-20 14:47:00 +02:00
c007d39e15 Reverted back to crypted streams 2021-07-20 14:44:20 +02:00
d80702e949 Updated .gitignore 2021-07-16 15:05:18 +02:00
e194588d39 Version bump to 3.0.4 2021-07-16 14:59:35 +02:00
a7dd659e22 Added better error reporting for generation in cli 2021-07-16 14:55:11 +02:00
4119617c6b Fixed spotify plugin not working #7 2021-07-16 14:51:26 +02:00
263ecd4be0 Fixed #6 2021-07-16 14:49:10 +02:00
11447d606b Version bump to 3.0.3 2021-07-05 18:19:38 +02:00
536caee401 Fixed cli 2021-07-05 18:18:57 +02:00
07bdca4599 Made artist download work 2021-07-05 18:11:17 +02:00
4e1485f8d6 Fixed subscriptable issue 2021-07-05 18:02:08 +02:00
f0c3152ffa Version bump to 3.0.2 2021-07-03 11:00:31 +02:00
78804710d1 Fixed isdecimal issue 2021-07-03 10:59:05 +02:00
82bbb5c2ab Make sure the new cover md5 exists before overwriting it 2021-07-01 20:19:13 +02:00
01cc9f5199 Version bump to 3.0.1 2021-06-29 01:39:22 +02:00
b8e8d27357 Listener logging parity 2021-06-29 01:37:34 +02:00
d8ecb244f5 Added missing unlink on error 2021-06-29 01:11:13 +02:00
f530a4e89f Merge refactoring (#4)
Removed saveDownloadQueue and tagsLanguage from lib settings

Revert embedded cover change

Fixed bitrate fallback check

Use overwriteFile setting when downloading embedded covers

Fixed bitrate fallback not working

Fixed some issues to make the lib work

Implemented spotify plugin back

Better handling of albums upcs

Fixed queue item not cancelling correctly

Code parity with deemix-js

Code cleanup with pylint

Even more rework on the library

More work on the library (WIP)

Total rework of the library (WIP)

Some rework done on types

Added start queue function

Made nextitem work on a thread

Removed dz as first parameter

Started queuemanager refactoring

Removed eventlet

Co-authored-by: RemixDev <RemixDev64@gmail.com>
Reviewed-on: https://git.freezer.life/RemixDev/deemix-py/pulls/4
Co-Authored-By: RemixDev <remixdev@noreply.localhost>
Co-Committed-By: RemixDev <remixdev@noreply.localhost>
2021-06-27 16:29:41 -04:00
21 changed files with 964 additions and 685 deletions

12
.gitignore vendored
View File

@ -26,11 +26,13 @@ yarn-error.log*
*.sw?
# Private configs
/config.py
/test.py
config.py
test.py
config
#build files
/build
/*egg-info
build
*egg-info
updatePyPi.sh
/deezer
deezer
.cache

View File

@ -7,11 +7,10 @@ from deemix.itemgen import generateTrackItem, \
generatePlaylistItem, \
generateArtistItem, \
generateArtistDiscographyItem, \
generateArtistTopItem, \
LinkNotRecognized, \
LinkNotSupported
generateArtistTopItem
from deemix.errors import LinkNotRecognized, LinkNotSupported
__version__ = "3.0.0"
__version__ = "3.6.6"
# Returns the Resolved URL, the Type and the ID
def parseLink(link):

View File

@ -7,9 +7,21 @@ from deezer import TrackFormats
from deemix import generateDownloadObject
from deemix.settings import load as loadSettings
from deemix.utils import getBitrateNumberFromText
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.option('--portable', is_flag=True, help='Creates the config folder in the same directory where the script is launched')
@ -22,7 +34,8 @@ def download(url, bitrate, portable, path):
configFolder = localpath / 'config' if portable else localpaths.getConfigFolder()
settings = loadSettings(configFolder)
dz = Deezer(settings.get('tagsLanguage', ""))
dz = Deezer()
listener = LogListener()
def requestValidArl():
while True:
@ -31,13 +44,20 @@ def download(url, bitrate, portable, path):
return arl
if (configFolder / '.arl').is_file():
with open(configFolder / '.arl', 'r') as f:
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()
with open(configFolder / '.arl', 'w') as f:
with open(configFolder / '.arl', 'w', encoding="utf-8") as f:
f.write(arl)
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 = []
@ -48,9 +68,24 @@ def download(url, bitrate, portable, path):
else:
links.append(link)
downloadObjects = []
for link in links:
downloadObject = generateDownloadObject(dz, link, bitrate)
Downloader(dz, downloadObject, settings).start()
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 == '': path = '.'
@ -66,7 +101,7 @@ def download(url, bitrate, portable, path):
isfile = False
if isfile:
filename = url[0]
with open(filename) as f:
with open(filename, encoding="utf-8") as f:
url = f.readlines()
downloadLinks(url, bitrate)

View File

@ -10,6 +10,7 @@ from deemix.utils.crypto import _md5, _ecbCrypt, _ecbDecrypt, generateBlowfishKe
from deemix.utils import USER_AGENT_HEADER
from deemix.types.DownloadObjects import Single
from deemix.errors import DownloadCanceled, DownloadEmpty
logger = logging.getLogger('deemix')
@ -40,15 +41,22 @@ def reverseStreamURL(url):
return reverseStreamPath(urlPart)
def streamTrack(outputStream, track, start=0, downloadObject=None, listener=None):
if downloadObject.isCanceled: raise DownloadCanceled
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
itemName = f"[{track.mainArtist.name} - {track.title}]"
itemData = {
'id': track.id,
'title': track.title,
'artist': track.mainArtist.name
}
try:
with get(track.downloadUrl, headers=headers, stream=True, timeout=10) as request:
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
@ -57,7 +65,7 @@ def streamTrack(outputStream, track, start=0, downloadObject=None, listener=None
if listener:
listener.send('downloadInfo', {
'uuid': downloadObject.uuid,
'itemName': itemName,
'data': itemData,
'state': "downloading",
'alreadyStarted': True,
'value': responseRange
@ -66,13 +74,24 @@ def streamTrack(outputStream, track, start=0, downloadObject=None, listener=None
if listener:
listener.send('downloadInfo', {
'uuid': downloadObject.uuid,
'itemName': itemName,
'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)
@ -86,71 +105,7 @@ def streamTrack(outputStream, track, start=0, downloadObject=None, listener=None
downloadObject.updateProgress(listener)
except (SSLError, u3SSLError):
logger.info('%s retrying from byte %s', itemName, chunkLength)
streamTrack(outputStream, track, chunkLength, downloadObject, listener)
except (RequestsConnectionError, ReadTimeout, ChunkedEncodingError):
sleep(2)
streamTrack(outputStream, track, start, downloadObject, listener)
def streamCryptedTrack(outputStream, track, start=0, downloadObject=None, listener=None):
if downloadObject.isCanceled: raise DownloadCanceled
headers= {'User-Agent': USER_AGENT_HEADER}
chunkLength = start
itemName = f"[{track.mainArtist.name} - {track.title}]"
try:
with 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"]
if listener:
listener.send('downloadInfo', {
'uuid': downloadObject.uuid,
'itemName': itemName,
'state': "downloading",
'alreadyStarted': True,
'value': responseRange
})
else:
if listener:
listener.send('downloadInfo', {
'uuid': downloadObject.uuid,
'itemName': itemName,
'state': "downloading",
'alreadyStarted': False,
'value': complete
})
for chunk in request.iter_content(2048 * 3):
if len(chunk) >= 2048:
chunk = decryptChunk(blowfish_key, chunk[0:2048]) + chunk[2048:]
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):
logger.info('%s retrying from byte %s', itemName, chunkLength)
streamCryptedTrack(outputStream, track, chunkLength, downloadObject, listener)
except (RequestsConnectionError, ReadTimeout, ChunkedEncodingError):
sleep(2)
streamCryptedTrack(outputStream, track, start, downloadObject, listener)
class DownloadCanceled(Exception):
pass
class DownloadEmpty(Exception):
pass

View File

@ -1,5 +1,6 @@
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
@ -18,14 +19,17 @@ 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, AlbumDoesntExists, MD5NotFound
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 generateStreamURL, streamTrack, DownloadCanceled
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')
@ -40,6 +44,17 @@ extensions = {
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)
@ -60,7 +75,7 @@ def downloadImage(url, path, overwrite=OverwriteOption.DONT_OVERWRITE):
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) as e:
except (requests.exceptions.ConnectionError, requests.exceptions.ChunkedEncodingError, u3SSLError):
if path.is_file(): path.unlink()
sleep(5)
return downloadImage(url, path, overwrite)
@ -70,11 +85,54 @@ def downloadImage(url, path, overwrite=OverwriteOption.DONT_OVERWRITE):
logger.exception("Error while downloading an image, you should report this to the developers: %s", e)
return None
def getPreferredBitrate(track, bitrate, shouldFallback, uuid=None, listener=None):
bitrate = int(bitrate)
if track.local: return TrackFormats.LOCAL
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",
@ -87,8 +145,7 @@ def getPreferredBitrate(track, bitrate, shouldFallback, uuid=None, listener=None
TrackFormats.MP4_RA1: "MP4_RA1",
}
is360format = bitrate in formats_360.keys()
is360format = preferredBitrate in formats_360.keys()
if not shouldFallback:
formats = formats_360
formats.update(formats_non_360)
@ -97,38 +154,41 @@ def getPreferredBitrate(track, bitrate, shouldFallback, uuid=None, listener=None
else:
formats = formats_non_360
def testBitrate(track, formatNumber, formatName):
request = requests.head(
generateStreamURL(track.id, track.MD5, track.mediaVersion, formatNumber),
headers={'User-Agent': USER_AGENT_HEADER},
timeout=30
)
try:
request.raise_for_status()
track.filesizes[f"FILESIZE_{formatName}"] = int(request.headers["Content-Length"])
track.filesizes[f"FILESIZE_{formatName}_TESTED"] = True
if track.filesizes[f"FILESIZE_{formatName}"] == 0: return None
return formatNumber
except requests.exceptions.HTTPError: # if the format is not available, Deezer returns a 403 error
return None
# check and renew trackToken before starting the check
track.checkAndRenewTrackToken(dz)
for formatNumber, formatName in formats.items():
if formatNumber > bitrate: continue
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"]:
testedBitrate = testBitrate(track, formatNumber, formatName)
if testedBitrate: return testedBitrate
# 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('queueUpdate', {
listener.send('downloadInfo', {
'uuid': uuid,
'bitrateFallback': True,
'state': 'bitrateFallback',
'data': {
'id': track.id,
'title': track.title,
@ -136,6 +196,8 @@ def getPreferredBitrate(track, bitrate, shouldFallback, uuid=None, listener=None
},
})
if is360format: raise TrackNot360
url = getCorrectURL(track, "MP3_MISC", TrackFormats.DEFAULT, feelingLucky)
track.urls["MP3_MISC"] = url
return TrackFormats.DEFAULT
class Downloader:
@ -146,7 +208,6 @@ class Downloader:
self.bitrate = downloadObject.bitrate
self.listener = listener
self.extrasPath = None
self.playlistCoverName = None
self.playlistURLs = []
@ -154,47 +215,58 @@ class Downloader:
if not self.downloadObject.isCanceled:
if isinstance(self.downloadObject, Single):
track = self.downloadWrapper({
'trackAPI_gw': self.downloadObject.single['trackAPI_gw'],
'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_gw'])
tracks = [None] * len(self.downloadObject.collection['tracks'])
with ThreadPoolExecutor(self.settings['queueConcurrency']) as executor:
for pos, track in enumerate(self.downloadObject.collection['tracks_gw'], start=0):
for pos, track in enumerate(self.downloadObject.collection['tracks'], start=0):
tracks[pos] = executor.submit(self.downloadWrapper, {
'trackAPI_gw': track,
'trackAPI': track,
'albumAPI': self.downloadObject.collection.get('albumAPI'),
'playlistAPI': self.downloadObject.collection.get('playlistAPI')
})
self.afterDownloadCollection(tracks)
if self.listener:
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_gw = extraData['trackAPI_gw']
trackAPI = extraData.get('trackAPI')
albumAPI = extraData.get('albumAPI')
playlistAPI = extraData.get('playlistAPI')
trackAPI['size'] = self.downloadObject.size
if self.downloadObject.isCanceled: raise DownloadCanceled
if trackAPI_gw['SNG_ID'] == "0": raise DownloadFailed("notOnDeezer")
if int(trackAPI['id']) == 0: raise DownloadFailed("notOnDeezer")
itemName = f"[{trackAPI_gw['ART_NAME']} - {trackAPI_gw['SNG_TITLE']}]"
itemData = {
'id': trackAPI['id'],
'title': trackAPI['title'],
'artist': trackAPI['artist']['name']
}
# Create Track object
if not track:
logger.info("%s Getting the tags", itemName)
self.log(itemData, "getTags")
try:
track = Track().parseData(
dz=self.dz,
trackAPI_gw=trackAPI_gw,
track_id=trackAPI['id'],
trackAPI=trackAPI,
albumAPI=albumAPI,
playlistAPI=playlistAPI
@ -203,26 +275,38 @@ class Downloader:
raise DownloadError('albumDoesntExists') from e
except MD5NotFound as e:
raise DownloadError('notLoggedIn') from e
self.log(itemData, "gotTags")
itemName = f"[{track.mainArtist.name} - {track.title}]"
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['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)
@ -236,7 +320,7 @@ class Downloader:
writepath = filepath / f"{filename}{extension}"
# Save extrasPath
if extrasPath and not self.extrasPath: self.extrasPath = extrasPath
if extrasPath and not self.downloadObject.extrasPath: self.downloadObject.extrasPath = extrasPath
# Generate covers URLs
embeddedImageFormat = f'jpg-{self.settings["jpegImageQuality"]}'
@ -248,8 +332,9 @@ class Downloader:
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
logger.info("%s Getting the album cover", itemName)
self.log(itemData, "getAlbumArt")
track.album.embeddedCoverPath = downloadImage(track.album.embeddedCoverURL, track.album.embeddedCoverPath)
self.log(itemData, "gotAlbumArt")
# Save local album art
if coverPath:
@ -297,8 +382,8 @@ class Downloader:
# 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'))
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()
@ -322,26 +407,26 @@ class Downloader:
writepath = Path(currentFilename)
if not trackAlreadyDownloaded or self.settings['overwriteFile'] == OverwriteOption.OVERWRITE:
logger.info("%s Downloading the track", itemName)
track.downloadUrl = generateStreamURL(track.id, track.MD5, track.mediaVersion, track.bitrate)
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:
logger.info("%s Skipping track as it's already downloaded", itemName)
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:
logger.info("%s Applying tags to the track", itemName)
self.log(itemData, "tagging")
if extension == '.mp3':
tagID3(writepath, track, self.settings['tags'])
elif extension == '.flac':
@ -349,47 +434,35 @@ class Downloader:
tagFLAC(writepath, track, self.settings['tags'])
except (FLACNoHeaderError, FLACError):
writepath.unlink()
logger.warning("%s Track not available in FLAC, falling back if necessary", itemName)
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(trackAPI_gw, track=track)
return self.download(extraData, track=track)
self.log(itemData, "tagged")
if track.searched: returnData['searched'] = True
self.downloadObject.downloaded += 1
self.downloadObject.files.append(str(writepath))
self.downloadObject.extrasPath = str(self.extrasPath)
logger.info("%s Track download completed\n%s", itemName, writepath)
if self.listener: self.listener.send("updateQueue", {
'uuid': self.downloadObject.uuid,
'downloaded': True,
'downloadPath': str(writepath),
'extrasPath': str(self.extrasPath)
'extrasPath': str(self.downloadObject.extrasPath)
})
returnData['filename'] = str(writepath)[len(str(extrasPath))+ len(pathSep):]
returnData['data'] = {
'id': track.id,
'title': track.title,
'artist': track.mainArtist.name
}
returnData['data'] = itemData
returnData['path'] = str(writepath)
self.downloadObject.files.append(returnData)
return returnData
def downloadWrapper(self, extraData, track=None):
trackAPI_gw = extraData['trackAPI_gw']
if ('_EXTRA_TRACK' in trackAPI_gw):
extraData['trackAPI'] = trackAPI_gw['_EXTRA_TRACK'].copy()
del extraData['trackAPI_gw']['_EXTRA_TRACK']
del trackAPI_gw['_EXTRA_TRACK']
trackAPI = extraData['trackAPI']
# Temp metadata to generate logs
tempTrack = {
'id': trackAPI_gw['SNG_ID'],
'title': trackAPI_gw['SNG_TITLE'].strip(),
'artist': trackAPI_gw['ART_NAME']
itemData = {
'id': trackAPI['id'],
'title': trackAPI['title'],
'artist': trackAPI['artist']['name']
}
if trackAPI_gw.get('VERSION') and trackAPI_gw['VERSION'] not in trackAPI_gw['SNG_TITLE']:
tempTrack['title'] += f" {trackAPI_gw['VERSION']}".strip()
itemName = f"[{tempTrack['artist']} - {tempTrack['title']}]"
try:
result = self.download(extraData, track)
@ -397,42 +470,50 @@ class Downloader:
if error.track:
track = error.track
if track.fallbackID != "0":
logger.warning("%s %s Using fallback id", itemName, error.message)
self.warn(itemData, error.errid, 'fallback')
newTrack = self.dz.gw.get_track_with_fallback(track.fallbackID)
newTrack = map_track(newTrack)
track.parseEssentialData(newTrack)
track.retriveFilesizes(self.dz)
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']:
logger.warning("%s %s Searching for alternative", itemName, error.message)
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.retriveFilesizes(self.dz)
track.searched = True
if self.listener: self.listener.send('queueUpdate', {
'uuid': self.downloadObject.uuid,
'searchFallback': True,
'data': {
'id': track.id,
'title': track.title,
'artist': track.mainArtist.name
},
})
self.log(itemData, "searchFallback")
return self.downloadWrapper(extraData, track)
error.errid += "NoAlternative"
error.message = errorMessages[error.errid]
logger.error("%s %s", itemName, error.message)
error.message = ErrorMessages[error.errid]
result = {'error': {
'message': error.message,
'errid': error.errid,
'data': tempTrack
'data': itemData,
'type': "track"
}}
except Exception as e:
logger.exception("%s %s", itemName, e)
logger.exception("%s %s", f"{itemData['artist']} - {itemData['title']}", e)
result = {'error': {
'message': str(e),
'data': tempTrack
'data': itemData,
'stack': traceback.format_exc(),
'type': "track"
}}
if 'error' in result:
@ -446,39 +527,74 @@ class Downloader:
'failed': True,
'data': error['data'],
'error': error['message'],
'errid': error['errid'] if 'errid' in error else None
'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.extrasPath: self.extrasPath = Path(self.settings['downloadLocation'])
if not self.downloadObject.extrasPath: self.downloadObject.extrasPath = Path(self.settings['downloadLocation'])
# Save Album Cover
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'])
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
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'])
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
if self.settings['logSearched'] and 'searched' in track:
filename = f"{track.data.artist} - {track.data.title}"
with open(self.extrasPath / 'searched.txt', 'wb+') as f:
searchedFile = f.read().decode('utf-8')
if not filename in searchedFile:
if searchedFile != "": searchedFile += "\r\n"
searchedFile += filename + "\r\n"
f.write(searchedFile.encode('utf-8'))
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
if self.settings['executeCommand'] != "":
execute(self.settings['executeCommand'].replace("%folder%", quote(str(self.extrasPath))).replace("%filename%", quote(track['filename'])), shell=True)
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.extrasPath: self.extrasPath = Path(self.settings['downloadLocation'])
if not self.downloadObject.extrasPath: self.downloadObject.extrasPath = Path(self.settings['downloadLocation'])
playlist = [None] * len(tracks)
errors = ""
searched = ""
@ -496,69 +612,61 @@ class Downloader:
if 'searched' in track: searched += track['searched'] + "\r\n"
# Save Album Cover
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'])
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
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'])
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
if self.settings['logErrors'] and errors != "":
with open(self.extrasPath / 'errors.txt', 'wb') as f:
f.write(errors.encode('utf-8'))
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
if self.settings['logSearched'] and searched != "":
with open(self.extrasPath / 'searched.txt', 'wb') as f:
f.write(searched.encode('utf-8'))
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
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'])
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
if self.settings['createM3U8File']:
filename = generateDownloadObjectName(self.settings['playlistFilenameTemplate'], self.downloadObject, 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'))
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
if self.settings['executeCommand'] != "":
execute(self.settings['executeCommand'].replace("%folder%", quote(str(self.extrasPath))), shell=True)
class DownloadError(Exception):
"""Base class for exceptions in this module."""
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"
}
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
try:
if self.settings['executeCommand'] != "":
execute(self.settings['executeCommand'].replace("%folder%", quote(str(self.downloadObject.extrasPath))))
except Exception as e:
self.afterDownloadErrorReport("ExecuteCommand", e)

96
deemix/errors.py Normal file
View File

@ -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

View File

@ -1,47 +1,52 @@
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 deezer.gw import GWAPIError, LyricsStatus
from deezer.api import APIError
from deezer.utils import map_user_playlist
from deemix.errors import GenerationError, ISRCnotOnDeezer, InvalidID, NotYourPrivatePlaylist
logger = logging.getLogger('deemix')
def generateTrackItem(dz, link_id, bitrate, trackAPI=None, albumAPI=None):
# Check if is an isrc: url
if str(link_id).startswith("isrc"):
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
if 'id' in trackAPI and 'title' in trackAPI:
link_id = trackAPI['id']
else:
raise ISRCnotOnDeezer(f"https://deezer.com/track/{link_id}")
if not link_id.isdecimal(): raise InvalidID(f"https://deezer.com/track/{link_id}")
# Get essential track info
try:
trackAPI_gw = dz.gw.get_track_with_fallback(link_id)
except GWAPIError as e:
raise GenerationError(f"https://deezer.com/track/{link_id}", str(e)) from e
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
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)))
# 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': 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,
'title': trackAPI['title'],
'artist': trackAPI['artist']['name'],
'cover': cover,
'explicit': trackAPI['explicit_lyrics'],
'single': {
'trackAPI_gw': trackAPI_gw,
'trackAPI': trackAPI,
'albumAPI': albumAPI
}
@ -64,18 +69,25 @@ def generateAlbumItem(dz, link_id, bitrate, rootArtist=None):
link_id = albumAPI['id']
else:
try:
albumAPI = dz.api.get_album(link_id)
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 link_id.isdecimal(): raise InvalidID(f"https://deezer.com/album/{link_id}")
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['nb_disk'] = albumAPI_gw['NUMBER_DISK']
albumAPI['copyright'] = albumAPI_gw['COPYRIGHT']
albumAPI['release_date'] = albumAPI_gw['PHYSICAL_RELEASE_DATE']
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
@ -89,18 +101,17 @@ def generateAlbumItem(dz, link_id, bitrate, rootArtist=None):
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_gw['ALB_PICTURE']}/75x75-000000-80-0-0.jpg"
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['POSITION'] = pos
trackAPI['SIZE'] = totalSize
trackAPI = map_track(trackAPI)
if 'track_token' in trackAPI: del trackAPI['track_token']
trackAPI['position'] = pos
collection.append(trackAPI)
explicit = albumAPI_gw.get('EXPLICIT_ALBUM_CONTENT', {}).get('EXPLICIT_LYRICS_STATUS', LyricsStatus.UNKNOWN) in [LyricsStatus.EXPLICIT, LyricsStatus.PARTIALLY_EXPLICIT]
return Collection({
'type': 'album',
'id': link_id,
@ -108,17 +119,17 @@ def generateAlbumItem(dz, link_id, bitrate, rootArtist=None):
'title': albumAPI['title'],
'artist': albumAPI['artist']['name'],
'cover': cover,
'explicit': explicit,
'explicit': albumAPI['explicit_lyrics'],
'size': totalSize,
'collection': {
'tracks_gw': collection,
'tracks': collection,
'albumAPI': albumAPI
}
})
def generatePlaylistItem(dz, link_id, bitrate, playlistAPI=None, playlistTracksAPI=None):
if not playlistAPI:
if not link_id.isdecimal(): raise InvalidID(f"https://deezer.com/playlist/{link_id}")
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)
@ -145,10 +156,11 @@ def generatePlaylistItem(dz, link_id, bitrate, playlistAPI=None, playlistTracksA
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]:
trackAPI = map_track(trackAPI)
if trackAPI['explicit_lyrics']:
playlistAPI['explicit'] = True
trackAPI['POSITION'] = pos
trackAPI['SIZE'] = totalSize
if 'track_token' in trackAPI: del trackAPI['track_token']
trackAPI['position'] = pos
collection.append(trackAPI)
if 'explicit' not in playlistAPI: playlistAPI['explicit'] = False
@ -163,13 +175,13 @@ def generatePlaylistItem(dz, link_id, bitrate, playlistAPI=None, playlistTracksA
'explicit': playlistAPI['explicit'],
'size': totalSize,
'collection': {
'tracks_gw': collection,
'tracks': collection,
'playlistAPI': playlistAPI
}
})
def generateArtistItem(dz, link_id, bitrate, listener=None):
if not link_id.isdecimal(): raise InvalidID(f"https://deezer.com/artist/{link_id}")
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)
@ -196,7 +208,7 @@ def generateArtistItem(dz, link_id, bitrate, listener=None):
return albumList
def generateArtistDiscographyItem(dz, link_id, bitrate, listener=None):
if not link_id.isdecimal(): raise InvalidID(f"https://deezer.com/artist/{link_id}/discography")
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)
@ -224,7 +236,7 @@ def generateArtistDiscographyItem(dz, link_id, bitrate, listener=None):
return albumList
def generateArtistTopItem(dz, link_id, bitrate):
if not link_id.isdecimal(): raise InvalidID(f"https://deezer.com/artist/{link_id}/top_track")
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)
@ -263,45 +275,3 @@ def generateArtistTopItem(dz, link_id, bitrate):
artistTopTracksAPI_gw = dz.gw.get_artist_toptracks(link_id)
return generatePlaylistItem(dz, playlistAPI['id'], bitrate, playlistAPI=playlistAPI, playlistTracksAPI=artistTopTracksAPI_gw)
class GenerationError(Exception):
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")

View File

@ -1,15 +1,19 @@
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, GenerationError, TrackNotOnDeezer, AlbumNotOnDeezer
from deemix.types.DownloadObjects import Convertable
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):
@ -54,7 +58,7 @@ class Spotify(Plugin):
return (link, link_type, link_id)
def generateDownloadObject(self, dz, link, bitrate):
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
@ -91,7 +95,10 @@ class Spotify(Plugin):
cachedTrack['id'] = trackID
cache['tracks'][link_id] = cachedTrack
self.saveCache(cache)
if cachedTrack['id'] != "0": return generateTrackItem(dz, cachedTrack['id'], bitrate)
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):
@ -112,9 +119,9 @@ class Spotify(Plugin):
spotifyPlaylist = self.sp.playlist(link_id)
playlistAPI = self._convertPlaylistStructure(spotifyPlaylist)
playlistAPI.various_artist = dz.api.get_artist(5080) # Useful for save as compilation
playlistAPI['various_artist'] = dz.api.get_artist(5080) # Useful for save as compilation
tracklistTemp = spotifyPlaylist.track.items
tracklistTemp = spotifyPlaylist['tracks']['items']
while spotifyPlaylist['tracks']['next']:
spotifyPlaylist['tracks'] = self.sp.next(spotifyPlaylist['tracks'])
tracklistTemp += spotifyPlaylist['tracks']['items']
@ -137,7 +144,7 @@ class Spotify(Plugin):
'explicit': playlistAPI['explicit'],
'size': len(tracklist),
'collection': {
'tracks_gw': [],
'tracks': [],
'playlistAPI': playlistAPI
},
'plugin': 'spotify',
@ -179,8 +186,10 @@ class Spotify(Plugin):
}
return cachedAlbum
def convertTrack(self, dz, downloadObject, track, pos, conversion, conversionNext, cache, listener):
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']]
@ -193,7 +202,7 @@ class Spotify(Plugin):
try:
trackAPI = dz.api.get_track_by_ISRC(cachedTrack['isrc'])
if 'id' not in trackAPI or 'title' not in trackAPI: trackAPI = None
except GenerationError: pass
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(
@ -205,46 +214,59 @@ class Spotify(Plugin):
cachedTrack['id'] = trackID
cache['tracks'][track['id']] = cachedTrack
self.saveCache(cache)
if cachedTrack['id'] != "0": trackAPI = dz.api.get_track(cachedTrack['id'])
if cachedTrack.get('id', "0") != "0":
trackAPI = dz.api.get_track(cachedTrack['id'])
if not trackAPI:
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']
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']
}
}
else:
deezerTrack = dz.gw.get_track_with_fallback(trackAPI['id'])
deezerTrack['_EXTRA_TRACK'] = trackAPI
deezerTrack['POSITION'] = pos+1
trackAPI['position'] = pos+1
conversionNext += (1 / downloadObject.size) * 100
if round(conversionNext) != conversion and round(conversionNext) % 2 == 0:
conversion = round(conversionNext)
if listener: listener.send("updateQueue", {'uuid': downloadObject.uuid, 'conversion': conversion})
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 = 0
conversionNext = 0
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, conversionNext,
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):
@ -273,6 +295,7 @@ class Spotify(Plugin):
'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'],
@ -282,19 +305,27 @@ class Spotify(Plugin):
return deezerPlaylist
def loadSettings(self):
if not (self.configFolder / 'settings.json').is_file():
with open(self.configFolder / 'settings.json', 'w') as f:
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 / 'settings.json', 'r') as settingsFile:
settings = json.load(settingsFile)
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 / 'settings.json', 'w') as f:
with open(self.configFolder / 'config.json', 'w', encoding="utf-8") as f:
json.dump({**self.credentials, **self.settings}, f, indent=2)
def getSettings(self):
@ -308,15 +339,21 @@ class Spotify(Plugin):
self.settings = settings
def loadCache(self):
cache = None
if (self.configFolder / 'cache.json').is_file():
with open(self.configFolder / 'cache.json', 'r') as f:
cache = json.load(f)
else:
cache = {'tracks': {}, 'albums': {}}
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') as spotifyCache:
with open(self.configFolder / 'cache.json', 'w', encoding="utf-8") as spotifyCache:
json.dump(newCache, spotifyCache)
def checkCredentials(self):
@ -325,8 +362,10 @@ class Spotify(Plugin):
return
try:
cache_handler = CacheFileHandler(self.configFolder / ".auth-cache")
client_credentials_manager = SpotifyClientCredentials(client_id=self.credentials['clientId'],
client_secret=self.credentials['clientSecret'])
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

View File

@ -1,4 +1,5 @@
import json
from copy import deepcopy
from pathlib import Path
from os import makedirs
from deezer import TrackFormats
@ -20,89 +21,93 @@ class FeaturesOption():
MOVE_TITLE = "2" # Move to track title
DEFAULTS = {
"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%",
"albumTracknameTemplate": "%tracknumber% - %title%",
"albumVariousArtists": True,
"artistCasing": "nothing",
"artistImageTemplate": "folder",
"artistNameTemplate": "%artist%",
"coverImageTemplate": "cover",
"createAlbumFolder": True,
"createArtistFolder": False,
"createCDFolder": True,
"createStructurePlaylist": False,
"createM3U8File": False,
"createPlaylistFolder": True,
"createSingleFolder": False,
"padTracks": True,
"paddingSize": "0",
"illegalCharacterReplacer": "_",
"queueConcurrency": 3,
"maxBitrate": str(TrackFormats.MP3_320),
"fallbackBitrate": True,
"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": str(TrackFormats.MP3_320),
"overwriteFile": OverwriteOption.DONT_OVERWRITE,
"createM3U8File": False,
"paddingSize": "0",
"padTracks": True,
"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,
"playlistNameTemplate": "%playlist%",
"playlistTracknameTemplate": "%position% - %artist% - %title%",
"queueConcurrency": 3,
"removeAlbumVersion": False,
"removeDuplicateArtists": False,
"featuredToTitle": FeaturesOption.NO_CHANGE,
"titleCasing": "nothing",
"artistCasing": "nothing",
"executeCommand": "",
"removeDuplicateArtists": True,
"saveArtwork": True,
"saveArtworkArtist": False,
"syncedLyrics": False,
"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,
"artist": True,
"artists": True,
"barcode": True,
"bpm": True,
"replayGain": False,
"label": True,
"lyrics": False,
"syncedLyrics": False,
"copyright": False,
"composer": False,
"copyright": False,
"cover": True,
"coverDescriptionUTF8": False,
"date": True,
"discNumber": True,
"discTotal": False,
"explicit": False,
"genre": True,
"involvedPeople": False,
"source": False,
"savePlaylistAsCompilation": False,
"useNullSeparator": False,
"saveID3v1": True,
"isrc": True,
"label": True,
"length": True,
"lyrics": False,
"multiArtistSeparator": "default",
"rating": False,
"replayGain": False,
"saveID3v1": True,
"savePlaylistAsCompilation": False,
"singleAlbumArtist": False,
"coverDescriptionUTF8": 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') as configFile:
with open(configFolder / 'config.json', 'w', encoding="utf-8") as configFile:
json.dump(settings, configFile, indent=2)
def load(configFolder=None):
@ -111,8 +116,14 @@ def load(configFolder=None):
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') as configFile:
settings = json.load(configFile)
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: save(settings, configFolder) # Check the settings and save them if something changed
return settings

View File

@ -1,7 +1,7 @@
from mutagen.flac import FLAC, Picture
from mutagen.id3 import ID3, ID3NoHeaderError, \
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
def tagID3(path, track, save):
@ -25,7 +25,8 @@ def tagID3(path, track, save):
tag.add(TPE1(text=track.artistsString))
# 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
tag.add(TXXX(desc="ARTISTS", text=track.artists))
if save['artists']:
tag.add(TXXX(desc="ARTISTS", text=track.artists))
if save['album']:
tag.add(TALB(text=track.album.title))
@ -58,7 +59,7 @@ def tagID3(path, track, save):
tag.add(TDAT(text=str(track.date.day) + str(track.date.month)))
if save['length']:
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)))
if save['label']:
tag.add(TPUB(text=track.album.label))
@ -89,7 +90,7 @@ def tagID3(path, track, save):
if len(involved_people) > 0 and save['involvedPeople']:
tag.add(IPLS(people=involved_people))
if save['copyright']:
if save['copyright'] and track.copyright:
tag.add(TCOP(text=track.copyright))
if save['savePlaylistAsCompilation'] and track.playlist or track.album.recordType == "compile":
tag.add(TCMP(text="1"))
@ -98,6 +99,15 @@ def tagID3(path, track, save):
tag.add(TXXX(desc="SOURCE", text='Deezer'))
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:
descEncoding = Encoding.LATIN1
@ -136,7 +146,8 @@ def tagFLAC(path, track, save):
tag["ARTIST"] = track.artistsString
# 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
tag["ARTISTS"] = track.artists
if save['artists']:
tag["ARTISTS"] = track.artists
if save['album']:
tag["ALBUM"] = track.album.title
@ -168,7 +179,7 @@ def tagFLAC(path, track, save):
if save['length']:
tag["LENGTH"] = str(int(track.duration)*1000)
if save['bpm']:
if save['bpm'] and track.bpm:
tag["BPM"] = str(track.bpm)
if save['label']:
tag["PUBLISHER"] = track.album.label
@ -190,7 +201,7 @@ def tagFLAC(path, track, save):
elif role == 'musicpublisher' and save['involvedPeople']:
tag["ORGANIZATION"] = track.contributors['musicpublisher']
if save['copyright']:
if save['copyright'] and track.copyright:
tag["COPYRIGHT"] = track.copyright
if save['savePlaylistAsCompilation'] and track.playlist or track.album.recordType == "compile":
tag["COMPILATION"] = "1"
@ -199,6 +210,10 @@ def tagFLAC(path, track, save):
tag["SOURCE"] = 'Deezer'
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:
image = Picture()
image.type = PictureType.COVER_FRONT

View File

@ -1,5 +1,3 @@
from deezer.gw import LyricsStatus
from deemix.utils import removeDuplicateArtists, removeFeatures
from deemix.types.Artist import Artist
from deemix.types.Date import Date
@ -30,7 +28,7 @@ class Album:
self.rootArtist = None
self.variousArtists = None
self.playlistId = None
self.playlistID = None
self.owner = None
self.isPlaylist = False
@ -39,8 +37,9 @@ class Album:
# Getting artist image ID
# ex: https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/56x56-000000-80-0-0.jpg
art_pic = albumAPI['artist']['picture_small']
art_pic = art_pic[art_pic.find('artist/') + 7:-24]
art_pic = albumAPI['artist'].get('picture_small')
if art_pic: art_pic = art_pic[art_pic.find('artist/') + 7:-24]
else: art_pic = ""
self.mainArtist = Artist(
albumAPI['artist']['id'],
albumAPI['artist']['name'],
@ -78,57 +77,36 @@ class Album:
self.artist[artist['role']].append(artist['name'])
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.label = albumAPI.get('label', self.label)
self.explicit = bool(albumAPI.get('explicit_lyrics', False))
if 'release_date' in albumAPI:
self.date.day = albumAPI["release_date"][8:10]
self.date.month = albumAPI["release_date"][5:7]
self.date.year = albumAPI["release_date"][0:4]
release_date = albumAPI.get('release_date')
if 'physical_release_date' in albumAPI:
release_date = albumAPI['physical_release_date']
if release_date:
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.copyright = albumAPI.get('copyright')
self.discTotal = albumAPI.get('nb_disk', "1")
self.copyright = albumAPI.get('copyright', "")
if self.pic.md5 == "":
# Getting album cover MD5
# ex: https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/56x56-000000-80-0-0.jpg
alb_pic = albumAPI['cover_small']
self.pic.md5 = alb_pic[alb_pic.find('cover/') + 6:-24]
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
# ex: https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/56x56-000000-80-0-0.jpg
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:
for genre in albumAPI['genres']['data']:
self.genre.append(genre['name'])
def parseAlbumGW(self, albumAPI_gw):
self.title = albumAPI_gw['ALB_TITLE']
self.mainArtist = Artist(
art_id = albumAPI_gw['ART_ID'],
name = albumAPI_gw['ART_NAME'],
role = "Main"
)
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]
self.addExtraAlbumGWData(albumAPI_gw)
def addExtraAlbumGWData(self, albumAPI_gw):
if self.pic.md5 == "":
self.pic.md5 = albumAPI_gw['ALB_PICTURE']
if 'PHYSICAL_RELEASE_DATE' in albumAPI_gw:
self.date.day = albumAPI_gw["PHYSICAL_RELEASE_DATE"][8:10]
self.date.month = albumAPI_gw["PHYSICAL_RELEASE_DATE"][5:7]
self.date.year = albumAPI_gw["PHYSICAL_RELEASE_DATE"][0:4]
self.date.fixDayMonth()
def makePlaylistCompilation(self, playlist):
self.variousArtists = playlist.variousArtists
self.mainArtist = playlist.mainArtist
@ -143,7 +121,7 @@ class Album:
self.explicit = playlist.explicit
self.date = playlist.date
self.discTotal = playlist.discTotal
self.playlistId = playlist.playlistId
self.playlistID = playlist.playlistID
self.owner = playlist.owner
self.pic = playlist.pic
self.isPlaylist = True

View File

@ -1,3 +1,5 @@
from pathlib import Path
class IDownloadObject:
"""DownloadObject Interface"""
def __init__(self, obj):
@ -14,6 +16,8 @@ class IDownloadObject:
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
@ -35,6 +39,7 @@ class IDownloadObject:
'progress': self.progress,
'errors': self.errors,
'files': self.files,
'extrasPath': str(self.extrasPath),
'__type__': self.__type__
}
@ -65,7 +70,8 @@ class IDownloadObject:
'artist': self.artist,
'cover': self.cover,
'explicit': self.explicit,
'size': self.size
'size': self.size,
'extrasPath': str(self.extrasPath)
}
def updateProgress(self, listener=None):

View File

@ -25,5 +25,5 @@ class StaticPicture:
def __init__(self, url):
self.staticURL = url
def getURL(self):
def getURL(self, _, __):
return self.staticURL

View File

@ -1,9 +1,9 @@
from time import sleep
import re
import requests
from datetime import datetime
from deezer.gw import GWAPIError
from deezer.api import APIError
from deezer.utils import map_track, map_album
from deezer.errors import APIError, GWAPIError
from deemix.errors import NoDataToParse, AlbumDoesntExists
from deemix.utils import removeFeatures, andCommaConcat, removeDuplicateArtists, generateReplayGainString, changeCase
@ -23,8 +23,11 @@ class Track:
self.title = name
self.MD5 = ""
self.mediaVersion = ""
self.trackToken = ""
self.trackTokenExpiration = 0
self.duration = 0
self.fallbackID = "0"
self.albumsFallback = []
self.filesizes = {}
self.local = False
self.mainArtist = None
@ -41,6 +44,7 @@ class Track:
self.explicit = False
self.ISRC = ""
self.replayGain = ""
self.rank = 0
self.playlist = None
self.position = None
self.searched = False
@ -50,78 +54,57 @@ class Track:
self.artistsString = ""
self.mainArtistsString = ""
self.featArtistsString = ""
self.urls = {}
def parseEssentialData(self, trackAPI_gw, trackAPI=None):
self.id = str(trackAPI_gw['SNG_ID'])
self.duration = trackAPI_gw['DURATION']
self.MD5 = trackAPI_gw.get('MD5_ORIGIN')
if not self.MD5:
if trackAPI and trackAPI.get('md5_origin'):
self.MD5 = trackAPI['md5_origin']
else:
raise MD5NotFound
self.mediaVersion = trackAPI_gw['MEDIA_VERSION']
def parseEssentialData(self, trackAPI):
self.id = str(trackAPI['id'])
self.duration = trackAPI['duration']
self.trackToken = trackAPI['track_token']
self.trackTokenExpiration = trackAPI['track_token_expire']
self.MD5 = trackAPI.get('md5_origin')
self.mediaVersion = trackAPI['media_version']
self.filesizes = trackAPI['filesizes']
self.fallbackID = "0"
if 'FALLBACK' in trackAPI_gw:
self.fallbackID = trackAPI_gw['FALLBACK']['SNG_ID']
if 'fallback_id' in trackAPI:
self.fallbackID = trackAPI['fallback_id']
self.local = int(self.id) < 0
self.urls = {}
def retriveFilesizes(self, dz):
guest_sid = dz.session.cookies.get('sid')
try:
site = requests.post(
"https://api.deezer.com/1.0/gateway.php",
params={
'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:
sleep(2)
self.retriveFilesizes(dz)
if len(result_json['error']):
raise TrackError(result_json.dumps(result_json['error']))
response = result_json.get("results", {})
filesizes = {}
for key, value in response.items():
if key.startswith("FILESIZE_"):
filesizes[key] = int(value)
filesizes[key+"_TESTED"] = False
self.filesizes = filesizes
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
def parseData(self, dz, track_id=None, trackAPI_gw=None, trackAPI=None, albumAPI_gw=None, albumAPI=None, playlistAPI=None):
if track_id and not trackAPI_gw: trackAPI_gw = dz.gw.get_track_with_fallback(track_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)
self.parseEssentialData(trackAPI_gw, trackAPI)
# only public api has bpm
if not trackAPI.get('bpm') and not self.local:
try:
trackAPI_new = dz.api.get_track(trackAPI['id'])
trackAPI_new['release_date'] = trackAPI['release_date']
trackAPI.update(trackAPI_new)
except APIError: pass
if self.local:
self.parseLocalTrackData(trackAPI_gw)
self.parseLocalTrackData(trackAPI)
else:
self.retriveFilesizes(dz)
self.parseTrackGW(trackAPI_gw)
self.parseTrack(trackAPI)
# Get Lyrics data
if not "LYRICS" in trackAPI_gw and self.lyrics.id != "0":
try: trackAPI_gw["LYRICS"] = dz.gw.get_track_lyrics(self.id)
if not trackAPI.get("lyrics") and self.lyrics.id != "0":
try: trackAPI["lyrics"] = dz.gw.get_track_lyrics(self.id)
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
self.album = Album(
alb_id = trackAPI_gw['ALB_ID'],
title = trackAPI_gw['ALB_TITLE'],
pic_md5 = trackAPI_gw.get('ALB_PICTURE')
alb_id = trackAPI['album']['id'],
title = trackAPI['album']['title'],
pic_md5 = trackAPI['album'].get('md5_origin')
)
# Get album Data
@ -130,28 +113,31 @@ class Track:
except APIError: albumAPI = None
# Get album_gw Data
if not albumAPI_gw:
try: albumAPI_gw = dz.gw.get_album(self.album.id)
except GWAPIError: albumAPI_gw = None
# Only gw has disk number
if not albumAPI or albumAPI and not albumAPI.get('nb_disk'):
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 albumAPI:
self.album.parseAlbum(albumAPI)
elif albumAPI_gw:
self.album.parseAlbumGW(albumAPI_gw)
# albumAPI_gw doesn't contain the artist cover
# Getting artist image ID
# ex: https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/56x56-000000-80-0-0.jpg
if not albumAPI: raise AlbumDoesntExists
self.album.parseAlbum(albumAPI)
# albumAPI_gw doesn't contain the artist cover
# Getting artist image ID
# 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)
self.album.mainArtist.pic.md5 = artistAPI['picture_small'][artistAPI['picture_small'].find('artist/') + 7:-24]
else:
raise AlbumDoesntExists
# Fill missing data
if albumAPI_gw: self.album.addExtraAlbumGWData(albumAPI_gw)
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 not self.copyright: self.copyright = albumAPI_gw['COPYRIGHT']
self.parseTrack(trackAPI)
if 'genres' in trackAPI:
for genre in trackAPI['genres']:
if genre not in self.album.genre: self.album.genre.append(genre)
# Remove unwanted charaters in track name
# Example: track/127793
@ -159,9 +145,9 @@ class Track:
# Make sure there is at least one artist
if len(self.artist['Main']) == 0:
self.artist['Main'] = [self.mainArtist['name']]
self.artist['Main'] = [self.mainArtist.name]
self.position = trackAPI_gw.get('POSITION')
self.position = trackAPI.get('position')
# Add playlist data if track is in a playlist
if playlistAPI: self.playlist = Playlist(playlistAPI)
@ -169,64 +155,54 @@ class Track:
self.generateMainFeatStrings()
return self
def parseLocalTrackData(self, trackAPI_gw):
def parseLocalTrackData(self, trackAPI):
# Local tracks has only the trackAPI_gw page and
# contains only the tags provided by the file
self.title = trackAPI_gw['SNG_TITLE']
self.album = Album(title=trackAPI_gw['ALB_TITLE'])
self.title = trackAPI['title']
self.album = Album(title=trackAPI['album']['title'])
self.album.pic = Picture(
md5 = trackAPI_gw.get('ALB_PICTURE', ""),
md5 = trackAPI.get('md5_image', ""),
pic_type = "cover"
)
self.mainArtist = Artist(name=trackAPI_gw['ART_NAME'], role="Main")
self.artists = [trackAPI_gw['ART_NAME']]
self.mainArtist = Artist(name=trackAPI['artist']['name'], role="Main")
self.artists = [trackAPI['artist']['name']]
self.artist = {
'Main': [trackAPI_gw['ART_NAME']]
'Main': [trackAPI['artist']['name']]
}
self.album.artist = self.artist
self.album.artists = self.artists
self.album.date = self.date
self.album.mainArtist = self.mainArtist
def parseTrackGW(self, trackAPI_gw):
self.title = trackAPI_gw['SNG_TITLE'].strip()
if trackAPI_gw.get('VERSION') and not trackAPI_gw['VERSION'].strip() in self.title:
self.title += f" {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(
art_id = trackAPI_gw['ART_ID'],
name = trackAPI_gw['ART_NAME'],
role = "Main",
pic_md5 = trackAPI_gw.get('ART_PICTURE')
)
if 'PHYSICAL_RELEASE_DATE' in trackAPI_gw:
self.date.day = trackAPI_gw["PHYSICAL_RELEASE_DATE"][8:10]
self.date.month = trackAPI_gw["PHYSICAL_RELEASE_DATE"][5:7]
self.date.year = trackAPI_gw["PHYSICAL_RELEASE_DATE"][0:4]
self.date.fixDayMonth()
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']
if not self.replayGain and 'gain' in trackAPI:
self.replayGain = generateReplayGainString(trackAPI['gain'])
if not self.explicit:
self.explicit = trackAPI['explicit_lyrics']
if not self.discNumber:
self.discNumber = trackAPI['disk_number']
self.lyrics = Lyrics(trackAPI.get('lyrics_id', "0"))
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
isMainArtist = artist['role'] == "Main"
@ -241,6 +217,11 @@ class Track:
self.artist[artist['role']] = []
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):
(self.artist, self.artists) = removeDuplicateArtists(self.artist, self.artists)
@ -259,6 +240,14 @@ class Track:
if 'Featured' in self.artist:
self.featArtistsString = "feat. "+andCommaConcat(self.artist['Featured'])
def checkAndRenewTrackToken(self, dz):
now = datetime.now()
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']
def applySettings(self, settings):
# Check if should save the playlist as a compilation
@ -331,15 +320,3 @@ class Track:
self.artistsString = separator.join(self.artist['Main'])
else:
self.artistsString = separator.join(self.artists)
class TrackError(Exception):
"""Base class for exceptions in this module."""
class AlbumDoesntExists(TrackError):
pass
class MD5NotFound(TrackError):
pass
class NoDataToParse(TrackError):
pass

View File

@ -1,6 +1,8 @@
import string
import re
from deezer import TrackFormats
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"
@ -33,18 +35,35 @@ def changeCase(txt, case_type):
if case_type == "upper":
return txt.upper()
if case_type == "start":
return string.capwords(txt)
txt = txt.strip().split(" ")
for i, word in enumerate(txt):
if word[0] in ['(', '{', '[', "'", '"']:
txt[i] = word[0] + word[1:].capitalize()
else:
txt[i] = word.capitalize()
return " ".join(txt)
if case_type == "sentence":
return txt.capitalize()
return str
def removeFeatures(title):
clean = title
if "(feat." in clean.lower():
pos = clean.lower().find("(feat.")
found = False
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]
if ")" in clean:
tempTrack += clean[clean.find(")", pos + 1) + 1:]
if ")" in clean and openBracket:
tempTrack += clean[clean.find(")", pos+2) + 1:]
if not openBracket and otherBracket != -1:
tempTrack += f" {clean[otherBracket:]}"
clean = tempTrack.strip()
clean = ' '.join(clean.split())
return clean
@ -73,3 +92,59 @@ def removeDuplicateArtists(artist, artists):
for role in artist.keys():
artist[role] = uniqueArray(artist[role])
return (artist, artists)
def formatListener(key, data=None):
if key == "startAddingArtist":
return f"Started gathering {data['name']}'s albums ({data['id']})"
if key == "finishAddingArtist":
return f"Finished gathering {data['name']}'s albums ({data['id']})"
if key == "updateQueue":
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 ""

View File

@ -20,7 +20,7 @@ def generateBlowfishKey(trackId):
bfKey = ""
for i in range(16):
bfKey += chr(ord(idMd5[i]) ^ ord(idMd5[i + 16]) ^ ord(SECRET[i]))
return bfKey
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)

View File

@ -5,28 +5,40 @@ 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]))
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()
return response.get('access_token')
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 getArtFromAccessToken(accessToken):
def getArlFromAccessToken(accessToken):
if not accessToken: return None
arl = None
session = requests.Session()
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()
return response.get('results')
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

View File

@ -44,22 +44,27 @@ def getMusicFolder():
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') as f:
with open(homedata / '.config' / 'user-dirs.dirs', 'r', encoding="utf-8") as f:
userDirs = f.read()
musicdata = re.search(r"XDG_MUSIC_DIR=\"(.*)\"", userDirs).group(1)
musicdata = Path(os.path.expandvars(musicdata))
musicdata = checkPath(musicdata)
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 == "":
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)
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)

View File

@ -2,5 +2,5 @@ click
pycryptodomex
mutagen
requests
spotipy>=2.11.0
deezer-py
spotipy>=2.11.0

View File

@ -7,7 +7,7 @@ README = (HERE / "README.md").read_text()
setup(
name="deemix",
version="3.0.0",
version="3.6.6",
description="A barebone deezer downloader library",
long_description=README,
long_description_content_type="text/markdown",
@ -23,7 +23,10 @@ setup(
python_requires='>=3.7',
packages=find_packages(exclude=("tests",)),
include_package_data=True,
install_requires=["click", "pycryptodomex", "mutagen", "requests", "spotipy>=2.11.0", "deezer-py"],
install_requires=["click", "pycryptodomex", "mutagen", "requests", "deezer-py>=1.3.0"],
extras_require={
"spotify": ["spotipy>=2.11.0"]
},
entry_points={
"console_scripts": [
"deemix=deemix.__main__:download",

View File

@ -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/*