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
|
||||
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
|
||||
|
||||
* 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 music from last.fm playlists (Spotify, Apple Music and Youtube playlists are also supported through this method)
|
||||
* Queue support on **interactive** mode
|
||||
* Support for albums with multiple discs
|
||||
* Read URLs from text file
|
||||
* Downloads URLs from text file
|
||||
* And more
|
||||
|
||||
## 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
|
||||
```
|
||||
Download a playlist in maximum quality
|
||||
Download a Qobuz playlist in maximum quality
|
||||
```
|
||||
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.
|
||||
|
||||
@ -124,7 +137,27 @@ commands:
|
||||
dl input 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
|
||||
`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. 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
|
||||
|
246
qobuz_dl/cli.py
246
qobuz_dl/cli.py
@ -1,16 +1,10 @@
|
||||
import argparse
|
||||
import base64
|
||||
import configparser
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from pick import pick
|
||||
from pathvalidate import sanitize_filename
|
||||
|
||||
import qobuz_dl.spoofbuz as spoofbuz
|
||||
from qobuz_dl import downloader, qopy
|
||||
from qobuz_dl.search import Search
|
||||
from qobuz_dl.core import QobuzDL
|
||||
from qobuz_dl.commands import qobuz_dl_args
|
||||
|
||||
if os.name == "nt":
|
||||
@ -21,8 +15,6 @@ else:
|
||||
CONFIG_PATH = os.path.join(OS_CONFIG, "qobuz-dl")
|
||||
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):
|
||||
print("Creating config file: " + config_file)
|
||||
@ -43,7 +35,7 @@ def reset_config(config_file):
|
||||
)
|
||||
or "6"
|
||||
)
|
||||
config["DEFAULT"]["default_limit"] = "10"
|
||||
config["DEFAULT"]["default_limit"] = "20"
|
||||
print("Getting tokens. Please wait...")
|
||||
spoofer = spoofbuz.Spoofer()
|
||||
config["DEFAULT"]["app_id"] = str(spoofer.getAppId())
|
||||
@ -53,193 +45,9 @@ def reset_config(config_file):
|
||||
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():
|
||||
if not os.path.isdir(CONFIG_PATH) or not os.path.isfile(CONFIG_FILE):
|
||||
try:
|
||||
os.makedirs(CONFIG_PATH, exist_ok=True)
|
||||
except FileExistsError:
|
||||
pass
|
||||
reset_config(CONFIG_FILE)
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
@ -273,44 +81,24 @@ def main():
|
||||
if arguments.reset:
|
||||
sys.exit(reset_config(CONFIG_FILE))
|
||||
|
||||
directory = musicDir(arguments.directory)
|
||||
|
||||
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":
|
||||
for url in arguments.SOURCE:
|
||||
if os.path.isfile(url):
|
||||
download_by_txt_file(
|
||||
Qz, url, directory, arguments.quality, arguments.embed_art
|
||||
)
|
||||
else:
|
||||
handle_urls(url, Qz, directory, arguments.quality, arguments.embed_art)
|
||||
else:
|
||||
download_lucky_mode(
|
||||
Qz,
|
||||
arguments.type,
|
||||
" ".join(arguments.QUERY),
|
||||
arguments.number,
|
||||
directory,
|
||||
qobuz = QobuzDL(
|
||||
arguments.directory,
|
||||
arguments.quality,
|
||||
arguments.embed_art,
|
||||
ignore_singles_eps=arguments.albums_only,
|
||||
)
|
||||
qobuz.initialize_client(email, password, app_id, secrets)
|
||||
|
||||
if arguments.command == "dl":
|
||||
qobuz.download_list_of_urls(arguments.SOURCE)
|
||||
elif arguments.command == "lucky":
|
||||
query = " ".join(arguments.QUERY)
|
||||
qobuz.lucky_type = arguments.type
|
||||
qobuz.lucky_limit = arguments.number
|
||||
qobuz.lucky_mode(query)
|
||||
else:
|
||||
qobuz.interactive_limit = arguments.limit
|
||||
qobuz.interactive()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -7,18 +7,12 @@ def fun_args(subparsers, default_limit):
|
||||
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)",
|
||||
help="limit of search results (default: 20)",
|
||||
)
|
||||
return interactive
|
||||
|
||||
@ -49,7 +43,7 @@ def lucky_args(subparsers):
|
||||
def dl_args(subparsers):
|
||||
download = subparsers.add_parser(
|
||||
"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",
|
||||
)
|
||||
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)"
|
||||
),
|
||||
)
|
||||
custom_parser.add_argument(
|
||||
"--albums-only",
|
||||
action="store_true",
|
||||
help=("don't download singles, EPs and VA releases"),
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
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"):
|
||||
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
|
||||
@ -149,7 +153,9 @@ def download_and_tag(
|
||||
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)
|
||||
|
||||
@ -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 str path: The root directory where the item will be downloaded
|
||||
: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
|
||||
|
||||
if album:
|
||||
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)
|
||||
print("\nDownloading: {}\n".format(album_title))
|
||||
dirT = (
|
||||
|
@ -4,6 +4,23 @@ from mutagen.flac import FLAC, Picture
|
||||
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):
|
||||
"""
|
||||
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)
|
||||
|
||||
try:
|
||||
audio["TITLE"] = "{} ({})".format(d["title"], d["version"])
|
||||
except KeyError:
|
||||
audio["TITLE"] = d["title"]
|
||||
audio["TITLE"] = get_title(d)
|
||||
|
||||
audio["TRACKNUMBER"] = str(d["track_number"]) # TRACK NUMBER
|
||||
|
||||
try:
|
||||
if d["work"]: # not none
|
||||
audio["WORK"] = d["work"]
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
||||
try:
|
||||
audio["COMPOSER"] = d["composer"]["name"] # COMPOSER
|
||||
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
|
||||
audio = EasyMP3(filename)
|
||||
|
||||
try:
|
||||
audio["title"] = "{} ({})".format(d["title"], d["version"])
|
||||
except KeyError:
|
||||
audio["title"] = d["title"]
|
||||
audio["title"] = get_title(d)
|
||||
|
||||
audio["tracknumber"] = str(d["track_number"])
|
||||
|
||||
try:
|
||||
if d["work"]: # not none
|
||||
audio["discsubtitle"] = d["work"]
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
try:
|
||||
audio["composer"] = d["composer"]["name"]
|
||||
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["date"] = d["album"]["release_date_original"].split("-")[0]
|
||||
else:
|
||||
audio["GENRE"] = ", ".join(album["genres_list"]) # GENRE
|
||||
audio["genre"] = ", ".join(album["genres_list"]) # GENRE
|
||||
audio["albumartist"] = album["artist"]["name"] # ALBUM ARTIST
|
||||
audio["album"] = album["title"] # ALBUM TITLE
|
||||
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
|
||||
import sys
|
||||
import os
|
||||
|
||||
pkg_name = "qobuz-dl"
|
||||
|
||||
@ -11,12 +9,11 @@ def read_file(fname):
|
||||
|
||||
|
||||
requirements = read_file("requirements.txt").strip().split()
|
||||
if os.name == "nt" or "win" in sys.platform:
|
||||
requirements.append("windows-curses")
|
||||
|
||||
|
||||
setup(
|
||||
name=pkg_name,
|
||||
version="0.5.4.2",
|
||||
version="0.5.0",
|
||||
author="Vitiko",
|
||||
author_email="vhnz98@gmail.com",
|
||||
description="The complete Lossless and Hi-Res music downloader for Qobuz",
|
||||
@ -39,6 +36,6 @@ setup(
|
||||
python_requires=">=3.6",
|
||||
)
|
||||
|
||||
# python3 setup.py sdist bdist_wheel
|
||||
# rm -f dist/*
|
||||
# python3 setup.py sdist bdist_wheel
|
||||
# twine upload dist/*
|
||||
|
Loading…
Reference in New Issue
Block a user