mirror of
https://github.com/Wojtek242/qobuz-dl.git
synced 2024-11-22 11:05:25 +01:00
Added new modes: interactive, download and lucky
Save files in separated folders when more than one disc. Fix for #13 Handle albums with the same name and different qualities. Fix for #15 Support for embedded artwork. Fix for #12 Check if the file exists before downloading New README
This commit is contained in:
parent
c54eb71713
commit
621c609721
1
.gitignore
vendored
1
.gitignore
vendored
@ -12,6 +12,7 @@ subtitle_search.py
|
|||||||
*.json
|
*.json
|
||||||
*.txt
|
*.txt
|
||||||
*.db
|
*.db
|
||||||
|
*.sh
|
||||||
|
|
||||||
*.txt
|
*.txt
|
||||||
|
|
||||||
|
99
README.md
99
README.md
@ -6,9 +6,12 @@ If you need help or want to report a problem, join [qobuz-dl's discord server](h
|
|||||||
## 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 mode
|
* Search and download music directly from your terminal with **interactive** or **lucky** mode
|
||||||
* Queue support
|
* Download albums, tracks, artists, playlists and labels with **download** mode
|
||||||
* Input url mode with download support for albums, tracks, artists, playlists and labels
|
* Queue support on **interactive** mode
|
||||||
|
* Support for albums with multiple discs
|
||||||
|
* Read URLs from text file
|
||||||
|
* And more
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
@ -34,21 +37,93 @@ qobuz-dl.exe
|
|||||||
|
|
||||||
> If something fails, run `qobuz-dl -r` to reset your config file.
|
> If something fails, run `qobuz-dl -r` to reset your config file.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
### Interactive mode
|
||||||
|
Run interactive mode with a limit of 10 results
|
||||||
|
```
|
||||||
|
qobuz-dl fun -l 10
|
||||||
|
```
|
||||||
|
Now you can search albums and tracks:
|
||||||
|
```
|
||||||
|
Logging...
|
||||||
|
Logged: OK
|
||||||
|
Membership: Studio
|
||||||
|
|
||||||
|
|
||||||
|
Enter your search: [Ctrl + c to quit]
|
||||||
|
- fka twigs magdalene
|
||||||
|
```
|
||||||
|
Everything else is interactive. Enjoy.
|
||||||
|
|
||||||
|
Run `qobuz-dl fun --help` for more info.
|
||||||
|
|
||||||
|
### Download mode
|
||||||
|
Download URL in 24B<96khz quality
|
||||||
|
```
|
||||||
|
qobuz-dl dl https://play.qobuz.com/album/qxjbxh1dc3xyb -q 7
|
||||||
|
```
|
||||||
|
Download multiple URLs to custom directory
|
||||||
|
```
|
||||||
|
qobuz-dl dl https://play.qobuz.com/artist/2038380 https://play.qobuz.com/album/ip8qjy1m6dakc -d "Some pop from 2020"
|
||||||
|
```
|
||||||
|
Download multiple URLs from text file
|
||||||
|
```
|
||||||
|
qobuz-dl dl this_txt_file_has_urls.txt
|
||||||
|
```
|
||||||
|
Download albums from a label and also embed cover art images into the downloaded files
|
||||||
|
```
|
||||||
|
qobuz-dl dl https://play.qobuz.com/label/7526 --embed-art
|
||||||
|
```
|
||||||
|
Download a playlist in maximum quality
|
||||||
|
```
|
||||||
|
qobuz-dl dl https://play.qobuz.com/playlist/5388296 -q 27
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `qobuz-dl dl --help` for more info.
|
||||||
|
|
||||||
|
### Lucky mode
|
||||||
|
Download the first album result
|
||||||
|
```
|
||||||
|
qobuz-dl lucky playboi carti die lit
|
||||||
|
```
|
||||||
|
Download the first 5 artist results
|
||||||
|
```
|
||||||
|
qobuz-dl lucky joy division -n 5 --type artist
|
||||||
|
```
|
||||||
|
Download the first 3 track results in 320 quality
|
||||||
|
```
|
||||||
|
qobuz-dl lucky eric dolphy remastered --type track -n 3 -q 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `qobuz-dl lucky --help` for more info.
|
||||||
|
|
||||||
|
### Other
|
||||||
|
Reset your config file
|
||||||
|
```
|
||||||
|
qobuz-dl -r
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
```
|
```
|
||||||
usage: qobuz-dl [-h] [-a] [-r] [-i Album/track URL] [-q int] [-l int] [-d PATH]
|
usage: qobuz-dl [-h] [-r] {fun,dl,lucky} ...
|
||||||
|
|
||||||
|
The ultimate Qobuz music downloader.
|
||||||
|
See usage examples on https://github.com/vitiko98/qobuz-dl
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
-r create/reset config file
|
-r, --reset create/reset config file
|
||||||
-a enable albums-only search
|
|
||||||
-i album/track/artist/label/playlist URL run qobuz-dl on URL input mode (download by url)
|
commands:
|
||||||
-q int quality (5, 6, 7, 27) (default: 6) [320, LOSSLESS, 24B <96KHZ, 24B >96KHZ]
|
run qobuz-dl <command> --help for more info
|
||||||
-l int limit of search results by type (default: 10)
|
(e.g. qobuz-dl fun --help)
|
||||||
-d PATH custom directory for downloads (default: 'Qobuz Downloads')
|
|
||||||
|
{fun,dl,lucky}
|
||||||
|
fun interactive mode
|
||||||
|
dl input mode
|
||||||
|
lucky lucky mode
|
||||||
```
|
```
|
||||||
## 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.
|
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).
|
||||||
Also, you are accepting this: [Qobuz API Terms of Use](https://static.qobuz.com/apps/api/QobuzAPI-TermsofUse.pdf).
|
|
||||||
|
204
qobuz_dl/cli.py
204
qobuz_dl/cli.py
@ -5,10 +5,12 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from pick import pick
|
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 import downloader, qopy
|
||||||
from qobuz_dl.search import Search
|
from qobuz_dl.search import Search
|
||||||
|
from qobuz_dl.commands import qobuz_dl_args
|
||||||
|
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
OS_CONFIG = os.environ.get("APPDATA")
|
OS_CONFIG = os.environ.get("APPDATA")
|
||||||
@ -18,6 +20,8 @@ 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)
|
||||||
@ -46,40 +50,11 @@ def reset_config(config_file):
|
|||||||
print("Config file updated.")
|
print("Config file updated.")
|
||||||
|
|
||||||
|
|
||||||
def getArgs(default_quality=6, default_limit=10, default_folder="Qobuz Downloads"):
|
def musicDir(directory):
|
||||||
parser = argparse.ArgumentParser(prog="qobuz-dl")
|
fix = os.path.normpath(directory)
|
||||||
parser.add_argument("-a", action="store_true", help="enable albums-only search")
|
|
||||||
parser.add_argument("-r", action="store_true", help="create/reset config file")
|
|
||||||
parser.add_argument(
|
|
||||||
"-i",
|
|
||||||
metavar="album/track/artist/label/playlist URL",
|
|
||||||
help="run qobuz-dl on URL input mode (download by url)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-q",
|
|
||||||
metavar="int",
|
|
||||||
default=default_quality,
|
|
||||||
help="quality for url input mode (5, 6, 7, 27) (default: 6)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-l",
|
|
||||||
metavar="int",
|
|
||||||
default=default_limit,
|
|
||||||
help="limit of search results by type (default: 10)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-d",
|
|
||||||
metavar="PATH",
|
|
||||||
default=default_folder,
|
|
||||||
help="custom directory for downloads (default: '{}')".format(default_folder),
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def musicDir(dir):
|
|
||||||
fix = os.path.normpath(dir)
|
|
||||||
if not os.path.isdir(fix):
|
if not os.path.isdir(fix):
|
||||||
os.mkdir(fix)
|
print("New directory created: " + fix)
|
||||||
|
os.makedirs(fix, exist_ok=True)
|
||||||
return fix
|
return fix
|
||||||
|
|
||||||
|
|
||||||
@ -91,21 +66,25 @@ def get_id(url):
|
|||||||
).group(1)
|
).group(1)
|
||||||
|
|
||||||
|
|
||||||
def processSelected(Qz, path, albums, ids, types, quality):
|
def processSelected(Qz, path, albums, ids, types, quality, embed_art=False):
|
||||||
q = ["5", "6", "7", "27"]
|
quality = [i for i in QUALITIES.keys()][quality[1]]
|
||||||
quality = q[quality[1]]
|
|
||||||
for alb, id_, type_ in zip(albums, ids, types):
|
for alb, id_, type_ in zip(albums, ids, types):
|
||||||
for al in alb:
|
for al in alb:
|
||||||
downloader.iterateIDs(
|
downloader.download_id_by_type(
|
||||||
Qz, id_[al[1]], path, quality, True if type_[al[1]] else False
|
Qz,
|
||||||
|
id_[al[1]],
|
||||||
|
path,
|
||||||
|
quality,
|
||||||
|
True if type_[al[1]] else False,
|
||||||
|
embed_art,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def fromUrl(Qz, id, path, quality, album=True):
|
def fromUrl(Qz, id, path, quality, album=True, embed_art=False):
|
||||||
downloader.iterateIDs(Qz, id, path, str(quality), album)
|
downloader.download_id_by_type(Qz, id, path, str(quality), album, embed_art)
|
||||||
|
|
||||||
|
|
||||||
def handle_urls(url, client, path, quality):
|
def handle_urls(url, client, path, quality, embed_art=False):
|
||||||
possibles = {
|
possibles = {
|
||||||
"playlist": {"func": client.get_plist_meta, "iterable_key": "tracks"},
|
"playlist": {"func": client.get_plist_meta, "iterable_key": "tracks"},
|
||||||
"artist": {"func": client.get_artist_meta, "iterable_key": "albums"},
|
"artist": {"func": client.get_artist_meta, "iterable_key": "albums"},
|
||||||
@ -117,28 +96,31 @@ def handle_urls(url, client, path, quality):
|
|||||||
url_type = url.split("/")[3]
|
url_type = url.split("/")[3]
|
||||||
type_dict = possibles[url_type]
|
type_dict = possibles[url_type]
|
||||||
item_id = get_id(url)
|
item_id = get_id(url)
|
||||||
print("Downloading {}...".format(url_type))
|
except (KeyError, IndexError):
|
||||||
except KeyError:
|
print('Invalid url: "{}". Use urls from https://play.qobuz.com!'.format(url))
|
||||||
print("Invalid url. Use urls from https://play.qobuz.com!")
|
|
||||||
return
|
return
|
||||||
if type_dict["func"]:
|
if type_dict["func"]:
|
||||||
items = [
|
content = [item for item in type_dict["func"](item_id)]
|
||||||
item[type_dict["iterable_key"]]["items"]
|
content_name = content[0]["name"]
|
||||||
for item in type_dict["func"](item_id)
|
print(
|
||||||
][0]
|
"\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:
|
for item in items:
|
||||||
fromUrl(
|
fromUrl(
|
||||||
client,
|
client,
|
||||||
item["id"],
|
item["id"],
|
||||||
path,
|
new_path,
|
||||||
quality,
|
quality,
|
||||||
True if type_dict["iterable_key"] == "albums" else False,
|
True if type_dict["iterable_key"] == "albums" else False,
|
||||||
|
embed_art,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
fromUrl(client, item_id, path, quality, type_dict["album"])
|
fromUrl(client, item_id, path, quality, type_dict["album"], embed_art)
|
||||||
|
|
||||||
|
|
||||||
def interactive(Qz, path, limit, tracks=True):
|
def interactive(Qz, path, limit, tracks=True, embed_art=False):
|
||||||
while True:
|
while True:
|
||||||
Albums, Types, IDs = [], [], []
|
Albums, Types, IDs = [], [], []
|
||||||
try:
|
try:
|
||||||
@ -180,19 +162,86 @@ def interactive(Qz, path, limit, tracks=True):
|
|||||||
)
|
)
|
||||||
Qualits = ["320", "Lossless", "Hi-res =< 96kHz", "Hi-Res > 96 kHz"]
|
Qualits = ["320", "Lossless", "Hi-res =< 96kHz", "Hi-Res > 96 kHz"]
|
||||||
quality = pick(Qualits, desc, default_index=1)
|
quality = pick(Qualits, desc, default_index=1)
|
||||||
processSelected(Qz, path, Albums, IDs, Types, quality)
|
processSelected(Qz, path, Albums, IDs, Types, quality, embed_art)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
sys.exit("\nBye")
|
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:
|
try:
|
||||||
os.mkdir(CONFIG_PATH)
|
os.makedirs(CONFIG_PATH, exist_ok=True)
|
||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
pass
|
pass
|
||||||
reset_config(CONFIG_FILE)
|
reset_config(CONFIG_FILE)
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
sys.exit(qobuz_dl_args().print_help())
|
||||||
|
|
||||||
email = None
|
email = None
|
||||||
password = None
|
password = None
|
||||||
app_id = None
|
app_id = None
|
||||||
@ -211,21 +260,54 @@ def main():
|
|||||||
secrets = [
|
secrets = [
|
||||||
secret for secret in config["DEFAULT"]["secrets"].split(",") if secret
|
secret for secret in config["DEFAULT"]["secrets"].split(",") if secret
|
||||||
]
|
]
|
||||||
arguments = getArgs(default_quality, default_limit, default_folder)
|
arguments = qobuz_dl_args(
|
||||||
|
default_quality, default_limit, default_folder
|
||||||
|
).parse_args()
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
arguments = qobuz_dl_args().parse_args()
|
||||||
|
if not arguments.reset:
|
||||||
print("Your config file is corrupted! Run 'qobuz-dl -r' to fix this\n")
|
print("Your config file is corrupted! Run 'qobuz-dl -r' to fix this\n")
|
||||||
arguments = getArgs()
|
if arguments.reset:
|
||||||
|
|
||||||
if arguments.r:
|
|
||||||
sys.exit(reset_config(CONFIG_FILE))
|
sys.exit(reset_config(CONFIG_FILE))
|
||||||
|
|
||||||
directory = musicDir(arguments.d) + "/"
|
directory = musicDir(arguments.directory)
|
||||||
|
|
||||||
Qz = qopy.Client(email, password, app_id, secrets)
|
Qz = qopy.Client(email, password, app_id, secrets)
|
||||||
|
|
||||||
if not arguments.i:
|
try:
|
||||||
interactive(Qz, directory, arguments.l, not arguments.a)
|
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":
|
||||||
|
for url in arguments.SOURCE:
|
||||||
|
if os.path.isfile(url):
|
||||||
|
download_by_txt_file(
|
||||||
|
Qz, url, directory, arguments.quality, arguments.embed_art
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
handle_urls(arguments.i, Qz, directory, arguments.q)
|
handle_urls(url, Qz, directory, arguments.quality, arguments.embed_art)
|
||||||
|
else:
|
||||||
|
download_lucky_mode(
|
||||||
|
Qz,
|
||||||
|
arguments.type,
|
||||||
|
" ".join(arguments.QUERY),
|
||||||
|
arguments.number,
|
||||||
|
directory,
|
||||||
|
arguments.quality,
|
||||||
|
arguments.embed_art,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
105
qobuz_dl/commands.py
Normal file
105
qobuz_dl/commands.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def qobuz_dl_args(
|
||||||
|
default_quality=6, default_limit=10, default_folder="Qobuz Downloads"
|
||||||
|
):
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="qobuz-dl",
|
||||||
|
description=(
|
||||||
|
"The ultimate Qobuz music downloader.\nSee usage"
|
||||||
|
" examples on https://github.com/vitiko98/qobuz-dl"
|
||||||
|
),
|
||||||
|
formatter_class=argparse.RawTextHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-r", "--reset", action="store_true", help="create/reset config file"
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(
|
||||||
|
title="commands",
|
||||||
|
description="run qobuz-dl <command> --help for more info\n(e.g. qobuz-dl fun --help)",
|
||||||
|
dest="command",
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_common_arg(custom_parser):
|
||||||
|
custom_parser.add_argument(
|
||||||
|
"-e", "--embed-art", action="store_true", help="embed cover art into files"
|
||||||
|
)
|
||||||
|
custom_parser.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--directory",
|
||||||
|
metavar="PATH",
|
||||||
|
default=default_folder,
|
||||||
|
help='directory for downloads (default: "{}")'.format(default_folder),
|
||||||
|
)
|
||||||
|
custom_parser.add_argument(
|
||||||
|
"-q",
|
||||||
|
"--quality",
|
||||||
|
metavar="int",
|
||||||
|
default=default_quality,
|
||||||
|
choices=[5, 6, 7, 27],
|
||||||
|
help=(
|
||||||
|
'audio "quality" (5, 6, 7, 27)\n'
|
||||||
|
"[320, LOSSLESS, 24B <96KHZ, 24B >96KHZ] (default: 6)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
custom_parser.add_argument(
|
||||||
|
"-z", "--zip", action="store_true", help="zip the downloaded item(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
interactive = subparsers.add_parser(
|
||||||
|
"fun",
|
||||||
|
description="Interactively search for tracks and albums.",
|
||||||
|
help="interactive mode",
|
||||||
|
)
|
||||||
|
interactive.add_argument(
|
||||||
|
"-a",
|
||||||
|
"--albums-only",
|
||||||
|
action="store_true",
|
||||||
|
help="enable albums-only search",
|
||||||
|
)
|
||||||
|
interactive.add_argument(
|
||||||
|
"-l",
|
||||||
|
"--limit",
|
||||||
|
metavar="int",
|
||||||
|
default=default_limit,
|
||||||
|
help="limit of search results by type (default: 10)",
|
||||||
|
)
|
||||||
|
add_common_arg(interactive)
|
||||||
|
|
||||||
|
download = subparsers.add_parser(
|
||||||
|
"dl",
|
||||||
|
description="Download by album/track/artist/label/playlist URL.",
|
||||||
|
help="input mode",
|
||||||
|
)
|
||||||
|
add_common_arg(download)
|
||||||
|
download.add_argument(
|
||||||
|
"SOURCE",
|
||||||
|
metavar="SOURCE",
|
||||||
|
nargs="+",
|
||||||
|
help=("one or more URLs (space separated) or a text file"),
|
||||||
|
)
|
||||||
|
|
||||||
|
lucky = subparsers.add_parser(
|
||||||
|
"lucky",
|
||||||
|
description="Download the first <n> albums returned from a Qobuz search.",
|
||||||
|
help="lucky mode",
|
||||||
|
)
|
||||||
|
lucky.add_argument(
|
||||||
|
"-t",
|
||||||
|
"--type",
|
||||||
|
default="album",
|
||||||
|
help="type of items to search (artist, album, track, playlist) (default: album)",
|
||||||
|
)
|
||||||
|
lucky.add_argument(
|
||||||
|
"-n",
|
||||||
|
"--number",
|
||||||
|
metavar="int",
|
||||||
|
default=default_limit,
|
||||||
|
help="number of results to download (default: 1)",
|
||||||
|
)
|
||||||
|
add_common_arg(lucky)
|
||||||
|
lucky.add_argument("QUERY", nargs="+", help="search query")
|
||||||
|
|
||||||
|
return parser
|
@ -7,7 +7,7 @@ from tqdm import tqdm
|
|||||||
import qobuz_dl.metadata as metadata
|
import qobuz_dl.metadata as metadata
|
||||||
|
|
||||||
|
|
||||||
def req_tqdm(url, fname, track_name):
|
def tqdm_download(url, fname, track_name):
|
||||||
r = requests.get(url, allow_redirects=True, stream=True)
|
r = requests.get(url, allow_redirects=True, stream=True)
|
||||||
total = int(r.headers.get("content-length", 0))
|
total = int(r.headers.get("content-length", 0))
|
||||||
with open(fname, "wb") as file, tqdm(
|
with open(fname, "wb") as file, tqdm(
|
||||||
@ -25,42 +25,115 @@ def req_tqdm(url, fname, track_name):
|
|||||||
|
|
||||||
def mkDir(dirn):
|
def mkDir(dirn):
|
||||||
try:
|
try:
|
||||||
os.mkdir(dirn)
|
os.makedirs(dirn, exist_ok=True)
|
||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
print("Warning: folder already exists. Overwriting...")
|
pass
|
||||||
|
|
||||||
|
|
||||||
def getDesc(u, mt):
|
def getDesc(u, mt, multiple=None):
|
||||||
return "{} [{}/{}]".format(mt["title"], u["bit_depth"], u["sampling_rate"])
|
return "{} [{}/{}]".format(
|
||||||
|
("[Disc {}] {}".format(multiple, mt["title"])) if multiple else mt["title"],
|
||||||
|
u["bit_depth"],
|
||||||
|
u["sampling_rate"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def getBooklet(i, dirn):
|
def get_format(album_dict, quality):
|
||||||
req_tqdm(i, dirn + "/booklet.pdf", "Downloading booklet")
|
try:
|
||||||
|
if int(quality) == 5:
|
||||||
|
return "MP3"
|
||||||
|
if album_dict["maximum_bit_depth"] == 16 and int(quality) < 7:
|
||||||
|
return "FLAC"
|
||||||
|
except KeyError:
|
||||||
|
return "Unknown"
|
||||||
|
return "Hi-Res"
|
||||||
|
|
||||||
|
|
||||||
def getCover(i, dirn):
|
def get_extra(i, dirn, extra="cover.jpg"):
|
||||||
req_tqdm(i, dirn + "/cover.jpg", "Downloading cover art")
|
tqdm_download(i, os.path.join(dirn, extra), "Downloading " + extra.split(".")[0])
|
||||||
|
|
||||||
|
|
||||||
# Download and tag a file
|
# Download and tag a file
|
||||||
def downloadItem(dirn, count, parse, meta, album, url, is_track, mp3):
|
def download_and_tag(
|
||||||
fname = (
|
root_dir,
|
||||||
"{}/{:02}.mp3".format(dirn, count)
|
tmp_count,
|
||||||
if mp3
|
track_url_dict,
|
||||||
else "{}/{:02}.flac".format(dirn, count)
|
track_metadata,
|
||||||
|
album_or_track_metadata,
|
||||||
|
is_track,
|
||||||
|
is_mp3,
|
||||||
|
embed_art=False,
|
||||||
|
multiple=None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Download and tag a file
|
||||||
|
|
||||||
|
:param str root_dir: Root directory where the track will be stored
|
||||||
|
:param int tmp_count: Temporal download file number
|
||||||
|
:param dict track_url_dict: get_track_url dictionary from Qobuz client
|
||||||
|
:param dict track_metadata: Track item dictionary from Qobuz client
|
||||||
|
:param dict album_or_track_metadata: Album/track dictionary from Qobuz client
|
||||||
|
:param bool is_track
|
||||||
|
:param bool is_mp3
|
||||||
|
:param bool embed_art: Embed cover art into file (FLAC-only)
|
||||||
|
:param multiple: Multiple disc integer
|
||||||
|
:type multiple: integer or None
|
||||||
|
"""
|
||||||
|
extension = ".mp3" if is_mp3 else ".flac"
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = track_url_dict["url"]
|
||||||
|
except KeyError:
|
||||||
|
print("Track not available for download")
|
||||||
|
return
|
||||||
|
|
||||||
|
if multiple:
|
||||||
|
root_dir = os.path.join(root_dir, "Disc " + str(multiple))
|
||||||
|
mkDir(root_dir)
|
||||||
|
|
||||||
|
filename = os.path.join(root_dir, ".{:02}".format(tmp_count) + extension)
|
||||||
|
|
||||||
|
new_track_title = sanitize_filename(track_metadata["title"])
|
||||||
|
track_file = "{:02}. {}{}".format(
|
||||||
|
track_metadata["track_number"], new_track_title, extension
|
||||||
)
|
)
|
||||||
func = metadata.tag_mp3 if mp3 else metadata.tag_flac
|
final_file = os.path.join(root_dir, track_file)
|
||||||
desc = getDesc(parse, meta)
|
if os.path.isfile(final_file):
|
||||||
req_tqdm(url, fname, desc)
|
print(track_metadata["title"] + " was already downloaded. Skipping...")
|
||||||
func(fname, dirn, meta, album, is_track)
|
return
|
||||||
|
|
||||||
|
desc = getDesc(track_url_dict, track_metadata, multiple)
|
||||||
|
tqdm_download(url, filename, desc)
|
||||||
|
tag_function = metadata.tag_mp3 if is_mp3 else metadata.tag_flac
|
||||||
|
try:
|
||||||
|
tag_function(
|
||||||
|
filename,
|
||||||
|
root_dir,
|
||||||
|
final_file,
|
||||||
|
track_metadata,
|
||||||
|
album_or_track_metadata,
|
||||||
|
is_track,
|
||||||
|
embed_art,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print("Error tagging the file: " + str(e))
|
||||||
|
os.remove(filename)
|
||||||
|
|
||||||
|
|
||||||
# Iterate over IDs by type calling downloadItem
|
def download_id_by_type(client, item_id, path, quality, album=False, embed_art=False):
|
||||||
def iterateIDs(client, id, path, quality, album=False):
|
"""
|
||||||
|
Download and get metadata by ID and type (album or track)
|
||||||
|
|
||||||
|
:param Qopy client: qopy Client
|
||||||
|
:param int item_id: Qobuz item id
|
||||||
|
:param str path: The root directory where the item will be downloaded
|
||||||
|
:param int quality: Audio quality (5, 6, 7, 27)
|
||||||
|
:param bool album
|
||||||
|
"""
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
if album:
|
if album:
|
||||||
meta = client.get_album_meta(id)
|
meta = client.get_album_meta(item_id)
|
||||||
album_title = (
|
album_title = (
|
||||||
"{} ({})".format(meta["title"], meta["version"])
|
"{} ({})".format(meta["title"], meta["version"])
|
||||||
if meta["version"]
|
if meta["version"]
|
||||||
@ -71,35 +144,42 @@ def iterateIDs(client, id, path, quality, album=False):
|
|||||||
meta["artist"]["name"],
|
meta["artist"]["name"],
|
||||||
album_title,
|
album_title,
|
||||||
meta["release_date_original"].split("-")[0],
|
meta["release_date_original"].split("-")[0],
|
||||||
|
get_format(meta, quality),
|
||||||
)
|
)
|
||||||
sanitized_title = sanitize_filename("{} - {} [{}]".format(*dirT))
|
sanitized_title = sanitize_filename("{} - {} [{}] [{}]".format(*dirT))
|
||||||
dirn = path + sanitized_title
|
dirn = os.path.join(path, sanitized_title)
|
||||||
mkDir(dirn)
|
mkDir(dirn)
|
||||||
getCover(meta["image"]["large"], dirn)
|
get_extra(meta["image"]["large"], dirn)
|
||||||
if "goodies" in meta:
|
if "goodies" in meta:
|
||||||
try:
|
try:
|
||||||
getBooklet(meta["goodies"][0]["url"], dirn)
|
get_extra(meta["goodies"][0]["url"], dirn, "booklet.pdf")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Error: " + e)
|
print("Error: " + e)
|
||||||
|
media_numbers = [track["media_number"] for track in meta["tracks"]["items"]]
|
||||||
|
is_multiple = True if len([*{*media_numbers}]) > 1 else False
|
||||||
for i in meta["tracks"]["items"]:
|
for i in meta["tracks"]["items"]:
|
||||||
parse = client.get_track_url(i["id"], quality)
|
parse = client.get_track_url(i["id"], quality)
|
||||||
try:
|
if "sample" not in parse and parse["sampling_rate"]:
|
||||||
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
|
is_mp3 = True if int(quality) == 5 else False
|
||||||
downloadItem(dirn, count, parse, i, meta, url, False, is_mp3)
|
download_and_tag(
|
||||||
|
dirn,
|
||||||
|
count,
|
||||||
|
parse,
|
||||||
|
i,
|
||||||
|
meta,
|
||||||
|
False,
|
||||||
|
is_mp3,
|
||||||
|
embed_art,
|
||||||
|
i["media_number"] if is_multiple else None,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print("Demo. Skipping")
|
print("Demo. Skipping")
|
||||||
count = count + 1
|
count = count + 1
|
||||||
else:
|
else:
|
||||||
parse = client.get_track_url(id, quality)
|
parse = client.get_track_url(item_id, quality)
|
||||||
url = parse["url"]
|
|
||||||
|
|
||||||
if "sample" not in parse:
|
if "sample" not in parse and parse["sampling_rate"]:
|
||||||
meta = client.get_track_meta(id)
|
meta = client.get_track_meta(item_id)
|
||||||
track_title = (
|
track_title = (
|
||||||
"{} ({})".format(meta["title"], meta["version"])
|
"{} ({})".format(meta["title"], meta["version"])
|
||||||
if meta["version"]
|
if meta["version"]
|
||||||
@ -110,13 +190,14 @@ def iterateIDs(client, id, path, quality, album=False):
|
|||||||
meta["album"]["artist"]["name"],
|
meta["album"]["artist"]["name"],
|
||||||
track_title,
|
track_title,
|
||||||
meta["album"]["release_date_original"].split("-")[0],
|
meta["album"]["release_date_original"].split("-")[0],
|
||||||
|
get_format(meta, quality),
|
||||||
)
|
)
|
||||||
sanitized_title = sanitize_filename("{} - {} [{}]".format(*dirT))
|
sanitized_title = sanitize_filename("{} - {} [{}] [{}]".format(*dirT))
|
||||||
dirn = path + sanitized_title
|
dirn = os.path.join(path, sanitized_title)
|
||||||
mkDir(dirn)
|
mkDir(dirn)
|
||||||
getCover(meta["album"]["image"]["large"], dirn)
|
get_extra(meta["album"]["image"]["large"], dirn)
|
||||||
is_mp3 = True if int(quality) == 5 else False
|
is_mp3 = True if int(quality) == 5 else False
|
||||||
downloadItem(dirn, count, parse, meta, meta, url, True, is_mp3)
|
download_and_tag(dirn, count, parse, meta, meta, True, is_mp3, embed_art)
|
||||||
else:
|
else:
|
||||||
print("Demo. Skipping")
|
print("Demo. Skipping")
|
||||||
print("\nCompleted\n")
|
print("\nCompleted\n")
|
||||||
|
@ -1,12 +1,22 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from mutagen.flac import FLAC
|
from mutagen.flac import FLAC, Picture
|
||||||
from mutagen.mp3 import EasyMP3
|
from mutagen.mp3 import EasyMP3
|
||||||
from pathvalidate import sanitize_filename
|
|
||||||
|
|
||||||
|
|
||||||
def tag_flac(file, path, d, album, istrack=True):
|
def tag_flac(filename, root_dir, final_name, d, album, istrack=True, em_image=False):
|
||||||
audio = FLAC(file)
|
"""
|
||||||
|
Tag a FLAC file
|
||||||
|
|
||||||
|
:param str filename: FLAC file path
|
||||||
|
:param str root_dir: Root dir used to get the cover art
|
||||||
|
:param str final_name: Final name of the FLAC file (complete path)
|
||||||
|
:param dict d: Track dictionary from Qobuz_client
|
||||||
|
:param dict album: Album dictionary from Qobuz_client
|
||||||
|
:param bool istrack
|
||||||
|
:param bool em_image: Embed cover art into file
|
||||||
|
"""
|
||||||
|
audio = FLAC(filename)
|
||||||
|
|
||||||
audio["TITLE"] = (
|
audio["TITLE"] = (
|
||||||
"{} ({})".format(d["title"], d["version"]) if d["version"] else d["title"]
|
"{} ({})".format(d["title"], d["version"]) if d["version"] else d["title"]
|
||||||
@ -38,16 +48,37 @@ def tag_flac(file, path, d, album, istrack=True):
|
|||||||
audio["ALBUM"] = album["title"] # ALBUM TITLE
|
audio["ALBUM"] = album["title"] # ALBUM TITLE
|
||||||
audio["YEAR"] = album["release_date_original"].split("-")[0] # YEAR
|
audio["YEAR"] = album["release_date_original"].split("-")[0] # YEAR
|
||||||
|
|
||||||
audio.save()
|
emb_image = os.path.join(root_dir, "cover.jpg")
|
||||||
title = sanitize_filename(d["title"])
|
if os.path.isfile(emb_image) and em_image:
|
||||||
try:
|
try:
|
||||||
os.rename(file, "{}/{:02}. {}.flac".format(path, d["track_number"], title))
|
image = Picture()
|
||||||
except FileExistsError:
|
image.type = 3
|
||||||
print("File already exists. Skipping...")
|
image.mime = "image/jpeg"
|
||||||
|
image.desc = "cover"
|
||||||
|
with open(emb_image, "rb") as img:
|
||||||
|
image.data = img.read()
|
||||||
|
audio.add_picture(image)
|
||||||
|
except Exception as e:
|
||||||
|
print("Error embedding image: " + str(e))
|
||||||
|
|
||||||
|
audio.save()
|
||||||
|
os.rename(filename, final_name)
|
||||||
|
|
||||||
|
|
||||||
def tag_mp3(file, path, d, album, istrack=True):
|
def tag_mp3(filename, root_dir, final_name, d, album, istrack=True, em_image=False):
|
||||||
audio = EasyMP3(file)
|
"""
|
||||||
|
Tag a mp3 file
|
||||||
|
|
||||||
|
:param str filename: mp3 file path
|
||||||
|
:param str root_dir: Root dir used to get the cover art
|
||||||
|
:param str final_name: Final name of the mp3 file (complete path)
|
||||||
|
:param dict d: Track dictionary from Qobuz_client
|
||||||
|
:param dict album: Album dictionary from Qobuz_client
|
||||||
|
:param bool istrack
|
||||||
|
:param bool em_image: Embed cover art into file
|
||||||
|
"""
|
||||||
|
# TODO: add embedded cover art support for mp3
|
||||||
|
audio = EasyMP3(filename)
|
||||||
|
|
||||||
audio["title"] = (
|
audio["title"] = (
|
||||||
"{} ({})".format(d["title"], d["version"]) if d["version"] else d["title"]
|
"{} ({})".format(d["title"], d["version"]) if d["version"] else d["title"]
|
||||||
@ -77,8 +108,4 @@ def tag_mp3(file, path, d, album, istrack=True):
|
|||||||
audio["date"] = album["release_date_original"].split("-")[0] # YEAR
|
audio["date"] = album["release_date_original"].split("-")[0] # YEAR
|
||||||
|
|
||||||
audio.save()
|
audio.save()
|
||||||
title = sanitize_filename(d["title"])
|
os.rename(filename, final_name)
|
||||||
try:
|
|
||||||
os.rename(file, "{}/{:02}. {}.mp3".format(path, d["track_number"], title))
|
|
||||||
except FileExistsError:
|
|
||||||
print("File already exists. Skipping...")
|
|
||||||
|
@ -115,7 +115,7 @@ class Client:
|
|||||||
self.uat = usr_info["user_auth_token"]
|
self.uat = usr_info["user_auth_token"]
|
||||||
self.session.headers.update({"X-User-Auth-Token": self.uat})
|
self.session.headers.update({"X-User-Auth-Token": self.uat})
|
||||||
self.label = usr_info["user"]["credential"]["parameters"]["short_label"]
|
self.label = usr_info["user"]["credential"]["parameters"]["short_label"]
|
||||||
print("Membership: {}".format(self.label))
|
print("Membership: {}\n".format(self.label))
|
||||||
|
|
||||||
def multi_meta(self, epoint, key, id, type):
|
def multi_meta(self, epoint, key, id, type):
|
||||||
total = 1
|
total = 1
|
||||||
@ -154,6 +154,12 @@ class Client:
|
|||||||
def search_albums(self, query, limit):
|
def search_albums(self, query, limit):
|
||||||
return self.api_call("album/search", query=query, limit=limit)
|
return self.api_call("album/search", query=query, limit=limit)
|
||||||
|
|
||||||
|
def search_artists(self, query, limit):
|
||||||
|
return self.api_call("artist/search", query=query, limit=limit)
|
||||||
|
|
||||||
|
def search_playlists(self, query, limit):
|
||||||
|
return self.api_call("playlist/search", query=query, limit=limit)
|
||||||
|
|
||||||
def search_tracks(self, query, limit):
|
def search_tracks(self, query, limit):
|
||||||
return self.api_call("track/search", query=query, limit=limit)
|
return self.api_call("track/search", query=query, limit=limit)
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ class Search:
|
|||||||
self.Types = []
|
self.Types = []
|
||||||
self.Tracks = Qz.search_tracks(query, limit)["tracks"]["items"]
|
self.Tracks = Qz.search_tracks(query, limit)["tracks"]["items"]
|
||||||
self.Albums = Qz.search_albums(query, limit)["albums"]["items"]
|
self.Albums = Qz.search_albums(query, limit)["albums"]["items"]
|
||||||
|
self.Artists = Qz.search_artists(query, limit)
|
||||||
|
|
||||||
def seconds(self, duration):
|
def seconds(self, duration):
|
||||||
return time.strftime("%H:%M:%S", time.gmtime(duration))
|
return time.strftime("%H:%M:%S", time.gmtime(duration))
|
||||||
|
5
setup.py
5
setup.py
@ -15,7 +15,7 @@ if os.name == "nt":
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=pkg_name,
|
name=pkg_name,
|
||||||
version="0.4.2",
|
version="0.5",
|
||||||
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",
|
||||||
@ -38,5 +38,6 @@ setup(
|
|||||||
python_requires=">=3.6",
|
python_requires=">=3.6",
|
||||||
)
|
)
|
||||||
|
|
||||||
# python setup.py sdist
|
# python3 setup.py sdist bdist_wheel
|
||||||
|
# rm -f dist/*
|
||||||
# twine upload dist/*
|
# twine upload dist/*
|
||||||
|
Loading…
Reference in New Issue
Block a user