├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── close_inactive_issues.yml ├── .gitignore ├── .gitmodules ├── README.md ├── __init__.py ├── interface.py └── tidal_api.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: Dniel97 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Use command '...' 16 | 2. Choose '....' (only needed for search) 17 | 3. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem or to show the error message. 24 | 25 | **Desktop (please complete the following information):** 26 | - OS: [e.g. Windows, Unix, Android] 27 | - Python version [e.g. 3.6.9, 3.9.3] 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.github/workflows/close_inactive_issues.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "0 22 * * *" 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v4 14 | with: 15 | days-before-issue-stale: 7 16 | days-before-issue-close: 7 17 | stale-issue-label: "no-issue-activity" 18 | stale-issue-message: "This issue has been automatically marked as stale because it has been open for 7 days with no activity." 19 | close-issue-message: "This issue was automatically closed because it has been inactive for 7 days since being marked as stale." 20 | days-before-pr-stale: -1 21 | days-before-pr-close: -1 22 | exempt-issue-labels: "feature-request" 23 | repo-token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | venv 3 | .vscode 4 | .DS_Store 5 | .idea/ 6 | .python-version 7 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "mqa_identifier_python"] 2 | path = mqa_identifier_python 3 | url = https://github.com/Dniel97/MQA-identifier-python.git 4 | branch = master 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | OrpheusDL - TIDAL 4 | ================= 5 | 6 | A TIDAL module for the OrpheusDL modular archival music program 7 | 8 | [Report Bug](https://github.com/Dniel97/orpheusdl-tidal/issues) 9 | · 10 | [Request Feature](https://github.com/Dniel97/orpheusdl-tidal/issues) 11 | 12 | 13 | ## Table of content 14 | 15 | - [About OrpheusDL - TIDAL](#about-orpheusdl---tidal) 16 | - [Getting Started](#getting-started) 17 | - [Prerequisites](#prerequisites) 18 | - [Installation](#installation) 19 | - [Updating](#updating) 20 | - [Usage](#usage) 21 | - [Configuration](#configuration) 22 | - [Global](#global) 23 | - [TIDAL](#tidal) 24 | - [Contact](#contact) 25 | - [Acknowledgements](#acknowledgements) 26 | 27 | 28 | 29 | 30 | ## About OrpheusDL - TIDAL 31 | 32 | OrpheusDL - TIDAL is a module written in Python which allows archiving from **[tidal.com](https://listen.tidal.com)** for the modular music archival program. 33 | 34 | 35 | 36 | ## Getting Started 37 | 38 | Follow these steps to get a local copy of Orpheus up and running: 39 | 40 | ### Prerequisites 41 | 42 | * Already have [OrpheusDL](https://github.com/yarrm80s/orpheusdl) installed 43 | 44 | ### Installation 45 | 46 | 1. Go to your already cloned `orpheusdl/` directory and run the following command: 47 | ```sh 48 | git clone --recurse-submodules https://github.com/Dniel97/orpheusdl-tidal.git modules/tidal 49 | ``` 50 | 2. Execute: 51 | ```sh 52 | python orpheus.py 53 | ``` 54 | 3. Now the `config/settings.json` file should be updated with the [TIDAL settings](#tidal) 55 | 56 | ### Updating 57 | 58 | 1. Go to your already cloned `orpheusdl/` directory and run the following command: 59 | ```sh 60 | git -C modules/tidal pull 61 | ``` 62 | 2. Execute to update your already existing TIDAL settings (if needed): 63 | ```sh 64 | python orpheus.py 65 | ``` 66 | 3. Now the `config/settings.json` file should be updated with the new updated [TIDAL settings](#tidal) 67 | 68 | 69 | 70 | ## Usage 71 | 72 | Just call `orpheus.py` with any link you want to archive: 73 | 74 | ```sh 75 | python orpheus.py https://tidal.com/browse/album/92265334 76 | ``` 77 | 78 | 79 | ## Configuration 80 | 81 | You can customize every module from Orpheus individually and also set general/global settings which are active in every 82 | loaded module. You'll find the configuration file here: `config/settings.json` 83 | 84 | ### Global 85 | 86 | ```json5 87 | "global": { 88 | "general": { 89 | // ... 90 | "download_quality": "hifi" 91 | }, 92 | "formatting": { 93 | "album_format": "{artist}/{name}{quality}{explicit}" 94 | // ... 95 | }, 96 | "codecs": { 97 | "proprietary_codecs": false, 98 | "spatial_codecs": true 99 | }, 100 | "covers": { 101 | "main_resolution": 1400 102 | // ... 103 | } 104 | // ... 105 | } 106 | ``` 107 | 108 | #### `download_quality` 109 | 110 | Choose one of the following settings: 111 | 112 | | Quality | Info | 113 | |------------|--------------------------------------------------------------------------------------| 114 | | `hifi` | FLAC up to 192/24 **or** MQA (FLAC) up to 48/24 when `proprietary_codecs` is enabled | 115 | | `lossless` | FLAC with 44.1/16 (is MQA if the album is available in MQA) | 116 | | `high` | same as `medium` | 117 | | `medium` | AAC 320 kbit/s | 118 | | `low` | same as `minimum` | 119 | | `minimum` | AAC 96 kbit/s | 120 | 121 | **Note: HiRes will ALWAYS be preferred instead of MQA!** 122 | 123 | #### `album_format` 124 | * `{quality}` will add 125 | ``` 126 | [Dolby Atmos] 127 | [360] 128 | [M] 129 | ``` 130 | depending on the album quality (with a space in at the first character) 131 | * `{explicit}` will add 132 | ``` 133 | [E] 134 | ``` 135 | to the album path (with a space at the first character) 136 | 137 | 138 | | Option | Info | 139 | |--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 140 | | proprietary_codecs | Enables/Disables MQA (Tidal Masters) downloading when no HiRes track is available | 141 | | spatial_codecs | Enables/Disables downloading of Dolby Atmos (EAC-3, AC-4) and Sony 360RA (MHA1) | 142 | | main_resolution | Tidal only supports 80x80, 160x160, 320x320, 480x480, 640x640, 1080x1080 and 1280x1280px (1280px won't work for playlists).
If you choose 1400 or anything above 1280, it will get the highest quality even if the highest is 4000x4000px. That's because Tidal doesn't provide the "origin artwork" size, so the module will just get the largest. | 143 | 144 | ### TIDAL 145 | ```json 146 | { 147 | "tv_atmos_token": "4N3n6Q1x95LL5K7p", 148 | "tv_atmos_secret": "oKOXfJW371cX6xaZ0PyhgGNBdNLlBZd4AKKYougMjik=", 149 | "mobile_atmos_hires_token": "km8T1xS355y7dd3H", 150 | "mobile_hires_token": "6BDSRdpK9hqEBTgU", 151 | "enable_mobile": true, 152 | "prefer_ac4": false, 153 | "fix_mqa": true 154 | } 155 | ``` 156 | 157 | | Option | Info | 158 | |---------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 159 | | tv_token | Enter a valid TV client token | 160 | | tv_secret | Enter a valid TV client secret for the `tv_token` | 161 | | mobile_* | Enter a valid MOBILE client token for the desired session | 162 | | enable_mobile | Enables a MOBILE session to archive Sony 360RA and Dolby AC-4 if available | 163 | | prefer_ac4 | If enabled and a mobile session is available (`enable_mobile` is set to `true`) this will ensure to get Dolby AC-4 on Dolby Atmos tracks | 164 | | fix_mqa | If enabled it will download the MQA file before the actual track and analyze the FLAC file to extract the bitDepth and originalSampleRate. The tags `MQAENCODER`, `ENCODER` and `ORIGINALSAMPLERATE` are than added to the FLAC file in order to get properly detected by MQA enabled software such as Roon, UAPP or Audirvana. | 165 | 166 | 167 | **Credits: [MQA_identifier](https://github.com/purpl3F0x/MQA_identifier) by 168 | [@purpl3F0x](https://github.com/purpl3F0x) and [mqaid](https://github.com/redsudo/mqaid) by 169 | [@redsudo](https://github.com/redsudo).** 170 | 171 | **NOTE: `fix_mqa` may be slower as a download without `fix_mqa` and could be incorrect.** 172 | 173 | 174 | ## Contact 175 | 176 | Yarrm80s (pronounced 'Yeargh mateys!') - [@yarrm80s](https://github.com/yarrm80s) 177 | 178 | Dniel97 - [@Dniel97](https://github.com/Dniel97) 179 | 180 | Project Link: [OrpheusDL TIDAL Public GitHub Repository](https://github.com/Dniel97/orpheusdl-tidal) 181 | 182 | 183 | 184 | ## Acknowledgements 185 | * [RedSudos's RedSea fork](https://github.com/redsudo/RedSea) 186 | * [My RedSea fork](https://github.com/Dniel97/RedSea) 187 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dniel97/orpheusdl-tidal/14305d3a68268ff78489054b0ccb64e0378f477b/__init__.py -------------------------------------------------------------------------------- /interface.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import logging 4 | import re 5 | import ffmpeg 6 | 7 | from datetime import datetime 8 | from getpass import getpass 9 | from dataclasses import dataclass 10 | from shutil import copyfileobj 11 | from xml.etree import ElementTree 12 | from tqdm import tqdm 13 | 14 | from utils.models import * 15 | from utils.utils import sanitise_name, silentremove, download_to_temp, create_temp_filename, create_requests_session 16 | from .mqa_identifier_python.mqa_identifier_python.mqa_identifier import MqaIdentifier 17 | from .tidal_api import TidalTvSession, TidalApi, TidalMobileSession, SessionType, TidalError, TidalRequestError 18 | 19 | module_information = ModuleInformation( 20 | service_name='TIDAL', 21 | module_supported_modes=ModuleModes.download | ModuleModes.credits | ModuleModes.covers | ModuleModes.lyrics, 22 | login_behaviour=ManualEnum.manual, 23 | global_settings={ 24 | 'tv_atmos_token': '4N3n6Q1x95LL5K7p', 25 | 'tv_atmos_secret': 'oKOXfJW371cX6xaZ0PyhgGNBdNLlBZd4AKKYougMjik=', 26 | 'mobile_atmos_hires_token': 'km8T1xS355y7dd3H', 27 | 'mobile_hires_token': '6BDSRdpK9hqEBTgU', 28 | 'enable_mobile': True, 29 | 'prefer_ac4': False, 30 | 'fix_mqa': True 31 | }, 32 | # currently too broken to keep it, cover needs to be jpg else crash, problems on termux due to pillow 33 | # flags=ModuleFlags.needs_cover_resize, 34 | session_storage_variables=['sessions'], 35 | netlocation_constant='tidal', 36 | test_url='https://tidal.com/browse/track/92265335' 37 | ) 38 | 39 | 40 | @dataclass 41 | class AudioTrack: 42 | codec: CodecEnum 43 | sample_rate: int 44 | bitrate: int 45 | urls: list 46 | 47 | 48 | class ModuleInterface: 49 | # noinspection PyTypeChecker 50 | def __init__(self, module_controller: ModuleController): 51 | self.cover_size = module_controller.orpheus_options.default_cover_options.resolution 52 | self.oprinter = module_controller.printer_controller 53 | self.print = module_controller.printer_controller.oprint 54 | self.disable_subscription_check = module_controller.orpheus_options.disable_subscription_check 55 | self.settings = module_controller.module_settings 56 | 57 | # LOW = 96kbit/s AAC, HIGH = 320kbit/s AAC, LOSSLESS = 44.1/16 FLAC, HI_RES <= 48/24 FLAC with MQA 58 | self.quality_parse = { 59 | QualityEnum.MINIMUM: 'LOW', 60 | QualityEnum.LOW: 'LOW', 61 | QualityEnum.MEDIUM: 'HIGH', 62 | QualityEnum.HIGH: 'HIGH', 63 | QualityEnum.LOSSLESS: 'LOSSLESS', 64 | QualityEnum.HIFI: 'HI_RES' 65 | } 66 | 67 | # save all the TidalSession objects 68 | sessions = {} 69 | self.available_sessions = [SessionType.TV.name, SessionType.MOBILE_DEFAULT.name, SessionType.MOBILE_ATMOS.name] 70 | 71 | # load all saved sessions (TV, Mobile Atmos, Mobile Default) 72 | saved_sessions = module_controller.temporary_settings_controller.read('sessions') 73 | if not saved_sessions: 74 | saved_sessions = {} 75 | 76 | if not self.settings['enable_mobile']: 77 | self.available_sessions = [SessionType.TV.name] 78 | 79 | while True: 80 | login_session = None 81 | 82 | def auth_and_save_session(session, session_type): 83 | session = self.auth_session(session, session_type, login_session) 84 | 85 | # get the dict representation from the TidalSession object and save it into saved_session/loginstorage 86 | saved_sessions[session_type] = session.get_storage() 87 | module_controller.temporary_settings_controller.set('sessions', saved_sessions) 88 | return session 89 | 90 | # ask for login if there are no saved sessions 91 | if not saved_sessions: 92 | login_session_type = None 93 | if len(self.available_sessions) == 1: 94 | login_session_type = self.available_sessions[0] 95 | else: 96 | self.print(f'{module_information.service_name}: Choose a login method:') 97 | self.print(f'{module_information.service_name}: 1. TV (browser)') 98 | self.print( 99 | f"{module_information.service_name}: 2. Mobile (username and password, choose TV if this doesn't work)") 100 | 101 | while not login_session_type: 102 | input_str = input(' Login method: ') 103 | try: 104 | login_session_type = { 105 | '1': SessionType.TV.name, 106 | 'tv': SessionType.TV.name, 107 | '2': SessionType.MOBILE_DEFAULT.name, 108 | 'mobile': SessionType.MOBILE_DEFAULT.name, 109 | }[input_str.lower()] 110 | except KeyError: 111 | self.print(f'{module_information.service_name}: Invalid choice, try again') 112 | 113 | login_session = auth_and_save_session(self.init_session(login_session_type), login_session_type) 114 | 115 | for session_type in self.available_sessions: 116 | sessions[session_type] = self.init_session(session_type) 117 | 118 | if session_type in saved_sessions: 119 | logging.debug(f'{module_information.service_name}: {session_type} session found, loading') 120 | 121 | # load the dictionary from the temporary_settings_controller inside the TidalSession class 122 | sessions[session_type].set_storage(saved_sessions[session_type]) 123 | else: 124 | logging.debug( 125 | f'{module_information.service_name}: No {session_type} session found, creating new one') 126 | sessions[session_type] = auth_and_save_session(sessions[session_type], session_type) 127 | 128 | # always try to refresh session 129 | if not sessions[session_type].valid(): 130 | sessions[session_type].refresh() 131 | # Save the refreshed session in the temporary settings 132 | saved_sessions[session_type] = sessions[session_type].get_storage() 133 | module_controller.temporary_settings_controller.set('sessions', saved_sessions) 134 | 135 | # check for a valid subscription 136 | subscription = self.check_subscription(sessions[session_type].get_subscription()) 137 | if not subscription: 138 | confirm = input(' Do you want to relogin? [Y/n]: ') 139 | 140 | if confirm.upper() == 'N': 141 | self.print('Exiting...') 142 | exit() 143 | 144 | # reset saved sessions and loop back to login 145 | saved_sessions = {} 146 | break 147 | 148 | if not login_session: 149 | login_session = sessions[session_type] 150 | 151 | if saved_sessions: 152 | break 153 | 154 | # only needed for region locked albums where the track is available but force_album_format is used 155 | self.album_cache = {} 156 | 157 | # load the Tidal session with all saved sessions (TV, Mobile Atmos, Mobile Default) 158 | self.session: TidalApi = TidalApi(sessions) 159 | 160 | def init_session(self, session_type): 161 | session = None 162 | # initialize session with the needed API keys 163 | if session_type == SessionType.TV.name: 164 | session = TidalTvSession(self.settings['tv_atmos_token'], self.settings['tv_atmos_secret']) 165 | elif session_type == SessionType.MOBILE_ATMOS.name: 166 | session = TidalMobileSession(self.settings['mobile_atmos_hires_token']) 167 | else: 168 | session = TidalMobileSession(self.settings['mobile_hires_token']) 169 | return session 170 | 171 | def auth_session(self, session, session_type, login_session): 172 | if login_session: 173 | # refresh tokens can be used with any client id 174 | # this can be used to switch to any client type from an existing session 175 | session.refresh_token = login_session.refresh_token 176 | session.user_id = login_session.user_id 177 | session.country_code = login_session.country_code 178 | session.refresh() 179 | elif session_type == SessionType.TV.name: 180 | self.print(f'{module_information.service_name}: Creating a TV session') 181 | session.auth() 182 | else: 183 | self.print(f'{module_information.service_name}: Creating a Mobile session') 184 | self.print(f'{module_information.service_name}: Enter your TIDAL username and password:') 185 | self.print(f'{module_information.service_name}: (password will not be echoed)') 186 | username = input(' Username: ') 187 | password = getpass(' Password: ') 188 | session.auth(username, password) 189 | self.print(f'Successfully logged in, using {session_type} token!') 190 | 191 | return session 192 | 193 | def check_subscription(self, subscription: str) -> bool: 194 | # returns true if "disable_subscription_checks" is enabled or subscription is HIFI (Plus) 195 | if not self.disable_subscription_check and subscription not in {'HIFI', 'PREMIUM', 'PREMIUM_PLUS'}: 196 | self.print(f'{module_information.service_name}: Account does not have a HiFi (Plus) subscription, ' 197 | f'detected subscription: {subscription}') 198 | return False 199 | return True 200 | 201 | @staticmethod 202 | def _generate_artwork_url(cover_id: str, size: int, max_size: int = 1280): 203 | # not the best idea, but it rounds the self.cover_size to the nearest number in supported_sizes, 1281 is needed 204 | # for the "uncompressed" cover 205 | supported_sizes = [80, 160, 320, 480, 640, 1080, 1280, 1281] 206 | best_size = min(supported_sizes, key=lambda x: abs(x - size)) 207 | # only supports 80x80, 160x160, 320x320, 480x480, 640x640, 1080x1080 and 1280x1280 only for non playlists 208 | # return "uncompressed" cover if self.cover_resolution > max_size 209 | image_name = '{0}x{0}.jpg'.format(best_size) if best_size <= max_size else 'origin.jpg' 210 | return f'https://resources.tidal.com/images/{cover_id.replace("-", "/")}/{image_name}' 211 | 212 | @staticmethod 213 | def _generate_animated_artwork_url(cover_id: str, size=1280): 214 | return 'https://resources.tidal.com/videos/{0}/{1}x{1}.mp4'.format(cover_id.replace('-', '/'), size) 215 | 216 | def search(self, query_type: DownloadTypeEnum, query: str, track_info: TrackInfo = None, limit: int = 20): 217 | if track_info and track_info.tags.isrc: 218 | results = self.session.get_tracks_by_isrc(track_info.tags.isrc) 219 | else: 220 | results = self.session.get_search_data(query, limit=limit)[query_type.name + 's'] 221 | 222 | items = [] 223 | for i in results.get('items'): 224 | duration, name = None, None 225 | if query_type is DownloadTypeEnum.artist: 226 | name = i.get('name') 227 | artists = None 228 | year = None 229 | elif query_type is DownloadTypeEnum.playlist: 230 | if 'name' in i.get('creator'): 231 | artists = [i.get('creator').get('name')] 232 | elif i.get('type') == 'EDITORIAL': 233 | artists = [module_information.service_name] 234 | else: 235 | artists = ['Unknown'] 236 | 237 | duration = i.get('duration') 238 | # TODO: Use playlist creation date or lastUpdated? 239 | year = i.get('created')[:4] 240 | elif query_type is DownloadTypeEnum.track: 241 | artists = [j.get('name') for j in i.get('artists')] 242 | # Getting the year from the album? 243 | year = i.get('album').get('releaseDate')[:4] if i.get('album').get('releaseDate') else None 244 | duration = i.get('duration') 245 | elif query_type is DownloadTypeEnum.album: 246 | artists = [j.get('name') for j in i.get('artists')] 247 | duration = i.get('duration') 248 | year = i.get('releaseDate')[:4] 249 | else: 250 | raise Exception('Query type is invalid') 251 | 252 | if query_type is not DownloadTypeEnum.artist: 253 | name = i.get('title') 254 | name += f' ({i.get("version")})' if i.get("version") else '' 255 | 256 | additional = None 257 | if query_type not in {DownloadTypeEnum.artist, DownloadTypeEnum.playlist}: 258 | if 'DOLBY_ATMOS' in i.get('audioModes'): 259 | additional = "Dolby Atmos" 260 | elif 'SONY_360RA' in i.get('audioModes'): 261 | additional = "360 Reality Audio" 262 | elif i.get('audioQuality') == 'HI_RES': 263 | additional = "MQA" 264 | else: 265 | additional = 'HiFi' 266 | 267 | item = SearchResult( 268 | name=name, 269 | artists=artists, 270 | year=year, 271 | result_id=str(i.get('id')) if query_type is not DownloadTypeEnum.playlist else i.get('uuid'), 272 | explicit=i.get('explicit'), 273 | duration=duration, 274 | additional=[additional] if additional else None 275 | ) 276 | 277 | items.append(item) 278 | 279 | return items 280 | 281 | def get_playlist_info(self, playlist_id: str) -> PlaylistInfo: 282 | playlist_data = self.session.get_playlist(playlist_id) 283 | playlist_tracks = self.session.get_playlist_items(playlist_id) 284 | 285 | tracks = [track.get('item').get('id') for track in playlist_tracks.get('items') if track.get('type') == 'track'] 286 | 287 | if 'name' in playlist_data.get('creator'): 288 | creator_name = playlist_data.get('creator').get('name') 289 | elif playlist_data.get('type') == 'EDITORIAL': 290 | creator_name = module_information.service_name 291 | else: 292 | creator_name = 'Unknown' 293 | 294 | if playlist_data.get('squareImage'): 295 | cover_url = self._generate_artwork_url(playlist_data['squareImage'], size=self.cover_size, max_size=1080) 296 | cover_type = ImageFileTypeEnum.jpg 297 | else: 298 | # fallback to defaultPlaylistImage 299 | cover_url = 'https://tidal.com/browse/assets/images/defaultImages/defaultPlaylistImage.png' 300 | cover_type = ImageFileTypeEnum.png 301 | 302 | return PlaylistInfo( 303 | name=playlist_data.get('title'), 304 | creator=creator_name, 305 | tracks=tracks, 306 | release_year=playlist_data.get('created')[:4], 307 | duration=playlist_data.get('duration'), 308 | creator_id=playlist_data['creator'].get('id'), 309 | cover_url=cover_url, 310 | cover_type=cover_type, 311 | track_extra_kwargs={ 312 | 'data': {track.get('item').get('id'): track.get('item') for track in playlist_tracks.get('items')} 313 | } 314 | ) 315 | 316 | def get_artist_info(self, artist_id: str, get_credited_albums: bool) -> ArtistInfo: 317 | artist_data = self.session.get_artist(artist_id) 318 | 319 | artist_albums = self.session.get_artist_albums(artist_id).get('items') 320 | artist_singles = self.session.get_artist_albums_ep_singles(artist_id).get('items') 321 | 322 | # Only works with a mobile session, annoying, never do this again 323 | credit_albums = [] 324 | if get_credited_albums and SessionType.MOBILE_DEFAULT.name in self.available_sessions: 325 | self.session.default = SessionType.MOBILE_DEFAULT 326 | credited_albums_page = self.session.get_page('contributor', params={'artistId': artist_id}) 327 | 328 | # This is so retarded 329 | page_list = credited_albums_page['rows'][-1]['modules'][0].get('pagedList') 330 | if page_list: 331 | total_items = page_list['totalNumberOfItems'] 332 | more_items_link = page_list['dataApiPath'][6:] 333 | 334 | # Now fetch all the found total_items 335 | items = [] 336 | for offset in range(0, total_items // 50 + 1): 337 | print(f'Fetching {offset * 50}/{total_items}', end='\r') 338 | items += self.session.get_page(more_items_link, params={'limit': 50, 'offset': offset * 50})[ 339 | 'items'] 340 | 341 | credit_albums = [item.get('item').get('album') for item in items] 342 | self.session.default = SessionType.TV 343 | 344 | # use set to filter out duplicate album ids 345 | albums = {str(album.get('id')) for album in artist_albums + artist_singles + credit_albums} 346 | 347 | return ArtistInfo( 348 | name=artist_data.get('name'), 349 | albums=list(albums), 350 | album_extra_kwargs={'data': {str(album.get('id')): album for album in artist_albums + artist_singles}} 351 | ) 352 | 353 | def get_album_info(self, album_id: str, data=None) -> AlbumInfo: 354 | # check if album is already in album cache, add it 355 | if data is None: 356 | data = {} 357 | 358 | if data.get(album_id): 359 | album_data = data[album_id] 360 | elif self.album_cache.get(album_id): 361 | album_data = self.album_cache[album_id] 362 | else: 363 | album_data = self.session.get_album(album_id) 364 | 365 | # get all album tracks with corresponding credits with a limit of 100 366 | limit = 100 367 | cache = {'data': {}} 368 | try: 369 | tracks_data = self.session.get_album_contributors(album_id, limit=limit) 370 | total_tracks = tracks_data.get('totalNumberOfItems') 371 | 372 | # round total_tracks to the next 100 and loop over the offset, that's hideous 373 | for offset in range(limit, ((total_tracks // limit) + 1) * limit, limit): 374 | # fetch the new album tracks with the given offset 375 | track_items = self.session.get_album_contributors(album_id, offset=offset, limit=limit) 376 | # append those tracks to the album_data 377 | tracks_data['items'] += track_items.get('items') 378 | 379 | # add the track contributors to a new list called 'credits' 380 | cache = {'data': {}} 381 | for track in tracks_data.get('items'): 382 | track.get('item').update({'credits': track.get('credits')}) 383 | cache.get('data')[str(track.get('item').get('id'))] = track.get('item') 384 | 385 | # filter out video clips 386 | tracks = [str(track['item']['id']) for track in tracks_data.get('items') if track.get('type') == 'track'] 387 | except TidalError: 388 | tracks = [] 389 | 390 | quality = None 391 | if 'audioModes' in album_data: 392 | if album_data['audioModes'] == ['DOLBY_ATMOS']: 393 | quality = 'Dolby Atmos' 394 | elif album_data['audioModes'] == ['SONY_360RA']: 395 | quality = '360' 396 | elif album_data['audioQuality'] == 'HI_RES': 397 | quality = 'M' 398 | 399 | release_year = None 400 | if album_data.get('releaseDate'): 401 | release_year = album_data.get('releaseDate')[:4] 402 | elif album_data.get('streamStartDate'): 403 | release_year = album_data.get('streamStartDate')[:4] 404 | elif album_data.get('copyright'): 405 | # assume that every copyright includes the year 406 | release_year = [int(s) for s in album_data.get('copyright').split() if s.isdigit()] 407 | if len(release_year) > 0: 408 | release_year = release_year[0] 409 | 410 | if album_data.get('cover'): 411 | cover_url = self._generate_artwork_url(album_data.get('cover'), size=self.cover_size) 412 | cover_type = ImageFileTypeEnum.jpg 413 | else: 414 | # fallback to defaultAlbumImage 415 | cover_url = 'https://tidal.com/browse/assets/images/defaultImages/defaultAlbumImage.png' 416 | cover_type = ImageFileTypeEnum.png 417 | 418 | return AlbumInfo( 419 | name=album_data.get('title'), 420 | release_year=release_year, 421 | explicit=album_data.get('explicit'), 422 | quality=quality, 423 | upc=album_data.get('upc'), 424 | duration=album_data.get('duration'), 425 | cover_url=cover_url, 426 | cover_type=cover_type, 427 | animated_cover_url=self._generate_animated_artwork_url(album_data.get('videoCover')) if album_data.get( 428 | 'videoCover') else None, 429 | artist=album_data.get('artist').get('name'), 430 | artist_id=album_data.get('artist').get('id'), 431 | tracks=tracks, 432 | track_extra_kwargs=cache 433 | ) 434 | 435 | def get_track_info(self, track_id: str, quality_tier: QualityEnum, codec_options: CodecOptions, 436 | data=None) -> TrackInfo: 437 | if data is None: 438 | data = {} 439 | 440 | track_data = data[track_id] if track_id in data else self.session.get_track(track_id) 441 | 442 | album_id = str(track_data.get('album').get('id')) 443 | # check if album is already in album cache, get it 444 | try: 445 | album_data = data[album_id] if album_id in data else self.session.get_album(album_id) 446 | except TidalError as e: 447 | # if an error occurs, catch it and set the album_data to an empty dict to catch it 448 | self.print(f'{module_information.service_name}: {e} Trying workaround ...', drop_level=1) 449 | album_data = track_data.get('album') 450 | album_data.update({ 451 | 'artist': track_data.get('artist'), 452 | 'numberOfVolumes': 1, 453 | 'audioQuality': 'LOSSLESS', 454 | 'audioModes': ['STEREO'] 455 | }) 456 | 457 | # add the region locked album to the cache in order to properly use it later (force_album_format) 458 | self.album_cache = {album_id: album_data} 459 | 460 | media_tags = track_data['mediaMetadata']['tags'] 461 | format = None 462 | if codec_options.spatial_codecs: 463 | if 'SONY_360RA' in media_tags: 464 | format = '360ra' 465 | elif 'DOLBY_ATMOS' in media_tags: 466 | if self.settings['prefer_ac4']: 467 | format = 'ac4' 468 | else: 469 | format = 'ac3' 470 | if 'HIRES_LOSSLESS' in media_tags and not format and quality_tier is QualityEnum.HIFI: 471 | format = 'flac_hires' 472 | 473 | session = { 474 | 'flac_hires': SessionType.MOBILE_DEFAULT, 475 | '360ra': SessionType.MOBILE_DEFAULT, 476 | 'ac4': SessionType.MOBILE_ATMOS, 477 | 'ac3': SessionType.TV, 478 | # TV is used whenever possible to avoid MPEG-DASH, which slows downloading 479 | None: SessionType.TV, 480 | }[format] 481 | 482 | if not format and 'DOLBY_ATMOS' in media_tags: 483 | # if atmos is available, we don't use the TV session here because that will get atmos everytime 484 | # there are no tracks with both 360RA and atmos afaik, 485 | # so this shouldn't be an issue for now 486 | session = SessionType.MOBILE_DEFAULT 487 | 488 | if session.name in self.available_sessions: 489 | self.session.default = session 490 | else: 491 | format = None 492 | 493 | # define all default values in case the stream_data is None (region locked) 494 | audio_track, mqa_file, track_codec, bitrate, download_args, error = None, None, CodecEnum.FLAC, None, None, None 495 | 496 | try: 497 | stream_data = self.session.get_stream_url(track_id, self.quality_parse[ 498 | quality_tier] if format != 'flac_hires' else 'HI_RES_LOSSLESS') 499 | except TidalRequestError as e: 500 | error = e 501 | # definitely region locked 502 | if 'Asset is not ready for playback' in str(e): 503 | error = f'Track [{track_id}] is not available in your region' 504 | stream_data = None 505 | 506 | if stream_data is not None: 507 | if stream_data['manifestMimeType'] == 'application/dash+xml': 508 | manifest = base64.b64decode(stream_data['manifest']) 509 | audio_track = self.parse_mpd(manifest)[0] # Only one AudioTrack? 510 | track_codec = audio_track.codec 511 | else: 512 | manifest = json.loads(base64.b64decode(stream_data['manifest'])) 513 | track_codec = CodecEnum['AAC' if 'mp4a' in manifest['codecs'] else manifest['codecs'].upper()] 514 | 515 | if not codec_data[track_codec].spatial: 516 | if not codec_options.proprietary_codecs and codec_data[track_codec].proprietary: 517 | self.print(f'Proprietary codecs are disabled, if you want to download {track_codec.name}, ' 518 | f'set "proprietary_codecs": true', drop_level=1) 519 | stream_data = self.session.get_stream_url(track_id, 'LOSSLESS') 520 | 521 | if stream_data['manifestMimeType'] == 'application/dash+xml': 522 | manifest = base64.b64decode(stream_data['manifest']) 523 | audio_track = self.parse_mpd(manifest)[0] # Only one AudioTrack? 524 | track_codec = audio_track.codec 525 | else: 526 | manifest = json.loads(base64.b64decode(stream_data['manifest'])) 527 | track_codec = CodecEnum['AAC' if 'mp4a' in manifest['codecs'] else manifest['codecs'].upper()] 528 | 529 | if audio_track: 530 | download_args = {'audio_track': audio_track} 531 | else: 532 | # check if MQA 533 | if track_codec is CodecEnum.MQA and self.settings['fix_mqa']: 534 | # download the first chunk of the flac file to analyze it 535 | temp_file_path = self.download_temp_header(manifest['urls'][0]) 536 | 537 | # detect MQA file 538 | mqa_file = MqaIdentifier(temp_file_path) 539 | 540 | # add the file to download_args 541 | download_args = {'file_url': manifest['urls'][0]} 542 | 543 | # https://en.wikipedia.org/wiki/Audio_bit_depth#cite_ref-1 544 | bit_depth = (24 if stream_data and stream_data['audioQuality'] == 'HI_RES_LOSSLESS' else 16) \ 545 | if track_codec in {CodecEnum.FLAC, CodecEnum.ALAC} else None 546 | sample_rate = 48 if track_codec in {CodecEnum.EAC3, CodecEnum.MHA1, CodecEnum.AC4} else 44.1 547 | 548 | if stream_data: 549 | # fallback bitrate 550 | bitrate = { 551 | 'LOW': 96, 552 | 'HIGH': 320, 553 | 'LOSSLESS': 1411, 554 | 'HI_RES': None, 555 | 'HI_RES_LOSSLESS': None 556 | }[stream_data['audioQuality']] 557 | 558 | # manually set bitrate for immersive formats 559 | if stream_data['audioMode'] == 'DOLBY_ATMOS': 560 | # check if the Dolby Atmos format is E-AC-3 JOC or AC-4 561 | if track_codec == CodecEnum.EAC3: 562 | bitrate = 768 563 | elif track_codec == CodecEnum.AC4: 564 | bitrate = 256 565 | elif stream_data['audioMode'] == 'SONY_360RA': 566 | bitrate = 667 567 | 568 | # more precise bitrate tidal uses MPEG-DASH 569 | if audio_track: 570 | bitrate = audio_track.bitrate // 1000 571 | if stream_data['audioQuality'] == 'HI_RES_LOSSLESS': 572 | sample_rate = audio_track.sample_rate / 1000 573 | 574 | # now set everything for MQA 575 | if mqa_file is not None and mqa_file.is_mqa: 576 | bit_depth = mqa_file.bit_depth 577 | sample_rate = mqa_file.get_original_sample_rate() 578 | 579 | track_name = track_data.get('title') 580 | track_name += f' ({track_data.get("version")})' if track_data.get("version") else '' 581 | 582 | if track_data['album'].get('cover'): 583 | cover_url = self._generate_artwork_url(track_data['album'].get('cover'), size=self.cover_size) 584 | else: 585 | # fallback to defaultTrackImage, no cover_type flag? Might crash in the future 586 | cover_url = 'https://tidal.com/browse/assets/images/defaultImages/defaultTrackImage.png' 587 | 588 | track_info = TrackInfo( 589 | name=track_name, 590 | album=album_data.get('title'), 591 | album_id=album_id, 592 | artists=[a.get('name') for a in track_data.get('artists')], 593 | artist_id=track_data['artist'].get('id'), 594 | release_year=track_data.get('streamStartDate')[:4] if track_data.get( 595 | 'streamStartDate') else track_data.get('dateAdded')[:4] if track_data.get('dateAdded') else None, 596 | bit_depth=bit_depth, 597 | sample_rate=sample_rate, 598 | bitrate=bitrate, 599 | duration=track_data.get('duration'), 600 | cover_url=cover_url, 601 | explicit=track_data.get('explicit'), 602 | tags=self.convert_tags(track_data, album_data, mqa_file), 603 | codec=track_codec, 604 | download_extra_kwargs=download_args, 605 | lyrics_extra_kwargs={'track_data': track_data}, 606 | # check if 'credits' are present (only from get_album_data) 607 | credits_extra_kwargs={'data': {track_id: track_data['credits']} if 'credits' in track_data else {}} 608 | ) 609 | 610 | if error is not None: 611 | track_info.error = f'Error: {error}' 612 | 613 | return track_info 614 | 615 | @staticmethod 616 | def download_temp_header(file_url: str, chunk_size: int = 32768) -> str: 617 | # create flac temp_location 618 | temp_location = create_temp_filename() + '.flac' 619 | 620 | # create session and download the file to the temp_location 621 | r_session = create_requests_session() 622 | 623 | r = r_session.get(file_url, stream=True, verify=False) 624 | with open(temp_location, 'wb') as f: 625 | # only download the first chunk_size bytes 626 | for chunk in r.iter_content(chunk_size=chunk_size): 627 | if chunk: # filter out keep-alive new chunks 628 | f.write(chunk) 629 | break 630 | 631 | return temp_location 632 | 633 | @staticmethod 634 | def parse_mpd(xml: bytes) -> list: 635 | xml = xml.decode('UTF-8') 636 | # Removes default namespace definition, don't do that! 637 | xml = re.sub(r'xmlns="[^"]+"', '', xml, count=1) 638 | root = ElementTree.fromstring(xml) 639 | 640 | # List of AudioTracks 641 | tracks = [] 642 | 643 | for period in root.findall('Period'): 644 | for adaptation_set in period.findall('AdaptationSet'): 645 | for rep in adaptation_set.findall('Representation'): 646 | # Check if representation is audio 647 | content_type = adaptation_set.get('contentType') 648 | if content_type != 'audio': 649 | raise ValueError('Only supports audio MPDs!') 650 | 651 | # Codec checks 652 | codec = rep.get('codecs').upper() 653 | if codec.startswith('MP4A'): 654 | codec = 'AAC' 655 | 656 | # Segment template 657 | seg_template = rep.find('SegmentTemplate') 658 | # Add init file to track_urls 659 | track_urls = [seg_template.get('initialization')] 660 | start_number = int(seg_template.get('startNumber') or 1) 661 | 662 | # https://dashif-documents.azurewebsites.net/Guidelines-TimingModel/master/Guidelines-TimingModel.html#addressing-explicit 663 | # Also see example 9 664 | seg_timeline = seg_template.find('SegmentTimeline') 665 | if seg_timeline is not None: 666 | seg_time_list = [] 667 | cur_time = 0 668 | 669 | for s in seg_timeline.findall('S'): 670 | # Media segments start time 671 | if s.get('t'): 672 | cur_time = int(s.get('t')) 673 | 674 | # Segment reference 675 | for i in range((int(s.get('r') or 0) + 1)): 676 | seg_time_list.append(cur_time) 677 | # Add duration to current time 678 | cur_time += int(s.get('d')) 679 | 680 | # Create list with $Number$ indices 681 | seg_num_list = list(range(start_number, len(seg_time_list) + start_number)) 682 | # Replace $Number$ with all the seg_num_list indices 683 | track_urls += [seg_template.get('media').replace('$Number$', str(n)) for n in seg_num_list] 684 | 685 | tracks.append(AudioTrack( 686 | codec=CodecEnum[codec], 687 | sample_rate=int(rep.get('audioSamplingRate') or 0), 688 | bitrate=int(rep.get('bandwidth') or 0), 689 | urls=track_urls 690 | )) 691 | 692 | return tracks 693 | 694 | def get_track_download(self, file_url: str = None, audio_track: AudioTrack = None) \ 695 | -> TrackDownloadInfo: 696 | # only file_url or audio_track at a time 697 | 698 | # MHA1, EC-3 or MQA 699 | if file_url: 700 | return TrackDownloadInfo(download_type=DownloadEnum.URL, file_url=file_url) 701 | 702 | # MPEG-DASH 703 | # use the total_file size for a better progress bar? Is it even possible to calculate the total size from MPD? 704 | try: 705 | columns = os.get_terminal_size().columns 706 | if os.name == 'nt': 707 | bar = tqdm(audio_track.urls, ncols=(columns - self.oprinter.indent_number), 708 | bar_format=' ' * self.oprinter.indent_number + '{l_bar}{bar}{r_bar}') 709 | else: 710 | raise OSError 711 | except OSError: 712 | bar = tqdm(audio_track.urls, bar_format=' ' * self.oprinter.indent_number + '{l_bar}{bar}{r_bar}') 713 | 714 | # download all segments and save the locations inside temp_locations 715 | temp_locations = [] 716 | for download_url in bar: 717 | temp_locations.append(download_to_temp(download_url, extension='mp4')) 718 | 719 | # needed for bar indent 720 | bar.close() 721 | 722 | # concatenated/Merged .mp4 file 723 | merged_temp_location = create_temp_filename() + '.mp4' 724 | # actual converted .flac file 725 | output_location = create_temp_filename() + '.' + codec_data[audio_track.codec].container.name 726 | 727 | # download is finished, merge chunks into 1 file 728 | with open(merged_temp_location, 'wb') as dest_file: 729 | for temp_location in temp_locations: 730 | with open(temp_location, 'rb') as segment_file: 731 | copyfileobj(segment_file, dest_file) 732 | 733 | # convert .mp4 back to .flac 734 | try: 735 | ffmpeg.input(merged_temp_location, hide_banner=None, y=None).output(output_location, acodec='copy', 736 | loglevel='error').run() 737 | # Remove all files 738 | silentremove(merged_temp_location) 739 | for temp_location in temp_locations: 740 | silentremove(temp_location) 741 | except: 742 | self.print('FFmpeg is not installed or working! Using fallback, may have errors') 743 | 744 | # return the MP4 temp file, but tell orpheus to change the container to .m4a (AAC) 745 | return TrackDownloadInfo( 746 | download_type=DownloadEnum.TEMP_FILE_PATH, 747 | temp_file_path=merged_temp_location, 748 | different_codec=CodecEnum.AAC 749 | ) 750 | 751 | # return the converted flac file now 752 | return TrackDownloadInfo( 753 | download_type=DownloadEnum.TEMP_FILE_PATH, 754 | temp_file_path=output_location, 755 | ) 756 | 757 | def get_track_cover(self, track_id: str, cover_options: CoverOptions, data=None) -> CoverInfo: 758 | if data is None: 759 | data = {} 760 | 761 | track_data = data[track_id] if track_id in data else self.session.get_track(track_id) 762 | cover_id = track_data['album'].get('cover') 763 | 764 | if cover_id: 765 | return CoverInfo(url=self._generate_artwork_url(cover_id, size=cover_options.resolution), 766 | file_type=ImageFileTypeEnum.jpg) 767 | 768 | return CoverInfo(url='https://tidal.com/browse/assets/images/defaultImages/defaultTrackImage.png', 769 | file_type=ImageFileTypeEnum.png) 770 | 771 | def get_track_lyrics(self, track_id: str, track_data: dict = None) -> LyricsInfo: 772 | if not track_data: 773 | track_data = {} 774 | 775 | # get lyrics data for current track id 776 | lyrics_data = self.session.get_lyrics(track_id) 777 | 778 | if 'error' in lyrics_data and track_data: 779 | # search for title and artist to find a matching track (non Atmos) 780 | results = self.search( 781 | DownloadTypeEnum.track, 782 | f'{track_data.get("title")} {" ".join(a.get("name") for a in track_data.get("artists"))}', 783 | limit=10) 784 | 785 | # check every result to find a matching result 786 | best_tracks = [r.result_id for r in results 787 | if r.name == track_data.get('title') and 788 | r.artists[0] == track_data.get('artist').get('name') and 789 | 'Dolby Atmos' not in r.additional] 790 | 791 | # retrieve the lyrics for the first one, otherwise return empty dict 792 | lyrics_data = self.session.get_lyrics(best_tracks[0]) if len(best_tracks) > 0 else {} 793 | 794 | embedded = lyrics_data.get('lyrics') 795 | synced = lyrics_data.get('subtitles') 796 | 797 | return LyricsInfo( 798 | embedded=embedded, 799 | # regex to remove the space after the timestamp "[mm:ss.xx] " to "[mm:ss.xx]" 800 | synced=re.sub(r'(\[\d{2}:\d{2}.\d{2,3}])(?: )', r'\1', synced) if synced else None 801 | ) 802 | 803 | def get_track_credits(self, track_id: str, data=None) -> Optional[list]: 804 | if data is None: 805 | data = {} 806 | 807 | credits_dict = {} 808 | 809 | # fetch credits from cache if not fetch those credits 810 | if track_id in data: 811 | track_contributors = data[track_id] 812 | 813 | for contributor in track_contributors: 814 | credits_dict[contributor.get('type')] = [c.get('name') for c in contributor.get('contributors')] 815 | else: 816 | track_contributors = self.session.get_track_contributors(track_id).get('items') 817 | 818 | if len(track_contributors) > 0: 819 | for contributor in track_contributors: 820 | # check if the dict contains no list, create one 821 | if contributor.get('role') not in credits_dict: 822 | credits_dict[contributor.get('role')] = [] 823 | 824 | credits_dict[contributor.get('role')].append(contributor.get('name')) 825 | 826 | if len(credits_dict) > 0: 827 | # convert the dictionary back to a list of CreditsInfo 828 | return [CreditsInfo(sanitise_name(k), v) for k, v in credits_dict.items()] 829 | return None 830 | 831 | @staticmethod 832 | def convert_tags(track_data: dict, album_data: dict, mqa_file: MqaIdentifier = None) -> Tags: 833 | track_name = track_data.get('title') 834 | track_name += f' ({track_data.get("version")})' if track_data.get('version') else '' 835 | 836 | extra_tags = {} 837 | if mqa_file is not None: 838 | encoder_time = datetime.now().strftime("%b %d %Y %H:%M:%S") 839 | extra_tags = { 840 | 'ENCODER': f'MQAEncode v1.1, 2.4.0+0 (278f5dd), E24F1DE5-32F1-4930-8197-24954EB9D6F4, {encoder_time}', 841 | 'MQAENCODER': f'MQAEncode v1.1, 2.4.0+0 (278f5dd), E24F1DE5-32F1-4930-8197-24954EB9D6F4, {encoder_time}', 842 | 'ORIGINALSAMPLERATE': str(mqa_file.original_sample_rate) 843 | } 844 | 845 | return Tags( 846 | album_artist=album_data.get('artist').get('name') if 'artist' in album_data else None, 847 | track_number=track_data.get('trackNumber'), 848 | total_tracks=album_data.get('numberOfTracks'), 849 | disc_number=track_data.get('volumeNumber'), 850 | total_discs=album_data.get('numberOfVolumes'), 851 | isrc=track_data.get('isrc'), 852 | upc=album_data.get('upc'), 853 | release_date=album_data.get('releaseDate'), 854 | copyright=track_data.get('copyright'), 855 | replay_gain=track_data.get('replayGain'), 856 | replay_peak=track_data.get('peak'), 857 | extra_tags=extra_tags 858 | ) 859 | -------------------------------------------------------------------------------- /tidal_api.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import json 4 | import secrets 5 | import sys 6 | import time 7 | import webbrowser 8 | from abc import ABC, abstractmethod 9 | from dataclasses import dataclass 10 | from enum import Enum, auto 11 | 12 | import requests 13 | import urllib3 14 | 15 | import urllib.parse as urlparse 16 | from urllib.parse import parse_qs, quote 17 | from datetime import datetime, timedelta 18 | 19 | from utils.utils import create_requests_session 20 | 21 | technical_names = { 22 | 'eac3': 'E-AC-3 JOC (Dolby Digital Plus with Dolby Atmos, with 5.1 bed)', 23 | 'mha1': 'MPEG-H 3D Audio (Sony 360 Reality Audio)', 24 | 'ac4': 'AC-4 IMS (Dolby AC-4 with Dolby Atmos immersive stereo)', 25 | 'mqa': 'MQA (Master Quality Authenticated) in FLAC container', 26 | 'flac': 'FLAC (Free Lossless Audio Codec)', 27 | 'alac': 'ALAC (Apple Lossless Audio Codec)', 28 | 'mp4a.40.2': 'AAC 320 (Advanced Audio Coding) with a bitrate of 320kb/s', 29 | 'mp4a.40.5': 'AAC 96 (Advanced Audio Coding) with a bitrate of 96kb/s' 30 | } 31 | 32 | 33 | class TidalRequestError(Exception): 34 | def __init__(self, payload): 35 | sf = '{subStatus}: {userMessage} (HTTP {status})'.format(**payload) 36 | self.payload = payload 37 | super(TidalRequestError, self).__init__(sf) 38 | 39 | 40 | class TidalAuthError(Exception): 41 | def __init__(self, message): 42 | super(TidalAuthError, self).__init__(message) 43 | 44 | 45 | class TidalError(Exception): 46 | def __init__(self, message): 47 | self.message = message 48 | super(TidalError, self).__init__(message) 49 | 50 | 51 | class SessionType(Enum): 52 | TV = auto() 53 | MOBILE_ATMOS = auto() 54 | MOBILE_DEFAULT = auto() 55 | 56 | 57 | class TidalApi(object): 58 | TIDAL_API_BASE = 'https://api.tidal.com/v1/' 59 | TIDAL_VIDEO_BASE = 'https://api.tidalhifi.com/v1/' 60 | TIDAL_CLIENT_VERSION = '2.26.1' 61 | 62 | def __init__(self, sessions: dict): 63 | self.sessions = sessions 64 | self.default: SessionType = SessionType.TV # Change to TV or MOBILE depending on AC-4/360RA 65 | 66 | self.s = create_requests_session() 67 | 68 | def _get(self, url, params=None, refresh=False): 69 | if params is None: 70 | params = {} 71 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 72 | params['countryCode'] = self.sessions[self.default.name].country_code 73 | if 'limit' not in params: 74 | params['limit'] = '9999' 75 | 76 | resp = self.s.get( 77 | self.TIDAL_API_BASE + url, 78 | headers=self.sessions[self.default.name].auth_headers(), 79 | params=params) 80 | 81 | # if the request 401s or 403s, try refreshing the TV/Mobile session in case that helps 82 | if not refresh and (resp.status_code == 401 or resp.status_code == 403): 83 | self.sessions[self.default.name].refresh() 84 | return self._get(url, params, True) 85 | 86 | resp_json = None 87 | try: 88 | resp_json = resp.json() 89 | except: # some tracks seem to return a JSON with leading whitespace 90 | try: 91 | resp_json = json.loads(resp.text.strip()) 92 | except: # if this doesn't work, the HTTP status probably isn't 200. Are we rate limited? 93 | pass 94 | 95 | if not resp_json: 96 | raise TidalError('Response was not valid JSON. HTTP status {}. {}'.format(resp.status_code, resp.text)) 97 | 98 | if 'status' in resp_json and resp_json['status'] == 404 and \ 99 | 'subStatus' in resp_json and resp_json['subStatus'] == 2001: 100 | raise TidalError('Error: {}. This might be region-locked.'.format(resp_json['userMessage'])) 101 | 102 | # Really hacky way, pls don't copy this ever 103 | if 'status' in resp_json and resp_json['status'] == 404 and \ 104 | 'error' in resp_json and resp_json['error'] == 'Not Found': 105 | return resp_json 106 | 107 | if 'status' in resp_json and not resp_json['status'] == 200: 108 | raise TidalRequestError(resp_json) 109 | 110 | return resp_json 111 | 112 | def get_stream_url(self, track_id, quality): 113 | return self._get('tracks/' + str(track_id) + '/playbackinfopostpaywall/v4', { 114 | 'playbackmode': 'STREAM', 115 | 'assetpresentation': 'FULL', 116 | 'audioquality': quality, 117 | 'prefetch': 'false' 118 | }) 119 | 120 | def get_search_data(self, search_term, limit=20): 121 | return self._get('search', params={ 122 | 'query': str(search_term), 123 | 'offset': 0, 124 | 'limit': limit, 125 | 'includeContributors': 'true' 126 | }) 127 | 128 | def get_page(self, pageurl, params=None): 129 | local_params = { 130 | 'deviceType': 'TV', 131 | 'locale': 'en_US', 132 | 'mediaFormats': 'SONY_360' 133 | } 134 | 135 | if params: 136 | local_params.update(params) 137 | 138 | return self._get('pages/' + pageurl, params=local_params) 139 | 140 | def get_playlist_items(self, playlist_id): 141 | result = self._get('playlists/' + playlist_id + '/items', { 142 | 'offset': 0, 143 | 'limit': 100 144 | }) 145 | 146 | if result['totalNumberOfItems'] <= 100: 147 | return result 148 | 149 | offset = len(result['items']) 150 | while True: 151 | buf = self._get('playlists/' + playlist_id + '/items', { 152 | 'offset': offset, 153 | 'limit': 100 154 | }) 155 | offset += len(buf['items']) 156 | result['items'] += buf['items'] 157 | 158 | if offset >= result['totalNumberOfItems']: 159 | break 160 | 161 | return result 162 | 163 | def get_playlist(self, playlist_id): 164 | return self._get('playlists/' + str(playlist_id)) 165 | 166 | def get_album_tracks(self, album_id): 167 | return self._get('albums/' + str(album_id) + '/tracks') 168 | 169 | def get_track(self, track_id): 170 | return self._get('tracks/' + str(track_id)) 171 | 172 | def get_album(self, album_id): 173 | return self._get('albums/' + str(album_id)) 174 | 175 | def get_video(self, video_id): 176 | return self._get('videos/' + str(video_id)) 177 | 178 | def get_tracks_by_isrc(self, isrc): 179 | return self._get('tracks', params={ 180 | 'isrc': isrc 181 | }) 182 | 183 | def get_favorite_tracks(self, user_id): 184 | return self._get('users/' + str(user_id) + '/favorites/tracks') 185 | 186 | def get_track_contributors(self, track_id): 187 | return self._get('tracks/' + str(track_id) + '/contributors') 188 | 189 | def get_album_contributors(self, album_id, offset: int = 0, limit: int = 100): 190 | return self._get('albums/' + album_id + '/items/credits', params={ 191 | 'replace': True, 192 | 'offset': offset, 193 | 'limit': limit, 194 | 'includeContributors': True 195 | }) 196 | 197 | def get_lyrics(self, track_id): 198 | return self._get('tracks/' + str(track_id) + '/lyrics', params={ 199 | 'deviceType': 'TV', 200 | 'locale': 'en_US' 201 | }) 202 | 203 | def get_video_contributors(self, video_id): 204 | return self._get('videos/' + video_id + '/contributors', params={ 205 | 'limit': 50 206 | }) 207 | 208 | def get_video_stream_url(self, video_id): 209 | return self._get('videos/' + str(video_id) + '/streamurl') 210 | 211 | def get_artist(self, artist_id): 212 | return self._get('artists/' + str(artist_id)) 213 | 214 | def get_artist_albums(self, artist_id): 215 | return self._get('artists/' + str(artist_id) + '/albums') 216 | 217 | def get_artist_albums_ep_singles(self, artist_id): 218 | return self._get('artists/' + str(artist_id) + '/albums', params={'filter': 'EPSANDSINGLES'}) 219 | 220 | def get_type_from_id(self, id_): 221 | result = None 222 | try: 223 | result = self.get_album(id_) 224 | return 'a' 225 | except TidalError: 226 | pass 227 | try: 228 | result = self.get_artist(id_) 229 | return 'r' 230 | except TidalError: 231 | pass 232 | try: 233 | result = self.get_track(id_) 234 | return 't' 235 | except TidalError: 236 | pass 237 | try: 238 | result = self.get_video(id_) 239 | return 'v' 240 | except TidalError: 241 | pass 242 | 243 | return result 244 | 245 | 246 | @dataclass 247 | class SessionStorage: 248 | access_token: str 249 | refresh_token: str 250 | expires: datetime 251 | user_id: str 252 | country_code: str 253 | 254 | 255 | class TidalSession(ABC): 256 | """ 257 | Tidal abstract session object with all (abstract) functions needed: auth_headers(), refresh(), session_type() 258 | """ 259 | def __init__(self): 260 | self.access_token = None 261 | self.refresh_token = None 262 | self.expires = None 263 | self.user_id = None 264 | self.country_code = None 265 | 266 | def set_storage(self, storage: dict): 267 | self.access_token = storage.get('access_token') 268 | self.refresh_token = storage.get('refresh_token') 269 | self.expires = storage.get('expires') 270 | self.user_id = storage.get('user_id') 271 | self.country_code = storage.get('country_code') 272 | 273 | def get_storage(self) -> dict: 274 | return { 275 | 'access_token': self.access_token, 276 | 'refresh_token': self.refresh_token, 277 | 'expires': self.expires, 278 | 'user_id': self.user_id, 279 | 'country_code': self.country_code 280 | } 281 | 282 | def get_subscription(self) -> str: 283 | if self.access_token: 284 | r = requests.get(f'https://api.tidal.com/v1/users/{self.user_id}/subscription', 285 | params={'countryCode': self.country_code}, 286 | headers=self.auth_headers()) 287 | if r.status_code != 200: 288 | raise TidalAuthError(r.json()['userMessage']) 289 | 290 | return r.json()['subscription']['type'] 291 | 292 | @abstractmethod 293 | def auth_headers(self) -> dict: 294 | pass 295 | 296 | def valid(self): 297 | """ 298 | Checks if session is still valid and returns True/False 299 | """ 300 | if not isinstance(self, TidalSession): 301 | if self.access_token is None or datetime.now() > self.expires: 302 | return False 303 | 304 | r = requests.get('https://api.tidal.com/v1/sessions', headers=self.auth_headers()) 305 | return r.status_code == 200 306 | 307 | @abstractmethod 308 | def refresh(self): 309 | pass 310 | 311 | @staticmethod 312 | def session_type() -> str: 313 | pass 314 | 315 | 316 | class TidalMobileSession(TidalSession): 317 | """ 318 | Tidal session object based on the mobile Android oauth flow 319 | """ 320 | 321 | def __init__(self, client_token: str): 322 | super().__init__() 323 | self.TIDAL_LOGIN_BASE = 'https://login.tidal.com/api/' 324 | self.TIDAL_AUTH_BASE = 'https://auth.tidal.com/v1/' 325 | 326 | self.client_id = client_token 327 | self.redirect_uri = 'https://tidal.com/android/login/auth' 328 | self.code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=') 329 | self.code_challenge = base64.urlsafe_b64encode(hashlib.sha256(self.code_verifier).digest()).rstrip(b'=') 330 | self.client_unique_key = secrets.token_hex(8) 331 | self.user_agent = 'Mozilla/5.0 (Linux; Android 13; Pixel 8 Build/TQ2A.230505.002; wv) AppleWebKit/537.36 ' \ 332 | '(KHTML, like Gecko) Version/4.0 Chrome/119.0.6045.163 Mobile Safari/537.36' 333 | 334 | def auth(self, username: str, password: str): 335 | s = requests.Session() 336 | 337 | params = { 338 | 'response_type': 'code', 339 | 'redirect_uri': self.redirect_uri, 340 | 'lang': 'en_US', 341 | 'appMode': 'android', 342 | 'client_id': self.client_id, 343 | 'client_unique_key': self.client_unique_key, 344 | 'code_challenge': self.code_challenge, 345 | 'code_challenge_method': 'S256', 346 | 'restrict_signup': 'true' 347 | } 348 | 349 | # retrieve csrf token for subsequent request 350 | r = s.get('https://login.tidal.com/authorize', params=params, headers={ 351 | 'user-agent': self.user_agent, 352 | 'accept-language': 'en-US', 353 | 'x-requested-with': 'com.aspiro.tidal' 354 | }) 355 | 356 | if r.status_code == 400: 357 | raise TidalAuthError("Authorization failed! Is the clientid/token up to date?") 358 | elif r.status_code == 403: 359 | raise TidalAuthError("TIDAL BOT protection, try again later!") 360 | 361 | # try Tidal DataDome cookie request 362 | r = s.post('https://dd.tidal.com/js/', data={ 363 | 'jsData': f'{{"opts":"endpoint,ajaxListenerPath","ua":"{self.user_agent}"}}', 364 | 'ddk': '1F633CDD8EF22541BD6D9B1B8EF13A', # API Key (required) 365 | 'Referer': quote(r.url), # Referer authorize link (required) 366 | 'responsePage': 'origin', # useless? 367 | 'ddv': '4.17.0' # useless? 368 | }, headers={ 369 | 'user-agent': self.user_agent, 370 | 'content-type': 'application/x-www-form-urlencoded' 371 | }) 372 | 373 | if r.status_code != 200 or not r.json().get('cookie'): 374 | raise TidalAuthError("TIDAL BOT protection, could not get DataDome cookie!") 375 | 376 | # get the cookie from the json request and save it in the session 377 | dd_cookie = r.json().get('cookie').split(';')[0] 378 | s.cookies[dd_cookie.split('=')[0]] = dd_cookie.split('=')[1] 379 | 380 | # enter email, verify email is valid 381 | r = s.post(self.TIDAL_LOGIN_BASE + 'email', params=params, json={ 382 | 'email': username 383 | }, headers={ 384 | 'user-agent': self.user_agent, 385 | 'x-csrf-token': s.cookies['_csrf-token'], 386 | 'accept': 'application/json, text/plain, */*', 387 | 'content-type': 'application/json', 388 | 'accept-language': 'en-US', 389 | 'x-requested-with': 'com.aspiro.tidal' 390 | }) 391 | 392 | if r.status_code != 200: 393 | raise TidalAuthError(r.text) 394 | 395 | if not r.json()['isValidEmail']: 396 | raise TidalAuthError('Invalid email') 397 | if r.json()['newUser']: 398 | raise TidalAuthError('User does not exist') 399 | 400 | # login with user credentials 401 | r = s.post(self.TIDAL_LOGIN_BASE + 'email/user/existing', params=params, json={ 402 | 'email': username, 403 | 'password': password 404 | }, headers={ 405 | 'User-Agent': self.user_agent, 406 | 'x-csrf-token': s.cookies['_csrf-token'], 407 | 'accept': 'application/json, text/plain, */*', 408 | 'content-type': 'application/json', 409 | 'accept-language': 'en-US', 410 | 'x-requested-with': 'com.aspiro.tidal' 411 | }) 412 | 413 | if r.status_code != 200: 414 | raise TidalAuthError(r.text) 415 | 416 | # retrieve access code 417 | r = s.get('https://login.tidal.com/success', allow_redirects=False, headers={ 418 | 'user-agent': self.user_agent, 419 | 'accept-language': 'en-US', 420 | 'x-requested-with': 'com.aspiro.tidal' 421 | }) 422 | 423 | if r.status_code == 401: 424 | raise TidalAuthError('Incorrect password') 425 | assert (r.status_code == 302) 426 | url = urlparse.urlparse(r.headers['location']) 427 | oauth_code = parse_qs(url.query)['code'][0] 428 | 429 | # exchange access code for oauth token 430 | r = requests.post(self.TIDAL_AUTH_BASE + 'oauth2/token', data={ 431 | 'code': oauth_code, 432 | 'client_id': self.client_id, 433 | 'grant_type': 'authorization_code', 434 | 'redirect_uri': self.redirect_uri, 435 | 'scope': 'r_usr w_usr w_sub', 436 | 'code_verifier': self.code_verifier, 437 | 'client_unique_key': self.client_unique_key 438 | }, headers={ 439 | 'User-Agent': self.user_agent 440 | }) 441 | 442 | if r.status_code != 200: 443 | raise TidalAuthError(r.text) 444 | 445 | self.access_token = r.json()['access_token'] 446 | self.refresh_token = r.json()['refresh_token'] 447 | self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in']) 448 | 449 | r = requests.get('https://api.tidal.com/v1/sessions', headers=self.auth_headers()) 450 | 451 | if r.status_code != 200: 452 | raise TidalAuthError(r.text) 453 | 454 | self.user_id = r.json()['userId'] 455 | self.country_code = r.json()['countryCode'] 456 | 457 | def refresh(self): 458 | assert (self.refresh_token is not None) 459 | r = requests.post(self.TIDAL_AUTH_BASE + 'oauth2/token', data={ 460 | 'refresh_token': self.refresh_token, 461 | 'client_id': self.client_id, 462 | 'grant_type': 'refresh_token' 463 | }) 464 | 465 | if r.status_code == 200: 466 | # print('TIDAL: Refreshing token successful') 467 | self.access_token = r.json()['access_token'] 468 | self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in']) 469 | 470 | if 'refresh_token' in r.json(): 471 | self.refresh_token = r.json()['refresh_token'] 472 | 473 | elif r.status_code == 401: 474 | print('\tERROR: ' + r.json()['userMessage']) 475 | 476 | return r.status_code == 200 477 | 478 | @staticmethod 479 | def session_type(): 480 | return 'Mobile' 481 | 482 | def auth_headers(self): 483 | return { 484 | 'Host': 'api.tidal.com', 485 | 'X-Tidal-Token': self.client_id, 486 | 'Authorization': 'Bearer {}'.format(self.access_token), 487 | 'Connection': 'Keep-Alive', 488 | 'Accept-Encoding': 'gzip', 489 | 'User-Agent': 'TIDAL_ANDROID/1039 okhttp/3.14.9' 490 | } 491 | 492 | 493 | class TidalTvSession(TidalSession): 494 | """ 495 | Tidal session object based on the AndroidTV oauth flow 496 | """ 497 | 498 | def __init__(self, client_token: str, client_secret: str): 499 | super().__init__() 500 | self.TIDAL_AUTH_BASE = 'https://auth.tidal.com/v1/' 501 | 502 | self.client_id = client_token 503 | self.client_secret = client_secret 504 | 505 | self.access_token = None 506 | self.refresh_token = None 507 | self.expires = None 508 | self.user_id = None 509 | self.country_code = None 510 | 511 | def auth(self): 512 | s = requests.Session() 513 | 514 | # retrieve csrf token for subsequent request 515 | r = s.post(self.TIDAL_AUTH_BASE + 'oauth2/device_authorization', data={ 516 | 'client_id': self.client_id, 517 | 'scope': 'r_usr w_usr' 518 | }) 519 | 520 | if r.status_code != 200: 521 | raise TidalAuthError("Authorization failed! Is the clientid/token up to date?") 522 | else: 523 | device_code = r.json()['deviceCode'] 524 | user_code = r.json()['userCode'] 525 | print('Opening https://link.tidal.com/{}, log in or sign up to TIDAL.'.format(user_code)) 526 | webbrowser.open('https://link.tidal.com/' + user_code, new=2) 527 | 528 | data = { 529 | 'client_id': self.client_id, 530 | 'device_code': device_code, 531 | 'client_secret': self.client_secret, 532 | 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', 533 | 'scope': 'r_usr w_usr' 534 | } 535 | 536 | status_code = 400 537 | print('Checking link ', end='') 538 | 539 | while status_code == 400: 540 | for index, char in enumerate("." * 5): 541 | sys.stdout.write(char) 542 | sys.stdout.flush() 543 | # exchange access code for oauth token 544 | time.sleep(0.2) 545 | r = requests.post(self.TIDAL_AUTH_BASE + 'oauth2/token', data=data) 546 | status_code = r.status_code 547 | index += 1 # lists are zero indexed, we need to increase by one for the accurate count 548 | # backtrack the written characters, overwrite them with space, backtrack again: 549 | sys.stdout.write("\b" * index + " " * index + "\b" * index) 550 | sys.stdout.flush() 551 | 552 | if r.status_code == 200: 553 | print('\nSuccessfully linked!') 554 | elif r.status_code == 401: 555 | raise TidalAuthError('Auth Error: ' + r.json()['error']) 556 | 557 | self.access_token = r.json()['access_token'] 558 | self.refresh_token = r.json()['refresh_token'] 559 | self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in']) 560 | 561 | r = requests.get('https://api.tidal.com/v1/sessions', headers=self.auth_headers()) 562 | assert (r.status_code == 200) 563 | self.user_id = r.json()['userId'] 564 | self.country_code = r.json()['countryCode'] 565 | 566 | r = requests.get('https://api.tidal.com/v1/users/{}?countryCode={}'.format(self.user_id, self.country_code), 567 | headers=self.auth_headers()) 568 | assert (r.status_code == 200) 569 | # self.username = r.json()['username'] 570 | 571 | def refresh(self): 572 | assert (self.refresh_token is not None) 573 | r = requests.post(self.TIDAL_AUTH_BASE + 'oauth2/token', data={ 574 | 'refresh_token': self.refresh_token, 575 | 'client_id': self.client_id, 576 | 'client_secret': self.client_secret, 577 | 'grant_type': 'refresh_token' 578 | }) 579 | 580 | if r.status_code == 200: 581 | # print('TIDAL: Refreshing token successful') 582 | self.access_token = r.json()['access_token'] 583 | self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in']) 584 | 585 | if 'refresh_token' in r.json(): 586 | self.refresh_token = r.json()['refresh_token'] 587 | 588 | return r.status_code == 200 589 | 590 | @staticmethod 591 | def session_type(): 592 | return 'Tv' 593 | 594 | def auth_headers(self): 595 | return { 596 | 'X-Tidal-Token': self.client_id, 597 | 'Authorization': 'Bearer {}'.format(self.access_token), 598 | 'Connection': 'Keep-Alive', 599 | 'Accept-Encoding': 'gzip', 600 | 'User-Agent': 'TIDAL_ANDROID/1039 okhttp/3.14.9' 601 | } 602 | --------------------------------------------------------------------------------