Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: stephanlensky/RedSea
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: master
Choose a base ref
...
head repository: zpoo32/RedSea
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: master
Choose a head ref
Able to merge. These branches can be automatically merged.
  • 6 commits
  • 5 files changed
  • 2 contributors

Commits on Jun 8, 2020

  1. Dolby Atmos Support

    zpoo32 committed Jun 8, 2020
    Copy the full SHA
    7e5f249 View commit details
  2. Quick readme fixes

    zpoo32 authored Jun 8, 2020
    Copy the full SHA
    bb5b653 View commit details
  3. Add credits

    zpoo32 authored Jun 8, 2020
    Copy the full SHA
    a040806 View commit details

Commits on Jul 12, 2020

  1. Delete VulnerableTidal.apk

    Infringes copyright, but also is now old, but may still work.
    zpoo32 authored Jul 12, 2020
    Copy the full SHA
    1cf8892 View commit details
  2. Copy the full SHA
    753a219 View commit details
  3. Small changes

    zpoo32 committed Jul 12, 2020
    Copy the full SHA
    6307756 View commit details
Showing with 75 additions and 77 deletions.
  1. +8 −2 config/settings.py
  2. +6 −27 readme.md
  3. +6 −2 redsea/cli.py
  4. +12 −21 redsea/mediadownloader.py
  5. +43 −25 redsea/tidal_api.py
10 changes: 8 additions & 2 deletions config/settings.py
Original file line number Diff line number Diff line change
@@ -34,6 +34,12 @@
# BRUTEFORCEREGION: Attempts to download the track/album with all available accounts if dl fails
BRUTEFORCEREGION = True

# This usually comes along with the authorization header
TOKEN = "dN2N95wCyEBTllu4"

# AUTHHEADER will look like "Bearer abcd......."
AUTHHEADER = "Bearer "

path = "./downloads/"

PRESETS = {
@@ -48,9 +54,9 @@
"track_format": "{tracknumber} - {title}",
"album_format": "{albumartist} - {album}",
"MQA_FLAC_24": False,
"FLAC_16": True,
"FLAC_16": False,
"AAC_320": False,
"AAC_96": False
"AAC_96": True
},

# This will download the highest available quality including MQA
33 changes: 6 additions & 27 deletions readme.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
**This fork has a bunch of bugfixes and feature additions. Check the commit messages for detailed info about changes.**
**This fork is based on stephanlensky's fork of redsudo's fork of mreweilk's fork of svbnet's Redsea where I've disabled a bunch of features to allow for Dolby Atmos downloading. It can also download MQAs (Master quality). Now updated for Tidal 2.26.1 to make way for E-AC-3 downloading for when someone with a rooted NVIDIA Shield TV comes to help with this. This method is unlikely to be patched by Tidal, although there is a very easy way to patch it.**

To get this to work you MUST set your own auth header retrieved from MITM'ing a Tidal (version 2.26.1) APK that has its target version changed (and also cloned with something like App Cloner for some reason, maybe some protection is bypassed by it), which you must get yourself, as it is copyrighted material. To use this downloader you must install Fiddler-Everywhere on any computer and follow [this guide](https://www.telerik.com/blogs/how-to-capture-android-traffic-with-fiddler). Note that the guide is for the old version of Fiddler, so the placement of the options will be different, and so will the port (Everywhere usually has port 8866). Once you have done that, play a song on tidal, select any api.tidal.com entry, and copy the text next to 'X-Tidal-Token:' and'Authorization:' in the header tab on the right side, and copy it into the config file.

To download MQA, also add -p MQA to your command line arguments. For other types of files, look at stock presets below, as this downloader is set up to download Dolby Atmos by default. (Also, tagging is broken for AAC files, as nobody cares about them but it is an easy fix, just replace ftype in the tagging section of mediadownloader.py with the codec name provided by the metadata)

