Repository: Dniel97/RedSea
Branch: master
Commit: 173b695b66e3
Files: 22
Total size: 138.6 KB
Directory structure:
gitextract_3o21b9fc/
├── .github/
│ └── ISSUE_TEMPLATE/
│ └── bug_report.md
├── .gitignore
├── captcha/
│ ├── main.js
│ ├── package.json
│ └── public/
│ ├── html/
│ │ ├── captcha.html
│ │ └── index.html
│ └── js/
│ └── captcha.js
├── config/
│ ├── __init__.py
│ └── settings.example.py
├── deezer/
│ ├── __init__.py
│ └── deezer.py
├── readme.md
├── redsea/
│ ├── __init__.py
│ ├── cli.py
│ ├── decryption.py
│ ├── mediadownloader.py
│ ├── sessions.py
│ ├── tagger.py
│ ├── tidal_api.py
│ └── videodownloader.py
├── redsea.py
└── requirements.txt
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: Dniel97
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Use command '...'
2. Choose '....' (only needed for explore/search)
3. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. Android]
- Python version [e.g. 3.6.9, 3.9.3]
**Additional context**
Add any other context about the problem here.
================================================
FILE: .gitignore
================================================
__pycache__/
.venv
venv/
.vscode
sessions.pk
downloads/
.DS_Store
.idea/
settings.py
node_modules/
failed_tracks.txt
.python-version
================================================
FILE: captcha/main.js
================================================
const {app, BrowserWindow, Menu, Tray} = require('electron');
const captcha = require('./public/js/captcha');
const remote = require('electron').remote;
captcha.registerScheme();
let tray = null;
let mainWindow;
let trayIcon = __dirname + "/icon.png";
const isMac = process.platform === 'darwin'
function createTray() {
tray = new Tray(trayIcon);
const contextMenu = Menu.buildFromTemplate([
{
label: 'Show App',
click: function () {
if (mainWindow) mainWindow.show();
}
},
{
label: 'Quit',
click: function () {
app.isQuiting = true;
if (mainWindow) {
mainWindow.close()
} else {
app.quit()
}
}
}
]);
tray.setToolTip('Tidal Recaptcha');
if (isMac) {
app.dock.setIcon(trayIcon);
}
tray.setContextMenu(contextMenu);
tray.on('click', function (e) {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
}
}
});
}
function createWindow() {
// Create browser window
mainWindow = new BrowserWindow({
width: 350,
height: 650,
icon: trayIcon,
webPreferences: {
contextIsolation: true,
enableRemoteModule: true
}
});
mainWindow.setMenu(null);
let template = [
isMac ? {
label: 'Tidal reCAPTCHA',
submenu: [
{label: "About Tidal reCAPTCHA", role: 'about'},
{type: 'separator'},
{
label: "Quit", accelerator: "Command+Q", click: function () {
app.quit();
}
}
]
} : {
label: 'Tidal reCAPTCHA',
submenu: [
{label: 'Close', role: 'quit'}
]
},
{
label: 'View',
submenu: [
{label: 'Reload', role: 'reload'},
{label: 'Force Reload', role: 'forceReload'},
{label: 'Toggle Developer Tools', role: 'toggleDevTools'},
{type: 'separator'},
{label: 'Actual Size', role: 'resetZoom'},
{label: 'Zoom in', role: 'zoomIn'},
{label: 'Zoom out', role: 'zoomOut'},
{type: 'separator'},
{label: 'Toogle Full Screen', role: 'togglefullscreen'}
]
},
{
label: "Edit",
submenu: [
{label: "Undo", accelerator: "CmdOrCtrl+Z", selector: "undo:"},
{label: "Redo", accelerator: "Shift+CmdOrCtrl+Z", selector: "redo:"},
{type: "separator"},
{label: "Cut", accelerator: "CmdOrCtrl+X", selector: "cut:"},
{label: "Copy", accelerator: "CmdOrCtrl+C", selector: "copy:"},
{label: "Paste", accelerator: "CmdOrCtrl+V", selector: "paste:"},
{label: "Select All", accelerator: "CmdOrCtrl+A", selector: "selectAll:"}
]
}
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
mainWindow.loadFile('public/html/index.html');
// mainWindow.openDevTools();
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
app.on('ready', function () {
createTray();
createWindow();
captcha.registerProtocol();
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
app.quit()
});
================================================
FILE: captcha/package.json
================================================
{
"name": "tidal_recaptcha",
"version": "0.1.0",
"description": "Get the reCaptcha response from Tidal",
"main": "main.js",
"scripts": {
"start": "electron ."
},
"author": "Dniel97",
"devDependencies": {
"electron": "^15.5.5"
}
}
================================================
FILE: captcha/public/html/captcha.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>captcha</title>
</head>
<body>
<script src="https://www.google.com/recaptcha/api.js?render=6Lf-N-0UAAAAAOm0_ZBFblrmIr7KRswyRawEBonm"></script>
<form id="modal_captcha_form_v3" method="post" action="/submit">
<button style="display: none;" class="g-recaptcha" id="g-recaptcha-v3" data-sitekey="6Lf-N-0UAAAAAOm0_ZBFblrmIr7KRswyRawEBonm"
data-callback="onCaptchaSolvedV3" data-action="submit"></button>
</form>
<form id="modal_captcha_form_v2" action="/submit" method="get">
<div class="g-recaptcha" data-sitekey="6LcaN-0UAAAAAN056lYOwirUdIJ70tvy9QwNBajZ"
data-callback="onCaptchaSolvedV2"></div>
<script type="text/javascript" src="https://www.google.com/recaptcha/api.js"></script>
</form>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"
integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
<script type="text/javascript">
grecaptcha.ready(function () {
document.getElementById("g-recaptcha-v3").click();
});
function onCaptchaSolvedV3() {
$.get("/submit", $("#modal_captcha_form_v3").serialize());
}
function onCaptchaSolvedV2() {
$.get("/submit", $("#modal_captcha_form_v2").serialize());
}
/*
grecaptcha.ready(function () {
grecaptcha.execute('6Lf-N-0UAAAAAOm0_ZBFblrmIr7KRswyRawEBonm', {action: 'submit'}).then(function (token) {
console.log(token)
});
});
*/
</script>
</body>
</html>
================================================
FILE: captcha/public/html/index.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Solve reCAPTCHA</title>
<style>
#modal_login_iframe_captcha {
width: 100%;
height: 550px;
border: 0;
}
</style>
</head>
<body>
<input id="modal_login_input_captchaResponse" type="hidden" value=""/>
<iframe id="modal_login_iframe_captcha" src="cap://login.tidal.com/"></iframe>
</body>
</html>
================================================
FILE: captcha/public/js/captcha.js
================================================
const {app, protocol} = require('electron');
const url = require('url');
const fs = require('fs');
const path = require('path');
let captchaPage = fs.readFileSync(path.join(__dirname, '..', 'html', 'captcha.html'), 'utf8');
let count = 0;
module.exports = {
callbackResponse: function (data) {
// registerProtocol must be called before callback can set
// so this is just a placeholder for the real callback function
console.log("'response': '" + data + "'");
// if it recieves the second response, close the app
if (count === 1)
app.exit(0);
count += 1;
},
registerScheme: function () {
protocol.registerSchemesAsPrivileged([{ scheme: 'cap', privileges: { standard: true, secure: true, supportFetchAPI: true } }])
// protocol.registerStandardSchemes(['cap']);
},
registerProtocol: function () {
protocol.registerBufferProtocol('cap', (request, callback) => {
let ReUrl = url.parse(request.url, true);
if(ReUrl.query["g-recaptcha-response"])
{
let response = ReUrl.query["g-recaptcha-response"];
this.callbackResponse(response);
}
callback({
mimeType: 'text/html',
data: Buffer.from(captchaPage)
})
})
}
};
================================================
FILE: config/__init__.py
================================================
================================================
FILE: config/settings.example.py
================================================
'''
Store your redsea download presets here
You may modify/add/remove as you wish. The only preset which must exist is "default"
and you may change the default as needed.
=== Stock Presets ===
(use these with the -p flag)
default: FLAC 44.1k / 16bit only
best_available: Download the highest available quality (MQA > FLAC > 320 > 96)
mqa_flac: Accept both MQA 24bit and FLAC 16bit
MQA: Only allow FLAC 44.1k / 24bit (includes 'folded' 96k content)
FLAC: FLAC 44.1k / 16bit only
320: AAC ~320 VBR only
96: AAC ~96 VBR only
=== Options ===
keep_cover_jpg: Whether to keep the cover.jpg file in the album directory
embed_album_art: Whether to embed album art or not into the file.
save_album_json: save the album metadata as a json file
tries: How many times to attempt to get a valid stream URL.
path: Base download directory
convert_to_alac: Converts a .flac file to an ALAC .m4a file (requires ffmpeg)
save_credits_txt: Saves a {track_format}.txt file with the file containing all the credits of a specific song
embed_credits: Embeds all the credits tags inside a FLAC/MP4 file
save_lyrics_lrc: Saves synced lyrics as .lrc using the official Tidal provider: musixmatch
embed_lyrics: Embed the unsynced lyrics inside a FLAC/MP4 file
genre_language: Select the language of the genres from Deezer to "en-US", "de", "fr", ...
artwork_size: Downloads (artwork_size)x(artwork_size) album covers from iTunes, set it to 0 to disable iTunes cover
resolution: Which resolution you want to download the videos
Format variables are {title}, {artist}, {album}, {tracknumber}, {discnumber}, {date}, {quality}, {explicit}.
quality: has a whitespace in front, so it will look like this " [Dolby Atmos]", " [360]" or " [M]" according to the downloaded quality
explicit: has a whitespace in front, so it will look like this " [E]"
track_format: How tracks are formatted. The relevant extension is appended to the end.
album_format: Base album directory - tracks and cover art are stored here. May have slashes in it, for instance {artist}/{album}.
playlist_format: How playlist tracks are formatted, same as track_format just with {playlistnumber} added
Format variables are {title}, {artist}, {tracknumber}, {discnumber}, {date}, {quality}, {explicit}.
quality has a whitespace in front, so it will look like this " [1080P]" according to the highest available resolution returned by the API
{explicit} has a whitespace in front, so it will look like this " [E]"
video_file_format: How video filenames are formatted. The '.mp4' extension is appended to the end.
video_folder_format: The video directory - tmp files and cover art are stored here. May have slashes in it, for instance {artist}/{title}.
=== Formats ===
MQA_FLAC_24: MQA Format / 24bit FLAC with high-frequency "folded" data
FLAC_16: 16bit FLAC
AAC_320: 320Kbps AAC
AAC_96: 96Kbps AAC
'''
# BRUTEFORCEREGION: Attempts to download the track/album with all available accounts if dl fails
BRUTEFORCEREGION = True
# Shows the Access JWT after every refresh and creation
SHOWAUTH = False
# The Desktop token
TOKEN = 'c7RLy4RJ3OCNeZki' # MQA Token, unused
# The mobile token which usually comes along with the authorization header
# MOBILE_TOKEN = "WAU9gXp3tHhK4Nns" # MQA Token
MOBILE_TOKEN = "dN2N95wCyEBTllu4" # Dolby Atmos AC-4 + MQA + FLAC + AAC
# The TV_TOKEN and the line below (TV_SECRET) are tied together, so un-/comment both.
TV_TOKEN = "7m7Ap0JC9j1cOM3n" # FireTV Dolby Atmos E-AC-3 + MQA
TV_SECRET = "vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY="
path = "./downloads/"
PRESETS = {
# Default settings / only download FLAC_16
"default": {
"keep_cover_jpg": True,
"embed_album_art": True,
"save_album_json": False,
"tries": 5,
"path": path,
"track_format": "{tracknumber} - {title}",
"playlist_format": "{playlistnumber} - {title}",
"album_format": "{albumartist} - {album}{quality}{explicit}",
"video_folder_format": "{artist} - {title}{quality}",
"video_file_format": "{title}",
"convert_to_alac": False,
"save_credits_txt": False,
"embed_credits": True,
"save_lyrics_lrc": True,
"embed_lyrics": True,
"genre_language": "en-US",
"artwork_size": 3000,
"uncompressed_artwork": True,
"resolution": 1080,
"MQA_FLAC_24": True,
"FLAC_16": True,
"AAC_320": False,
"AAC_96": False
},
# This will download the highest available quality including MQA
"best_available": {
"keep_cover_jpg": False,
"embed_album_art": True,
"save_album_json": False,
"aggressive_remix_filtering": True,
"skip_singles_when_possible": True,
"skip_360ra": True,
"tries": 5,
"path": path,
"track_format": "{tracknumber} - {title}",
"playlist_format": "{playlistnumber} - {title}",
"album_format": "{albumartist} - {album}{quality}{explicit}",
"video_folder_format": "{artist} - {title}{quality}",
"video_file_format": "{title}",
"convert_to_alac": True,
"save_credits_txt": False,
"embed_credits": True,
"save_lyrics_lrc": True,
"embed_lyrics": True,
"genre_language": "en-US",
"artwork_size": 3000,
"uncompressed_artwork": True,
"resolution": 1080,
"MQA_FLAC_24": True,
"FLAC_16": True,
"AAC_320": True,
"AAC_96": True
},
# This preset will download every song from playlist inside a playlist folder
"playlist": {
"keep_cover_jpg": False,
"embed_album_art": True,
"save_album_json": False,
"tries": 5,
"path": path,
"track_format": "{albumartist} - {title}",
"playlist_format": "{playlistnumber} - {title}",
"video_folder_format": "{artist} - {title}{quality}",
"video_file_format": "{title}",
"album_format": "",
"convert_to_alac": False,
"save_credits_txt": False,
"embed_credits": True,
"save_lyrics_lrc": True,
"embed_lyrics": True,
"genre_language": "en-US",
"artwork_size": 3000,
"uncompressed_artwork": False,
"resolution": 1080,
"MQA_FLAC_24": True,
"FLAC_16": True,
"AAC_320": False,
"AAC_96": False
},
# This preset will only download FLAC 16
"FLAC": {
"keep_cover_jpg": True,
"embed_album_art": True,
"save_album_json": False,
"tries": 5,
"path": path,
"track_format": "{tracknumber} - {title}",
"playlist_format": "{playlistnumber} - {title}",
"album_format": "{albumartist} - {album}{quality}{explicit}",
"video_folder_format": "{artist} - {title}{quality}",
"video_file_format": "{title}",
"convert_to_alac": False,
"save_credits_txt": False,
"embed_credits": True,
"save_lyrics_lrc": True,
"embed_lyrics": True,
"genre_language": "en-US",
"artwork_size": 3000,
"uncompressed_artwork": True,
"resolution": 1080,
"MQA_FLAC_24": False,
"FLAC_16": True,
"AAC_320": False,
"AAC_96": False
},
}
================================================
FILE: deezer/__init__.py
================================================
================================================
FILE: deezer/deezer.py
================================================
#!/usr/bin/env python3
import time
import requests
import re
import json
USER_AGENT_HEADER = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " \
"Chrome/79.0.3945.130 Safari/537.36"
class Deezer:
def __init__(self, language='en'):
requests.packages.urllib3.disable_warnings(requests.packages.urllib3.exceptions.InsecureRequestWarning)
self.api_url = "http://www.deezer.com/ajax/gw-light.php"
self.legacy_api_url = "https://api.deezer.com/"
self.http_headers = {
"User-Agent": USER_AGENT_HEADER,
"Accept-Language": language
}
self.session = requests.Session()
self.session.post("https://www.deezer.com/", headers=self.http_headers, verify=False)
def get_token(self):
token_data = self.gw_api_call('deezer.getUserData')
return token_data["results"]["checkForm"]
def gw_api_call(self, method, args=None):
if args is None:
args = {}
try:
result = self.session.post(
self.api_url,
params={
'api_version': "1.0",
'api_token': 'null' if method == 'deezer.getUserData' else self.get_token(),
'input': '3',
'method': method
},
timeout=30,
json=args,
headers=self.http_headers,
verify=False
)
result_json = result.json()
except:
time.sleep(2)
return self.gw_api_call(method, args)
if len(result_json['error']):
raise APIError(json.dumps(result_json['error']))
return result.json()
def api_call(self, method, args=None):
if args is None:
args = {}
try:
result = self.session.get(
self.legacy_api_url + method,
params=args,
headers=self.http_headers,
timeout=30,
verify=False
)
result_json = result.json()
except:
time.sleep(2)
return self.api_call(method, args)
if 'error' in result_json.keys():
if 'code' in result_json['error'] and result_json['error']['code'] == 4:
time.sleep(5)
return self.api_call(method, args)
raise APIError(json.dumps(result_json['error']))
return result_json
def get_track_gw(self, sng_id):
if int(sng_id) < 0:
body = self.gw_api_call('song.getData', {'sng_id': sng_id})
else:
body = self.gw_api_call('deezer.pageTrack', {'sng_id': sng_id})
if 'LYRICS' in body['results']:
body['results']['DATA']['LYRICS'] = body['results']['LYRICS']
body['results'] = body['results']['DATA']
return body['results']
def get_tracks_gw(self, ids):
tracks_array = []
body = self.gw_api_call('song.getListData', {'sng_ids': ids})
errors = 0
for i in range(len(ids)):
if ids[i] != 0:
tracks_array.append(body['results']['data'][i - errors])
else:
errors += 1
tracks_array.append({
'SNG_ID': 0,
'SNG_TITLE': '',
'DURATION': 0,
'MD5_ORIGIN': 0,
'MEDIA_VERSION': 0,
'FILESIZE': 0,
'ALB_TITLE': "",
'ALB_PICTURE': "",
'ART_ID': 0,
'ART_NAME': ""
})
return tracks_array
def get_album_gw(self, alb_id):
return self.gw_api_call('album.getData', {'alb_id': alb_id})['results']
def get_album_tracks_gw(self, alb_id):
tracks_array = []
body = self.gw_api_call('song.getListByAlbum', {'alb_id': alb_id, 'nb': -1})
for track in body['results']['data']:
_track = track
_track['position'] = body['results']['data'].index(track)
tracks_array.append(_track)
return tracks_array
def get_artist_gw(self, art_id):
return self.gw_api_call('deezer.pageArtist', {'art_id': art_id})
def search_gw(self, term, type, start, nb=20):
return \
self.gw_api_call('search.music',
{"query": clean_search_query(term), "filter": "ALL", "output": type, "start": start, "nb": nb})[
'results']
def get_lyrics_gw(self, sng_id):
return self.gw_api_call('song.getLyrics', {'sng_id': sng_id})["results"]
def get_track(self, sng_id):
return self.api_call('track/' + str(sng_id))
def get_track_by_ISRC(self, isrc):
return self.api_call('track/isrc:' + isrc)
def get_album(self, album_id):
return self.api_call('album/' + str(album_id))
def get_album_by_UPC(self, upc):
return self.api_call('album/upc:' + str(upc))
def get_album_tracks(self, album_id):
return self.api_call('album/' + str(album_id) + '/tracks', {'limit': -1})
def get_artist(self, artist_id):
return self.api_call('artist/' + str(artist_id))
def get_artist_albums(self, artist_id):
return self.api_call('artist/' + str(artist_id) + '/albums', {'limit': -1})
def search(self, term, search_type, limit=30, index=0):
return self.api_call('search/' + search_type, {'q': clean_search_query(term), 'limit': limit, 'index': index})
def get_track_from_metadata(self, artist, track, album):
artist = artist.replace("–", "-").replace("’", "'")
track = track.replace("–", "-").replace("’", "'")
album = album.replace("–", "-").replace("’", "'")
resp = self.search(f'artist:"{artist}" track:"{track}" album:"{album}"', "track", 1)
if len(resp['data']) > 0:
return resp['data'][0]['id']
resp = self.search(f'artist:"{artist}" track:"{track}"', "track", 1)
if len(resp['data']) > 0:
return resp['data'][0]['id']
if "(" in track and ")" in track and track.find("(") < track.find(")"):
resp = self.search(f'artist:"{artist}" track:"{track[:track.find("(")]}"', "track", 1)
if len(resp['data']) > 0:
return resp['data'][0]['id']
elif " - " in track:
resp = self.search(f'artist:"{artist}" track:"{track[:track.find(" - ")]}"', "track", 1)
if len(resp['data']) > 0:
return resp['data'][0]['id']
else:
return 0
return 0
def clean_search_query(term):
term = str(term)
term = re.sub(r' feat[\.]? ', " ", term)
term = re.sub(r' ft[\.]? ', " ", term)
term = re.sub(r'\(feat[\.]? ', " ", term)
term = re.sub(r'\(ft[\.]? ', " ", term)
term = term.replace('&', " ").replace('–', "-").replace('—', "-")
return term
class APIError(Exception):
pass
================================================
FILE: readme.md
================================================
RedSea
======
Music downloader and tagger for Tidal. For educational use only, and will break in the future.
Current state
-------------
**This fork will only get bug/hotfixes by me ([Dniel97](https://github.com/Dniel97)). Currently, Tidal changes/removes old
tokens which supported single .flac/.m4a files and the newer tokens only receives MPEG-DASH which would require a lot of
rewrite! For now is deprecated in favor of a newer [OrpheusDL](https://github.com/yarrm80s/orpheusdl) module:
[Orpheus Tidal module](https://github.com/Dniel97/orpheusdl-tidal).**
Telegram
--------
Join the telegram group [RedSea Community](https://t.me/RedSea_Community) if you have questions, want to get help,
submit bugs or want to talk to the developer.
Introduction
------------
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 `redsea/tidal_api.py` and only requires `requests` to be installed.
Choosing login types and client IDs
-----------------------------------
* To get the E-AC-3 codec version of Dolby Atmos Music, the TV sign in must be used with the client ID and secret of one of the supported Android TVs (full list below) (now included)
* To get the AC-4 codec version of Dolby Atmos Music, the Mobile sign in must be used with the client ID of one of the supported phones (default mobile works)
* To get MQA, use literally anything that is not the browser, nearly all client IDs work. (In this case change the client ID of the desktop login) (bring your own anything (TV, mobile, desktop))
* To get ALAC without conversion, use the client ID of an iOS device, or the optional desktop token included from macOS (comment out the default FLAC supporting one, and uncomment the ALAC one) (secondary desktop works, or bring your own mobile)
* To get 360, use the client ID of a supported Android or iOS device (nearly all support it anyway, so that's easy) (default mobile works)
Client IDs provided by default:
* TV: FireTV with E-AC-3 (Dolby Atmos) and MQA support
* Mobile: Default has AC-4 support (which also supports MQA by extension). There is also another one which only supports MQA without AC-4 optionally (commented out)
* Desktop: Neither of the included ones support MQA! You must replace it with your own if you want MQA support! Default token can get FLACs only, whereas the optional one can get ALACs only (both are also able to get AAC)
* Browser: Is completely unsupported for now, though why would you want it anyway?
Further Reading has moved to the wiki: [https://github.com/Dniel97/RedSea/wiki/Technical-info](https://github.com/Dniel97/RedSea/wiki/Technical-info)
Requirements
------------
* Python (3.6 or higher)
* requests (2.22.0 or higher)
* mutagen (1.37 or higher)
* pycryptodomex
* ffmpeg-python (0.2.0 or higher)
* prettytable (1.0.0 or higher)
* tqdm (4.56.0 or higher)
* deezerapi (already included from [deemix](https://codeberg.org/RemixDev/deemix))
Installation
------------
The new more detailed Installation Guide has been moved to the wiki: [https://github.com/Dniel97/RedSea/wiki/Installation-Guide](https://github.com/Dniel97/RedSea/wiki/Installation-Guide)
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 TV, Mobile or Desktop session. The TV option
displays a 6 digit key which should be entered inside
link.tidal.com where the user can login. The Mobile/Desktop
option prompts for a Tidal username and password. Both options
authorize 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
Further reading on which session to choose and which prompts to choose in the wiki: [https://github.com/Dniel97/RedSea/wiki/Adding-a-session](https://github.com/Dniel97/RedSea/wiki/Adding-a-session)
How to use
----------
usage: redsea.py [-h] [-p PRESET] [-a ACCOUNT] [-s] [--file FILE] urls [urls ...]
A music downloader for Tidal.
positional arguments:
urls The URLs to download. You may need to wrap the URLs in
double quotes if you have issues downloading.
optional arguments:
-h, --help show this help message and exit
-p PRESET, --preset PRESET
Select a download preset. Defaults to Lossless only.
See /config/settings.py for presets
-a ACCOUNT, --account ACCOUNT
Select a session/account to use. Defaults to
the "default" session. If it does not exist, you
will be prompted to create one
-s, --skip Pass this flag to skip track and continue when a track
does not meet the requested quality
-f, --file The URLs to download inside a .txt file with a single
track/album/artist each line.
#### Searching
Searching for tracks, albums and videos is now supported.
Usage: `python redsea.py search [track/album/video] [name of song/video, spaces are allowed]`
Example: `python redsea.py search video Darkside Alan Walker`
#### ID downloading
Download an album/track/artist/video/playlist with just the ID instead of an URL
Usage: `python redsea.py id [album/track/artist/video/playlist ID]`
Example: `python redsea.py id id 92265335`
#### Exploring
Exploring new Dolby Atmos or 360 Reality Audio releases is now supported
Usage: `python redsea.py explore (atmos|360) (albums|tracks)`
Example: `python redsea.py explore atmos tracks`
Example: `python redsea.py explore 360 albums`
Lyrics Support
--------------
Redsea supports retrieving synchronized lyrics from the services LyricFind via Deezer, and Musixmatch, automatically falling back if one doesn't have lyrics, depending on the configuration
Tidal issues
------------
* Sometimes, tracks will be tagged with a useless version (for instance, "(album version)"), or have the same version twice "(album version)(album version)". This is because tracks in
Tidal are not consistent in terms of metadata - sometimes a version may be included in the track title, included in the version field, or both.
* Tracks may be tagged with an inaccurate release year; this may be because of Tidal only having the "rerelease" or "remastered" version but showing it as the original.
To do/Whishlist
---------------
* ~~ID based downloading (check if ID is a track, album, video, ...)~~
* Complete `mediadownloader.py` rewrite
* Move lyrics support to tagger.py
* Support for being used as a python module (maybe pip?)
* Maybe Spotify playlist support
* Artist album/video download (which downloads all albums/videos from a given artist)
Config reference
----------------
`BRUTEFORCEREGION`: When True, redsea will iterate through every available account and attempt to download when the default or specified session fails to download the release
### `Stock Presets`
`default`: FLAC 44.1k / 16bit only
`best_available`: Download the highest available quality (MQA > FLAC > 320 > 96)
`mqa_flac`: Accept both MQA 24bit and FLAC 16bit
`MQA`: Only allow FLAC 44.1k / 24bit (includes 'folded' 96k content)
`FLAC`: FLAC 44.1k / 16bit only
`320`: AAC ~320 VBR only
`96`: AAC ~96 VBR only
### `Preset Configuration Variables`
`keep_cover_jpg`: Whether to keep the cover.jpg file in the album directory
`embed_album_art`: Whether to embed album art or not into the file.
`save_album_json`: save the album metadata as a json file
`tries`: How many times to attempt to get a valid stream URL.
`path`: Base download directory
`convert_to_alac`: Converts a .flac file to an ALAC .m4a file (requires ffmpeg)
`save_credits_txt`: Saves a `{track_format}.txt` file with the file containing all the credits of a specific song
`embed_credits`: Embeds all the credits tags inside a FLAC/MP4 file
`save_lyrics_lrc`: Saves synced lyrics as .lrc using the Deezer API (from [deemix](https://codeberg.org/RemixDev/deemix)) or musiXmatch
`embed_lyrics`: Embed the unsynced lyrics inside a FLAC/MP4 file
`lyrics_provider_order`: Defines the order (from left to right) you want to get the lyrics from
`genre_language`: Select the language of the genres from Deezer to `en-US, de, fr, ...`
`artwork_size`: Downloads (artwork_size)x(artwork_size) album covers from iTunes, set it to `0` to disable iTunes cover
`resolution`: Which resolution you want to download the videos
### Album/track format
Format variables are `{title}`, `{artist}`, `{album}`, `{tracknumber}`, `{discnumber}`, `{date}`, `{quality}`, `{explicit}`.
* `{quality}` has a whitespace in front, so it will look like this " [Dolby Atmos]", " [360]" or " [M]" according to the downloaded quality
* `{explicit}` has a whitespace in front, so it will look like this " [E]"
`track_format`: How tracks are formatted. The relevant extension is appended to the end.
`album_format`: Base album directory - tracks and cover art are stored here. May have slashes in it, for instance {artist}/{album}.
`playlist_format`: How playlist tracks are formatted, same as track_format just with `{playlistnumber}` added
### Video format
Format variables are `{title}`, `{artist}`, `{tracknumber}`, `{discnumber}`, `{date}`, `{quality}`, `{explicit}`.
* `{quality}` has a whitespace in front, so it will look like this " [1080P]" according to the highest available resolution returned by the API
* `{explicit}` has a whitespace in front, so it will look like this " [E]"
`video_file_format`: How video filenames are formatted. The '.mp4' extension is appended to the end.
`video_folder_format`: The video directory - tmp files and cover art are stored here. May have slashes in it, for instance {artist}/{title}.
================================================
FILE: redsea/__init__.py
================================================
================================================
FILE: redsea/cli.py
================================================
import argparse
import re
from urllib.parse import urlparse
from os import path
def get_args():
#
# argparse setup
#
parser = argparse.ArgumentParser(
description='A music downloader for Tidal.')
parser.add_argument(
'-p',
'--preset',
default='default',
help='Select a download preset. Defaults to Lossless only. See /config/settings.py for presets')
parser.add_argument(
'-b',
'--bruteforce',
action='store_true',
default=False,
help='Brute force the download with all available accounts')
parser.add_argument(
'-a',
'--account',
default='',
help='Select a session/account to use. Defaults to the "default" session.')
parser.add_argument(
'-s',
'--skip',
action='store_true',
default=False,
help='Pass this flag to skip track and continue when a track does not meet the requested quality')
parser.add_argument(
'-o',
'--overwrite',
action='store_true',
default=False,
help='Overwrite existing files [Default=skip]'
)
parser.add_argument(
'--resumeon',
type=int,
help='If ripping a single playlist, resume on the given track number.'
)
parser.add_argument(
'urls',
nargs='+',
help='The URLs to download. You may need to wrap the URLs in double quotes if you have issues downloading.'
)
parser.add_argument(
'-f',
'--file',
action='store_const',
const=True,
default=False,
help='The URLs to download inside a .txt file with a single track/album/artist each line.'
)
args = parser.parse_args()
if args.resumeon and args.resumeon <= 0:
parser.error('--resumeon must be a positive integer')
# Check if only URLs or a file exists
if len(args.urls) > 1 and args.file:
parser.error('URLs and -f (--file) cannot be used at the same time')
return args
def parse_media_option(mo, is_file):
opts = []
if is_file:
file_name = str(mo[0])
mo = []
if path.exists(file_name):
file = open(file_name, 'r')
lines = file.readlines()
for line in lines:
mo.append(line.strip())
else:
print("\t File " + file_name + " doesn't exist")
for m in mo:
if m.startswith('http'):
m = re.sub(r'tidal.com\/.{2}\/store\/', 'tidal.com/', m)
m = re.sub(r'tidal.com\/store\/', 'tidal.com/', m)
m = re.sub(r'tidal.com\/browse\/', 'tidal.com/', m)
url = urlparse(m)
components = url.path.split('/')
if not components or len(components) <= 2:
print('Invalid URL: ' + m)
exit()
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':
type_ = 't'
elif type_ == 'playlist':
type_ = 'p'
elif type_ == 'artist':
type_ = 'r'
elif type_ == 'video':
type_ = 'v'
opts.append({'type': type_, 'id': id_})
continue
elif ':' in m and '#' in m:
ci = m.index(':')
hi = m.find('#')
hi = len(m) if hi == -1 else hi
o = {'type': m[:ci], 'id': m[ci + 1:hi], 'index': m[hi + 1:]}
opts.append(o)
else:
print('Input "{}" does not appear to be a valid url.'.format(m))
return opts
================================================
FILE: redsea/decryption.py
================================================
import base64
from Cryptodome.Cipher import AES
from Cryptodome.Util import Counter
def decrypt_security_token(security_token):
'''
Decrypts security token into key and nonce pair
security_token should match the securityToken value from the web response
'''
# Do not change this
master_key = 'UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754='
# Decode the base64 strings to ascii strings
master_key = base64.b64decode(master_key)
security_token = base64.b64decode(security_token)
# Get the IV from the first 16 bytes of the securityToken
iv = security_token[:16]
encrypted_st = security_token[16:]
# Initialize decryptor
decryptor = AES.new(master_key, AES.MODE_CBC, iv)
# Decrypt the security token
decrypted_st = decryptor.decrypt(encrypted_st)
# Get the audio stream decryption key and nonce from the decrypted security token
key = decrypted_st[:16]
nonce = decrypted_st[16:24]
return key, nonce
def decrypt_file(file, key, nonce):
'''
Decrypts an encrypted MQA file given the file, key and nonce
'''
# Initialize counter and file decryptor
counter = Counter.new(64, prefix=nonce, initial_value=0)
decryptor = AES.new(key, AES.MODE_CTR, counter=counter)
# Open and decrypt
with open(file, 'rb') as eflac:
flac = decryptor.decrypt(eflac.read())
# Replace with decrypted file
with open(file, 'wb') as dflac:
dflac.write(flac)
================================================
FILE: redsea/mediadownloader.py
================================================
import errno
import json
import os
import os.path as path
import re
import base64
import ffmpeg
import shutil
import requests
from tqdm import tqdm
from urllib3.util.retry import Retry
from requests.adapters import HTTPAdapter
from .decryption import decrypt_file, decrypt_security_token
from .tagger import FeaturingFormat
from .tidal_api import TidalApi, TidalRequestError, technical_names
from deezer.deezer import Deezer, APIError
from .videodownloader import download_stream, download_file, tags
def _mkdir_p(path):
try:
if not os.path.isdir(path):
os.makedirs(path)
except OSError as exc:
if exc.errno == errno.EEXIST and os.path.isdir(path):
pass
else:
raise
class MediaDownloader(object):
def __init__(self, api, options, tagger=None):
self.api = api
self.opts = options
self.tm = tagger
# Deezer API
if 'genre_language' in self.opts:
self.dz = Deezer(language=self.opts['genre_language'])
else:
self.dz = Deezer()
self.session = requests.Session()
retries = Retry(total=10,
backoff_factor=0.4,
status_forcelist=[429, 500, 502, 503, 504])
self.session.mount('http://', HTTPAdapter(max_retries=retries))
self.session.mount('https://', HTTPAdapter(max_retries=retries))
def _dl_url(self, url, where):
r = self.session.get(url, stream=True, verify=False)
try:
total = int(r.headers['content-length'])
except KeyError:
return False
with open(where, 'wb') as f:
with tqdm(total=total, unit='B', unit_scale=True, unit_divisor=1024, miniters=1,
bar_format=' {l_bar}{bar}{r_bar}') as bar:
for chunk in r.iter_content(chunk_size=1024):
if chunk: # filter out keep-alive new chunks
f.write(chunk)
bar.update(len(chunk))
print()
return where
def _dl_picture(self, album_id, where):
if album_id is not None:
rc = self._dl_url(TidalApi.get_album_artwork_url(album_id), where)
if not rc:
return False
else:
return rc
else:
return False
@staticmethod
def _sanitise_name(name):
name = re.sub(r'[\\\/*?"\'’<>|]', '', str(name))
# Check file length
if len(name) > 230:
name = name[:230]
# Check last character is space
if len(name) > 0:
if name[len(name) - 1] == ' ':
name = name[:len(name) - 1]
return re.sub(r'[:]', ' - ', name)
def _normalise_info(self, track_info, album_info, use_album_artists=False):
info = {
k: self._sanitise_name(v)
for k, v in self.tm.tags(track_info, None, album_info).items()
}
if len(album_info['artists']) > 1 and use_album_artists:
self.featform = FeaturingFormat()
artists = []
for a in album_info['artists']:
if a['type'] == 'MAIN':
artists.append(a['name'])
info['artist'] = self._sanitise_name(self.featform.get_artist_format(artists))
return info
def _normalise_video(self, video_info):
info = {
k: self._sanitise_name(v) for k, v in tags(video_info).items()
}
return info
def get_stream_url(self, track_id, quality):
stream_data = None
print('\tGrabbing stream URL...')
try:
stream_data = self.api.get_stream_url(track_id, quality)
except TidalRequestError as te:
if te.payload['status'] == 404:
print('\tTrack does not exist.')
# in this case, we need to use this workaround discovered by reverse engineering the mobile app, idk why
elif te.payload['subStatus'] == 4005:
try:
print('\tStatus 4005 when getting stream URL, trying workaround...')
playback_info = self.api.get_stream_url(track_id, quality)
manifest = json.loads(base64.b64decode(playback_info['manifest']))
stream_data = {
'soundQuality': playback_info['audioQuality'],
'codec': manifest['codecs'],
'url': manifest['urls'][0],
'encryptionKey': manifest['keyId'] if 'encryptionType' in manifest and manifest[
'encryptionType'] != 'NONE' else ''
}
except TidalRequestError as te:
print('\t' + str(te))
else:
print('\t' + str(te))
if stream_data is None:
raise ValueError('Stream could not be acquired')
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))
try:
print(line)
except UnicodeEncodeError:
line = line.encode('ascii', 'replace').decode('ascii')
print(line)
print('\t----')
def search_for_id(self, term):
return self.api.get_search_data(term)
def page(self, page_url, offset=None):
return self.api.get_page(page_url, offset)
def type_from_id(self, id):
return self.api.get_type_from_id(id)
def credits_from_album(self, album_id):
return self.api.get_credits(album_id)
def credits_from_video(self, video_id):
return self.api.get_video_credits(video_id)
def lyrics_from_track(self, track_id):
return self.api.get_lyrics(track_id)
def playlist_from_id(self, id):
return self.api.get_playlist(id)
def download_media(self, track_info, album_info=None, overwrite=False, track_num=None):
track_id = track_info['id']
assert track_info['allowStreaming'], 'Unable to download track {0}: not allowed to stream/download'.format(
track_id)
print('=== Downloading track ID {0} ==='.format(track_id))
# Check if track is video
if 'type' in track_info:
playback_info = self.api.get_video_stream_url(track_id)
url = playback_info['url']
# Fallback if settings doesn't exist
if 'resolution' not in self.opts:
self.opts['resolution'] = 1080
if 'video_folder_format' not in self.opts:
self.opts['video_folder_format'] = '{artist} - {title}{quality}'
if 'video_file_format' not in self.opts:
self.opts['video_file_format'] = '{title}'
# Make video locations
video_location = path.join(
self.opts['path'], self.opts['video_folder_format'].format(**self._normalise_video(track_info))).strip()
video_file = self.opts['video_file_format'].format(**self._normalise_video(track_info))
_mkdir_p(video_location)
file_location = os.path.join(video_location, video_file + '.mp4')
if path.isfile(file_location) and not overwrite:
print('\tFile {} already exists, skipping.'.format(file_location))
return None
# Get video credits
video_credits = self.credits_from_video(str(track_info['id']))
credits_dict = {}
if video_credits['totalNumberOfItems'] > 0:
for contributor in video_credits['items']:
if contributor['role'] not in credits_dict:
credits_dict[contributor['role']] = []
credits_dict[contributor['role']].append(contributor['name'])
if credits_dict != {}:
'''
if 'save_credits_txt' in self.opts:
if self.opts['save_credits_txt']:
data = ''
for key, value in credits_dict.items():
data += key + ': '
data += value + '\n'
with open((os.path.splitext(track_path)[0] + '.txt'), 'w') as f:
f.write(data)
'''
# Janky way to set the dict to None to tell the tagger not to include it
if 'embed_credits' in self.opts:
if not self.opts['embed_credits']:
credits_dict = None
download_stream(video_location, video_file, url, self.opts['resolution'], track_info, credits_dict)
else:
if album_info is None:
print('\tGrabbing album info...')
tries = self.opts['tries']
for i in range(tries):
try:
album_info = self.api.get_album(track_info['album']['id'])
break
except Exception as e:
print(e)
print('\tGrabbing album info failed, retrying... ({}/{})'.format(i + 1, tries))
if i + 1 == tries:
raise
# create correct playlist numbering if track_num is present
if track_num:
if 'playlist_format' not in self.opts:
self.opts['playlist_format'] = "{playlistnumber} - {title}"
# ugly replace operation
playlist_format = self.opts['playlist_format'].replace('{playlistnumber}', str(track_num).zfill(2))
# Make locations
# path already includes the playlist name in this case
album_location = self.opts['path']
track_file = playlist_format.format(**self._normalise_info(track_info, album_info))
else:
# Make locations
album_location = path.join(
self.opts['path'], self.opts['album_format'].format(
**self._normalise_info(track_info, album_info, True))).strip()
track_file = self.opts['track_format'].format(**self._normalise_info(track_info, album_info))
# Make multi disc directories
if album_info['numberOfVolumes'] > 1:
disc_location = path.join(
album_location,
'CD{num}'.format(num=track_info['volumeNumber']))
disc_location = re.sub(r'\.+$', '', disc_location)
_mkdir_p(disc_location)
album_location = re.sub(r'\.+$', '', album_location)
if len(track_file) > 255: # trim filename to be under OS limit (and account for file extension)
track_file = track_file[:250 - len(track_file)]
track_file = re.sub(r'\.+$', '', track_file)
_mkdir_p(album_location)
# Attempt to get stream URL
# stream_data = self.get_stream_url(track_id, quality)
DRM = False
playback_info = self.api.get_stream_url(track_id, self.opts['quality'])
manifest_unparsed = base64.b64decode(playback_info['manifest']).decode('UTF-8')
if 'ContentProtection' in manifest_unparsed:
DRM = True
print("\tWarning: DRM has been detected. If you do not have the decryption key, do not use web login.")
elif 'manifestMimeType' in playback_info:
if playback_info['manifestMimeType'] == 'application/dash+xml':
raise AssertionError(f'\tUnable to download track {playback_info["trackId"]} in '
f'{playback_info["audioQuality"]}!\n')
if not DRM:
manifest = json.loads(manifest_unparsed)
# Detect codec
print('\tCodec: ', end='')
print(technical_names[manifest['codecs']])
url = manifest['urls'][0]
if url.find('.flac?') == -1:
if url.find('.m4a?') == -1:
if url.find('.mp4?') == -1:
ftype = ''
else:
ftype = 'm4a'
else:
ftype = 'm4a'
else:
ftype = 'flac'
# ftype needs to be changed to work with audio codecs instead when with web auth
else:
ftype = 'flac'
if album_info['numberOfVolumes'] > 1 and not track_num:
track_path = path.join(disc_location, track_file + '.' + ftype)
else:
track_path = path.join(album_location, track_file + '.' + ftype)
if path.isfile(track_path) and not overwrite:
print('\tFile {} already exists, skipping.'.format(track_path))
return None
self.print_track_info(track_info, album_info)
if DRM:
manifest = manifest_unparsed
# Get playback link
pattern = re.compile(r'(?<=media=")[^"]+')
playback_link = pattern.findall(manifest)[0].replace("amp;", "")
# Create album tmp folder
tmp_folder = os.path.join(album_location, 'tmp/')
if not os.path.isdir(tmp_folder):
os.makedirs(tmp_folder)
pattern = re.compile(r'(?<= r=")[^"]+')
# Add 2?
length = int(pattern.findall(manifest)[0]) + 3
# Download all chunk files from MPD
with open(album_location + '/encrypted.mp4', 'wb') as encrypted_file:
for i in range(length):
link = playback_link.replace("$Number$", str(i))
filename = os.path.join(tmp_folder, str(i).zfill(3) + '.mp4')
download_file([link], 0, filename)
with open(filename, 'rb') as fd:
shutil.copyfileobj(fd, encrypted_file)
print('\tDownload progress: {0:.0f}%'.format(((i + 1) / length) * 100), end='\r')
print()
os.chdir(album_location)
decryption_key = input("\tInput key (ID:key): ")
print("\tDecrypting m4a")
try:
os.system('mp4decrypt --key {} encrypted.mp4 "{}"'.format(decryption_key, track_file + '.m4a'))
except Exception as e:
print(e)
print('mp4decrypt not found!')
temp_file = track_path
print("\tRemuxing m4a to FLAC")
(
ffmpeg
.input(track_file + '.m4a')
.output(track_file + '.flac', acodec="copy", loglevel='warning')
.overwrite_output()
.run()
)
shutil.rmtree("tmp")
os.remove('encrypted.mp4')
os.remove(track_file + '.m4a')
os.chdir('../../')
try:
if not DRM:
temp_file = self._dl_url(url, track_path)
if 'encryptionType' in manifest and manifest['encryptionType'] != 'NONE':
if not manifest['keyId'] == '':
print('\tLooks like file is encrypted. Decrypting...')
key, nonce = decrypt_security_token(manifest['keyId'])
decrypt_file(temp_file, key, nonce)
aa_location = path.join(album_location, 'Cover.jpg')
if not path.isfile(aa_location):
try:
artwork_size = 1200
if 'artwork_size' in self.opts:
if self.opts['artwork_size'] == 0:
raise Exception
artwork_size = self.opts['artwork_size']
print('\tDownloading album art from iTunes...')
s = requests.Session()
params = {
'country': 'US',
'entity': 'album',
'term': track_info['artist']['name'] + ' ' + track_info['album']['title']
}
r = s.get('https://itunes.apple.com/search', params=params)
r = r.json()
album_cover = None
for i in range(len(r['results'])):
if album_info['title'] == r['results'][i]['collectionName']:
# Get high resolution album cover
album_cover = r['results'][i]['artworkUrl100']
break
if album_cover is None:
raise Exception
compressed = 'bb'
if 'uncompressed_artwork' in self.opts:
if self.opts['uncompressed_artwork']:
compressed = '-999'
album_cover = album_cover.replace('100x100bb.jpg',
'{}x{}{}.jpg'.format(artwork_size, artwork_size, compressed))
self._dl_url(album_cover, aa_location)
if ftype == 'flac':
# Open cover.jpg to check size
with open(aa_location, 'rb') as f:
data = f.read()
# Check if cover is smaller than 16MB
max_size = 16777215
if len(data) > max_size:
print('\tCover file size is too large, only {0:.2f}MB are allowed.'.format(
max_size / 1024 ** 2))
print('\tFallback to compressed iTunes cover')
album_cover = album_cover.replace('-999', 'bb')
self._dl_url(album_cover, aa_location)
except:
print('\tDownloading album art from Tidal...')
if not self._dl_picture(track_info['album']['cover'], aa_location):
aa_location = None
# Converting FLAC to ALAC
if self.opts['convert_to_alac'] and ftype == 'flac':
print("\tConverting FLAC to ALAC...")
conv_file = temp_file[:-5] + ".m4a"
# command = 'ffmpeg -i "{0}" -vn -c:a alac "{1}"'.format(temp_file, conv_file)
(
ffmpeg
.input(temp_file)
.output(conv_file, acodec='alac', loglevel='warning')
.overwrite_output()
.run()
)
if path.isfile(conv_file) and not overwrite:
print("\tConversion successful")
os.remove(temp_file)
temp_file = conv_file
ftype = "m4a"
# Get credits from album id
print('\tSaving credits to file')
album_credits = self.credits_from_album(str(album_info['id']))
credits_dict = {}
try:
track_credits = album_credits['items'][track_info['trackNumber'] - 1]['credits']
for i in range(len(track_credits)):
credits_dict[track_credits[i]['type']] = ''
contributors = track_credits[i]['contributors']
for j in range(len(contributors)):
if j != len(contributors) - 1:
credits_dict[track_credits[i]['type']] += contributors[j]['name'] + ', '
else:
credits_dict[track_credits[i]['type']] += contributors[j]['name']
if credits_dict != {}:
if 'save_credits_txt' in self.opts:
if self.opts['save_credits_txt']:
data = ''
for key, value in credits_dict.items():
data += key + ': '
data += value + '\n'
with open((os.path.splitext(track_path)[0] + '.txt'), 'w') as f:
f.write(data)
# Janky way to set the dict to None to tell the tagger not to include it
if 'embed_credits' in self.opts:
if not self.opts['embed_credits']:
credits_dict = None
except IndexError:
credits_dict = None
lyrics = None
if 'save_lyrics_lrc' in self.opts and 'embed_lyrics' in self.opts:
if self.opts['save_lyrics_lrc'] or self.opts['embed_lyrics']:
# New API lyrics call with hacky 404 fix, pls never do it that way
lyrics_data = self.lyrics_from_track(track_id)
# Get unsynced lyrics
if self.opts['embed_lyrics']:
if 'lyrics' in lyrics_data and lyrics_data['lyrics']:
lyrics = lyrics_data['lyrics']
else:
print('\tNo unsynced lyrics could be found!')
# Get synced lyrics
if self.opts['save_lyrics_lrc']:
if 'subtitles' in lyrics_data and lyrics_data['subtitles']:
if not os.path.isfile(os.path.splitext(track_path)[0] + '.lrc'):
with open((os.path.splitext(track_path)[0] + '.lrc'), 'wb') as f:
f.write(lyrics_data['subtitles'].encode('utf-8'))
else:
print('\tNo synced lyrics could be found!')
# Tagging
print('\tTagging media file...')
if ftype == 'flac':
self.tm.tag_flac(temp_file, track_info, album_info, lyrics, credits_dict=credits_dict,
album_art_path=aa_location)
elif ftype == 'm4a' or ftype == 'mp4':
self.tm.tag_m4a(temp_file, track_info, album_info, lyrics, credits_dict=credits_dict,
album_art_path=aa_location)
else:
print('\tUnknown file type to tag!')
# Cleanup
if not self.opts['keep_cover_jpg'] and aa_location:
os.remove(aa_location)
return album_location, temp_file
# Delete partially downloaded file on keyboard interrupt
except KeyboardInterrupt:
if path.isfile(track_path):
print('Deleting partially downloaded file ' + str(track_path))
os.remove(track_path)
raise
================================================
FILE: redsea/sessions.py
================================================
import getpass
from redsea.tidal_api import TidalSessionFile, TidalRequestError, TidalMobileSession, TidalTvSession, SessionFormats
class RedseaSessionFile(TidalSessionFile):
'''
Redsea - TidalSession interpreter
Provides more user-friendly cli feedback for the
TidalSessionFile class
'''
def create_session(self, name, username, password):
super().new_session(name, username, password)
def new_session(self):
'''
Authenticates with Tidal service
Returns True if successful
'''
# confirm = input('Do you want to use the new TV authorization (needed for E-AC-3 JOC)? [y/N]? ')
confirm = input('Which login method do you want to use: TV (needed for MQA, E-AC-3), '
'Mobile (needed for MQA, AC-4, 360) [t/m]? ')
token_confirm = 'N'
device = 'mobile'
if confirm.upper() == 'T':
device = 'tv'
while True:
if device != 'tv' and token_confirm.upper() == 'N':
print('LOGIN: Enter your Tidal username and password:\n')
username = input('Username: ')
password = getpass.getpass('Password: ')
else:
username = ''
password = ''
name = ''
while name == '':
name = input('What would you like to call this new session? ')
if not name == '':
if name in self.sessions:
confirm = input('A session with name "{}" already exists. Overwrite [y/N]? '.format(name))
if confirm.upper() == 'Y':
super().remove(name)
else:
name = ''
continue
else:
confirm = input('Invalid entry! Would you like to cancel [y/N]? ')
if confirm.upper() == 'Y':
print('Operation cancelled.')
return False
try:
super().new_session(name, username, password, device)
break
except TidalRequestError as e:
if str(e).startswith('3001'):
print('\nUSERNAME OR PASSWORD INCORRECT. Please try again.\n\n')
continue
elif str(e).startswith('6004'):
print('\nINVALID TOKEN. (HTTP 401)')
continue
except AssertionError as e:
print(e)
confirm = input('Would you like to try again [Y/n]? ')
if not confirm.upper() == 'N':
continue
SessionFormats(self.sessions[name]).print_fomats()
print('Session saved!')
if not self.default == name:
print('Session named "{}". Use the "-a {}" flag when running redsea to choose session'.format(name, name))
return True
def load_session(self, session_name=None):
'''
Loads session from session store by name
'''
if session_name == '':
session_name = None
try:
return super().load(session_name=session_name)
except ValueError as e:
print(e)
if session_name is None:
confirm = input('No sessions found. Would you like to add one [Y/n]? ')
else:
confirm = input('No session "{}" found. Would you like to create it [Y/n]? '.format(session_name))
if confirm.upper() == 'Y':
if self.new_session():
if len(self.sessions) == 1:
return self.sessions[self.default]
else:
return self.sessions[session_name]
else:
print('No session was created!')
exit(0)
def get_session(self):
'''
Generator which iterates through available sessions
'''
for session in self.sessions:
yield self.sessions[session], session
def remove_session(self):
'''
Removes a session from the session store
'''
self.list_sessions(formats=False)
name = ''
while name == '':
name = input('Type the full name of the session you would like to remove: ')
if not name == '':
super().remove(name)
print('Session "{}" has been removed.'.format(name))
else:
confirm = input('Invalid entry! Would you like to cancel [y/N]? ')
if confirm.upper() == 'Y':
return False
def list_sessions(self, mobile_only=False, formats=True):
'''
List all available sessions
'''
mobile_sessions = [isinstance(self.sessions[s], TidalMobileSession) for s in self.sessions]
if len(self.sessions) == 0 or (mobile_sessions.count(True) == 0 and mobile_only):
confirm = input('No (mobile) sessions found. Would you like to add one [Y/n]? ')
if confirm.upper() == 'Y':
self.new_session()
else:
exit()
print('\nSESSIONS:')
for s in self.sessions:
if isinstance(self.sessions[s], TidalMobileSession):
device = '[MOBILE]'
elif isinstance(self.sessions[s], TidalTvSession) and not mobile_only:
device = '[TV]'
else:
device = '[DESKTOP]'
if mobile_only and isinstance(self.sessions[s], TidalTvSession):
continue
print(' [{}]{} {} | {}'.format(self.sessions[s].country_code, device, self.sessions[s].username, s))
if formats:
SessionFormats(self.sessions[s]).print_fomats()
print('')
if self.default is not None:
print('Default session is currently set to: {}'.format(self.default))
print('')
def set_default(self):
'''
Sets a session as the default
'''
self.list_sessions(formats=False)
while True:
name = input('Please provide the name of the session you would like to set as default: ')
if name != '' and name in self.sessions:
super().set_default(name)
print('Default session has successfully been set to "{}"'.format(name))
return
else:
print('ERROR: Session "{}" not found in sessions store!'.format(name))
def reauth(self):
'''
Requests password from the user and then re-auths
with the Tidal server to get a new (valid) sessionId
'''
self.list_sessions(formats=False)
while True:
name = input('Please provide the name of the session you would like to reauthenticate: ')
if name != '' and name in self.sessions:
try:
session = self.sessions[name]
if isinstance(session, TidalTvSession):
print('You cannot reauthenticate a TV session!')
exit()
print('LOGIN: Enter your Tidal password for account {}:\n'.format(session.username))
password = getpass.getpass('Password: ')
session.auth(password)
self._save()
print('Session "{}" has been successfully reauthed.'.format(name))
return
except TidalRequestError as e:
if 'Username or password is wrong' in str(e):
print('Error ' + str(e) + '. Please try again..')
continue
else:
raise(e)
except AssertionError as e:
if 'invalid sessionId' in str(e):
print('Reauthentication failed. SessionID is still invalid. Please try again.')
print('Note: If this fails more than once, please check your account and subscription status.')
continue
else:
raise(e)
else:
print('ERROR: Session "{}" not found in sessions store!'.format(name))
================================================
FILE: redsea/tagger.py
================================================
import unicodedata
from mutagen.easymp4 import EasyMP4
from mutagen.flac import FLAC, Picture
from mutagen.mp4 import MP4Cover
from mutagen.mp4 import MP4Tags
from mutagen.id3 import PictureType
# Needed for Windows tagging support
MP4Tags._padding = 0
def normalize_key(s):
# Remove accents from a given string
return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
class FeaturingFormat():
'''
Formatter for featuring artist tags
'''
def _format(self, featuredArtists, andStr):
artists = ''
if len(featuredArtists) == 1:
artists = featuredArtists[0]
elif len(featuredArtists) == 2:
artists = featuredArtists[0] + ' {} '.format(andStr) + featuredArtists[1]
else:
for i in range(0, len(featuredArtists)):
name = featuredArtists[i]
artists += name
if i < len(featuredArtists) - 1:
artists += ', '
if i == len(featuredArtists) - 2:
artists += andStr + ' '
return artists
def get_artist_format(self, mainArtists):
return self._format(mainArtists, '&')
def get_feature_format(self, featuredArtists):
return '(feat. {})'.format(self._format(featuredArtists, 'and'))
class Tagger(object):
def __init__(self, format_options):
self.fmtopts = format_options
def tags(self, track_info, track_type, album_info=None, tagger=None):
if tagger is None:
tagger = {}
title = track_info['title']
if len(track_info['artists']) == 1:
tagger['artist'] = track_info['artist']['name']
else:
self.featform = FeaturingFormat()
mainArtists = []
featuredArtists = []
for artist in track_info['artists']:
if artist['type'] == 'MAIN':
mainArtists.append(artist['name'])
elif artist['type'] == 'FEATURED':
featuredArtists.append(artist['name'])
if len(featuredArtists) > 0 and '(feat.' not in title:
title += ' ' + self.featform.get_feature_format(
featuredArtists)
tagger['artist'] = self.featform.get_artist_format(mainArtists)
if album_info is not None:
tagger['albumartist'] = album_info['artist']['name']
tagger['tracknumber'] = str(track_info['trackNumber']).zfill(2)
tagger['album'] = track_info['album']['title']
if album_info is not None:
# TODO: find a way to get numberOfTracks relative to the volume
if track_type == 'm4a':
tagger['tracknumber'] = str(track_info['trackNumber']).zfill(2) + '/' + str(
album_info['numberOfTracks'])
tagger['discnumber'] = str(
track_info['volumeNumber']) + '/' + str(
album_info['numberOfVolumes'])
if track_type == 'flac':
tagger['discnumber'] = str(track_info['volumeNumber'])
tagger['totaldiscs'] = str(album_info['numberOfVolumes'])
tagger['tracknumber'] = str(track_info['trackNumber'])
tagger['totaltracks'] = str(album_info['numberOfTracks'])
else:
tagger['discnumber'] = str(track_info['volumeNumber'])
if album_info['releaseDate']:
tagger['date'] = str(album_info['releaseDate'][:4])
if album_info['upc']:
if track_type == 'm4a':
tagger['upc'] = album_info['upc'].encode()
elif track_type == 'flac':
tagger['UPC'] = album_info['upc']
if track_info['version'] is not None and track_info['version'] != '':
fmt = ' ({})'.format(track_info['version'])
title += fmt
tagger['title'] = title
if track_info['copyright'] is not None:
tagger['copyright'] = track_info['copyright']
if track_info['isrc'] is not None:
if track_type == 'm4a':
tagger['isrc'] = track_info['isrc'].encode()
elif track_type == 'flac':
tagger['isrc'] = track_info['isrc']
# Stupid library won't accept int so it is needed to cast it to a byte with hex value 01
if track_info['explicit'] is not None:
if track_type == 'm4a':
tagger['explicit'] = b'\x01' if track_info['explicit'] else b'\x02'
elif track_type == 'flac':
tagger['Rating'] = 'Explicit' if track_info['explicit'] else 'Clean'
# Set genre from Deezer
if 'genre' in track_info:
tagger['genre'] = track_info['genre']
if 'replayGain' in track_info and 'peak' in track_info:
if track_type == 'flac':
tagger['REPLAYGAIN_TRACK_GAIN'] = str(track_info['replayGain'])
tagger['REPLAYGAIN_TRACK_PEAK'] = str(track_info['peak'])
if track_type is None:
if track_info['audioModes'] == ['DOLBY_ATMOS']:
tagger['quality'] = ' [Dolby Atmos]'
elif track_info['audioModes'] == ['SONY_360RA']:
tagger['quality'] = ' [360]'
elif track_info['audioQuality'] == 'HI_RES':
tagger['quality'] = ' [M]'
else:
tagger['quality'] = ''
if 'explicit' in album_info:
tagger['explicit'] = ' [E]' if album_info['explicit'] else ''
return tagger
def _meta_tag(self, tagger, track_info, album_info, track_type):
self.tags(track_info, track_type, album_info, tagger)
def tag_flac(self, file_path, track_info, album_info, lyrics, credits_dict=None, album_art_path=None):
tagger = FLAC(file_path)
self._meta_tag(tagger, track_info, album_info, 'flac')
if self.fmtopts['embed_album_art'] and album_art_path is not None:
pic = Picture()
with open(album_art_path, 'rb') as f:
pic.data = f.read()
# Check if cover is smaller than 16MB
if len(pic.data) < pic._MAX_SIZE:
pic.type = PictureType.COVER_FRONT
pic.mime = u'image/jpeg'
tagger.add_picture(pic)
else:
print('\tCover file size is too large, only {0:.2f}MB are allowed.'.format(pic._MAX_SIZE / 1024 ** 2))
print('\tSet "artwork_size" to a lower value in config/settings.py')
# Set lyrics from Deezer
if lyrics:
tagger['lyrics'] = lyrics
if credits_dict:
for key, value in credits_dict.items():
contributors = value.split(', ')
for con in contributors:
tagger.tags.append((normalize_key(key), con))
tagger.save(file_path)
def tag_m4a(self, file_path, track_info, album_info, lyrics, credits_dict=None, album_art_path=None):
tagger = EasyMP4(file_path)
# Register ISRC, UPC, lyrics and explicit tags
tagger.RegisterTextKey('isrc', '----:com.apple.itunes:ISRC')
tagger.RegisterTextKey('upc', '----:com.apple.itunes:UPC')
tagger.RegisterTextKey('explicit', 'rtng')
tagger.RegisterTextKey('lyrics', '\xa9lyr')
self._meta_tag(tagger, track_info, album_info, 'm4a')
if self.fmtopts['embed_album_art'] and album_art_path is not None:
pic = None
with open(album_art_path, 'rb') as f:
pic = MP4Cover(f.read())
tagger.RegisterTextKey('covr', 'covr')
tagger['covr'] = [pic]
# Set lyrics from Deezer
if lyrics:
tagger['lyrics'] = lyrics
if credits_dict:
for key, value in credits_dict.items():
contributors = value.split(', ')
key = normalize_key(key)
# Create a new freeform atom and set the contributors in bytes
tagger.RegisterTextKey(key, '----:com.apple.itunes:' + key)
tagger[key] = [con.encode() for con in contributors]
tagger.save(file_path)
================================================
FILE: redsea/tidal_api.py
================================================
import pickle
import uuid
import os
import re
import json
import urllib.parse as urlparse
import webbrowser
from urllib.parse import parse_qs
import hashlib
import base64
import secrets
from datetime import datetime, timedelta
import urllib3
import time
import sys
import prettytable
import requests
from urllib3.util.retry import Retry
from requests.adapters import HTTPAdapter
from subprocess import Popen, PIPE
from config.settings import TOKEN, MOBILE_TOKEN, TV_TOKEN, TV_SECRET, SHOWAUTH
technical_names = {
'eac3': 'E-AC-3 JOC (Dolby Digital Plus with Dolby Atmos, with 5.1 bed)',
'mha1': 'MPEG-H 3D Audio (Sony 360 Reality Audio)',
'ac4': 'AC-4 IMS (Dolby AC-4 with Dolby Atmos immersive stereo)',
'mqa': 'MQA (Master Quality Authenticated) in FLAC container',
'flac': 'FLAC (Free Lossless Audio Codec)',
'alac': 'ALAC (Apple Lossless Audio Codec)',
'mp4a.40.2': 'AAC 320 (Advanced Audio Coding) with a bitrate of 320kb/s',
'mp4a.40.5': 'AAC 96 (Advanced Audio Coding) with a bitrate of 96kb/s'
}
class TidalRequestError(Exception):
def __init__(self, payload):
sf = '{subStatus}: {userMessage} (HTTP {status})'.format(**payload)
self.payload = payload
super(TidalRequestError, self).__init__(sf)
class TidalAuthError(Exception):
def __init__(self, message):
super(TidalAuthError, self).__init__(message)
class TidalError(Exception):
def __init__(self, message):
self.message = message
super(TidalError, self).__init__(message)
class TidalApi:
TIDAL_API_BASE = 'https://api.tidal.com/v1/'
TIDAL_VIDEO_BASE = 'https://api.tidalhifi.com/v1/'
TIDAL_CLIENT_VERSION = '2.26.1'
def __init__(self, session):
self.session = session
self.s = requests.Session()
retries = Retry(total=10,
backoff_factor=0.4,
status_forcelist=[429, 500, 502, 503, 504])
self.s.mount('http://', HTTPAdapter(max_retries=retries))
self.s.mount('https://', HTTPAdapter(max_retries=retries))
def _get(self, url, params=None, refresh=False):
if params is None:
params = {}
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
params['countryCode'] = self.session.country_code
if 'limit' not in params:
params['limit'] = '9999'
# Catch video for different base
if url[:5] == 'video':
resp = self.s.get(
self.TIDAL_VIDEO_BASE + url,
headers=self.session.auth_headers(),
params=params,
verify=False)
else:
resp = self.s.get(
self.TIDAL_API_BASE + url,
headers=self.session.auth_headers(),
params=params,
verify=False)
# if the request 401s or 403s, try refreshing the TV/Mobile session in case that helps
if not refresh and (resp.status_code == 401 or resp.status_code == 403):
if isinstance(self.session, TidalMobileSession) or isinstance(self.session, TidalTvSession):
self.session.refresh()
return self._get(url, params, True)
resp_json = None
try:
resp_json = resp.json()
except: # some tracks seem to return a JSON with leading whitespace
try:
resp_json = json.loads(resp.text.strip())
except: # if this doesn't work, the HTTP status probably isn't 200. Are we rate limited?
pass
if not resp_json:
raise TidalError('Response was not valid JSON. HTTP status {}. {}'.format(resp.status_code, resp.text))
if 'status' in resp_json and resp_json['status'] == 404 and \
'subStatus' in resp_json and resp_json['subStatus'] == 2001:
raise TidalError('Error: {}. This might be region-locked.'.format(resp_json['userMessage']))
# Really hacky way
if 'status' in resp_json and resp_json['status'] == 404 and \
'error' in resp_json and resp_json['error'] == 'Not Found':
return resp_json
if 'status' in resp_json and not resp_json['status'] == 200:
raise TidalRequestError(resp_json)
return resp_json
def get_stream_url(self, track_id, quality):
return self._get('tracks/' + str(track_id) + '/playbackinfopostpaywall', {
'playbackmode': 'STREAM',
'assetpresentation': 'FULL',
'audioquality': quality[0],
'prefetch': 'false'
})
def get_search_data(self, searchterm):
return self._get('search', params={
'query': str(searchterm),
'offset': 0,
'limit': 20,
'includeContributors': 'true'
})
def get_page(self, page_url, offset=None):
return self._get('pages/' + page_url, params={
'deviceType': 'TV',
'locale': 'en_US',
'limit': 50,
'offset': offset if offset else None
})
def get_credits(self, album_id):
return self._get('albums/' + album_id + '/items/credits', params={
'replace': True,
'offset': 0,
'limit': 50,
'includeContributors': True
})
def get_video_credits(self, video_id):
return self._get('videos/' + video_id + '/contributors', params={
'limit': 50
})
def get_lyrics(self, track_id):
return self._get('tracks/' + str(track_id) + '/lyrics', params={
'deviceType': 'PHONE',
'locale': 'en_US'
})
def get_playlist_items(self, playlist_id):
result = self._get('playlists/' + playlist_id + '/items', {
'offset': 0,
'limit': 100
})
if result['totalNumberOfItems'] <= 100:
return result
offset = len(result['items'])
while True:
buf = self._get('playlists/' + playlist_id + '/items', {
'offset': offset,
'limit': 100
})
offset += len(buf['items'])
result['items'] += buf['items']
if offset >= result['totalNumberOfItems']:
break
return result
def get_playlist(self, playlist_id):
return self._get('playlists/' + str(playlist_id))
def get_album_tracks(self, album_id):
return self._get('albums/' + str(album_id) + '/tracks')
def get_track(self, track_id):
return self._get('tracks/' + str(track_id))
def get_album(self, album_id):
return self._get('albums/' + str(album_id))
def get_video(self, video_id):
return self._get('videos/' + str(video_id))
def get_favorite_tracks(self, user_id):
return self._get('users/' + str(user_id) + '/favorites/tracks')
def get_track_contributors(self, track_id):
return self._get('tracks/' + str(track_id) + '/contributors')
def get_video_stream_url(self, video_id):
return self._get('videos/' + str(video_id) + '/streamurl')
def get_artist(self, artist_id):
return self._get('artists/' + str(artist_id))
def get_artist_albums(self, artist_id):
return self._get('artists/' + str(artist_id) + '/albums')
def get_artist_albums_ep_singles(self, artist_id):
return self._get('artists/' + str(artist_id) + '/albums', params={'filter': 'EPSANDSINGLES'})
def get_type_from_id(self, id_):
result = None
try:
result = self.get_album(id_)
return 'a'
except TidalError:
pass
try:
result = self.get_artist(id_)
return 'r'
except TidalError:
pass
try:
result = self.get_track(id_)
return 't'
except TidalError:
pass
try:
result = self.get_video(id_)
return 'v'
except TidalError:
pass
return result
@classmethod
def get_album_artwork_url(cls, album_id, size=1280):
return 'https://resources.tidal.com/images/{0}/{1}x{1}.jpg'.format(
album_id.replace('-', '/'), size)
class SessionFormats:
def __init__(self, session):
self.mqa_trackid = '91950969'
self.dolby_trackid = '131069353'
self.sony_trackid = '142292058'
self.quality = ['HI_RES', 'LOSSLESS', 'HIGH', 'LOW']
self.formats = {
'eac3': False,
'mha1': False,
'ac4': False,
'mqa': False,
'flac': False,
'alac': False,
'mp4a.40.2': False,
'mp4a.40.5': False
}
try:
self.check_formats(session)
except TidalRequestError:
print('\tERROR: No (HiFi) subscription found!')
def check_formats(self, session):
api = TidalApi(session)
for id in [self.dolby_trackid, self.sony_trackid]:
playback_info = api.get_stream_url(id, ['LOW'])
if playback_info['manifestMimeType'] == 'application/dash+xml':
continue
manifest_unparsed = base64.b64decode(playback_info['manifest']).decode('UTF-8')
if 'ContentProtection' not in manifest_unparsed:
self.formats[json.loads(manifest_unparsed)['codecs']] = True
for i in range(len(self.quality)):
playback_info = api.get_stream_url(self.mqa_trackid, [self.quality[i]])
if playback_info['manifestMimeType'] == 'application/dash+xml':
continue
manifest_unparsed = base64.b64decode(playback_info['manifest']).decode('UTF-8')
if 'ContentProtection' not in manifest_unparsed:
self.formats[json.loads(manifest_unparsed)['codecs']] = True
def print_fomats(self):
table = prettytable.PrettyTable()
table.field_names = ['Codec', 'Technical name', 'Supported']
table.align = 'l'
for format in self.formats:
table.add_row([format, technical_names[format], self.formats[format]])
string_table = '\t' + table.__str__().replace('\n', '\n\t')
print(string_table)
print('')
class ReCaptcha(object):
def __init__(self):
self.captcha_path = 'captcha/'
self.response_v3 = None
self.response_v2 = None
self.get_response()
@staticmethod
def check_npm():
pipe = Popen('npm -version', shell=True, stdout=PIPE).stdout
output = pipe.read().decode('UTF-8')
found = re.search(r'[0-9].[0-9]+.', output)
if not found:
print("NPM could not be found.")
return False
return True
def get_response(self):
if self.check_npm():
print("Opening reCAPTCHA check...")
command = 'npm start --prefix '
pipe = Popen(command + self.captcha_path, shell=True, stdout=PIPE)
pipe.wait()
output = pipe.stdout.read().decode('UTF-8')
pattern = re.compile(r"(?<='response': ')[0-9A-Za-z-_]+")
response = pattern.findall(output)
if len(response) > 2:
print('You only need to complete the captcha once.')
return False
elif len(response) == 1:
self.response_v3 = response[0]
return True
elif len(response) == 2:
self.response_v3 = response[0]
self.response_v2 = response[1]
return True
print('Please complete the reCAPTCHA check.')
return False
class TidalSession:
'''
Tidal session object which can be used to communicate with Tidal servers
'''
def __init__(self, username, password):
'''
Initiate a new session
'''
self.TIDAL_CLIENT_VERSION = '2.26.1'
self.TIDAL_API_BASE = 'https://api.tidal.com/v1/'
self.username = username
self.token = TOKEN
self.unique_id = str(uuid.uuid4()).replace('-', '')[16:]
self.session_id = None
self.user_id = None
self.country_code = None
# simple fix for OOB
if username != '' and password != '':
self.auth(password)
password = None
def auth(self, password):
'''
Attempts to authorize and create a new valid session
'''
params = {
'username': self.username,
'password': password,
'token': self.token,
'clientUniqueKey': self.unique_id,
'clientVersion': self.TIDAL_CLIENT_VERSION
}
r = requests.post(self.TIDAL_API_BASE + 'login/username', data=params, verify=False)
password = None
if not r.status_code == 200:
raise TidalRequestError(r)
self.session_id = r.json()['sessionId']
self.user_id = r.json()['userId']
self.country_code = r.json()['countryCode']
assert self.valid(), 'This session has an invalid sessionId. Please re-authenticate'
self.check_subscription()
@staticmethod
def session_type():
'''
Returns the type of token used to create the session
'''
return 'Desktop'
def check_subscription(self):
'''
Checks if subscription is either HiFi or Premium Plus
'''
r = requests.get(f'{self.TIDAL_API_BASE}users/{self.user_id}/subscription',
headers=self.auth_headers(), verify=False)
assert (r.status_code == 200)
if r.json()['subscription']['type'] not in ['HIFI', 'PREMIUM_PLUS']:
raise TidalAuthError('You need a HiFi subscription')
def valid(self):
'''
Checks if session is still valid and returns True/False
'''
if not isinstance(self, TidalSession):
if self.access_token is None or datetime.now() > self.expires:
return False
r = requests.get(f'{self.TIDAL_API_BASE}sessions', headers=self.auth_headers(), verify=False)
return r.status_code == 200
def auth_headers(self):
return {
'Host': 'api.tidal.com',
'User-Agent': 'okhttp/3.12.3',
'X-Tidal-Token': self.token,
'X-Tidal-SessionId': self.session_id,
'Connection': 'Keep-Alive',
'Accept-Encoding': 'gzip',
}
class TidalMobileSession(TidalSession):
'''
Tidal session object based on the mobile Android oauth flow
'''
def __init__(self, username, password):
# init the TidalSession class first
super(TidalMobileSession, self).__init__('', '')
self.TIDAL_LOGIN_BASE = 'https://login.tidal.com/api/'
self.TIDAL_AUTH_BASE = 'https://auth.tidal.com/v1/'
self.username = username
self.client_id = MOBILE_TOKEN
self.redirect_uri = 'https://tidal.com/android/login/auth'
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(16)
self.user_agent = 'Mozilla/5.0 (Linux; Android 12; Pixel 6 Build/RKQ1.200826.002; wv) AppleWebKit/537.36 ' \
'(KHTML, like Gecko) Version/4.0 Chrome/105.0.5195.136 Mobile Safari/537.36'
self.access_token = None
self.refresh_token = None
self.expires = None
self.user_id = None
self.cid = None
self.country_code = None
self.auth(password)
def auth(self, password):
s = requests.Session()
params = {
'response_type': 'code',
'redirect_uri': self.redirect_uri,
'lang': 'en_US',
'appMode': 'android',
'client_id': self.client_id,
'client_unique_key': self.client_unique_key,
'code_challenge': self.code_challenge,
'code_challenge_method': 'S256',
'restrict_signup': 'true'
}
# retrieve csrf token for subsequent request
r = s.get('https://login.tidal.com/authorize', params=params, headers={
'user-agent': self.user_agent,
'accept-language': 'en-US',
'x-requested-with': 'com.aspiro.tidal'
})
if r.status_code == 400:
raise TidalAuthError("Authorization failed! Is the clientid/token up to date?")
elif r.status_code == 403:
raise TidalAuthError("TIDAL BOT protection, try again later!")
# try Tidal DataDome cookie request
r = s.post('https://dd.tidal.com/js/', data={
'ddk': '1F633CDD8EF22541BD6D9B1B8EF13A', # API Key (required)
'Referer': r.url, # Referer authorize link (required)
'responsePage': 'origin', # useless?
'ddv': '4.4.7' # useless?
}, headers={
'user-agent': self.user_agent,
'content-type': 'application/x-www-form-urlencoded'
})
if r.status_code != 200 or not r.json().get('cookie'):
raise TidalAuthError("TIDAL BOT protection, could not get DataDome cookie!")
# get the cookie from the json request and save it in the session
dd_cookie = r.json().get('cookie').split(';')[0]
s.cookies[dd_cookie.split('=')[0]] = dd_cookie.split('=')[1]
# enter email, verify email is valid
r = s.post(self.TIDAL_LOGIN_BASE + 'email', params=params, json={
'email': self.username
}, headers={
'user-agent': self.user_agent,
'x-csrf-token': s.cookies['_csrf-token'],
'accept': 'application/json, text/plain, */*',
'content-type': 'application/json',
'accept-language': 'en-US',
'x-requested-with': 'com.aspiro.tidal'
})
if r.status_code != 200:
raise TidalAuthError(r.text)
if not r.json()['isValidEmail']:
raise TidalAuthError('Invalid email')
if r.json()['newUser']:
raise TidalAuthError('User does not exist')
# login with user credentials
r = s.post(self.TIDAL_LOGIN_BASE + 'email/user/existing', params=params, json={
'email': self.username,
'password': password
}, headers={
'User-Agent': self.user_agent,
'x-csrf-token': s.cookies['_csrf-token'],
'accept': 'application/json, text/plain, */*',
'content-type': 'application/json',
'accept-language': 'en-US',
'x-requested-with': 'com.aspiro.tidal'
})
if r.status_code != 200:
raise TidalAuthError(r.text)
# retrieve access code
r = s.get('https://login.tidal.com/success?lang=en', allow_redirects=False, headers={
'user-agent': self.user_agent,
'accept-language': 'en-US',
'x-requested-with': 'com.aspiro.tidal'
})
if r.status_code == 401:
raise TidalAuthError('Incorrect password')
assert (r.status_code == 302)
url = urlparse.urlparse(r.headers['location'])
oauth_code = parse_qs(url.query)['code'][0]
# exchange access code for oauth token
r = requests.post(self.TIDAL_AUTH_BASE + 'oauth2/token', data={
'code': oauth_code,
'client_id': self.client_id,
'grant_type': 'authorization_code',
'redirect_uri': self.redirect_uri,
'scope': 'r_usr w_usr w_sub',
'code_verifier': self.code_verifier,
'client_unique_key': self.client_unique_key
}, headers={
'User-Agent': self.user_agent
})
if r.status_code != 200:
raise TidalAuthError(r.text)
self.access_token = r.json()['access_token']
self.refresh_token = r.json()['refresh_token']
self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in'])
if SHOWAUTH:
print('Your Authorization token: ' + self.access_token)
r = requests.get(f'{self.TIDAL_API_BASE}sessions', headers=self.auth_headers(), verify=False)
assert (r.status_code == 200)
self.user_id = r.json()['userId']
self.country_code = r.json()['countryCode']
self.check_subscription()
def refresh(self):
assert (self.refresh_token is not None)
r = requests.post(self.TIDAL_AUTH_BASE + 'oauth2/token', data={
'refresh_token': self.refresh_token,
'client_id': self.client_id,
'grant_type': 'refresh_token'
}, verify=False)
if r.status_code == 200:
print('\tRefreshing token successful')
self.access_token = r.json()['access_token']
self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in'])
if SHOWAUTH:
print('Your Authorization token: ' + self.access_token)
if 'refresh_token' in r.json():
self.refresh_token = r.json()['refresh_token']
elif r.status_code == 401:
print('\tERROR: ' + r.json()['userMessage'])
return r.status_code == 200
def session_type(self):
return 'Mobile'
def auth_headers(self):
return {
'Host': 'api.tidal.com',
'X-Tidal-Token': self.client_id,
'Authorization': 'Bearer {}'.format(self.access_token),
'Connection': 'Keep-Alive',
'Accept-Encoding': 'gzip',
'User-Agent': 'TIDAL_ANDROID/1039 okhttp/3.14.9'
}
class TidalTvSession(TidalSession):
'''
Tidal session object based on the mobile Android oauth flow
'''
def __init__(self):
# init the TidalSession class first
super(TidalTvSession, self).__init__('', '')
self.TIDAL_AUTH_BASE = 'https://auth.tidal.com/v1/'
self.username = None
self.client_id = TV_TOKEN
self.client_secret = TV_SECRET
self.device_code = None
self.user_code = None
self.access_token = None
self.refresh_token = None
self.expires = None
self.user_id = None
self.country_code = None
self.auth()
def auth(self, password=''):
s = requests.Session()
# retrieve csrf token for subsequent request
r = s.post(self.TIDAL_AUTH_BASE + 'oauth2/device_authorization', data={
'client_id': self.client_id,
'scope': 'r_usr w_usr'
}, verify=False)
if r.status_code == 400:
raise TidalAuthError("Authorization failed! Is the clientid/token up to date?")
elif r.status_code == 403:
raise TidalAuthError("Tidal BOT Protection, try again later!")
self.device_code = r.json()['deviceCode']
self.user_code = r.json()['userCode']
print('Go to https://link.tidal.com/{} and log in or sign up to TIDAL.'.format(self.user_code))
webbrowser.open('https://link.tidal.com/' + self.user_code, new=2)
data = {
'client_id': self.client_id,
'device_code': self.device_code,
'client_secret': self.client_secret,
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
'scope': 'r_usr w_usr'
}
status_code = 400
print('Checking link ', end='')
while status_code == 400:
for index, char in enumerate("." * 5):
sys.stdout.write(char)
sys.stdout.flush()
# exchange access code for oauth token
time.sleep(0.2)
r = requests.post(self.TIDAL_AUTH_BASE + 'oauth2/token', data=data, verify=False)
status_code = r.status_code
index += 1 # lists are zero indexed, we need to increase by one for the accurate count
# backtrack the written characters, overwrite them with space, backtrack again:
sys.stdout.write("\b" * index + " " * index + "\b" * index)
sys.stdout.flush()
if r.status_code == 200:
print('\nSuccessfully linked!')
elif r.status_code == 401:
raise TidalAuthError('Auth Error: ' + r.json()['error'])
self.access_token = r.json()['access_token']
self.refresh_token = r.json()['refresh_token']
self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in'])
if SHOWAUTH:
print('Your Authorization token: ' + self.access_token)
r = requests.get('https://api.tidal.com/v1/sessions', headers=self.auth_headers(), verify=False)
assert (r.status_code == 200)
self.user_id = r.json()['userId']
self.country_code = r.json()['countryCode']
r = requests.get('https://api.tidal.com/v1/users/{}?countryCode={}'.format(self.user_id, self.country_code),
headers=self.auth_headers(), verify=False)
assert (r.status_code == 200)
self.username = r.json()['username']
self.check_subscription()
def refresh(self):
assert (self.refresh_token is not None)
r = requests.post(self.TIDAL_AUTH_BASE + 'oauth2/token', data={
'refresh_token': self.refresh_token,
'client_id': self.client_id,
'client_secret': self.client_secret,
'grant_type': 'refresh_token'
}, verify=False)
if r.status_code == 200:
print('\tRefreshing token successful')
self.access_token = r.json()['access_token']
self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in'])
if SHOWAUTH:
print('Your Authorization token: ' + self.access_token)
if 'refresh_token' in r.json():
self.refresh_token = r.json()['refresh_token']
return r.status_code == 200
def session_type(self):
return 'Tv'
def auth_headers(self):
return {
'Host': 'api.tidal.com',
'X-Tidal-Token': self.client_id,
'Authorization': 'Bearer {}'.format(self.access_token),
'Connection': 'Keep-Alive',
'Accept-Encoding': 'gzip',
'User-Agent': 'TIDAL_ANDROID/1039 okhttp/3.14.9'
}
class TidalSessionFile(object):
'''
Tidal session storage file which can save/load
'''
def __init__(self, session_file):
self.VERSION = '1.0'
self.session_file = session_file # Session file path
self.session_store = {} # Will contain data from session file
self.sessions = {} # Will contain sessions from session_store['sessions']
self.default = None # Specifies the name of the default session to use
if os.path.isfile(self.session_file):
with open(self.session_file, 'rb') as f:
self.session_store = pickle.load(f)
if 'version' in self.session_store and self.session_store['version'] == self.VERSION:
self.sessions = self.session_store['sessions']
self.default = self.session_store['default']
elif 'version' in self.session_store:
raise ValueError(
'Session file is version {} while redsea expects version {}'.
format(self.session_store['version'], self.VERSION))
else:
raise ValueError('Existing session file is malformed. Please delete/rebuild session file.')
f.close()
else:
self._save()
self = TidalSessionFile(session_file=self.session_file)
def _save(self):
'''
Attempts to write current session store to file
'''
self.session_store['version'] = self.VERSION
self.session_store['sessions'] = self.sessions
self.session_store['default'] = self.default
with open(self.session_file, 'wb') as f:
pickle.dump(self.session_store, f)
def new_session(self, session_name, username, password, device):
'''
Create a new TidalSession object and auth with Tidal server
'''
if session_name not in self.sessions:
if device == 'mobile':
session = TidalMobileSession(username, password)
elif device == 'tv':
session = TidalTvSession()
else:
session = TidalSession(username, password)
self.sessions[session_name] = session
password = None
if len(self.sessions) == 1:
self.default = session_name
else:
password = None
raise ValueError('Session "{}" already exists in sessions file!'.format(session_name))
self._save()
def remove(self, session_name):
'''
Removes a session from the session store and saves the session file
'''
if session_name not in self.sessions:
raise ValueError('Session "{}" does not exist in session store.'.format(session_name))
self.sessions.pop(session_name)
self._save()
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 and no valid AUTHHEADER was provided!')
if session_name is None:
session_name = self.default
if session_name in self.sessions:
# TODO: Only required for old sessions, remove if possible
if not hasattr(self.sessions[session_name], 'TIDAL_API_BASE'):
self.sessions[session_name].TIDAL_API_BASE = 'https://api.tidal.com/v1/'
if not self.sessions[session_name].valid() and isinstance(self.sessions[session_name], TidalMobileSession):
self.sessions[session_name].refresh()
if not self.sessions[session_name].valid() and isinstance(self.sessions[session_name], TidalTvSession):
self.sessions[session_name].refresh()
assert self.sessions[session_name].valid(), '{} has an invalid sessionId. Please re-authenticate'.format(
session_name)
self._save()
return self.sessions[session_name]
raise ValueError('Session "{}" could not be found.'.format(session_name))
def set_default(self, session_name):
'''
Set a default session to return when
load() is called without a session name
'''
if session_name in self.sessions:
# TODO: Only required for old sessions, remove if possible
if not hasattr(self.sessions[session_name], 'TIDAL_API_BASE'):
self.sessions[session_name].TIDAL_API_BASE = 'https://api.tidal.com/v1/'
if not self.sessions[session_name].valid() and isinstance(self.sessions[session_name], TidalMobileSession):
self.sessions[session_name].refresh()
if not self.sessions[session_name].valid() and isinstance(self.sessions[session_name], TidalTvSession):
self.sessions[session_name].refresh()
assert self.sessions[session_name].valid(), '{} has an invalid sessionId. Please re-authenticate'.format(
session_name)
self.default = session_name
self._save()
================================================
FILE: redsea/videodownloader.py
================================================
import os
import re
import shutil
import unicodedata
import ffmpeg
import requests
from mutagen.easymp4 import EasyMP4
from mutagen.mp4 import MP4Cover
from mutagen.mp4 import MP4Tags
# Needed for Windows tagging support
MP4Tags._padding = 0
def normalize_key(s):
# Remove accents from a given string
return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
def parse_master_playlist(masterurl: str):
content = str(requests.get(masterurl, verify=False).content)
pattern = re.compile(r"(?<=RESOLUTION=)[0-9]+x[0-9]+")
resolution_list = pattern.findall(content)
pattern = re.compile(r"(?<=http).+?(?=\\n)")
plist = pattern.findall(content)
playlists = [{'height': int(resolution_list[i].split('x')[1]),
'url': "http" + plist[i]} for i in range(len(plist))]
return sorted(playlists, key=lambda k: k['height'], reverse=True)
def parse_playlist(url: str):
content = requests.get(url, verify=False).content
pattern = re.compile(r"(?<=http).+?(?=\\n)")
plist = pattern.findall(str(content))
urllist = []
for item in plist:
urllist.append("http" + item)
return urllist
def download_file(urllist: list, part: int, filename: str):
if os.path.isfile(filename):
# print('\tFile {} already exists, skipping.'.format(filename))
return None
r = requests.get(urllist[part], stream=True, verify=False)
try:
total = int(r.headers['content-length'])
except KeyError:
return False
with open(filename, 'wb') as f:
cc = 0
for chunk in r.iter_content(chunk_size=1024):
cc += 1024
if chunk: # filter out keep-alive new chunks
f.write(chunk)
f.close()
def print_video_info(track_info: dict):
line = '\tTitle: {0}\n\tArtist: {1}\n\tType: {2}\n\tResolution: {3}'.format(track_info['title'],
track_info['artist']['name'],
track_info['type'],
track_info['resolution'])
try:
print(line)
except UnicodeEncodeError:
line = line.encode('ascii', 'replace').decode('ascii')
print(line)
print('\t----')
def download_video_artwork(image_id: str, where: str):
url = 'https://resources.tidal.com/images/{0}/{1}x{2}.jpg'.format(
image_id.replace('-', '/'), 1280, 720)
r = requests.get(url, stream=True, verify=False)
try:
total = int(r.headers['content-length'])
except KeyError:
return False
with open(where, 'wb') as f:
cc = 0
for chunk in r.iter_content(chunk_size=1024):
cc += 1024
print(
"\tDownload progress: {0:.0f}%".format((cc / total) * 100),
end='\r')
if chunk: # filter out keep-alive new chunks
f.write(chunk)
print()
return True
def tags(video_info: dict, tagger=None, ftype=None):
if tagger is None:
tagger = {'id': video_info['id'], 'quality': ' [' + video_info['quality'][4:] + ']'}
tagger['title'] = video_info['title']
tagger['artist'] = video_info['artist']['name']
if ftype:
tagger['tracknumber'] = str(video_info['trackNumber']).zfill(2) + '/' + str(video_info['volumeNumber'])
else:
tagger['tracknumber'] = str(video_info['trackNumber']).zfill(2)
tagger['discnumber'] = str(video_info['volumeNumber'])
if 'explicit' in video_info:
if ftype:
tagger['explicit'] = b'\x01' if video_info['explicit'] else b'\x02'
else:
tagger['explicit'] = ' [E]' if video_info['explicit'] else ''
if video_info['releaseDate']:
# TODO: less hacky way of getting the year?
tagger['date'] = str(video_info['releaseDate'][:4])
return tagger
def tag_video(file_path: str, track_info: dict, credits_dict: dict, album_art_path: str):
tagger = EasyMP4(file_path)
tagger.RegisterTextKey('explicit', 'rtng')
# Add tags to the EasyMP4 tagger
tags(track_info, tagger, ftype='mp4')
pic = None
with open(album_art_path, 'rb') as f:
pic = MP4Cover(f.read())
tagger.RegisterTextKey('covr', 'covr')
tagger['covr'] = [pic]
if credits_dict:
for key, value in credits_dict.items():
key = normalize_key(key)
# Create a new freeform atom and set the contributors in bytes
tagger.RegisterTextKey(key, '----:com.apple.itunes:' + key)
tagger[key] = [bytes(con, encoding='utf-8') for con in value]
tagger.save(file_path)
def download_stream(folder_path: str, file_name: str, url: str, resolution: int, video_info: dict, credits_dict: dict):
tmp_folder = os.path.join(folder_path, 'tmp')
playlists = parse_master_playlist(url)
urllist = []
for playlist in playlists:
if resolution >= playlist['height']:
video_info['resolution'] = playlist['height']
urllist = parse_playlist(playlist['url'])
break
if len(urllist) <= 0:
print('Error: list of URLs is empty!')
return False
print_video_info(video_info)
if not os.path.isdir(tmp_folder):
os.makedirs(tmp_folder)
filelist_loc = os.path.join(tmp_folder, 'filelist.txt')
if os.path.exists(filelist_loc):
os.remove(filelist_loc)
filename = ""
for i in range(len(urllist)):
try:
filename = os.path.join(tmp_folder, str(i).zfill(3) + '.ts')
download_file(urllist, i, filename)
with open(filelist_loc, 'a') as f:
f.write("file '" + str(i).zfill(3) + '.ts' + "'\n")
percent = i / (len(urllist) - 1) * 100
print("\tDownload progress: {0:.0f}%".format(percent), end='\r')
# print(percent)
# Delete partially downloaded file on keyboard interrupt
except KeyboardInterrupt:
if os.path.isfile(filename):
print('\tDeleting partially downloaded file ' + str(filename))
os.remove(filename)
raise
# print("\tDownload progress: {0:.0f}%".format(percent), end='\r')
print("\n\tDownload succeeded!")
file_path = os.path.join(folder_path, file_name + '.mp4')
(
ffmpeg
.input(filelist_loc, format='concat', safe=0)
.output(file_path, vcodec='copy', acodec='copy', loglevel='warning')
.overwrite_output()
.run()
)
print('\tConcatenation succeeded!')
shutil.rmtree(tmp_folder)
print('\tDownloading album art ...')
aa_location = os.path.join(folder_path, 'Cover.jpg')
if not os.path.isfile(aa_location):
if not download_video_artwork(video_info['imageId'], aa_location):
aa_location = None
print('\tTagging video file...')
tag_video(file_path, video_info, credits_dict, aa_location)
================================================
FILE: redsea.py
================================================
#!/usr/bin/env python
import traceback
import sys
import os
import re
import urllib3
import redsea.cli as cli
from redsea.mediadownloader import MediaDownloader
from redsea.tagger import Tagger
from redsea.tidal_api import TidalApi, TidalError
from redsea.sessions import RedseaSessionFile
from config.settings import PRESETS, BRUTEFORCEREGION
LOGO = """
/$$$$$$$ /$$ /$$$$$$
| $$__ $$ | $$ /$$__ $$
| $$ \ $$ /$$$$$$ /$$$$$$$| $$ \__/ /$$$$$$ /$$$$$$
| $$$$$$$/ /$$__ $$ /$$__ $$| $$$$$$ /$$__ $$ |____ $$
| $$__ $$| $$$$$$$$| $$ | $$ \____ $$| $$$$$$$$ /$$$$$$$
| $$ \ $$| $$_____/| $$ | $$ /$$ \ $$| $$_____/ /$$__ $$
| $$ | $$| $$$$$$$| $$$$$$$| $$$$$$/| $$$$$$$| $$$$$$$
|__/ |__/ \_______/ \_______/ \______/ \_______/ \_______/
(c) 2016 Joe Thatcher
https://github.com/svbnet/RedSea
\n"""
MEDIA_TYPES = {'t': 'track', 'p': 'playlist', 'a': 'album', 'r': 'artist', 'v': 'video'}
def main():
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
os.chdir(sys.path[0])
# Get args
args = cli.get_args()
# Load config
BRUTEFORCE = args.bruteforce or BRUTEFORCEREGION
preset = PRESETS[args.preset]
# Parse options
preset['quality'] = []
preset['quality'].append('HI_RES') if preset['MQA_FLAC_24'] else None
preset['quality'].append('LOSSLESS') if preset['FLAC_16'] else None
preset['quality'].append('HIGH') if preset['AAC_320'] else None
preset['quality'].append('LOW') if preset['AAC_96'] else None
# Check for auth flag / session settings
RSF = RedseaSessionFile('./config/sessions.pk')
if args.urls[0] == 'auth' and len(args.urls) == 1:
print('\nThe "auth" command provides the following methods:')
print('\n list: Lists stored sessions if any exist')
print(' add: Prompts for a TV or Mobile session. The TV option displays a 6 digit key which should be '
'entered inside link.tidal.com where the user can login. The Mobile option prompts for a Tidal username '
'and password. Both options authorize a session which then gets stored in the sessions file')
print(' remove: Removes a stored session from the sessions file by name')
print(' default: Set a default account for redsea to use when the -a flag has not been passed')
print(' reauth: Reauthenticates with server to get new sessionId')
print('\nUsage: redsea.py auth add\n')
exit()
elif args.urls[0] == 'auth' and len(args.urls) > 1:
if args.urls[1] == 'list':
RSF.list_sessions()
exit()
elif args.urls[1] == 'add':
if len(args.urls) == 5:
RSF.create_session(args.urls[2], args.urls[3], args.urls[4])
else:
RSF.new_session()
exit()
elif args.urls[1] == 'remove':
RSF.remove_session()
exit()
elif args.urls[1] == 'default':
RSF.set_default()
exit()
elif args.urls[1] == 'reauth':
RSF.reauth()
exit()
elif args.urls[0] == 'id':
type = None
md = MediaDownloader(TidalApi(RSF.load_session(args.account)), preset, Tagger(preset))
if len(args.urls) == 2:
id = args.urls[1]
if not id.isdigit():
# Check if id is playlist (UUIDv4)
pattern = re.compile('^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$')
if pattern.match(id):
try:
result = md.playlist_from_id(id)
type = 'p'
except TidalError:
print("The playlist id " + str(id) + " could not be found!")
exit()
else:
print('The id ' + str(id) + ' is not valid.')
exit()
else:
print('Example usage: python redsea.py id 92265335')
exit()
if type is None:
type = md.type_from_id(id)
if type:
media_to_download = [{'id': id, 'type': type}]
else:
print("The id " + str(id) + " could not be found!")
exit()
elif args.urls[0] == 'explore':
try:
if args.urls[1] == 'atmos':
page = 'dolby_atmos'
if args.urls[2] == 'tracks':
title = 'Tracks'
elif args.urls[2] == 'albums':
title = 'New Albums'
elif args.urls[1] == '360':
page = '360'
if args.urls[2] == 'tracks':
title = 'New Tracks'
elif args.urls[2] == 'albums':
title = 'Now Available'
except IndexError:
print("Example usage of explore: python redsea.py explore (atmos|360) (albums|tracks)")
exit()
print(f'Selected: {page.replace("_", " ").title()} - {title}')
md = MediaDownloader(TidalApi(RSF.load_session(args.account)), preset, Tagger(preset))
page_content = md.page(page)
# Iterate though all the page and find the module with the title: "Now Available" or "Tracks"
show_more_link = [module['modules'][0]['showMore']['apiPath'] for module in page_content['rows'] if
module['modules'][0]['title'] == title]
singe_page_content = md.page(show_more_link[0][6:])
# Get the number of all items for offset and the dataApiPath
page_list = singe_page_content['rows'][0]['modules'][0]['pagedList']
total_items = page_list['totalNumberOfItems']
more_items_link = page_list['dataApiPath'][6:]
# Now fetch all the found total_items
items = []
for offset in range(0, total_items//50 + 1):
print(f'Fetching {offset * 50}/{total_items}', end='\r')
items += md.page(more_items_link, offset * 50)['items']
print()
total_items = len(items)
# Beauty print all found items
for i in range(total_items):
item = items[i]
if item['audioModes'] == ['DOLBY_ATMOS']:
specialtag = " [Dolby Atmos]"
elif item['audioModes'] == ['SONY_360RA']:
specialtag = " [360 Reality Audio]"
else:
specialtag = ""
if item['explicit']:
explicittag = " [E]"
else:
explicittag = ""
date = " (" + item['streamStartDate'].split('T')[0] + ")"
print(str(i + 1) + ") " + str(item['title']) + " - " + str(
item['artists'][0]['name']) + explicittag + specialtag + date)
print(str(total_items + 1) + ") Download all items listed above")
print(str(total_items + 2) + ") Exit")
while True:
chosen = int(input("Selection: ")) - 1
if chosen == total_items + 1:
exit()
elif chosen > total_items + 1 or chosen < 0:
print("Enter an existing number")
else:
break
print()
# if 'album' in item['url'] is a really ugly way but well should be fine for now
if chosen == total_items:
print('Downloading all albums')
media_to_download = [{'id': str(item['id']), 'type': 'a' if 'album' in item['url'] else 't'} for item in items]
else:
media_to_download = [{'id': str(items[chosen]['id']), 'type': 'a' if 'album' in items[chosen]['url'] else 't'}]
elif args.urls[0] == 'search':
md = MediaDownloader(TidalApi(RSF.load_session(args.account)), preset, Tagger(preset))
while True:
searchresult = md.search_for_id(args.urls[2:])
if args.urls[1] == 'track':
searchtype = 'tracks'
elif args.urls[1] == 'album':
searchtype = 'albums'
elif args.urls[1] == 'video':
searchtype = 'videos'
else:
print("Example usage of search: python redsea.py search [track/album/video] Darkside Alan Walker")
exit()
# elif args.urls[1] == 'playlist':
# searchtype = 'playlists'
numberofsongs = int(searchresult[searchtype]['totalNumberOfItems'])
if numberofsongs > 20:
numberofsongs = 20
for i in range(numberofsongs):
song = searchresult[searchtype]['items'][i]
if searchtype != 'videos':
if song['audioModes'] == ['DOLBY_ATMOS']:
specialtag = " [Dolby Atmos]"
elif song['audioModes'] == ['SONY_360RA']:
specialtag = " [360 Reality Audio]"
elif song['audioQuality'] == 'HI_RES':
specialtag = " [MQA]"
else:
specialtag = ""
else:
specialtag = " [" + song['quality'].replace('MP4_', '') + "]"
if song['explicit']:
explicittag = " [E]"
else:
explicittag = ""
print(str(i + 1) + ") " + str(song['title']) + " - " + str(
song['artists'][0]['name']) + explicittag + specialtag)
query = None
if numberofsongs > 0:
print(str(numberofsongs + 1) + ") Not found? Try a new search")
while True:
chosen = int(input("Song Selection: ")) - 1
if chosen == numberofsongs:
query = input("Enter new search query: [track/album/video] Darkside Alan Walker: ")
break
elif chosen > numberofsongs:
print("Enter an existing number")
else:
break
print()
if query:
args.urls = ("search " + query).split()
continue
else:
print("No results found for '" + ' '.join(args.urls[2:]))
print("1) Not found? Try a new search")
print("2) Quit")
while True:
chosen = int(input("Selection: "))
if chosen == 1:
query = input("Enter new search query: [track/album/video] Darkside Alan Walker: ")
break
else:
exit()
print()
if query:
args.urls = ("search " + query).split()
continue
if searchtype == 'tracks':
media_to_download = [{'id': str(searchresult[searchtype]['items'][chosen]['id']), 'type': 't'}]
elif searchtype == 'albums':
media_to_download = [{'id': str(searchresult[searchtype]['items'][chosen]['id']), 'type': 'a'}]
elif searchtype == 'videos':
media_to_download = [{'id': str(searchresult[searchtype]['items'][chosen]['id']), 'type': 'v'}]
# elif searchtype == 'playlists':
# media_to_download = [{'id': str(searchresult[searchtype]['items'][chosen]['id']), 'type': 'p'}]
break
else:
media_to_download = cli.parse_media_option(args.urls, args.file)
print(LOGO)
# Loop through media and download if possible
cm = 0
for mt in media_to_download:
# Is it an acceptable media type? (skip if not)
if not mt['type'] in MEDIA_TYPES:
print('Unknown media type - ' + mt['type'])
continue
cm += 1
print('<<< Getting {0} info... >>>'.format(MEDIA_TYPES[mt['type']]))
# Create a new TidalApi and pass it to a new MediaDownloader
md = MediaDownloader(TidalApi(RSF.load_session(args.account)), preset.copy(), Tagger(preset))
# Create a new session generator in case we need to switch sessions
session_gen = RSF.get_session()
# Get media info
def get_tracks(media):
media_name = None
tracks = []
media_info = None
track_info = []
while True:
try:
if media['type'] == 'f':
lines = media['content'].split('\n')
for i, l in enumerate(lines):
print('Getting info for track {}/{}'.format(i, len(lines)), end='\r')
tracks.append(md.api.get_track(l))
print()
# Track
elif media['type'] == 't':
tracks.append(md.api.get_track(media['id']))
# Playlist
elif media['type'] == 'p':
# Stupid mess to get the preset path rather than the modified path when > 2 playlist links added
# md = MediaDownloader(TidalApi(RSF.load_session(args.account)), preset, Tagger(preset))
# Get playlist title to create path
playlist = md.api.get_playlist(media['id'])
# Ugly way to get the playlist creator
creator = None
if playlist['creator']['id'] == 0:
creator = 'Tidal'
elif 'name' in playlist['creator']:
creator = md._sanitise_name(playlist["creator"]["name"])
if creator:
md.opts['path'] = os.path.join(md.opts['path'], f'{creator} - {md._sanitise_name(playlist["title"])}')
else:
md.opts['path'] = os.path.join(md.opts['path'], md._sanitise_name(playlist["title"]))
# Make sure only tracks are in playlist items
playlist_items = md.api.get_playlist_items(media['id'])['items']
for item_ in playlist_items:
tracks.append(item_['item'])
# Album
elif media['type'] == 'a':
# Get album information
media_info = md.api.get_album(media['id'])
# Get a list of the tracks from the album
tracks = md.api.get_album_tracks(media['id'])['items']
# Video
elif media['type'] == 'v':
# Get video information
tracks.append(md.api.get_video(media['id']))
# Artist
else:
# Get the name of the artist for display to user
media_name = md.api.get_artist(media['id'])['name']
# Collect all of the tracks from all of the artist's albums
albums = md.api.get_artist_albums(media['id'])['items'] + md.api.get_artist_albums_ep_singles(media['id'])['items']
eps_info = []
singles_info = []
for album in albums:
if 'aggressive_remix_filtering' in preset and preset['aggressive_remix_filtering']:
title = album['title'].lower()
if 'remix' in title or 'commentary' in title or 'karaoke' in title:
print('\tSkipping ' + album['title'])
continue
# remove sony 360 reality audio albums if there's another (duplicate) album that isn't 360 reality audio
if 'skip_360ra' in preset and preset['skip_360ra']:
if 'SONY_360RA' in album['audioModes']:
is_duplicate = False
for a2 in albums:
if album['title'] == a2['title'] and album['numberOfTracks'] == a2['numberOfTracks']:
is_duplicate = True
break
if is_duplicate:
print('\tSkipping duplicate Sony 360 Reality Audio album - ' + album['title'])
continue
# Get album information
media_info = md.api.get_album(album['id'])
# Get a list of the tracks from the album
tracks = md.api.get_album_tracks(album['id'])['items']
if 'type' in media_info and str(media_info['type']).lower() == 'single':
singles_info.append((tracks, media_info))
else:
eps_info.append((tracks, media_info))
if 'skip_singles_when_possible' in preset and preset['skip_singles_when_possible']:
# Filter singles that also appear in albums (EPs)
def track_in_ep(title):
for tracks, _ in eps_info:
for t in tracks:
if t['title'] == title:
return True
return False
for track_info in singles_info[:]:
for t in track_info[0][:]:
if track_in_ep(t['title']):
print('\tSkipping ' + t['title'])
track_info[0].remove(t)
if len(track_info[0]) == 0:
singles_info.remove(track_info)
track_info = eps_info + singles_info
if not track_info:
track_info = [(tracks, media_info)]
return media_name, track_info
# Catch region error
except TidalError as e:
if 'not found. This might be region-locked.' in str(e) and BRUTEFORCE:
# Try again with a different session
try:
session, name = next(session_gen)
md.api = TidalApi(session)
print('Checking info fetch with session "{}" in region {}'.format(name, session.country_code))
continue
# Ran out of sessions
except StopIteration as s:
print(e)
raise s
# Skip or halt
else:
raise(e)
try:
media_name, track_info = get_tracks(media=mt)
except StopIteration:
# Let the user know we cannot download this release and skip it
print('None of the available accounts were able to get info for release {}. Skipping..'.format(mt['id']))
continue
total = sum([len(t[0]) for t in track_info])
# Single
if total == 1:
print('<<< Downloading single track... >>>')
# Playlist or album
else:
if mt['type'] == 'p':
name = md.playlist_from_id(mt['id'])['title']
else:
name = track_info[0][1]['title']
print('<<< Downloading {0} "{1}": {2} track(s) in total >>>'.format(
MEDIA_TYPES[mt['type']] + (' ' + media_name if media_name else ''), name, total))
if args.resumeon and len(media_to_download) == 1 and mt['type'] == 'p':
print('<<< Resuming on track {} >>>'.format(args.resumeon))
args.resumeon -= 1
else:
args.resumeon = 0
cur = args.resumeon
for tracks, media_info in track_info:
for track in tracks[args.resumeon:]:
first = True
# Actually download the track (finally)
while True:
try:
md.download_media(track, media_info, overwrite=args.overwrite,
track_num=cur+1 if mt['type'] == 'p' else None)
break
# Catch quality error
except ValueError as e:
print("\t" + str(e))
traceback.print_exc()
if args.skip is True:
print('Skipping track "{} - {}" due to insufficient quality'.format(
track['artist']['name'], track['title']))
break
else:
print('Halting on track "{} - {}" due to insufficient quality'.format(
track['artist']['name'], track['title']))
break
# Catch file name errors
except OSError as e:
print(e)
print("\tFile name too long or contains apostrophes")
file = open('failed_tracks.txt', 'a')
file.write(str(track['url']) + "\n")
file.close()
break
# Catch session audio stream privilege error
except AssertionError as e:
if 'Unable to download track' in str(e) and BRUTEFORCE:
# Try again with a different session
try:
# Reset generator if this is the first attempt
if first:
session_gen = RSF.get_session()
first = False
session, name = next(session_gen)
md.api = TidalApi(session)
print('Attempting audio stream with session "{}" in region {}'.format(name, session.country_code))
continue
# Ran out of sessions, skip track
except StopIteration:
# Let the user know we cannot download this release and skip it
print('None of the available accounts were able to download track {}. Skipping..'.format(track['id']))
break
elif 'Please use a mobile session' in str(e):
print(e)
print('Choose one of the following mobile sessions: ')
RSF.list_sessions(True)
break
# Skip
else:
print(str(e) + '. Skipping..')
# Progress of current track
cur += 1
print('=== {0}/{1} complete ({2:.0f}% done) ===\n'.format(
cur, total, (cur / total) * 100))
# Progress of queue
print('> Download queue: {0}/{1} items complete ({2:.0f}% done) <\n'.
format(cm, len(media_to_download), (cm / len(media_to_download)) * 100))
print('> All downloads completed. <')
# since oauth sessions can change while downloads are happening if the token gets refreshed
RSF._save()
# Run from CLI - catch Ctrl-C and handle it gracefully
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print('\n^C pressed - abort')
exit()
================================================
FILE: requirements.txt
================================================
mutagen>=1.37
pycryptodomex>=3.6.1
requests>=2.22.0
urllib3>=1.25.3
ffmpeg-python>=0.2.0
prettytable>1.0.0
tqdm>=4.56.0
gitextract_3o21b9fc/ ├── .github/ │ └── ISSUE_TEMPLATE/ │ └── bug_report.md ├── .gitignore ├── captcha/ │ ├── main.js │ ├── package.json │ └── public/ │ ├── html/ │ │ ├── captcha.html │ │ └── index.html │ └── js/ │ └── captcha.js ├── config/ │ ├── __init__.py │ └── settings.example.py ├── deezer/ │ ├── __init__.py │ └── deezer.py ├── readme.md ├── redsea/ │ ├── __init__.py │ ├── cli.py │ ├── decryption.py │ ├── mediadownloader.py │ ├── sessions.py │ ├── tagger.py │ ├── tidal_api.py │ └── videodownloader.py ├── redsea.py └── requirements.txt
SYMBOL INDEX (140 symbols across 10 files)
FILE: captcha/main.js
function createTray (line 13) | function createTray() {
function createWindow (line 52) | function createWindow() {
FILE: deezer/deezer.py
class Deezer (line 12) | class Deezer:
method __init__ (line 13) | def __init__(self, language='en'):
method get_token (line 24) | def get_token(self):
method gw_api_call (line 28) | def gw_api_call(self, method, args=None):
method api_call (line 53) | def api_call(self, method, args=None):
method get_track_gw (line 75) | def get_track_gw(self, sng_id):
method get_tracks_gw (line 85) | def get_tracks_gw(self, ids):
method get_album_gw (line 108) | def get_album_gw(self, alb_id):
method get_album_tracks_gw (line 111) | def get_album_tracks_gw(self, alb_id):
method get_artist_gw (line 120) | def get_artist_gw(self, art_id):
method search_gw (line 123) | def search_gw(self, term, type, start, nb=20):
method get_lyrics_gw (line 129) | def get_lyrics_gw(self, sng_id):
method get_track (line 132) | def get_track(self, sng_id):
method get_track_by_ISRC (line 135) | def get_track_by_ISRC(self, isrc):
method get_album (line 138) | def get_album(self, album_id):
method get_album_by_UPC (line 141) | def get_album_by_UPC(self, upc):
method get_album_tracks (line 144) | def get_album_tracks(self, album_id):
method get_artist (line 147) | def get_artist(self, artist_id):
method get_artist_albums (line 150) | def get_artist_albums(self, artist_id):
method search (line 153) | def search(self, term, search_type, limit=30, index=0):
method get_track_from_metadata (line 156) | def get_track_from_metadata(self, artist, track, album):
function clean_search_query (line 180) | def clean_search_query(term):
class APIError (line 190) | class APIError(Exception):
FILE: redsea.py
function main (line 35) | def main():
FILE: redsea/cli.py
function get_args (line 7) | def get_args():
function parse_media_option (line 80) | def parse_media_option(mo, is_file):
FILE: redsea/decryption.py
function decrypt_security_token (line 7) | def decrypt_security_token(security_token):
function decrypt_file (line 38) | def decrypt_file(file, key, nonce):
FILE: redsea/mediadownloader.py
function _mkdir_p (line 22) | def _mkdir_p(path):
class MediaDownloader (line 33) | class MediaDownloader(object):
method __init__ (line 35) | def __init__(self, api, options, tagger=None):
method _dl_url (line 54) | def _dl_url(self, url, where):
method _dl_picture (line 70) | def _dl_picture(self, album_id, where):
method _sanitise_name (line 81) | def _sanitise_name(name):
method _normalise_info (line 95) | def _normalise_info(self, track_info, album_info, use_album_artists=Fa...
method _normalise_video (line 111) | def _normalise_video(self, video_info):
method get_stream_url (line 118) | def get_stream_url(self, track_id, quality):
method print_track_info (line 147) | def print_track_info(self, track_info, album_info):
method search_for_id (line 157) | def search_for_id(self, term):
method page (line 160) | def page(self, page_url, offset=None):
method type_from_id (line 163) | def type_from_id(self, id):
method credits_from_album (line 166) | def credits_from_album(self, album_id):
method credits_from_video (line 169) | def credits_from_video(self, video_id):
method lyrics_from_track (line 172) | def lyrics_from_track(self, track_id):
method playlist_from_id (line 175) | def playlist_from_id(self, id):
method download_media (line 178) | def download_media(self, track_info, album_info=None, overwrite=False,...
FILE: redsea/sessions.py
class RedseaSessionFile (line 6) | class RedseaSessionFile(TidalSessionFile):
method create_session (line 14) | def create_session(self, name, username, password):
method new_session (line 17) | def new_session(self):
method load_session (line 82) | def load_session(self, session_name=None):
method get_session (line 110) | def get_session(self):
method remove_session (line 118) | def remove_session(self):
method list_sessions (line 136) | def list_sessions(self, mobile_only=False, formats=True):
method set_default (line 170) | def set_default(self):
method reauth (line 186) | def reauth(self):
FILE: redsea/tagger.py
function normalize_key (line 13) | def normalize_key(s):
class FeaturingFormat (line 18) | class FeaturingFormat():
method _format (line 23) | def _format(self, featuredArtists, andStr):
method get_artist_format (line 39) | def get_artist_format(self, mainArtists):
method get_feature_format (line 42) | def get_feature_format(self, featuredArtists):
class Tagger (line 46) | class Tagger(object):
method __init__ (line 48) | def __init__(self, format_options):
method tags (line 51) | def tags(self, track_info, track_type, album_info=None, tagger=None):
method _meta_tag (line 144) | def _meta_tag(self, tagger, track_info, album_info, track_type):
method tag_flac (line 147) | def tag_flac(self, file_path, track_info, album_info, lyrics, credits_...
method tag_m4a (line 177) | def tag_m4a(self, file_path, track_info, album_info, lyrics, credits_d...
FILE: redsea/tidal_api.py
class TidalRequestError (line 37) | class TidalRequestError(Exception):
method __init__ (line 38) | def __init__(self, payload):
class TidalAuthError (line 44) | class TidalAuthError(Exception):
method __init__ (line 45) | def __init__(self, message):
class TidalError (line 49) | class TidalError(Exception):
method __init__ (line 50) | def __init__(self, message):
class TidalApi (line 55) | class TidalApi:
method __init__ (line 60) | def __init__(self, session):
method _get (line 70) | def _get(self, url, params=None, refresh=False):
method get_stream_url (line 124) | def get_stream_url(self, track_id, quality):
method get_search_data (line 133) | def get_search_data(self, searchterm):
method get_page (line 141) | def get_page(self, page_url, offset=None):
method get_credits (line 149) | def get_credits(self, album_id):
method get_video_credits (line 157) | def get_video_credits(self, video_id):
method get_lyrics (line 162) | def get_lyrics(self, track_id):
method get_playlist_items (line 168) | def get_playlist_items(self, playlist_id):
method get_playlist (line 191) | def get_playlist(self, playlist_id):
method get_album_tracks (line 194) | def get_album_tracks(self, album_id):
method get_track (line 197) | def get_track(self, track_id):
method get_album (line 200) | def get_album(self, album_id):
method get_video (line 203) | def get_video(self, video_id):
method get_favorite_tracks (line 206) | def get_favorite_tracks(self, user_id):
method get_track_contributors (line 209) | def get_track_contributors(self, track_id):
method get_video_stream_url (line 212) | def get_video_stream_url(self, video_id):
method get_artist (line 215) | def get_artist(self, artist_id):
method get_artist_albums (line 218) | def get_artist_albums(self, artist_id):
method get_artist_albums_ep_singles (line 221) | def get_artist_albums_ep_singles(self, artist_id):
method get_type_from_id (line 224) | def get_type_from_id(self, id_):
method get_album_artwork_url (line 250) | def get_album_artwork_url(cls, album_id, size=1280):
class SessionFormats (line 255) | class SessionFormats:
method __init__ (line 256) | def __init__(self, session):
method check_formats (line 279) | def check_formats(self, session):
method print_fomats (line 299) | def print_fomats(self):
class ReCaptcha (line 311) | class ReCaptcha(object):
method __init__ (line 312) | def __init__(self):
method check_npm (line 321) | def check_npm():
method get_response (line 330) | def get_response(self):
class TidalSession (line 354) | class TidalSession:
method __init__ (line 359) | def __init__(self, username, password):
method auth (line 380) | def auth(self, password):
method session_type (line 408) | def session_type():
method check_subscription (line 414) | def check_subscription(self):
method valid (line 424) | def valid(self):
method auth_headers (line 435) | def auth_headers(self):
class TidalMobileSession (line 446) | class TidalMobileSession(TidalSession):
method __init__ (line 451) | def __init__(self, username, password):
method auth (line 476) | def auth(self, password):
method refresh (line 600) | def refresh(self):
method session_type (line 624) | def session_type(self):
method auth_headers (line 627) | def auth_headers(self):
class TidalTvSession (line 638) | class TidalTvSession(TidalSession):
method __init__ (line 643) | def __init__(self):
method auth (line 664) | def auth(self, password=''):
method refresh (line 731) | def refresh(self):
method session_type (line 753) | def session_type(self):
method auth_headers (line 756) | def auth_headers(self):
class TidalSessionFile (line 767) | class TidalSessionFile(object):
method __init__ (line 772) | def __init__(self, session_file):
method _save (line 796) | def _save(self):
method new_session (line 808) | def new_session(self, session_name, username, password, device):
method remove (line 831) | def remove(self, session_name):
method load (line 842) | def load(self, session_name=None):
method set_default (line 871) | def set_default(self, session_name):
FILE: redsea/videodownloader.py
function normalize_key (line 16) | def normalize_key(s):
function parse_master_playlist (line 21) | def parse_master_playlist(masterurl: str):
function parse_playlist (line 33) | def parse_playlist(url: str):
function download_file (line 44) | def download_file(urllist: list, part: int, filename: str):
function print_video_info (line 64) | def print_video_info(track_info: dict):
function download_video_artwork (line 77) | def download_video_artwork(image_id: str, where: str):
function tags (line 100) | def tags(video_info: dict, tagger=None, ftype=None):
function tag_video (line 125) | def tag_video(file_path: str, track_info: dict, credits_dict: dict, albu...
function download_stream (line 148) | def download_stream(folder_path: str, file_name: str, url: str, resoluti...
Condensed preview — 22 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (148K chars).
[
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 671,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: Dniel97\n\n---\n\n**Describe"
},
{
"path": ".gitignore",
"chars": 133,
"preview": "__pycache__/\n.venv\nvenv/\n.vscode\nsessions.pk\ndownloads/\n.DS_Store\n.idea/\nsettings.py\nnode_modules/\nfailed_tracks.txt\n.py"
},
{
"path": "captcha/main.js",
"chars": 3870,
"preview": "const {app, BrowserWindow, Menu, Tray} = require('electron');\n\nconst captcha = require('./public/js/captcha');\nconst rem"
},
{
"path": "captcha/package.json",
"chars": 256,
"preview": "{\n \"name\": \"tidal_recaptcha\",\n \"version\": \"0.1.0\",\n \"description\": \"Get the reCaptcha response from Tidal\",\n \"main\":"
},
{
"path": "captcha/public/html/captcha.html",
"chars": 1581,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <title>captcha</title>\n</head>\n<body>\n<script src"
},
{
"path": "captcha/public/html/index.html",
"chars": 432,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <title>Solve reCAPTCHA</title>\n <style>\n "
},
{
"path": "captcha/public/js/captcha.js",
"chars": 1363,
"preview": "const {app, protocol} = require('electron');\nconst url = require('url');\nconst fs = require('fs');\nconst path = require("
},
{
"path": "config/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "config/settings.example.py",
"chars": 7344,
"preview": "'''\nStore your redsea download presets here\n\nYou may modify/add/remove as you wish. The only preset which must exist is "
},
{
"path": "deezer/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "deezer/deezer.py",
"chars": 7024,
"preview": "#!/usr/bin/env python3\nimport time\n\nimport requests\nimport re\nimport json\n\nUSER_AGENT_HEADER = \"Mozilla/5.0 (X11; Linux "
},
{
"path": "readme.md",
"chars": 10536,
"preview": "RedSea\n======\nMusic downloader and tagger for Tidal. For educational use only, and will break in the future.\n\nCurrent st"
},
{
"path": "redsea/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "redsea/cli.py",
"chars": 3800,
"preview": "import argparse\nimport re\nfrom urllib.parse import urlparse\nfrom os import path\n\n\ndef get_args():\n #\n # argparse s"
},
{
"path": "redsea/decryption.py",
"chars": 1487,
"preview": "import base64\n\nfrom Cryptodome.Cipher import AES\nfrom Cryptodome.Util import Counter\n\n\ndef decrypt_security_token(securi"
},
{
"path": "redsea/mediadownloader.py",
"chars": 23776,
"preview": "import errno\nimport json\nimport os\nimport os.path as path\nimport re\nimport base64\nimport ffmpeg\nimport shutil\n\nimport re"
},
{
"path": "redsea/sessions.py",
"chars": 8386,
"preview": "import getpass\n\nfrom redsea.tidal_api import TidalSessionFile, TidalRequestError, TidalMobileSession, TidalTvSession, Se"
},
{
"path": "redsea/tagger.py",
"chars": 8236,
"preview": "import unicodedata\n\nfrom mutagen.easymp4 import EasyMP4\nfrom mutagen.flac import FLAC, Picture\nfrom mutagen.mp4 import M"
},
{
"path": "redsea/tidal_api.py",
"chars": 31608,
"preview": "import pickle\nimport uuid\nimport os\nimport re\nimport json\nimport urllib.parse as urlparse\nimport webbrowser\nfrom urllib."
},
{
"path": "redsea/videodownloader.py",
"chars": 7114,
"preview": "import os\nimport re\nimport shutil\nimport unicodedata\n\nimport ffmpeg\nimport requests\nfrom mutagen.easymp4 import EasyMP4\n"
},
{
"path": "redsea.py",
"chars": 24191,
"preview": "#!/usr/bin/env python\n\nimport traceback\nimport sys\nimport os\nimport re\nimport urllib3\n\nimport redsea.cli as cli\n\nfrom re"
},
{
"path": "requirements.txt",
"chars": 120,
"preview": "mutagen>=1.37\npycryptodomex>=3.6.1\nrequests>=2.22.0\nurllib3>=1.25.3\nffmpeg-python>=0.2.0\nprettytable>1.0.0\ntqdm>=4.56.0\n"
}
]
About this extraction
This page contains the full source code of the Dniel97/RedSea GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 22 files (138.6 KB), approximately 32.3k tokens, and a symbol index with 140 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.