├── __init__.py ├── .gitignore ├── .gitmodules ├── README.md ├── nugs_api.py └── interface.py /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.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 - nugs 4 | ================= 5 | 6 | A nugs module for the OrpheusDL modular archival music program 7 | 8 | [Report Bug](https://github.com/Dniel97/orpheusdl-nugs/issues) 9 | · 10 | [Request Feature](https://github.com/Dniel97/orpheusdl-nugs/issues) 11 | 12 | 13 | ## Table of content 14 | 15 | - [About OrpheusDL - nugs](#about-orpheusdl---nugs) 16 | - [Getting Started](#getting-started) 17 | - [Prerequisites](#prerequisites) 18 | - [Installation](#installation) 19 | - [Usage](#usage) 20 | - [Configuration](#configuration) 21 | - [Global](#global) 22 | - [nugs](#nugs) 23 | - [Contact](#contact) 24 | 25 | 26 | 27 | 28 | ## About OrpheusDL - nugs 29 | 30 | OrpheusDL - nugs is a module written in Python which allows archiving from **[nugs.net](https://nugs.net)** for the modular music archival program. 31 | 32 | 33 | 34 | ## Getting Started 35 | 36 | Follow these steps to get a local copy of Orpheus up and running: 37 | 38 | ### Prerequisites 39 | 40 | * Already have [OrpheusDL](https://github.com/yarrm80s/orpheusdl) installed 41 | 42 | ### Installation 43 | 44 | 1. Go to your `orpheusdl/` directory and run the following command: 45 | ```sh 46 | git clone --recurse-submodules https://github.com/Dniel97/orpheusdl-nugs.git modules/nugs 47 | ``` 48 | 2. Execute: 49 | ```sh 50 | python orpheus.py 51 | ``` 52 | 3. Now the `config/settings.json` file should be updated with the [nugs settings](#nugs) 53 | 54 | 55 | ## Usage 56 | 57 | Just call `orpheus.py` with any link you want to archive: 58 | 59 | ```sh 60 | python orpheus.py https://play.nugs.net/#/catalog/recording/28751 61 | ``` 62 | 63 | 64 | ## Configuration 65 | 66 | You can customize every module from Orpheus individually and also set general/global settings which are active in every 67 | loaded module. You'll find the configuration file here: `config/settings.json` 68 | 69 | ### Global 70 | 71 | ```json5 72 | "global": { 73 | "general": { 74 | // ... 75 | "download_quality": "hifi" 76 | }, 77 | "codecs": { 78 | "proprietary_codecs": false, 79 | "spatial_codecs": true 80 | }, 81 | // ... 82 | } 83 | ``` 84 | 85 | `download_quality`: Choose one of the following settings: 86 | * "hifi": FLAC with MQA up to 48/24 87 | * "lossless": FLAC or ALAC with 44.1/16 88 | * "high": same as "medium" 89 | * "medium": same as "low" 90 | * "low": same as "minimum" 91 | * "minimum": AAC 150 kbit/s 92 | 93 | 94 | | Option | Info | 95 | |--------------------|----------------------------------------------------------------------------------------| 96 | | proprietary_codecs | Enables/Disables MQA downloading regardless the "hifi" setting from `download_quality` | 97 | | spatial_codecs | Enables/Disables downloading of Sony 360RA | 98 | 99 | **Note: `spatial_codecs` will overwrite your `download_quality` setting and will always get Sony 360RA if available.** 100 | 101 | ### nugs 102 | ```json 103 | { 104 | "username": "", 105 | "password": "", 106 | "client_id": "Eg7HuH873H65r5rt325UytR5429", 107 | "dev_key": "x7f54tgbdyc64y656thy47er4" 108 | } 109 | ``` 110 | 111 | | Option | Info | 112 | |-----------|---------------------------------------------------------| 113 | | username | Enter your nugs email address | 114 | | password | Enter your nugs password | 115 | | client_id | Enter a valid android client_id from /connect/authorize | 116 | | dev_key | Enter a valid android developerKey from secureApi.aspx | 117 | 118 | **Credits: [MQA_identifier](https://github.com/purpl3F0x/MQA_identifier) by 119 | [@purpl3F0x](https://github.com/purpl3F0x) and [mqaid](https://github.com/redsudo/mqaid) by 120 | [@redsudo](https://github.com/redsudo).** 121 | 122 | 123 | ## Contact 124 | 125 | Yarrm80s (pronounced 'Yeargh mateys!') - [@yarrm80s](https://github.com/yarrm80s) 126 | 127 | Dniel97 - [@Dniel97](https://github.com/Dniel97) 128 | 129 | Project Link: [OrpheusDL nugs Public GitHub Repository](https://github.com/Dniel97/orpheusdl-nugs) 130 | -------------------------------------------------------------------------------- /nugs_api.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import re 4 | import secrets 5 | from abc import ABC, abstractmethod 6 | from base64 import b64decode, urlsafe_b64encode 7 | from dataclasses import dataclass 8 | from datetime import datetime, timedelta, timezone 9 | 10 | import requests 11 | from requests.adapters import HTTPAdapter 12 | import urllib.parse as urlparse 13 | from urllib.parse import parse_qs 14 | from urllib3 import Retry 15 | 16 | 17 | @dataclass 18 | class NugsSubscription: 19 | subscription_id: str 20 | sub_cost_plan_id_access_list: str 21 | start_stamp: int 22 | end_stamp: int 23 | 24 | 25 | class NugsNotAvailableError(Exception): 26 | def __init__(self, message): 27 | super(NugsNotAvailableError, self).__init__(message) 28 | 29 | 30 | class NugsSession(ABC): 31 | """ 32 | Nugs abstract session object with all (abstract) functions needed: auth_headers(), refresh() 33 | """ 34 | def __init__(self): 35 | self.user_agent = None 36 | 37 | self.access_token = None 38 | self.refresh_token = None 39 | self.expires = None 40 | 41 | self.username = None 42 | self.user_id = None 43 | 44 | self.client_id = None 45 | self.dev_key = None 46 | 47 | @staticmethod 48 | def convert_timestamps(time_string: str): 49 | return int(datetime.strptime(time_string, "%m/%d/%Y %H:%M:%S").replace(tzinfo=timezone.utc).timestamp()) 50 | 51 | def get_legacy_token(self): 52 | if self.access_token: 53 | # make sure to add padding? 54 | jwt = json.loads(b64decode(f"{self.access_token.split('.')[1]}===").decode('utf-8')) 55 | return jwt.get('legacy_token') 56 | return None 57 | 58 | def set_session(self, session: dict): 59 | self.access_token = session.get('access_token') 60 | self.refresh_token = session.get('refresh_token') 61 | self.expires = session.get('expires') 62 | self.user_id = session.get('user_id') 63 | self.username = session.get('username') 64 | 65 | def get_session(self): 66 | return { 67 | 'access_token': self.access_token, 68 | 'refresh_token': self.refresh_token, 69 | 'expires': self.expires, 70 | 'user_id': self.user_id, 71 | 'username': self.username 72 | } 73 | 74 | def get_user(self): 75 | """ 76 | Returns the user data. 77 | """ 78 | if self.access_token: 79 | r = requests.get('https://id.nugs.net/connect/userinfo', headers=self.auth_headers()) 80 | 81 | if r.status_code != 200: 82 | raise Exception(r.json()) 83 | 84 | self.user_id = r.json()['sub'] 85 | 86 | def get_subscription(self): 87 | """ 88 | Returns the subscription status of the user. 89 | """ 90 | if self.access_token: 91 | r = requests.get('https://subscriptions.nugs.net/api/v1/me/subscriptions/', headers=self.auth_headers()) 92 | 93 | if r.status_code != 200: 94 | raise Exception(r.json()) 95 | 96 | r = r.json() 97 | 98 | return NugsSubscription( 99 | subscription_id=r.get('legacySubscriptionId'), 100 | sub_cost_plan_id_access_list=r.get('promo').get('plan').get('id') if r.get('promo') else r.get('plan').get('id'), 101 | start_stamp=self.convert_timestamps(r.get('startedAt')), 102 | end_stamp=self.convert_timestamps(r.get('endsAt')) 103 | ) 104 | 105 | @abstractmethod 106 | def auth_headers(self, use_access_token: bool = True) -> dict: 107 | pass 108 | 109 | @abstractmethod 110 | def auth(self, username: str, password: str): 111 | pass 112 | 113 | @abstractmethod 114 | def refresh(self): 115 | pass 116 | 117 | 118 | class NugsApi: 119 | API_URL = 'https://streamapi.nugs.net/' 120 | 121 | def __init__(self, session: NugsSession): 122 | self.session = session 123 | 124 | self.s = requests.Session() 125 | 126 | retries = Retry(total=10, 127 | backoff_factor=0.4, 128 | status_forcelist=[429, 500, 502, 503, 504]) 129 | 130 | self.s.mount('http://', HTTPAdapter(max_retries=retries)) 131 | self.s.mount('https://', HTTPAdapter(max_retries=retries)) 132 | 133 | def _get(self, url: str = '', params=None, parse_response: bool = True): 134 | if not params: 135 | params = {} 136 | 137 | r = self.s.get(f'{self.API_URL}{url}', params=params, headers=self.session.auth_headers()) 138 | 139 | if r.status_code not in {200, 201, 202}: 140 | raise ConnectionError(r.text) 141 | 142 | if parse_response: 143 | r = r.json() 144 | if r.get('responseAvailabilityCode') == 0: 145 | return r.get('Response') 146 | raise NugsNotAvailableError(r.get('responseAvailabilityCodeStr')) 147 | 148 | return r.json() 149 | 150 | def get_album(self, album_id: str): 151 | return self._get('api.aspx', { 152 | 'method': 'catalog.container', 153 | 'containerID': album_id, 154 | 'vdisp': 1 155 | }) 156 | 157 | def get_user_playlist(self, playlist_id: str): 158 | return self._get('secureApi.aspx', { 159 | 'method': 'user.playlist', 160 | 'playlistID': playlist_id, 161 | 'token': self.session.get_legacy_token(), 162 | 'developerKey': self.session.dev_key, 163 | 'user': self.session.username, 164 | }) 165 | 166 | def get_artist(self, artist_id: str): 167 | return self._get('api.aspx', { 168 | 'method': 'catalog.artist.years', 169 | 'artistId': artist_id, 170 | 'limit': '10', 171 | }) 172 | 173 | def get_artist_albums(self, artist_id: str, offset: int = 1, limit: int = 100): 174 | return self._get('api.aspx', { 175 | 'method': 'catalog.containersAll', 176 | 'startOffset': offset, 177 | 'artistList': artist_id, 178 | 'limit': limit, 179 | 'vdisp': '1', 180 | 'availType': '1' 181 | }) 182 | 183 | def get_stream(self, track_id: str, sub: NugsSubscription, quality: int or None = 8): 184 | # quality can be 2, 5, 8, 9 or None 185 | return self._get('bigriver/subPlayer.aspx', { 186 | 'trackID': track_id, 187 | 'subscriptionID': sub.subscription_id, 188 | 'subCostplanIDAccessList': sub.sub_cost_plan_id_access_list, 189 | 'startDateStamp': sub.start_stamp, 190 | 'endDateStamp': sub.end_stamp, 191 | 'nn_userID': self.session.user_id, 192 | 'app': '1', 193 | 'platformID': quality 194 | }, parse_response=False) 195 | 196 | def get_search(self, query: str): 197 | return self._get('api.aspx', { 198 | 'method': 'catalog.search', 199 | 'searchStr': query 200 | }) 201 | 202 | def get_all_artists(self): 203 | return self._get('api.aspx', { 204 | 'method': 'catalog.artists' 205 | }) 206 | 207 | 208 | class NugsMobileSession(NugsSession): 209 | """ 210 | Nugs session object based on the mobile Android oauth flow 211 | """ 212 | 213 | def __init__(self, client_id: str, dev_key: str): 214 | super().__init__() 215 | 216 | self.NUGS_AUTH_BASE = 'https://id.nugs.net' 217 | 218 | self.client_id = client_id 219 | self.dev_key = dev_key 220 | 221 | self.redirect_uri = 'nugsnet://oauth2/callback' 222 | self.code_verifier = urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b'=') 223 | self.code_challenge = urlsafe_b64encode(hashlib.sha256(self.code_verifier).digest()).rstrip(b'=') 224 | self.user_agent = 'Mozilla/5.0 (Linux; Android 12; Google Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) ' \ 225 | 'Chrome/103.0.0.0 Mobile Safari/537.36' 226 | 227 | def auth(self, username: str, password: str): 228 | s = requests.Session() 229 | s.headers.update({'User-Agent': self.user_agent}) 230 | 231 | # get the login url 232 | r = s.get(f'{self.NUGS_AUTH_BASE}/connect/authorize', params={ 233 | 'redirect_uri': self.redirect_uri, 234 | 'client_id': self.client_id, 235 | 'response_type': 'code', 236 | 'prompt': 'login', 237 | 'state': urlsafe_b64encode(secrets.token_bytes(16)).rstrip(b'='), 238 | 'nonce': urlsafe_b64encode(secrets.token_bytes(16)).rstrip(b'='), 239 | 'scope': 'roles email profile openid nugsnet:api nugsnet:legacyapi offline_access', 240 | 'code_challenge_method': 'S256' 241 | }) 242 | 243 | assert r.status_code == 200 244 | 245 | # save the login url for later 246 | login_url = r.url 247 | 248 | # request the login site to get the __RequestVerificationToken from the form 249 | r = s.get(login_url) 250 | 251 | assert r.status_code == 200 252 | 253 | # extract the __RequestVerificationToken from the form 254 | request_verification_token = re.search( 255 | r'name="__RequestVerificationToken" type="hidden" value="(.*?)"', r.text).group(1) 256 | 257 | # ok that's dumb, but you have to disable redirects in order to avoid the InvalidSchema error because of the 258 | # redirect url: nugsnet:// 259 | r = s.post(login_url, data={ 260 | 'Input.Email': username, 261 | 'Input.Password': password, 262 | 'Input.RememberLogin': 'true', 263 | '__RequestVerificationToken': request_verification_token 264 | }, allow_redirects=False) 265 | 266 | # so get the actual redirect_url finally, again disable redirects to avoid the InvalidSchema error 267 | if 'location' not in r.headers: 268 | raise Exception('Invalid username/password') 269 | 270 | # WHY IS THEIR STUPID API NOT RETURNING THE code_challenge PROPERLY?! 271 | r = s.get(f"{self.NUGS_AUTH_BASE}{r.headers['location']}", params={ 272 | 'code_challenge': self.code_challenge, 273 | }, allow_redirects=False) 274 | 275 | # extract the oauth code from the redirect url 276 | url = urlparse.urlparse(r.headers['location']) 277 | oauth_code = parse_qs(url.query)['code'][0] 278 | 279 | # exchange the code for access token 280 | r = s.post(f'{self.NUGS_AUTH_BASE}/connect/token', data={ 281 | 'code': oauth_code, 282 | 'grant_type': 'authorization_code', 283 | 'redirect_uri': self.redirect_uri, 284 | 'code_verifier': self.code_verifier, 285 | 'client_id': self.client_id 286 | }) 287 | 288 | assert (r.status_code == 200) 289 | 290 | self.access_token = r.json()['access_token'] 291 | self.refresh_token = r.json()['refresh_token'] 292 | self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in']) 293 | 294 | # save the user id in the session 295 | self.username = username 296 | self.get_user() 297 | 298 | def refresh(self): 299 | s = requests.Session() 300 | s.headers.update({'User-Agent': self.user_agent}) 301 | 302 | # exchange the code for access token 303 | r = s.post(f'{self.NUGS_AUTH_BASE}/connect/token', data={ 304 | 'refresh_token': self.refresh_token, 305 | 'client_id': self.client_id, 306 | 'grant_type': 'refresh_token' 307 | }) 308 | 309 | assert (r.status_code == 200) 310 | 311 | self.access_token = r.json()['access_token'] 312 | self.refresh_token = r.json()['refresh_token'] 313 | self.expires = datetime.now() + timedelta(seconds=r.json()['expires_in']) 314 | 315 | def auth_headers(self, use_access_token: bool = True) -> dict: 316 | return { 317 | 'User-Agent': 'NugsNet/3.16.1.682 (Android; 12; Google; Pixel 6)', 318 | 'Authorization': f'Bearer {self.access_token}' if use_access_token else None, 319 | 'Connection': 'Keep-Alive', 320 | 'Accept-Encoding': 'gzip' 321 | } 322 | -------------------------------------------------------------------------------- /interface.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from datetime import datetime 4 | 5 | from .mqa_identifier_python.mqa_identifier_python.mqa_identifier import MqaIdentifier 6 | from .nugs_api import NugsMobileSession, NugsApi 7 | from utils.utils import create_temp_filename, create_requests_session 8 | from utils.models import * 9 | 10 | 11 | module_information = ModuleInformation( 12 | service_name='nugs', 13 | module_supported_modes=ModuleModes.download | ModuleModes.covers, 14 | session_settings={'username': '', 'password': '', 'client_id': 'Eg7HuH873H65r5rt325UytR5429', 15 | 'dev_key': 'x7f54tgbdyc64y656thy47er4'}, 16 | session_storage_variables=['access_token', 'refresh_token', 'expires', 'user_id', 'username'], 17 | netlocation_constant='nugs', 18 | url_decoding=ManualEnum.manual, 19 | test_url='https://play.nugs.net/#/catalog/recording/28751' 20 | ) 21 | 22 | 23 | class ModuleInterface: 24 | def __init__(self, module_controller: ModuleController): 25 | self.cover_size = module_controller.orpheus_options.default_cover_options.resolution 26 | self.exception = module_controller.module_error 27 | self.oprinter = module_controller.printer_controller 28 | self.print = module_controller.printer_controller.oprint 29 | self.temp_settings = module_controller.temporary_settings_controller 30 | 31 | # the numbers are the priorities from self.format_parse 32 | self.quality_parse = { 33 | QualityEnum.MINIMUM: 0, 34 | QualityEnum.LOW: 0, 35 | QualityEnum.MEDIUM: 0, 36 | QualityEnum.HIGH: 0, 37 | QualityEnum.LOSSLESS: 2, 38 | QualityEnum.HIFI: 3, 39 | } 40 | 41 | self.format_parse = { 42 | ".alac16/": {'codec': CodecEnum.ALAC, 'bitrate': 1411, 'priority': 1}, 43 | ".flac16/": {'codec': CodecEnum.FLAC, 'bitrate': 1411, 'priority': 2}, 44 | ".mqa24/": {'codec': CodecEnum.MQA, 'bitrate': None, 'priority': 3}, 45 | ".s360/": {'codec': CodecEnum.MHA1, 'bitrate': 336, 'priority': 4}, 46 | ".aac150/": {'codec': CodecEnum.AAC, 'bitrate': 150, 'priority': 0}, 47 | } 48 | 49 | self.session = NugsApi(NugsMobileSession(module_controller.module_settings['client_id'], 50 | module_controller.module_settings['dev_key'])) 51 | 52 | session = { 53 | 'access_token': self.temp_settings.read('access_token'), 54 | 'refresh_token': self.temp_settings.read('refresh_token'), 55 | 'expires': self.temp_settings.read('expires'), 56 | 'user_id': self.temp_settings.read('user_id'), 57 | 'username': self.temp_settings.read('username') 58 | } 59 | 60 | self.session.session.set_session(session) 61 | 62 | if session['refresh_token'] is not None and datetime.now() > session['expires']: 63 | # access token expired, get new refresh token 64 | self.refresh_token() 65 | 66 | self.sub = self.session.session.get_subscription() 67 | 68 | def login(self, email: str, password: str): 69 | logging.debug(f'{module_information.service_name}: no session found, login') 70 | self.session.session.auth(email, password) 71 | 72 | # save the new access_token, refresh_token and expires in the temporary settings 73 | self.temp_settings.set('access_token', self.session.session.access_token) 74 | self.temp_settings.set('refresh_token', self.session.session.refresh_token) 75 | self.temp_settings.set('expires', self.session.session.expires) 76 | self.temp_settings.set('user_id', self.session.session.user_id) 77 | self.temp_settings.set('username', self.session.session.username) 78 | 79 | self.sub = self.session.session.get_subscription() 80 | 81 | def refresh_token(self): 82 | logging.debug(f'{module_information.service_name}: access_token expired, getting a new one') 83 | 84 | # get a new access_token and refresh_token from the API 85 | self.session.session.refresh() 86 | 87 | # save the new access_token, refresh_token and expires in the temporary settings 88 | self.temp_settings.set('access_token', self.session.session.access_token) 89 | self.temp_settings.set('refresh_token', self.session.session.refresh_token) 90 | self.temp_settings.set('expires', self.session.session.expires) 91 | self.temp_settings.set('user_id', self.session.session.user_id) 92 | self.temp_settings.set('username', self.session.session.username) 93 | 94 | @staticmethod 95 | def custom_url_parse(link: str): 96 | # the most beautiful regex ever written 97 | match = re.search(r'https?://play.nugs.net/#/(artist|catalog/recording|playlists/playlist)/(\d+)', link) 98 | 99 | # so parse the regex "match" to the actual DownloadTypeEnum 100 | media_types = { 101 | 'catalog/recording': DownloadTypeEnum.album, 102 | 'artist': DownloadTypeEnum.artist, 103 | 'playlists/playlist': DownloadTypeEnum.playlist, 104 | } 105 | 106 | return MediaIdentification( 107 | media_type=media_types[match.group(1)], 108 | media_id=match.group(2), 109 | ) 110 | 111 | def search(self, query_type: DownloadTypeEnum, query, track_info: TrackInfo = None, limit: int = 10): 112 | results = self.session.get_search(query) 113 | 114 | items = [] 115 | if query_type == DownloadTypeEnum.artist: 116 | # nugs don't return the artistID so you have to fetch ALLLLLLL freaking artists 117 | all_artists_data = self.session.get_all_artists().get('artists') 118 | artist_results = [r for r in results.get('catalogSearchTypeContainers') if r.get('matchType') == 1] 119 | 120 | if len(artist_results) == 0: 121 | return items 122 | 123 | for artist_result in artist_results[0].get('catalogSearchContainers'): 124 | artist_name = artist_result.get('matchedStr') 125 | # then you search for the matched artist string in ALL artists.... 126 | artist_data = [a for a in all_artists_data if a.get('artistName') == artist_name][0] 127 | 128 | # get additional info such as numAlbums 129 | total_albums = artist_data.get('numAlbums') 130 | items.append(SearchResult( 131 | result_id=artist_data.get('artistID'), 132 | name=artist_data.get('artistName'), 133 | additional=[f"{total_albums} album{'s' if total_albums != 1 else ''}"], 134 | )) 135 | 136 | elif query_type == DownloadTypeEnum.album: 137 | album_results = [r for r in results.get('catalogSearchTypeContainers') if r.get('matchType') == 6] 138 | 139 | if len(album_results) == 0: 140 | return items 141 | 142 | for album_result in album_results[0].get('catalogSearchContainers'): 143 | for album_data in album_result.get('catalogSearchResultItems'): 144 | items.append(SearchResult( 145 | result_id=album_data.get('containerID'), 146 | artists=[album_data.get('artistName')], 147 | name=album_data.get('containerName'), 148 | )) 149 | 150 | elif query_type == DownloadTypeEnum.track: 151 | track_results = [r for r in results.get('catalogSearchTypeContainers') if r.get('matchType') == 2] 152 | 153 | if len(track_results) == 0: 154 | return items 155 | 156 | for track_result in track_results[0].get('catalogSearchContainers'): 157 | for track_data in track_result.get('catalogSearchResultItems'): 158 | track_data['albumID'] = track_data.get('containerID') 159 | items.append(SearchResult( 160 | result_id=track_data.get('songID'), 161 | artists=[track_data.get('artistName')], 162 | name=f"High Hopes: {track_data.get('containerName')}", 163 | # get_track_info required the album_id and the track_data 164 | extra_kwargs={'data': {track_data.get('songID'): track_data}} 165 | )) 166 | else: 167 | raise Exception('Query type is invalid') 168 | 169 | return items 170 | 171 | def get_artist_info(self, artist_id: str, get_credited_albums: bool) -> ArtistInfo: 172 | artist_data = self.session.get_artist(artist_id) 173 | artist_albums_data = self.session.get_artist_albums(artist_id) 174 | 175 | # now save all the albums 176 | artist_albums = [a for a in artist_albums_data.get('containers') if a.get('containerType') == 1] 177 | total_items = artist_albums_data.get('totalMatchedRecords') 178 | for page in range(2, total_items // 100 + 2): 179 | print(f'Fetching {page * 100}/{total_items}', end='\r') 180 | artist_albums += [a for a in self.session.get_artist_albums(artist_id, offset=page).get('containers') 181 | if a.get('containerType') == 1] 182 | 183 | return ArtistInfo( 184 | name=artist_data.get('ownerName'), 185 | albums=[a.get('containerID') for a in artist_albums], 186 | album_extra_kwargs={'data': {a.get('containerID'): a for a in artist_albums}}, 187 | ) 188 | 189 | def get_playlist_info(self, playlist_id): 190 | playlist_data = self.session.get_user_playlist(playlist_id) 191 | 192 | cache = {'data': {t.get('track').get('songID'): t.get('track') for t in playlist_data.get('items')}} 193 | for track in playlist_data.get('items'): 194 | # stupid API don't save the albumID in the track so every track has the album data attached in 195 | # playlistContainer, so dumb 196 | album_data = track.get('playlistContainer') 197 | cache['data'][track.get('track').get('songID')]['albumID'] = album_data.get('containerID') 198 | 199 | return PlaylistInfo( 200 | name=playlist_data.get('playListName'), 201 | creator='Unknown', 202 | creator_id=playlist_data.get('userID'), 203 | release_year=int(playlist_data.get('createDate')[:4]) if playlist_data.get('createDate') else None, 204 | tracks=[t.get('track').get('songID') for t in playlist_data.get('items')], 205 | track_extra_kwargs=cache 206 | ) 207 | 208 | def get_album_info(self, album_id: str, data=None) -> AlbumInfo: 209 | # check if album is already in album cache, add it 210 | if data is None: 211 | data = {} 212 | 213 | album_data = data.get(album_id) if album_id in data else self.session.get_album(album_id) 214 | 215 | # create the cache with all the tracks and the album data 216 | cache = {'data': {album_id: album_data}} 217 | cache['data'].update({s.get('songID'): s for s in album_data.get('songs')}) 218 | for track in album_data.get('songs'): 219 | cache['data'][track.get('songID')]['albumID'] = album_id 220 | 221 | return AlbumInfo( 222 | name=album_data.get('containerInfo'), 223 | release_year=album_data.get('releaseDateFormatted')[:4] if album_data.get('releaseDateFormatted') else None, 224 | cover_url=f"https://secure.livedownloads.com{album_data.get('img').get('url')}", 225 | artist=album_data.get('artistName'), 226 | artist_id=album_data.get('artistID'), 227 | tracks=[t.get('songID') for t in album_data.get('songs')], 228 | track_extra_kwargs=cache 229 | ) 230 | 231 | def parse_stream_format(self, stream_url: str): 232 | # return the quality of the stream and None if it's not a file 233 | for key, value in self.format_parse.items(): 234 | if key in stream_url: 235 | return value 236 | return None 237 | 238 | def get_track_info(self, track_id: str, quality_tier: QualityEnum, codec_options: CodecOptions, 239 | data=None) -> TrackInfo: 240 | if data is None: 241 | data = {} 242 | 243 | track_data = data[track_id] if track_id in data else None 244 | # get the manually added albumID 245 | album_id = track_data.get('albumID') 246 | 247 | album_data = data[album_id] if album_id in data else self.session.get_album(album_id) 248 | 249 | track_name = track_data.get('songTitle') 250 | release_year = album_data.get('releaseDateFormatted')[:4] if album_data.get('releaseDateFormatted') else None 251 | 252 | tags = Tags( 253 | album_artist=album_data.get('artistsID'), 254 | track_number=track_data.get('trackNum'), 255 | disc_number=track_data.get('discNum'), 256 | total_tracks=len(album_data.get('songs')), 257 | release_date=album_data.get('releaseDateFormatted').replace('/', '-') if album_data.get( 258 | 'releaseDateFormatted') else None, 259 | copyright=f'© {release_year} {album_data.get("licensorName")}', 260 | ) 261 | 262 | error, selected_stream, quality, mqa_file = None, None, None, None 263 | 264 | # why is the API so stupid? Those formats make absolutely no sense, and it's random what you get 265 | stream_data = [] 266 | for stream_format in [9, 5, 2, None]: 267 | stream_url = self.session.get_stream(track_data.get('trackID'), self.sub, stream_format).get('streamLink') 268 | quality = self.parse_stream_format(stream_url) 269 | if quality: 270 | stream = {'stream_url': stream_url} 271 | stream.update(quality) 272 | stream_data.append(stream) 273 | 274 | # sort the dict by priority 275 | stream_data = sorted(stream_data, key=lambda k: k['priority'], reverse=True) 276 | 277 | # check if the track is spatial and if spatial_codecs is enabled 278 | if not codec_options.spatial_codecs and any([codec_data[s.get('codec')].spatial for s in stream_data]): 279 | self.print(f'Spatial codecs are disabled, if you want to download Sony 360RA, ' 280 | f'set "spatial_codecs": true', drop_level=1) 281 | 282 | # check if the track is proprietary and if proprietary_codecs is enabled 283 | if not codec_options.proprietary_codecs and any([codec_data[s.get('codec')].proprietary for s in stream_data]): 284 | self.print(f'Proprietary codecs are disabled, if you want to download MQA, ' 285 | f'set "proprietary_codecs": true', drop_level=1) 286 | 287 | # get the highest wanted quality from the settings.json 288 | highest_priority = self.quality_parse[quality_tier] 289 | # set the highest wanted priority to match Sony 360RA 290 | if codec_options.spatial_codecs: 291 | highest_priority = 4 292 | 293 | wanted_quality = [i for i in range(highest_priority + 1)] 294 | 295 | # remove the MQA priority 296 | if not codec_options.proprietary_codecs: 297 | wanted_quality.remove(3) 298 | 299 | # filter out non-valid streams 300 | valid_streams = [i for i in stream_data if i['priority'] in wanted_quality] 301 | 302 | if len(valid_streams) > 0: 303 | # select the highest valid stream 304 | selected_stream = valid_streams[0] 305 | track_codec = selected_stream.get('codec') 306 | bitrate = selected_stream.get('bitrate') 307 | 308 | # https://en.wikipedia.org/wiki/Audio_bit_depth#cite_ref-1 309 | bit_depth = 16 if track_codec in {CodecEnum.FLAC, CodecEnum.ALAC} else None 310 | sample_rate = 48 if track_codec in {CodecEnum.MHA1} else 44.1 311 | 312 | if track_codec == CodecEnum.MQA: 313 | # download the first chunk of the flac file to analyze it 314 | temp_file_path = self.download_temp_header(selected_stream.get('stream_url')) 315 | 316 | # detect MQA file 317 | mqa_file = MqaIdentifier(temp_file_path) 318 | 319 | else: 320 | error = f'Selected quality is not available' 321 | track_codec = CodecEnum.NONE 322 | bitrate = None 323 | bit_depth = None 324 | sample_rate = None 325 | 326 | # now set everything for MQA 327 | if mqa_file is not None and mqa_file.is_mqa: 328 | bit_depth = mqa_file.bit_depth 329 | sample_rate = mqa_file.get_original_sample_rate() 330 | 331 | track_info = TrackInfo( 332 | name=track_name, 333 | album=album_data.get('containerInfo'), 334 | album_id=album_data.get('containerID'), 335 | artists=[album_data.get('artistName')], 336 | artist_id=album_data.get('artistID'), 337 | release_year=release_year, 338 | cover_url=f"https://secure.livedownloads.com{album_data.get('img').get('url')}", 339 | tags=tags, 340 | codec=track_codec, 341 | bitrate=bitrate, 342 | bit_depth=bit_depth, 343 | sample_rate=sample_rate, 344 | download_extra_kwargs={'stream_url': selected_stream.get('stream_url')}, 345 | error=error 346 | ) 347 | 348 | return track_info 349 | 350 | @staticmethod 351 | def download_temp_header(file_url: str, chunk_size: int = 32768) -> str: 352 | # create flac temp_location 353 | temp_location = create_temp_filename() + '.flac' 354 | 355 | # create session and download the file to the temp_location 356 | r_session = create_requests_session() 357 | 358 | r = r_session.get(file_url, stream=True, verify=False) 359 | with open(temp_location, 'wb') as f: 360 | # only download the first chunk_size bytes 361 | for chunk in r.iter_content(chunk_size=chunk_size): 362 | if chunk: # filter out keep-alive new chunks 363 | f.write(chunk) 364 | break 365 | 366 | return temp_location 367 | 368 | def get_track_download(self, stream_url: str) -> TrackDownloadInfo: 369 | return TrackDownloadInfo( 370 | download_type=DownloadEnum.URL, 371 | file_url=stream_url, 372 | file_url_headers={'User-Agent': self.session.session.user_agent} 373 | ) 374 | --------------------------------------------------------------------------------