├── .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 |
4 |
5 | Deezync
6 |
7 |
8 |
9 | [](https://discord.gg/4cJczdyu9n)
10 | [](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 | #  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 | #  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 |
--------------------------------------------------------------------------------