Merge branch 'develop'

This commit is contained in:
Dniel97
2023-11-23 23:39:40 +01:00
2 changed files with 151 additions and 93 deletions

View File

@@ -23,7 +23,7 @@ module_information = ModuleInformation(
global_settings={ global_settings={
'tv_token': '7m7Ap0JC9j1cOM3n', 'tv_token': '7m7Ap0JC9j1cOM3n',
'tv_secret': 'vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY=', 'tv_secret': 'vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY=',
'mobile_atmos_token': 'dN2N95wCyEBTllu4', 'mobile_atmos_token': 'km8T1xS355y7dd3H',
'mobile_default_token': 'WAU9gXp3tHhK4Nns', 'mobile_default_token': 'WAU9gXp3tHhK4Nns',
'enable_mobile': True, 'enable_mobile': True,
'force_non_spatial': False, 'force_non_spatial': False,
@@ -74,86 +74,81 @@ class ModuleInterface:
if not saved_sessions: if not saved_sessions:
saved_sessions = {} saved_sessions = {}
if self.settings['enable_mobile']: if not 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:
self.available_sessions = [SessionType.TV.name] self.available_sessions = [SessionType.TV.name]
username, password = None, None while True:
for session_type in self.available_sessions: login_session = None
# 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'])
if session_type in saved_sessions: def auth_and_save_session(session, session_type):
logging.debug(f'{module_information.service_name}: {session_type} session found, loading') session = self.auth_session(session, session_type, login_session)
# 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!')
# get the dict representation from the TidalSession object and save it into saved_session/loginstorage # 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) module_controller.temporary_settings_controller.set('sessions', saved_sessions)
return session
# always try to refresh session # ask for login if there are no saved sessions
if not sessions[session_type].valid(): if not saved_sessions:
sessions[session_type].refresh() login_session_type = None
# Save the refreshed session in the temporary settings if len(self.available_sessions) == 1:
saved_sessions[session_type] = sessions[session_type].get_storage() login_session_type = self.available_sessions[0]
module_controller.temporary_settings_controller.set('sessions', saved_sessions) 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 # check for a valid subscription
subscription = self.check_subscription(sessions[session_type].get_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 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': if saved_sessions:
self.print('Exiting...') break
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
# only needed for region locked albums where the track is available but force_album_format is used # only needed for region locked albums where the track is available but force_album_format is used
self.album_cache = {} self.album_cache = {}
@@ -161,6 +156,39 @@ class ModuleInterface:
# load the Tidal session with all saved sessions (TV, Mobile Atmos, Mobile Default) # load the Tidal session with all saved sessions (TV, Mobile Atmos, Mobile Default)
self.session: TidalApi = TidalApi(sessions) 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: def check_subscription(self, subscription: str) -> bool:
# returns true if "disable_subscription_checks" is enabled or subscription is HIFI Plus # 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'}: if not self.disable_subscription_check and subscription not in {'HIFI', 'PREMIUM_PLUS'}:
@@ -185,10 +213,13 @@ class ModuleInterface:
return 'https://resources.tidal.com/videos/{0}/{1}x{1}.mp4'.format(cover_id.replace('-', '/'), size) 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): 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 = [] items = []
for i in results[query_type.name + 's'].get('items'): for i in results.get('items'):
duration, name = None, None duration, name = None, None
if query_type is DownloadTypeEnum.artist: if query_type is DownloadTypeEnum.artist:
name = i.get('name') name = i.get('name')
@@ -424,26 +455,43 @@ class ModuleInterface:
# add the region locked album to the cache in order to properly use it later (force_album_format) # 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} 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 media_tags = track_data['mediaMetadata']['tags']
# get FLACs faster, instead of using MPEG-DASH format = None
# lmao what did I smoke when I wrote this, track_data and not album_data! if 'HIRES_LOSSLESS' in media_tags and quality_tier is QualityEnum.HIFI:
if (self.settings['force_non_spatial'] or ( format = 'flac_hires'
(quality_tier is QualityEnum.LOSSLESS or track_data.get('audioQuality') == 'LOSSLESS') if 'SONY_360RA' in media_tags and not format and not self.settings['force_non_spatial']:
and track_data.get('audioModes') == ['STEREO'])) and ( format = '360ra'
SessionType.MOBILE_DEFAULT.name in self.available_sessions): if 'DOLBY_ATMOS' in media_tags and not format and not self.settings['force_non_spatial']:
self.session.default = SessionType.MOBILE_DEFAULT if self.settings['prefer_ac4']:
elif (track_data.get('audioModes') == ['SONY_360RA'] format = 'ac4'
or ('DOLBY_ATMOS' in track_data.get('audioModes') and self.settings['prefer_ac4'])) \ else:
and SessionType.MOBILE_ATMOS.name in self.available_sessions: format = 'ac3'
self.session.default = SessionType.MOBILE_ATMOS
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 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
session = SessionType.TV
if session.name in self.available_sessions:
self.session.default = session
else: else:
self.session.default = SessionType.TV format = None
# define all default values in case the stream_data is None (region locked) # 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 audio_track, mqa_file, track_codec, bitrate, download_args, error = None, None, CodecEnum.FLAC, None, None, None
try: 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 format != 'flac_hires' else 'HI_RES_LOSSLESS')
except TidalRequestError as e: except TidalRequestError as e:
error = e error = e
# definitely region locked # definitely region locked
@@ -489,7 +537,8 @@ class ModuleInterface:
download_args = {'file_url': manifest['urls'][0]} download_args = {'file_url': manifest['urls'][0]}
# https://en.wikipedia.org/wiki/Audio_bit_depth#cite_ref-1 # 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 sample_rate = 48 if track_codec in {CodecEnum.EAC3, CodecEnum.MHA1, CodecEnum.AC4} else 44.1
if stream_data: if stream_data:
@@ -498,7 +547,8 @@ class ModuleInterface:
'LOW': 96, 'LOW': 96,
'HIGH': 320, 'HIGH': 320,
'LOSSLESS': 1411, 'LOSSLESS': 1411,
'HI_RES': None 'HI_RES': None,
'HI_RES_LOSSLESS': None
}[stream_data['audioQuality']] }[stream_data['audioQuality']]
# manually set bitrate for immersive formats # manually set bitrate for immersive formats
@@ -514,6 +564,8 @@ class ModuleInterface:
# more precise bitrate tidal uses MPEG-DASH # more precise bitrate tidal uses MPEG-DASH
if audio_track: if audio_track:
bitrate = audio_track.bitrate // 1000 bitrate = audio_track.bitrate // 1000
if stream_data['audioQuality'] == 'HI_RES_LOSSLESS':
sample_rate = audio_track.sample_rate / 1000
# now set everything for MQA # now set everything for MQA
if mqa_file is not None and mqa_file.is_mqa: if mqa_file is not None and mqa_file.is_mqa:

