From a968a1a33a77d121e004035bc749d4b5cce66be2 Mon Sep 17 00:00:00 2001 From: uh wot Date: Wed, 12 Jul 2023 01:15:35 +0200 Subject: [PATCH 1/7] added hires flac support, rewrote session selection entirely --- interface.py | 59 ++++++++++++++++++++++++++++++++++++++-------------- tidal_api.py | 2 +- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/interface.py b/interface.py index 7ade841..f96c010 100644 --- a/interface.py +++ b/interface.py @@ -23,7 +23,7 @@ module_information = ModuleInformation( global_settings={ 'tv_token': '7m7Ap0JC9j1cOM3n', 'tv_secret': 'vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY=', - 'mobile_atmos_token': 'dN2N95wCyEBTllu4', + 'mobile_atmos_token': 'km8T1xS355y7dd3H', 'mobile_default_token': 'WAU9gXp3tHhK4Nns', 'enable_mobile': True, 'force_non_spatial': False, @@ -424,26 +424,52 @@ class ModuleInterface: # add the region locked album to the cache in order to properly use it later (force_album_format) self.album_cache = {album_id: album_data} - # check if album is only available in LOSSLESS and STEREO, so it switches to the MOBILE_DEFAULT which will - # get FLACs faster, instead of using MPEG-DASH - # lmao what did I smoke when I wrote this, track_data and not album_data! - if (self.settings['force_non_spatial'] or ( - (quality_tier is QualityEnum.LOSSLESS or track_data.get('audioQuality') == 'LOSSLESS') - and track_data.get('audioModes') == ['STEREO'])) and ( - SessionType.MOBILE_DEFAULT.name in self.available_sessions): - self.session.default = SessionType.MOBILE_DEFAULT - elif (track_data.get('audioModes') == ['SONY_360RA'] - or ('DOLBY_ATMOS' in track_data.get('audioModes') and self.settings['prefer_ac4'])) \ - and SessionType.MOBILE_ATMOS.name in self.available_sessions: - self.session.default = SessionType.MOBILE_ATMOS + # MOBILE_DEFAULT is used whenever possible to avoid MPEG-DASH, which slows downloading + session = SessionType.MOBILE_DEFAULT + hires_flac = False + media_tags = track_data['mediaMetadata']['tags'] + + if quality_tier is QualityEnum.HIFI: + format = None + + if 'HIRES_LOSSLESS' in media_tags: + format = 'flac_hires' + + if 'SONY_360RA' in media_tags and not format and not self.settings['force_non_spacial']: + format = '360ra' + + if 'DOLBY_ATMOS' in media_tags and not format and not self.settings['force_non_spacial']: + if self.settings['prefer_ac4']: + format = 'ac4' + else: + format = 'ac3' + + session = { + 'flac_hires': SessionType.MOBILE_ATMOS, + '360ra': SessionType.MOBILE_ATMOS, + 'ac4': SessionType.MOBILE_ATMOS, + 'ac3': SessionType.TV, + None: SessionType.MOBILE_DEFAULT, + }[format] + + if format == 'flac_hires': + hires_flac = True + elif 'SONY_360RA' in media_tags: + # if 360RA is available, we don't use the mobile session here because that will get 360RA + # there are no tracks with both 360RA and atmos afaik, + # so this shouldn't be an issue for now + session = SessionType.TV + + if session.name in self.available_sessions: + self.session.default = session else: - self.session.default = SessionType.TV + hires_flac = False # define all default values in case the stream_data is None (region locked) audio_track, mqa_file, track_codec, bitrate, download_args, error = None, None, CodecEnum.FLAC, None, None, None try: - stream_data = self.session.get_stream_url(track_id, self.quality_parse[quality_tier]) + stream_data = self.session.get_stream_url(track_id, self.quality_parse[quality_tier] if not hires_flac else 'HI_RES_LOSSLESS') except TidalRequestError as e: error = e # definitely region locked @@ -498,7 +524,8 @@ class ModuleInterface: 'LOW': 96, 'HIGH': 320, 'LOSSLESS': 1411, - 'HI_RES': None + 'HI_RES': None, + 'HI_RES_LOSSLESS': None }[stream_data['audioQuality']] # manually set bitrate for immersive formats diff --git a/tidal_api.py b/tidal_api.py index 9a3ef04..ab8fc9a 100644 --- a/tidal_api.py +++ b/tidal_api.py @@ -110,7 +110,7 @@ class TidalApi(object): return resp_json def get_stream_url(self, track_id, quality): - return self._get('tracks/' + str(track_id) + '/playbackinfopostpaywall', { + return self._get('tracks/' + str(track_id) + '/playbackinfopostpaywall/v4', { 'playbackmode': 'STREAM', 'assetpresentation': 'FULL', 'audioquality': quality, From d19583413affe6e0befd7f122c6fa03d23a4c89f Mon Sep 17 00:00:00 2001 From: uh wot Date: Wed, 12 Jul 2023 02:14:22 +0200 Subject: [PATCH 2/7] moved spatial formats out of hifi quality since there's a goddamn option for it already god i shouldn't be doing this at 2am --- interface.py | 51 +++++++++++++++++++++------------------------------ 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/interface.py b/interface.py index f96c010..7cf1dc3 100644 --- a/interface.py +++ b/interface.py @@ -424,37 +424,28 @@ class ModuleInterface: # add the region locked album to the cache in order to properly use it later (force_album_format) self.album_cache = {album_id: album_data} - # MOBILE_DEFAULT is used whenever possible to avoid MPEG-DASH, which slows downloading - session = SessionType.MOBILE_DEFAULT - hires_flac = False media_tags = track_data['mediaMetadata']['tags'] + format = None + if 'HIRES_LOSSLESS' in media_tags and quality_tier is QualityEnum.HIFI: + format = 'flac_hires' + if 'SONY_360RA' in media_tags and not format and not self.settings['force_non_spatial']: + format = '360ra' + if 'DOLBY_ATMOS' in media_tags and not format and not self.settings['force_non_spatial']: + if self.settings['prefer_ac4']: + format = 'ac4' + else: + format = 'ac3' - if quality_tier is QualityEnum.HIFI: - format = None + session = { + 'flac_hires': SessionType.MOBILE_ATMOS, + '360ra': SessionType.MOBILE_ATMOS, + 'ac4': SessionType.MOBILE_ATMOS, + 'ac3': SessionType.TV, + # MOBILE_DEFAULT is used whenever possible to avoid MPEG-DASH, which slows downloading + None: SessionType.MOBILE_DEFAULT, + }[format] - if 'HIRES_LOSSLESS' in media_tags: - format = 'flac_hires' - - if 'SONY_360RA' in media_tags and not format and not self.settings['force_non_spacial']: - format = '360ra' - - if 'DOLBY_ATMOS' in media_tags and not format and not self.settings['force_non_spacial']: - if self.settings['prefer_ac4']: - format = 'ac4' - else: - format = 'ac3' - - session = { - 'flac_hires': SessionType.MOBILE_ATMOS, - '360ra': SessionType.MOBILE_ATMOS, - 'ac4': SessionType.MOBILE_ATMOS, - 'ac3': SessionType.TV, - None: SessionType.MOBILE_DEFAULT, - }[format] - - if format == 'flac_hires': - hires_flac = True - elif 'SONY_360RA' in media_tags: + if not format and 'SONY_360RA' in media_tags: # if 360RA is available, we don't use the mobile session here because that will get 360RA # there are no tracks with both 360RA and atmos afaik, # so this shouldn't be an issue for now @@ -463,13 +454,13 @@ class ModuleInterface: if session.name in self.available_sessions: self.session.default = session else: - hires_flac = False + format = None # define all default values in case the stream_data is None (region locked) audio_track, mqa_file, track_codec, bitrate, download_args, error = None, None, CodecEnum.FLAC, None, None, None try: - stream_data = self.session.get_stream_url(track_id, self.quality_parse[quality_tier] if not hires_flac else 'HI_RES_LOSSLESS') + stream_data = self.session.get_stream_url(track_id, self.quality_parse[quality_tier] if format != 'flac_hires' else 'HI_RES_LOSSLESS') except TidalRequestError as e: error = e # definitely region locked From 50ce6dbf619d8c6d3929765af7b1510e00d2adfd Mon Sep 17 00:00:00 2001 From: uh wot Date: Wed, 12 Jul 2023 02:59:01 +0200 Subject: [PATCH 3/7] fixed bit depth and sample rate info on hires flacs --- interface.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/interface.py b/interface.py index 7cf1dc3..f806ba3 100644 --- a/interface.py +++ b/interface.py @@ -506,7 +506,8 @@ class ModuleInterface: download_args = {'file_url': manifest['urls'][0]} # https://en.wikipedia.org/wiki/Audio_bit_depth#cite_ref-1 - bit_depth = 16 if track_codec in {CodecEnum.FLAC, CodecEnum.ALAC} else None + bit_depth = (24 if stream_data and stream_data['audioQuality'] == 'HI_RES_LOSSLESS' else 16) \ + if track_codec in {CodecEnum.FLAC, CodecEnum.ALAC} else None sample_rate = 48 if track_codec in {CodecEnum.EAC3, CodecEnum.MHA1, CodecEnum.AC4} else 44.1 if stream_data: @@ -532,6 +533,8 @@ class ModuleInterface: # more precise bitrate tidal uses MPEG-DASH if audio_track: bitrate = audio_track.bitrate // 1000 + if stream_data['audioQuality'] == 'HI_RES_LOSSLESS': + sample_rate = audio_track.sample_rate / 1000 # now set everything for MQA if mqa_file is not None and mqa_file.is_mqa: From ce13fc9f6b635c0745e18cf207e6eebf07ac4320 Mon Sep 17 00:00:00 2001 From: uh wot Date: Mon, 17 Jul 2023 13:50:50 +0200 Subject: [PATCH 4/7] automated logging in to multiple sessions --- interface.py | 162 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 95 insertions(+), 67 deletions(-) diff --git a/interface.py b/interface.py index f806ba3..d02d4ca 100644 --- a/interface.py +++ b/interface.py @@ -74,86 +74,81 @@ class ModuleInterface: if not saved_sessions: saved_sessions = {} - if self.settings['enable_mobile']: - # check all saved session for a session starting with "MOBILE" - if not any(session for session in saved_sessions.keys() if session[:6] == 'MOBILE'): - confirm = input(' "enable_mobile" is enabled but no MOBILE session was found. Do you want to create a ' - 'MOBILE session (used for AC-4/360RA) [Y/n]? ') - if confirm.upper() == 'N': - self.available_sessions = [SessionType.TV.name] - else: + if not self.settings['enable_mobile']: self.available_sessions = [SessionType.TV.name] - username, password = None, None - for session_type in self.available_sessions: - # create all sessions with the needed API keys - if session_type == SessionType.TV.name: - sessions[session_type] = TidalTvSession(self.settings['tv_token'], self.settings['tv_secret']) - elif session_type == SessionType.MOBILE_ATMOS.name: - sessions[session_type] = TidalMobileSession(self.settings['mobile_atmos_token']) - else: - sessions[session_type] = TidalMobileSession(self.settings['mobile_default_token']) + while True: + login_session = None - if session_type in saved_sessions: - logging.debug(f'{module_information.service_name}: {session_type} session found, loading') - - # load the dictionary from the temporary_settings_controller inside the TidalSession class - sessions[session_type].set_storage(saved_sessions[session_type]) - else: - logging.debug(f'{module_information.service_name}: No {session_type} session found, creating new one') - if session_type == SessionType.TV.name: - self.print(f'{module_information.service_name}: Creating a TV session') - sessions[session_type].auth() - else: - if not username or not password: - self.print(f'{module_information.service_name}: Creating a Mobile session') - self.print(f'{module_information.service_name}: Enter your TIDAL username and password:') - self.print(f'{module_information.service_name}: (password will not be echoed)') - username = input(' Username: ') - password = getpass(' Password: ') - sessions[session_type].auth(username, password) - self.print(f'Successfully logged in, using {session_type} token!') + def auth_and_save_session(session, session_type): + session = self.auth_session(session, session_type, login_session) # get the dict representation from the TidalSession object and save it into saved_session/loginstorage - saved_sessions[session_type] = sessions[session_type].get_storage() + saved_sessions[session_type] = session.get_storage() module_controller.temporary_settings_controller.set('sessions', saved_sessions) + return session - # always try to refresh session - if not sessions[session_type].valid(): - sessions[session_type].refresh() - # Save the refreshed session in the temporary settings - saved_sessions[session_type] = sessions[session_type].get_storage() - module_controller.temporary_settings_controller.set('sessions', saved_sessions) + # ask for login if there are no saved sessions + if not saved_sessions: + login_session_type = None + if len(self.available_sessions) == 1: + login_session_type = self.available_sessions[0] + else: + self.print(f'{module_information.service_name}: Choose a login method:') + self.print(f'{module_information.service_name}: 1. TV (browser)') + self.print(f"{module_information.service_name}: 2. Mobile (username and password, choose TV if this doesn't work)") + + while not login_session_type: + input_str = input(' Login method: ') + try: + login_session_type = { + '1': SessionType.TV.name, + 'tv': SessionType.TV.name, + '2': SessionType.MOBILE_DEFAULT.name, + 'mobile': SessionType.MOBILE_DEFAULT.name, + }[input_str.lower()] + except KeyError: + self.print(f'{module_information.service_name}: Invalid choice, try again') + + login_session = auth_and_save_session(self.init_session(login_session_type), login_session_type) + + for session_type in self.available_sessions: + sessions[session_type] = self.init_session(session_type) + + if session_type in saved_sessions: + logging.debug(f'{module_information.service_name}: {session_type} session found, loading') + + # load the dictionary from the temporary_settings_controller inside the TidalSession class + sessions[session_type].set_storage(saved_sessions[session_type]) + else: + logging.debug(f'{module_information.service_name}: No {session_type} session found, creating new one') + sessions[session_type] = auth_and_save_session(sessions[session_type], session_type) + + # always try to refresh session + if not sessions[session_type].valid(): + sessions[session_type].refresh() + # Save the refreshed session in the temporary settings + saved_sessions[session_type] = sessions[session_type].get_storage() + module_controller.temporary_settings_controller.set('sessions', saved_sessions) - while True: # check for a valid subscription subscription = self.check_subscription(sessions[session_type].get_subscription()) - if subscription: + if not subscription: + confirm = input(' Do you want to relogin? [Y/n]: ') + + if confirm.upper() == 'N': + self.print('Exiting...') + exit() + + # reset saved sessions and loop back to login + saved_sessions = {} break - confirm = input(' Do you want to create a new session? [Y/n]: ') + if not login_session: + login_session = sessions[session_type] - if confirm.upper() == 'N': - self.print('Exiting...') - exit() - - # create a new session finally - if session_type == SessionType.TV.name: - self.print(f'{module_information.service_name}: Recreating a TV session') - sessions[session_type].auth() - else: - self.print(f'{module_information.service_name}: Recreating a Mobile session') - self.print(f'{module_information.service_name}: Enter your TIDAL username and password:') - self.print(f'{module_information.service_name}: (password will not be echoed)') - username = input('Username: ') - password = getpass('Password: ') - sessions[session_type].auth(username, password) - - saved_sessions[session_type] = sessions[session_type].get_storage() - module_controller.temporary_settings_controller.set('sessions', saved_sessions) - - # reset username and password - username, password = None, None + if saved_sessions: + break # only needed for region locked albums where the track is available but force_album_format is used self.album_cache = {} @@ -161,6 +156,39 @@ class ModuleInterface: # load the Tidal session with all saved sessions (TV, Mobile Atmos, Mobile Default) self.session: TidalApi = TidalApi(sessions) + def init_session(self, session_type): + session = None + # initialize session with the needed API keys + if session_type == SessionType.TV.name: + session = TidalTvSession(self.settings['tv_token'], self.settings['tv_secret']) + elif session_type == SessionType.MOBILE_ATMOS.name: + session = TidalMobileSession(self.settings['mobile_atmos_token']) + else: + session = TidalMobileSession(self.settings['mobile_default_token']) + return session + + def auth_session(self, session, session_type, login_session): + if login_session: + # refresh tokens can be used with any client id + # this can be used to switch to any client type from an existing session + session.refresh_token = login_session.refresh_token + session.user_id = login_session.user_id + session.country_code = login_session.country_code + session.refresh() + elif session_type == SessionType.TV.name: + self.print(f'{module_information.service_name}: Creating a TV session') + session.auth() + else: + self.print(f'{module_information.service_name}: Creating a Mobile session') + self.print(f'{module_information.service_name}: Enter your TIDAL username and password:') + self.print(f'{module_information.service_name}: (password will not be echoed)') + username = input(' Username: ') + password = getpass(' Password: ') + session.auth(username, password) + self.print(f'Successfully logged in, using {session_type} token!') + + return session + def check_subscription(self, subscription: str) -> bool: # returns true if "disable_subscription_checks" is enabled or subscription is HIFI Plus if not self.disable_subscription_check and subscription not in {'HIFI', 'PREMIUM_PLUS'}: From 77cd438aaa3f396c83d19b9fe40b11e354fde831 Mon Sep 17 00:00:00 2001 From: uh wot Date: Sat, 4 Nov 2023 02:26:05 +0100 Subject: [PATCH 5/7] fix bot protection in mobile login --- tidal_api.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tidal_api.py b/tidal_api.py index ab8fc9a..916e29e 100644 --- a/tidal_api.py +++ b/tidal_api.py @@ -13,7 +13,7 @@ import requests import urllib3 import urllib.parse as urlparse -from urllib.parse import parse_qs +from urllib.parse import parse_qs, quote from datetime import datetime, timedelta from utils.utils import create_requests_session @@ -332,7 +332,7 @@ class TidalMobileSession(TidalSession): params = { 'response_type': 'code', 'redirect_uri': self.redirect_uri, - 'lang': 'en_US', + 'lang': 'en', 'appMode': 'android', 'client_id': self.client_id, 'client_unique_key': self.client_unique_key, @@ -355,10 +355,11 @@ class TidalMobileSession(TidalSession): # try Tidal DataDome cookie request r = s.post('https://dd.tidal.com/js/', data={ + 'jsData': f'{{"opts":"endpoint,ajaxListenerPath","ua":"{self.user_agent}"}}', 'ddk': '1F633CDD8EF22541BD6D9B1B8EF13A', # API Key (required) - 'Referer': r.url, # Referer authorize link (required) + 'Referer': quote(r.url), # Referer authorize link (required) 'responsePage': 'origin', # useless? - 'ddv': '4.4.7' # useless? + 'ddv': '4.15.0' # useless? }, headers={ 'user-agent': self.user_agent, 'content-type': 'application/x-www-form-urlencoded' @@ -408,7 +409,7 @@ class TidalMobileSession(TidalSession): raise TidalAuthError(r.text) # retrieve access code - r = s.get('https://login.tidal.com/success?lang=en', allow_redirects=False, headers={ + r = s.get('https://login.tidal.com/success', allow_redirects=False, headers={ 'user-agent': self.user_agent, 'accept-language': 'en-US', 'x-requested-with': 'com.aspiro.tidal' From 1e5f3bc2b33f71810568836cb52b8a1ec991822f Mon Sep 17 00:00:00 2001 From: uh wot Date: Mon, 20 Nov 2023 20:50:46 +0100 Subject: [PATCH 6/7] add track search by isrc --- interface.py | 7 +++++-- tidal_api.py | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/interface.py b/interface.py index d02d4ca..2529334 100644 --- a/interface.py +++ b/interface.py @@ -213,10 +213,13 @@ class ModuleInterface: return 'https://resources.tidal.com/videos/{0}/{1}x{1}.mp4'.format(cover_id.replace('-', '/'), size) def search(self, query_type: DownloadTypeEnum, query: str, track_info: TrackInfo = None, limit: int = 20): - results = self.session.get_search_data(query, limit=limit) + if track_info and track_info.tags.isrc: + results = self.session.get_tracks_by_isrc(track_info.tags.isrc) + else: + results = self.session.get_search_data(query, limit=limit)[query_type.name + 's'] items = [] - for i in results[query_type.name + 's'].get('items'): + for i in results.get('items'): duration, name = None, None if query_type is DownloadTypeEnum.artist: name = i.get('name') diff --git a/tidal_api.py b/tidal_api.py index ab8fc9a..d919d2e 100644 --- a/tidal_api.py +++ b/tidal_api.py @@ -174,6 +174,11 @@ class TidalApi(object): def get_video(self, video_id): return self._get('videos/' + str(video_id)) + + def get_tracks_by_isrc(self, isrc): + return self._get('tracks', params={ + 'isrc': isrc + }) def get_favorite_tracks(self, user_id): return self._get('users/' + str(user_id) + '/favorites/tracks') From 85f106c757be794ea111014a5a73bb969a3fe181 Mon Sep 17 00:00:00 2001 From: Dniel97 <6324072+Dniel97@users.noreply.github.com> Date: Thu, 23 Nov 2023 23:39:33 +0100 Subject: [PATCH 7/7] small ua update --- tidal_api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tidal_api.py b/tidal_api.py index d58c023..93178e6 100644 --- a/tidal_api.py +++ b/tidal_api.py @@ -328,8 +328,8 @@ class TidalMobileSession(TidalSession): self.code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=') self.code_challenge = base64.urlsafe_b64encode(hashlib.sha256(self.code_verifier).digest()).rstrip(b'=') self.client_unique_key = secrets.token_hex(8) - self.user_agent = 'Mozilla/5.0 (Linux; Android 13; Pixel 7 Build/TD1A.221105.001; wv) AppleWebKit/537.36' \ - '(KHTML, like Gecko) Version/4.0 Chrome/109.0.5414.80 Mobile Safari/537.36' + self.user_agent = 'Mozilla/5.0 (Linux; Android 13; Pixel 8 Build/TQ2A.230505.002; wv) AppleWebKit/537.36 ' \ + '(KHTML, like Gecko) Version/4.0 Chrome/119.0.6045.163 Mobile Safari/537.36' def auth(self, username: str, password: str): s = requests.Session() @@ -337,7 +337,7 @@ class TidalMobileSession(TidalSession): params = { 'response_type': 'code', 'redirect_uri': self.redirect_uri, - 'lang': 'en', + 'lang': 'en_US', 'appMode': 'android', 'client_id': self.client_id, 'client_unique_key': self.client_unique_key, @@ -364,7 +364,7 @@ class TidalMobileSession(TidalSession): 'ddk': '1F633CDD8EF22541BD6D9B1B8EF13A', # API Key (required) 'Referer': quote(r.url), # Referer authorize link (required) 'responsePage': 'origin', # useless? - 'ddv': '4.15.0' # useless? + 'ddv': '4.17.0' # useless? }, headers={ 'user-agent': self.user_agent, 'content-type': 'application/x-www-form-urlencoded'