├── .gitignore ├── README.md ├── __init__.py ├── interface.py └── qobuz_api.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | venv 3 | .vscode 4 | .DS_Store 5 | .idea/ 6 | .python-version 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | OrpheusDL - Qobuz 4 | ================= 5 | 6 | A Qobuz module for the OrpheusDL modular archival music program 7 | 8 | [Report Bug](https://github.com/yarrm80s/orpheusdl/issues) 9 | · 10 | [Request Feature](https://github.com/yarrm80s/orpheusdl/issues) 11 | 12 | 13 | ## Table of content 14 | 15 | - [About OrpheusDL - Qobuz](#about-orpheusdl-qobuz) 16 | - [Getting Started](#getting-started) 17 | - [Prerequisites](#prerequisites) 18 | - [Installation](#installation) 19 | - [Usage](#usage) 20 | - [Configuration](#configuration) 21 | - [Global](#global) 22 | - [Qobuz](#qobuz) 23 | - [Contact](#contact) 24 | 25 | 26 | 27 | ## About OrpheusDL - Qobuz 28 | 29 | OrpheusDL - Qobuz is a module written in Python which allows archiving from **Qobuz** for the modular music archival program. 30 | 31 | 32 | 33 | ## Getting Started 34 | 35 | Follow these steps to get a local copy of Orpheus up and running: 36 | 37 | ### Prerequisites 38 | 39 | * Already have [OrpheusDL](https://github.com/yarrm80s/orpheusdl) installed 40 | 41 | ### Installation 42 | 43 | 1. Clone the repo inside the folder `orpheusdl/modules/` 44 | ```sh 45 | git clone https://github.com/yarrm80s/orpheusdl-qobuz.git qobuz 46 | ``` 47 | 2. Execute: 48 | ```sh 49 | python orpheus.py 50 | ``` 51 | 3. Now the `config/settings.json` file should be updated with the Qobuz settings 52 | 53 | 54 | ## Usage 55 | 56 | Just call `orpheus.py` with any link you want to archive: 57 | 58 | ```sh 59 | python orpheus.py https://open.qobuz.com/album/c9wsrrjh49ftb 60 | ``` 61 | 62 | 63 | ## Configuration 64 | 65 | You can customize every module from Orpheus individually and also set general/global settings which are active in every 66 | loaded module. You'll find the configuration file here: `config/settings.json` 67 | 68 | ### Global 69 | 70 | ```json5 71 | "global": { 72 | "general": { 73 | // ... 74 | "download_quality": "hifi" 75 | }, 76 | "formatting": { 77 | "album_format": "{album_name}{quality}{explicit}", 78 | // ... 79 | } 80 | // ... 81 | } 82 | ``` 83 | 84 | `download_quality`: Choose one of the following settings: 85 | * "hifi": FLAC up to 192/24 86 | * "lossless": FLAC with 44.1/16 87 | * "high": MP3 320 kbit/s 88 | 89 | `album_format`: 90 | * `{quality}` will add the format which you can specify under `quality_format` (see below), default: 91 | ``` 92 | [192kHz 24bit] 93 | ``` 94 | depending on the maximum available album quality and the chosen `download_quality` setting 95 | * `{explicit}` will add 96 | ``` 97 | [E] 98 | ``` 99 | to the album path 100 | 101 | ### Qobuz 102 | ```json5 103 | "qobuz": { 104 | "app_id": "", 105 | "app_secret": "", 106 | "quality_format": "{sample_rate}kHz {bit_depth}bit", 107 | "username": "", 108 | "password": "" 109 | } 110 | ``` 111 | `app_id`: Enter a valid mobile app id 112 | 113 | `app_secret`: Enter a valid mobile app secret 114 | 115 | `quality_format`: How the quality is formatted when `{quality}` is present in `album_format`, possible values are 116 | `{sample_rate}` and `bit_depth`. 117 | 118 | **NOTE: Set the `"quality_format": ""` to remove the quality string even if `{quality}` is present in `album_format`. 119 | Square brackets `[]` will always be added before and after the `quality_format` in the album path.** 120 | 121 | `username`: Enter your qobuz email address here 122 | 123 | `password`: Enter your qobuz password here 124 | 125 | 126 | ## Contact 127 | 128 | Yarrm80s - [@yarrm80s](https://github.com/yarrm80s) 129 | 130 | Dniel97 - [@Dniel97](https://github.com/Dniel97) 131 | 132 | Project Link: [OrpheusDL Qobuz Public GitHub Repository](https://github.com/yarrm80s/orpheusdl-qobuz) 133 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OrfiDev/orpheusdl-qobuz/6dc5c7e2bd9820a6a80b9ba4d3004fbdc6955e44/__init__.py -------------------------------------------------------------------------------- /interface.py: -------------------------------------------------------------------------------- 1 | import unicodedata 2 | import re 3 | from datetime import datetime 4 | from urllib.parse import urlparse 5 | 6 | from utils.models import * 7 | from .qobuz_api import Qobuz 8 | 9 | 10 | module_information = ModuleInformation( 11 | service_name = 'Qobuz', 12 | module_supported_modes = ModuleModes.download | ModuleModes.credits, 13 | global_settings = {'app_id': '', 'app_secret': '', 'quality_format': '{sample_rate}kHz {bit_depth}bit'}, 14 | session_settings = {'username': '', 'password': ''}, 15 | session_storage_variables = ['token'], 16 | netlocation_constant = 'qobuz', 17 | url_constants={ 18 | 'track': DownloadTypeEnum.track, 19 | 'album': DownloadTypeEnum.album, 20 | 'playlist': DownloadTypeEnum.playlist, 21 | 'artist': DownloadTypeEnum.artist, 22 | 'interpreter': DownloadTypeEnum.artist 23 | }, 24 | test_url = 'https://open.qobuz.com/track/52151405' 25 | ) 26 | 27 | 28 | class ModuleInterface: 29 | def __init__(self, module_controller: ModuleController): 30 | settings = module_controller.module_settings 31 | self.session = Qobuz(settings['app_id'], settings['app_secret'], module_controller.module_error) # TODO: get rid of this module_error thing 32 | self.session.auth_token = module_controller.temporary_settings_controller.read('token') 33 | self.module_controller = module_controller 34 | 35 | # 5 = 320 kbps MP3, 6 = 16-bit FLAC, 7 = 24-bit / =< 96kHz FLAC, 27 =< 192 kHz FLAC 36 | self.quality_parse = { 37 | QualityEnum.MINIMUM: 5, 38 | QualityEnum.LOW: 5, 39 | QualityEnum.MEDIUM: 5, 40 | QualityEnum.HIGH: 5, 41 | QualityEnum.LOSSLESS: 6, 42 | QualityEnum.HIFI: 27 43 | } 44 | self.quality_tier = module_controller.orpheus_options.quality_tier 45 | self.quality_format = settings.get('quality_format') 46 | 47 | def login(self, email, password): 48 | token = self.session.login(email, password) 49 | self.session.auth_token = token 50 | self.module_controller.temporary_settings_controller.set('token', token) 51 | 52 | def get_track_info(self, track_id, quality_tier: QualityEnum, codec_options: CodecOptions, data={}): 53 | track_data = data[track_id] if track_id in data else self.session.get_track(track_id) 54 | album_data = track_data['album'] 55 | 56 | quality_tier = self.quality_parse[quality_tier] 57 | 58 | main_artist = track_data.get('performer', album_data['artist']) 59 | artists = [ 60 | unicodedata.normalize('NFKD', main_artist['name']) 61 | .encode('ascii', 'ignore') 62 | .decode('utf-8') 63 | ] 64 | 65 | # Filter MainArtist and FeaturedArtist from performers 66 | if track_data.get('performers'): 67 | performers = [] 68 | for credit in track_data['performers'].split(' - '): 69 | contributor_role = credit.split(', ')[1:] 70 | contributor_name = credit.split(', ')[0] 71 | 72 | for contributor in ['MainArtist', 'FeaturedArtist', 'Artist']: 73 | if contributor in contributor_role: 74 | if contributor_name not in artists: 75 | artists.append(contributor_name) 76 | contributor_role.remove(contributor) 77 | 78 | if not contributor_role: 79 | continue 80 | performers.append(f"{contributor_name}, {', '.join(contributor_role)}") 81 | track_data['performers'] = ' - '.join(performers) 82 | artists[0] = main_artist['name'] 83 | 84 | tags = Tags( 85 | album_artist = album_data['artist']['name'], 86 | composer = track_data['composer']['name'] if 'composer' in track_data else None, 87 | release_date = album_data.get('release_date_original'), 88 | track_number = track_data['track_number'], 89 | total_tracks = album_data['tracks_count'], 90 | disc_number = track_data['media_number'], 91 | total_discs = album_data['media_count'], 92 | isrc = track_data.get('isrc'), 93 | upc = album_data.get('upc'), 94 | label = album_data.get('label').get('name') if album_data.get('label') else None, 95 | copyright = album_data.get('copyright'), 96 | genres = [album_data['genre']['name']], 97 | ) 98 | 99 | stream_data = self.session.get_file_url(track_id, quality_tier) 100 | # uncompressed PCM bitrate calculation, not quite accurate for FLACs due to the up to 60% size improvement 101 | bitrate = 320 102 | if stream_data.get('format_id') in {6, 7, 27}: 103 | bitrate = int((stream_data['sampling_rate'] * 1000 * stream_data['bit_depth'] * 2) // 1000) 104 | elif not stream_data.get('format_id'): 105 | bitrate = stream_data.get('format_id') 106 | 107 | # track and album title fix to include version tag 108 | track_name = f"{track_data.get('work')} - " if track_data.get('work') else "" 109 | track_name += track_data.get('title').rstrip() 110 | track_name += f' ({track_data.get("version")})' if track_data.get("version") else '' 111 | 112 | album_name = album_data.get('title').rstrip() 113 | album_name += f' ({album_data.get("version")})' if album_data.get("version") else '' 114 | 115 | return TrackInfo( 116 | name = track_name, 117 | album_id = album_data['id'], 118 | album = album_name, 119 | artists = artists, 120 | artist_id = main_artist['id'], 121 | bit_depth = stream_data['bit_depth'], 122 | bitrate = bitrate, 123 | sample_rate = stream_data['sampling_rate'], 124 | release_year = int(album_data['release_date_original'].split('-')[0]), 125 | explicit = track_data['parental_warning'], 126 | cover_url = album_data['image']['large'].split('_')[0] + '_org.jpg', 127 | tags = tags, 128 | codec = CodecEnum.FLAC if stream_data.get('format_id') in {6, 7, 27} else CodecEnum.NONE if not stream_data.get('format_id') else CodecEnum.MP3, 129 | duration = track_data.get('duration'), 130 | credits_extra_kwargs = {'data': {track_id: track_data}}, 131 | download_extra_kwargs = {'url': stream_data.get('url')}, 132 | error=f'Track "{track_data["title"]}" is not streamable!' if not track_data['streamable'] else None 133 | ) 134 | 135 | def get_track_download(self, url): 136 | return TrackDownloadInfo(download_type=DownloadEnum.URL, file_url=url) 137 | 138 | def get_album_info(self, album_id): 139 | album_data = self.session.get_album(album_id) 140 | booklet_url = album_data['goodies'][0]['url'] if 'goodies' in album_data and len(album_data['goodies']) != 0 else None 141 | 142 | tracks, extra_kwargs = [], {} 143 | for track in album_data.pop('tracks')['items']: 144 | track_id = str(track['id']) 145 | tracks.append(track_id) 146 | track['album'] = album_data 147 | extra_kwargs[track_id] = track 148 | 149 | # get the wanted quality for an actual album quality_format string 150 | quality_tier = self.quality_parse[self.quality_tier] 151 | # TODO: Ignore sample_rate and bit_depth if album_data['hires'] is False? 152 | bit_depth = 24 if quality_tier == 27 and album_data['hires_streamable'] else 16 153 | sample_rate = album_data['maximum_sampling_rate'] if quality_tier == 27 and album_data[ 154 | 'hires_streamable'] else 44.1 155 | 156 | quality_tags = { 157 | 'sample_rate': sample_rate, 158 | 'bit_depth': bit_depth 159 | } 160 | 161 | # album title fix to include version tag 162 | album_name = album_data.get('title').rstrip() 163 | album_name += f' ({album_data.get("version")})' if album_data.get("version") else '' 164 | 165 | return AlbumInfo( 166 | name = album_name, 167 | artist = album_data['artist']['name'], 168 | artist_id = album_data['artist']['id'], 169 | tracks = tracks, 170 | release_year = int(album_data['release_date_original'].split('-')[0]), 171 | explicit = album_data['parental_warning'], 172 | quality = self.quality_format.format(**quality_tags) if self.quality_format != '' else None, 173 | description = album_data.get('description'), 174 | cover_url = album_data['image']['large'].split('_')[0] + '_org.jpg', 175 | all_track_cover_jpg_url = album_data['image']['large'], 176 | upc = album_data.get('upc'), 177 | duration = album_data.get('duration'), 178 | booklet_url = booklet_url, 179 | track_extra_kwargs = {'data': extra_kwargs} 180 | ) 181 | 182 | def get_playlist_info(self, playlist_id): 183 | playlist_data = self.session.get_playlist(playlist_id) 184 | 185 | tracks, extra_kwargs = [], {} 186 | for track in playlist_data['tracks']['items']: 187 | track_id = str(track['id']) 188 | extra_kwargs[track_id] = track 189 | tracks.append(track_id) 190 | 191 | return PlaylistInfo( 192 | name = playlist_data['name'], 193 | creator = playlist_data['owner']['name'], 194 | creator_id = playlist_data['owner']['id'], 195 | release_year = datetime.utcfromtimestamp(playlist_data['created_at']).strftime('%Y'), 196 | description = playlist_data.get('description'), 197 | duration = playlist_data.get('duration'), 198 | tracks = tracks, 199 | track_extra_kwargs = {'data': extra_kwargs} 200 | ) 201 | 202 | def get_artist_info(self, artist_id, get_credited_albums): 203 | artist_data = self.session.get_artist(artist_id) 204 | albums = [str(album['id']) for album in artist_data['albums']['items']] 205 | 206 | return ArtistInfo( 207 | name = artist_data['name'], 208 | albums = albums 209 | ) 210 | 211 | def get_track_credits(self, track_id, data=None): 212 | track_data = data[track_id] if track_id in data else self.session.get_track(track_id) 213 | track_contributors = track_data.get('performers') 214 | 215 | # Credits look like: {name}, {type1}, {type2} - {name2}, {type2} 216 | credits_dict = {} 217 | if track_contributors: 218 | for credit in track_contributors.split(' - '): 219 | contributor_role = credit.split(', ')[1:] 220 | contributor_name = credit.split(', ')[0] 221 | 222 | for role in contributor_role: 223 | # Check if the dict contains no list, create one 224 | if role not in credits_dict: 225 | credits_dict[role] = [] 226 | # Now add the name to the type list 227 | credits_dict[role].append(contributor_name) 228 | 229 | # Convert the dictionary back to a list of CreditsInfo 230 | return [CreditsInfo(k, v) for k, v in credits_dict.items()] 231 | 232 | def search(self, query_type: DownloadTypeEnum, query, track_info: TrackInfo = None, limit: int = 10): 233 | results = {} 234 | if track_info and track_info.tags.isrc: 235 | results = self.session.search(query_type.name, track_info.tags.isrc, limit) 236 | if not results: 237 | results = self.session.search(query_type.name, query, limit) 238 | 239 | items = [] 240 | for i in results[query_type.name + 's']['items']: 241 | duration = None 242 | if query_type is DownloadTypeEnum.artist: 243 | artists = None 244 | year = None 245 | elif query_type is DownloadTypeEnum.playlist: 246 | artists = [i['owner']['name']] 247 | year = datetime.utcfromtimestamp(i['created_at']).strftime('%Y') 248 | duration = i['duration'] 249 | elif query_type is DownloadTypeEnum.track: 250 | artists = [i['performer']['name']] 251 | year = int(i['album']['release_date_original'].split('-')[0]) 252 | duration = i['duration'] 253 | elif query_type is DownloadTypeEnum.album: 254 | artists = [i['artist']['name']] 255 | year = int(i['release_date_original'].split('-')[0]) 256 | duration = i['duration'] 257 | else: 258 | raise Exception('Query type is invalid') 259 | name = i.get('name') or i.get('title') 260 | name += f" ({i.get('version')})" if i.get('version') else '' 261 | item = SearchResult( 262 | name = name, 263 | artists = artists, 264 | year = year, 265 | result_id = str(i['id']), 266 | explicit = bool(i.get('parental_warning')), 267 | additional = [f'{i["maximum_sampling_rate"]}kHz/{i["maximum_bit_depth"]}bit'] if "maximum_sampling_rate" in i else None, 268 | duration = duration, 269 | extra_kwargs = {'data': {str(i['id']): i}} if query_type is DownloadTypeEnum.track else {} 270 | ) 271 | 272 | items.append(item) 273 | 274 | return items 275 | -------------------------------------------------------------------------------- /qobuz_api.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from utils.utils import hash_string, create_requests_session 4 | 5 | 6 | class Qobuz: 7 | def __init__(self, app_id: str, app_secret: str, exception): 8 | self.api_base = 'https://www.qobuz.com/api.json/0.2/' 9 | self.app_id = app_id 10 | self.app_secret = app_secret 11 | self.auth_token = None 12 | self.exception = exception 13 | self.s = create_requests_session() 14 | 15 | def headers(self): 16 | return { 17 | 'X-Device-Platform': 'android', 18 | 'X-Device-Model': 'Pixel 3', 19 | 'X-Device-Os-Version': '10', 20 | 'X-User-Auth-Token': self.auth_token if self.auth_token else None, 21 | 'X-Device-Manufacturer-Id': 'ffffffff-5783-1f51-ffff-ffffef05ac4a', 22 | 'X-App-Version': '5.16.1.5', 23 | 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 10; Pixel 3 Build/QP1A.190711.020))' 24 | 'QobuzMobileAndroid/5.16.1.5-b21041415' 25 | } 26 | 27 | def _get(self, url: str, params=None): 28 | if not params: 29 | params = {} 30 | 31 | r = self.s.get(f'{self.api_base}{url}', params=params, headers=self.headers()) 32 | 33 | if r.status_code not in [200, 201, 202]: 34 | raise self.exception(r.text) 35 | 36 | return r.json() 37 | 38 | def login(self, email: str, password: str): 39 | params = { 40 | 'username': email, 41 | 'password': hash_string(password, 'MD5'), 42 | 'extra': 'partner', 43 | 'app_id': self.app_id 44 | } 45 | 46 | signature = self.create_signature('user/login', params) 47 | params['request_ts'] = signature[0] 48 | params['request_sig'] = signature[1] 49 | 50 | r = self._get('user/login', params) 51 | 52 | if 'user_auth_token' in r and r['user']['credential']['parameters']: 53 | self.auth_token = r['user_auth_token'] 54 | elif not r['user']['credential']['parameters']: 55 | raise self.exception("Free accounts are not eligible for downloading") 56 | else: 57 | raise self.exception('Invalid username/password') 58 | 59 | return r['user_auth_token'] 60 | 61 | def create_signature(self, method: str, parameters: dict): 62 | timestamp = str(int(time.time())) 63 | to_hash = method.replace('/', '') 64 | 65 | for key in sorted(parameters.keys()): 66 | if not (key == 'app_id' or key == 'user_auth_token'): 67 | to_hash += key + parameters[key] 68 | 69 | to_hash += timestamp + self.app_secret 70 | signature = hash_string(to_hash, 'MD5') 71 | return timestamp, signature 72 | 73 | def search(self, query_type: str, query: str, limit: int = 10): 74 | return self._get('catalog/search', { 75 | 'query': query, 76 | 'type': query_type + 's', 77 | 'limit': limit, 78 | 'app_id': self.app_id 79 | }) 80 | 81 | def get_file_url(self, track_id: str, quality_id=27): 82 | params = { 83 | 'track_id': track_id, 84 | 'format_id': str(quality_id), 85 | 'intent': 'stream', 86 | 'sample': 'false', 87 | 'app_id': self.app_id, 88 | 'user_auth_token': self.auth_token 89 | } 90 | 91 | signature = self.create_signature('track/getFileUrl', params) 92 | params['request_ts'] = signature[0] 93 | params['request_sig'] = signature[1] 94 | 95 | return self._get('track/getFileUrl', params) 96 | 97 | def get_track(self, track_id: str): 98 | return self._get('track/get', params={ 99 | 'track_id': track_id, 100 | 'app_id': self.app_id 101 | }) 102 | 103 | def get_playlist(self, playlist_id: str): 104 | return self._get('playlist/get', params={ 105 | 'playlist_id': playlist_id, 106 | 'app_id': self.app_id, 107 | 'limit': '2000', 108 | 'offset': '0', 109 | 'extra': 'tracks,subscribers,focusAll' 110 | }) 111 | 112 | def get_album(self, album_id: str): 113 | return self._get('album/get', params={ 114 | 'album_id': album_id, 115 | 'app_id': self.app_id, 116 | 'extra': 'albumsFromSameArtist,focusAll' 117 | }) 118 | 119 | def get_artist(self, artist_id: str): 120 | return self._get('artist/get', params={ 121 | 'artist_id': artist_id, 122 | 'app_id': self.app_id, 123 | 'extra': 'albums,playlists,tracks_appears_on,albums_with_last_release,focusAll', 124 | 'limit': '1000', 125 | 'offset': '0' 126 | }) 127 | --------------------------------------------------------------------------------