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 ================================================ captcha ================================================ FILE: captcha/public/html/index.html ================================================ Solve reCAPTCHA ================================================ 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