├── .gitignore ├── README.md ├── __init__.py ├── interface.py └── qobuz_api.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /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/OrfiTeam/OrpheusDL/issues) 9 | · 10 | [Request Feature](https://github.com/OrfiTeam/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 | 21 | 22 | 23 | 24 | ## About OrpheusDL - Qobuz 25 | 26 | OrpheusDL - Qobuz is a module written in Python which allows archiving from **Qobuz** for the modular music archival program. 27 | 28 | 29 | 30 | ## Getting Started 31 | 32 | Follow these steps to get a local copy of Orpheus up and running: 33 | 34 | ### Prerequisites 35 | 36 | * Already have [OrpheusDL](https://github.com/OrfiTeam/OrpheusDL) installed 37 | 38 | ### Installation 39 | 40 | 1. Clone the repo inside the folder `orpheusdl` 41 | ```sh 42 | git clone https://github.com/thekvt/orpheusdl-qobuz modules/qobuz 43 | ``` 44 | 2. Execute: 45 | ```sh 46 | python orpheus.py 47 | ``` 48 | 3. Now the `config/settings.json` file should be updated with the Qobuz settings 49 | 50 | 51 | ## Usage 52 | 53 | Just call `orpheus.py` with any link you want to archive: 54 | 55 | ```sh 56 | python orpheus.py https://open.qobuz.com/album/c9wsrrjh49ftb 57 | ``` 58 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheKVT/orpheusdl-qobuz/8492d9c265e02a31e271c8b4e82aa22d9f776956/__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': '{bit_depth}B-{sample_rate}kHz','user_id':'', 'auth_token': ''}, 14 | netlocation_constant = 'qobuz', 15 | login_behaviour = ManualEnum.manual, 16 | url_constants={ 17 | 'track': DownloadTypeEnum.track, 18 | 'album': DownloadTypeEnum.album, 19 | 'playlist': DownloadTypeEnum.playlist, 20 | 'artist': DownloadTypeEnum.artist, 21 | 'interpreter': DownloadTypeEnum.artist 22 | }, 23 | test_url = 'https://open.qobuz.com/track/52151405' 24 | ) 25 | 26 | 27 | class ModuleInterface: 28 | def __init__(self, module_controller: ModuleController): 29 | settings = module_controller.module_settings 30 | self.session = Qobuz(settings['app_id'], settings['app_secret'], settings['auth_token'], module_controller.module_error) # TODO: get rid of this module_error thing 31 | self.module_controller = module_controller 32 | self.session.check_token() 33 | 34 | # 5 = 320 kbps MP3, 6 = 16-bit FLAC, 7 = 24-bit / =< 96kHz FLAC, 27 =< 192 kHz FLAC 35 | self.quality_parse = { 36 | QualityEnum.MINIMUM: 5, 37 | QualityEnum.LOW: 5, 38 | QualityEnum.MEDIUM: 5, 39 | QualityEnum.HIGH: 5, 40 | QualityEnum.LOSSLESS: 6, 41 | QualityEnum.HIFI: 27 42 | } 43 | self.quality_tier = module_controller.orpheus_options.quality_tier 44 | self.quality_format = settings.get('quality_format') 45 | 46 | 47 | def get_track_info(self, track_id, quality_tier: QualityEnum, codec_options: CodecOptions, data={}): 48 | track_data = data[track_id] if track_id in data else self.session.get_track(track_id) 49 | album_data = track_data['album'] 50 | 51 | quality_tier = self.quality_parse[quality_tier] 52 | 53 | main_artist = track_data.get('performer', album_data['artist']) 54 | artists = [ 55 | unicodedata.normalize('NFKD', main_artist['name']) 56 | .encode('ascii', 'ignore') 57 | .decode('utf-8') 58 | ] 59 | 60 | # Filter MainArtist and FeaturedArtist from performers 61 | if track_data.get('performers'): 62 | performers = [] 63 | for credit in track_data['performers'].split(' - '): 64 | contributor_role = credit.split(', ')[1:] 65 | contributor_name = credit.split(', ')[0] 66 | 67 | for contributor in ['MainArtist', 'FeaturedArtist', 'Artist']: 68 | if contributor in contributor_role: 69 | if contributor_name not in artists: 70 | artists.append(contributor_name) 71 | contributor_role.remove(contributor) 72 | 73 | if not contributor_role: 74 | continue 75 | performers.append(f"{contributor_name}, {', '.join(contributor_role)}") 76 | track_data['performers'] = ' - '.join(performers) 77 | artists[0] = main_artist['name'] 78 | 79 | tags = Tags( 80 | album_artist = album_data['artist']['name'], 81 | composer = track_data['composer']['name'] if 'composer' in track_data else None, 82 | release_date = album_data.get('release_date_original'), 83 | track_number = track_data['track_number'], 84 | total_tracks = album_data['tracks_count'], 85 | disc_number = track_data['media_number'], 86 | total_discs = album_data['media_count'], 87 | isrc = track_data.get('isrc'), 88 | upc = album_data.get('upc'), 89 | label = album_data.get('label').get('name') if album_data.get('label') else None, 90 | copyright = album_data.get('copyright'), 91 | genres = [album_data['genre']['name']], 92 | ) 93 | 94 | stream_data = self.session.get_file_url(track_id, quality_tier) 95 | # uncompressed PCM bitrate calculation, not quite accurate for FLACs due to the up to 60% size improvement 96 | bitrate = 320 97 | if stream_data.get('format_id') in {6, 7, 27}: 98 | bitrate = int((stream_data['sampling_rate'] * 1000 * stream_data['bit_depth'] * 2) // 1000) 99 | elif not stream_data.get('format_id'): 100 | bitrate = stream_data.get('format_id') 101 | 102 | # track and album title fix to include version tag 103 | track_name = f"{track_data.get('work')} - " if track_data.get('work') else "" 104 | track_name += track_data.get('title').rstrip() 105 | track_name += f' ({track_data.get("version")})' if track_data.get("version") else '' 106 | 107 | album_name = album_data.get('title').rstrip() 108 | album_name += f' ({album_data.get("version")})' if album_data.get("version") else '' 109 | 110 | return TrackInfo( 111 | name = track_name, 112 | album_id = album_data['id'], 113 | album = album_name, 114 | artists = artists, 115 | artist_id = main_artist['id'], 116 | bit_depth = stream_data['bit_depth'], 117 | bitrate = bitrate, 118 | sample_rate = stream_data['sampling_rate'], 119 | release_year = int(album_data['release_date_original'].split('-')[0]), 120 | explicit = track_data['parental_warning'], 121 | cover_url = album_data['image']['large'].split('_')[0] + '_org.jpg', 122 | tags = tags, 123 | 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, 124 | duration = track_data.get('duration'), 125 | credits_extra_kwargs = {'data': {track_id: track_data}}, 126 | download_extra_kwargs = {'url': stream_data.get('url')}, 127 | error=f'Track "{track_data["title"]}" is not streamable!' if not track_data['streamable'] else None 128 | ) 129 | 130 | def get_track_download(self, url): 131 | return TrackDownloadInfo(download_type=DownloadEnum.URL, file_url=url) 132 | 133 | def get_album_info(self, album_id): 134 | album_data = self.session.get_album(album_id) 135 | booklet_url = album_data['goodies'][0]['url'] if 'goodies' in album_data and len(album_data['goodies']) != 0 else None 136 | 137 | tracks, extra_kwargs = [], {} 138 | for track in album_data.pop('tracks')['items']: 139 | track_id = str(track['id']) 140 | tracks.append(track_id) 141 | track['album'] = album_data 142 | extra_kwargs[track_id] = track 143 | 144 | # get the wanted quality for an actual album quality_format string 145 | quality_tier = self.quality_parse[self.quality_tier] 146 | # TODO: Ignore sample_rate and bit_depth if album_data['hires'] is False? 147 | bit_depth = 24 if quality_tier == 27 and album_data['hires_streamable'] else 16 148 | sample_rate = album_data['maximum_sampling_rate'] if quality_tier == 27 and album_data[ 149 | 'hires_streamable'] else 44.1 150 | 151 | quality_tags = { 152 | 'sample_rate': sample_rate, 153 | 'bit_depth': bit_depth 154 | } 155 | 156 | # album title fix to include version tag 157 | album_name = album_data.get('title').rstrip() 158 | album_name += f' ({album_data.get("version")})' if album_data.get("version") else '' 159 | 160 | return AlbumInfo( 161 | name = album_name, 162 | artist = album_data['artist']['name'], 163 | artist_id = album_data['artist']['id'], 164 | tracks = tracks, 165 | release_year = int(album_data['release_date_original'].split('-')[0]), 166 | explicit = album_data['parental_warning'], 167 | quality = self.quality_format.format(**quality_tags) if self.quality_format != '' else None, 168 | description = album_data.get('description'), 169 | cover_url = album_data['image']['large'].split('_')[0] + '_org.jpg', 170 | all_track_cover_jpg_url = album_data['image']['large'], 171 | upc = album_data.get('upc'), 172 | duration = album_data.get('duration'), 173 | booklet_url = booklet_url, 174 | track_extra_kwargs = {'data': extra_kwargs} 175 | ) 176 | 177 | def get_playlist_info(self, playlist_id): 178 | playlist_data = self.session.get_playlist(playlist_id) 179 | 180 | tracks, extra_kwargs = [], {} 181 | for track in playlist_data['tracks']['items']: 182 | track_id = str(track['id']) 183 | extra_kwargs[track_id] = track 184 | tracks.append(track_id) 185 | 186 | return PlaylistInfo( 187 | name = playlist_data['name'], 188 | creator = playlist_data['owner']['name'], 189 | creator_id = playlist_data['owner']['id'], 190 | release_year = datetime.utcfromtimestamp(playlist_data['created_at']).strftime('%Y'), 191 | description = playlist_data.get('description'), 192 | duration = playlist_data.get('duration'), 193 | tracks = tracks, 194 | track_extra_kwargs = {'data': extra_kwargs} 195 | ) 196 | 197 | def get_artist_info(self, artist_id, get_credited_albums): 198 | artist_data = self.session.get_artist(artist_id) 199 | albums = [str(album['id']) for album in artist_data['albums']['items']] 200 | 201 | return ArtistInfo( 202 | name = artist_data['name'], 203 | albums = albums 204 | ) 205 | 206 | def get_track_credits(self, track_id, data=None): 207 | track_data = data[track_id] if track_id in data else self.session.get_track(track_id) 208 | track_contributors = track_data.get('performers') 209 | 210 | # Credits look like: {name}, {type1}, {type2} - {name2}, {type2} 211 | credits_dict = {} 212 | if track_contributors: 213 | for credit in track_contributors.split(' - '): 214 | contributor_role = credit.split(', ')[1:] 215 | contributor_name = credit.split(', ')[0] 216 | 217 | for role in contributor_role: 218 | # Check if the dict contains no list, create one 219 | if role not in credits_dict: 220 | credits_dict[role] = [] 221 | # Now add the name to the type list 222 | credits_dict[role].append(contributor_name) 223 | 224 | # Convert the dictionary back to a list of CreditsInfo 225 | return [CreditsInfo(k, v) for k, v in credits_dict.items()] 226 | 227 | def search(self, query_type: DownloadTypeEnum, query, track_info: TrackInfo = None, limit: int = 10): 228 | results = {} 229 | if track_info and track_info.tags.isrc: 230 | results = self.session.search(query_type.name, track_info.tags.isrc, limit) 231 | if not results: 232 | results = self.session.search(query_type.name, query, limit) 233 | 234 | items = [] 235 | for i in results[query_type.name + 's']['items']: 236 | duration = None 237 | if query_type is DownloadTypeEnum.artist: 238 | artists = None 239 | year = None 240 | elif query_type is DownloadTypeEnum.playlist: 241 | artists = [i['owner']['name']] 242 | year = datetime.utcfromtimestamp(i['created_at']).strftime('%Y') 243 | duration = i['duration'] 244 | elif query_type is DownloadTypeEnum.track: 245 | artists = [i['performer']['name']] 246 | year = int(i['album']['release_date_original'].split('-')[0]) 247 | duration = i['duration'] 248 | elif query_type is DownloadTypeEnum.album: 249 | artists = [i['artist']['name']] 250 | year = int(i['release_date_original'].split('-')[0]) 251 | duration = i['duration'] 252 | else: 253 | raise Exception('Query type is invalid') 254 | name = i.get('name') or i.get('title') 255 | name += f" ({i.get('version')})" if i.get('version') else '' 256 | item = SearchResult( 257 | name = name, 258 | artists = artists, 259 | year = year, 260 | result_id = str(i['id']), 261 | explicit = bool(i.get('parental_warning')), 262 | additional = [f'{i["maximum_sampling_rate"]}kHz/{i["maximum_bit_depth"]}bit'] if "maximum_sampling_rate" in i else None, 263 | duration = duration, 264 | extra_kwargs = {'data': {str(i['id']): i}} if query_type is DownloadTypeEnum.track else {} 265 | ) 266 | 267 | items.append(item) 268 | 269 | return items -------------------------------------------------------------------------------- /qobuz_api.py: -------------------------------------------------------------------------------- 1 | import time 2 | from utils.utils import hash_string, create_requests_session 3 | 4 | class Qobuz: 5 | def __init__(self, app_id: str, app_secret: str, auth_token: str, exception): 6 | self.api_base = 'https://www.qobuz.com/api.json/0.2/' 7 | self.app_id = app_id 8 | self.app_secret = app_secret 9 | self.auth_token = auth_token 10 | self.exception = exception 11 | self.s = create_requests_session() 12 | 13 | def headers(self): 14 | return { 15 | 'X-Device-Platform': 'android', 16 | 'X-Device-Model': 'Pixel 3', 17 | 'X-Device-Os-Version': '10', 18 | 'X-User-Auth-Token': self.auth_token if self.auth_token else None, 19 | 'X-Device-Manufacturer-Id': '482D8CB7-015D-402F-A93B-5EEF0E0996F3', 20 | 'X-App-Version': '5.16.1.5', 21 | 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 10; Pixel 3 Build/QP1A.190711.020))' 22 | 'QobuzMobileAndroid/5.16.1.5-b21041415' 23 | } 24 | 25 | def _get(self, url: str, params=None): 26 | if not params: 27 | params = {} 28 | 29 | r = self.s.get(f'{self.api_base}{url}', params=params, headers=self.headers()) 30 | 31 | if r.status_code not in [200, 201, 202]: 32 | raise self.exception(r.text) 33 | 34 | return r.json() 35 | 36 | def check_token(self): 37 | params = { 38 | 'app_id': self.app_id, 39 | } 40 | signature = self.create_signature('user/get', params) 41 | params['request_ts'] = signature[0] 42 | params['request_sig'] = signature[1] 43 | 44 | r = self._get('user/get', params) 45 | 46 | if r['credential']['parameters']: 47 | print("Account Region:", r["country"]) 48 | elif not r['credential']['parameters']: 49 | raise self.exception("Free accounts are not eligible for downloading") 50 | else: 51 | raise self.exception('Invalid UserID/Token') 52 | 53 | 54 | def create_signature(self, method: str, parameters: dict): 55 | timestamp = str(int(time.time())) 56 | to_hash = method.replace('/', '') 57 | 58 | for key in sorted(parameters.keys()): 59 | if not (key == 'app_id' or key == 'user_auth_token'): 60 | to_hash += key + parameters[key] 61 | 62 | to_hash += timestamp + self.app_secret 63 | signature = hash_string(to_hash, 'MD5') 64 | return timestamp, signature 65 | 66 | def search(self, query_type: str, query: str, limit: int = 10): 67 | return self._get('catalog/search', { 68 | 'query': query, 69 | 'type': query_type + 's', 70 | 'limit': limit, 71 | 'app_id': self.app_id 72 | }) 73 | 74 | def get_file_url(self, track_id: str, quality_id=27): 75 | params = { 76 | 'track_id': track_id, 77 | 'format_id': str(quality_id), 78 | 'intent': 'stream', 79 | 'sample': 'false', 80 | 'app_id': self.app_id, 81 | 'user_auth_token': self.auth_token 82 | } 83 | 84 | signature = self.create_signature('track/getFileUrl', params) 85 | params['request_ts'] = signature[0] 86 | params['request_sig'] = signature[1] 87 | 88 | return self._get('track/getFileUrl', params) 89 | 90 | def get_track(self, track_id: str): 91 | return self._get('track/get', params={ 92 | 'track_id': track_id, 93 | 'app_id': self.app_id 94 | }) 95 | 96 | def get_playlist(self, playlist_id: str): 97 | return self._get('playlist/get', params={ 98 | 'playlist_id': playlist_id, 99 | 'app_id': self.app_id, 100 | 'limit': '2000', 101 | 'offset': '0', 102 | 'extra': 'tracks,subscribers,focusAll' 103 | }) 104 | 105 | def get_album(self, album_id: str): 106 | return self._get('album/get', params={ 107 | 'album_id': album_id, 108 | 'app_id': self.app_id, 109 | 'extra': 'albumsFromSameArtist,focusAll' 110 | }) 111 | 112 | def get_artist(self, artist_id: str): 113 | return self._get('artist/get', params={ 114 | 'artist_id': artist_id, 115 | 'app_id': self.app_id, 116 | 'extra': 'albums,playlists,tracks_appears_on,albums_with_last_release,focusAll', 117 | 'limit': '1000', 118 | 'offset': '0' 119 | }) 120 | --------------------------------------------------------------------------------