diff --git a/CHANGELOG.md b/CHANGELOG.md index f268daf..f3dd8df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog + + +## v3.0.3 + +### [Fix] + +- Change pickle path to addon directory + + + +## v3.0.2 + +### [Fix] + +- Temporary muzic file is now special://temp/deezer-temp.mp3 + + + +## v3.0.1 + +### [Fix] + +- Fixed streaming 128kbps when no MD5 for legacy + + +## v3.0.0 + +### [Breaking changes] + +- Added auth from https://connect.deezer.com/oauth/user_auth.php +- Added API from https://www.deezer.com/ajax/gw-light.php for getting track tokens +- Added getting streams from https://media.deezer.com/v1/get_url +- Added streams decryption +- Now working with free tiers + ## v2.0.5 ### [Fix] diff --git a/README.md b/README.md index 06a9cef..7d62c0f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # DeezerKodi This is an unofficial Deezer addon for Kodi. -It works **only for premium account** (Deezer API doesn't allow streaming for free accounts). +It works with all kind of deezer accounts. +Streaming is limited to 128kb/s for free accounts, but can play up to flac for premium's. This addon works for personal and family accounts (profiles are usable). It supports access to your playlists. Searching for tracks, albums and artists is also implemented. Let me know if there is a particular feature you want to see implemented. -Tracks are played in 128kb resolution as that's the max available bitrate for TVs from Deezer. # Installation @@ -50,3 +50,5 @@ new [issue](https://github.com/Valentin271/DeezerKodi/issues). This addon is forked from [kubatek94](https://github.com/kubatek94)' s [DeezerKodi repo](https://github.com/kubatek94/DeezerKodi). Only the streaming API, icon and fanart have been reused. + +Auth and decryption are adapted from [orpheusdl-deezer](https://github.com/TheKVT/orpheusdl-deezer). \ No newline at end of file diff --git a/addons.xml b/addons.xml index 7969033..a033baf 100644 --- a/addons.xml +++ b/addons.xml @@ -1,5 +1,5 @@ - + @@ -10,28 +10,28 @@ audio - + - https://raw.githubusercontent.com/Valentin271/DeezerKodi/master/addons.xml - https://raw.githubusercontent.com/Valentin271/DeezerKodi/master/addons.xml.md5 + https://raw.githubusercontent.com/Davy39/DeezerKodi/master/addons.xml + https://raw.githubusercontent.com/Davy39/DeezerKodi/master/addons.xml.md5 md5 This addon allows you to listen to music from Deezer. - This addon allows you to stream music from Deezer. You can browse your playlists and loved tracks, search for tracks, artists and albums. A premium account is REQUIRED. + This addon allows you to stream music from Deezer. You can browse your playlists and loved tracks, search for tracks, artists and albums. This addon was not provided by Deezer. -- Fixed "authenticate user failed" when streaming +- Fixed login and streaming (2024) all en GPL-3.0 - https://github.com/Valentin271/DeezerKodi/issues - https://github.com/Valentin271/DeezerKodi - https://github.com/Valentin271/DeezerKodi/tree/master/src + https://github.com/Davy39/DeezerKodi/issues + https://github.com/Davy39/DeezerKodi + https://github.com/Davy39/DeezerKodi/tree/master/src resources/icon.png diff --git a/addons.xml.md5 b/addons.xml.md5 index 5d74b34..2bfeeea 100644 --- a/addons.xml.md5 +++ b/addons.xml.md5 @@ -1 +1 @@ -f97fa007c33dd7b5de4c6335222dc01c addons.xml +efc6a4a4587c89b4a63b5e3f5b182f77 addons.xml diff --git a/plugin.audio.deezer/addon.xml b/plugin.audio.deezer/addon.xml index 4ad6c76..6cd9cab 100644 --- a/plugin.audio.deezer/addon.xml +++ b/plugin.audio.deezer/addon.xml @@ -1,5 +1,5 @@ - + @@ -10,28 +10,28 @@ audio - + - https://raw.githubusercontent.com/Valentin271/DeezerKodi/master/addons.xml - https://raw.githubusercontent.com/Valentin271/DeezerKodi/master/addons.xml.md5 + https://raw.githubusercontent.com/Davy39/DeezerKodi/master/addons.xml + https://raw.githubusercontent.com/Davy39/DeezerKodi/master/addons.xml.md5 md5 This addon allows you to listen to music from Deezer. - This addon allows you to stream music from Deezer. You can browse your playlists and loved tracks, search for tracks, artists and albums. A premium account is REQUIRED. + This addon allows you to stream music from Deezer. You can browse your playlists and loved tracks, search for tracks, artists and albums. This addon was not provided by Deezer. -- Fixed "authenticate user failed" when streaming +- Fixed login and streaming (2024) all en GPL-3.0 - https://github.com/Valentin271/DeezerKodi/issues - https://github.com/Valentin271/DeezerKodi - https://github.com/Valentin271/DeezerKodi/tree/master/src + https://github.com/Davy39/DeezerKodi/issues + https://github.com/Davy39/DeezerKodi + https://github.com/Davy39/DeezerKodi/tree/master/src resources/icon.png diff --git a/plugin.audio.deezer/plugin.audio.deezer-2.0.5.zip.md5 b/plugin.audio.deezer/plugin.audio.deezer-2.0.5.zip.md5 deleted file mode 100644 index 3db686a..0000000 --- a/plugin.audio.deezer/plugin.audio.deezer-2.0.5.zip.md5 +++ /dev/null @@ -1 +0,0 @@ -4592a81e342e27a14df7b648627dd3b7 *plugin.audio.deezer-2.0.5.zip diff --git a/plugin.audio.deezer/plugin.audio.deezer-2.0.5.zip b/plugin.audio.deezer/plugin.audio.deezer-3.0.3.zip similarity index 87% rename from plugin.audio.deezer/plugin.audio.deezer-2.0.5.zip rename to plugin.audio.deezer/plugin.audio.deezer-3.0.3.zip index ab75266..4e664e4 100644 Binary files a/plugin.audio.deezer/plugin.audio.deezer-2.0.5.zip and b/plugin.audio.deezer/plugin.audio.deezer-3.0.3.zip differ diff --git a/plugin.audio.deezer/plugin.audio.deezer-3.0.3.zip.md5 b/plugin.audio.deezer/plugin.audio.deezer-3.0.3.zip.md5 new file mode 100644 index 0000000..18fed1b --- /dev/null +++ b/plugin.audio.deezer/plugin.audio.deezer-3.0.3.zip.md5 @@ -0,0 +1 @@ +a97b349ba15d694eb062eec53aded4fd *plugin.audio.deezer-3.0.3.zip diff --git a/src/addon.xml b/src/addon.xml index 4ad6c76..6cd9cab 100755 --- a/src/addon.xml +++ b/src/addon.xml @@ -1,5 +1,5 @@ - + @@ -10,28 +10,28 @@ audio - + - https://raw.githubusercontent.com/Valentin271/DeezerKodi/master/addons.xml - https://raw.githubusercontent.com/Valentin271/DeezerKodi/master/addons.xml.md5 + https://raw.githubusercontent.com/Davy39/DeezerKodi/master/addons.xml + https://raw.githubusercontent.com/Davy39/DeezerKodi/master/addons.xml.md5 md5 This addon allows you to listen to music from Deezer. - This addon allows you to stream music from Deezer. You can browse your playlists and loved tracks, search for tracks, artists and albums. A premium account is REQUIRED. + This addon allows you to stream music from Deezer. You can browse your playlists and loved tracks, search for tracks, artists and albums. This addon was not provided by Deezer. -- Fixed "authenticate user failed" when streaming +- Fixed login and streaming (2024) all en GPL-3.0 - https://github.com/Valentin271/DeezerKodi/issues - https://github.com/Valentin271/DeezerKodi - https://github.com/Valentin271/DeezerKodi/tree/master/src + https://github.com/Davy39/DeezerKodi/issues + https://github.com/Davy39/DeezerKodi + https://github.com/Davy39/DeezerKodi/tree/master/src resources/icon.png diff --git a/src/app/actions/tracks_actions.py b/src/app/actions/tracks_actions.py index 43d582c..12e6168 100644 --- a/src/app/actions/tracks_actions.py +++ b/src/app/actions/tracks_actions.py @@ -1,6 +1,6 @@ import xbmcgui import xbmcplugin - +import xbmcvfs from app.actions.base_actions import BaseActions from app.http import Api from lib.helpers.logger import Logger @@ -17,14 +17,19 @@ def play(cls, identifiant): :param identifiant: ID of the track to stream :return: track's stream url """ + # Get stream url url = Api.instance().request_streaming(identifiant) - if url.startswith('http'): - Logger.debug("Playing track " + identifiant) - item = xbmcgui.ListItem(path=url) + # Create a path to download stream + path = xbmcvfs.translatePath(f'special://temp/deezer-temp.mp3') + # Download and decrypt stream + Api.instance().dl_track(identifiant, url, path) + # Create an item to give to kodi's player + item = xbmcgui.ListItem(path=path) + # Send the item to Kodi's player xbmcplugin.setResolvedUrl(cls.app.args().addon_handle, True, listitem=item) else: - Logger.warn("Unplayable track " + identifiant) + Logger.warn("Unable to get url of track " + identifiant) xbmcgui.Dialog().notification( "Unplayable track", "Track " + identifiant + " cannot be played.", @@ -32,5 +37,4 @@ def play(cls, identifiant): sound=False ) xbmcplugin.setResolvedUrl(cls.app.args().addon_handle, False, xbmcgui.ListItem()) - return [] diff --git a/src/app/http/api.py b/src/app/http/api.py index 7843345..a89af62 100644 --- a/src/app/http/api.py +++ b/src/app/http/api.py @@ -4,8 +4,12 @@ """ import hashlib +from math import ceil import pickle - +from random import randint +from time import time +from Cryptodome.Hash import MD5 +from Cryptodome.Cipher import Blowfish, AES import requests import xbmcvfs @@ -21,10 +25,12 @@ class Api(object): """ _API_BASE_URL = "http://api.deezer.com/2.0/{service}/{id}/{method}" - _API_BASE_STREAMING_URL = "http://tv.deezer.com/smarttv/streaming.php" - _API_AUTH_URL = "http://tv.deezer.com/smarttv/authentication.php" - - __CACHE_FILE = xbmcvfs.translatePath('special://temp/deezer-api.pickle') + _API_BASE_STREAMING_URL = "https://media.deezer.com/v1/get_url" + _API_AUTH_URL = "https://connect.deezer.com/oauth/user_auth.php" + _API_BASE_GW_URL = 'https://www.deezer.com/ajax/gw-light.php' + _CLIENT_ID = "447462" + _CLIENT_SECRET = "a83bf7f38ad2f137e444727cfc3775cf" + __CACHE_FILE = xbmcvfs.translatePath('special://profile/addon_data/plugin.audio.deezer/deezer-api.pickle') __INSTANCE = None @@ -47,7 +53,7 @@ def instance(cls): Logger.debug("Api instance not saved, trying to get token ...") cls.__INSTANCE = cls( - Settings.get('username'), + Settings.get('email'), Settings.get('password') ) except LoadedCredentialsException: @@ -55,7 +61,7 @@ def instance(cls): cls.clean_cache() cls.__INSTANCE = cls( - Settings.get('username'), + Settings.get('email'), Settings.get('password') ) @@ -68,25 +74,41 @@ def clean_cache(cls) -> None: xbmcvfs.delete(Api.__CACHE_FILE) cls.__INSTANCE = None - def __init__(self, username: str, password: str): + def __init__(self, email: str, password: str): """ - Instantiate a Connection object from username and password. + Instantiate a Connection object from email and password. - :param str username: The user's name + :param str email: The user's email :param str password: The user's password :raise EmptyCredentialsException: If the credentials are empty """ Logger.debug("Creating new API connection ...") - self._username = username + self._email = email self._password = "" self.set_password(password) self._access_token = None if self.empty_credentials(): - raise EmptyCredentialsException("Username and password are required!") + raise EmptyCredentialsException("email and password are required!") + + self.s = requests.Session() + self.s.headers.update({ + 'accept': '*/*', + 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36', + 'content-type': 'text/plain;charset=UTF-8', + 'origin': 'https://www.deezer.com', + 'sec-fetch-site': 'same-origin', + 'sec-fetch-mode': 'same-origin', + 'sec-fetch-dest': 'empty', + 'referer': 'https://www.deezer.com/', + 'accept-language': 'en-US,en;q=0.9', + }) + self.s.get('https://www.deezer.com') self._obtain_access_token() + self._obtain_api_token() + def set_password(self, password: str) -> None: """ Save md5 of user password. @@ -119,7 +141,7 @@ def load(cache_file: str = __CACHE_FILE): cls = pickle.load(file) if cls.empty_credentials(check_token=True): - raise LoadedCredentialsException("Loaded empty username or password") + raise LoadedCredentialsException("Loaded empty email or password") return cls @@ -130,7 +152,7 @@ def empty_credentials(self, check_token: bool = False) -> bool: :param check_token: Tells wether or not to check the access token :return: True if any credential is empty, False if there are all filled """ - empty_creds = self._username == "" or self._password == "" + empty_creds = self._email == "" or self._password == "" empty_tok = False if check_token: @@ -143,16 +165,53 @@ def _obtain_access_token(self) -> None: Obtain access token by pretending to be a smart tv. """ Logger.debug("Connection: Getting access token from API ...") + hashed_params = hashlib.md5(f'{self._CLIENT_ID}{self._email}{self._password}{self._CLIENT_SECRET}'.encode('utf-8')).hexdigest() + url = f'{self._API_AUTH_URL}?app_id={self._CLIENT_ID}&login={self._email}&password={self._password}&hash={hashed_params}' + response = self.s.get(url).json() + Api.check_error(response) + self._access_token = response['access_token'] - response = requests.get(self._API_AUTH_URL, params={ - 'login': self._username, - 'password': self._password, - 'device': 'panasonic' - }).json() - Api.check_error(response) + def _obtain_api_token(self) -> None: + """ + Obtain api token from gw_api. + """ + Logger.debug("Connection: Getting api token ...") + self.call_gw_api('deezer.getUserData') + # TODO : allow login with ARL + #arl = self.call_gw_api('user.getArl') + #self.s.cookies.set('arl', self._arl, domain='.deezer.com') + + + def call_gw_api(self, method, payload={}): + api_token = '' if method in ('deezer.getUserData', 'user.getArl') else self.api_token + params = { + 'method': method, + 'input': 3, + 'api_version': 1.0, + 'api_token': api_token, + 'cid': randint(0, 1_000_000_000), + } + resp = self.s.post(self._API_BASE_GW_URL, params=params, json=payload).json() + if resp['error']!=[]: + self.s.cookies.clear() + Logger.debug(f"Error: {resp['error']}") + else: + resp = resp['results'] + if method == 'deezer.getUserData': + self.api_token = resp['checkForm'] + self.country = resp['COUNTRY'] + self.license_token = resp['USER']['OPTIONS']['license_token'] + self.renew_timestamp = ceil(time()) + self.language = resp['USER']['SETTING']['global']['language'] + self.available_formats = ['MP3_128'] + format_dict = {'web_hq': 'MP3_320', 'web_lossless': 'FLAC'} + for k, v in format_dict.items(): + if resp['USER']['OPTIONS'][k]: + self.available_formats.append(v) + print(self.available_formats) + return resp - self._access_token = response['access_token'] def request( self, @@ -180,7 +239,7 @@ def request( parameters = {} url = self._API_BASE_URL.format(service=service, id=identifiant, method=method) - response = requests.get(url, params=_merge_two_dicts( + response = self.s.get(url, params=_merge_two_dicts( {'output': 'json', 'access_token': self._access_token}, parameters )).json() @@ -213,6 +272,74 @@ def request_url(url: str): return response + + def get_track_url(self, id, track_token, track_token_expiry, format): + # renews license token + if time() - self.renew_timestamp >= 3600: + self.call_gw_api('deezer.getUserData') + # renews track token + if time() - track_token_expiry >= 0: + track_token = self.call_gw_api('song.getData', {'sng_id': id, 'array_default': ['TRACK_TOKEN']})['TRACK_TOKEN'] + json = { + 'license_token': self.license_token, + 'media': [ + { + 'type': 'FULL', + 'formats': [{'cipher': 'BF_CBC_STRIPE', 'format': format}] + } + ], + 'track_tokens': [track_token] + } + resp = self.s.post(self._API_BASE_STREAMING_URL, json=json).json() + return resp['data'][0]['media'][0]['sources'][0]['url'] + + + def get_legacy_track_url(self, md5_origin, format, id, media_version): + format_num = { + 'MP3_MISC': '0', + 'MP3_128': '1', + 'MP4_RA1': '13', + 'MP4_RA2': '14', + 'MP4_RA3': '15', + 'MHM1_RA1': '16', + 'MHM1_RA2': '17', + 'MHM1_RA3': '18' + }[format] + # mashing a bunch of metadata and hashing it with MD5 + info = b"\xa4".join([i.encode() for i in [ + md5_origin, format_num, str(id), str(media_version) + ]]) + hash = MD5.new(info).hexdigest() + # hash + metadata + hash_metadata = hash.encode() + b"\xa4" + info + b"\xa4" + # padding + while len(hash_metadata) % 16 > 0: + hash_metadata += b"\0" + # AES encryption + track_url_key = "jo6aey6haid2Teih" + legacy_url_cipher = AES.new(track_url_key.encode('ascii'), AES.MODE_ECB) + result = legacy_url_cipher.encrypt(hash_metadata).hex() + # getting url + return f"https://cdns-proxy-{md5_origin[0]}.dzcdn.net/mobile/1/{result}" + + + def dl_track(self, id, url, path): + md5_id = MD5.new(str(id).encode()).hexdigest().encode('ascii') + bf_secret = "g4el58wc0zvf9na1".encode('ascii') + bf_key = bytes([md5_id[i] ^ md5_id[i + 16] ^ bf_secret[i] for i in range(16)]) + req = requests.get(url, stream=True) + req.raise_for_status() + with open(path, "wb") as file: + for i, chunk in enumerate(req.iter_content(2048)): + # every 3rd chunk is encrypted + if i % 3 == 0 and len(chunk) == 2048: + # yes, the cipher has to be reset on every chunk. + # those deezer devs were prob smoking crack when they made this DRM + cipher = Blowfish.new(bf_key, Blowfish.MODE_CBC, b"\x00\x01\x02\x03\x04\x05\x06\x07") + chunk = cipher.decrypt(chunk) + file.write(chunk) + + def request_streaming(self, identifiant: str = '', st_type: str = 'track'): """ Make a request to get the streaming url of `type` with `id`. @@ -225,19 +352,23 @@ def request_streaming(self, identifiant: str = '', st_type: str = 'track'): "Connection: Requesting streaming for {} with id {} ...".format(st_type, identifiant) ) - if self._access_token is None: - self._obtain_access_token() - - response = requests.get(self._API_BASE_STREAMING_URL, params={ - 'access_token': self._access_token, - "{}_id".format(st_type): identifiant, - 'device': 'panasonic' - }) - - if st_type.startswith('radio') or st_type.startswith('artist'): - return response.json() + if time() - self.renew_timestamp >= 3600: + self.call_gw_api('deezer.getUserData') + t_data = self.call_gw_api('deezer.pageTrack', {'sng_id': identifiant})['DATA'] + print(t_data) + id = t_data['SNG_ID'] + track_token = t_data['TRACK_TOKEN'] + track_token_expiry = t_data['TRACK_TOKEN_EXPIRE'] + format = self.available_formats[-1] + md5_origin = t_data.get('MD5_ORIGIN') + media_version = t_data['MEDIA_VERSION'] + if format not in ('MP3_320', 'FLAC') and md5_origin: + url = self.get_legacy_track_url(md5_origin, format, id, media_version) + else: + url = self.get_track_url(id, track_token, track_token_expiry, format) + + return url - return response.text @staticmethod def check_error(response): diff --git a/src/resources/settings.xml b/src/resources/settings.xml index df47eb3..557a1e2 100755 --- a/src/resources/settings.xml +++ b/src/resources/settings.xml @@ -1,7 +1,7 @@ - + \ No newline at end of file