View File

@@ -13,7 +13,7 @@ import requests
import urllib3 import urllib3
import urllib.parse as urlparse import urllib.parse as urlparse
from urllib.parse import parse_qs from urllib.parse import parse_qs, quote
from datetime import datetime, timedelta from datetime import datetime, timedelta
from utils.utils import create_requests_session from utils.utils import create_requests_session
@@ -110,7 +110,7 @@ class TidalApi(object):
return resp_json return resp_json
def get_stream_url(self, track_id, quality): 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', 'playbackmode': 'STREAM',
'assetpresentation': 'FULL', 'assetpresentation': 'FULL',
'audioquality': quality, 'audioquality': quality,
@@ -174,6 +174,11 @@ class TidalApi(object):
def get_video(self, video_id): def get_video(self, video_id):
return self._get('videos/' + str(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): def get_favorite_tracks(self, user_id):
return self._get('users/' + str(user_id) + '/favorites/tracks') return self._get('users/' + str(user_id) + '/favorites/tracks')
@@ -323,8 +328,8 @@ class TidalMobileSession(TidalSession):
self.code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=') 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.code_challenge = base64.urlsafe_b64encode(hashlib.sha256(self.code_verifier).digest()).rstrip(b'=')
self.client_unique_key = secrets.token_hex(8) 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' \ 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/109.0.5414.80 Mobile Safari/537.36' '(KHTML, like Gecko) Version/4.0 Chrome/119.0.6045.163 Mobile Safari/537.36'
def auth(self, username: str, password: str): def auth(self, username: str, password: str):
s = requests.Session() s = requests.Session()
@@ -355,10 +360,11 @@ class TidalMobileSession(TidalSession):
# try Tidal DataDome cookie request # try Tidal DataDome cookie request
r = s.post('https://dd.tidal.com/js/', data={ r = s.post('https://dd.tidal.com/js/', data={
'jsData': f'{{"opts":"endpoint,ajaxListenerPath","ua":"{self.user_agent}"}}',
'ddk': '1F633CDD8EF22541BD6D9B1B8EF13A', # API Key (required) 'ddk': '1F633CDD8EF22541BD6D9B1B8EF13A', # API Key (required)
'Referer': r.url, # Referer authorize link (required) 'Referer': quote(r.url), # Referer authorize link (required)
'responsePage': 'origin', # useless? 'responsePage': 'origin', # useless?
'ddv': '4.4.7' # useless? 'ddv': '4.17.0' # useless?
}, headers={ }, headers={
'user-agent': self.user_agent, 'user-agent': self.user_agent,
'content-type': 'application/x-www-form-urlencoded' 'content-type': 'application/x-www-form-urlencoded'
@@ -408,7 +414,7 @@ class TidalMobileSession(TidalSession):
raise TidalAuthError(r.text) raise TidalAuthError(r.text)
# retrieve access code # 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, 'user-agent': self.user_agent,
'accept-language': 'en-US', 'accept-language': 'en-US',
'x-requested-with': 'com.aspiro.tidal' 'x-requested-with': 'com.aspiro.tidal'