├── .gitattributes ├── .gitignore ├── Dockerfile ├── README.md ├── config.yaml ├── deemix └── config.json ├── local_packages └── deemix │ ├── LICENSE.txt │ ├── README.md │ ├── build │ └── lib │ │ └── deemix │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── decryption.py │ │ ├── downloader.py │ │ ├── errors.py │ │ ├── itemgen.py │ │ ├── plugins │ │ ├── __init__.py │ │ └── spotify.py │ │ ├── settings.py │ │ ├── tagger.py │ │ ├── types │ │ ├── Album.py │ │ ├── Artist.py │ │ ├── Date.py │ │ ├── DownloadObjects.py │ │ ├── Lyrics.py │ │ ├── Picture.py │ │ ├── Playlist.py │ │ ├── Track.py │ │ └── __init__.py │ │ └── utils │ │ ├── __init__.py │ │ ├── crypto.py │ │ ├── deezer.py │ │ ├── localpaths.py │ │ └── pathtemplates.py │ ├── deemix.egg-info │ ├── PKG-INFO │ ├── SOURCES.txt │ ├── dependency_links.txt │ ├── entry_points.txt │ ├── requires.txt │ └── top_level.txt │ ├── deemix │ ├── __init__.py │ ├── __main__.py │ ├── decryption.py │ ├── downloader.py │ ├── errors.py │ ├── itemgen.py │ ├── plugins │ │ ├── __init__.py │ │ └── spotify.py │ ├── settings.py │ ├── tagger.py │ ├── types │ │ ├── Album.py │ │ ├── Artist.py │ │ ├── Date.py │ │ ├── DownloadObjects.py │ │ ├── Lyrics.py │ │ ├── Picture.py │ │ ├── Playlist.py │ │ ├── Track.py │ │ └── __init__.py │ └── utils │ │ ├── __init__.py │ │ ├── crypto.py │ │ ├── deezer.py │ │ ├── localpaths.py │ │ └── pathtemplates.py │ ├── icon.svg │ ├── requirements.txt │ └── setup.py ├── main.py └── requirements.txt /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | .venv/* 3 | config.yaml 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM --platform=linux/amd64 python:3.9-slim 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Copy the current directory contents into the container at /app 8 | COPY . /app 9 | 10 | # Install any needed packages specified in requirements.txt 11 | RUN pip install --no-cache-dir -r requirements.txt 12 | 13 | # Use modified deemix package with fix 14 | COPY local_packages/deemix /app/local_packages/deemix 15 | RUN pip install /app/local_packages/deemix 16 | 17 | # Run script.py when the container launches 18 | CMD ["python", "./main.py"] 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | deezync_350 4 |

5 |

Deezync

6 | 7 |
8 | 9 | [![Static Badge](https://img.shields.io/badge/Join-Discord-blue?color=%237289da)](https://discord.gg/4cJczdyu9n) 10 | [![Docker Pulls](https://img.shields.io/docker/pulls/m8tec/deezync)](https://hub.docker.com/repository/docker/m8tec/deezync) 11 | 12 | Deezync syncs Deezer playlists to Plex and uses Deemix to download missing tracks. 13 | 14 |
15 | 16 | ## Installation 17 | 1. Bind container path `/music` to the location of your Plex music library 18 | 2. Bind container path `/config` to where you want to store the configs 19 | 20 | ## Setup 21 | Deezync will create the needed config files at `/config` on its first run. 22 | 1. Paste your Plex credentials in. 23 | - Token: [Where is my Plex token?](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) 24 | - Server: The name of your Plex server 25 | - Library: The name of the Plex music library you want to sync 26 | 2. Add and configure monitored playlists 27 | - Id: Find the Deezer playlist id by opening it in a browser and inspect the end of the link 28 | - Bitrate: FLAC = 9, MP3_320 = 3, MP3_128 = 1 29 | - Delete unmatched from playlist: Delete tracks from Plex playlist if not matched with Deezer track 30 | - Active: Disable playlist for syncing. Active = 1, inactive = 0 31 | - Sync interval seconds: The interval in which Deezync should check for playlist changes 32 | - Make sure that your monitored playlists are not private! 33 | 5. Paste in your Deezer arl at `/config/Deemix/.arl`. [Where is my Deezer arl?](https://github.com/nathom/streamrip/wiki/Finding-Your-Deezer-ARL-Cookie) 34 | 6. Configure Plex to automatically detect new files. Tick the following settings: 35 | - Plex > Settings > Library > "Scan my library automatically" 36 | - Plex > Settings > Library > "Run a partial scan when changes are detected" 37 | - Plex > Settings > Library > "Include music libraries in automatic updates" 38 | 7. Configure Plex to prefer local metadata, so Deezync can find the downloaded tracks: 39 | - Plex > Your music library > Manage Library > Edit > Advanced > Agent > Personal Media Artists 40 | - Plex > Your music library > Manage Library > Edit > Advanced > Prefer local metadata 41 | 42 | ### Add to an existing library 43 | By default Deemix will use the suggested file naming scheme and skip the download of missing tracks if their download path already exists. In order to prevent file duplicates, make sure to properly setup your naming conventions in the `/config/deemix/config.json` config. 44 | 45 | ## Contributing 46 | I'll be happy to review pull requests 47 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | plex_token: # token of your Plex server 2 | plex_server: # name of your Plex server 3 | plex_library: # name of your Plex music library 4 | deezer_playlists: 5 | - id: # your deezer playlist id 6 | bitrate: 9 # FLAC = 9, MP3_320 = 3, MP3_128 = 1 7 | delete_unmatched_from_playlist: 0 # delete unmatched tracks from your Plex playlist 8 | active: 1 # enable/disable this playlist for sync 9 | sync_interval_seconds: 3600 # sync this playlist every x seconds 10 | sync_cover_description: 1 # sync cover + description from Deezer to Plex 11 | -------------------------------------------------------------------------------- /deemix/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "downloadLocation": "/music", 3 | "tracknameTemplate": "%artist% - %title%", 4 | "albumTracknameTemplate": "%tracknumber% - %title%", 5 | "playlistTracknameTemplate": "%position% - %artist% - %title%", 6 | "createPlaylistFolder": true, 7 | "playlistNameTemplate": "%playlist%", 8 | "createArtistFolder": true, 9 | "artistNameTemplate": "%artist%", 10 | "createAlbumFolder": true, 11 | "albumNameTemplate": "%artist% - %album%", 12 | "createCDFolder": true, 13 | "createStructurePlaylist": false, 14 | "createSingleFolder": true, 15 | "padTracks": true, 16 | "paddingSize": "0", 17 | "illegalCharacterReplacer": "_", 18 | "queueConcurrency": 3, 19 | "maxBitrate": "9", 20 | "feelingLucky": false, 21 | "fallbackBitrate": true, 22 | "fallbackSearch": false, 23 | "fallbackISRC": false, 24 | "logErrors": true, 25 | "logSearched": false, 26 | "overwriteFile": "n", 27 | "createM3U8File": false, 28 | "playlistFilenameTemplate": "playlist", 29 | "syncedLyrics": true, 30 | "embeddedArtworkSize": 800, 31 | "embeddedArtworkPNG": true, 32 | "localArtworkSize": 1400, 33 | "localArtworkFormat": "jpg", 34 | "saveArtwork": true, 35 | "coverImageTemplate": "cover", 36 | "saveArtworkArtist": true, 37 | "artistImageTemplate": "folder", 38 | "jpegImageQuality": 100, 39 | "dateFormat": "D-M-Y", 40 | "albumVariousArtists": true, 41 | "removeAlbumVersion": false, 42 | "removeDuplicateArtists": true, 43 | "featuredToTitle": "0", 44 | "titleCasing": "nothing", 45 | "artistCasing": "nothing", 46 | "executeCommand": "", 47 | "tags": { 48 | "title": true, 49 | "artist": true, 50 | "artists": true, 51 | "album": true, 52 | "cover": true, 53 | "trackNumber": true, 54 | "trackTotal": true, 55 | "discNumber": true, 56 | "discTotal": true, 57 | "albumArtist": true, 58 | "genre": true, 59 | "year": true, 60 | "date": true, 61 | "explicit": true, 62 | "isrc": true, 63 | "length": true, 64 | "barcode": true, 65 | "bpm": true, 66 | "replayGain": false, 67 | "label": true, 68 | "lyrics": true, 69 | "syncedLyrics": true, 70 | "copyright": true, 71 | "composer": true, 72 | "involvedPeople": true, 73 | "source": true, 74 | "rating": true, 75 | "savePlaylistAsCompilation": false, 76 | "useNullSeparator": false, 77 | "saveID3v1": true, 78 | "multiArtistSeparator": "default", 79 | "singleAlbumArtist": false, 80 | "coverDescriptionUTF8": false 81 | } 82 | } -------------------------------------------------------------------------------- /local_packages/deemix/README.md: -------------------------------------------------------------------------------- 1 | # ![](./icon.svg) deemix 2 | ## What is deemix? 3 | deemix is a deezer downloader built from the ashes of Deezloader Remix. The base library (or core) can be used as a stand alone CLI app or implemented in an UI using the API. 4 | 5 | ## Installation 6 | NOTE: If `python3` is "not a recognized command" try using `python` instead.
7 |
8 | ### From PyPi 9 | You can install the library by using `pip`:
10 | `python3 -m pip install deemix`
11 | If you install it this way you can use the deemix CLI by using `deemix` directly in your terminal instead of `python3 -m deemix` 12 | 13 | ### Building from source 14 | After installing Python open a terminal/command prompt and install the dependencies using `python3 -m pip install -r requirements.txt --user`
15 | Run `python3 -m deemix --help` to see how to use the app in CLI mode.
16 | 17 | ## What's left to do? 18 | - Write the API Documentation 19 | - Fix whatever is broken 20 | 21 | # License 22 | This program is free software: you can redistribute it and/or modify 23 | it under the terms of the GNU General Public License as published by 24 | the Free Software Foundation, either version 3 of the License, or 25 | (at your option) any later version. 26 | 27 | This program is distributed in the hope that it will be useful, 28 | but WITHOUT ANY WARRANTY; without even the implied warranty of 29 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 30 | GNU General Public License for more details. 31 | 32 | You should have received a copy of the GNU General Public License 33 | along with this program. If not, see . 34 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | from urllib.request import urlopen 4 | 5 | from deemix.itemgen import generateTrackItem, \ 6 | generateAlbumItem, \ 7 | generatePlaylistItem, \ 8 | generateArtistItem, \ 9 | generateArtistDiscographyItem, \ 10 | generateArtistTopItem 11 | from deemix.errors import LinkNotRecognized, LinkNotSupported 12 | 13 | __version__ = "3.6.6" 14 | 15 | # Returns the Resolved URL, the Type and the ID 16 | def parseLink(link): 17 | if 'deezer.page.link' in link: link = urlopen(link).url # Resolve URL shortner 18 | # Remove extra stuff 19 | if '?' in link: link = link[:link.find('?')] 20 | if '&' in link: link = link[:link.find('&')] 21 | if link.endswith('/'): link = link[:-1] # Remove last slash if present 22 | 23 | link_type = None 24 | link_id = None 25 | 26 | if not 'deezer' in link: return (link, link_type, link_id) # return if not a deezer link 27 | 28 | if '/track' in link: 29 | link_type = 'track' 30 | link_id = re.search(r"/track/(.+)", link).group(1) 31 | elif '/playlist' in link: 32 | link_type = 'playlist' 33 | link_id = re.search(r"/playlist/(\d+)", link).group(1) 34 | elif '/album' in link: 35 | link_type = 'album' 36 | link_id = re.search(r"/album/(.+)", link).group(1) 37 | elif re.search(r"/artist/(\d+)/top_track", link): 38 | link_type = 'artist_top' 39 | link_id = re.search(r"/artist/(\d+)/top_track", link).group(1) 40 | elif re.search(r"/artist/(\d+)/discography", link): 41 | link_type = 'artist_discography' 42 | link_id = re.search(r"/artist/(\d+)/discography", link).group(1) 43 | elif '/artist' in link: 44 | link_type = 'artist' 45 | link_id = re.search(r"/artist/(\d+)", link).group(1) 46 | 47 | return (link, link_type, link_id) 48 | 49 | def generateDownloadObject(dz, link, bitrate, plugins=None, listener=None): 50 | (link, link_type, link_id) = parseLink(link) 51 | 52 | if link_type is None or link_id is None: 53 | if plugins is None: plugins = {} 54 | plugin_names = plugins.keys() 55 | current_plugin = None 56 | item = None 57 | for plugin in plugin_names: 58 | current_plugin = plugins[plugin] 59 | item = current_plugin.generateDownloadObject(dz, link, bitrate, listener) 60 | if item: return item 61 | raise LinkNotRecognized(link) 62 | 63 | if link_type == "track": 64 | return generateTrackItem(dz, link_id, bitrate) 65 | if link_type == "album": 66 | return generateAlbumItem(dz, link_id, bitrate) 67 | if link_type == "playlist": 68 | return generatePlaylistItem(dz, link_id, bitrate) 69 | if link_type == "artist": 70 | return generateArtistItem(dz, link_id, bitrate, listener) 71 | if link_type == "artist_discography": 72 | return generateArtistDiscographyItem(dz, link_id, bitrate, listener) 73 | if link_type == "artist_top": 74 | return generateArtistTopItem(dz, link_id, bitrate) 75 | 76 | raise LinkNotSupported(link) 77 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import click 3 | from pathlib import Path 4 | 5 | from deezer import Deezer 6 | from deezer import TrackFormats 7 | 8 | from deemix import generateDownloadObject 9 | from deemix.settings import load as loadSettings 10 | from deemix.utils import getBitrateNumberFromText, formatListener 11 | import deemix.utils.localpaths as localpaths 12 | from deemix.downloader import Downloader 13 | from deemix.itemgen import GenerationError 14 | try: 15 | from deemix.plugins.spotify import Spotify 16 | except ImportError: 17 | Spotify = None 18 | 19 | class LogListener: 20 | @classmethod 21 | def send(cls, key, value=None): 22 | logString = formatListener(key, value) 23 | if logString: print(logString) 24 | 25 | 26 | @click.command() 27 | @click.option('--portable', is_flag=True, help='Creates the config folder in the same directory where the script is launched') 28 | @click.option('-b', '--bitrate', default=None, help='Overwrites the default bitrate selected') 29 | @click.option('-p', '--path', type=str, help='Downloads in the given folder') 30 | @click.argument('url', nargs=-1, required=True) 31 | def download(url, bitrate, portable, path): 32 | # Check for local configFolder 33 | localpath = Path('.') 34 | configFolder = localpath / 'config' if portable else localpaths.getConfigFolder() 35 | 36 | settings = loadSettings(configFolder) 37 | dz = Deezer() 38 | listener = LogListener() 39 | 40 | def requestValidArl(): 41 | while True: 42 | arl = input("Paste here your arl:") 43 | if dz.login_via_arl(arl.strip()): break 44 | return arl 45 | 46 | if (configFolder / '.arl').is_file(): 47 | with open(configFolder / '.arl', 'r', encoding="utf-8") as f: 48 | arl = f.readline().rstrip("\n").strip() 49 | if not dz.login_via_arl(arl): arl = requestValidArl() 50 | else: arl = requestValidArl() 51 | with open(configFolder / '.arl', 'w', encoding="utf-8") as f: 52 | f.write(arl) 53 | 54 | plugins = {} 55 | if Spotify: 56 | plugins = { 57 | "spotify": Spotify(configFolder=configFolder) 58 | } 59 | plugins["spotify"].setup() 60 | 61 | def downloadLinks(url, bitrate=None): 62 | if not bitrate: bitrate = settings.get("maxBitrate", TrackFormats.MP3_320) 63 | links = [] 64 | for link in url: 65 | if ';' in link: 66 | for l in link.split(";"): 67 | links.append(l) 68 | else: 69 | links.append(link) 70 | 71 | downloadObjects = [] 72 | 73 | for link in links: 74 | try: 75 | downloadObject = generateDownloadObject(dz, link, bitrate, plugins, listener) 76 | except GenerationError as e: 77 | print(f"{e.link}: {e.message}") 78 | continue 79 | if isinstance(downloadObject, list): 80 | downloadObjects += downloadObject 81 | else: 82 | downloadObjects.append(downloadObject) 83 | 84 | for obj in downloadObjects: 85 | if obj.__type__ == "Convertable": 86 | obj = plugins[obj.plugin].convert(dz, obj, settings, listener) 87 | Downloader(dz, obj, settings, listener).start() 88 | 89 | 90 | if path is not None: 91 | if path == '': path = '.' 92 | path = Path(path) 93 | settings['downloadLocation'] = str(path) 94 | url = list(url) 95 | if bitrate: bitrate = getBitrateNumberFromText(bitrate) 96 | 97 | # If first url is filepath readfile and use them as URLs 98 | try: 99 | isfile = Path(url[0]).is_file() 100 | except Exception: 101 | isfile = False 102 | if isfile: 103 | filename = url[0] 104 | with open(filename, encoding="utf-8") as f: 105 | url = f.readlines() 106 | 107 | downloadLinks(url, bitrate) 108 | click.echo("All done!") 109 | 110 | if __name__ == '__main__': 111 | download() # pylint: disable=E1120 112 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/decryption.py: -------------------------------------------------------------------------------- 1 | from ssl import SSLError 2 | from time import sleep 3 | import logging 4 | 5 | from requests import get 6 | from requests.exceptions import ConnectionError as RequestsConnectionError, ReadTimeout, ChunkedEncodingError 7 | from urllib3.exceptions import SSLError as u3SSLError 8 | 9 | from deemix.utils.crypto import _md5, _ecbCrypt, _ecbDecrypt, generateBlowfishKey, decryptChunk 10 | 11 | from deemix.utils import USER_AGENT_HEADER 12 | from deemix.types.DownloadObjects import Single 13 | from deemix.errors import DownloadCanceled, DownloadEmpty 14 | 15 | logger = logging.getLogger('deemix') 16 | 17 | def generateStreamPath(sng_id, md5, media_version, media_format): 18 | urlPart = b'\xa4'.join( 19 | [md5.encode(), str(media_format).encode(), str(sng_id).encode(), str(media_version).encode()]) 20 | md5val = _md5(urlPart) 21 | step2 = md5val.encode() + b'\xa4' + urlPart + b'\xa4' 22 | step2 = step2 + (b'.' * (16 - (len(step2) % 16))) 23 | urlPart = _ecbCrypt('jo6aey6haid2Teih', step2) 24 | return urlPart.decode("utf-8") 25 | 26 | def reverseStreamPath(urlPart): 27 | step2 = _ecbDecrypt('jo6aey6haid2Teih', urlPart) 28 | (_, md5, media_format, sng_id, media_version, _) = step2.split(b'\xa4') 29 | return (sng_id.decode('utf-8'), md5.decode('utf-8'), media_version.decode('utf-8'), media_format.decode('utf-8')) 30 | 31 | def generateCryptedStreamURL(sng_id, md5, media_version, media_format): 32 | urlPart = generateStreamPath(sng_id, md5, media_version, media_format) 33 | return "https://e-cdns-proxy-" + md5[0] + ".dzcdn.net/mobile/1/" + urlPart 34 | 35 | def generateStreamURL(sng_id, md5, media_version, media_format): 36 | urlPart = generateStreamPath(sng_id, md5, media_version, media_format) 37 | return "https://e-cdns-proxy-" + md5[0] + ".dzcdn.net/api/1/" + urlPart 38 | 39 | def reverseStreamURL(url): 40 | urlPart = url[url.find("/1/")+3:] 41 | return reverseStreamPath(urlPart) 42 | 43 | def streamTrack(outputStream, track, start=0, downloadObject=None, listener=None): 44 | if downloadObject and downloadObject.isCanceled: raise DownloadCanceled 45 | headers= {'User-Agent': USER_AGENT_HEADER} 46 | chunkLength = start 47 | isCryptedStream = "/mobile/" in track.downloadURL or "/media/" in track.downloadURL 48 | 49 | itemData = { 50 | 'id': track.id, 51 | 'title': track.title, 52 | 'artist': track.mainArtist.name 53 | } 54 | 55 | try: 56 | with get(track.downloadURL, headers=headers, stream=True, timeout=10) as request: 57 | request.raise_for_status() 58 | if isCryptedStream: 59 | blowfish_key = generateBlowfishKey(str(track.id)) 60 | 61 | complete = int(request.headers["Content-Length"]) 62 | if complete == 0: raise DownloadEmpty 63 | if start != 0: 64 | responseRange = request.headers["Content-Range"] 65 | if listener: 66 | listener.send('downloadInfo', { 67 | 'uuid': downloadObject.uuid, 68 | 'data': itemData, 69 | 'state': "downloading", 70 | 'alreadyStarted': True, 71 | 'value': responseRange 72 | }) 73 | else: 74 | if listener: 75 | listener.send('downloadInfo', { 76 | 'uuid': downloadObject.uuid, 77 | 'data': itemData, 78 | 'state': "downloading", 79 | 'alreadyStarted': False, 80 | 'value': complete 81 | }) 82 | 83 | isStart = True 84 | for chunk in request.iter_content(2048 * 3): 85 | if isCryptedStream: 86 | if len(chunk) >= 2048: 87 | chunk = decryptChunk(blowfish_key, chunk[0:2048]) + chunk[2048:] 88 | 89 | if isStart and chunk[0] == 0 and chunk[4:8].decode('utf-8') != "ftyp": 90 | for i, byte in enumerate(chunk): 91 | if byte != 0: break 92 | chunk = chunk[i:] 93 | isStart = False 94 | 95 | outputStream.write(chunk) 96 | chunkLength += len(chunk) 97 | 98 | if downloadObject: 99 | if isinstance(downloadObject, Single): 100 | chunkProgres = (chunkLength / (complete + start)) * 100 101 | downloadObject.progressNext = chunkProgres 102 | else: 103 | chunkProgres = (len(chunk) / (complete + start)) / downloadObject.size * 100 104 | downloadObject.progressNext += chunkProgres 105 | downloadObject.updateProgress(listener) 106 | 107 | except (SSLError, u3SSLError): 108 | streamTrack(outputStream, track, chunkLength, downloadObject, listener) 109 | except (RequestsConnectionError, ReadTimeout, ChunkedEncodingError): 110 | sleep(2) 111 | streamTrack(outputStream, track, start, downloadObject, listener) 112 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/errors.py: -------------------------------------------------------------------------------- 1 | class DeemixError(Exception): 2 | """Base exception for this module""" 3 | 4 | class GenerationError(DeemixError): 5 | """Generation related errors""" 6 | def __init__(self, link, message, errid=None): 7 | super().__init__() 8 | self.link = link 9 | self.message = message 10 | self.errid = errid 11 | 12 | def toDict(self): 13 | return { 14 | 'link': self.link, 15 | 'error': self.message, 16 | 'errid': self.errid 17 | } 18 | 19 | class ISRCnotOnDeezer(GenerationError): 20 | def __init__(self, link): 21 | super().__init__(link, "Track ISRC is not available on deezer", "ISRCnotOnDeezer") 22 | 23 | class NotYourPrivatePlaylist(GenerationError): 24 | def __init__(self, link): 25 | super().__init__(link, "You can't download others private playlists.", "notYourPrivatePlaylist") 26 | 27 | class TrackNotOnDeezer(GenerationError): 28 | def __init__(self, link): 29 | super().__init__(link, "Track not found on deezer!", "trackNotOnDeezer") 30 | 31 | class AlbumNotOnDeezer(GenerationError): 32 | def __init__(self, link): 33 | super().__init__(link, "Album not found on deezer!", "albumNotOnDeezer") 34 | 35 | class InvalidID(GenerationError): 36 | def __init__(self, link): 37 | super().__init__(link, "Link ID is invalid!", "invalidID") 38 | 39 | class LinkNotSupported(GenerationError): 40 | def __init__(self, link): 41 | super().__init__(link, "Link is not supported.", "unsupportedURL") 42 | 43 | class LinkNotRecognized(GenerationError): 44 | def __init__(self, link): 45 | super().__init__(link, "Link is not recognized.", "invalidURL") 46 | 47 | class DownloadError(DeemixError): 48 | """Download related errors""" 49 | 50 | ErrorMessages = { 51 | 'notOnDeezer': "Track not available on Deezer!", 52 | 'notEncoded': "Track not yet encoded!", 53 | 'notEncodedNoAlternative': "Track not yet encoded and no alternative found!", 54 | 'wrongBitrate': "Track not found at desired bitrate.", 55 | 'wrongBitrateNoAlternative': "Track not found at desired bitrate and no alternative found!", 56 | 'wrongLicense': "Your account can't stream the track at the desired bitrate.", 57 | 'no360RA': "Track is not available in Reality Audio 360.", 58 | 'notAvailable': "Track not available on deezer's servers!", 59 | 'notAvailableNoAlternative': "Track not available on deezer's servers and no alternative found!", 60 | 'noSpaceLeft': "No space left on target drive, clean up some space for the tracks", 61 | 'albumDoesntExists': "Track's album does not exsist, failed to gather info.", 62 | 'notLoggedIn': "You need to login to download tracks.", 63 | 'wrongGeolocation': "Your account can't stream the track from your current country.", 64 | 'wrongGeolocationNoAlternative': "Your account can't stream the track from your current country and no alternative found." 65 | } 66 | 67 | class DownloadFailed(DownloadError): 68 | def __init__(self, errid, track=None): 69 | super().__init__() 70 | self.errid = errid 71 | self.message = ErrorMessages[self.errid] 72 | self.track = track 73 | 74 | class PreferredBitrateNotFound(DownloadError): 75 | pass 76 | 77 | class TrackNot360(DownloadError): 78 | pass 79 | 80 | class DownloadCanceled(DownloadError): 81 | pass 82 | 83 | class DownloadEmpty(DownloadError): 84 | pass 85 | 86 | class TrackError(DeemixError): 87 | """Track generation related errors""" 88 | 89 | class AlbumDoesntExists(TrackError): 90 | pass 91 | 92 | class MD5NotFound(TrackError): 93 | pass 94 | 95 | class NoDataToParse(TrackError): 96 | pass 97 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/itemgen.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from deezer.errors import GWAPIError, APIError 4 | from deezer.utils import map_user_playlist, map_track, map_album 5 | 6 | from deemix.types.DownloadObjects import Single, Collection 7 | from deemix.errors import GenerationError, ISRCnotOnDeezer, InvalidID, NotYourPrivatePlaylist 8 | 9 | logger = logging.getLogger('deemix') 10 | 11 | def generateTrackItem(dz, link_id, bitrate, trackAPI=None, albumAPI=None): 12 | # Get essential track info 13 | if not trackAPI: 14 | if str(link_id).startswith("isrc") or int(link_id) > 0: 15 | try: 16 | trackAPI = dz.api.get_track(link_id) 17 | except APIError as e: 18 | raise GenerationError(f"https://deezer.com/track/{link_id}", str(e)) from e 19 | 20 | # Check if is an isrc: url 21 | if str(link_id).startswith("isrc"): 22 | if 'id' in trackAPI and 'title' in trackAPI: 23 | link_id = trackAPI['id'] 24 | else: 25 | raise ISRCnotOnDeezer(f"https://deezer.com/track/{link_id}") 26 | else: 27 | trackAPI_gw = dz.gw.get_track(link_id) 28 | trackAPI = map_track(trackAPI_gw) 29 | else: 30 | link_id = trackAPI['id'] 31 | if not str(link_id).strip('-').isdecimal(): raise InvalidID(f"https://deezer.com/track/{link_id}") 32 | 33 | cover = None 34 | if trackAPI['album']['cover_small']: 35 | cover = trackAPI['album']['cover_small'][:-24] + '/75x75-000000-80-0-0.jpg' 36 | else: 37 | cover = f"https://e-cdns-images.dzcdn.net/images/cover/{trackAPI['md5_image']}/75x75-000000-80-0-0.jpg" 38 | 39 | if 'track_token' in trackAPI: del trackAPI['track_token'] 40 | 41 | return Single({ 42 | 'type': 'track', 43 | 'id': link_id, 44 | 'bitrate': bitrate, 45 | 'title': trackAPI['title'], 46 | 'artist': trackAPI['artist']['name'], 47 | 'cover': cover, 48 | 'explicit': trackAPI['explicit_lyrics'], 49 | 'single': { 50 | 'trackAPI': trackAPI, 51 | 'albumAPI': albumAPI 52 | } 53 | }) 54 | 55 | def generateAlbumItem(dz, link_id, bitrate, rootArtist=None): 56 | # Get essential album info 57 | if str(link_id).startswith('upc'): 58 | upcs = [link_id[4:],] 59 | upcs.append(int(upcs[0])) 60 | lastError = None 61 | for upc in upcs: 62 | try: 63 | albumAPI = dz.api.get_album(f"upc:{upc}") 64 | except APIError as e: 65 | lastError = e 66 | albumAPI = None 67 | if not albumAPI: 68 | raise GenerationError(f"https://deezer.com/album/{link_id}", str(lastError)) from lastError 69 | link_id = albumAPI['id'] 70 | else: 71 | try: 72 | albumAPI_gw_page = dz.gw.get_album_page(link_id) 73 | if 'DATA' in albumAPI_gw_page: 74 | albumAPI = map_album(albumAPI_gw_page['DATA']) 75 | link_id = albumAPI_gw_page['DATA']['ALB_ID'] 76 | albumAPI_new = dz.api.get_album(link_id) 77 | albumAPI.update(albumAPI_new) 78 | else: 79 | raise GenerationError(f"https://deezer.com/album/{link_id}", "Can't find the album") 80 | except APIError as e: 81 | raise GenerationError(f"https://deezer.com/album/{link_id}", str(e)) from e 82 | 83 | if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/album/{link_id}") 84 | 85 | # Get extra info about album 86 | # This saves extra api calls when downloading 87 | albumAPI_gw = dz.gw.get_album(link_id) 88 | albumAPI_gw = map_album(albumAPI_gw) 89 | albumAPI_gw.update(albumAPI) 90 | albumAPI = albumAPI_gw 91 | albumAPI['root_artist'] = rootArtist 92 | 93 | # If the album is a single download as a track 94 | if albumAPI['nb_tracks'] == 1: 95 | if len(albumAPI['tracks']['data']): 96 | return generateTrackItem(dz, albumAPI['tracks']['data'][0]['id'], bitrate, albumAPI=albumAPI) 97 | raise GenerationError(f"https://deezer.com/album/{link_id}", "Single has no tracks.") 98 | 99 | tracksArray = dz.gw.get_album_tracks(link_id) 100 | 101 | if albumAPI['cover_small'] is not None: 102 | cover = albumAPI['cover_small'][:-24] + '/75x75-000000-80-0-0.jpg' 103 | else: 104 | cover = f"https://e-cdns-images.dzcdn.net/images/cover/{albumAPI['md5_image']}/75x75-000000-80-0-0.jpg" 105 | 106 | totalSize = len(tracksArray) 107 | albumAPI['nb_tracks'] = totalSize 108 | collection = [] 109 | for pos, trackAPI in enumerate(tracksArray, start=1): 110 | trackAPI = map_track(trackAPI) 111 | if 'track_token' in trackAPI: del trackAPI['track_token'] 112 | trackAPI['position'] = pos 113 | collection.append(trackAPI) 114 | 115 | return Collection({ 116 | 'type': 'album', 117 | 'id': link_id, 118 | 'bitrate': bitrate, 119 | 'title': albumAPI['title'], 120 | 'artist': albumAPI['artist']['name'], 121 | 'cover': cover, 122 | 'explicit': albumAPI['explicit_lyrics'], 123 | 'size': totalSize, 124 | 'collection': { 125 | 'tracks': collection, 126 | 'albumAPI': albumAPI 127 | } 128 | }) 129 | 130 | def generatePlaylistItem(dz, link_id, bitrate, playlistAPI=None, playlistTracksAPI=None): 131 | if not playlistAPI: 132 | if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/playlist/{link_id}") 133 | # Get essential playlist info 134 | try: 135 | playlistAPI = dz.api.get_playlist(link_id) 136 | except APIError: 137 | playlistAPI = None 138 | # Fallback to gw api if the playlist is private 139 | if not playlistAPI: 140 | try: 141 | userPlaylist = dz.gw.get_playlist_page(link_id) 142 | playlistAPI = map_user_playlist(userPlaylist['DATA']) 143 | except GWAPIError as e: 144 | raise GenerationError(f"https://deezer.com/playlist/{link_id}", str(e)) from e 145 | 146 | # Check if private playlist and owner 147 | if not playlistAPI.get('public', False) and playlistAPI['creator']['id'] != str(dz.current_user['id']): 148 | logger.warning("You can't download others private playlists.") 149 | raise NotYourPrivatePlaylist(f"https://deezer.com/playlist/{link_id}") 150 | 151 | if not playlistTracksAPI: 152 | playlistTracksAPI = dz.gw.get_playlist_tracks(link_id) 153 | playlistAPI['various_artist'] = dz.api.get_artist(5080) # Useful for save as compilation 154 | 155 | totalSize = len(playlistTracksAPI) 156 | playlistAPI['nb_tracks'] = totalSize 157 | collection = [] 158 | for pos, trackAPI in enumerate(playlistTracksAPI, start=1): 159 | trackAPI = map_track(trackAPI) 160 | if trackAPI['explicit_lyrics']: 161 | playlistAPI['explicit'] = True 162 | if 'track_token' in trackAPI: del trackAPI['track_token'] 163 | trackAPI['position'] = pos 164 | collection.append(trackAPI) 165 | 166 | if 'explicit' not in playlistAPI: playlistAPI['explicit'] = False 167 | 168 | return Collection({ 169 | 'type': 'playlist', 170 | 'id': link_id, 171 | 'bitrate': bitrate, 172 | 'title': playlistAPI['title'], 173 | 'artist': playlistAPI['creator']['name'], 174 | 'cover': playlistAPI['picture_small'][:-24] + '/75x75-000000-80-0-0.jpg', 175 | 'explicit': playlistAPI['explicit'], 176 | 'size': totalSize, 177 | 'collection': { 178 | 'tracks': collection, 179 | 'playlistAPI': playlistAPI 180 | } 181 | }) 182 | 183 | def generateArtistItem(dz, link_id, bitrate, listener=None): 184 | if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/artist/{link_id}") 185 | # Get essential artist info 186 | try: 187 | artistAPI = dz.api.get_artist(link_id) 188 | except APIError as e: 189 | raise GenerationError(f"https://deezer.com/artist/{link_id}", str(e)) from e 190 | 191 | rootArtist = { 192 | 'id': artistAPI['id'], 193 | 'name': artistAPI['name'], 194 | 'picture_small': artistAPI['picture_small'] 195 | } 196 | if listener: listener.send("startAddingArtist", rootArtist) 197 | 198 | artistDiscographyAPI = dz.gw.get_artist_discography_tabs(link_id, 100) 199 | allReleases = artistDiscographyAPI.pop('all', []) 200 | albumList = [] 201 | for album in allReleases: 202 | try: 203 | albumList.append(generateAlbumItem(dz, album['id'], bitrate, rootArtist=rootArtist)) 204 | except GenerationError as e: 205 | logger.warning("Album %s has no data: %s", str(album['id']), str(e)) 206 | 207 | if listener: listener.send("finishAddingArtist", rootArtist) 208 | return albumList 209 | 210 | def generateArtistDiscographyItem(dz, link_id, bitrate, listener=None): 211 | if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/artist/{link_id}/discography") 212 | # Get essential artist info 213 | try: 214 | artistAPI = dz.api.get_artist(link_id) 215 | except APIError as e: 216 | raise GenerationError(f"https://deezer.com/artist/{link_id}/discography", str(e)) from e 217 | 218 | rootArtist = { 219 | 'id': artistAPI['id'], 220 | 'name': artistAPI['name'], 221 | 'picture_small': artistAPI['picture_small'] 222 | } 223 | if listener: listener.send("startAddingArtist", rootArtist) 224 | 225 | artistDiscographyAPI = dz.gw.get_artist_discography_tabs(link_id, 100) 226 | artistDiscographyAPI.pop('all', None) # all contains albums and singles, so its all duplicates. This removes them 227 | albumList = [] 228 | for releaseType in artistDiscographyAPI: 229 | for album in artistDiscographyAPI[releaseType]: 230 | try: 231 | albumList.append(generateAlbumItem(dz, album['id'], bitrate, rootArtist=rootArtist)) 232 | except GenerationError as e: 233 | logger.warning("Album %s has no data: %s", str(album['id']), str(e)) 234 | 235 | if listener: listener.send("finishAddingArtist", rootArtist) 236 | return albumList 237 | 238 | def generateArtistTopItem(dz, link_id, bitrate): 239 | if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/artist/{link_id}/top_track") 240 | # Get essential artist info 241 | try: 242 | artistAPI = dz.api.get_artist(link_id) 243 | except APIError as e: 244 | raise GenerationError(f"https://deezer.com/artist/{link_id}/top_track", str(e)) from e 245 | 246 | # Emulate the creation of a playlist 247 | # Can't use generatePlaylistItem directly as this is not a real playlist 248 | playlistAPI = { 249 | 'id':f"{artistAPI['id']}_top_track", 250 | 'title': f"{artistAPI['name']} - Top Tracks", 251 | 'description': f"Top Tracks for {artistAPI['name']}", 252 | 'duration': 0, 253 | 'public': True, 254 | 'is_loved_track': False, 255 | 'collaborative': False, 256 | 'nb_tracks': 0, 257 | 'fans': artistAPI['nb_fan'], 258 | 'link': f"https://www.deezer.com/artist/{artistAPI['id']}/top_track", 259 | 'share': None, 260 | 'picture': artistAPI['picture'], 261 | 'picture_small': artistAPI['picture_small'], 262 | 'picture_medium': artistAPI['picture_medium'], 263 | 'picture_big': artistAPI['picture_big'], 264 | 'picture_xl': artistAPI['picture_xl'], 265 | 'checksum': None, 266 | 'tracklist': f"https://api.deezer.com/artist/{artistAPI['id']}/top", 267 | 'creation_date': "XXXX-00-00", 268 | 'creator': { 269 | 'id': f"art_{artistAPI['id']}", 270 | 'name': artistAPI['name'], 271 | 'type': "user" 272 | }, 273 | 'type': "playlist" 274 | } 275 | 276 | artistTopTracksAPI_gw = dz.gw.get_artist_toptracks(link_id) 277 | return generatePlaylistItem(dz, playlistAPI['id'], bitrate, playlistAPI=playlistAPI, playlistTracksAPI=artistTopTracksAPI_gw) 278 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | class Plugin: 2 | def __init__(self): 3 | pass 4 | 5 | def setup(self): 6 | pass 7 | 8 | def parseLink(self, link): 9 | pass 10 | 11 | def generateDownloadObject(self, dz, link, bitrate, listener): 12 | pass 13 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/settings.py: -------------------------------------------------------------------------------- 1 | import json 2 | from copy import deepcopy 3 | from pathlib import Path 4 | from os import makedirs 5 | from deezer import TrackFormats 6 | import deemix.utils.localpaths as localpaths 7 | 8 | class OverwriteOption(): 9 | """Should the lib overwrite files?""" 10 | OVERWRITE = 'y' # Yes, overwrite the file 11 | DONT_OVERWRITE = 'n' # No, don't overwrite the file 12 | DONT_CHECK_EXT = 'e' # No, and don't check for extensions 13 | KEEP_BOTH = 'b' # No, and keep both files 14 | ONLY_TAGS = 't' # Overwrite only the tags 15 | 16 | class FeaturesOption(): 17 | """What should I do with featured artists?""" 18 | NO_CHANGE = "0" # Do nothing 19 | REMOVE_TITLE = "1" # Remove from track title 20 | REMOVE_TITLE_ALBUM = "3" # Remove from track title and album title 21 | MOVE_TITLE = "2" # Move to track title 22 | 23 | DEFAULTS = { 24 | "downloadLocation": str(localpaths.getMusicFolder()), 25 | "tracknameTemplate": "%artist% - %title%", 26 | "albumTracknameTemplate": "%tracknumber% - %title%", 27 | "playlistTracknameTemplate": "%position% - %artist% - %title%", 28 | "createPlaylistFolder": True, 29 | "playlistNameTemplate": "%playlist%", 30 | "createArtistFolder": False, 31 | "artistNameTemplate": "%artist%", 32 | "createAlbumFolder": True, 33 | "albumNameTemplate": "%artist% - %album%", 34 | "createCDFolder": True, 35 | "createStructurePlaylist": False, 36 | "createSingleFolder": False, 37 | "padTracks": True, 38 | "paddingSize": "0", 39 | "illegalCharacterReplacer": "_", 40 | "queueConcurrency": 3, 41 | "maxBitrate": str(TrackFormats.MP3_320), 42 | "feelingLucky": False, 43 | "fallbackBitrate": False, 44 | "fallbackSearch": False, 45 | "fallbackISRC": False, 46 | "logErrors": True, 47 | "logSearched": False, 48 | "overwriteFile": OverwriteOption.DONT_OVERWRITE, 49 | "createM3U8File": False, 50 | "playlistFilenameTemplate": "playlist", 51 | "syncedLyrics": False, 52 | "embeddedArtworkSize": 800, 53 | "embeddedArtworkPNG": False, 54 | "localArtworkSize": 1400, 55 | "localArtworkFormat": "jpg", 56 | "saveArtwork": True, 57 | "coverImageTemplate": "cover", 58 | "saveArtworkArtist": False, 59 | "artistImageTemplate": "folder", 60 | "jpegImageQuality": 90, 61 | "dateFormat": "Y-M-D", 62 | "albumVariousArtists": True, 63 | "removeAlbumVersion": False, 64 | "removeDuplicateArtists": True, 65 | "featuredToTitle": FeaturesOption.NO_CHANGE, 66 | "titleCasing": "nothing", 67 | "artistCasing": "nothing", 68 | "executeCommand": "", 69 | "tags": { 70 | "title": True, 71 | "artist": True, 72 | "artists": True, 73 | "album": True, 74 | "cover": True, 75 | "trackNumber": True, 76 | "trackTotal": False, 77 | "discNumber": True, 78 | "discTotal": False, 79 | "albumArtist": True, 80 | "genre": True, 81 | "year": True, 82 | "date": True, 83 | "explicit": False, 84 | "isrc": True, 85 | "length": True, 86 | "barcode": True, 87 | "bpm": True, 88 | "replayGain": False, 89 | "label": True, 90 | "lyrics": False, 91 | "syncedLyrics": False, 92 | "copyright": False, 93 | "composer": False, 94 | "involvedPeople": False, 95 | "source": False, 96 | "rating": False, 97 | "savePlaylistAsCompilation": False, 98 | "useNullSeparator": False, 99 | "saveID3v1": True, 100 | "multiArtistSeparator": "default", 101 | "singleAlbumArtist": False, 102 | "coverDescriptionUTF8": False 103 | } 104 | } 105 | 106 | def save(settings, configFolder=None): 107 | configFolder = Path(configFolder or localpaths.getConfigFolder()) 108 | makedirs(configFolder, exist_ok=True) # Create config folder if it doesn't exsist 109 | 110 | with open(configFolder / 'config.json', 'w', encoding="utf-8") as configFile: 111 | json.dump(settings, configFile, indent=2) 112 | 113 | def load(configFolder=None): 114 | configFolder = Path(configFolder or localpaths.getConfigFolder()) 115 | makedirs(configFolder, exist_ok=True) # Create config folder if it doesn't exsist 116 | if not (configFolder / 'config.json').is_file(): save(DEFAULTS, configFolder) # Create config file if it doesn't exsist 117 | 118 | # Read config file 119 | with open(configFolder / 'config.json', 'r', encoding="utf-8") as configFile: 120 | try: 121 | settings = json.load(configFile) 122 | except json.decoder.JSONDecodeError: 123 | save(DEFAULTS, configFolder) 124 | settings = deepcopy(DEFAULTS) 125 | except Exception: 126 | settings = deepcopy(DEFAULTS) 127 | 128 | if check(settings) > 0: save(settings, configFolder) # Check the settings and save them if something changed 129 | return settings 130 | 131 | def check(settings): 132 | changes = 0 133 | for i_set in DEFAULTS: 134 | if not i_set in settings or not isinstance(settings[i_set], type(DEFAULTS[i_set])): 135 | settings[i_set] = DEFAULTS[i_set] 136 | changes += 1 137 | for i_set in DEFAULTS['tags']: 138 | if not i_set in settings['tags'] or not isinstance(settings['tags'][i_set], type(DEFAULTS['tags'][i_set])): 139 | settings['tags'][i_set] = DEFAULTS['tags'][i_set] 140 | changes += 1 141 | if settings['downloadLocation'] == "": 142 | settings['downloadLocation'] = DEFAULTS['downloadLocation'] 143 | changes += 1 144 | for template in ['tracknameTemplate', 'albumTracknameTemplate', 'playlistTracknameTemplate', 'playlistNameTemplate', 'artistNameTemplate', 'albumNameTemplate', 'playlistFilenameTemplate', 'coverImageTemplate', 'artistImageTemplate', 'paddingSize']: 145 | if settings[template] == "": 146 | settings[template] = DEFAULTS[template] 147 | changes += 1 148 | return changes 149 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/tagger.py: -------------------------------------------------------------------------------- 1 | from mutagen.flac import FLAC, Picture 2 | from mutagen.id3 import ID3, ID3NoHeaderError, \ 3 | TXXX, TIT2, TPE1, TALB, TPE2, TRCK, TPOS, TCON, TYER, TDAT, TLEN, TBPM, \ 4 | TPUB, TSRC, USLT, SYLT, APIC, IPLS, TCOM, TCOP, TCMP, Encoding, PictureType, POPM 5 | 6 | # Adds tags to a MP3 file 7 | def tagID3(path, track, save): 8 | # Delete exsisting tags 9 | try: 10 | tag = ID3(path) 11 | tag.delete() 12 | except ID3NoHeaderError: 13 | tag = ID3() 14 | 15 | if save['title']: 16 | tag.add(TIT2(text=track.title)) 17 | 18 | if save['artist'] and len(track.artists): 19 | if save['multiArtistSeparator'] == "default": 20 | tag.add(TPE1(text=track.artists)) 21 | else: 22 | if save['multiArtistSeparator'] == "nothing": 23 | tag.add(TPE1(text=track.mainArtist.name)) 24 | else: 25 | tag.add(TPE1(text=track.artistsString)) 26 | # Tag ARTISTS is added to keep the multiartist support when using a non standard tagging method 27 | # https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html#artists 28 | if save['artists']: 29 | tag.add(TXXX(desc="ARTISTS", text=track.artists)) 30 | 31 | if save['album']: 32 | tag.add(TALB(text=track.album.title)) 33 | 34 | if save['albumArtist'] and len(track.album.artists): 35 | if save['singleAlbumArtist'] and track.album.mainArtist.save: 36 | tag.add(TPE2(text=track.album.mainArtist.name)) 37 | else: 38 | tag.add(TPE2(text=track.album.artists)) 39 | 40 | if save['trackNumber']: 41 | trackNumber = str(track.trackNumber) 42 | if save['trackTotal']: 43 | trackNumber += "/" + str(track.album.trackTotal) 44 | tag.add(TRCK(text=trackNumber)) 45 | if save['discNumber']: 46 | discNumber = str(track.discNumber) 47 | if save['discTotal']: 48 | discNumber += "/" + str(track.album.discTotal) 49 | tag.add(TPOS(text=discNumber)) 50 | 51 | if save['genre']: 52 | tag.add(TCON(text=track.album.genre)) 53 | if save['year']: 54 | tag.add(TYER(text=str(track.date.year))) 55 | if save['date']: 56 | # Referencing ID3 standard 57 | # https://id3.org/id3v2.3.0#TDAT 58 | # The 'Date' frame is a numeric string in the DDMM format. 59 | tag.add(TDAT(text=str(track.date.day) + str(track.date.month))) 60 | if save['length']: 61 | tag.add(TLEN(text=str(int(track.duration)*1000))) 62 | if save['bpm'] and track.bpm: 63 | tag.add(TBPM(text=str(track.bpm))) 64 | if save['label']: 65 | tag.add(TPUB(text=track.album.label)) 66 | if save['isrc']: 67 | tag.add(TSRC(text=track.ISRC)) 68 | if save['barcode']: 69 | tag.add(TXXX(desc="BARCODE", text=track.album.barcode)) 70 | if save['explicit']: 71 | tag.add(TXXX(desc="ITUNESADVISORY", text= "1" if track.explicit else "0" )) 72 | if save['replayGain']: 73 | tag.add(TXXX(desc="REPLAYGAIN_TRACK_GAIN", text=track.replayGain)) 74 | if track.lyrics.unsync and save['lyrics']: 75 | tag.add(USLT(text=track.lyrics.unsync)) 76 | if track.lyrics.syncID3 and save['syncedLyrics']: 77 | # Referencing ID3 standard 78 | # https://id3.org/id3v2.3.0#sec4.10 79 | # Type: 1 => is lyrics 80 | # Format: 2 => Absolute time, 32 bit sized, using milliseconds as unit 81 | tag.add(SYLT(Encoding.UTF8, type=1, format=2, text=track.lyrics.syncID3)) 82 | 83 | involved_people = [] 84 | for role in track.contributors: 85 | if role in ['author', 'engineer', 'mixer', 'producer', 'writer']: 86 | for person in track.contributors[role]: 87 | involved_people.append([role, person]) 88 | elif role == 'composer' and save['composer']: 89 | tag.add(TCOM(text=track.contributors['composer'])) 90 | if len(involved_people) > 0 and save['involvedPeople']: 91 | tag.add(IPLS(people=involved_people)) 92 | 93 | if save['copyright'] and track.copyright: 94 | tag.add(TCOP(text=track.copyright)) 95 | if save['savePlaylistAsCompilation'] and track.playlist or track.album.recordType == "compile": 96 | tag.add(TCMP(text="1")) 97 | 98 | if save['source']: 99 | tag.add(TXXX(desc="SOURCE", text='Deezer')) 100 | tag.add(TXXX(desc="SOURCEID", text=str(track.id))) 101 | 102 | if save['rating']: 103 | rank = round((int(track.rank) / 10000) * 2.55) 104 | if rank > 255 : 105 | rank = 255 106 | else: 107 | rank = round(rank, 0) 108 | 109 | tag.add(POPM(rating=rank)) 110 | 111 | if save['cover'] and track.album.embeddedCoverPath: 112 | 113 | descEncoding = Encoding.LATIN1 114 | if save['coverDescriptionUTF8']: 115 | descEncoding = Encoding.UTF8 116 | 117 | mimeType = 'image/jpeg' 118 | if str(track.album.embeddedCoverPath).endswith('png'): 119 | mimeType = 'image/png' 120 | 121 | with open(track.album.embeddedCoverPath, 'rb') as f: 122 | tag.add(APIC(descEncoding, mimeType, PictureType.COVER_FRONT, desc='cover', data=f.read())) 123 | 124 | tag.save( path, 125 | v1=2 if save['saveID3v1'] else 0, 126 | v2_version=3, 127 | v23_sep=None if save['useNullSeparator'] else '/' ) 128 | 129 | # Adds tags to a FLAC file 130 | def tagFLAC(path, track, save): 131 | # Delete exsisting tags 132 | tag = FLAC(path) 133 | tag.delete() 134 | tag.clear_pictures() 135 | 136 | if save['title']: 137 | tag["TITLE"] = track.title 138 | 139 | if save['artist'] and len(track.artists): 140 | if save['multiArtistSeparator'] == "default": 141 | tag["ARTIST"] = track.artists 142 | else: 143 | if save['multiArtistSeparator'] == "nothing": 144 | tag["ARTIST"] = track.mainArtist.name 145 | else: 146 | tag["ARTIST"] = track.artistsString 147 | # Tag ARTISTS is added to keep the multiartist support when using a non standard tagging method 148 | # https://picard-docs.musicbrainz.org/en/technical/tag_mapping.html#artists 149 | if save['artists']: 150 | tag["ARTISTS"] = track.artists 151 | 152 | if save['album']: 153 | tag["ALBUM"] = track.album.title 154 | 155 | if save['albumArtist'] and len(track.album.artists): 156 | if save['singleAlbumArtist'] and track.album.mainArtist.save: 157 | tag["ALBUMARTIST"] = track.album.mainArtist.name 158 | else: 159 | tag["ALBUMARTIST"] = track.album.artists 160 | 161 | if save['trackNumber']: 162 | tag["TRACKNUMBER"] = str(track.trackNumber) 163 | if save['trackTotal']: 164 | tag["TRACKTOTAL"] = str(track.album.trackTotal) 165 | if save['discNumber']: 166 | tag["DISCNUMBER"] = str(track.discNumber) 167 | if save['discTotal']: 168 | tag["DISCTOTAL"] = str(track.album.discTotal) 169 | if save['genre']: 170 | tag["GENRE"] = track.album.genre 171 | 172 | # YEAR tag is not suggested as a standard tag 173 | # Being YEAR already contained in DATE will only use DATE instead 174 | # Reference: https://www.xiph.org/vorbis/doc/v-comment.html#fieldnames 175 | if save['date']: 176 | tag["DATE"] = track.dateString 177 | elif save['year']: 178 | tag["DATE"] = str(track.date.year) 179 | 180 | if save['length']: 181 | tag["LENGTH"] = str(int(track.duration)*1000) 182 | if save['bpm'] and track.bpm: 183 | tag["BPM"] = str(track.bpm) 184 | if save['label']: 185 | tag["PUBLISHER"] = track.album.label 186 | if save['isrc']: 187 | tag["ISRC"] = track.ISRC 188 | if save['barcode']: 189 | tag["BARCODE"] = track.album.barcode 190 | if save['explicit']: 191 | tag["ITUNESADVISORY"] = "1" if track.explicit else "0" 192 | if save['replayGain']: 193 | tag["REPLAYGAIN_TRACK_GAIN"] = track.replayGain 194 | if track.lyrics.unsync and save['lyrics']: 195 | tag["LYRICS"] = track.lyrics.unsync 196 | 197 | for role in track.contributors: 198 | if role in ['author', 'engineer', 'mixer', 'producer', 'writer', 'composer']: 199 | if save['involvedPeople'] and role != 'composer' or save['composer'] and role == 'composer': 200 | tag[role] = track.contributors[role] 201 | elif role == 'musicpublisher' and save['involvedPeople']: 202 | tag["ORGANIZATION"] = track.contributors['musicpublisher'] 203 | 204 | if save['copyright'] and track.copyright: 205 | tag["COPYRIGHT"] = track.copyright 206 | if save['savePlaylistAsCompilation'] and track.playlist or track.album.recordType == "compile": 207 | tag["COMPILATION"] = "1" 208 | 209 | if save['source']: 210 | tag["SOURCE"] = 'Deezer' 211 | tag["SOURCEID"] = str(track.id) 212 | 213 | if save['rating']: 214 | rank = round((int(track.rank) / 10000)) 215 | tag['RATING'] = str(rank) 216 | 217 | if save['cover'] and track.album.embeddedCoverPath: 218 | image = Picture() 219 | image.type = PictureType.COVER_FRONT 220 | image.mime = 'image/jpeg' 221 | if str(track.album.embeddedCoverPath).endswith('png'): 222 | image.mime = 'image/png' 223 | with open(track.album.embeddedCoverPath, 'rb') as f: 224 | image.data = f.read() 225 | tag.add_picture(image) 226 | 227 | tag.save(deleteid3=True) 228 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/types/Album.py: -------------------------------------------------------------------------------- 1 | from deemix.utils import removeDuplicateArtists, removeFeatures 2 | from deemix.types.Artist import Artist 3 | from deemix.types.Date import Date 4 | from deemix.types.Picture import Picture 5 | from deemix.types import VARIOUS_ARTISTS 6 | 7 | class Album: 8 | def __init__(self, alb_id="0", title="", pic_md5=""): 9 | self.id = alb_id 10 | self.title = title 11 | self.pic = Picture(pic_md5, "cover") 12 | self.artist = {"Main": []} 13 | self.artists = [] 14 | self.mainArtist = None 15 | self.date = Date() 16 | self.dateString = "" 17 | self.trackTotal = "0" 18 | self.discTotal = "0" 19 | self.embeddedCoverPath = "" 20 | self.embeddedCoverURL = "" 21 | self.explicit = False 22 | self.genre = [] 23 | self.barcode = "Unknown" 24 | self.label = "Unknown" 25 | self.copyright = "" 26 | self.recordType = "album" 27 | self.bitrate = 0 28 | self.rootArtist = None 29 | self.variousArtists = None 30 | 31 | self.playlistID = None 32 | self.owner = None 33 | self.isPlaylist = False 34 | 35 | def parseAlbum(self, albumAPI): 36 | self.title = albumAPI['title'] 37 | 38 | # Getting artist image ID 39 | # ex: https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/56x56-000000-80-0-0.jpg 40 | art_pic = albumAPI['artist'].get('picture_small') 41 | if art_pic: art_pic = art_pic[art_pic.find('artist/') + 7:-24] 42 | else: art_pic = "" 43 | self.mainArtist = Artist( 44 | albumAPI['artist']['id'], 45 | albumAPI['artist']['name'], 46 | "Main", 47 | art_pic 48 | ) 49 | if albumAPI.get('root_artist'): 50 | art_pic = albumAPI['root_artist']['picture_small'] 51 | art_pic = art_pic[art_pic.find('artist/') + 7:-24] 52 | self.rootArtist = Artist( 53 | albumAPI['root_artist']['id'], 54 | albumAPI['root_artist']['name'], 55 | "Root", 56 | art_pic 57 | ) 58 | 59 | for artist in albumAPI['contributors']: 60 | isVariousArtists = str(artist['id']) == VARIOUS_ARTISTS 61 | isMainArtist = artist['role'] == "Main" 62 | 63 | if isVariousArtists: 64 | self.variousArtists = Artist( 65 | art_id = artist['id'], 66 | name = artist['name'], 67 | role = artist['role'] 68 | ) 69 | continue 70 | 71 | if artist['name'] not in self.artists: 72 | self.artists.append(artist['name']) 73 | 74 | if isMainArtist or artist['name'] not in self.artist['Main'] and not isMainArtist: 75 | if not artist['role'] in self.artist: 76 | self.artist[artist['role']] = [] 77 | self.artist[artist['role']].append(artist['name']) 78 | 79 | self.trackTotal = albumAPI['nb_tracks'] 80 | self.recordType = albumAPI.get('record_type', self.recordType) 81 | 82 | self.barcode = albumAPI.get('upc', self.barcode) 83 | self.label = albumAPI.get('label', self.label) 84 | self.explicit = bool(albumAPI.get('explicit_lyrics', False)) 85 | release_date = albumAPI.get('release_date') 86 | if 'physical_release_date' in albumAPI: 87 | release_date = albumAPI['physical_release_date'] 88 | if release_date: 89 | self.date.day = release_date[8:10] 90 | self.date.month = release_date[5:7] 91 | self.date.year = release_date[0:4] 92 | self.date.fixDayMonth() 93 | 94 | self.discTotal = albumAPI.get('nb_disk', "1") 95 | self.copyright = albumAPI.get('copyright', "") 96 | 97 | if not self.pic.md5 or self.pic.md5 == "": 98 | if albumAPI.get('md5_image'): 99 | self.pic.md5 = albumAPI['md5_image'] 100 | elif albumAPI.get('cover_small'): 101 | # Getting album cover MD5 102 | # ex: https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/56x56-000000-80-0-0.jpg 103 | alb_pic = albumAPI['cover_small'] 104 | self.pic.md5 = alb_pic[alb_pic.find('cover/') + 6:-24] 105 | 106 | if albumAPI.get('genres') and len(albumAPI['genres'].get('data', [])) > 0: 107 | for genre in albumAPI['genres']['data']: 108 | self.genre.append(genre['name']) 109 | 110 | def makePlaylistCompilation(self, playlist): 111 | self.variousArtists = playlist.variousArtists 112 | self.mainArtist = playlist.mainArtist 113 | self.title = playlist.title 114 | self.rootArtist = playlist.rootArtist 115 | self.artist = playlist.artist 116 | self.artists = playlist.artists 117 | self.trackTotal = playlist.trackTotal 118 | self.recordType = playlist.recordType 119 | self.barcode = playlist.barcode 120 | self.label = playlist.label 121 | self.explicit = playlist.explicit 122 | self.date = playlist.date 123 | self.discTotal = playlist.discTotal 124 | self.playlistID = playlist.playlistID 125 | self.owner = playlist.owner 126 | self.pic = playlist.pic 127 | self.isPlaylist = True 128 | 129 | def removeDuplicateArtists(self): 130 | """Removes duplicate artists for both artist array and artists dict""" 131 | (self.artist, self.artists) = removeDuplicateArtists(self.artist, self.artists) 132 | 133 | def getCleanTitle(self): 134 | """Removes featuring from the album name""" 135 | return removeFeatures(self.title) 136 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/types/Artist.py: -------------------------------------------------------------------------------- 1 | from deemix.types.Picture import Picture 2 | from deemix.types import VARIOUS_ARTISTS 3 | 4 | class Artist: 5 | def __init__(self, art_id="0", name="", role="", pic_md5=""): 6 | self.id = str(art_id) 7 | self.name = name 8 | self.pic = Picture(md5=pic_md5, pic_type="artist") 9 | self.role = role 10 | self.save = True 11 | 12 | def isVariousArtists(self): 13 | return self.id == VARIOUS_ARTISTS 14 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/types/Date.py: -------------------------------------------------------------------------------- 1 | class Date: 2 | def __init__(self, day="00", month="00", year="XXXX"): 3 | self.day = day 4 | self.month = month 5 | self.year = year 6 | self.fixDayMonth() 7 | 8 | # Fix incorrect day month when detectable 9 | def fixDayMonth(self): 10 | if int(self.month) > 12: 11 | monthTemp = self.month 12 | self.month = self.day 13 | self.day = monthTemp 14 | 15 | def format(self, template): 16 | elements = { 17 | 'year': ['YYYY', 'YY', 'Y'], 18 | 'month': ['MM', 'M'], 19 | 'day': ['DD', 'D'] 20 | } 21 | for element, placeholders in elements.items(): 22 | for placeholder in placeholders: 23 | if placeholder in template: 24 | template = template.replace(placeholder, str(getattr(self, element))) 25 | return template 26 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/types/DownloadObjects.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | class IDownloadObject: 4 | """DownloadObject Interface""" 5 | def __init__(self, obj): 6 | self.type = obj['type'] 7 | self.id = obj['id'] 8 | self.bitrate = obj['bitrate'] 9 | self.title = obj['title'] 10 | self.artist = obj['artist'] 11 | self.cover = obj['cover'] 12 | self.explicit = obj.get('explicit', False) 13 | self.size = obj.get('size', 0) 14 | self.downloaded = obj.get('downloaded', 0) 15 | self.failed = obj.get('failed', 0) 16 | self.progress = obj.get('progress', 0) 17 | self.errors = obj.get('errors', []) 18 | self.files = obj.get('files', []) 19 | self.extrasPath = obj.get('extrasPath', "") 20 | if self.extrasPath: self.extrasPath = Path(self.extrasPath) 21 | self.progressNext = 0 22 | self.uuid = f"{self.type}_{self.id}_{self.bitrate}" 23 | self.isCanceled = False 24 | self.__type__ = None 25 | 26 | def toDict(self): 27 | return { 28 | 'type': self.type, 29 | 'id': self.id, 30 | 'bitrate': self.bitrate, 31 | 'uuid': self.uuid, 32 | 'title': self.title, 33 | 'artist': self.artist, 34 | 'cover': self.cover, 35 | 'explicit': self.explicit, 36 | 'size': self.size, 37 | 'downloaded': self.downloaded, 38 | 'failed': self.failed, 39 | 'progress': self.progress, 40 | 'errors': self.errors, 41 | 'files': self.files, 42 | 'extrasPath': str(self.extrasPath), 43 | '__type__': self.__type__ 44 | } 45 | 46 | def getResettedDict(self): 47 | item = self.toDict() 48 | item['downloaded'] = 0 49 | item['failed'] = 0 50 | item['progress'] = 0 51 | item['errors'] = [] 52 | item['files'] = [] 53 | return item 54 | 55 | def getSlimmedDict(self): 56 | light = self.toDict() 57 | propertiesToDelete = ['single', 'collection', 'plugin', 'conversion_data'] 58 | for prop in propertiesToDelete: 59 | if prop in light: 60 | del light[prop] 61 | return light 62 | 63 | def getEssentialDict(self): 64 | return { 65 | 'type': self.type, 66 | 'id': self.id, 67 | 'bitrate': self.bitrate, 68 | 'uuid': self.uuid, 69 | 'title': self.title, 70 | 'artist': self.artist, 71 | 'cover': self.cover, 72 | 'explicit': self.explicit, 73 | 'size': self.size, 74 | 'extrasPath': str(self.extrasPath) 75 | } 76 | 77 | def updateProgress(self, listener=None): 78 | if round(self.progressNext) != self.progress and round(self.progressNext) % 2 == 0: 79 | self.progress = round(self.progressNext) 80 | if listener: listener.send("updateQueue", {'uuid': self.uuid, 'progress': self.progress}) 81 | 82 | class Single(IDownloadObject): 83 | def __init__(self, obj): 84 | super().__init__(obj) 85 | self.size = 1 86 | self.single = obj['single'] 87 | self.__type__ = "Single" 88 | 89 | def toDict(self): 90 | item = super().toDict() 91 | item['single'] = self.single 92 | return item 93 | 94 | def completeTrackProgress(self, listener=None): 95 | self.progressNext = 100 96 | self.updateProgress(listener) 97 | 98 | def removeTrackProgress(self, listener=None): 99 | self.progressNext = 0 100 | self.updateProgress(listener) 101 | 102 | class Collection(IDownloadObject): 103 | def __init__(self, obj): 104 | super().__init__(obj) 105 | self.collection = obj['collection'] 106 | self.__type__ = "Collection" 107 | 108 | def toDict(self): 109 | item = super().toDict() 110 | item['collection'] = self.collection 111 | return item 112 | 113 | def completeTrackProgress(self, listener=None): 114 | self.progressNext += (1 / self.size) * 100 115 | self.updateProgress(listener) 116 | 117 | def removeTrackProgress(self, listener=None): 118 | self.progressNext -= (1 / self.size) * 100 119 | self.updateProgress(listener) 120 | 121 | class Convertable(Collection): 122 | def __init__(self, obj): 123 | super().__init__(obj) 124 | self.plugin = obj['plugin'] 125 | self.conversion_data = obj['conversion_data'] 126 | self.__type__ = "Convertable" 127 | 128 | def toDict(self): 129 | item = super().toDict() 130 | item['plugin'] = self.plugin 131 | item['conversion_data'] = self.conversion_data 132 | return item 133 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/types/Lyrics.py: -------------------------------------------------------------------------------- 1 | class Lyrics: 2 | def __init__(self, lyr_id="0"): 3 | self.id = lyr_id 4 | self.sync = "" 5 | self.unsync = "" 6 | self.syncID3 = [] 7 | 8 | def parseLyrics(self, lyricsAPI): 9 | self.unsync = lyricsAPI.get("LYRICS_TEXT") 10 | if "LYRICS_SYNC_JSON" in lyricsAPI: 11 | syncLyricsJson = lyricsAPI["LYRICS_SYNC_JSON"] 12 | timestamp = "" 13 | milliseconds = 0 14 | for line, _ in enumerate(syncLyricsJson): 15 | if syncLyricsJson[line]["line"] != "": 16 | timestamp = syncLyricsJson[line]["lrc_timestamp"] 17 | milliseconds = int(syncLyricsJson[line]["milliseconds"]) 18 | self.syncID3.append((syncLyricsJson[line]["line"], milliseconds)) 19 | else: 20 | notEmptyLine = line + 1 21 | while syncLyricsJson[notEmptyLine]["line"] == "": 22 | notEmptyLine += 1 23 | timestamp = syncLyricsJson[notEmptyLine]["lrc_timestamp"] 24 | self.sync += timestamp + syncLyricsJson[line]["line"] + "\r\n" 25 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/types/Picture.py: -------------------------------------------------------------------------------- 1 | class Picture: 2 | def __init__(self, md5="", pic_type=""): 3 | self.md5 = md5 4 | self.type = pic_type 5 | 6 | def getURL(self, size, pic_format): 7 | url = "https://e-cdns-images.dzcdn.net/images/{}/{}/{size}x{size}".format( 8 | self.type, 9 | self.md5, 10 | size=size 11 | ) 12 | 13 | if pic_format.startswith("jpg"): 14 | quality = 80 15 | if '-' in pic_format: 16 | quality = pic_format[4:] 17 | pic_format = 'jpg' 18 | return url + f'-000000-{quality}-0-0.jpg' 19 | if pic_format == 'png': 20 | return url + '-none-100-0-0.png' 21 | 22 | return url+'.jpg' 23 | 24 | class StaticPicture: 25 | def __init__(self, url): 26 | self.staticURL = url 27 | 28 | def getURL(self, _, __): 29 | return self.staticURL 30 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/types/Playlist.py: -------------------------------------------------------------------------------- 1 | from deemix.types.Artist import Artist 2 | from deemix.types.Date import Date 3 | from deemix.types.Picture import Picture, StaticPicture 4 | 5 | class Playlist: 6 | def __init__(self, playlistAPI): 7 | self.id = "pl_" + str(playlistAPI['id']) 8 | self.title = playlistAPI['title'] 9 | self.rootArtist = None 10 | self.artist = {"Main": []} 11 | self.artists = [] 12 | self.trackTotal = playlistAPI['nb_tracks'] 13 | self.recordType = "compile" 14 | self.barcode = "" 15 | self.label = "" 16 | self.explicit = playlistAPI['explicit'] 17 | self.genre = ["Compilation", ] 18 | 19 | year = playlistAPI["creation_date"][0:4] 20 | month = playlistAPI["creation_date"][5:7] 21 | day = playlistAPI["creation_date"][8:10] 22 | self.date = Date(day, month, year) 23 | 24 | self.discTotal = "1" 25 | self.playlistID = playlistAPI['id'] 26 | self.owner = playlistAPI['creator'] 27 | 28 | if 'dzcdn.net' in playlistAPI['picture_small']: 29 | url = playlistAPI['picture_small'] 30 | picType = url[url.find('images/')+7:] 31 | picType = picType[:picType.find('/')] 32 | md5 = url[url.find(picType+'/') + len(picType)+1:-24] 33 | self.pic = Picture(md5, picType) 34 | else: 35 | self.pic = StaticPicture(playlistAPI['picture_xl']) 36 | 37 | if 'various_artist' in playlistAPI: 38 | pic_md5 = playlistAPI['various_artist']['picture_small'] 39 | pic_md5 = pic_md5[pic_md5.find('artist/') + 7:-24] 40 | self.variousArtists = Artist( 41 | playlistAPI['various_artist']['id'], 42 | playlistAPI['various_artist']['name'], 43 | "Main", 44 | pic_md5 45 | ) 46 | self.mainArtist = self.variousArtists 47 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/types/Track.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | 4 | from deezer.utils import map_track, map_album 5 | from deezer.errors import APIError, GWAPIError 6 | from deemix.errors import NoDataToParse, AlbumDoesntExists 7 | 8 | from deemix.utils import removeFeatures, andCommaConcat, removeDuplicateArtists, generateReplayGainString, changeCase 9 | 10 | from deemix.types.Album import Album 11 | from deemix.types.Artist import Artist 12 | from deemix.types.Date import Date 13 | from deemix.types.Picture import Picture 14 | from deemix.types.Playlist import Playlist 15 | from deemix.types.Lyrics import Lyrics 16 | from deemix.types import VARIOUS_ARTISTS 17 | 18 | from deemix.settings import FeaturesOption 19 | 20 | class Track: 21 | def __init__(self, sng_id="0", name=""): 22 | self.id = sng_id 23 | self.title = name 24 | self.MD5 = "" 25 | self.mediaVersion = "" 26 | self.trackToken = "" 27 | self.trackTokenExpiration = 0 28 | self.duration = 0 29 | self.fallbackID = "0" 30 | self.albumsFallback = [] 31 | self.filesizes = {} 32 | self.local = False 33 | self.mainArtist = None 34 | self.artist = {"Main": []} 35 | self.artists = [] 36 | self.album = None 37 | self.trackNumber = "0" 38 | self.discNumber = "0" 39 | self.date = Date() 40 | self.lyrics = None 41 | self.bpm = 0 42 | self.contributors = {} 43 | self.copyright = "" 44 | self.explicit = False 45 | self.ISRC = "" 46 | self.replayGain = "" 47 | self.rank = 0 48 | self.playlist = None 49 | self.position = None 50 | self.searched = False 51 | self.selectedFormat = 0 52 | self.singleDownload = False 53 | self.dateString = "" 54 | self.artistsString = "" 55 | self.mainArtistsString = "" 56 | self.featArtistsString = "" 57 | self.urls = {} 58 | 59 | def parseEssentialData(self, trackAPI): 60 | self.id = str(trackAPI['id']) 61 | self.duration = trackAPI['duration'] 62 | self.trackToken = trackAPI['track_token'] 63 | self.trackTokenExpiration = trackAPI['track_token_expire'] 64 | self.MD5 = trackAPI.get('md5_origin') 65 | self.mediaVersion = trackAPI['media_version'] 66 | self.filesizes = trackAPI['filesizes'] 67 | self.fallbackID = "0" 68 | if 'fallback_id' in trackAPI: 69 | self.fallbackID = trackAPI['fallback_id'] 70 | self.local = int(self.id) < 0 71 | self.urls = {} 72 | 73 | def parseData(self, dz, track_id=None, trackAPI=None, albumAPI=None, playlistAPI=None): 74 | if track_id and (not trackAPI or trackAPI and not trackAPI.get('track_token')): 75 | trackAPI_new = dz.gw.get_track_with_fallback(track_id) 76 | trackAPI_new = map_track(trackAPI_new) 77 | if not trackAPI: trackAPI = {} 78 | trackAPI_new.update(trackAPI) 79 | trackAPI = trackAPI_new 80 | elif not trackAPI: raise NoDataToParse 81 | 82 | self.parseEssentialData(trackAPI) 83 | 84 | # only public api has bpm 85 | if not trackAPI.get('bpm') and not self.local: 86 | try: 87 | trackAPI_new = dz.api.get_track(trackAPI['id']) 88 | trackAPI_new['release_date'] = trackAPI['release_date'] 89 | trackAPI.update(trackAPI_new) 90 | except APIError: pass 91 | 92 | if self.local: 93 | self.parseLocalTrackData(trackAPI) 94 | else: 95 | self.parseTrack(trackAPI) 96 | 97 | # Get Lyrics data 98 | if not trackAPI.get("lyrics") and self.lyrics.id != "0": 99 | try: trackAPI["lyrics"] = dz.gw.get_track_lyrics(self.id) 100 | except GWAPIError: self.lyrics.id = "0" 101 | if self.lyrics.id != "0": self.lyrics.parseLyrics(trackAPI["lyrics"]) 102 | 103 | # Parse Album Data 104 | self.album = Album( 105 | alb_id = trackAPI['album']['id'], 106 | title = trackAPI['album']['title'], 107 | pic_md5 = trackAPI['album'].get('md5_origin') 108 | ) 109 | 110 | # Get album Data 111 | if not albumAPI: 112 | try: albumAPI = dz.api.get_album(self.album.id) 113 | except APIError: albumAPI = None 114 | 115 | # Get album_gw Data 116 | # Only gw has disk number 117 | if not albumAPI or albumAPI and not albumAPI.get('nb_disk'): 118 | try: 119 | albumAPI_gw = dz.gw.get_album(self.album.id) 120 | albumAPI_gw = map_album(albumAPI_gw) 121 | except GWAPIError: albumAPI_gw = {} 122 | if not albumAPI: albumAPI = {} 123 | albumAPI_gw.update(albumAPI) 124 | albumAPI = albumAPI_gw 125 | 126 | if not albumAPI: raise AlbumDoesntExists 127 | 128 | self.album.parseAlbum(albumAPI) 129 | # albumAPI_gw doesn't contain the artist cover 130 | # Getting artist image ID 131 | # ex: https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/56x56-000000-80-0-0.jpg 132 | if not self.album.mainArtist.pic.md5 or self.album.mainArtist.pic.md5 == "": 133 | artistAPI = dz.api.get_artist(self.album.mainArtist.id) 134 | self.album.mainArtist.pic.md5 = artistAPI['picture_small'][artistAPI['picture_small'].find('artist/') + 7:-24] 135 | 136 | # Fill missing data 137 | if self.album.date and not self.date: self.date = self.album.date 138 | if 'genres' in trackAPI: 139 | for genre in trackAPI['genres']: 140 | if genre not in self.album.genre: self.album.genre.append(genre) 141 | 142 | # Remove unwanted charaters in track name 143 | # Example: track/127793 144 | self.title = ' '.join(self.title.split()) 145 | 146 | # Make sure there is at least one artist 147 | if len(self.artist['Main']) == 0: 148 | self.artist['Main'] = [self.mainArtist.name] 149 | 150 | self.position = trackAPI.get('position') 151 | 152 | # Add playlist data if track is in a playlist 153 | if playlistAPI: self.playlist = Playlist(playlistAPI) 154 | 155 | self.generateMainFeatStrings() 156 | return self 157 | 158 | def parseLocalTrackData(self, trackAPI): 159 | # Local tracks has only the trackAPI_gw page and 160 | # contains only the tags provided by the file 161 | self.title = trackAPI['title'] 162 | self.album = Album(title=trackAPI['album']['title']) 163 | self.album.pic = Picture( 164 | md5 = trackAPI.get('md5_image', ""), 165 | pic_type = "cover" 166 | ) 167 | self.mainArtist = Artist(name=trackAPI['artist']['name'], role="Main") 168 | self.artists = [trackAPI['artist']['name']] 169 | self.artist = { 170 | 'Main': [trackAPI['artist']['name']] 171 | } 172 | self.album.artist = self.artist 173 | self.album.artists = self.artists 174 | self.album.date = self.date 175 | self.album.mainArtist = self.mainArtist 176 | 177 | def parseTrack(self, trackAPI): 178 | self.title = trackAPI['title'] 179 | 180 | self.discNumber = trackAPI.get('disk_number') 181 | self.explicit = trackAPI.get('explicit_lyrics', False) 182 | self.copyright = trackAPI.get('copyright') 183 | if 'gain' in trackAPI: self.replayGain = generateReplayGainString(trackAPI['gain']) 184 | self.ISRC = trackAPI.get('isrc') 185 | self.trackNumber = trackAPI['track_position'] 186 | self.contributors = trackAPI.get('song_contributors') 187 | self.rank = trackAPI['rank'] 188 | self.bpm = trackAPI['bpm'] 189 | 190 | self.lyrics = Lyrics(trackAPI.get('lyrics_id', "0")) 191 | 192 | self.mainArtist = Artist( 193 | art_id = trackAPI['artist']['id'], 194 | name = trackAPI['artist']['name'], 195 | role = "Main", 196 | pic_md5 = trackAPI['artist'].get('md5_image') 197 | ) 198 | 199 | if trackAPI.get('physical_release_date'): 200 | self.date.day = trackAPI["physical_release_date"][8:10] 201 | self.date.month = trackAPI["physical_release_date"][5:7] 202 | self.date.year = trackAPI["physical_release_date"][0:4] 203 | self.date.fixDayMonth() 204 | 205 | for artist in trackAPI.get('contributors', []): 206 | isVariousArtists = str(artist['id']) == VARIOUS_ARTISTS 207 | isMainArtist = artist['role'] == "Main" 208 | 209 | if len(trackAPI['contributors']) > 1 and isVariousArtists: 210 | continue 211 | 212 | if artist['name'] not in self.artists: 213 | self.artists.append(artist['name']) 214 | 215 | if isMainArtist or artist['name'] not in self.artist['Main'] and not isMainArtist: 216 | if not artist['role'] in self.artist: 217 | self.artist[artist['role']] = [] 218 | self.artist[artist['role']].append(artist['name']) 219 | 220 | if trackAPI.get('alternative_albums'): 221 | for album in trackAPI['alternative_albums']['data']: 222 | if 'RIGHTS' in album and album['RIGHTS'].get('STREAM_ADS_AVAILABLE') or album['RIGHTS'].get('STREAM_SUB_AVAILABLE'): 223 | self.albumsFallback.append(album['ALB_ID']) 224 | 225 | def removeDuplicateArtists(self): 226 | (self.artist, self.artists) = removeDuplicateArtists(self.artist, self.artists) 227 | 228 | # Removes featuring from the title 229 | def getCleanTitle(self): 230 | return removeFeatures(self.title) 231 | 232 | def getFeatTitle(self): 233 | if self.featArtistsString and "feat." not in self.title.lower(): 234 | return f"{self.title} ({self.featArtistsString})" 235 | return self.title 236 | 237 | def generateMainFeatStrings(self): 238 | self.mainArtistsString = andCommaConcat(self.artist['Main']) 239 | self.featArtistsString = "" 240 | if 'Featured' in self.artist: 241 | self.featArtistsString = "feat. "+andCommaConcat(self.artist['Featured']) 242 | 243 | def checkAndRenewTrackToken(self, dz): 244 | now = datetime.now() 245 | expiration = datetime.fromtimestamp(self.trackTokenExpiration) 246 | if now > expiration: 247 | newTrack = dz.gw.get_track_with_fallback(self.id) 248 | self.trackToken = newTrack['TRACK_TOKEN'] 249 | self.trackTokenExpiration = newTrack['TRACK_TOKEN_EXPIRE'] 250 | 251 | def applySettings(self, settings): 252 | 253 | # Check if should save the playlist as a compilation 254 | if self.playlist and settings['tags']['savePlaylistAsCompilation']: 255 | self.trackNumber = self.position 256 | self.discNumber = "1" 257 | self.album.makePlaylistCompilation(self.playlist) 258 | else: 259 | if self.album.date: self.date = self.album.date 260 | 261 | self.dateString = self.date.format(settings['dateFormat']) 262 | self.album.dateString = self.album.date.format(settings['dateFormat']) 263 | if self.playlist: self.playlist.dateString = self.playlist.date.format(settings['dateFormat']) 264 | 265 | # Check various artist option 266 | if settings['albumVariousArtists'] and self.album.variousArtists: 267 | artist = self.album.variousArtists 268 | isMainArtist = artist.role == "Main" 269 | 270 | if artist.name not in self.album.artists: 271 | self.album.artists.insert(0, artist.name) 272 | 273 | if isMainArtist or artist.name not in self.album.artist['Main'] and not isMainArtist: 274 | if artist.role not in self.album.artist: 275 | self.album.artist[artist.role] = [] 276 | self.album.artist[artist.role].insert(0, artist.name) 277 | self.album.mainArtist.save = not self.album.mainArtist.isVariousArtists() or settings['albumVariousArtists'] and self.album.mainArtist.isVariousArtists() 278 | 279 | # Check removeDuplicateArtists 280 | if settings['removeDuplicateArtists']: self.removeDuplicateArtists() 281 | 282 | # Check if user wants the feat in the title 283 | if str(settings['featuredToTitle']) == FeaturesOption.REMOVE_TITLE: 284 | self.title = self.getCleanTitle() 285 | elif str(settings['featuredToTitle']) == FeaturesOption.MOVE_TITLE: 286 | self.title = self.getFeatTitle() 287 | elif str(settings['featuredToTitle']) == FeaturesOption.REMOVE_TITLE_ALBUM: 288 | self.title = self.getCleanTitle() 289 | self.album.title = self.album.getCleanTitle() 290 | 291 | # Remove (Album Version) from tracks that have that 292 | if settings['removeAlbumVersion'] and "Album Version" in self.title: 293 | self.title = re.sub(r' ?\(Album Version\)', "", self.title).strip() 294 | 295 | # Change Title and Artists casing if needed 296 | if settings['titleCasing'] != "nothing": 297 | self.title = changeCase(self.title, settings['titleCasing']) 298 | if settings['artistCasing'] != "nothing": 299 | self.mainArtist.name = changeCase(self.mainArtist.name, settings['artistCasing']) 300 | for i, artist in enumerate(self.artists): 301 | self.artists[i] = changeCase(artist, settings['artistCasing']) 302 | for art_type in self.artist: 303 | for i, artist in enumerate(self.artist[art_type]): 304 | self.artist[art_type][i] = changeCase(artist, settings['artistCasing']) 305 | self.generateMainFeatStrings() 306 | 307 | # Generate artist tag 308 | if settings['tags']['multiArtistSeparator'] == "default": 309 | if str(settings['featuredToTitle']) == FeaturesOption.MOVE_TITLE: 310 | self.artistsString = ", ".join(self.artist['Main']) 311 | else: 312 | self.artistsString = ", ".join(self.artists) 313 | elif settings['tags']['multiArtistSeparator'] == "andFeat": 314 | self.artistsString = self.mainArtistsString 315 | if self.featArtistsString and str(settings['featuredToTitle']) != FeaturesOption.MOVE_TITLE: 316 | self.artistsString += " " + self.featArtistsString 317 | else: 318 | separator = settings['tags']['multiArtistSeparator'] 319 | if str(settings['featuredToTitle']) == FeaturesOption.MOVE_TITLE: 320 | self.artistsString = separator.join(self.artist['Main']) 321 | else: 322 | self.artistsString = separator.join(self.artists) 323 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/types/__init__.py: -------------------------------------------------------------------------------- 1 | VARIOUS_ARTISTS = "5080" 2 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import string 2 | import re 3 | from deezer import TrackFormats 4 | import os 5 | from deemix.errors import ErrorMessages 6 | 7 | USER_AGENT_HEADER = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " \ 8 | "Chrome/79.0.3945.130 Safari/537.36" 9 | 10 | def canWrite(folder): 11 | return os.access(folder, os.W_OK) 12 | 13 | def generateReplayGainString(trackGain): 14 | return "{0:.2f} dB".format((float(trackGain) + 18.4) * -1) 15 | 16 | def getBitrateNumberFromText(txt): 17 | txt = str(txt).lower() 18 | if txt in ['flac', 'lossless', '9']: 19 | return TrackFormats.FLAC 20 | if txt in ['mp3', '320', '3']: 21 | return TrackFormats.MP3_320 22 | if txt in ['128', '1']: 23 | return TrackFormats.MP3_128 24 | if txt in ['360', '360_hq', '15']: 25 | return TrackFormats.MP4_RA3 26 | if txt in ['360_mq', '14']: 27 | return TrackFormats.MP4_RA2 28 | if txt in ['360_lq', '13']: 29 | return TrackFormats.MP4_RA1 30 | return None 31 | 32 | def changeCase(txt, case_type): 33 | if case_type == "lower": 34 | return txt.lower() 35 | if case_type == "upper": 36 | return txt.upper() 37 | if case_type == "start": 38 | txt = txt.strip().split(" ") 39 | for i, word in enumerate(txt): 40 | if word[0] in ['(', '{', '[', "'", '"']: 41 | txt[i] = word[0] + word[1:].capitalize() 42 | else: 43 | txt[i] = word.capitalize() 44 | return " ".join(txt) 45 | if case_type == "sentence": 46 | return txt.capitalize() 47 | return str 48 | 49 | def removeFeatures(title): 50 | clean = title 51 | found = False 52 | pos = -1 53 | if re.search(r"[\s(]\(?\s?feat\.?\s", clean): 54 | pos = re.search(r"[\s(]\(?\s?feat\.?\s", clean).start(0) 55 | found = True 56 | if re.search(r"[\s(]\(?\s?ft\.?\s", clean): 57 | pos = re.search(r"[\s(]\(?\s?ft\.?\s", clean).start(0) 58 | found = True 59 | openBracket = clean[pos] == '(' or clean[pos+1] == '(' 60 | otherBracket = clean.find('(', pos+2) 61 | if found: 62 | tempTrack = clean[:pos] 63 | if ")" in clean and openBracket: 64 | tempTrack += clean[clean.find(")", pos+2) + 1:] 65 | if not openBracket and otherBracket != -1: 66 | tempTrack += f" {clean[otherBracket:]}" 67 | clean = tempTrack.strip() 68 | clean = ' '.join(clean.split()) 69 | return clean 70 | 71 | def andCommaConcat(lst): 72 | tot = len(lst) 73 | result = "" 74 | for i, art in enumerate(lst): 75 | result += art 76 | if tot != i + 1: 77 | if tot - 1 == i + 1: 78 | result += " & " 79 | else: 80 | result += ", " 81 | return result 82 | 83 | def uniqueArray(arr): 84 | for iPrinc, namePrinc in enumerate(arr): 85 | for iRest, nRest in enumerate(arr): 86 | if iPrinc!=iRest and namePrinc.lower() in nRest.lower(): 87 | del arr[iRest] 88 | return arr 89 | 90 | def removeDuplicateArtists(artist, artists): 91 | artists = uniqueArray(artists) 92 | for role in artist.keys(): 93 | artist[role] = uniqueArray(artist[role]) 94 | return (artist, artists) 95 | 96 | def formatListener(key, data=None): 97 | if key == "startAddingArtist": 98 | return f"Started gathering {data['name']}'s albums ({data['id']})" 99 | if key == "finishAddingArtist": 100 | return f"Finished gathering {data['name']}'s albums ({data['id']})" 101 | if key == "updateQueue": 102 | uuid = f"[{data['uuid']}]" 103 | if data.get('downloaded'): 104 | shortFilepath = data['downloadPath'][len(data['extrasPath']):] 105 | return f"{uuid} Completed download of {shortFilepath}" 106 | if data.get('failed'): 107 | return f"{uuid} {data['data']['artist']} - {data['data']['title']} :: {data['error']}" 108 | if data.get('progress'): 109 | return f"{uuid} Download at {data['progress']}%" 110 | if data.get('conversion'): 111 | return f"{uuid} Conversion at {data['conversion']}%" 112 | return uuid 113 | if key == "downloadInfo": 114 | message = data['state'] 115 | if data['state'] == "getTags": message = "Getting tags." 116 | elif data['state'] == "gotTags": message = "Tags got." 117 | elif data['state'] == "getBitrate": message = "Getting download URL." 118 | elif data['state'] == "bitrateFallback": message = "Desired bitrate not found, falling back to lower bitrate." 119 | elif data['state'] == "searchFallback": message = "This track has been searched for, result might not be 100% exact." 120 | elif data['state'] == "gotBitrate": message = "Download URL got." 121 | elif data['state'] == "getAlbumArt": message = "Downloading album art." 122 | elif data['state'] == "gotAlbumArt": message = "Album art downloaded." 123 | elif data['state'] == "downloading": 124 | message = "Downloading track." 125 | if data['alreadyStarted']: 126 | message += f" Recovering download from {data['value']}." 127 | else: 128 | message += f" Downloading {data['value']} bytes." 129 | elif data['state'] == "downloaded": message = "Track downloaded." 130 | elif data['state'] == "alreadyDownloaded": message = "Track already downloaded." 131 | elif data['state'] == "tagging": message = "Tagging track." 132 | elif data['state'] == "tagged": message = "Track tagged." 133 | return f"[{data['uuid']}] {data['data']['artist']} - {data['data']['title']} :: {message}" 134 | if key == "downloadWarn": 135 | errorMessage = ErrorMessages[data['state']] 136 | solutionMessage = "" 137 | if data['solution'] == 'fallback': solutionMessage = "Using fallback id." 138 | if data['solution'] == 'search': solutionMessage = "Searching for alternative." 139 | return f"[{data['uuid']}] {data['data']['artist']} - {data['data']['title']} :: {errorMessage} {solutionMessage}" 140 | if key == "currentItemCancelled": 141 | return f"Current item cancelled ({data})" 142 | if key == "removedFromQueue": 143 | return f"[{data}] Removed from the queue" 144 | if key == "finishDownload": 145 | return f"[{data}] Finished downloading" 146 | if key == "startConversion": 147 | return f"[{data}] Started converting" 148 | if key == "finishConversion": 149 | return f"[{data['uuid']}] Finished converting" 150 | return "" 151 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/utils/crypto.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | 3 | from Cryptodome.Cipher import Blowfish, AES 4 | from Cryptodome.Hash import MD5 5 | 6 | def _md5(data): 7 | h = MD5.new() 8 | h.update(data.encode() if isinstance(data, str) else data) 9 | return h.hexdigest() 10 | 11 | def _ecbCrypt(key, data): 12 | return binascii.hexlify(AES.new(key.encode(), AES.MODE_ECB).encrypt(data)) 13 | 14 | def _ecbDecrypt(key, data): 15 | return AES.new(key.encode(), AES.MODE_ECB).decrypt(binascii.unhexlify(data.encode("utf-8"))) 16 | 17 | def generateBlowfishKey(trackId): 18 | SECRET = 'g4el58wc0zvf9na1' 19 | idMd5 = _md5(trackId) 20 | bfKey = "" 21 | for i in range(16): 22 | bfKey += chr(ord(idMd5[i]) ^ ord(idMd5[i + 16]) ^ ord(SECRET[i])) 23 | return str.encode(bfKey) 24 | 25 | def decryptChunk(key, data): 26 | return Blowfish.new(key, Blowfish.MODE_CBC, b"\x00\x01\x02\x03\x04\x05\x06\x07").decrypt(data) 27 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/utils/deezer.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from deemix.utils.crypto import _md5 3 | from deemix.utils import USER_AGENT_HEADER 4 | CLIENT_ID = "172365" 5 | CLIENT_SECRET = "fb0bec7ccc063dab0417eb7b0d847f34" 6 | 7 | def getAccessToken(email, password): 8 | accessToken = None 9 | password = _md5(password) 10 | request_hash = _md5(''.join([CLIENT_ID, email, password, CLIENT_SECRET])) 11 | try: 12 | response = requests.get( 13 | 'https://api.deezer.com/auth/token', 14 | params={ 15 | 'app_id': CLIENT_ID, 16 | 'login': email, 17 | 'password': password, 18 | 'hash': request_hash 19 | }, 20 | headers={"User-Agent": USER_AGENT_HEADER} 21 | ).json() 22 | accessToken = response.get('access_token') 23 | if accessToken == "undefined": accessToken = None 24 | except Exception: 25 | pass 26 | return accessToken 27 | 28 | def getArlFromAccessToken(accessToken): 29 | if not accessToken: return None 30 | arl = None 31 | session = requests.Session() 32 | try: 33 | session.get( 34 | "https://api.deezer.com/platform/generic/track/3135556", 35 | headers={"Authorization": f"Bearer {accessToken}", "User-Agent": USER_AGENT_HEADER} 36 | ) 37 | response = session.get( 38 | 'https://www.deezer.com/ajax/gw-light.php?method=user.getArl&input=3&api_version=1.0&api_token=null', 39 | headers={"User-Agent": USER_AGENT_HEADER} 40 | ).json() 41 | arl = response.get('results') 42 | except Exception: 43 | pass 44 | return arl 45 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/utils/localpaths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import sys 3 | import os 4 | import re 5 | from deemix.utils import canWrite 6 | 7 | homedata = Path.home() 8 | userdata = "" 9 | musicdata = "" 10 | 11 | def checkPath(path): 12 | if path == "": return "" 13 | if not path.is_dir(): return "" 14 | if not canWrite(path): return "" 15 | return path 16 | 17 | def getConfigFolder(): 18 | global userdata 19 | if userdata != "": return userdata 20 | if os.getenv("XDG_CONFIG_HOME") and userdata == "": 21 | userdata = Path(os.getenv("XDG_CONFIG_HOME")) 22 | userdata = checkPath(userdata) 23 | if os.getenv("APPDATA") and userdata == "": 24 | userdata = Path(os.getenv("APPDATA")) 25 | userdata = checkPath(userdata) 26 | if sys.platform.startswith('darwin') and userdata == "": 27 | userdata = homedata / 'Library' / 'Application Support' 28 | userdata = checkPath(userdata) 29 | if userdata == "": 30 | userdata = homedata / '.config' 31 | userdata = checkPath(userdata) 32 | 33 | if userdata == "": userdata = Path(os.getcwd()) / 'config' 34 | else: userdata = userdata / 'deemix' 35 | 36 | if os.getenv("DEEMIX_DATA_DIR"): 37 | userdata = Path(os.getenv("DEEMIX_DATA_DIR")) 38 | return userdata 39 | 40 | def getMusicFolder(): 41 | global musicdata 42 | if musicdata != "": return musicdata 43 | if os.getenv("XDG_MUSIC_DIR") and musicdata == "": 44 | musicdata = Path(os.getenv("XDG_MUSIC_DIR")) 45 | musicdata = checkPath(musicdata) 46 | if (homedata / '.config' / 'user-dirs.dirs').is_file() and musicdata == "": 47 | with open(homedata / '.config' / 'user-dirs.dirs', 'r', encoding="utf-8") as f: 48 | userDirs = f.read() 49 | musicdata_search = re.search(r"XDG_MUSIC_DIR=\"(.*)\"", userDirs) 50 | if musicdata_search: 51 | musicdata = musicdata_search.group(1) 52 | musicdata = Path(os.path.expandvars(musicdata)) 53 | musicdata = checkPath(musicdata) 54 | if os.name == 'nt' and musicdata == "": 55 | try: 56 | musicKeys = ['My Music', '{4BD8D571-6D19-48D3-BE97-422220080E43}'] 57 | regData = os.popen(r'reg.exe query "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"').read().split('\r\n') 58 | for i, line in enumerate(regData): 59 | if line == "": continue 60 | if i == 1: continue 61 | line = line.split(' ') 62 | if line[1] in musicKeys: 63 | musicdata = Path(line[3]) 64 | break 65 | musicdata = checkPath(musicdata) 66 | except Exception: 67 | musicdata = "" 68 | if musicdata == "": 69 | musicdata = homedata / 'Music' 70 | musicdata = checkPath(musicdata) 71 | 72 | if musicdata == "": musicdata = Path(os.getcwd()) / 'music' 73 | else: musicdata = musicdata / 'deemix Music' 74 | 75 | if os.getenv("DEEMIX_MUSIC_DIR"): 76 | musicdata = Path(os.getenv("DEEMIX_MUSIC_DIR")) 77 | return musicdata 78 | -------------------------------------------------------------------------------- /local_packages/deemix/build/lib/deemix/utils/pathtemplates.py: -------------------------------------------------------------------------------- 1 | import re 2 | from os.path import sep as pathSep 3 | from pathlib import Path 4 | from unicodedata import normalize 5 | from deezer import TrackFormats 6 | 7 | bitrateLabels = { 8 | TrackFormats.MP4_RA3: "360 HQ", 9 | TrackFormats.MP4_RA2: "360 MQ", 10 | TrackFormats.MP4_RA1: "360 LQ", 11 | TrackFormats.FLAC : "FLAC", 12 | TrackFormats.MP3_320: "320", 13 | TrackFormats.MP3_128: "128", 14 | TrackFormats.DEFAULT: "128", 15 | TrackFormats.LOCAL : "MP3" 16 | } 17 | 18 | def fixName(txt, char='_'): 19 | txt = str(txt) 20 | txt = re.sub(r'[\0\/\\:*?"<>|]', char, txt) 21 | txt = normalize("NFC", txt) 22 | return txt 23 | 24 | def fixLongName(name): 25 | def fixEndOfData(bString): 26 | try: 27 | bString.decode() 28 | return True 29 | except Exception: 30 | return False 31 | if pathSep in name: 32 | sepName = name.split(pathSep) 33 | name = "" 34 | for txt in sepName: 35 | txt = fixLongName(txt) 36 | name += txt + pathSep 37 | name = name[:-1] 38 | else: 39 | name = name.encode('utf-8')[:200] 40 | while not fixEndOfData(name): 41 | name = name[:-1] 42 | name = name.decode() 43 | return name 44 | 45 | 46 | def antiDot(string): 47 | while string[-1:] == "." or string[-1:] == " " or string[-1:] == "\n": 48 | string = string[:-1] 49 | if len(string) < 1: 50 | string = "dot" 51 | return string 52 | 53 | 54 | def pad(num, max_val, settings): 55 | if int(settings['paddingSize']) == 0: 56 | paddingSize = len(str(max_val)) 57 | else: 58 | paddingSize = len(str(10 ** (int(settings['paddingSize']) - 1))) 59 | if paddingSize == 1: 60 | paddingSize = 2 61 | if settings['padTracks']: 62 | return str(num).zfill(paddingSize) 63 | return str(num) 64 | 65 | def generatePath(track, downloadObject, settings): 66 | filenameTemplate = "%artist% - %title%" 67 | singleTrack = False 68 | if downloadObject.type == "track": 69 | if settings['createSingleFolder']: 70 | filenameTemplate = settings['albumTracknameTemplate'] 71 | else: 72 | filenameTemplate = settings['tracknameTemplate'] 73 | singleTrack = True 74 | elif downloadObject.type == "album": 75 | filenameTemplate = settings['albumTracknameTemplate'] 76 | else: 77 | filenameTemplate = settings['playlistTracknameTemplate'] 78 | 79 | filename = generateTrackName(filenameTemplate, track, settings) 80 | 81 | filepath = Path(settings['downloadLocation'] or '.') 82 | artistPath = None 83 | coverPath = None 84 | extrasPath = None 85 | 86 | if settings['createPlaylistFolder'] and track.playlist and not settings['tags']['savePlaylistAsCompilation']: 87 | filepath = filepath / generatePlaylistName(settings['playlistNameTemplate'], track.playlist, settings) 88 | 89 | if track.playlist and not settings['tags']['savePlaylistAsCompilation']: 90 | extrasPath = filepath 91 | 92 | if ( 93 | (settings['createArtistFolder'] and not track.playlist) or 94 | (settings['createArtistFolder'] and track.playlist and settings['tags']['savePlaylistAsCompilation']) or 95 | (settings['createArtistFolder'] and track.playlist and settings['createStructurePlaylist']) 96 | ): 97 | filepath = filepath / generateArtistName(settings['artistNameTemplate'], track.album.mainArtist, settings, rootArtist=track.album.rootArtist) 98 | artistPath = filepath 99 | 100 | if (settings['createAlbumFolder'] and 101 | (not singleTrack or (singleTrack and settings['createSingleFolder'])) and 102 | (not track.playlist or 103 | (track.playlist and settings['tags']['savePlaylistAsCompilation']) or 104 | (track.playlist and settings['createStructurePlaylist']) 105 | ) 106 | ): 107 | filepath = filepath / generateAlbumName(settings['albumNameTemplate'], track.album, settings, track.playlist) 108 | coverPath = filepath 109 | 110 | if not extrasPath: extrasPath = filepath 111 | 112 | if ( 113 | int(track.album.discTotal) > 1 and ( 114 | (settings['createAlbumFolder'] and settings['createCDFolder']) and 115 | (not singleTrack or (singleTrack and settings['createSingleFolder'])) and 116 | (not track.playlist or 117 | (track.playlist and settings['tags']['savePlaylistAsCompilation']) or 118 | (track.playlist and settings['createStructurePlaylist']) 119 | ) 120 | )): 121 | filepath = filepath / f'CD{track.discNumber}' 122 | 123 | # Remove subfolders from filename and add it to filepath 124 | if pathSep in filename: 125 | tempPath = filename[:filename.rfind(pathSep)] 126 | filepath = filepath / tempPath 127 | filename = filename[filename.rfind(pathSep) + len(pathSep):] 128 | 129 | return (filename, filepath, artistPath, coverPath, extrasPath) 130 | 131 | 132 | def generateTrackName(filename, track, settings): 133 | c = settings['illegalCharacterReplacer'] 134 | filename = filename.replace("%title%", fixName(track.title, c)) 135 | filename = filename.replace("%artist%", fixName(track.mainArtist.name, c)) 136 | filename = filename.replace("%artists%", fixName(", ".join(track.artists), c)) 137 | filename = filename.replace("%allartists%", fixName(track.artistsString, c)) 138 | filename = filename.replace("%mainartists%", fixName(track.mainArtistsString, c)) 139 | if track.featArtistsString: 140 | filename = filename.replace("%featartists%", fixName('('+track.featArtistsString+')', c)) 141 | else: 142 | filename = filename.replace("%featartists%", '') 143 | filename = filename.replace("%album%", fixName(track.album.title, c)) 144 | filename = filename.replace("%albumartist%", fixName(track.album.mainArtist.name, c)) 145 | filename = filename.replace("%tracknumber%", pad(track.trackNumber, track.album.trackTotal, settings)) 146 | filename = filename.replace("%tracktotal%", str(track.album.trackTotal)) 147 | filename = filename.replace("%discnumber%", str(track.discNumber)) 148 | filename = filename.replace("%disctotal%", str(track.album.discTotal)) 149 | if len(track.album.genre) > 0: 150 | filename = filename.replace("%genre%", fixName(track.album.genre[0], c)) 151 | else: 152 | filename = filename.replace("%genre%", "Unknown") 153 | filename = filename.replace("%year%", str(track.date.year)) 154 | filename = filename.replace("%date%", track.dateString) 155 | filename = filename.replace("%bpm%", str(track.bpm)) 156 | filename = filename.replace("%label%", fixName(track.album.label, c)) 157 | filename = filename.replace("%isrc%", track.ISRC) 158 | filename = filename.replace("%upc%", str(track.album.barcode)) 159 | filename = filename.replace("%explicit%", "(Explicit)" if track.explicit else "") 160 | 161 | filename = filename.replace("%track_id%", str(track.id)) 162 | filename = filename.replace("%album_id%", str(track.album.id)) 163 | filename = filename.replace("%artist_id%", str(track.mainArtist.id)) 164 | if track.playlist: 165 | filename = filename.replace("%playlist_id%", str(track.playlist.playlistID)) 166 | filename = filename.replace("%position%", pad(track.position, track.playlist.trackTotal, settings)) 167 | else: 168 | filename = filename.replace("%playlist_id%", '') 169 | filename = filename.replace("%position%", pad(track.position, track.album.trackTotal, settings)) 170 | filename = filename.replace('\\', pathSep).replace('/', pathSep) 171 | return antiDot(fixLongName(filename)) 172 | 173 | 174 | def generateAlbumName(foldername, album, settings, playlist=None): 175 | c = settings['illegalCharacterReplacer'] 176 | if playlist and settings['tags']['savePlaylistAsCompilation']: 177 | foldername = foldername.replace("%album_id%", "pl_" + str(playlist.playlistID)) 178 | foldername = foldername.replace("%genre%", "Compile") 179 | else: 180 | foldername = foldername.replace("%album_id%", str(album.id)) 181 | if len(album.genre) > 0: 182 | foldername = foldername.replace("%genre%", fixName(album.genre[0], c)) 183 | else: 184 | foldername = foldername.replace("%genre%", "Unknown") 185 | foldername = foldername.replace("%album%", fixName(album.title, c)) 186 | foldername = foldername.replace("%artist%", fixName(album.mainArtist.name, c)) 187 | foldername = foldername.replace("%artist_id%", str(album.mainArtist.id)) 188 | if album.rootArtist: 189 | foldername = foldername.replace("%root_artist%", fixName(album.rootArtist.name, c)) 190 | foldername = foldername.replace("%root_artist_id%", str(album.rootArtist.id)) 191 | else: 192 | foldername = foldername.replace("%root_artist%", fixName(album.mainArtist.name, c)) 193 | foldername = foldername.replace("%root_artist_id%", str(album.mainArtist.id)) 194 | foldername = foldername.replace("%tracktotal%", str(album.trackTotal)) 195 | foldername = foldername.replace("%disctotal%", str(album.discTotal)) 196 | foldername = foldername.replace("%type%", fixName(album.recordType.capitalize(), c)) 197 | foldername = foldername.replace("%upc%", album.barcode) 198 | foldername = foldername.replace("%explicit%", "(Explicit)" if album.explicit else "") 199 | foldername = foldername.replace("%label%", fixName(album.label, c)) 200 | foldername = foldername.replace("%year%", str(album.date.year)) 201 | foldername = foldername.replace("%date%", album.dateString) 202 | foldername = foldername.replace("%bitrate%", bitrateLabels[int(album.bitrate)]) 203 | 204 | foldername = foldername.replace('\\', pathSep).replace('/', pathSep) 205 | return antiDot(fixLongName(foldername)) 206 | 207 | 208 | def generateArtistName(foldername, artist, settings, rootArtist=None): 209 | c = settings['illegalCharacterReplacer'] 210 | foldername = foldername.replace("%artist%", fixName(artist.name, c)) 211 | foldername = foldername.replace("%artist_id%", str(artist.id)) 212 | if rootArtist: 213 | foldername = foldername.replace("%root_artist%", fixName(rootArtist.name, c)) 214 | foldername = foldername.replace("%root_artist_id%", str(rootArtist.id)) 215 | else: 216 | foldername = foldername.replace("%root_artist%", fixName(artist.name, c)) 217 | foldername = foldername.replace("%root_artist_id%", str(artist.id)) 218 | foldername = foldername.replace('\\', pathSep).replace('/', pathSep) 219 | return antiDot(fixLongName(foldername)) 220 | 221 | 222 | def generatePlaylistName(foldername, playlist, settings): 223 | c = settings['illegalCharacterReplacer'] 224 | foldername = foldername.replace("%playlist%", fixName(playlist.title, c)) 225 | foldername = foldername.replace("%playlist_id%", fixName(playlist.playlistID, c)) 226 | foldername = foldername.replace("%owner%", fixName(playlist.owner['name'], c)) 227 | foldername = foldername.replace("%owner_id%", str(playlist.owner['id'])) 228 | foldername = foldername.replace("%year%", str(playlist.date.year)) 229 | foldername = foldername.replace("%date%", str(playlist.dateString)) 230 | foldername = foldername.replace("%explicit%", "(Explicit)" if playlist.explicit else "") 231 | foldername = foldername.replace('\\', pathSep).replace('/', pathSep) 232 | return antiDot(fixLongName(foldername)) 233 | 234 | def generateDownloadObjectName(foldername, queueItem, settings): 235 | c = settings['illegalCharacterReplacer'] 236 | foldername = foldername.replace("%title%", fixName(queueItem.title, c)) 237 | foldername = foldername.replace("%artist%", fixName(queueItem.artist, c)) 238 | foldername = foldername.replace("%size%", str(queueItem.size)) 239 | foldername = foldername.replace("%type%", fixName(queueItem.type, c)) 240 | foldername = foldername.replace("%id%", fixName(queueItem.id, c)) 241 | foldername = foldername.replace("%bitrate%", bitrateLabels[int(queueItem.bitrate)]) 242 | foldername = foldername.replace('\\', pathSep).replace('/', pathSep).replace(pathSep, c) 243 | return antiDot(fixLongName(foldername)) 244 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.1 2 | Name: deemix 3 | Version: 3.6.6 4 | Summary: A barebone deezer downloader library 5 | Author: RemixDev 6 | Author-email: RemixDev64@gmail.com 7 | License: GPL3 8 | Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) 9 | Classifier: Programming Language :: Python :: 3 :: Only 10 | Classifier: Programming Language :: Python :: 3.7 11 | Classifier: Operating System :: OS Independent 12 | Requires-Python: >=3.7 13 | Description-Content-Type: text/markdown 14 | License-File: LICENSE.txt 15 | Requires-Dist: click 16 | Requires-Dist: pycryptodomex 17 | Requires-Dist: mutagen 18 | Requires-Dist: requests 19 | Requires-Dist: deezer-py>=1.3.0 20 | Provides-Extra: spotify 21 | Requires-Dist: spotipy>=2.11.0; extra == "spotify" 22 | 23 | # ![](./icon.svg) deemix 24 | ## What is deemix? 25 | deemix is a deezer downloader built from the ashes of Deezloader Remix. The base library (or core) can be used as a stand alone CLI app or implemented in an UI using the API. 26 | 27 | ## Installation 28 | NOTE: If `python3` is "not a recognized command" try using `python` instead.
29 |
30 | ### From PyPi 31 | You can install the library by using `pip`:
32 | `python3 -m pip install deemix`
33 | If you install it this way you can use the deemix CLI by using `deemix` directly in your terminal instead of `python3 -m deemix` 34 | 35 | ### Building from source 36 | After installing Python open a terminal/command prompt and install the dependencies using `python3 -m pip install -r requirements.txt --user`
37 | Run `python3 -m deemix --help` to see how to use the app in CLI mode.
38 | 39 | ## What's left to do? 40 | - Write the API Documentation 41 | - Fix whatever is broken 42 | 43 | # License 44 | This program is free software: you can redistribute it and/or modify 45 | it under the terms of the GNU General Public License as published by 46 | the Free Software Foundation, either version 3 of the License, or 47 | (at your option) any later version. 48 | 49 | This program is distributed in the hope that it will be useful, 50 | but WITHOUT ANY WARRANTY; without even the implied warranty of 51 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 52 | GNU General Public License for more details. 53 | 54 | You should have received a copy of the GNU General Public License 55 | along with this program. If not, see . 56 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix.egg-info/SOURCES.txt: -------------------------------------------------------------------------------- 1 | LICENSE.txt 2 | README.md 3 | setup.py 4 | deemix/__init__.py 5 | deemix/__main__.py 6 | deemix/decryption.py 7 | deemix/downloader.py 8 | deemix/errors.py 9 | deemix/itemgen.py 10 | deemix/settings.py 11 | deemix/tagger.py 12 | deemix.egg-info/PKG-INFO 13 | deemix.egg-info/SOURCES.txt 14 | deemix.egg-info/dependency_links.txt 15 | deemix.egg-info/entry_points.txt 16 | deemix.egg-info/requires.txt 17 | deemix.egg-info/top_level.txt 18 | deemix/plugins/__init__.py 19 | deemix/plugins/spotify.py 20 | deemix/types/Album.py 21 | deemix/types/Artist.py 22 | deemix/types/Date.py 23 | deemix/types/DownloadObjects.py 24 | deemix/types/Lyrics.py 25 | deemix/types/Picture.py 26 | deemix/types/Playlist.py 27 | deemix/types/Track.py 28 | deemix/types/__init__.py 29 | deemix/utils/__init__.py 30 | deemix/utils/crypto.py 31 | deemix/utils/deezer.py 32 | deemix/utils/localpaths.py 33 | deemix/utils/pathtemplates.py -------------------------------------------------------------------------------- /local_packages/deemix/deemix.egg-info/dependency_links.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix.egg-info/entry_points.txt: -------------------------------------------------------------------------------- 1 | [console_scripts] 2 | deemix = deemix.__main__:download 3 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix.egg-info/requires.txt: -------------------------------------------------------------------------------- 1 | click 2 | pycryptodomex 3 | mutagen 4 | requests 5 | deezer-py>=1.3.0 6 | 7 | [spotify] 8 | spotipy>=2.11.0 9 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix.egg-info/top_level.txt: -------------------------------------------------------------------------------- 1 | deemix 2 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | from urllib.request import urlopen 4 | 5 | from deemix.itemgen import generateTrackItem, \ 6 | generateAlbumItem, \ 7 | generatePlaylistItem, \ 8 | generateArtistItem, \ 9 | generateArtistDiscographyItem, \ 10 | generateArtistTopItem 11 | from deemix.errors import LinkNotRecognized, LinkNotSupported 12 | 13 | __version__ = "3.6.6" 14 | 15 | # Returns the Resolved URL, the Type and the ID 16 | def parseLink(link): 17 | if 'deezer.page.link' in link: link = urlopen(link).url # Resolve URL shortner 18 | # Remove extra stuff 19 | if '?' in link: link = link[:link.find('?')] 20 | if '&' in link: link = link[:link.find('&')] 21 | if link.endswith('/'): link = link[:-1] # Remove last slash if present 22 | 23 | link_type = None 24 | link_id = None 25 | 26 | if not 'deezer' in link: return (link, link_type, link_id) # return if not a deezer link 27 | 28 | if '/track' in link: 29 | link_type = 'track' 30 | link_id = re.search(r"/track/(.+)", link).group(1) 31 | elif '/playlist' in link: 32 | link_type = 'playlist' 33 | link_id = re.search(r"/playlist/(\d+)", link).group(1) 34 | elif '/album' in link: 35 | link_type = 'album' 36 | link_id = re.search(r"/album/(.+)", link).group(1) 37 | elif re.search(r"/artist/(\d+)/top_track", link): 38 | link_type = 'artist_top' 39 | link_id = re.search(r"/artist/(\d+)/top_track", link).group(1) 40 | elif re.search(r"/artist/(\d+)/discography", link): 41 | link_type = 'artist_discography' 42 | link_id = re.search(r"/artist/(\d+)/discography", link).group(1) 43 | elif '/artist' in link: 44 | link_type = 'artist' 45 | link_id = re.search(r"/artist/(\d+)", link).group(1) 46 | 47 | return (link, link_type, link_id) 48 | 49 | def generateDownloadObject(dz, link, bitrate, plugins=None, listener=None): 50 | (link, link_type, link_id) = parseLink(link) 51 | 52 | if link_type is None or link_id is None: 53 | if plugins is None: plugins = {} 54 | plugin_names = plugins.keys() 55 | current_plugin = None 56 | item = None 57 | for plugin in plugin_names: 58 | current_plugin = plugins[plugin] 59 | item = current_plugin.generateDownloadObject(dz, link, bitrate, listener) 60 | if item: return item 61 | raise LinkNotRecognized(link) 62 | 63 | if link_type == "track": 64 | return generateTrackItem(dz, link_id, bitrate) 65 | if link_type == "album": 66 | return generateAlbumItem(dz, link_id, bitrate) 67 | if link_type == "playlist": 68 | return generatePlaylistItem(dz, link_id, bitrate) 69 | if link_type == "artist": 70 | return generateArtistItem(dz, link_id, bitrate, listener) 71 | if link_type == "artist_discography": 72 | return generateArtistDiscographyItem(dz, link_id, bitrate, listener) 73 | if link_type == "artist_top": 74 | return generateArtistTopItem(dz, link_id, bitrate) 75 | 76 | raise LinkNotSupported(link) 77 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import click 3 | from pathlib import Path 4 | 5 | from deezer import Deezer 6 | from deezer import TrackFormats 7 | 8 | from deemix import generateDownloadObject 9 | from deemix.settings import load as loadSettings 10 | from deemix.utils import getBitrateNumberFromText, formatListener 11 | import deemix.utils.localpaths as localpaths 12 | from deemix.downloader import Downloader 13 | from deemix.itemgen import GenerationError 14 | try: 15 | from deemix.plugins.spotify import Spotify 16 | except ImportError: 17 | Spotify = None 18 | 19 | class LogListener: 20 | @classmethod 21 | def send(cls, key, value=None): 22 | logString = formatListener(key, value) 23 | if logString: print(logString) 24 | 25 | 26 | @click.command() 27 | @click.option('--portable', is_flag=True, help='Creates the config folder in the same directory where the script is launched') 28 | @click.option('-b', '--bitrate', default=None, help='Overwrites the default bitrate selected') 29 | @click.option('-p', '--path', type=str, help='Downloads in the given folder') 30 | @click.argument('url', nargs=-1, required=True) 31 | def download(url, bitrate, portable, path): 32 | # Check for local configFolder 33 | localpath = Path('.') 34 | configFolder = localpath / 'config' if portable else localpaths.getConfigFolder() 35 | 36 | settings = loadSettings(configFolder) 37 | dz = Deezer() 38 | listener = LogListener() 39 | 40 | def requestValidArl(): 41 | while True: 42 | arl = input("Paste here your arl:") 43 | if dz.login_via_arl(arl.strip()): break 44 | return arl 45 | 46 | if (configFolder / '.arl').is_file(): 47 | with open(configFolder / '.arl', 'r', encoding="utf-8") as f: 48 | arl = f.readline().rstrip("\n").strip() 49 | if not dz.login_via_arl(arl): arl = requestValidArl() 50 | else: arl = requestValidArl() 51 | with open(configFolder / '.arl', 'w', encoding="utf-8") as f: 52 | f.write(arl) 53 | 54 | plugins = {} 55 | if Spotify: 56 | plugins = { 57 | "spotify": Spotify(configFolder=configFolder) 58 | } 59 | plugins["spotify"].setup() 60 | 61 | def downloadLinks(url, bitrate=None): 62 | if not bitrate: bitrate = settings.get("maxBitrate", TrackFormats.MP3_320) 63 | links = [] 64 | for link in url: 65 | if ';' in link: 66 | for l in link.split(";"): 67 | links.append(l) 68 | else: 69 | links.append(link) 70 | 71 | downloadObjects = [] 72 | 73 | for link in links: 74 | try: 75 | downloadObject = generateDownloadObject(dz, link, bitrate, plugins, listener) 76 | except GenerationError as e: 77 | print(f"{e.link}: {e.message}") 78 | continue 79 | if isinstance(downloadObject, list): 80 | downloadObjects += downloadObject 81 | else: 82 | downloadObjects.append(downloadObject) 83 | 84 | for obj in downloadObjects: 85 | if obj.__type__ == "Convertable": 86 | obj = plugins[obj.plugin].convert(dz, obj, settings, listener) 87 | Downloader(dz, obj, settings, listener).start() 88 | 89 | 90 | if path is not None: 91 | if path == '': path = '.' 92 | path = Path(path) 93 | settings['downloadLocation'] = str(path) 94 | url = list(url) 95 | if bitrate: bitrate = getBitrateNumberFromText(bitrate) 96 | 97 | # If first url is filepath readfile and use them as URLs 98 | try: 99 | isfile = Path(url[0]).is_file() 100 | except Exception: 101 | isfile = False 102 | if isfile: 103 | filename = url[0] 104 | with open(filename, encoding="utf-8") as f: 105 | url = f.readlines() 106 | 107 | downloadLinks(url, bitrate) 108 | click.echo("All done!") 109 | 110 | if __name__ == '__main__': 111 | download() # pylint: disable=E1120 112 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/decryption.py: -------------------------------------------------------------------------------- 1 | from ssl import SSLError 2 | from time import sleep 3 | import logging 4 | 5 | from requests import get 6 | from requests.exceptions import ConnectionError as RequestsConnectionError, ReadTimeout, ChunkedEncodingError 7 | from urllib3.exceptions import SSLError as u3SSLError 8 | 9 | from deemix.utils.crypto import _md5, _ecbCrypt, _ecbDecrypt, generateBlowfishKey, decryptChunk 10 | 11 | from deemix.utils import USER_AGENT_HEADER 12 | from deemix.types.DownloadObjects import Single 13 | from deemix.errors import DownloadCanceled, DownloadEmpty 14 | 15 | logger = logging.getLogger('deemix') 16 | 17 | def generateStreamPath(sng_id, md5, media_version, media_format): 18 | urlPart = b'\xa4'.join( 19 | [md5.encode(), str(media_format).encode(), str(sng_id).encode(), str(media_version).encode()]) 20 | md5val = _md5(urlPart) 21 | step2 = md5val.encode() + b'\xa4' + urlPart + b'\xa4' 22 | step2 = step2 + (b'.' * (16 - (len(step2) % 16))) 23 | urlPart = _ecbCrypt('jo6aey6haid2Teih', step2) 24 | return urlPart.decode("utf-8") 25 | 26 | def reverseStreamPath(urlPart): 27 | step2 = _ecbDecrypt('jo6aey6haid2Teih', urlPart) 28 | (_, md5, media_format, sng_id, media_version, _) = step2.split(b'\xa4') 29 | return (sng_id.decode('utf-8'), md5.decode('utf-8'), media_version.decode('utf-8'), media_format.decode('utf-8')) 30 | 31 | def generateCryptedStreamURL(sng_id, md5, media_version, media_format): 32 | urlPart = generateStreamPath(sng_id, md5, media_version, media_format) 33 | return "https://e-cdns-proxy-" + md5[0] + ".dzcdn.net/mobile/1/" + urlPart 34 | 35 | def generateStreamURL(sng_id, md5, media_version, media_format): 36 | urlPart = generateStreamPath(sng_id, md5, media_version, media_format) 37 | return "https://e-cdns-proxy-" + md5[0] + ".dzcdn.net/api/1/" + urlPart 38 | 39 | def reverseStreamURL(url): 40 | urlPart = url[url.find("/1/")+3:] 41 | return reverseStreamPath(urlPart) 42 | 43 | def streamTrack(outputStream, track, start=0, downloadObject=None, listener=None): 44 | if downloadObject and downloadObject.isCanceled: raise DownloadCanceled 45 | headers= {'User-Agent': USER_AGENT_HEADER} 46 | chunkLength = start 47 | isCryptedStream = "/mobile/" in track.downloadURL or "/media/" in track.downloadURL 48 | 49 | itemData = { 50 | 'id': track.id, 51 | 'title': track.title, 52 | 'artist': track.mainArtist.name 53 | } 54 | 55 | try: 56 | with get(track.downloadURL, headers=headers, stream=True, timeout=10) as request: 57 | request.raise_for_status() 58 | if isCryptedStream: 59 | blowfish_key = generateBlowfishKey(str(track.id)) 60 | 61 | complete = int(request.headers["Content-Length"]) 62 | if complete == 0: raise DownloadEmpty 63 | if start != 0: 64 | responseRange = request.headers["Content-Range"] 65 | if listener: 66 | listener.send('downloadInfo', { 67 | 'uuid': downloadObject.uuid, 68 | 'data': itemData, 69 | 'state': "downloading", 70 | 'alreadyStarted': True, 71 | 'value': responseRange 72 | }) 73 | else: 74 | if listener: 75 | listener.send('downloadInfo', { 76 | 'uuid': downloadObject.uuid, 77 | 'data': itemData, 78 | 'state': "downloading", 79 | 'alreadyStarted': False, 80 | 'value': complete 81 | }) 82 | 83 | isStart = True 84 | for chunk in request.iter_content(2048 * 3): 85 | if isCryptedStream: 86 | if len(chunk) >= 2048: 87 | chunk = decryptChunk(blowfish_key, chunk[0:2048]) + chunk[2048:] 88 | 89 | if isStart and chunk[0] == 0 and chunk[4:8].decode('utf-8') != "ftyp": 90 | for i, byte in enumerate(chunk): 91 | if byte != 0: break 92 | chunk = chunk[i:] 93 | isStart = False 94 | 95 | outputStream.write(chunk) 96 | chunkLength += len(chunk) 97 | 98 | if downloadObject: 99 | if isinstance(downloadObject, Single): 100 | chunkProgres = (chunkLength / (complete + start)) * 100 101 | downloadObject.progressNext = chunkProgres 102 | else: 103 | chunkProgres = (len(chunk) / (complete + start)) / downloadObject.size * 100 104 | downloadObject.progressNext += chunkProgres 105 | downloadObject.updateProgress(listener) 106 | 107 | except (SSLError, u3SSLError): 108 | streamTrack(outputStream, track, chunkLength, downloadObject, listener) 109 | except (RequestsConnectionError, ReadTimeout, ChunkedEncodingError): 110 | sleep(2) 111 | streamTrack(outputStream, track, start, downloadObject, listener) 112 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/errors.py: -------------------------------------------------------------------------------- 1 | class DeemixError(Exception): 2 | """Base exception for this module""" 3 | 4 | class GenerationError(DeemixError): 5 | """Generation related errors""" 6 | def __init__(self, link, message, errid=None): 7 | super().__init__() 8 | self.link = link 9 | self.message = message 10 | self.errid = errid 11 | 12 | def toDict(self): 13 | return { 14 | 'link': self.link, 15 | 'error': self.message, 16 | 'errid': self.errid 17 | } 18 | 19 | class ISRCnotOnDeezer(GenerationError): 20 | def __init__(self, link): 21 | super().__init__(link, "Track ISRC is not available on deezer", "ISRCnotOnDeezer") 22 | 23 | class NotYourPrivatePlaylist(GenerationError): 24 | def __init__(self, link): 25 | super().__init__(link, "You can't download others private playlists.", "notYourPrivatePlaylist") 26 | 27 | class TrackNotOnDeezer(GenerationError): 28 | def __init__(self, link): 29 | super().__init__(link, "Track not found on deezer!", "trackNotOnDeezer") 30 | 31 | class AlbumNotOnDeezer(GenerationError): 32 | def __init__(self, link): 33 | super().__init__(link, "Album not found on deezer!", "albumNotOnDeezer") 34 | 35 | class InvalidID(GenerationError): 36 | def __init__(self, link): 37 | super().__init__(link, "Link ID is invalid!", "invalidID") 38 | 39 | class LinkNotSupported(GenerationError): 40 | def __init__(self, link): 41 | super().__init__(link, "Link is not supported.", "unsupportedURL") 42 | 43 | class LinkNotRecognized(GenerationError): 44 | def __init__(self, link): 45 | super().__init__(link, "Link is not recognized.", "invalidURL") 46 | 47 | class DownloadError(DeemixError): 48 | """Download related errors""" 49 | 50 | ErrorMessages = { 51 | 'notOnDeezer': "Track not available on Deezer!", 52 | 'notEncoded': "Track not yet encoded!", 53 | 'notEncodedNoAlternative': "Track not yet encoded and no alternative found!", 54 | 'wrongBitrate': "Track not found at desired bitrate.", 55 | 'wrongBitrateNoAlternative': "Track not found at desired bitrate and no alternative found!", 56 | 'wrongLicense': "Your account can't stream the track at the desired bitrate.", 57 | 'no360RA': "Track is not available in Reality Audio 360.", 58 | 'notAvailable': "Track not available on deezer's servers!", 59 | 'notAvailableNoAlternative': "Track not available on deezer's servers and no alternative found!", 60 | 'noSpaceLeft': "No space left on target drive, clean up some space for the tracks", 61 | 'albumDoesntExists': "Track's album does not exsist, failed to gather info.", 62 | 'notLoggedIn': "You need to login to download tracks.", 63 | 'wrongGeolocation': "Your account can't stream the track from your current country.", 64 | 'wrongGeolocationNoAlternative': "Your account can't stream the track from your current country and no alternative found." 65 | } 66 | 67 | class DownloadFailed(DownloadError): 68 | def __init__(self, errid, track=None): 69 | super().__init__() 70 | self.errid = errid 71 | self.message = ErrorMessages[self.errid] 72 | self.track = track 73 | 74 | class PreferredBitrateNotFound(DownloadError): 75 | pass 76 | 77 | class TrackNot360(DownloadError): 78 | pass 79 | 80 | class DownloadCanceled(DownloadError): 81 | pass 82 | 83 | class DownloadEmpty(DownloadError): 84 | pass 85 | 86 | class TrackError(DeemixError): 87 | """Track generation related errors""" 88 | 89 | class AlbumDoesntExists(TrackError): 90 | pass 91 | 92 | class MD5NotFound(TrackError): 93 | pass 94 | 95 | class NoDataToParse(TrackError): 96 | pass 97 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/itemgen.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from deezer.errors import GWAPIError, APIError 4 | from deezer.utils import map_user_playlist, map_track, map_album 5 | 6 | from deemix.types.DownloadObjects import Single, Collection 7 | from deemix.errors import GenerationError, ISRCnotOnDeezer, InvalidID, NotYourPrivatePlaylist 8 | 9 | logger = logging.getLogger('deemix') 10 | 11 | def generateTrackItem(dz, link_id, bitrate, trackAPI=None, albumAPI=None): 12 | # Get essential track info 13 | if not trackAPI: 14 | if str(link_id).startswith("isrc") or int(link_id) > 0: 15 | try: 16 | trackAPI = dz.api.get_track(link_id) 17 | except APIError as e: 18 | raise GenerationError(f"https://deezer.com/track/{link_id}", str(e)) from e 19 | 20 | # Check if is an isrc: url 21 | if str(link_id).startswith("isrc"): 22 | if 'id' in trackAPI and 'title' in trackAPI: 23 | link_id = trackAPI['id'] 24 | else: 25 | raise ISRCnotOnDeezer(f"https://deezer.com/track/{link_id}") 26 | else: 27 | trackAPI_gw = dz.gw.get_track(link_id) 28 | trackAPI = map_track(trackAPI_gw) 29 | else: 30 | link_id = trackAPI['id'] 31 | if not str(link_id).strip('-').isdecimal(): raise InvalidID(f"https://deezer.com/track/{link_id}") 32 | 33 | cover = None 34 | if trackAPI['album']['cover_small']: 35 | cover = trackAPI['album']['cover_small'][:-24] + '/75x75-000000-80-0-0.jpg' 36 | else: 37 | cover = f"https://e-cdns-images.dzcdn.net/images/cover/{trackAPI['md5_image']}/75x75-000000-80-0-0.jpg" 38 | 39 | if 'track_token' in trackAPI: del trackAPI['track_token'] 40 | 41 | return Single({ 42 | 'type': 'track', 43 | 'id': link_id, 44 | 'bitrate': bitrate, 45 | 'title': trackAPI['title'], 46 | 'artist': trackAPI['artist']['name'], 47 | 'cover': cover, 48 | 'explicit': trackAPI['explicit_lyrics'], 49 | 'single': { 50 | 'trackAPI': trackAPI, 51 | 'albumAPI': albumAPI 52 | } 53 | }) 54 | 55 | def generateAlbumItem(dz, link_id, bitrate, rootArtist=None): 56 | # Get essential album info 57 | if str(link_id).startswith('upc'): 58 | upcs = [link_id[4:],] 59 | upcs.append(int(upcs[0])) 60 | lastError = None 61 | for upc in upcs: 62 | try: 63 | albumAPI = dz.api.get_album(f"upc:{upc}") 64 | except APIError as e: 65 | lastError = e 66 | albumAPI = None 67 | if not albumAPI: 68 | raise GenerationError(f"https://deezer.com/album/{link_id}", str(lastError)) from lastError 69 | link_id = albumAPI['id'] 70 | else: 71 | try: 72 | albumAPI_gw_page = dz.gw.get_album_page(link_id) 73 | if 'DATA' in albumAPI_gw_page: 74 | albumAPI = map_album(albumAPI_gw_page['DATA']) 75 | link_id = albumAPI_gw_page['DATA']['ALB_ID'] 76 | albumAPI_new = dz.api.get_album(link_id) 77 | albumAPI.update(albumAPI_new) 78 | else: 79 | raise GenerationError(f"https://deezer.com/album/{link_id}", "Can't find the album") 80 | except APIError as e: 81 | raise GenerationError(f"https://deezer.com/album/{link_id}", str(e)) from e 82 | 83 | if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/album/{link_id}") 84 | 85 | # Get extra info about album 86 | # This saves extra api calls when downloading 87 | albumAPI_gw = dz.gw.get_album(link_id) 88 | albumAPI_gw = map_album(albumAPI_gw) 89 | albumAPI_gw.update(albumAPI) 90 | albumAPI = albumAPI_gw 91 | albumAPI['root_artist'] = rootArtist 92 | 93 | # If the album is a single download as a track 94 | if albumAPI['nb_tracks'] == 1: 95 | if len(albumAPI['tracks']['data']): 96 | return generateTrackItem(dz, albumAPI['tracks']['data'][0]['id'], bitrate, albumAPI=albumAPI) 97 | raise GenerationError(f"https://deezer.com/album/{link_id}", "Single has no tracks.") 98 | 99 | tracksArray = dz.gw.get_album_tracks(link_id) 100 | 101 | if albumAPI['cover_small'] is not None: 102 | cover = albumAPI['cover_small'][:-24] + '/75x75-000000-80-0-0.jpg' 103 | else: 104 | cover = f"https://e-cdns-images.dzcdn.net/images/cover/{albumAPI['md5_image']}/75x75-000000-80-0-0.jpg" 105 | 106 | totalSize = len(tracksArray) 107 | albumAPI['nb_tracks'] = totalSize 108 | collection = [] 109 | for pos, trackAPI in enumerate(tracksArray, start=1): 110 | trackAPI = map_track(trackAPI) 111 | if 'track_token' in trackAPI: del trackAPI['track_token'] 112 | trackAPI['position'] = pos 113 | collection.append(trackAPI) 114 | 115 | return Collection({ 116 | 'type': 'album', 117 | 'id': link_id, 118 | 'bitrate': bitrate, 119 | 'title': albumAPI['title'], 120 | 'artist': albumAPI['artist']['name'], 121 | 'cover': cover, 122 | 'explicit': albumAPI['explicit_lyrics'], 123 | 'size': totalSize, 124 | 'collection': { 125 | 'tracks': collection, 126 | 'albumAPI': albumAPI 127 | } 128 | }) 129 | 130 | def generatePlaylistItem(dz, link_id, bitrate, playlistAPI=None, playlistTracksAPI=None): 131 | if not playlistAPI: 132 | if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/playlist/{link_id}") 133 | # Get essential playlist info 134 | try: 135 | playlistAPI = dz.api.get_playlist(link_id) 136 | except APIError: 137 | playlistAPI = None 138 | # Fallback to gw api if the playlist is private 139 | if not playlistAPI: 140 | try: 141 | userPlaylist = dz.gw.get_playlist_page(link_id) 142 | playlistAPI = map_user_playlist(userPlaylist['DATA']) 143 | except GWAPIError as e: 144 | raise GenerationError(f"https://deezer.com/playlist/{link_id}", str(e)) from e 145 | 146 | # Check if private playlist and owner 147 | if not playlistAPI.get('public', False) and playlistAPI['creator']['id'] != str(dz.current_user['id']): 148 | logger.warning("You can't download others private playlists.") 149 | raise NotYourPrivatePlaylist(f"https://deezer.com/playlist/{link_id}") 150 | 151 | if not playlistTracksAPI: 152 | playlistTracksAPI = dz.gw.get_playlist_tracks(link_id) 153 | playlistAPI['various_artist'] = dz.api.get_artist(5080) # Useful for save as compilation 154 | 155 | totalSize = len(playlistTracksAPI) 156 | playlistAPI['nb_tracks'] = totalSize 157 | collection = [] 158 | for pos, trackAPI in enumerate(playlistTracksAPI, start=1): 159 | trackAPI = map_track(trackAPI) 160 | if trackAPI['explicit_lyrics']: 161 | playlistAPI['explicit'] = True 162 | if 'track_token' in trackAPI: del trackAPI['track_token'] 163 | trackAPI['position'] = pos 164 | collection.append(trackAPI) 165 | 166 | if 'explicit' not in playlistAPI: playlistAPI['explicit'] = False 167 | 168 | return Collection({ 169 | 'type': 'playlist', 170 | 'id': link_id, 171 | 'bitrate': bitrate, 172 | 'title': playlistAPI['title'], 173 | 'artist': playlistAPI['creator']['name'], 174 | 'cover': playlistAPI['picture_small'][:-24] + '/75x75-000000-80-0-0.jpg', 175 | 'explicit': playlistAPI['explicit'], 176 | 'size': totalSize, 177 | 'collection': { 178 | 'tracks': collection, 179 | 'playlistAPI': playlistAPI 180 | } 181 | }) 182 | 183 | def generateArtistItem(dz, link_id, bitrate, listener=None): 184 | if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/artist/{link_id}") 185 | # Get essential artist info 186 | try: 187 | artistAPI = dz.api.get_artist(link_id) 188 | except APIError as e: 189 | raise GenerationError(f"https://deezer.com/artist/{link_id}", str(e)) from e 190 | 191 | rootArtist = { 192 | 'id': artistAPI['id'], 193 | 'name': artistAPI['name'], 194 | 'picture_small': artistAPI['picture_small'] 195 | } 196 | if listener: listener.send("startAddingArtist", rootArtist) 197 | 198 | artistDiscographyAPI = dz.gw.get_artist_discography_tabs(link_id, 100) 199 | allReleases = artistDiscographyAPI.pop('all', []) 200 | albumList = [] 201 | for album in allReleases: 202 | try: 203 | albumList.append(generateAlbumItem(dz, album['id'], bitrate, rootArtist=rootArtist)) 204 | except GenerationError as e: 205 | logger.warning("Album %s has no data: %s", str(album['id']), str(e)) 206 | 207 | if listener: listener.send("finishAddingArtist", rootArtist) 208 | return albumList 209 | 210 | def generateArtistDiscographyItem(dz, link_id, bitrate, listener=None): 211 | if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/artist/{link_id}/discography") 212 | # Get essential artist info 213 | try: 214 | artistAPI = dz.api.get_artist(link_id) 215 | except APIError as e: 216 | raise GenerationError(f"https://deezer.com/artist/{link_id}/discography", str(e)) from e 217 | 218 | rootArtist = { 219 | 'id': artistAPI['id'], 220 | 'name': artistAPI['name'], 221 | 'picture_small': artistAPI['picture_small'] 222 | } 223 | if listener: listener.send("startAddingArtist", rootArtist) 224 | 225 | artistDiscographyAPI = dz.gw.get_artist_discography_tabs(link_id, 100) 226 | artistDiscographyAPI.pop('all', None) # all contains albums and singles, so its all duplicates. This removes them 227 | albumList = [] 228 | for releaseType in artistDiscographyAPI: 229 | for album in artistDiscographyAPI[releaseType]: 230 | try: 231 | albumList.append(generateAlbumItem(dz, album['id'], bitrate, rootArtist=rootArtist)) 232 | except GenerationError as e: 233 | logger.warning("Album %s has no data: %s", str(album['id']), str(e)) 234 | 235 | if listener: listener.send("finishAddingArtist", rootArtist) 236 | return albumList 237 | 238 | def generateArtistTopItem(dz, link_id, bitrate): 239 | if not str(link_id).isdecimal(): raise InvalidID(f"https://deezer.com/artist/{link_id}/top_track") 240 | # Get essential artist info 241 | try: 242 | artistAPI = dz.api.get_artist(link_id) 243 | except APIError as e: 244 | raise GenerationError(f"https://deezer.com/artist/{link_id}/top_track", str(e)) from e 245 | 246 | # Emulate the creation of a playlist 247 | # Can't use generatePlaylistItem directly as this is not a real playlist 248 | playlistAPI = { 249 | 'id':f"{artistAPI['id']}_top_track", 250 | 'title': f"{artistAPI['name']} - Top Tracks", 251 | 'description': f"Top Tracks for {artistAPI['name']}", 252 | 'duration': 0, 253 | 'public': True, 254 | 'is_loved_track': False, 255 | 'collaborative': False, 256 | 'nb_tracks': 0, 257 | 'fans': artistAPI['nb_fan'], 258 | 'link': f"https://www.deezer.com/artist/{artistAPI['id']}/top_track", 259 | 'share': None, 260 | 'picture': artistAPI['picture'], 261 | 'picture_small': artistAPI['picture_small'], 262 | 'picture_medium': artistAPI['picture_medium'], 263 | 'picture_big': artistAPI['picture_big'], 264 | 'picture_xl': artistAPI['picture_xl'], 265 | 'checksum': None, 266 | 'tracklist': f"https://api.deezer.com/artist/{artistAPI['id']}/top", 267 | 'creation_date': "XXXX-00-00", 268 | 'creator': { 269 | 'id': f"art_{artistAPI['id']}", 270 | 'name': artistAPI['name'], 271 | 'type': "user" 272 | }, 273 | 'type': "playlist" 274 | } 275 | 276 | artistTopTracksAPI_gw = dz.gw.get_artist_toptracks(link_id) 277 | return generatePlaylistItem(dz, playlistAPI['id'], bitrate, playlistAPI=playlistAPI, playlistTracksAPI=artistTopTracksAPI_gw) 278 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | class Plugin: 2 | def __init__(self): 3 | pass 4 | 5 | def setup(self): 6 | pass 7 | 8 | def parseLink(self, link): 9 | pass 10 | 11 | def generateDownloadObject(self, dz, link, bitrate, listener): 12 | pass 13 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/settings.py: -------------------------------------------------------------------------------- 1 | import json 2 | from copy import deepcopy 3 | from pathlib import Path 4 | from os import makedirs 5 | from deezer import TrackFormats 6 | import deemix.utils.localpaths as localpaths 7 | 8 | class OverwriteOption(): 9 | """Should the lib overwrite files?""" 10 | OVERWRITE = 'y' # Yes, overwrite the file 11 | DONT_OVERWRITE = 'n' # No, don't overwrite the file 12 | DONT_CHECK_EXT = 'e' # No, and don't check for extensions 13 | KEEP_BOTH = 'b' # No, and keep both files 14 | ONLY_TAGS = 't' # Overwrite only the tags 15 | 16 | class FeaturesOption(): 17 | """What should I do with featured artists?""" 18 | NO_CHANGE = "0" # Do nothing 19 | REMOVE_TITLE = "1" # Remove from track title 20 | REMOVE_TITLE_ALBUM = "3" # Remove from track title and album title 21 | MOVE_TITLE = "2" # Move to track title 22 | 23 | DEFAULTS = { 24 | "downloadLocation": str(localpaths.getMusicFolder()), 25 | "tracknameTemplate": "%artist% - %title%", 26 | "albumTracknameTemplate": "%tracknumber% - %title%", 27 | "playlistTracknameTemplate": "%position% - %artist% - %title%", 28 | "createPlaylistFolder": True, 29 | "playlistNameTemplate": "%playlist%", 30 | "createArtistFolder": False, 31 | "artistNameTemplate": "%artist%", 32 | "createAlbumFolder": True, 33 | "albumNameTemplate": "%artist% - %album%", 34 | "createCDFolder": True, 35 | "createStructurePlaylist": False, 36 | "createSingleFolder": False, 37 | "padTracks": True, 38 | "paddingSize": "0", 39 | "illegalCharacterReplacer": "_", 40 | "queueConcurrency": 3, 41 | "maxBitrate": str(TrackFormats.MP3_320), 42 | "feelingLucky": False, 43 | "fallbackBitrate": False, 44 | "fallbackSearch": False, 45 | "fallbackISRC": False, 46 | "logErrors": True, 47 | "logSearched": False, 48 | "overwriteFile": OverwriteOption.DONT_OVERWRITE, 49 | "createM3U8File": False, 50 | "playlistFilenameTemplate": "playlist", 51 | "syncedLyrics": False, 52 | "embeddedArtworkSize": 800, 53 | "embeddedArtworkPNG": False, 54 | "localArtworkSize": 1400, 55 | "localArtworkFormat": "jpg", 56 | "saveArtwork": True, 57 | "coverImageTemplate": "cover", 58 | "saveArtworkArtist": False, 59 | "artistImageTemplate": "folder", 60 | "jpegImageQuality": 90, 61 | "dateFormat": "Y-M-D", 62 | "albumVariousArtists": True, 63 | "removeAlbumVersion": False, 64 | "removeDuplicateArtists": True, 65 | "featuredToTitle": FeaturesOption.NO_CHANGE, 66 | "titleCasing": "nothing", 67 | "artistCasing": "nothing", 68 | "executeCommand": "", 69 | "tags": { 70 | "title": True, 71 | "artist": True, 72 | "artists": True, 73 | "album": True, 74 | "cover": True, 75 | "trackNumber": True, 76 | "trackTotal": False, 77 | "discNumber": True, 78 | "discTotal": False, 79 | "albumArtist": True, 80 | "genre": True, 81 | "year": True, 82 | "date": True, 83 | "explicit": False, 84 | "isrc": True, 85 | "length": True, 86 | "barcode": True, 87 | "bpm": True, 88 | "replayGain": False, 89 | "label": True, 90 | "lyrics": False, 91 | "syncedLyrics": False, 92 | "copyright": False, 93 | "composer": False, 94 | "involvedPeople": False, 95 | "source": False, 96 | "rating": False, 97 | "savePlaylistAsCompilation": False, 98 | "useNullSeparator": False, 99 | "saveID3v1": True, 100 | "multiArtistSeparator": "default", 101 | "singleAlbumArtist": False, 102 | "coverDescriptionUTF8": False 103 | } 104 | } 105 | 106 | def save(settings, configFolder=None): 107 | configFolder = Path(configFolder or localpaths.getConfigFolder()) 108 | makedirs(configFolder, exist_ok=True) # Create config folder if it doesn't exsist 109 | 110 | with open(configFolder / 'config.json', 'w', encoding="utf-8") as configFile: 111 | json.dump(settings, configFile, indent=2) 112 | 113 | def load(configFolder=None): 114 | configFolder = Path(configFolder or localpaths.getConfigFolder()) 115 | makedirs(configFolder, exist_ok=True) # Create config folder if it doesn't exsist 116 | if not (configFolder / 'config.json').is_file(): save(DEFAULTS, configFolder) # Create config file if it doesn't exsist 117 | 118 | # Read config file 119 | with open(configFolder / 'config.json', 'r', encoding="utf-8") as configFile: 120 | try: 121 | settings = json.load(configFile) 122 | except json.decoder.JSONDecodeError: 123 | save(DEFAULTS, configFolder) 124 | settings = deepcopy(DEFAULTS) 125 | except Exception: 126 | settings = deepcopy(DEFAULTS) 127 | 128 | if check(settings) > 0: save(settings, configFolder) # Check the settings and save them if something changed 129 | return settings 130 | 131 | def check(settings): 132 | changes = 0 133 | for i_set in DEFAULTS: 134 | if not i_set in settings or not isinstance(settings[i_set], type(DEFAULTS[i_set])): 135 | settings[i_set] = DEFAULTS[i_set] 136 | changes += 1 137 | for i_set in DEFAULTS['tags']: 138 | if not i_set in settings['tags'] or not isinstance(settings['tags'][i_set], type(DEFAULTS['tags'][i_set])): 139 | settings['tags'][i_set] = DEFAULTS['tags'][i_set] 140 | changes += 1 141 | if settings['downloadLocation'] == "": 142 | settings['downloadLocation'] = DEFAULTS['downloadLocation'] 143 | changes += 1 144 | for template in ['tracknameTemplate', 'albumTracknameTemplate', 'playlistTracknameTemplate', 'playlistNameTemplate', 'artistNameTemplate', 'albumNameTemplate', 'playlistFilenameTemplate', 'coverImageTemplate', 'artistImageTemplate', 'paddingSize']: 145 | if settings[template] == "": 146 | settings[template] = DEFAULTS[template] 147 | changes += 1 148 | return changes 149 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/tagger.py: -------------------------------------------------------------------------------- 1 | from mutagen.flac import FLAC, Picture 2 | from mutagen.id3 import ID3, ID3NoHeaderError, \ 3 | TXXX, TIT2, TPE1, TALB, TPE2, TRCK, TPOS, TCON, TYER, TDAT, TLEN, TBPM, \ 4 | TPUB, TSRC, USLT, SYLT, APIC, IPLS, TCOM, TCOP, TCMP, Encoding, PictureType, POPM 5 | 6 | # Adds tags to a MP3 file 7 | def tagID3(path, track, save): 8 | # Delete existing tags 9 | try: 10 | tag = ID3(path) 11 | tag.delete() 12 | except ID3NoHeaderError: 13 | tag = ID3() 14 | 15 | if save['title']: 16 | tag.add(TIT2(text=track.title)) 17 | 18 | if save['artist'] and len(track.artists): 19 | if save['multiArtistSeparator'] == "default": 20 | tag.add(TPE1(text=track.artists)) 21 | else: 22 | if save['multiArtistSeparator'] == "nothing": 23 | tag.add(TPE1(text=track.mainArtist.name)) 24 | else: 25 | tag.add(TPE1(text=track.artistsString)) 26 | # Tag ARTISTS is added to keep the multiartist support when using a non standard tagging method 27 | # https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html#artists 28 | if save['artists']: 29 | tag.add(TXXX(desc="ARTISTS", text=track.artists)) 30 | 31 | if save['album']: 32 | tag.add(TALB(text=track.album.title)) 33 | 34 | if save['albumArtist'] and len(track.album.artists): 35 | if save['singleAlbumArtist'] and track.album.mainArtist.save: 36 | tag.add(TPE2(text=track.album.mainArtist.name)) 37 | else: 38 | tag.add(TPE2(text=track.album.artists)) 39 | 40 | if save['trackNumber']: 41 | trackNumber = str(track.trackNumber) 42 | if save['trackTotal']: 43 | trackNumber += "/" + str(track.album.trackTotal) 44 | tag.add(TRCK(text=trackNumber)) 45 | if save['discNumber']: 46 | discNumber = str(track.discNumber) 47 | if save['discTotal']: 48 | discNumber += "/" + str(track.album.discTotal) 49 | tag.add(TPOS(text=discNumber)) 50 | 51 | if save['genre']: 52 | tag.add(TCON(text=track.album.genre)) 53 | if save['year']: 54 | tag.add(TYER(text=str(track.date.year))) 55 | if save['date']: 56 | # Referencing ID3 standard 57 | # https://id3.org/id3v2.3.0#TDAT 58 | # The 'Date' frame is a numeric string in the DDMM format. 59 | tag.add(TDAT(text=str(track.date.day) + str(track.date.month))) 60 | if save['length']: 61 | tag.add(TLEN(text=str(int(track.duration)*1000))) 62 | if save['bpm'] and track.bpm: 63 | tag.add(TBPM(text=str(track.bpm))) 64 | if save['label']: 65 | tag.add(TPUB(text=track.album.label)) 66 | if save['isrc']: 67 | tag.add(TSRC(text=track.ISRC)) 68 | if track.album.barcode and save['barcode']: 69 | tag.add(TXXX(desc="BARCODE", text=track.album.barcode)) 70 | if save['explicit']: 71 | tag.add(TXXX(desc="ITUNESADVISORY", text= "1" if track.explicit else "0" )) 72 | if save['replayGain']: 73 | tag.add(TXXX(desc="REPLAYGAIN_TRACK_GAIN", text=track.replayGain)) 74 | if track.lyrics.unsync and save['lyrics']: 75 | tag.add(USLT(text=track.lyrics.unsync)) 76 | if track.lyrics.syncID3 and save['syncedLyrics']: 77 | # Referencing ID3 standard 78 | # https://id3.org/id3v2.3.0#sec4.10 79 | # Type: 1 => is lyrics 80 | # Format: 2 => Absolute time, 32 bit sized, using milliseconds as unit 81 | tag.add(SYLT(Encoding.UTF8, type=1, format=2, text=track.lyrics.syncID3)) 82 | 83 | involved_people = [] 84 | for role in track.contributors: 85 | if role in ['author', 'engineer', 'mixer', 'producer', 'writer']: 86 | for person in track.contributors[role]: 87 | involved_people.append([role, person]) 88 | elif role == 'composer' and save['composer']: 89 | tag.add(TCOM(text=track.contributors['composer'])) 90 | if len(involved_people) > 0 and save['involvedPeople']: 91 | tag.add(IPLS(people=involved_people)) 92 | 93 | if save['copyright'] and track.copyright: 94 | tag.add(TCOP(text=track.copyright)) 95 | if (save['savePlaylistAsCompilation'] and track.playlist or 96 | (track.album.recordType and track.album.recordType == "compile")): 97 | tag.add(TCMP(text="1")) 98 | 99 | if save['source']: 100 | tag.add(TXXX(desc="SOURCE", text='Deezer')) 101 | tag.add(TXXX(desc="SOURCEID", text=str(track.id))) 102 | 103 | if save['rating']: 104 | rank = round((int(track.rank) / 10000) * 2.55) 105 | if rank > 255 : 106 | rank = 255 107 | else: 108 | rank = round(rank, 0) 109 | 110 | tag.add(POPM(rating=rank)) 111 | 112 | if save['cover'] and track.album.embeddedCoverPath: 113 | 114 | descEncoding = Encoding.LATIN1 115 | if save['coverDescriptionUTF8']: 116 | descEncoding = Encoding.UTF8 117 | 118 | mimeType = 'image/jpeg' 119 | if str(track.album.embeddedCoverPath).endswith('png'): 120 | mimeType = 'image/png' 121 | 122 | with open(track.album.embeddedCoverPath, 'rb') as f: 123 | tag.add(APIC(descEncoding, mimeType, PictureType.COVER_FRONT, desc='cover', data=f.read())) 124 | 125 | tag.save( path, 126 | v1=2 if save['saveID3v1'] else 0, 127 | v2_version=3, 128 | v23_sep=None if save['useNullSeparator'] else '/' ) 129 | 130 | # Adds tags to a FLAC file 131 | def tagFLAC(path, track, save): 132 | # Delete existing tags 133 | tag = FLAC(path) 134 | tag.delete() 135 | tag.clear_pictures() 136 | 137 | if save['title']: 138 | tag["TITLE"] = track.title 139 | 140 | if save['artist'] and len(track.artists): 141 | if save['multiArtistSeparator'] == "default": 142 | tag["ARTIST"] = track.artists 143 | else: 144 | if save['multiArtistSeparator'] == "nothing": 145 | tag["ARTIST"] = track.mainArtist.name 146 | else: 147 | tag["ARTIST"] = track.artistsString 148 | # Tag ARTISTS is added to keep the multiartist support when using a non standard tagging method 149 | # https://picard-docs.musicbrainz.org/en/technical/tag_mapping.html#artists 150 | if save['artists']: 151 | tag["ARTISTS"] = track.artists 152 | 153 | if save['album']: 154 | tag["ALBUM"] = track.album.title 155 | 156 | if save['albumArtist'] and len(track.album.artists): 157 | if save['singleAlbumArtist'] and track.album.mainArtist.save: 158 | tag["ALBUMARTIST"] = track.album.mainArtist.name 159 | else: 160 | tag["ALBUMARTIST"] = track.album.artists 161 | 162 | if save['trackNumber']: 163 | tag["TRACKNUMBER"] = str(track.trackNumber) 164 | if save['trackTotal']: 165 | tag["TRACKTOTAL"] = str(track.album.trackTotal) 166 | if save['discNumber']: 167 | tag["DISCNUMBER"] = str(track.discNumber) 168 | if save['discTotal']: 169 | tag["DISCTOTAL"] = str(track.album.discTotal) 170 | if save['genre']: 171 | tag["GENRE"] = track.album.genre 172 | 173 | # YEAR tag is not suggested as a standard tag 174 | # Being YEAR already contained in DATE will only use DATE instead 175 | # Reference: https://www.xiph.org/vorbis/doc/v-comment.html#fieldnames 176 | if save['date']: 177 | tag["DATE"] = track.dateString 178 | elif save['year']: 179 | tag["DATE"] = str(track.date.year) 180 | 181 | if save['length']: 182 | tag["LENGTH"] = str(int(track.duration)*1000) 183 | if save['bpm'] and track.bpm: 184 | tag["BPM"] = str(track.bpm) 185 | if save['label']: 186 | tag["PUBLISHER"] = track.album.label 187 | if save['isrc']: 188 | tag["ISRC"] = track.ISRC 189 | if track.album.barcode and save['barcode']: 190 | tag["BARCODE"] = track.album.barcode 191 | if save['explicit']: 192 | tag["ITUNESADVISORY"] = "1" if track.explicit else "0" 193 | if save['replayGain']: 194 | tag["REPLAYGAIN_TRACK_GAIN"] = track.replayGain 195 | if track.lyrics.unsync and save['lyrics']: 196 | tag["LYRICS"] = track.lyrics.unsync 197 | 198 | for role in track.contributors: 199 | if role in ['author', 'engineer', 'mixer', 'producer', 'writer', 'composer']: 200 | if save['involvedPeople'] and role != 'composer' or save['composer'] and role == 'composer': 201 | tag[role] = track.contributors[role] 202 | elif role == 'musicpublisher' and save['involvedPeople']: 203 | tag["ORGANIZATION"] = track.contributors['musicpublisher'] 204 | 205 | if save['copyright'] and track.copyright: 206 | tag["COPYRIGHT"] = track.copyright 207 | if (save['savePlaylistAsCompilation'] and track.playlist or 208 | (track.album.recordType and track.album.recordType == "compile")): 209 | tag["COMPILATION"] = "1" 210 | 211 | if save['source']: 212 | tag["SOURCE"] = 'Deezer' 213 | tag["SOURCEID"] = str(track.id) 214 | 215 | if save['rating']: 216 | rank = round((int(track.rank) / 10000)) 217 | tag['RATING'] = str(rank) 218 | 219 | if save['cover'] and track.album.embeddedCoverPath: 220 | image = Picture() 221 | image.type = PictureType.COVER_FRONT 222 | image.mime = 'image/jpeg' 223 | if str(track.album.embeddedCoverPath).endswith('png'): 224 | image.mime = 'image/png' 225 | with open(track.album.embeddedCoverPath, 'rb') as f: 226 | image.data = f.read() 227 | tag.add_picture(image) 228 | 229 | tag.save(deleteid3=True) 230 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/types/Album.py: -------------------------------------------------------------------------------- 1 | from deemix.utils import removeDuplicateArtists, removeFeatures 2 | from deemix.types.Artist import Artist 3 | from deemix.types.Date import Date 4 | from deemix.types.Picture import Picture 5 | from deemix.types import VARIOUS_ARTISTS 6 | 7 | class Album: 8 | def __init__(self, alb_id="0", title="", pic_md5=""): 9 | self.id = alb_id 10 | self.title = title 11 | self.pic = Picture(pic_md5, "cover") 12 | self.artist = {"Main": []} 13 | self.artists = [] 14 | self.mainArtist = None 15 | self.date = Date() 16 | self.dateString = "" 17 | self.trackTotal = "0" 18 | self.discTotal = "0" 19 | self.embeddedCoverPath = "" 20 | self.embeddedCoverURL = "" 21 | self.explicit = False 22 | self.genre = [] 23 | self.barcode = "Unknown" 24 | self.label = "Unknown" 25 | self.copyright = "" 26 | self.recordType = "album" 27 | self.bitrate = 0 28 | self.rootArtist = None 29 | self.variousArtists = None 30 | 31 | self.playlistID = None 32 | self.owner = None 33 | self.isPlaylist = False 34 | 35 | def parseAlbum(self, albumAPI): 36 | self.title = albumAPI['title'] 37 | 38 | # Getting artist image ID 39 | # ex: https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/56x56-000000-80-0-0.jpg 40 | art_pic = albumAPI['artist'].get('picture_small') 41 | if art_pic: art_pic = art_pic[art_pic.find('artist/') + 7:-24] 42 | else: art_pic = "" 43 | self.mainArtist = Artist( 44 | albumAPI['artist']['id'], 45 | albumAPI['artist']['name'], 46 | "Main", 47 | art_pic 48 | ) 49 | if albumAPI.get('root_artist'): 50 | art_pic = albumAPI['root_artist']['picture_small'] 51 | art_pic = art_pic[art_pic.find('artist/') + 7:-24] 52 | self.rootArtist = Artist( 53 | albumAPI['root_artist']['id'], 54 | albumAPI['root_artist']['name'], 55 | "Root", 56 | art_pic 57 | ) 58 | 59 | for artist in albumAPI['contributors']: 60 | isVariousArtists = str(artist['id']) == VARIOUS_ARTISTS 61 | isMainArtist = artist['role'] == "Main" 62 | 63 | if isVariousArtists: 64 | self.variousArtists = Artist( 65 | art_id = artist['id'], 66 | name = artist['name'], 67 | role = artist['role'] 68 | ) 69 | continue 70 | 71 | if artist['name'] not in self.artists: 72 | self.artists.append(artist['name']) 73 | 74 | if isMainArtist or artist['name'] not in self.artist['Main'] and not isMainArtist: 75 | if not artist['role'] in self.artist: 76 | self.artist[artist['role']] = [] 77 | self.artist[artist['role']].append(artist['name']) 78 | 79 | self.trackTotal = albumAPI['nb_tracks'] 80 | self.recordType = albumAPI.get('record_type', self.recordType) 81 | 82 | self.barcode = albumAPI.get('upc', self.barcode) 83 | self.label = albumAPI.get('label', self.label) 84 | self.explicit = bool(albumAPI.get('explicit_lyrics', False)) 85 | release_date = albumAPI.get('release_date') 86 | if 'physical_release_date' in albumAPI: 87 | release_date = albumAPI['physical_release_date'] 88 | if release_date: 89 | self.date.day = release_date[8:10] 90 | self.date.month = release_date[5:7] 91 | self.date.year = release_date[0:4] 92 | self.date.fixDayMonth() 93 | 94 | self.discTotal = albumAPI.get('nb_disk', "1") 95 | self.copyright = albumAPI.get('copyright', "") 96 | 97 | if not self.pic.md5 or self.pic.md5 == "": 98 | if albumAPI.get('md5_image'): 99 | self.pic.md5 = albumAPI['md5_image'] 100 | elif albumAPI.get('cover_small'): 101 | # Getting album cover MD5 102 | # ex: https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/56x56-000000-80-0-0.jpg 103 | alb_pic = albumAPI['cover_small'] 104 | self.pic.md5 = alb_pic[alb_pic.find('cover/') + 6:-24] 105 | 106 | if albumAPI.get('genres') and len(albumAPI['genres'].get('data', [])) > 0: 107 | for genre in albumAPI['genres']['data']: 108 | self.genre.append(genre['name']) 109 | 110 | def makePlaylistCompilation(self, playlist): 111 | self.variousArtists = playlist.variousArtists 112 | self.mainArtist = playlist.mainArtist 113 | self.title = playlist.title 114 | self.rootArtist = playlist.rootArtist 115 | self.artist = playlist.artist 116 | self.artists = playlist.artists 117 | self.trackTotal = playlist.trackTotal 118 | self.recordType = playlist.recordType 119 | self.barcode = playlist.barcode 120 | self.label = playlist.label 121 | self.explicit = playlist.explicit 122 | self.date = playlist.date 123 | self.discTotal = playlist.discTotal 124 | self.playlistID = playlist.playlistID 125 | self.owner = playlist.owner 126 | self.pic = playlist.pic 127 | self.isPlaylist = True 128 | 129 | def removeDuplicateArtists(self): 130 | """Removes duplicate artists for both artist array and artists dict""" 131 | (self.artist, self.artists) = removeDuplicateArtists(self.artist, self.artists) 132 | 133 | def getCleanTitle(self): 134 | """Removes featuring from the album name""" 135 | return removeFeatures(self.title) 136 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/types/Artist.py: -------------------------------------------------------------------------------- 1 | from deemix.types.Picture import Picture 2 | from deemix.types import VARIOUS_ARTISTS 3 | 4 | class Artist: 5 | def __init__(self, art_id="0", name="", role="", pic_md5=""): 6 | self.id = str(art_id) 7 | self.name = name 8 | self.pic = Picture(md5=pic_md5, pic_type="artist") 9 | self.role = role 10 | self.save = True 11 | 12 | def isVariousArtists(self): 13 | return self.id == VARIOUS_ARTISTS 14 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/types/Date.py: -------------------------------------------------------------------------------- 1 | class Date: 2 | def __init__(self, day="00", month="00", year="XXXX"): 3 | self.day = day 4 | self.month = month 5 | self.year = year 6 | self.fixDayMonth() 7 | 8 | # Fix incorrect day month when detectable 9 | def fixDayMonth(self): 10 | if int(self.month) > 12: 11 | monthTemp = self.month 12 | self.month = self.day 13 | self.day = monthTemp 14 | 15 | def format(self, template): 16 | elements = { 17 | 'year': ['YYYY', 'YY', 'Y'], 18 | 'month': ['MM', 'M'], 19 | 'day': ['DD', 'D'] 20 | } 21 | for element, placeholders in elements.items(): 22 | for placeholder in placeholders: 23 | if placeholder in template: 24 | template = template.replace(placeholder, str(getattr(self, element))) 25 | return template 26 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/types/DownloadObjects.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | class IDownloadObject: 4 | """DownloadObject Interface""" 5 | def __init__(self, obj): 6 | self.type = obj['type'] 7 | self.id = obj['id'] 8 | self.bitrate = obj['bitrate'] 9 | self.title = obj['title'] 10 | self.artist = obj['artist'] 11 | self.cover = obj['cover'] 12 | self.explicit = obj.get('explicit', False) 13 | self.size = obj.get('size', 0) 14 | self.downloaded = obj.get('downloaded', 0) 15 | self.failed = obj.get('failed', 0) 16 | self.progress = obj.get('progress', 0) 17 | self.errors = obj.get('errors', []) 18 | self.files = obj.get('files', []) 19 | self.extrasPath = obj.get('extrasPath', "") 20 | if self.extrasPath: self.extrasPath = Path(self.extrasPath) 21 | self.progressNext = 0 22 | self.uuid = f"{self.type}_{self.id}_{self.bitrate}" 23 | self.isCanceled = False 24 | self.__type__ = None 25 | 26 | def toDict(self): 27 | return { 28 | 'type': self.type, 29 | 'id': self.id, 30 | 'bitrate': self.bitrate, 31 | 'uuid': self.uuid, 32 | 'title': self.title, 33 | 'artist': self.artist, 34 | 'cover': self.cover, 35 | 'explicit': self.explicit, 36 | 'size': self.size, 37 | 'downloaded': self.downloaded, 38 | 'failed': self.failed, 39 | 'progress': self.progress, 40 | 'errors': self.errors, 41 | 'files': self.files, 42 | 'extrasPath': str(self.extrasPath), 43 | '__type__': self.__type__ 44 | } 45 | 46 | def getResettedDict(self): 47 | item = self.toDict() 48 | item['downloaded'] = 0 49 | item['failed'] = 0 50 | item['progress'] = 0 51 | item['errors'] = [] 52 | item['files'] = [] 53 | return item 54 | 55 | def getSlimmedDict(self): 56 | light = self.toDict() 57 | propertiesToDelete = ['single', 'collection', 'plugin', 'conversion_data'] 58 | for prop in propertiesToDelete: 59 | if prop in light: 60 | del light[prop] 61 | return light 62 | 63 | def getEssentialDict(self): 64 | return { 65 | 'type': self.type, 66 | 'id': self.id, 67 | 'bitrate': self.bitrate, 68 | 'uuid': self.uuid, 69 | 'title': self.title, 70 | 'artist': self.artist, 71 | 'cover': self.cover, 72 | 'explicit': self.explicit, 73 | 'size': self.size, 74 | 'extrasPath': str(self.extrasPath) 75 | } 76 | 77 | def updateProgress(self, listener=None): 78 | if round(self.progressNext) != self.progress and round(self.progressNext) % 2 == 0: 79 | self.progress = round(self.progressNext) 80 | if listener: listener.send("updateQueue", {'uuid': self.uuid, 'progress': self.progress}) 81 | 82 | class Single(IDownloadObject): 83 | def __init__(self, obj): 84 | super().__init__(obj) 85 | self.size = 1 86 | self.single = obj['single'] 87 | self.__type__ = "Single" 88 | 89 | def toDict(self): 90 | item = super().toDict() 91 | item['single'] = self.single 92 | return item 93 | 94 | def completeTrackProgress(self, listener=None): 95 | self.progressNext = 100 96 | self.updateProgress(listener) 97 | 98 | def removeTrackProgress(self, listener=None): 99 | self.progressNext = 0 100 | self.updateProgress(listener) 101 | 102 | class Collection(IDownloadObject): 103 | def __init__(self, obj): 104 | super().__init__(obj) 105 | self.collection = obj['collection'] 106 | self.__type__ = "Collection" 107 | 108 | def toDict(self): 109 | item = super().toDict() 110 | item['collection'] = self.collection 111 | return item 112 | 113 | def completeTrackProgress(self, listener=None): 114 | self.progressNext += (1 / self.size) * 100 115 | self.updateProgress(listener) 116 | 117 | def removeTrackProgress(self, listener=None): 118 | self.progressNext -= (1 / self.size) * 100 119 | self.updateProgress(listener) 120 | 121 | class Convertable(Collection): 122 | def __init__(self, obj): 123 | super().__init__(obj) 124 | self.plugin = obj['plugin'] 125 | self.conversion_data = obj['conversion_data'] 126 | self.__type__ = "Convertable" 127 | 128 | def toDict(self): 129 | item = super().toDict() 130 | item['plugin'] = self.plugin 131 | item['conversion_data'] = self.conversion_data 132 | return item 133 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/types/Lyrics.py: -------------------------------------------------------------------------------- 1 | class Lyrics: 2 | def __init__(self, lyr_id="0"): 3 | self.id = lyr_id 4 | self.sync = "" 5 | self.unsync = "" 6 | self.syncID3 = [] 7 | 8 | def parseLyrics(self, lyricsAPI): 9 | self.unsync = lyricsAPI.get("LYRICS_TEXT") 10 | if "LYRICS_SYNC_JSON" in lyricsAPI: 11 | syncLyricsJson = lyricsAPI["LYRICS_SYNC_JSON"] 12 | timestamp = "" 13 | milliseconds = 0 14 | for line, _ in enumerate(syncLyricsJson): 15 | if syncLyricsJson[line]["line"] != "": 16 | timestamp = syncLyricsJson[line]["lrc_timestamp"] 17 | milliseconds = int(syncLyricsJson[line]["milliseconds"]) 18 | self.syncID3.append((syncLyricsJson[line]["line"], milliseconds)) 19 | else: 20 | notEmptyLine = line + 1 21 | while syncLyricsJson[notEmptyLine]["line"] == "": 22 | notEmptyLine += 1 23 | timestamp = syncLyricsJson[notEmptyLine]["lrc_timestamp"] 24 | self.sync += timestamp + syncLyricsJson[line]["line"] + "\r\n" 25 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/types/Picture.py: -------------------------------------------------------------------------------- 1 | class Picture: 2 | def __init__(self, md5="", pic_type=""): 3 | self.md5 = md5 4 | self.type = pic_type 5 | 6 | def getURL(self, size, pic_format): 7 | url = "https://e-cdns-images.dzcdn.net/images/{}/{}/{size}x{size}".format( 8 | self.type, 9 | self.md5, 10 | size=size 11 | ) 12 | 13 | if pic_format.startswith("jpg"): 14 | quality = 80 15 | if '-' in pic_format: 16 | quality = pic_format[4:] 17 | pic_format = 'jpg' 18 | return url + f'-000000-{quality}-0-0.jpg' 19 | if pic_format == 'png': 20 | return url + '-none-100-0-0.png' 21 | 22 | return url+'.jpg' 23 | 24 | class StaticPicture: 25 | def __init__(self, url): 26 | self.staticURL = url 27 | 28 | def getURL(self, _, __): 29 | return self.staticURL 30 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/types/Playlist.py: -------------------------------------------------------------------------------- 1 | from deemix.types.Artist import Artist 2 | from deemix.types.Date import Date 3 | from deemix.types.Picture import Picture, StaticPicture 4 | 5 | class Playlist: 6 | def __init__(self, playlistAPI): 7 | self.id = "pl_" + str(playlistAPI['id']) 8 | self.title = playlistAPI['title'] 9 | self.rootArtist = None 10 | self.artist = {"Main": []} 11 | self.artists = [] 12 | self.trackTotal = playlistAPI['nb_tracks'] 13 | self.recordType = "compile" 14 | self.barcode = "" 15 | self.label = "" 16 | self.explicit = playlistAPI['explicit'] 17 | self.genre = ["Compilation", ] 18 | 19 | year = playlistAPI["creation_date"][0:4] 20 | month = playlistAPI["creation_date"][5:7] 21 | day = playlistAPI["creation_date"][8:10] 22 | self.date = Date(day, month, year) 23 | 24 | self.discTotal = "1" 25 | self.playlistID = playlistAPI['id'] 26 | self.owner = playlistAPI['creator'] 27 | 28 | if 'dzcdn.net' in playlistAPI['picture_small']: 29 | url = playlistAPI['picture_small'] 30 | picType = url[url.find('images/')+7:] 31 | picType = picType[:picType.find('/')] 32 | md5 = url[url.find(picType+'/') + len(picType)+1:-24] 33 | self.pic = Picture(md5, picType) 34 | else: 35 | self.pic = StaticPicture(playlistAPI['picture_xl']) 36 | 37 | if 'various_artist' in playlistAPI: 38 | pic_md5 = playlistAPI['various_artist']['picture_small'] 39 | pic_md5 = pic_md5[pic_md5.find('artist/') + 7:-24] 40 | self.variousArtists = Artist( 41 | playlistAPI['various_artist']['id'], 42 | playlistAPI['various_artist']['name'], 43 | "Main", 44 | pic_md5 45 | ) 46 | self.mainArtist = self.variousArtists 47 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/types/Track.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | 4 | from deezer.utils import map_track, map_album 5 | from deezer.errors import APIError, GWAPIError 6 | from deemix.errors import NoDataToParse, AlbumDoesntExists 7 | 8 | from deemix.utils import removeFeatures, andCommaConcat, removeDuplicateArtists, generateReplayGainString, changeCase 9 | 10 | from deemix.types.Album import Album 11 | from deemix.types.Artist import Artist 12 | from deemix.types.Date import Date 13 | from deemix.types.Picture import Picture 14 | from deemix.types.Playlist import Playlist 15 | from deemix.types.Lyrics import Lyrics 16 | from deemix.types import VARIOUS_ARTISTS 17 | 18 | from deemix.settings import FeaturesOption 19 | 20 | class Track: 21 | def __init__(self, sng_id="0", name=""): 22 | self.id = sng_id 23 | self.title = name 24 | self.MD5 = "" 25 | self.mediaVersion = "" 26 | self.trackToken = "" 27 | self.trackTokenExpiration = 0 28 | self.duration = 0 29 | self.fallbackID = "0" 30 | self.albumsFallback = [] 31 | self.filesizes = {} 32 | self.local = False 33 | self.mainArtist = None 34 | self.artist = {"Main": []} 35 | self.artists = [] 36 | self.album = None 37 | self.trackNumber = "0" 38 | self.discNumber = "0" 39 | self.date = Date() 40 | self.lyrics = None 41 | self.bpm = 0 42 | self.contributors = {} 43 | self.copyright = "" 44 | self.explicit = False 45 | self.ISRC = "" 46 | self.replayGain = "" 47 | self.rank = 0 48 | self.playlist = None 49 | self.position = None 50 | self.searched = False 51 | self.selectedFormat = 0 52 | self.singleDownload = False 53 | self.dateString = "" 54 | self.artistsString = "" 55 | self.mainArtistsString = "" 56 | self.featArtistsString = "" 57 | self.urls = {} 58 | 59 | def parseEssentialData(self, trackAPI): 60 | self.id = str(trackAPI['id']) 61 | self.duration = trackAPI['duration'] 62 | self.trackToken = trackAPI['track_token'] 63 | self.trackTokenExpiration = trackAPI['track_token_expire'] 64 | self.MD5 = trackAPI.get('md5_origin') 65 | self.mediaVersion = trackAPI['media_version'] 66 | self.filesizes = trackAPI['filesizes'] 67 | self.fallbackID = "0" 68 | if 'fallback_id' in trackAPI: 69 | self.fallbackID = trackAPI['fallback_id'] 70 | self.local = int(self.id) < 0 71 | self.urls = {} 72 | 73 | def parseData(self, dz, track_id=None, trackAPI=None, albumAPI=None, playlistAPI=None): 74 | if track_id and (not trackAPI or trackAPI and not trackAPI.get('track_token')): 75 | trackAPI_new = dz.gw.get_track_with_fallback(track_id) 76 | trackAPI_new = map_track(trackAPI_new) 77 | if not trackAPI: trackAPI = {} 78 | trackAPI_new.update(trackAPI) 79 | trackAPI = trackAPI_new 80 | elif not trackAPI: raise NoDataToParse 81 | 82 | self.parseEssentialData(trackAPI) 83 | 84 | # only public api has bpm 85 | if not trackAPI.get('bpm') and not self.local: 86 | try: 87 | trackAPI_new = dz.api.get_track(trackAPI['id']) 88 | trackAPI_new['release_date'] = trackAPI['release_date'] 89 | trackAPI.update(trackAPI_new) 90 | except APIError: pass 91 | 92 | if self.local: 93 | self.parseLocalTrackData(trackAPI) 94 | else: 95 | self.parseTrack(trackAPI) 96 | 97 | # Get Lyrics data 98 | if not trackAPI.get("lyrics") and self.lyrics.id != "0": 99 | try: trackAPI["lyrics"] = dz.gw.get_track_lyrics(self.id) 100 | except GWAPIError: self.lyrics.id = "0" 101 | if self.lyrics.id != "0": self.lyrics.parseLyrics(trackAPI["lyrics"]) 102 | 103 | # Parse Album Data 104 | self.album = Album( 105 | alb_id = trackAPI['album']['id'], 106 | title = trackAPI['album']['title'], 107 | pic_md5 = trackAPI['album'].get('md5_origin') 108 | ) 109 | 110 | # Get album Data 111 | if not albumAPI: 112 | try: albumAPI = dz.api.get_album(self.album.id) 113 | except APIError: albumAPI = None 114 | 115 | # Get album_gw Data 116 | # Only gw has disk number 117 | if not albumAPI or albumAPI and not albumAPI.get('nb_disk'): 118 | try: 119 | albumAPI_gw = dz.gw.get_album(self.album.id) 120 | albumAPI_gw = map_album(albumAPI_gw) 121 | except GWAPIError: albumAPI_gw = {} 122 | if not albumAPI: albumAPI = {} 123 | albumAPI_gw.update(albumAPI) 124 | albumAPI = albumAPI_gw 125 | 126 | if not albumAPI: raise AlbumDoesntExists 127 | 128 | self.album.parseAlbum(albumAPI) 129 | # albumAPI_gw doesn't contain the artist cover 130 | # Getting artist image ID 131 | # ex: https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/56x56-000000-80-0-0.jpg 132 | if not self.album.mainArtist.pic.md5 or self.album.mainArtist.pic.md5 == "": 133 | artistAPI = dz.api.get_artist(self.album.mainArtist.id) 134 | self.album.mainArtist.pic.md5 = artistAPI['picture_small'][artistAPI['picture_small'].find('artist/') + 7:-24] 135 | 136 | # Fill missing data 137 | if self.album.date and not self.date: self.date = self.album.date 138 | if 'genres' in trackAPI: 139 | for genre in trackAPI['genres']: 140 | if genre not in self.album.genre: self.album.genre.append(genre) 141 | 142 | # Remove unwanted charaters in track name 143 | # Example: track/127793 144 | self.title = ' '.join(self.title.split()) 145 | 146 | # Make sure there is at least one artist 147 | if len(self.artist['Main']) == 0: 148 | self.artist['Main'] = [self.mainArtist.name] 149 | 150 | self.position = trackAPI.get('position') 151 | 152 | # Add playlist data if track is in a playlist 153 | if playlistAPI: self.playlist = Playlist(playlistAPI) 154 | 155 | self.generateMainFeatStrings() 156 | return self 157 | 158 | def parseLocalTrackData(self, trackAPI): 159 | # Local tracks has only the trackAPI_gw page and 160 | # contains only the tags provided by the file 161 | self.title = trackAPI['title'] 162 | self.album = Album(title=trackAPI['album']['title']) 163 | self.album.pic = Picture( 164 | md5 = trackAPI.get('md5_image', ""), 165 | pic_type = "cover" 166 | ) 167 | self.mainArtist = Artist(name=trackAPI['artist']['name'], role="Main") 168 | self.artists = [trackAPI['artist']['name']] 169 | self.artist = { 170 | 'Main': [trackAPI['artist']['name']] 171 | } 172 | self.album.artist = self.artist 173 | self.album.artists = self.artists 174 | self.album.date = self.date 175 | self.album.mainArtist = self.mainArtist 176 | 177 | def parseTrack(self, trackAPI): 178 | self.title = trackAPI['title'] 179 | 180 | self.discNumber = trackAPI.get('disk_number') 181 | self.explicit = trackAPI.get('explicit_lyrics', False) 182 | self.copyright = trackAPI.get('copyright') 183 | if 'gain' in trackAPI: self.replayGain = generateReplayGainString(trackAPI['gain']) 184 | self.ISRC = trackAPI.get('isrc') 185 | self.trackNumber = trackAPI['track_position'] 186 | self.contributors = trackAPI.get('song_contributors') 187 | self.rank = trackAPI['rank'] 188 | self.bpm = trackAPI['bpm'] 189 | 190 | self.lyrics = Lyrics(trackAPI.get('lyrics_id', "0")) 191 | 192 | self.mainArtist = Artist( 193 | art_id = trackAPI['artist']['id'], 194 | name = trackAPI['artist']['name'], 195 | role = "Main", 196 | pic_md5 = trackAPI['artist'].get('md5_image') 197 | ) 198 | 199 | if trackAPI.get('physical_release_date'): 200 | self.date.day = trackAPI["physical_release_date"][8:10] 201 | self.date.month = trackAPI["physical_release_date"][5:7] 202 | self.date.year = trackAPI["physical_release_date"][0:4] 203 | self.date.fixDayMonth() 204 | 205 | for artist in trackAPI.get('contributors', []): 206 | isVariousArtists = str(artist['id']) == VARIOUS_ARTISTS 207 | isMainArtist = artist['role'] == "Main" 208 | 209 | if len(trackAPI['contributors']) > 1 and isVariousArtists: 210 | continue 211 | 212 | if artist['name'] not in self.artists: 213 | self.artists.append(artist['name']) 214 | 215 | if isMainArtist or artist['name'] not in self.artist['Main'] and not isMainArtist: 216 | if not artist['role'] in self.artist: 217 | self.artist[artist['role']] = [] 218 | self.artist[artist['role']].append(artist['name']) 219 | 220 | if trackAPI.get('alternative_albums'): 221 | for album in trackAPI['alternative_albums']['data']: 222 | if 'RIGHTS' in album and album['RIGHTS'].get('STREAM_ADS_AVAILABLE') or album['RIGHTS'].get('STREAM_SUB_AVAILABLE'): 223 | self.albumsFallback.append(album['ALB_ID']) 224 | 225 | def removeDuplicateArtists(self): 226 | (self.artist, self.artists) = removeDuplicateArtists(self.artist, self.artists) 227 | 228 | # Removes featuring from the title 229 | def getCleanTitle(self): 230 | return removeFeatures(self.title) 231 | 232 | def getFeatTitle(self): 233 | if self.featArtistsString and "feat." not in self.title.lower(): 234 | return f"{self.title} ({self.featArtistsString})" 235 | return self.title 236 | 237 | def generateMainFeatStrings(self): 238 | self.mainArtistsString = andCommaConcat(self.artist['Main']) 239 | self.featArtistsString = "" 240 | if 'Featured' in self.artist: 241 | self.featArtistsString = "feat. "+andCommaConcat(self.artist['Featured']) 242 | 243 | def checkAndRenewTrackToken(self, dz): 244 | now = datetime.now() 245 | expiration = datetime.fromtimestamp(self.trackTokenExpiration) 246 | if now > expiration: 247 | newTrack = dz.gw.get_track_with_fallback(self.id) 248 | self.trackToken = newTrack['TRACK_TOKEN'] 249 | self.trackTokenExpiration = newTrack['TRACK_TOKEN_EXPIRE'] 250 | 251 | def applySettings(self, settings): 252 | 253 | # Check if should save the playlist as a compilation 254 | if self.playlist and settings['tags']['savePlaylistAsCompilation']: 255 | self.trackNumber = self.position 256 | self.discNumber = "1" 257 | self.album.makePlaylistCompilation(self.playlist) 258 | else: 259 | if self.album.date: self.date = self.album.date 260 | 261 | self.dateString = self.date.format(settings['dateFormat']) 262 | self.album.dateString = self.album.date.format(settings['dateFormat']) 263 | if self.playlist: self.playlist.dateString = self.playlist.date.format(settings['dateFormat']) 264 | 265 | # Check various artist option 266 | if settings['albumVariousArtists'] and self.album.variousArtists: 267 | artist = self.album.variousArtists 268 | isMainArtist = artist.role == "Main" 269 | 270 | if artist.name not in self.album.artists: 271 | self.album.artists.insert(0, artist.name) 272 | 273 | if isMainArtist or artist.name not in self.album.artist['Main'] and not isMainArtist: 274 | if artist.role not in self.album.artist: 275 | self.album.artist[artist.role] = [] 276 | self.album.artist[artist.role].insert(0, artist.name) 277 | self.album.mainArtist.save = not self.album.mainArtist.isVariousArtists() or settings['albumVariousArtists'] and self.album.mainArtist.isVariousArtists() 278 | 279 | # Check removeDuplicateArtists 280 | if settings['removeDuplicateArtists']: self.removeDuplicateArtists() 281 | 282 | # Check if user wants the feat in the title 283 | if str(settings['featuredToTitle']) == FeaturesOption.REMOVE_TITLE: 284 | self.title = self.getCleanTitle() 285 | elif str(settings['featuredToTitle']) == FeaturesOption.MOVE_TITLE: 286 | self.title = self.getFeatTitle() 287 | elif str(settings['featuredToTitle']) == FeaturesOption.REMOVE_TITLE_ALBUM: 288 | self.title = self.getCleanTitle() 289 | self.album.title = self.album.getCleanTitle() 290 | 291 | # Remove (Album Version) from tracks that have that 292 | if settings['removeAlbumVersion'] and "Album Version" in self.title: 293 | self.title = re.sub(r' ?\(Album Version\)', "", self.title).strip() 294 | 295 | # Change Title and Artists casing if needed 296 | if settings['titleCasing'] != "nothing": 297 | self.title = changeCase(self.title, settings['titleCasing']) 298 | if settings['artistCasing'] != "nothing": 299 | self.mainArtist.name = changeCase(self.mainArtist.name, settings['artistCasing']) 300 | for i, artist in enumerate(self.artists): 301 | self.artists[i] = changeCase(artist, settings['artistCasing']) 302 | for art_type in self.artist: 303 | for i, artist in enumerate(self.artist[art_type]): 304 | self.artist[art_type][i] = changeCase(artist, settings['artistCasing']) 305 | self.generateMainFeatStrings() 306 | 307 | # Generate artist tag 308 | if settings['tags']['multiArtistSeparator'] == "default": 309 | if str(settings['featuredToTitle']) == FeaturesOption.MOVE_TITLE: 310 | self.artistsString = ", ".join(self.artist['Main']) 311 | else: 312 | self.artistsString = ", ".join(self.artists) 313 | elif settings['tags']['multiArtistSeparator'] == "andFeat": 314 | self.artistsString = self.mainArtistsString 315 | if self.featArtistsString and str(settings['featuredToTitle']) != FeaturesOption.MOVE_TITLE: 316 | self.artistsString += " " + self.featArtistsString 317 | else: 318 | separator = settings['tags']['multiArtistSeparator'] 319 | if str(settings['featuredToTitle']) == FeaturesOption.MOVE_TITLE: 320 | self.artistsString = separator.join(self.artist['Main']) 321 | else: 322 | self.artistsString = separator.join(self.artists) 323 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/types/__init__.py: -------------------------------------------------------------------------------- 1 | VARIOUS_ARTISTS = "5080" 2 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import string 2 | import re 3 | from deezer import TrackFormats 4 | import os 5 | from deemix.errors import ErrorMessages 6 | 7 | USER_AGENT_HEADER = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " \ 8 | "Chrome/79.0.3945.130 Safari/537.36" 9 | 10 | def canWrite(folder): 11 | return os.access(folder, os.W_OK) 12 | 13 | def generateReplayGainString(trackGain): 14 | return "{0:.2f} dB".format((float(trackGain) + 18.4) * -1) 15 | 16 | def getBitrateNumberFromText(txt): 17 | txt = str(txt).lower() 18 | if txt in ['flac', 'lossless', '9']: 19 | return TrackFormats.FLAC 20 | if txt in ['mp3', '320', '3']: 21 | return TrackFormats.MP3_320 22 | if txt in ['128', '1']: 23 | return TrackFormats.MP3_128 24 | if txt in ['360', '360_hq', '15']: 25 | return TrackFormats.MP4_RA3 26 | if txt in ['360_mq', '14']: 27 | return TrackFormats.MP4_RA2 28 | if txt in ['360_lq', '13']: 29 | return TrackFormats.MP4_RA1 30 | return None 31 | 32 | def changeCase(txt, case_type): 33 | if case_type == "lower": 34 | return txt.lower() 35 | if case_type == "upper": 36 | return txt.upper() 37 | if case_type == "start": 38 | txt = txt.strip().split(" ") 39 | for i, word in enumerate(txt): 40 | if word[0] in ['(', '{', '[', "'", '"']: 41 | txt[i] = word[0] + word[1:].capitalize() 42 | else: 43 | txt[i] = word.capitalize() 44 | return " ".join(txt) 45 | if case_type == "sentence": 46 | return txt.capitalize() 47 | return str 48 | 49 | def removeFeatures(title): 50 | clean = title 51 | found = False 52 | pos = -1 53 | if re.search(r"[\s(]\(?\s?feat\.?\s", clean): 54 | pos = re.search(r"[\s(]\(?\s?feat\.?\s", clean).start(0) 55 | found = True 56 | if re.search(r"[\s(]\(?\s?ft\.?\s", clean): 57 | pos = re.search(r"[\s(]\(?\s?ft\.?\s", clean).start(0) 58 | found = True 59 | openBracket = clean[pos] == '(' or clean[pos+1] == '(' 60 | otherBracket = clean.find('(', pos+2) 61 | if found: 62 | tempTrack = clean[:pos] 63 | if ")" in clean and openBracket: 64 | tempTrack += clean[clean.find(")", pos+2) + 1:] 65 | if not openBracket and otherBracket != -1: 66 | tempTrack += f" {clean[otherBracket:]}" 67 | clean = tempTrack.strip() 68 | clean = ' '.join(clean.split()) 69 | return clean 70 | 71 | def andCommaConcat(lst): 72 | tot = len(lst) 73 | result = "" 74 | for i, art in enumerate(lst): 75 | result += art 76 | if tot != i + 1: 77 | if tot - 1 == i + 1: 78 | result += " & " 79 | else: 80 | result += ", " 81 | return result 82 | 83 | def uniqueArray(arr): 84 | for iPrinc, namePrinc in enumerate(arr): 85 | for iRest, nRest in enumerate(arr): 86 | if iPrinc!=iRest and namePrinc.lower() in nRest.lower(): 87 | del arr[iRest] 88 | return arr 89 | 90 | def removeDuplicateArtists(artist, artists): 91 | artists = uniqueArray(artists) 92 | for role in artist.keys(): 93 | artist[role] = uniqueArray(artist[role]) 94 | return (artist, artists) 95 | 96 | def formatListener(key, data=None): 97 | if key == "startAddingArtist": 98 | return f"Started gathering {data['name']}'s albums ({data['id']})" 99 | if key == "finishAddingArtist": 100 | return f"Finished gathering {data['name']}'s albums ({data['id']})" 101 | if key == "updateQueue": 102 | uuid = f"[{data['uuid']}]" 103 | if data.get('downloaded'): 104 | shortFilepath = data['downloadPath'][len(data['extrasPath']):] 105 | return f"{uuid} Completed download of {shortFilepath}" 106 | if data.get('failed'): 107 | return f"{uuid} {data['data']['artist']} - {data['data']['title']} :: {data['error']}" 108 | if data.get('progress'): 109 | return f"{uuid} Download at {data['progress']}%" 110 | if data.get('conversion'): 111 | return f"{uuid} Conversion at {data['conversion']}%" 112 | return uuid 113 | if key == "downloadInfo": 114 | message = data['state'] 115 | if data['state'] == "getTags": message = "Getting tags." 116 | elif data['state'] == "gotTags": message = "Tags got." 117 | elif data['state'] == "getBitrate": message = "Getting download URL." 118 | elif data['state'] == "bitrateFallback": message = "Desired bitrate not found, falling back to lower bitrate." 119 | elif data['state'] == "searchFallback": message = "This track has been searched for, result might not be 100% exact." 120 | elif data['state'] == "gotBitrate": message = "Download URL got." 121 | elif data['state'] == "getAlbumArt": message = "Downloading album art." 122 | elif data['state'] == "gotAlbumArt": message = "Album art downloaded." 123 | elif data['state'] == "downloading": 124 | message = "Downloading track." 125 | if data['alreadyStarted']: 126 | message += f" Recovering download from {data['value']}." 127 | else: 128 | message += f" Downloading {data['value']} bytes." 129 | elif data['state'] == "downloaded": message = "Track downloaded." 130 | elif data['state'] == "alreadyDownloaded": message = "Track already downloaded." 131 | elif data['state'] == "tagging": message = "Tagging track." 132 | elif data['state'] == "tagged": message = "Track tagged." 133 | return f"[{data['uuid']}] {data['data']['artist']} - {data['data']['title']} :: {message}" 134 | if key == "downloadWarn": 135 | errorMessage = ErrorMessages[data['state']] 136 | solutionMessage = "" 137 | if data['solution'] == 'fallback': solutionMessage = "Using fallback id." 138 | if data['solution'] == 'search': solutionMessage = "Searching for alternative." 139 | return f"[{data['uuid']}] {data['data']['artist']} - {data['data']['title']} :: {errorMessage} {solutionMessage}" 140 | if key == "currentItemCancelled": 141 | return f"Current item cancelled ({data})" 142 | if key == "removedFromQueue": 143 | return f"[{data}] Removed from the queue" 144 | if key == "finishDownload": 145 | return f"[{data}] Finished downloading" 146 | if key == "startConversion": 147 | return f"[{data}] Started converting" 148 | if key == "finishConversion": 149 | return f"[{data['uuid']}] Finished converting" 150 | return "" 151 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/utils/crypto.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | 3 | from Cryptodome.Cipher import Blowfish, AES 4 | from Cryptodome.Hash import MD5 5 | 6 | def _md5(data): 7 | h = MD5.new() 8 | h.update(data.encode() if isinstance(data, str) else data) 9 | return h.hexdigest() 10 | 11 | def _ecbCrypt(key, data): 12 | return binascii.hexlify(AES.new(key.encode(), AES.MODE_ECB).encrypt(data)) 13 | 14 | def _ecbDecrypt(key, data): 15 | return AES.new(key.encode(), AES.MODE_ECB).decrypt(binascii.unhexlify(data.encode("utf-8"))) 16 | 17 | def generateBlowfishKey(trackId): 18 | SECRET = 'g4el58wc0zvf9na1' 19 | idMd5 = _md5(trackId) 20 | bfKey = "" 21 | for i in range(16): 22 | bfKey += chr(ord(idMd5[i]) ^ ord(idMd5[i + 16]) ^ ord(SECRET[i])) 23 | return str.encode(bfKey) 24 | 25 | def decryptChunk(key, data): 26 | return Blowfish.new(key, Blowfish.MODE_CBC, b"\x00\x01\x02\x03\x04\x05\x06\x07").decrypt(data) 27 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/utils/deezer.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from deemix.utils.crypto import _md5 3 | from deemix.utils import USER_AGENT_HEADER 4 | CLIENT_ID = "172365" 5 | CLIENT_SECRET = "fb0bec7ccc063dab0417eb7b0d847f34" 6 | 7 | def getAccessToken(email, password): 8 | accessToken = None 9 | password = _md5(password) 10 | request_hash = _md5(''.join([CLIENT_ID, email, password, CLIENT_SECRET])) 11 | try: 12 | response = requests.get( 13 | 'https://api.deezer.com/auth/token', 14 | params={ 15 | 'app_id': CLIENT_ID, 16 | 'login': email, 17 | 'password': password, 18 | 'hash': request_hash 19 | }, 20 | headers={"User-Agent": USER_AGENT_HEADER} 21 | ).json() 22 | accessToken = response.get('access_token') 23 | if accessToken == "undefined": accessToken = None 24 | except Exception: 25 | pass 26 | return accessToken 27 | 28 | def getArlFromAccessToken(accessToken): 29 | if not accessToken: return None 30 | arl = None 31 | session = requests.Session() 32 | try: 33 | session.get( 34 | "https://api.deezer.com/platform/generic/track/3135556", 35 | headers={"Authorization": f"Bearer {accessToken}", "User-Agent": USER_AGENT_HEADER} 36 | ) 37 | response = session.get( 38 | 'https://www.deezer.com/ajax/gw-light.php?method=user.getArl&input=3&api_version=1.0&api_token=null', 39 | headers={"User-Agent": USER_AGENT_HEADER} 40 | ).json() 41 | arl = response.get('results') 42 | except Exception: 43 | pass 44 | return arl 45 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/utils/localpaths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import sys 3 | import os 4 | import re 5 | from deemix.utils import canWrite 6 | 7 | homedata = Path.home() 8 | userdata = "" 9 | musicdata = "" 10 | 11 | def checkPath(path): 12 | if path == "": return "" 13 | if not path.is_dir(): return "" 14 | if not canWrite(path): return "" 15 | return path 16 | 17 | def getConfigFolder(): 18 | global userdata 19 | if userdata != "": return userdata 20 | if os.getenv("XDG_CONFIG_HOME") and userdata == "": 21 | userdata = Path(os.getenv("XDG_CONFIG_HOME")) 22 | userdata = checkPath(userdata) 23 | if os.getenv("APPDATA") and userdata == "": 24 | userdata = Path(os.getenv("APPDATA")) 25 | userdata = checkPath(userdata) 26 | if sys.platform.startswith('darwin') and userdata == "": 27 | userdata = homedata / 'Library' / 'Application Support' 28 | userdata = checkPath(userdata) 29 | if userdata == "": 30 | userdata = homedata / '.config' 31 | userdata = checkPath(userdata) 32 | 33 | if userdata == "": userdata = Path(os.getcwd()) / 'config' 34 | else: userdata = userdata / 'deemix' 35 | 36 | if os.getenv("DEEMIX_DATA_DIR"): 37 | userdata = Path(os.getenv("DEEMIX_DATA_DIR")) 38 | return userdata 39 | 40 | def getMusicFolder(): 41 | global musicdata 42 | if musicdata != "": return musicdata 43 | if os.getenv("XDG_MUSIC_DIR") and musicdata == "": 44 | musicdata = Path(os.getenv("XDG_MUSIC_DIR")) 45 | musicdata = checkPath(musicdata) 46 | if (homedata / '.config' / 'user-dirs.dirs').is_file() and musicdata == "": 47 | with open(homedata / '.config' / 'user-dirs.dirs', 'r', encoding="utf-8") as f: 48 | userDirs = f.read() 49 | musicdata_search = re.search(r"XDG_MUSIC_DIR=\"(.*)\"", userDirs) 50 | if musicdata_search: 51 | musicdata = musicdata_search.group(1) 52 | musicdata = Path(os.path.expandvars(musicdata)) 53 | musicdata = checkPath(musicdata) 54 | if os.name == 'nt' and musicdata == "": 55 | try: 56 | musicKeys = ['My Music', '{4BD8D571-6D19-48D3-BE97-422220080E43}'] 57 | regData = os.popen(r'reg.exe query "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"').read().split('\r\n') 58 | for i, line in enumerate(regData): 59 | if line == "": continue 60 | if i == 1: continue 61 | line = line.split(' ') 62 | if line[1] in musicKeys: 63 | musicdata = Path(line[3]) 64 | break 65 | musicdata = checkPath(musicdata) 66 | except Exception: 67 | musicdata = "" 68 | if musicdata == "": 69 | musicdata = homedata / 'Music' 70 | musicdata = checkPath(musicdata) 71 | 72 | if musicdata == "": musicdata = Path(os.getcwd()) / 'music' 73 | else: musicdata = musicdata / 'deemix Music' 74 | 75 | if os.getenv("DEEMIX_MUSIC_DIR"): 76 | musicdata = Path(os.getenv("DEEMIX_MUSIC_DIR")) 77 | return musicdata 78 | -------------------------------------------------------------------------------- /local_packages/deemix/deemix/utils/pathtemplates.py: -------------------------------------------------------------------------------- 1 | import re 2 | from os.path import sep as pathSep 3 | from pathlib import Path 4 | from unicodedata import normalize 5 | from deezer import TrackFormats 6 | 7 | bitrateLabels = { 8 | TrackFormats.MP4_RA3: "360 HQ", 9 | TrackFormats.MP4_RA2: "360 MQ", 10 | TrackFormats.MP4_RA1: "360 LQ", 11 | TrackFormats.FLAC : "FLAC", 12 | TrackFormats.MP3_320: "320", 13 | TrackFormats.MP3_128: "128", 14 | TrackFormats.DEFAULT: "128", 15 | TrackFormats.LOCAL : "MP3" 16 | } 17 | 18 | def fixName(txt, char='_'): 19 | txt = str(txt) 20 | txt = re.sub(r'[\0\/\\:*?"<>|]', char, txt) 21 | txt = normalize("NFC", txt) 22 | return txt 23 | 24 | def fixLongName(name): 25 | def fixEndOfData(bString): 26 | try: 27 | bString.decode() 28 | return True 29 | except Exception: 30 | return False 31 | if pathSep in name: 32 | sepName = name.split(pathSep) 33 | name = "" 34 | for txt in sepName: 35 | txt = fixLongName(txt) 36 | name += txt + pathSep 37 | name = name[:-1] 38 | else: 39 | name = name.encode('utf-8')[:200] 40 | while not fixEndOfData(name): 41 | name = name[:-1] 42 | name = name.decode() 43 | return name 44 | 45 | 46 | def antiDot(string): 47 | while string[-1:] == "." or string[-1:] == " " or string[-1:] == "\n": 48 | string = string[:-1] 49 | if len(string) < 1: 50 | string = "dot" 51 | return string 52 | 53 | 54 | def pad(num, max_val, settings): 55 | if int(settings['paddingSize']) == 0: 56 | paddingSize = len(str(max_val)) 57 | else: 58 | paddingSize = len(str(10 ** (int(settings['paddingSize']) - 1))) 59 | if paddingSize == 1: 60 | paddingSize = 2 61 | if settings['padTracks']: 62 | return str(num).zfill(paddingSize) 63 | return str(num) 64 | 65 | def generatePath(track, downloadObject, settings): 66 | filenameTemplate = "%artist% - %title%" 67 | singleTrack = False 68 | if downloadObject.type == "track": 69 | if settings['createSingleFolder']: 70 | filenameTemplate = settings['albumTracknameTemplate'] 71 | else: 72 | filenameTemplate = settings['tracknameTemplate'] 73 | singleTrack = True 74 | elif downloadObject.type == "album": 75 | filenameTemplate = settings['albumTracknameTemplate'] 76 | else: 77 | filenameTemplate = settings['playlistTracknameTemplate'] 78 | 79 | filename = generateTrackName(filenameTemplate, track, settings) 80 | 81 | filepath = Path(settings['downloadLocation'] or '.') 82 | artistPath = None 83 | coverPath = None 84 | extrasPath = None 85 | 86 | if settings['createPlaylistFolder'] and track.playlist and not settings['tags']['savePlaylistAsCompilation']: 87 | filepath = filepath / generatePlaylistName(settings['playlistNameTemplate'], track.playlist, settings) 88 | 89 | if track.playlist and not settings['tags']['savePlaylistAsCompilation']: 90 | extrasPath = filepath 91 | 92 | if ( 93 | (settings['createArtistFolder'] and not track.playlist) or 94 | (settings['createArtistFolder'] and track.playlist and settings['tags']['savePlaylistAsCompilation']) or 95 | (settings['createArtistFolder'] and track.playlist and settings['createStructurePlaylist']) 96 | ): 97 | filepath = filepath / generateArtistName(settings['artistNameTemplate'], track.album.mainArtist, settings, rootArtist=track.album.rootArtist) 98 | artistPath = filepath 99 | 100 | if (settings['createAlbumFolder'] and 101 | (not singleTrack or (singleTrack and settings['createSingleFolder'])) and 102 | (not track.playlist or 103 | (track.playlist and settings['tags']['savePlaylistAsCompilation']) or 104 | (track.playlist and settings['createStructurePlaylist']) 105 | ) 106 | ): 107 | filepath = filepath / generateAlbumName(settings['albumNameTemplate'], track.album, settings, track.playlist) 108 | coverPath = filepath 109 | 110 | if not extrasPath: extrasPath = filepath 111 | 112 | if ( 113 | int(track.album.discTotal) > 1 and ( 114 | (settings['createAlbumFolder'] and settings['createCDFolder']) and 115 | (not singleTrack or (singleTrack and settings['createSingleFolder'])) and 116 | (not track.playlist or 117 | (track.playlist and settings['tags']['savePlaylistAsCompilation']) or 118 | (track.playlist and settings['createStructurePlaylist']) 119 | ) 120 | )): 121 | filepath = filepath / f'CD{track.discNumber}' 122 | 123 | # Remove subfolders from filename and add it to filepath 124 | if pathSep in filename: 125 | tempPath = filename[:filename.rfind(pathSep)] 126 | filepath = filepath / tempPath 127 | filename = filename[filename.rfind(pathSep) + len(pathSep):] 128 | 129 | return (filename, filepath, artistPath, coverPath, extrasPath) 130 | 131 | 132 | def generateTrackName(filename, track, settings): 133 | c = settings['illegalCharacterReplacer'] 134 | filename = filename.replace("%title%", fixName(track.title, c)) 135 | filename = filename.replace("%artist%", fixName(track.mainArtist.name, c)) 136 | filename = filename.replace("%artists%", fixName(", ".join(track.artists), c)) 137 | filename = filename.replace("%allartists%", fixName(track.artistsString, c)) 138 | filename = filename.replace("%mainartists%", fixName(track.mainArtistsString, c)) 139 | if track.featArtistsString: 140 | filename = filename.replace("%featartists%", fixName('('+track.featArtistsString+')', c)) 141 | else: 142 | filename = filename.replace("%featartists%", '') 143 | filename = filename.replace("%album%", fixName(track.album.title, c)) 144 | filename = filename.replace("%albumartist%", fixName(track.album.mainArtist.name, c)) 145 | filename = filename.replace("%tracknumber%", pad(track.trackNumber, track.album.trackTotal, settings)) 146 | filename = filename.replace("%tracktotal%", str(track.album.trackTotal)) 147 | filename = filename.replace("%discnumber%", str(track.discNumber)) 148 | filename = filename.replace("%disctotal%", str(track.album.discTotal)) 149 | if len(track.album.genre) > 0: 150 | filename = filename.replace("%genre%", fixName(track.album.genre[0], c)) 151 | else: 152 | filename = filename.replace("%genre%", "Unknown") 153 | filename = filename.replace("%year%", str(track.date.year)) 154 | filename = filename.replace("%date%", track.dateString) 155 | filename = filename.replace("%bpm%", str(track.bpm)) 156 | filename = filename.replace("%label%", fixName(track.album.label, c)) 157 | filename = filename.replace("%isrc%", track.ISRC) 158 | if (track.album.barcode): 159 | filename = filename.replace("%upc%", track.album.barcode) 160 | filename = filename.replace("%explicit%", "(Explicit)" if track.explicit else "") 161 | 162 | filename = filename.replace("%track_id%", str(track.id)) 163 | filename = filename.replace("%album_id%", str(track.album.id)) 164 | filename = filename.replace("%artist_id%", str(track.mainArtist.id)) 165 | if track.playlist: 166 | filename = filename.replace("%playlist_id%", str(track.playlist.playlistID)) 167 | filename = filename.replace("%position%", pad(track.position, track.playlist.trackTotal, settings)) 168 | else: 169 | filename = filename.replace("%playlist_id%", '') 170 | filename = filename.replace("%position%", pad(track.position, track.album.trackTotal, settings)) 171 | filename = filename.replace('\\', pathSep).replace('/', pathSep) 172 | return antiDot(fixLongName(filename)) 173 | 174 | 175 | def generateAlbumName(foldername, album, settings, playlist=None): 176 | c = settings['illegalCharacterReplacer'] 177 | if playlist and settings['tags']['savePlaylistAsCompilation']: 178 | foldername = foldername.replace("%album_id%", "pl_" + str(playlist.playlistID)) 179 | foldername = foldername.replace("%genre%", "Compile") 180 | else: 181 | foldername = foldername.replace("%album_id%", str(album.id)) 182 | if len(album.genre) > 0: 183 | foldername = foldername.replace("%genre%", fixName(album.genre[0], c)) 184 | else: 185 | foldername = foldername.replace("%genre%", "Unknown") 186 | foldername = foldername.replace("%album%", fixName(album.title, c)) 187 | foldername = foldername.replace("%artist%", fixName(album.mainArtist.name, c)) 188 | foldername = foldername.replace("%artist_id%", str(album.mainArtist.id)) 189 | if album.rootArtist: 190 | foldername = foldername.replace("%root_artist%", fixName(album.rootArtist.name, c)) 191 | foldername = foldername.replace("%root_artist_id%", str(album.rootArtist.id)) 192 | else: 193 | foldername = foldername.replace("%root_artist%", fixName(album.mainArtist.name, c)) 194 | foldername = foldername.replace("%root_artist_id%", str(album.mainArtist.id)) 195 | foldername = foldername.replace("%tracktotal%", str(album.trackTotal)) 196 | foldername = foldername.replace("%disctotal%", str(album.discTotal)) 197 | if album.recordType: 198 | foldername = foldername.replace("%type%", fixName(album.recordType.capitalize(), c)) 199 | if album.barcode: 200 | foldername = foldername.replace("%upc%", album.barcode) 201 | foldername = foldername.replace("%explicit%", "(Explicit)" if album.explicit else "") 202 | foldername = foldername.replace("%label%", fixName(album.label, c)) 203 | foldername = foldername.replace("%year%", str(album.date.year)) 204 | foldername = foldername.replace("%date%", album.dateString) 205 | foldername = foldername.replace("%bitrate%", bitrateLabels[int(album.bitrate)]) 206 | 207 | foldername = foldername.replace('\\', pathSep).replace('/', pathSep) 208 | return antiDot(fixLongName(foldername)) 209 | 210 | 211 | def generateArtistName(foldername, artist, settings, rootArtist=None): 212 | c = settings['illegalCharacterReplacer'] 213 | foldername = foldername.replace("%artist%", fixName(artist.name, c)) 214 | foldername = foldername.replace("%artist_id%", str(artist.id)) 215 | if rootArtist: 216 | foldername = foldername.replace("%root_artist%", fixName(rootArtist.name, c)) 217 | foldername = foldername.replace("%root_artist_id%", str(rootArtist.id)) 218 | else: 219 | foldername = foldername.replace("%root_artist%", fixName(artist.name, c)) 220 | foldername = foldername.replace("%root_artist_id%", str(artist.id)) 221 | foldername = foldername.replace('\\', pathSep).replace('/', pathSep) 222 | return antiDot(fixLongName(foldername)) 223 | 224 | 225 | def generatePlaylistName(foldername, playlist, settings): 226 | c = settings['illegalCharacterReplacer'] 227 | foldername = foldername.replace("%playlist%", fixName(playlist.title, c)) 228 | foldername = foldername.replace("%playlist_id%", fixName(playlist.playlistID, c)) 229 | foldername = foldername.replace("%owner%", fixName(playlist.owner['name'], c)) 230 | foldername = foldername.replace("%owner_id%", str(playlist.owner['id'])) 231 | foldername = foldername.replace("%year%", str(playlist.date.year)) 232 | foldername = foldername.replace("%date%", str(playlist.dateString)) 233 | foldername = foldername.replace("%explicit%", "(Explicit)" if playlist.explicit else "") 234 | foldername = foldername.replace('\\', pathSep).replace('/', pathSep) 235 | return antiDot(fixLongName(foldername)) 236 | 237 | def generateDownloadObjectName(foldername, queueItem, settings): 238 | c = settings['illegalCharacterReplacer'] 239 | foldername = foldername.replace("%title%", fixName(queueItem.title, c)) 240 | foldername = foldername.replace("%artist%", fixName(queueItem.artist, c)) 241 | foldername = foldername.replace("%size%", str(queueItem.size)) 242 | foldername = foldername.replace("%type%", fixName(queueItem.type, c)) 243 | foldername = foldername.replace("%id%", fixName(queueItem.id, c)) 244 | foldername = foldername.replace("%bitrate%", bitrateLabels[int(queueItem.bitrate)]) 245 | foldername = foldername.replace('\\', pathSep).replace('/', pathSep).replace(pathSep, c) 246 | return antiDot(fixLongName(foldername)) 247 | -------------------------------------------------------------------------------- /local_packages/deemix/requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | pycryptodomex 3 | mutagen 4 | requests 5 | deezer-py 6 | spotipy>=2.11.0 7 | -------------------------------------------------------------------------------- /local_packages/deemix/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import pathlib 3 | from setuptools import find_packages, setup 4 | 5 | HERE = pathlib.Path(__file__).parent 6 | README = (HERE / "README.md").read_text() 7 | 8 | setup( 9 | name="deemix", 10 | version="3.6.6", 11 | description="A barebone deezer downloader library", 12 | long_description=README, 13 | long_description_content_type="text/markdown", 14 | author="RemixDev", 15 | author_email="RemixDev64@gmail.com", 16 | license="GPL3", 17 | classifiers=[ 18 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 19 | "Programming Language :: Python :: 3 :: Only", 20 | "Programming Language :: Python :: 3.7", 21 | "Operating System :: OS Independent", 22 | ], 23 | python_requires='>=3.7', 24 | packages=find_packages(exclude=("tests",)), 25 | include_package_data=True, 26 | install_requires=["click", "pycryptodomex", "mutagen", "requests", "deezer-py>=1.3.0"], 27 | extras_require={ 28 | "spotify": ["spotipy>=2.11.0"] 29 | }, 30 | entry_points={ 31 | "console_scripts": [ 32 | "deemix=deemix.__main__:download", 33 | ] 34 | }, 35 | ) 36 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from deemix.downloader import Downloader 2 | from deemix import generateDownloadObject 3 | from deemix.itemgen import GenerationError 4 | from deemix.settings import load as load_deemix_settings 5 | 6 | from deezer import Deezer as deemixDeezer 7 | 8 | from plexapi.myplex import MyPlexAccount 9 | 10 | import yaml 11 | 12 | from pathlib import Path 13 | import os 14 | import shutil 15 | import time 16 | 17 | import logging 18 | 19 | # Configure the logging format 20 | logging.basicConfig( 21 | format='[%(levelname)s] %(asctime)s: %(message)s', 22 | datefmt='%H:%M:%S', 23 | level=logging.INFO) 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | def deezer_login(): 28 | deezer = deemixDeezer() 29 | deezer.login_via_arl(downloadArl.strip()) 30 | 31 | if not deezer.current_user: 32 | logging.error("Couldn't log in to Deezer, check arl") 33 | quit() 34 | 35 | logging.info(f"Logged in to Deezer as {deezer.current_user['name']}") 36 | 37 | return deezer 38 | 39 | 40 | def load_download_arl(): 41 | if (deemix_folder / '.arl').is_file(): 42 | with open(deemix_folder / '.arl', 'r', encoding="utf-8") as f: 43 | arl = f.readline().rstrip("\n").strip() 44 | else: 45 | raise Exception("No arl provided") 46 | with open(deemix_folder / '.arl', 'w', encoding="utf-8") as f: 47 | f.write(arl) 48 | 49 | return arl 50 | 51 | 52 | def load_deezync_config(): 53 | config_path = '/config/config.yaml' 54 | 55 | # copy template config if no config exists 56 | if not os.path.isfile(config_path): 57 | shutil.copy('config.yaml', config_path) # Deezync config 58 | shutil.copy('deemix/config.json', '/config/deemix/config.json') # Deemix config 59 | 60 | logging.info(f"Restored template deemix/config.yaml, please set up configs!") 61 | logging.info("Quit for user to set up configs") 62 | 63 | quit() 64 | 65 | logging.debug(f"Loading config from {config_path}") 66 | with open(config_path, 'r') as deezync_config: 67 | loaded_config = yaml.safe_load(deezync_config) 68 | 69 | logging.info("Loaded config") 70 | return loaded_config 71 | 72 | 73 | # Deemix 74 | deemix_folder = Path('/config/deemix') 75 | settings = load_deemix_settings(deemix_folder) 76 | downloadArl = load_download_arl() 77 | 78 | # login to deezer download account 79 | dz: deemixDeezer = deezer_login() 80 | plex_server = None 81 | 82 | downloaded_tracks = [] 83 | cached_deezer_playlists = {} 84 | playlist_last_sync_time = {} 85 | 86 | 87 | def connect_plex(): 88 | global plex_server 89 | 90 | if not config['plex_token'] or not config['plex_token'] or not config['plex_token']: 91 | logging.error("Missing Plex credentials, won't sync") 92 | return 93 | 94 | if not plex_server: 95 | logging.debug("Logging in to Plex") 96 | account = MyPlexAccount(token=config['plex_token']) 97 | logging.info("Logged in to Plex") 98 | 99 | logging.debug(f"Connecting to server {config['plex_server']}") 100 | plex_server = account.resource(config['plex_server']).connect() 101 | 102 | logging.info(f"Connected to Plex server {config['plex_server']}") 103 | 104 | 105 | def deezer_plex_sync(deezer_playlists): 106 | global plex_server 107 | 108 | # get all tracks + playlists in Plex library 109 | plex_playlists = plex_server.playlists() 110 | all_library_tracks = plex_server.library.section(config['plex_library']).all(libtype='track') 111 | 112 | missing_by_playlist = {} 113 | 114 | sync_playlist_counter = 1 115 | for deezer_playlist in deezer_playlists: 116 | for looped_config in deezer_playlist_configs: 117 | if looped_config['id'] == deezer_playlist['id']: 118 | playlist_config = looped_config 119 | break 120 | 121 | logging.info(f"Syncing {sync_playlist_counter}/{len(deezer_playlists)} Deezer playlist " 122 | f"'{deezer_playlist['title']}' to Plex...") 123 | sync_playlist_counter += 1 124 | 125 | # check if Plex playlist already exists 126 | plex_playlist = None 127 | plex_playlist_tracks = None 128 | plex_playlist_unmatched_tracks = None 129 | 130 | for playlist in plex_playlists: 131 | if deezer_playlist['title'] == playlist.title: 132 | plex_playlist = playlist 133 | plex_playlist_tracks = plex_playlist.items() 134 | plex_playlist_unmatched_tracks = plex_playlist_tracks.copy() 135 | break 136 | 137 | missing_by_playlist[deezer_playlist['id']] = deezer_playlist['tracks']['data'] 138 | found_plex_tracks = [] 139 | 140 | # search track match in Plex library 141 | for track in all_library_tracks: 142 | matching_tracks = [ 143 | t for t in missing_by_playlist[deezer_playlist['id']] 144 | 145 | if (t['title'].lower() == track.title.lower() or 146 | t['title'].lower().replace('?', '_').replace('/', '_').replace('[', '(').replace(']', ')') 147 | == track.title.lower()) and 148 | ((track.artist() 149 | and t['artist']['name'].lower() in track.artist().title.replace('’', '\'').lower()) or 150 | t['artist']['name'].lower() in str(track.originalTitle).replace('’', '\'').lower()) 151 | ] 152 | 153 | # process matching track if found 154 | if matching_tracks: 155 | # use first match 156 | matching_track = matching_tracks[0] 157 | 158 | # remove matching track from missing_tracks 159 | missing_by_playlist[deezer_playlist['id']].remove(matching_track) 160 | 161 | # remove matching track from unmatched tracks in Plex playlist 162 | if plex_playlist_unmatched_tracks and track in plex_playlist_tracks: 163 | plex_playlist_unmatched_tracks.remove(track) 164 | 165 | # add matching track to found_plex_tracks if not already in playlist 166 | if (not plex_playlist_tracks or 167 | not any(playlist_track.ratingKey == track.ratingKey for playlist_track in plex_playlist_tracks)): 168 | found_plex_tracks.append(track) 169 | 170 | # for matching_track in matching_tracks: 171 | # logging.debug(f"Matching track title: {matching_track['title']}") 172 | # logging.debug(f"Matching track artist: {matching_track['artist']['name']}/{track.artist().title}") 173 | 174 | removed_counter = 0 175 | 176 | # add missing tracks to the end of the playlist 177 | if len(found_plex_tracks) > 0: 178 | if plex_playlist: 179 | plex_playlist.addItems(found_plex_tracks) 180 | if playlist_config['delete_unmatched_from_playlist'] == 1: 181 | plex_playlist.removeItems(plex_playlist_unmatched_tracks) 182 | removed_counter = len(plex_playlist_unmatched_tracks) 183 | else: 184 | # if the playlist does not exist, create it 185 | plex_playlist = plex_server.createPlaylist(deezer_playlist['title'], items=found_plex_tracks) 186 | logging.info(f"Created Plex playlist: {deezer_playlist['title']}") 187 | 188 | # update playlist cover 189 | if playlist_config['sync_cover_description'] == 1: 190 | plex_playlist.uploadPoster(deezer_playlist['picture_xl']) 191 | # update description 192 | if deezer_playlist['description']: 193 | plex_playlist.editSummary(deezer_playlist['description']) 194 | 195 | # logging 196 | logging.info( 197 | f"Synced '{deezer_playlist['title']}' playlist. Added: {len(found_plex_tracks)}, removed: " 198 | f"{removed_counter}, missing: {len(missing_by_playlist[deezer_playlist['id']])}") 199 | for track in missing_by_playlist[deezer_playlist['id']]: 200 | logging.debug(f"Missing title: {track['title']}, artist: {track['artist']['name']}") 201 | 202 | # remove empty playlist from missing tracks if no tracks missing 203 | if len(missing_by_playlist[deezer_playlist['id']]) < 1: 204 | missing_by_playlist.pop(deezer_playlist['id']) 205 | 206 | logging.info("Synced Deezer playlists to Plex") 207 | return missing_by_playlist 208 | 209 | 210 | def download(links, bitrate): 211 | # generate download objects for URLs 212 | downloadObjects = [] 213 | for link in links: 214 | try: 215 | # attempt to generate download object for the current URL 216 | downloadObject = generateDownloadObject(dz, link, bitrate) 217 | except GenerationError as e: 218 | # skip link if errors occurs 219 | logging.error(f"{e.link}: {e.message}") 220 | continue 221 | # append single object to the downloadObjects list 222 | downloadObjects.append(downloadObject) 223 | 224 | # download objects 225 | for obj in downloadObjects: 226 | Downloader(dz, obj, settings).start() 227 | 228 | 229 | def download_deezer_playlists(deezer_playlist_missing_tracks): 230 | download_count = 0 231 | for playlist_config in deezer_playlist_configs: 232 | playlist_id = playlist_config['id'] 233 | 234 | for track in deezer_playlist_missing_tracks.get(playlist_id, []): 235 | # skip download if track has already been attempted before 236 | if downloaded_tracks.__contains__(track['id']): 237 | continue 238 | downloaded_tracks.append(track['id']) 239 | 240 | logging.info(f"Download {track['title']} by {track['artist']['name']}...") 241 | 242 | # download track 243 | download([track['link']], playlist_config['bitrate']) 244 | download_count = download_count + 1 245 | 246 | logging.info(f"Downloaded {track['title']} by {track['artist']['name']}") 247 | 248 | logging.info(f"Downloaded {download_count} new tracks") 249 | 250 | 251 | def file_contains_string(folder_path, search_string): 252 | matching_files = [] 253 | 254 | # Walk through all files and directories in the given folder and its subdirectories 255 | for root, dirs, files in os.walk(folder_path): 256 | # Check files in the current directory for the search string 257 | matching_files.extend([os.path.join(root, file) for file in files if search_string in file]) 258 | 259 | # Check subdirectories for the search string in their names 260 | matching_files.extend([os.path.join(root, directory) for directory in dirs if search_string in dir]) 261 | 262 | return matching_files 263 | 264 | 265 | def loop(): 266 | connect_plex() 267 | if not plex_server: 268 | logging.error("Can't sync because no Plex server provided") 269 | 270 | cycleCount = 1 271 | while True: 272 | logging.info(f"Starting sync cycle {cycleCount}") 273 | 274 | # check playlists for updates 275 | changed_deezer_playlists = update_playlists() 276 | logging.info(f"Detected {len(changed_deezer_playlists)} playlist changes on Deezer") 277 | 278 | # skip further sync if no changes detected 279 | if not changed_deezer_playlists: 280 | intervalSeconds = 20 281 | logging.info(f"No changes detected, sleeping for {intervalSeconds} seconds...") 282 | time.sleep(intervalSeconds) 283 | pass 284 | 285 | # sync playlists to Plex and find missing tracks 286 | deezer_playlist_missing_tracks = deezer_plex_sync(changed_deezer_playlists) 287 | 288 | if deezer_playlist_missing_tracks: 289 | # download missing tracks 290 | logging.info(f"Downloading missing tracks") 291 | download_deezer_playlists(deezer_playlist_missing_tracks) 292 | 293 | # give Plex time to index new files 294 | seconds = 60 295 | logging.info(f"Wait {seconds} seconds until syncing playlist with new downloads") 296 | time.sleep(seconds) 297 | 298 | # resync playlists with new files 299 | deezer_plex_sync(changed_deezer_playlists) 300 | 301 | logging.info(f"Finished sync cycle {cycleCount}") 302 | cycleCount = cycleCount + 1 303 | 304 | 305 | def update_playlists(): 306 | global cached_deezer_playlists 307 | global playlist_last_sync_time 308 | 309 | logging.info("Update playlist info from Deezer...") 310 | 311 | playlists = [] 312 | update_count = 0 313 | for playlist_config in deezer_playlist_configs: 314 | # skip if set inactive by user 315 | if playlist_config['active'] == 0: 316 | continue 317 | 318 | # skip if sync interval not reached yet 319 | if playlist_last_sync_time.__contains__(playlist_config['id']): 320 | seconds_between = time.time() - playlist_last_sync_time[playlist_config['id']] 321 | if seconds_between < playlist_config['sync_interval_seconds']: 322 | continue 323 | 324 | # fetch playlist 325 | try: 326 | playlist = dz.api.get_playlist(playlist_config['id']) 327 | 328 | # detect changes to playlist using checksum 329 | if (not cached_deezer_playlists.__contains__(playlist_config['id']) or 330 | cached_deezer_playlists[playlist_config['id']] != playlist['checksum']): 331 | 332 | # save changes and add to changed playlist queue 333 | playlists.append(playlist) 334 | cached_deezer_playlists[playlist_config['id']] = playlist['checksum'] 335 | 336 | playlist_last_sync_time[playlist_config['id']] = time.time() 337 | update_count += 1 338 | except Exception as e: 339 | logging.info(f"Failed to fetch playlist {playlist_config['id']}: {e}") 340 | 341 | logging.info(f"Updated {update_count} playlists") 342 | return playlists 343 | 344 | 345 | # load Deezync configuration from the YAML file 346 | config = load_deezync_config() 347 | deezer_playlist_configs = config['deezer_playlists'] 348 | 349 | # check music path 350 | music_path = "/music" 351 | if not os.path.exists(music_path): 352 | raise FileNotFoundError(f"The specified music path '{music_path}' does not exist.") 353 | 354 | # quit if no playlists to sync 355 | if len(deezer_playlist_configs) < 1: 356 | logging.info("No sync playlist configured, quitting...") 357 | quit() 358 | 359 | loop() 360 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PlexAPI==4.15.7 2 | deezer-py==1.3.7 3 | PyYAML~=6.0.1 4 | --------------------------------------------------------------------------------