diff --git a/README.md b/README.md index c1d9e7c..011dfb5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Qobuz-DL +# qobuz-dl Seach and download Lossless and Hi-Res music from [Qobuz](https://www.qobuz.com/) ![Demostration](demo.gif) @@ -7,13 +7,13 @@ Seach and download Lossless and Hi-Res music from [Qobuz](https://www.qobuz.com/ ## Features * Download FLAC and MP3 files from Qobuz -* Forget about links: just search and download music directly from your terminal +* Search and download music directly from your terminal with interactive mode * Queue support -* If you still want links, `Qobuz-DL` also has an input url mode +* Input url mode with download support for albums, tracks, artists, playlists and labels ## Getting started -> Note: `Qobuz-DL` requires Python >3.6 +> Note: `qobuz-dl` requires Python >3.6 > Note 2: You'll need an **active subscription** @@ -32,7 +32,7 @@ pip3 install -r requirements.txt email = "your@email.com" password = "your_password" ``` -#### Run Qobuz-DL +#### Run qobuz-dl ##### Linux / MAC OS ``` python3 main.py @@ -48,13 +48,13 @@ usage: python3 main.py [-h] [-a] [-i] [-q int] [-l int] [-d PATH] optional arguments: -h, --help show this help message and exit -a enable albums-only search - -i Album/track URL run Qobuz-Dl on URL input mode (download by url) + -i album/track/artist/label/playlist URL run qobuz-dl on URL input mode (download by url) -q int quality (5, 6, 7, 27) (default: 6) [320, LOSSLESS, 24B <96KHZ, 24B >96KHZ] -l int limit of search results by type (default: 10) -d PATH custom directory for downloads (default: 'Qobuz Downloads') ``` ## A note about Qo-DL -`Qobuz-DL` is inspired in the discontinued Qo-DL-Reborn. This program uses two modules from Qo-DL: `qopy` and `spoofer`, both written by Sorrow446 and DashLt. +`qobuz-dl` is inspired in the discontinued Qo-DL-Reborn. This program uses two modules from Qo-DL: `qopy` and `spoofer`, both written by Sorrow446 and DashLt. ## Disclaimer This tool was written for educational purposes. I will not be responsible if you use this program in bad faith. Also, you are accepting this: https://static.qobuz.com/apps/api/QobuzAPI-TermsofUse.pdf diff --git a/main.py b/main.py index 97ebc0c..f79b612 100644 --- a/main.py +++ b/main.py @@ -50,9 +50,8 @@ def musicDir(dir): def get_id(url): return re.match( - r"https?://(?:w{0,3}|play|open)\.qobuz\.com/(?:(?" - ":album|track)/|[a-z]{2}-[a-z]{2}/album/-?\w+(?:-\w+)" - "*-?/|user/library/favorites/)(\w+)", + r"https?://(?:w{0,3}|play|open)\.qobuz\.com/(?:(?:album|track|artist" + "|playlist|label)/|[a-z]{2}-[a-z]{2}/album/-?\w+(?:-\w+)*-?/|user/library/favorites/)(\w+)", url, ).group(1) @@ -67,11 +66,41 @@ def searchSelected(Qz, path, albums, ids, types, quality): ) -def fromUrl(Qz, path, link, quality): - id = get_id(link) - downloader.iterateIDs( - Qz, id, path, str(quality), False if "/track/" in link else True - ) +def fromUrl(Qz, id, path, quality, album=True): + downloader.iterateIDs(Qz, id, path, str(quality), album) + + +def handle_urls(url, client, path, quality): + possibles = { + "playlist": {"func": client.get_plist_meta, "iterable_key": "tracks"}, + "artist": {"func": client.get_artist_meta, "iterable_key": "albums"}, + "label": {"func": client.get_label_meta, "iterable_key": "albums"}, + "album": {"album": True, "func": None, "iterable_key": None}, + "track": {"album": False, "func": None, "iterable_key": None}, + } + try: + url_type = url.split("/")[3] + type_dict = possibles[url_type] + item_id = get_id(url) + print("Downloading {}...".format(url_type)) + except KeyError: + print("Invalid url. Use urls from https://play.qobuz.com!") + return + if type_dict["func"]: + items = [ + item[type_dict["iterable_key"]]["items"] + for item in type_dict["func"](item_id) + ][0] + for item in items: + fromUrl( + client, + item["id"], + path, + quality, + True if type_dict["iterable_key"] == "albums" else False, + ) + else: + fromUrl(client, item_id, path, quality, type_dict["album"]) def interactive(Qz, path, limit, tracks=True): @@ -120,7 +149,7 @@ def main(): if not arguments.i: interactive(Qz, directory, arguments.l, not arguments.a) else: - fromUrl(Qz, directory, arguments.i, arguments.q) + handle_urls(arguments.i, Qz, directory, arguments.q) if __name__ == "__main__": diff --git a/qo_utils/downloader.py b/qo_utils/downloader.py index ada43fb..327aba3 100644 --- a/qo_utils/downloader.py +++ b/qo_utils/downloader.py @@ -69,7 +69,11 @@ def iterateIDs(client, id, path, quality, album=False): getCover(meta["image"]["large"], dirn) for i in meta["tracks"]["items"]: parse = client.get_track_url(i["id"], quality) - url = parse["url"] + try: + url = parse["url"] + except KeyError: + print("Track is not available for download") + return if "sample" not in parse: is_mp3 = True if int(quality) == 5 else False downloadItem(dirn, count, parse, i, meta, url, False, is_mp3) diff --git a/qo_utils/qopy.py b/qo_utils/qopy.py index afe57df..8e8c0ef 100644 --- a/qo_utils/qopy.py +++ b/qo_utils/qopy.py @@ -8,8 +8,12 @@ import time import requests from qo_utils import spoofbuz -from qo_utils.exceptions import (AuthenticationError, IneligibleError, - InvalidAppIdError, InvalidAppSecretError) +from qo_utils.exceptions import ( + AuthenticationError, + IneligibleError, + InvalidAppIdError, + InvalidAppSecretError, +) class Client: @@ -43,6 +47,28 @@ class Client: params = {"query": kwargs["query"], "limit": kwargs["limit"]} elif epoint == "album/search?": params = {"query": kwargs["query"], "limit": kwargs["limit"]} + elif epoint == "playlist/get?": + params = { + "extra": "tracks", + "playlist_id": kwargs["id"], + "limit": 500, + "offset": kwargs["offset"], + } + elif epoint == "artist/get?": + params = { + "app_id": self.id, + "artist_id": kwargs["id"], + "limit": 500, + "offset": kwargs["offset"], + "extra": "albums", + } + elif epoint == "label/get?": + params = { + "label_id": kwargs["id"], + "limit": 500, + "offset": kwargs["offset"], + "extra": "albums", + } elif epoint == "userLibrary/getAlbumsList?": unix = time.time() r_sig = "userLibrarygetAlbumsList" + str(unix) + kwargs["sec"] @@ -92,6 +118,22 @@ class Client: self.label = usr_info["user"]["credential"]["parameters"]["short_label"] print("Membership: {}".format(self.label)) + def multi_meta(self, epoint, key, id, type): + total = 1 + offset = 0 + while total > 0: + if type in ["tracks", "albums"]: + j = self.api_call(epoint, id=id, offset=offset, type=type)[type] + else: + j = self.api_call(epoint, id=id, offset=offset, type=type) + if offset == 0: + yield j + total = j[key] - 500 + else: + yield j + total -= 500 + offset += 500 + def get_album_meta(self, id): return self.api_call("album/get?", id=id) @@ -101,6 +143,15 @@ class Client: def get_track_url(self, id, fmt_id): return self.api_call("track/getFileUrl?", id=id, fmt_id=fmt_id) + def get_artist_meta(self, id): + return self.multi_meta("artist/get?", "albums_count", id, None) + + def get_plist_meta(self, id): + return self.multi_meta("playlist/get?", "tracks_count", id, None) + + def get_label_meta(self, id): + return self.multi_meta("label/get?", "albums_count", id, None) + def search_albums(self, query, limit): return self.api_call("album/search?", query=query, limit=limit)