mirror of
https://github.com/Wojtek242/qobuz-dl.git
synced 2024-11-22 11:05:25 +01:00
Partial rewrite
* Fix #26 * Fix #25 * Fix #23 (new flag: --albums-only) * Fix #6 * Add support for last.fm playlists * Update README
This commit is contained in:
parent
5525a48a9b
commit
4179f3bc73
43
README.md
43
README.md
@ -1,14 +1,15 @@
|
|||||||
# qobuz-dl
|
# qobuz-dl
|
||||||
Seach and download Lossless and Hi-Res music from [Qobuz](https://www.qobuz.com/).
|
Search, discover and download Lossless and Hi-Res music from [Qobuz](https://www.qobuz.com/).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Download FLAC and MP3 files from Qobuz
|
* Download FLAC and MP3 files from Qobuz
|
||||||
* Search and download music directly from your terminal with **interactive** or **lucky** mode
|
* Explore and download music directly from your terminal with **interactive** or **lucky** mode
|
||||||
* Download albums, tracks, artists, playlists and labels with **download** mode
|
* Download albums, tracks, artists, playlists and labels with **download** mode
|
||||||
|
* Download music from last.fm playlists (Spotify, Apple Music and Youtube playlists are also supported through this method)
|
||||||
* Queue support on **interactive** mode
|
* Queue support on **interactive** mode
|
||||||
* Support for albums with multiple discs
|
* Support for albums with multiple discs
|
||||||
* Read URLs from text file
|
* Downloads URLs from text file
|
||||||
* And more
|
* And more
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
@ -56,10 +57,22 @@ Download albums from a label and also embed cover art images into the downloaded
|
|||||||
```
|
```
|
||||||
qobuz-dl dl https://play.qobuz.com/label/7526 --embed-art
|
qobuz-dl dl https://play.qobuz.com/label/7526 --embed-art
|
||||||
```
|
```
|
||||||
Download a playlist in maximum quality
|
Download a Qobuz playlist in maximum quality
|
||||||
```
|
```
|
||||||
qobuz-dl dl https://play.qobuz.com/playlist/5388296 -q 27
|
qobuz-dl dl https://play.qobuz.com/playlist/5388296 -q 27
|
||||||
```
|
```
|
||||||
|
Download all the music from an artist except singles, EPs and VA releases
|
||||||
|
```
|
||||||
|
qobuz-dl dl https://play.qobuz.com/artist/2528676 --albums-only
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Last.fm playlists
|
||||||
|
Last.fm has a new feature for creating playlists: you can create your own based on the music you listen or you can import one from popular streaming services like Spotify, Apple Music and Youtube. Visit: `https://www.last.fm/user/<your profile>/playlists` (e.g. https://www.last.fm/user/vitiko98/playlists) to get started.
|
||||||
|
|
||||||
|
Download a last.fm playlist in the maximum quality
|
||||||
|
```
|
||||||
|
qobuz-dl dl https://www.last.fm/user/vitiko98/playlists/11887574 -q 27
|
||||||
|
```
|
||||||
|
|
||||||
Run `qobuz-dl dl --help` for more info.
|
Run `qobuz-dl dl --help` for more info.
|
||||||
|
|
||||||
@ -124,7 +137,27 @@ commands:
|
|||||||
dl input mode
|
dl input mode
|
||||||
lucky lucky mode
|
lucky lucky mode
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Module usage
|
||||||
|
Using `qobuz-dl` as a module is really easy. Basically, the only thing you need is to initialize `QobuzDL` from `core`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qobuz_dl.core import QobuzDL
|
||||||
|
|
||||||
|
email = "your@email.com"
|
||||||
|
password = "your_password"
|
||||||
|
|
||||||
|
qobuz = QobuzDL()
|
||||||
|
qobuz.get_tokens() # get 'app_id' and 'secrets' attrs
|
||||||
|
qobuz.initialize_client(email, password, qobuz.app_id, qobuz.secrets)
|
||||||
|
|
||||||
|
qobuz.handle_url("https://play.qobuz.com/album/va4j3hdlwaubc")
|
||||||
|
```
|
||||||
|
|
||||||
|
Attributes, methods and parameters have been named as self-explanatory as possible.
|
||||||
|
|
||||||
## A note about Qo-DL
|
## 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
|
## Disclaimer
|
||||||
This tool was written for educational purposes. I will not be responsible if you use this program in bad faith. By using it, you are accepting the [Qobuz API Terms of Use](https://static.qobuz.com/apps/api/QobuzAPI-TermsofUse.pdf).
|
* This tool was written for educational purposes. I will not be responsible if you use this program in bad faith. By using it, you are accepting the [Qobuz API Terms of Use](https://static.qobuz.com/apps/api/QobuzAPI-TermsofUse.pdf).
|
||||||
|
* `qobuz-dl` is not affiliated with Qobuz
|
||||||
|
248
qobuz_dl/cli.py
248
qobuz_dl/cli.py
@ -1,16 +1,10 @@
|
|||||||
import argparse
|
|
||||||
import base64
|
import base64
|
||||||
import configparser
|
import configparser
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from pick import pick
|
|
||||||
from pathvalidate import sanitize_filename
|
|
||||||
|
|
||||||
import qobuz_dl.spoofbuz as spoofbuz
|
import qobuz_dl.spoofbuz as spoofbuz
|
||||||
from qobuz_dl import downloader, qopy
|
from qobuz_dl.core import QobuzDL
|
||||||
from qobuz_dl.search import Search
|
|
||||||
from qobuz_dl.commands import qobuz_dl_args
|
from qobuz_dl.commands import qobuz_dl_args
|
||||||
|
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
@ -21,8 +15,6 @@ else:
|
|||||||
CONFIG_PATH = os.path.join(OS_CONFIG, "qobuz-dl")
|
CONFIG_PATH = os.path.join(OS_CONFIG, "qobuz-dl")
|
||||||
CONFIG_FILE = os.path.join(CONFIG_PATH, "config.ini")
|
CONFIG_FILE = os.path.join(CONFIG_PATH, "config.ini")
|
||||||
|
|
||||||
QUALITIES = {5: "320", 6: "LOSSLESS", 7: "24B <96KHZ", 27: "24B <196KHZ"}
|
|
||||||
|
|
||||||
|
|
||||||
def reset_config(config_file):
|
def reset_config(config_file):
|
||||||
print("Creating config file: " + config_file)
|
print("Creating config file: " + config_file)
|
||||||
@ -43,7 +35,7 @@ def reset_config(config_file):
|
|||||||
)
|
)
|
||||||
or "6"
|
or "6"
|
||||||
)
|
)
|
||||||
config["DEFAULT"]["default_limit"] = "10"
|
config["DEFAULT"]["default_limit"] = "20"
|
||||||
print("Getting tokens. Please wait...")
|
print("Getting tokens. Please wait...")
|
||||||
spoofer = spoofbuz.Spoofer()
|
spoofer = spoofbuz.Spoofer()
|
||||||
config["DEFAULT"]["app_id"] = str(spoofer.getAppId())
|
config["DEFAULT"]["app_id"] = str(spoofer.getAppId())
|
||||||
@ -53,193 +45,9 @@ def reset_config(config_file):
|
|||||||
print("Config file updated.")
|
print("Config file updated.")
|
||||||
|
|
||||||
|
|
||||||
def musicDir(directory):
|
|
||||||
fix = os.path.normpath(directory)
|
|
||||||
if not os.path.isdir(fix):
|
|
||||||
print("New directory created: " + fix)
|
|
||||||
os.makedirs(fix, exist_ok=True)
|
|
||||||
return fix
|
|
||||||
|
|
||||||
|
|
||||||
def get_id(url):
|
|
||||||
return re.match(
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def processSelected(Qz, path, albums, ids, types, quality, embed_art=False):
|
|
||||||
quality = [i for i in QUALITIES.keys()][quality[1]]
|
|
||||||
for alb, id_, type_ in zip(albums, ids, types):
|
|
||||||
for al in alb:
|
|
||||||
downloader.download_id_by_type(
|
|
||||||
Qz,
|
|
||||||
id_[al[1]],
|
|
||||||
path,
|
|
||||||
quality,
|
|
||||||
True if type_[al[1]] else False,
|
|
||||||
embed_art,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def fromUrl(Qz, id, path, quality, album=True, embed_art=False):
|
|
||||||
downloader.download_id_by_type(Qz, id, path, str(quality), album, embed_art)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_urls(url, client, path, quality, embed_art=False):
|
|
||||||
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)
|
|
||||||
except (KeyError, IndexError):
|
|
||||||
print('Invalid url: "{}". Use urls from https://play.qobuz.com!'.format(url))
|
|
||||||
return
|
|
||||||
if type_dict["func"]:
|
|
||||||
content = [item for item in type_dict["func"](item_id)]
|
|
||||||
content_name = content[0]["name"]
|
|
||||||
print(
|
|
||||||
"\nDownloading all the music from {} ({})!".format(content_name, url_type)
|
|
||||||
)
|
|
||||||
new_path = musicDir(os.path.join(path, sanitize_filename(content_name)))
|
|
||||||
items = [item[type_dict["iterable_key"]]["items"] for item in content][0]
|
|
||||||
for item in items:
|
|
||||||
fromUrl(
|
|
||||||
client,
|
|
||||||
item["id"],
|
|
||||||
new_path,
|
|
||||||
quality,
|
|
||||||
True if type_dict["iterable_key"] == "albums" else False,
|
|
||||||
embed_art,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
fromUrl(client, item_id, path, quality, type_dict["album"], embed_art)
|
|
||||||
|
|
||||||
|
|
||||||
def interactive(Qz, path, limit, tracks=True, embed_art=False):
|
|
||||||
while True:
|
|
||||||
Albums, Types, IDs = [], [], []
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
query = input("\nEnter your search: [Ctrl + c to quit]\n- ")
|
|
||||||
print("Searching...")
|
|
||||||
if len(query.strip()) == 0:
|
|
||||||
break
|
|
||||||
start = Search(Qz, query, limit)
|
|
||||||
start.getResults(tracks)
|
|
||||||
if len(start.Total) == 0:
|
|
||||||
break
|
|
||||||
Types.append(start.Types)
|
|
||||||
IDs.append(start.IDs)
|
|
||||||
|
|
||||||
title = (
|
|
||||||
"Select [space] the item(s) you want to download "
|
|
||||||
"(zero or more)\nPress Ctrl + c to quit\n"
|
|
||||||
)
|
|
||||||
Selected = pick(
|
|
||||||
start.Total, title, multiselect=True, min_selection_count=0
|
|
||||||
)
|
|
||||||
if len(Selected) > 0:
|
|
||||||
Albums.append(Selected)
|
|
||||||
|
|
||||||
y_n = pick(
|
|
||||||
["Yes", "No"],
|
|
||||||
"Items were added to queue to be downloaded. Keep searching?",
|
|
||||||
)
|
|
||||||
if y_n[0][0] == "N":
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
if len(Albums) > 0:
|
|
||||||
desc = (
|
|
||||||
"Select [intro] the quality (the quality will be automat"
|
|
||||||
"ically\ndowngraded if the selected is not found)"
|
|
||||||
)
|
|
||||||
Qualits = ["320", "Lossless", "Hi-res =< 96kHz", "Hi-Res > 96 kHz"]
|
|
||||||
quality = pick(Qualits, desc, default_index=1)
|
|
||||||
processSelected(Qz, path, Albums, IDs, Types, quality, embed_art)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
sys.exit("\nBye")
|
|
||||||
|
|
||||||
|
|
||||||
def download_by_txt_file(Qz, txt_file, path, quality, embed_art=False):
|
|
||||||
with open(txt_file, "r") as txt:
|
|
||||||
try:
|
|
||||||
urls = txt.read().strip().split()
|
|
||||||
except Exception as e:
|
|
||||||
print("Invalid text file: " + str(e))
|
|
||||||
return
|
|
||||||
print(
|
|
||||||
'qobuz-dl will download {} urls from file: "{}"\n'.format(
|
|
||||||
len(urls), txt_file
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for url in urls:
|
|
||||||
handle_urls(url, Qz, path, quality, embed_art)
|
|
||||||
|
|
||||||
|
|
||||||
def download_lucky_mode(Qz, mode, query, limit, path, quality, embed_art=False):
|
|
||||||
if len(query) < 3:
|
|
||||||
sys.exit("Your search query is too short or invalid!")
|
|
||||||
|
|
||||||
print(
|
|
||||||
'Searching {}s for "{}".\n'
|
|
||||||
"qobuz-dl will attempt to download the first {} results.".format(
|
|
||||||
mode, query, limit
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
WEB_URL = "https://play.qobuz.com/"
|
|
||||||
possibles = {
|
|
||||||
"album": {
|
|
||||||
"func": Qz.search_albums,
|
|
||||||
"album": True,
|
|
||||||
"key": "albums",
|
|
||||||
},
|
|
||||||
"artist": {
|
|
||||||
"func": Qz.search_artists,
|
|
||||||
"album": True,
|
|
||||||
"key": "artists",
|
|
||||||
},
|
|
||||||
"track": {
|
|
||||||
"func": Qz.search_tracks,
|
|
||||||
"album": False,
|
|
||||||
"key": "tracks",
|
|
||||||
},
|
|
||||||
"playlist": {
|
|
||||||
"func": Qz.search_playlists,
|
|
||||||
"album": False,
|
|
||||||
"key": "playlists",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
mode_dict = possibles[mode]
|
|
||||||
results = mode_dict["func"](query, limit)
|
|
||||||
iterable = results[mode_dict["key"]]["items"]
|
|
||||||
# Use handle_urls as everything is already handled there :p
|
|
||||||
urls = ["{}{}/{}".format(WEB_URL, mode, i["id"]) for i in iterable]
|
|
||||||
print("Found {} results!".format(len(urls)))
|
|
||||||
for url in urls:
|
|
||||||
handle_urls(url, Qz, path, quality, embed_art)
|
|
||||||
except (KeyError, IndexError):
|
|
||||||
sys.exit("Invalid mode: " + str(mode))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if not os.path.isdir(CONFIG_PATH) or not os.path.isfile(CONFIG_FILE):
|
if not os.path.isdir(CONFIG_PATH) or not os.path.isfile(CONFIG_FILE):
|
||||||
try:
|
os.makedirs(CONFIG_PATH, exist_ok=True)
|
||||||
os.makedirs(CONFIG_PATH, exist_ok=True)
|
|
||||||
except FileExistsError:
|
|
||||||
pass
|
|
||||||
reset_config(CONFIG_FILE)
|
reset_config(CONFIG_FILE)
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
@ -273,44 +81,24 @@ def main():
|
|||||||
if arguments.reset:
|
if arguments.reset:
|
||||||
sys.exit(reset_config(CONFIG_FILE))
|
sys.exit(reset_config(CONFIG_FILE))
|
||||||
|
|
||||||
directory = musicDir(arguments.directory)
|
qobuz = QobuzDL(
|
||||||
|
arguments.directory,
|
||||||
|
arguments.quality,
|
||||||
|
arguments.embed_art,
|
||||||
|
ignore_singles_eps=arguments.albums_only,
|
||||||
|
)
|
||||||
|
qobuz.initialize_client(email, password, app_id, secrets)
|
||||||
|
|
||||||
Qz = qopy.Client(email, password, app_id, secrets)
|
|
||||||
|
|
||||||
try:
|
|
||||||
quality_str = QUALITIES[int(arguments.quality)]
|
|
||||||
print("Quality set: " + quality_str)
|
|
||||||
except KeyError:
|
|
||||||
sys.exit("Invalid quality!")
|
|
||||||
|
|
||||||
if arguments.command == "fun":
|
|
||||||
sys.exit(
|
|
||||||
interactive(
|
|
||||||
Qz,
|
|
||||||
directory,
|
|
||||||
arguments.limit,
|
|
||||||
not arguments.albums_only,
|
|
||||||
arguments.embed_art,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if arguments.command == "dl":
|
if arguments.command == "dl":
|
||||||
for url in arguments.SOURCE:
|
qobuz.download_list_of_urls(arguments.SOURCE)
|
||||||
if os.path.isfile(url):
|
elif arguments.command == "lucky":
|
||||||
download_by_txt_file(
|
query = " ".join(arguments.QUERY)
|
||||||
Qz, url, directory, arguments.quality, arguments.embed_art
|
qobuz.lucky_type = arguments.type
|
||||||
)
|
qobuz.lucky_limit = arguments.number
|
||||||
else:
|
qobuz.lucky_mode(query)
|
||||||
handle_urls(url, Qz, directory, arguments.quality, arguments.embed_art)
|
|
||||||
else:
|
else:
|
||||||
download_lucky_mode(
|
qobuz.interactive_limit = arguments.limit
|
||||||
Qz,
|
qobuz.interactive()
|
||||||
arguments.type,
|
|
||||||
" ".join(arguments.QUERY),
|
|
||||||
arguments.number,
|
|
||||||
directory,
|
|
||||||
arguments.quality,
|
|
||||||
arguments.embed_art,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
@ -7,18 +7,12 @@ def fun_args(subparsers, default_limit):
|
|||||||
description="Interactively search for tracks and albums.",
|
description="Interactively search for tracks and albums.",
|
||||||
help="interactive mode",
|
help="interactive mode",
|
||||||
)
|
)
|
||||||
interactive.add_argument(
|
|
||||||
"-a",
|
|
||||||
"--albums-only",
|
|
||||||
action="store_true",
|
|
||||||
help="enable albums-only search",
|
|
||||||
)
|
|
||||||
interactive.add_argument(
|
interactive.add_argument(
|
||||||
"-l",
|
"-l",
|
||||||
"--limit",
|
"--limit",
|
||||||
metavar="int",
|
metavar="int",
|
||||||
default=default_limit,
|
default=default_limit,
|
||||||
help="limit of search results by type (default: 10)",
|
help="limit of search results (default: 20)",
|
||||||
)
|
)
|
||||||
return interactive
|
return interactive
|
||||||
|
|
||||||
@ -49,7 +43,7 @@ def lucky_args(subparsers):
|
|||||||
def dl_args(subparsers):
|
def dl_args(subparsers):
|
||||||
download = subparsers.add_parser(
|
download = subparsers.add_parser(
|
||||||
"dl",
|
"dl",
|
||||||
description="Download by album/track/artist/label/playlist URL.",
|
description="Download by album/track/artist/label/playlist/last.fm-playlist URL.",
|
||||||
help="input mode",
|
help="input mode",
|
||||||
)
|
)
|
||||||
download.add_argument(
|
download.add_argument(
|
||||||
@ -82,10 +76,15 @@ def add_common_arg(custom_parser, default_folder, default_quality):
|
|||||||
"[320, LOSSLESS, 24B <96KHZ, 24B >96KHZ] (default: 6)"
|
"[320, LOSSLESS, 24B <96KHZ, 24B >96KHZ] (default: 6)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
custom_parser.add_argument(
|
||||||
|
"--albums-only",
|
||||||
|
action="store_true",
|
||||||
|
help=("don't download singles, EPs and VA releases"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def qobuz_dl_args(
|
def qobuz_dl_args(
|
||||||
default_quality=6, default_limit=10, default_folder="Qobuz Downloads"
|
default_quality=6, default_limit=20, default_folder="Qobuz Downloads"
|
||||||
):
|
):
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="qobuz-dl",
|
prog="qobuz-dl",
|
||||||
|
350
qobuz_dl/core.py
Normal file
350
qobuz_dl/core.py
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import string
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup as bso
|
||||||
|
from pathvalidate import sanitize_filename
|
||||||
|
|
||||||
|
import qobuz_dl.spoofbuz as spoofbuz
|
||||||
|
from qobuz_dl import downloader, qopy
|
||||||
|
|
||||||
|
WEB_URL = "https://play.qobuz.com/"
|
||||||
|
ARTISTS_SELECTOR = "td.chartlist-artist > a"
|
||||||
|
TITLE_SELECTOR = "td.chartlist-name > a"
|
||||||
|
|
||||||
|
|
||||||
|
class PartialFormatter(string.Formatter):
|
||||||
|
def __init__(self, missing="n/a", bad_fmt="n/a"):
|
||||||
|
self.missing, self.bad_fmt = missing, bad_fmt
|
||||||
|
|
||||||
|
def get_field(self, field_name, args, kwargs):
|
||||||
|
try:
|
||||||
|
val = super(PartialFormatter, self).get_field(field_name, args, kwargs)
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
val = None, field_name
|
||||||
|
return val
|
||||||
|
|
||||||
|
def format_field(self, value, spec):
|
||||||
|
if not value:
|
||||||
|
return self.missing
|
||||||
|
try:
|
||||||
|
return super(PartialFormatter, self).format_field(value, spec)
|
||||||
|
except ValueError:
|
||||||
|
if self.bad_fmt:
|
||||||
|
return self.bad_fmt
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class QobuzDL:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
directory="Qobuz Downloads",
|
||||||
|
quality=6,
|
||||||
|
embed_art=False,
|
||||||
|
lucky_limit=1,
|
||||||
|
lucky_type="album",
|
||||||
|
interactive_limit=20,
|
||||||
|
ignore_singles_eps=False,
|
||||||
|
):
|
||||||
|
self.directory = self.create_dir(directory)
|
||||||
|
self.quality = quality
|
||||||
|
self.embed_art = embed_art
|
||||||
|
self.lucky_limit = lucky_limit
|
||||||
|
self.lucky_type = lucky_type
|
||||||
|
self.interactive_limit = interactive_limit
|
||||||
|
self.ignore_singles_eps = ignore_singles_eps
|
||||||
|
|
||||||
|
def initialize_client(self, email, pwd, app_id, secrets):
|
||||||
|
self.client = qopy.Client(email, pwd, app_id, secrets)
|
||||||
|
|
||||||
|
def get_tokens(self):
|
||||||
|
spoofer = spoofbuz.Spoofer()
|
||||||
|
self.app_id = spoofer.getAppId()
|
||||||
|
self.secrets = [
|
||||||
|
secret for secret in spoofer.getSecrets().values() if secret
|
||||||
|
] # avoid empty fields
|
||||||
|
|
||||||
|
def create_dir(self, directory=None):
|
||||||
|
fix = os.path.normpath(directory)
|
||||||
|
os.makedirs(fix, exist_ok=True)
|
||||||
|
return fix
|
||||||
|
|
||||||
|
def get_id(self, url):
|
||||||
|
return re.match(
|
||||||
|
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)
|
||||||
|
|
||||||
|
def download_from_id(self, item_id, album=True, alt_path=None):
|
||||||
|
downloader.download_id_by_type(
|
||||||
|
self.client,
|
||||||
|
item_id,
|
||||||
|
self.directory if not alt_path else alt_path,
|
||||||
|
str(self.quality),
|
||||||
|
album,
|
||||||
|
self.embed_art,
|
||||||
|
self.ignore_singles_eps,
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle_url(self, url):
|
||||||
|
possibles = {
|
||||||
|
"playlist": {
|
||||||
|
"func": self.client.get_plist_meta,
|
||||||
|
"iterable_key": "tracks",
|
||||||
|
},
|
||||||
|
"artist": {
|
||||||
|
"func": self.client.get_artist_meta,
|
||||||
|
"iterable_key": "albums",
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"func": self.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 = self.get_id(url)
|
||||||
|
except (KeyError, IndexError):
|
||||||
|
print(
|
||||||
|
'Invalid url: "{}". Use urls from https://play.qobuz.com!'.format(url)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if type_dict["func"]:
|
||||||
|
content = [item for item in type_dict["func"](item_id)]
|
||||||
|
content_name = content[0]["name"]
|
||||||
|
print(
|
||||||
|
"\nDownloading all the music from {} ({})!".format(
|
||||||
|
content_name, url_type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
new_path = self.create_dir(
|
||||||
|
os.path.join(self.directory, sanitize_filename(content_name))
|
||||||
|
)
|
||||||
|
items = [item[type_dict["iterable_key"]]["items"] for item in content][0]
|
||||||
|
print("{} downloads in queue".format(len(items)))
|
||||||
|
for item in items:
|
||||||
|
self.download_from_id(
|
||||||
|
item["id"],
|
||||||
|
True if type_dict["iterable_key"] == "albums" else False,
|
||||||
|
new_path,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.download_from_id(item_id, type_dict["album"])
|
||||||
|
|
||||||
|
def download_list_of_urls(self, urls):
|
||||||
|
if not urls or not isinstance(urls, list):
|
||||||
|
print("Nothing to download")
|
||||||
|
return
|
||||||
|
for url in urls:
|
||||||
|
if "last.fm" in url:
|
||||||
|
self.download_lastfm_pl(url)
|
||||||
|
else:
|
||||||
|
self.handle_url(url)
|
||||||
|
|
||||||
|
def download_from_txt_file(self, txt_file):
|
||||||
|
with open(txt_file, "r") as txt:
|
||||||
|
try:
|
||||||
|
urls = txt.read().strip().split()
|
||||||
|
except Exception as e:
|
||||||
|
print("Invalid text file: " + str(e))
|
||||||
|
return
|
||||||
|
print(
|
||||||
|
'qobuz-dl will download {} urls from file: "{}"\n'.format(
|
||||||
|
len(urls), txt_file
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.download_list_of_urls(urls)
|
||||||
|
|
||||||
|
def lucky_mode(self, query, download=True):
|
||||||
|
if len(query) < 3:
|
||||||
|
sys.exit("Your search query is too short or invalid!")
|
||||||
|
|
||||||
|
print(
|
||||||
|
'Searching {}s for "{}".\n'
|
||||||
|
"qobuz-dl will attempt to download the first {} results.".format(
|
||||||
|
self.lucky_type, query, self.lucky_limit
|
||||||
|
)
|
||||||
|
)
|
||||||
|
results = self.search_by_type(query, self.lucky_type, self.lucky_limit, True)
|
||||||
|
|
||||||
|
if download:
|
||||||
|
self.download_list_of_urls(results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def format_duration(self, duration):
|
||||||
|
return time.strftime("%H:%M:%S", time.gmtime(duration))
|
||||||
|
|
||||||
|
def search_by_type(self, query, item_type, limit=10, lucky=False):
|
||||||
|
if len(query) < 3:
|
||||||
|
print("Your search query is too short or invalid!")
|
||||||
|
return
|
||||||
|
|
||||||
|
possibles = {
|
||||||
|
"album": {
|
||||||
|
"func": self.client.search_albums,
|
||||||
|
"album": True,
|
||||||
|
"key": "albums",
|
||||||
|
"format": "{artist[name]} - {title}",
|
||||||
|
"requires_extra": True,
|
||||||
|
},
|
||||||
|
"artist": {
|
||||||
|
"func": self.client.search_artists,
|
||||||
|
"album": True,
|
||||||
|
"key": "artists",
|
||||||
|
"format": "{name} - ({albums_count} releases)",
|
||||||
|
"requires_extra": False,
|
||||||
|
},
|
||||||
|
"track": {
|
||||||
|
"func": self.client.search_tracks,
|
||||||
|
"album": False,
|
||||||
|
"key": "tracks",
|
||||||
|
"format": "{performer[name]} - {title}",
|
||||||
|
"requires_extra": True,
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"func": self.client.search_playlists,
|
||||||
|
"album": False,
|
||||||
|
"key": "playlists",
|
||||||
|
"format": "{name} - ({tracks_count} releases)",
|
||||||
|
"requires_extra": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
mode_dict = possibles[item_type]
|
||||||
|
results = mode_dict["func"](query, limit)
|
||||||
|
iterable = results[mode_dict["key"]]["items"]
|
||||||
|
item_list = []
|
||||||
|
for i in iterable:
|
||||||
|
fmt = PartialFormatter()
|
||||||
|
text = fmt.format(mode_dict["format"], **i)
|
||||||
|
if mode_dict["requires_extra"]:
|
||||||
|
|
||||||
|
text = "{} - {} [{}]".format(
|
||||||
|
text,
|
||||||
|
self.format_duration(i["duration"]),
|
||||||
|
"HI-RES" if i["hires_streamable"] else "LOSSLESS",
|
||||||
|
)
|
||||||
|
|
||||||
|
url = "{}{}/{}".format(WEB_URL, item_type, i.get("id", ""))
|
||||||
|
item_list.append({"text": text, "url": url} if not lucky else url)
|
||||||
|
return item_list
|
||||||
|
except (KeyError, IndexError):
|
||||||
|
print("Invalid mode: " + item_type)
|
||||||
|
return
|
||||||
|
|
||||||
|
def interactive(self, download=True):
|
||||||
|
try:
|
||||||
|
from pick import pick
|
||||||
|
except (ImportError, ModuleNotFoundError):
|
||||||
|
if os.name == "nt":
|
||||||
|
print('Please install curses with "pip3 install windows-curses"')
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
|
qualities = [
|
||||||
|
{"q_string": "320", "q": 5},
|
||||||
|
{"q_string": "Lossless", "q": 6},
|
||||||
|
{"q_string": "Hi-res =< 96kHz", "q": 7},
|
||||||
|
{"q_string": "Hi-Res > 96 kHz", "q": 27},
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_title_text(option):
|
||||||
|
return option.get("text")
|
||||||
|
|
||||||
|
def get_quality_text(option):
|
||||||
|
return option.get("q_string")
|
||||||
|
|
||||||
|
try:
|
||||||
|
item_types = ["Albums", "Tracks", "Artists", "Playlists"]
|
||||||
|
selected_type = pick(item_types, "I'll search for:\n[press Intro]")[0][
|
||||||
|
:-1
|
||||||
|
].lower()
|
||||||
|
print("Ok, we'll search for " + selected_type + "s")
|
||||||
|
final_url_list = []
|
||||||
|
while True:
|
||||||
|
query = input("\nEnter your search: [Ctrl + c to quit]\n- ")
|
||||||
|
print("Searching...")
|
||||||
|
options = self.search_by_type(
|
||||||
|
query, selected_type, self.interactive_limit
|
||||||
|
)
|
||||||
|
if not options:
|
||||||
|
print("Nothing found!")
|
||||||
|
continue
|
||||||
|
title = (
|
||||||
|
'*** RESULTS FOR "{}" ***\n\n'
|
||||||
|
"Select [space] the item(s) you want to download "
|
||||||
|
"(one or more)\nPress Ctrl + c to quit\n"
|
||||||
|
"Don't select anything to try another search".format(query.title())
|
||||||
|
)
|
||||||
|
selected_items = pick(
|
||||||
|
options,
|
||||||
|
title,
|
||||||
|
multiselect=True,
|
||||||
|
min_selection_count=0,
|
||||||
|
options_map_func=get_title_text,
|
||||||
|
)
|
||||||
|
if len(selected_items) > 0:
|
||||||
|
[final_url_list.append(i[0]["url"]) for i in selected_items]
|
||||||
|
y_n = pick(
|
||||||
|
["Yes", "No"],
|
||||||
|
"Items were added to queue to be downloaded. Keep searching?",
|
||||||
|
)
|
||||||
|
if y_n[0][0] == "N":
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print("\nOk, try again...")
|
||||||
|
continue
|
||||||
|
if final_url_list:
|
||||||
|
desc = (
|
||||||
|
"Select [intro] the quality (the quality will be automat"
|
||||||
|
"ically\ndowngraded if the selected is not found)"
|
||||||
|
)
|
||||||
|
self.quality = pick(
|
||||||
|
qualities,
|
||||||
|
desc,
|
||||||
|
default_index=1,
|
||||||
|
options_map_func=get_quality_text,
|
||||||
|
)[0]["q"]
|
||||||
|
|
||||||
|
if download:
|
||||||
|
self.download_list_of_urls(final_url_list)
|
||||||
|
|
||||||
|
return final_url_list
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nBye")
|
||||||
|
return
|
||||||
|
|
||||||
|
def download_lastfm_pl(self, playlist_url):
|
||||||
|
# Apparently, last fm API doesn't have a playlist endpoint. If you
|
||||||
|
# find out that it has, please fix this!
|
||||||
|
r = requests.get(playlist_url)
|
||||||
|
soup = bso(r.content, "html.parser")
|
||||||
|
artists = [artist.text for artist in soup.select(ARTISTS_SELECTOR)]
|
||||||
|
titles = [title.text for title in soup.select(TITLE_SELECTOR)]
|
||||||
|
|
||||||
|
if len(artists) == len(titles) and artists:
|
||||||
|
track_list = [
|
||||||
|
artist + " " + title for artist, title in zip(artists, titles)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not track_list:
|
||||||
|
print("Nothing found")
|
||||||
|
return
|
||||||
|
|
||||||
|
pl_title = sanitize_filename(soup.select_one("h1").text)
|
||||||
|
print("Downloading playlist: " + pl_title)
|
||||||
|
self.directory = os.path.join(self.directory, pl_title)
|
||||||
|
for i in track_list:
|
||||||
|
track_url = self.search_by_type(i, "track", 1, lucky=True)[0]
|
||||||
|
if track_url:
|
||||||
|
self.handle_url(track_url)
|
@ -79,7 +79,11 @@ def get_title(item_dict):
|
|||||||
|
|
||||||
|
|
||||||
def get_extra(i, dirn, extra="cover.jpg"):
|
def get_extra(i, dirn, extra="cover.jpg"):
|
||||||
tqdm_download(i, os.path.join(dirn, extra), "Downloading " + extra.split(".")[0])
|
tqdm_download(
|
||||||
|
i.replace("_600.", "_org."),
|
||||||
|
os.path.join(dirn, extra),
|
||||||
|
"Downloading " + extra.split(".")[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Download and tag a file
|
# Download and tag a file
|
||||||
@ -149,7 +153,9 @@ def download_and_tag(
|
|||||||
os.remove(filename)
|
os.remove(filename)
|
||||||
|
|
||||||
|
|
||||||
def download_id_by_type(client, item_id, path, quality, album=False, embed_art=False):
|
def download_id_by_type(
|
||||||
|
client, item_id, path, quality, album=False, embed_art=False, albums_only=False
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Download and get metadata by ID and type (album or track)
|
Download and get metadata by ID and type (album or track)
|
||||||
|
|
||||||
@ -157,12 +163,22 @@ def download_id_by_type(client, item_id, path, quality, album=False, embed_art=F
|
|||||||
:param int item_id: Qobuz item id
|
:param int item_id: Qobuz item id
|
||||||
:param str path: The root directory where the item will be downloaded
|
:param str path: The root directory where the item will be downloaded
|
||||||
:param int quality: Audio quality (5, 6, 7, 27)
|
:param int quality: Audio quality (5, 6, 7, 27)
|
||||||
:param bool album
|
:param bool album: album type or not
|
||||||
|
:param embed_art album: Embed cover art into files
|
||||||
|
:param bool albums_only: Ignore Singles, EPs and VA releases
|
||||||
"""
|
"""
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
if album:
|
if album:
|
||||||
meta = client.get_album_meta(item_id)
|
meta = client.get_album_meta(item_id)
|
||||||
|
|
||||||
|
if albums_only and (
|
||||||
|
meta.get("release_type") != "album"
|
||||||
|
or meta.get("artist").get("name") == "Various Artists"
|
||||||
|
):
|
||||||
|
print("Ignoring Single/EP/VA: " + meta.get("title", ""))
|
||||||
|
return
|
||||||
|
|
||||||
album_title = get_title(meta)
|
album_title = get_title(meta)
|
||||||
print("\nDownloading: {}\n".format(album_title))
|
print("\nDownloading: {}\n".format(album_title))
|
||||||
dirT = (
|
dirT = (
|
||||||
|
@ -4,6 +4,23 @@ from mutagen.flac import FLAC, Picture
|
|||||||
from mutagen.mp3 import EasyMP3
|
from mutagen.mp3 import EasyMP3
|
||||||
|
|
||||||
|
|
||||||
|
def get_title(track_dict):
|
||||||
|
try:
|
||||||
|
title = (
|
||||||
|
("{} ({})".format(track_dict["title"], track_dict["version"]))
|
||||||
|
if track_dict["version"]
|
||||||
|
else track_dict["title"]
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
title = track_dict["title"]
|
||||||
|
|
||||||
|
# for classical works
|
||||||
|
if track_dict.get("work"):
|
||||||
|
title = "{}: {}".format(track_dict["work"], title)
|
||||||
|
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
def tag_flac(filename, root_dir, final_name, d, album, istrack=True, em_image=False):
|
def tag_flac(filename, root_dir, final_name, d, album, istrack=True, em_image=False):
|
||||||
"""
|
"""
|
||||||
Tag a FLAC file
|
Tag a FLAC file
|
||||||
@ -18,19 +35,10 @@ def tag_flac(filename, root_dir, final_name, d, album, istrack=True, em_image=Fa
|
|||||||
"""
|
"""
|
||||||
audio = FLAC(filename)
|
audio = FLAC(filename)
|
||||||
|
|
||||||
try:
|
audio["TITLE"] = get_title(d)
|
||||||
audio["TITLE"] = "{} ({})".format(d["title"], d["version"])
|
|
||||||
except KeyError:
|
|
||||||
audio["TITLE"] = d["title"]
|
|
||||||
|
|
||||||
audio["TRACKNUMBER"] = str(d["track_number"]) # TRACK NUMBER
|
audio["TRACKNUMBER"] = str(d["track_number"]) # TRACK NUMBER
|
||||||
|
|
||||||
try:
|
|
||||||
if d["work"]: # not none
|
|
||||||
audio["WORK"] = d["work"]
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
audio["COMPOSER"] = d["composer"]["name"] # COMPOSER
|
audio["COMPOSER"] = d["composer"]["name"] # COMPOSER
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@ -88,18 +96,9 @@ def tag_mp3(filename, root_dir, final_name, d, album, istrack=True, em_image=Fal
|
|||||||
# TODO: add embedded cover art support for mp3
|
# TODO: add embedded cover art support for mp3
|
||||||
audio = EasyMP3(filename)
|
audio = EasyMP3(filename)
|
||||||
|
|
||||||
try:
|
audio["title"] = get_title(d)
|
||||||
audio["title"] = "{} ({})".format(d["title"], d["version"])
|
|
||||||
except KeyError:
|
|
||||||
audio["title"] = d["title"]
|
|
||||||
|
|
||||||
audio["tracknumber"] = str(d["track_number"])
|
audio["tracknumber"] = str(d["track_number"])
|
||||||
|
|
||||||
try:
|
|
||||||
if d["work"]: # not none
|
|
||||||
audio["discsubtitle"] = d["work"]
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
pass
|
|
||||||
try:
|
try:
|
||||||
audio["composer"] = d["composer"]["name"]
|
audio["composer"] = d["composer"]["name"]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@ -118,7 +117,7 @@ def tag_mp3(filename, root_dir, final_name, d, album, istrack=True, em_image=Fal
|
|||||||
audio["album"] = d["album"]["title"] # ALBUM TITLE
|
audio["album"] = d["album"]["title"] # ALBUM TITLE
|
||||||
audio["date"] = d["album"]["release_date_original"].split("-")[0]
|
audio["date"] = d["album"]["release_date_original"].split("-")[0]
|
||||||
else:
|
else:
|
||||||
audio["GENRE"] = ", ".join(album["genres_list"]) # GENRE
|
audio["genre"] = ", ".join(album["genres_list"]) # GENRE
|
||||||
audio["albumartist"] = album["artist"]["name"] # ALBUM ARTIST
|
audio["albumartist"] = album["artist"]["name"] # ALBUM ARTIST
|
||||||
audio["album"] = album["title"] # ALBUM TITLE
|
audio["album"] = album["title"] # ALBUM TITLE
|
||||||
audio["date"] = album["release_date_original"].split("-")[0] # YEAR
|
audio["date"] = album["release_date_original"].split("-")[0] # YEAR
|
||||||
|
@ -1,49 +0,0 @@
|
|||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
class Search:
|
|
||||||
def __init__(self, Qz, query, limit=10):
|
|
||||||
self.Total = []
|
|
||||||
self.IDs = []
|
|
||||||
self.Types = []
|
|
||||||
self.Tracks = Qz.search_tracks(query, limit)["tracks"]["items"]
|
|
||||||
self.Albums = Qz.search_albums(query, limit)["albums"]["items"]
|
|
||||||
self.Artists = Qz.search_artists(query, limit)
|
|
||||||
|
|
||||||
def seconds(self, duration):
|
|
||||||
return time.strftime("%H:%M:%S", time.gmtime(duration))
|
|
||||||
|
|
||||||
def appendInfo(self, i, bool):
|
|
||||||
self.IDs.append(i["id"])
|
|
||||||
self.Types.append(bool)
|
|
||||||
|
|
||||||
def itResults(self, iterable):
|
|
||||||
for i in iterable:
|
|
||||||
try:
|
|
||||||
items = (
|
|
||||||
i["artist"]["name"],
|
|
||||||
i["title"],
|
|
||||||
self.seconds(i["duration"]),
|
|
||||||
"HI-RES" if i["hires"] else "Lossless",
|
|
||||||
)
|
|
||||||
self.Total.append("[RELEASE] {} - {} - {} [{}]".format(*items))
|
|
||||||
self.appendInfo(i, True)
|
|
||||||
except KeyError:
|
|
||||||
try:
|
|
||||||
artist_field = i["performer"]["name"]
|
|
||||||
except KeyError:
|
|
||||||
print("Download: " + i["title"])
|
|
||||||
artist_field = i["composer"]["name"]
|
|
||||||
items = (
|
|
||||||
artist_field,
|
|
||||||
i["title"],
|
|
||||||
self.seconds(i["duration"]),
|
|
||||||
"HI-RES" if i["hires"] else "Lossless",
|
|
||||||
)
|
|
||||||
self.Total.append("[TRACK] {} - {} - {} [{}]".format(*items))
|
|
||||||
self.appendInfo(i, False)
|
|
||||||
|
|
||||||
def getResults(self, tracks=False):
|
|
||||||
self.itResults(self.Albums)
|
|
||||||
if tracks:
|
|
||||||
self.itResults(self.Tracks)
|
|
9
setup.py
9
setup.py
@ -1,6 +1,4 @@
|
|||||||
from setuptools import setup, find_packages
|
from setuptools import setup, find_packages
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
pkg_name = "qobuz-dl"
|
pkg_name = "qobuz-dl"
|
||||||
|
|
||||||
@ -11,12 +9,11 @@ def read_file(fname):
|
|||||||
|
|
||||||
|
|
||||||
requirements = read_file("requirements.txt").strip().split()
|
requirements = read_file("requirements.txt").strip().split()
|
||||||
if os.name == "nt" or "win" in sys.platform:
|
|
||||||
requirements.append("windows-curses")
|
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=pkg_name,
|
name=pkg_name,
|
||||||
version="0.5.4.2",
|
version="0.5.0",
|
||||||
author="Vitiko",
|
author="Vitiko",
|
||||||
author_email="vhnz98@gmail.com",
|
author_email="vhnz98@gmail.com",
|
||||||
description="The complete Lossless and Hi-Res music downloader for Qobuz",
|
description="The complete Lossless and Hi-Res music downloader for Qobuz",
|
||||||
@ -39,6 +36,6 @@ setup(
|
|||||||
python_requires=">=3.6",
|
python_requires=">=3.6",
|
||||||
)
|
)
|
||||||
|
|
||||||
# python3 setup.py sdist bdist_wheel
|
|
||||||
# rm -f dist/*
|
# rm -f dist/*
|
||||||
|
# python3 setup.py sdist bdist_wheel
|
||||||
# twine upload dist/*
|
# twine upload dist/*
|
||||||
|
Loading…
Reference in New Issue
Block a user