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