If you are a Windows user, you might want to check out [Athame](https://github.com/svbnet/Athame), a graphical music download client. It also seems to work well on Mono, if you use Linux or OS X.

@@ -12,8 +16,7 @@ RedSea is currently being worked on by members of RED. Reach out to RedSudo for

Introduction
------------
RedSea is a music downloader and tagger for the Tidal music streaming service. It is designed partially as a Tidal API example ~~and partially as a proof-of-concept of the Tidal
lossless download hack~~. Tidal seems to have fixed this hack, so you can't download FLACs on a normal subscription. :(. This repository also hosts a wildly incomplete Python Tidal
RedSea is a music downloader and tagger for the Tidal music streaming service. It is designed partially as a Tidal API example. This repository also hosts a wildly incomplete Python Tidal
API implementation - it is contained in `config/tidal_api.py` and only requires `requests` to be
installed. Note that you will you have to implement the Tidal lossless download hack yourself -- you can find this in `mediadownloader.py`.

@@ -36,30 +39,6 @@ Setting up (with Pipenv)
2. Run `pipenv run python redsea.py -h` to view the help file
3. Run `pipenv run python redsea.py urls` to download lossless files from urls

How to add accounts/sessions
----------------------------
usage: redsea.py auth list
redsea.py auth add
redsea.py auth remove
redsea.py auth default
redsea.py auth reauth

positional arguments:

list Lists stored sessions if any exist

add Prompts for a Tidal username and password and
authorizes a session which then gets stored in
the sessions file

remove Removes a stored session from the sessions file
by name

default Set a default account for redsea to use when the
-a flag has not been passed

reauth Reauthenticates with server to get new sessionId

How to use
----------
usage: redsea.py [-h] [-p PRESET] [-a ACCOUNT] [-s] urls [urls ...]
8 changes: 6 additions & 2 deletions redsea/cli.py
Original file line number Diff line number Diff line change
@@ -86,8 +86,12 @@ def parse_media_option(mo, is_file):
if not components or len(components) <= 2:
print('Invalid URL: ' + m)
exit()
type_ = components[1]
id_ = components[2]
if len(components) == 5:
type_ = components[3]
id_ = components[4]
else:
type_ = components[1]
id_ = components[2]
if type_ == 'album':
type_ = 'a'
elif type_ == 'track':
33 changes: 12 additions & 21 deletions redsea/mediadownloader.py
Original file line number Diff line number Diff line change
@@ -18,7 +18,8 @@

def _mkdir_p(path):
try:
os.makedirs(path)
if not os.path.isdir(path):
os.makedirs(path)
except OSError as exc:
if exc.errno == errno.EEXIST and os.path.isdir(path):
pass
@@ -42,7 +43,7 @@ def __init__(self, api, options, tagger=None):
self.session.mount('https://', HTTPAdapter(max_retries=retries))

def _dl_url(self, url, where):
r = self.session.get(url, stream=True)
r = self.session.get(url, stream=True, verify=False)
try:
total = int(r.headers['content-length'])
except KeyError:
@@ -101,7 +102,7 @@ def get_stream_url(self, track_id, quality):
elif te.payload['subStatus'] == 4005:
try:
print('\tStatus 4005 when getting stream URL, trying workaround...')
playback_info = self.api.get_stream_url_workaround(track_id, quality)
playback_info = self.api.get_stream_url(track_id, quality)
manifest = json.loads(base64.b64decode(playback_info['manifest']))
stream_data = {
'soundQuality': playback_info['audioQuality'],
@@ -117,13 +118,6 @@ def get_stream_url(self, track_id, quality):
if stream_data is None:
raise ValueError('Stream could not be acquired')

if stream_data['soundQuality'] not in quality:
if not (stream_data['codec'] == 'MQA' and quality[0] == 'HI_RES'):
raise ValueError('ERROR: {} quality requested, but only {} quality available.'.
format(quality, stream_data['soundQuality']))

return stream_data

def print_track_info(self, track_info, album_info):
line = '\tTrack: {tracknumber}\n\tTitle: {title}\n\tArtist: {artist}\n\tAlbum: {album}'.format(
**self.tm.tags(track_info, album_info))
@@ -174,11 +168,13 @@ def download_media(self, track_info, quality, album_info=None, overwrite=False):
_mkdir_p(disc_location)

# Attempt to get stream URL
stream_data = self.get_stream_url(track_id, quality)
#stream_data = self.get_stream_url(track_id, quality)

# Hacky way to get extension of file from URL
ftype = None
url = stream_data['url']
#ftype = None
playback_info = self.api.get_stream_url(track_id, quality)
manifest = json.loads(base64.b64decode(playback_info['manifest']))
url = manifest['urls'][0]
if url.find('.flac?') == -1:
if url.find('.m4a?') == -1:
if url.find('.mp4?') == -1:
@@ -204,12 +200,7 @@ def download_media(self, track_info, quality, album_info=None, overwrite=False):
self.print_track_info(track_info, album_info)

try:
temp_file = self._dl_url(stream_data['url'], track_path)

if not stream_data['encryptionKey'] == '':
print('\tLooks like file is encrypted. Decrypting...')
key, nonce = decrypt_security_token(stream_data['encryptionKey'])
decrypt_file(temp_file, key, nonce)
temp_file = self._dl_url(url, track_path)

aa_location = path.join(album_location, 'Cover.jpg')
if not path.isfile(aa_location):
@@ -222,8 +213,8 @@ def download_media(self, track_info, quality, album_info=None, overwrite=False):

if ftype == 'flac':
self.tm.tag_flac(temp_file, track_info, album_info, aa_location)
elif ftype == 'm4a' or ftype == 'mp4':
self.tm.tag_m4a(temp_file, track_info, album_info, aa_location)
#elif ftype == 'm4a' or ftype == 'mp4':
# self.tm.tag_m4a(temp_file, track_info, album_info, aa_location)
else:
print('\tUnknown file type to tag!')

68 changes: 43 additions & 25 deletions redsea/tidal_api.py
Original file line number Diff line number Diff line change
@@ -8,11 +8,14 @@
import base64
import secrets
from datetime import datetime, timedelta
import urllib3

import requests
from urllib3.util.retry import Retry
from requests.adapters import HTTPAdapter

from config.settings import TOKEN, AUTHHEADER


class TidalRequestError(Exception):
def __init__(self, payload):
@@ -33,8 +36,8 @@ def __init__(self, message):


class TidalApi(object):
TIDAL_API_BASE = 'https://api.tidalhifi.com/v1/'
TIDAL_CLIENT_VERSION = '1.9.1'
TIDAL_API_BASE = 'https://api.tidal.com/v1/'
TIDAL_CLIENT_VERSION = '2.26.1'

def __init__(self, session):
self.session = session
@@ -47,13 +50,21 @@ def __init__(self, session):
self.s.mount('https://', HTTPAdapter(max_retries=retries))

def _get(self, url, params={}, refresh=False):
params['countryCode'] = self.session.country_code
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
params['countryCode'] = 'US'
if 'limit' not in params:
params['limit'] = '9999'
resp = self.s.get(
self.TIDAL_API_BASE + url,
headers=self.session.auth_headers(),
params=params)
headers={
'X-Tidal-Token': TOKEN,
'Authorization': AUTHHEADER,
'Host': 'api.tidal.com',
'Connection': 'Keep-Alive',
'Accept-Encoding': 'gzip',
'User-Agent': 'TIDAL_ANDROID/995 okhttp/3.13.1'
},
params=params, verify=False)

# if the request 401s or 403s, try refreshing the session in case that helps
if not refresh and (resp.status_code == 401 or resp.status_code == 403) and isinstance(self.session, TidalMobileSession):
@@ -81,15 +92,13 @@ def _get(self, url, params={}, refresh=False):
return resp_json

def get_stream_url(self, track_id, quality):
return self._get('tracks/' + str(track_id) + '/streamUrl',
{'soundQuality': quality})

def get_stream_url_workaround(self, track_id, quality):

return self._get('tracks/' + str(track_id) + '/playbackinfopostpaywall', {
'playbackmode': 'STREAM',
'assetpresentation': 'FULL',
'audioquality': 'HI_RES' # TODO: use highest quality from 'quality' arg instead of defaulting to HI_RES
'audioquality': 'HI_RES',
'prefetch': 'false',
'countryCode': 'US'
})

def get_playlist_items(self, playlist_id):
@@ -156,12 +165,12 @@ class TidalSession(object):
Tidal session object which can be used to communicate with Tidal servers
'''

def __init__(self, username, password, token='u5qPNNYIbD0S0o36MrAiFZ56K6qMCrCmYPzZuTnV'):
def __init__(self, username, password, token=TOKEN):
'''
Initiate a new session
'''
self.TIDAL_CLIENT_VERSION = '1.9.1'
self.TIDAL_API_BASE = 'https://api.tidalhifi.com/v1/'
self.TIDAL_CLIENT_VERSION = '2.26.1'
self.TIDAL_API_BASE = 'https://api.tidal.com/v1/'

self.username = username
self.token = token
@@ -184,7 +193,7 @@ def auth(self, password):
'clientVersion': self.TIDAL_CLIENT_VERSION
}

r = requests.post(self.TIDAL_API_BASE + 'login/username', data=postParams).json()
r = requests.post(self.TIDAL_API_BASE + 'login/username', data=postParams)

password = None

@@ -201,12 +210,7 @@ def session_type(self):
'''
Returns the type of token used to create the session
'''
if self.token == 'u5qPNNYIbD0S0o36MrAiFZ56K6qMCrCmYPzZuTnV':
return 'Desktop'
elif self.token == 'kgsOOmYk3zShYrNP':
return 'Mobile'
else:
return 'Other/Unknown'
return 'Mobile'

def valid(self):
'''
@@ -221,7 +225,14 @@ def valid(self):
return True

def auth_headers(self):
return {'X-Tidal-SessionId': self.session_id}
return {
'Host': 'api.tidal.com',
'X-Tidal-Token': TOKEN,
'Authorization': AUTHHEADER,
'Connection': 'Keep-Alive',
'Accept-Encoding': 'gzip',
'User-Agent': 'TIDAL_ANDROID/995 okhttp/3.13.1'
}


class TidalMobileSession(TidalSession):
@@ -337,7 +348,14 @@ def session_type(self):
return 'Mobile'

def auth_headers(self):
return {'authorization': 'Bearer {}'.format(self.access_token)}
return {
'Host': 'api.tidal.com',
'X-Tidal-Token': TOKEN,
'Authorization': AUTHHEADER,
'Connection': 'Keep-Alive',
'Accept-Encoding': 'gzip',
'User-Agent': 'TIDAL_ANDROID/995 okhttp/3.13.1'
}


class TidalSessionFile(object):
@@ -418,8 +436,8 @@ def load(self, session_name=None):
Returns a session from the session store
'''

if len(self.sessions) == 0:
raise ValueError('There are no sessions in session file!')
#if len(self.sessions) == 0:
# raise ValueError('There are no sessions in session file!')

if session_name is None:
session_name = self.default
@@ -430,7 +448,7 @@ def load(self, session_name=None):
assert self.sessions[session_name].valid(), '{} has an invalid sessionId. Please re-authenticate'.format(session_name)
return self.sessions[session_name]

raise ValueError('Session "{}" could not be found.'.format(session_name))
#raise ValueError('Session "{}" could not be found.'.format(session_name))

def set_default(self, session_name):
'''