forked from vitiko98/qobuz-dl
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit fd1e0b8
Showing
15 changed files
with
1,203 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
'Qobuz Downloads' | ||
*__pycache* |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
# Qobuz-DL | ||
Seach and download Lossless and Hi-Res music from [Qobuz](https://www.qobuz.com/) | ||
|
||
![Demostration](demo.gif) | ||
|
||
## Features | ||
|
||
* Download FLAC files from Qobuz | ||
* Forget about links: just search and download music directly from your terminal | ||
* If you still want links, `Qobuz-DL` also has an input url mode | ||
|
||
## Getting started | ||
|
||
> Note: `Qobuz-DL` requires Python >3.6 | ||
> Note 2: You'll need an **active subscription** | ||
#### Install requirements with pip | ||
##### Linux / MAC OS | ||
``` | ||
pip3 install -r requirements.txt --user | ||
``` | ||
##### Windows 10 | ||
``` | ||
pip3 install windows-curses | ||
pip3 install -r requirements.txt | ||
``` | ||
#### Add your credentials to `config.json` | ||
```json | ||
{ | ||
"email": "", | ||
"password": "" | ||
} | ||
``` | ||
#### Run Qobuz-DL | ||
##### Linux / MAC OS | ||
``` | ||
python3 main.py | ||
``` | ||
##### Windows 10 | ||
``` | ||
python.exe main.py | ||
``` | ||
## Usage | ||
``` | ||
usage: python3 main.py [-h] [-a] [-i] [-q int] [-l int] [-d PATH] | ||
optional arguments: | ||
-h, --help show this help message and exit | ||
-a enable albums-only search | ||
-i run Qo-Dl-curses on URL input mode | ||
-q int FLAC quality (6, 7, 27) (default: 6) [LOSSLESS, 24B <96KHZ, 24B >96KHZ] | ||
-l int limit of search results by type (default: 10) | ||
-d PATH custom directory for downloads | ||
``` | ||
## A note about Qo-DL | ||
`Qobuz-DL` is inspired in the discontinued Qo-DL-Reborn. This program uses two modules from Qo-DL: `qopy` and `spoofer`, both written by Sorrow446 and DashLt. | ||
## Disclaimer | ||
This tool was written for educational purposes. I will not be responsible if you use this program in bad faith. | ||
If you are using this program, you are accepting this: https://static.qobuz.com/apps/api/QobuzAPI-TermsofUse.pdf |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
{ | ||
"email": "", | ||
"password": "" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
from qo_utils.search import Search | ||
from qo_utils import downloader | ||
import argparse | ||
import re | ||
import os | ||
import sys | ||
import json | ||
import qopy | ||
|
||
|
||
def getArgs(): | ||
parser = argparse.ArgumentParser(prog='python3 main.py') | ||
parser.add_argument("-a", action="store_true", | ||
help="enable albums-only search") | ||
parser.add_argument("-i", action="store_true", | ||
help="run Qo-Dl-curses on URL input mode") | ||
parser.add_argument("-q", metavar="int", default=6, | ||
help="FLAC quality (6, 7, 27) (default: 6)") | ||
parser.add_argument("-l", metavar="int", default=10, | ||
help="limit of search results by type (default: 10)") | ||
parser.add_argument("-d", metavar="PATH", default='Qobuz Downloads', | ||
help="custom directory for downloads") | ||
return parser.parse_args() | ||
|
||
|
||
def getSession(): | ||
print('Logging...') | ||
with open('config.json') as f: | ||
config = json.load(f) | ||
return qopy.Client(config['email'], config['password']) | ||
|
||
|
||
def musicDir(dir): | ||
fix = os.path.normpath(dir) | ||
if not os.path.isdir(fix): | ||
os.mkdir(fix) | ||
return fix | ||
|
||
|
||
def get_id(url): | ||
return re.match(r'https?://(?:w{0,3}|play|open)\.qobuz\.com/(?:(?' | ||
':album|track)/|[a-z]{2}-[a-z]{2}/album/-?\w+(?:-\w+)' | ||
'*-?/|user/library/favorites/)(\w+)', url).group(1) | ||
|
||
|
||
def searchSelected(Qz, path, start): | ||
q = ['6', '7', '27'] | ||
quality = q[start.quality[1]] | ||
for i in start.Selected: | ||
if start.Types[i[1]]: | ||
downloader.iterateIDs(Qz, start.IDs[i[1]], path, quality, True) | ||
else: | ||
downloader.iterateIDs(Qz, start.IDs[i[1]], path, quality, False) | ||
|
||
|
||
def fromUrl(Qz, path, link, quality): | ||
if '/track/' in link: | ||
id = get_id(link) | ||
downloader.iterateIDs(Qz, id, path, quality, False) | ||
else: | ||
id = get_id(link) | ||
downloader.iterateIDs(Qz, id, path, quality, True) | ||
|
||
|
||
def interactive(Qz, path, limit, tracks=True): | ||
while True: | ||
try: | ||
query = input("\nEnter your search: [Ctrl + c to quit]\n- ") | ||
print('Searching...') | ||
start = Search(Qz, query, limit) | ||
start.getResults(tracks) | ||
start.pickResults() | ||
searchSelected(Qz, path, start) | ||
except KeyboardInterrupt: | ||
sys.exit('\nBye') | ||
|
||
|
||
def inputMode(Qz, path, quality): | ||
while True: | ||
try: | ||
link = input("\nAlbum/track URL: [Ctrl + c to quit]\n- ") | ||
fromUrl(Qz, path, link, quality) | ||
except KeyboardInterrupt: | ||
sys.exit('\nBye') | ||
|
||
|
||
def main(): | ||
arguments = getArgs() | ||
directory = musicDir(arguments.d) + '/' | ||
Qz = getSession() | ||
if not arguments.i: | ||
if arguments.a: | ||
interactive(Qz, directory, arguments.l, False) | ||
else: | ||
interactive(Qz, directory, arguments.l, True) | ||
else: | ||
inputMode(Qz, directory, arguments.q) | ||
|
||
|
||
if __name__ == "__main__": | ||
sys.exit(main()) |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
import os | ||
import requests | ||
from qo_utils import metadata | ||
from tqdm import tqdm | ||
|
||
|
||
def req_tqdm(url, fname, track_name): | ||
r = requests.get(url, allow_redirects=True, stream=True) | ||
total = int(r.headers.get('content-length', 0)) | ||
with open(fname, 'wb') as file, tqdm( | ||
total=total, | ||
unit='iB', | ||
unit_scale=True, | ||
unit_divisor=1024, | ||
desc=track_name, | ||
bar_format='{n_fmt}/{total_fmt} /// {desc}', | ||
) as bar: | ||
for data in r.iter_content(chunk_size=1024): | ||
size = file.write(data) | ||
bar.update(size) | ||
|
||
|
||
def mkDir(dirn): | ||
try: | ||
os.mkdir(dirn) | ||
except FileExistsError: | ||
print('Warning: folder already exists. Overwriting...') | ||
|
||
|
||
def getDesc(u, mt): | ||
return '{} [{}/{}]'.format(mt['title'], u['bit_depth'], u['sampling_rate']) | ||
|
||
|
||
def getCover(i, dirn): | ||
req_tqdm(i, dirn + '/cover.jpg', 'Downloading cover art') | ||
|
||
|
||
# Download and tag a file | ||
def downloadItem(dirn, count, parse, meta, album, url, is_track): | ||
fname = '{}/{:02}.flac'.format(dirn, count) | ||
desc = getDesc(parse, meta) | ||
req_tqdm(url, fname, desc) | ||
metadata.iterateTag(fname, dirn, meta, album, is_track) | ||
|
||
|
||
# Iterate over IDs by type calling downloadItem | ||
def iterateIDs(client, id, path, quality, album=False): | ||
count = 0 | ||
|
||
if album: | ||
meta = client.get_album_meta(id) | ||
print('\nDownloading: {}\n'.format(meta['title'])) | ||
dirT = (meta['artist']['name'], | ||
meta['title'], | ||
meta['release_date_original'].split('-')[0]) | ||
dirn = path + '{} - {} [{}]'.format(*dirT) | ||
mkDir(dirn) | ||
getCover(meta['image']['large'], dirn) | ||
for i in meta['tracks']['items']: | ||
parse = client.get_track_url(i['id'], quality) | ||
url = parse['url'] | ||
|
||
if 'sample' not in parse and '.mp3' not in parse: | ||
downloadItem(dirn, count, parse, i, meta, url, False) | ||
else: | ||
print('Demo or MP3. Skipping') | ||
|
||
count = count + 1 | ||
else: | ||
parse = client.get_track_url(id, quality) | ||
url = parse['url'] | ||
|
||
if 'sample' not in parse and '.mp3' not in parse: | ||
meta = client.get_track_meta(id) | ||
print('\nDownloading: {}\n'.format(meta['title'])) | ||
dirT = (meta['album']['artist']['name'], | ||
meta['title'], | ||
meta['album']['release_date_original'].split('-')[0]) | ||
dirn = path + '{} - {} [{}]'.format(*dirT) | ||
mkDir(dirn) | ||
getCover(meta['album']['image']['large'], dirn) | ||
downloadItem(dirn, count, parse, meta, meta, url, True) | ||
else: | ||
print('Demo or MP3. Skipping') | ||
|
||
print('\nCompleted\n') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
from mutagen.flac import FLAC | ||
from pathvalidate import sanitize_filename | ||
import os | ||
|
||
|
||
def iterateTag(file, path, d, album, istrack=True): | ||
audio = FLAC(file) | ||
|
||
audio['TITLE'] = d['title'] # TRACK TITLE | ||
audio['TRACKNUMBER'] = str(d['track_number']) # TRACK NUMBER | ||
try: | ||
audio['COMPOSER'] = d['composer']['name'] # COMPOSER | ||
except KeyError: | ||
pass | ||
|
||
try: | ||
audio['ARTIST'] = d['performer']['name'] # TRACK ARTIST | ||
except KeyError: | ||
if istrack: | ||
audio['ARTIST'] = d['album']['artist']['name'] # TRACK ARTIST | ||
else: | ||
audio['ARTIST'] = album['artist']['name'] | ||
|
||
if istrack: | ||
audio['GENRE'] = ', '.join(d['album']['genres_list']) # GENRE | ||
audio['ALBUMARTIST'] = d['album']['artist']['name'] # ALBUM ARTIST | ||
audio['TRACKTOTAL'] = str(d['album']['tracks_count']) # TRACK TOTAL | ||
audio['ALBUM'] = d['album']['title'] # ALBUM TITLE | ||
audio['YEAR'] = d['album']['release_date_original'].split('-')[0] # YEAR | ||
else: | ||
audio['GENRE'] = ', '.join(album['genres_list']) # GENRE | ||
audio['ALBUMARTIST'] = album['artist']['name'] # ALBUM ARTIST | ||
audio['TRACKTOTAL'] = str(album['tracks_count']) # TRACK TOTAL | ||
audio['ALBUM'] = album['title'] # ALBUM TITLE | ||
audio['YEAR'] = album['release_date_original'].split('-')[0] # YEAR | ||
|
||
audio.save() | ||
title = sanitize_filename(d['title']) | ||
os.rename(file, '{}/{:02}. {}.flac'.format(path, d['track_number'], title)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import time | ||
import sys | ||
from pick import pick | ||
|
||
|
||
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'] | ||
|
||
def seconds(self, duration): | ||
return time.strftime("%M:%S", time.gmtime(duration)) | ||
|
||
def isHRes(self, item): | ||
if item: | ||
return 'HI-RES' | ||
else: | ||
return 'Lossless' | ||
|
||
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']), self.isHRes(i['hires'])) | ||
self.Total.append('[RELEASE] {} - {} - {} [{}]'.format(*items)) | ||
self.appendInfo(i, True) | ||
except KeyError: | ||
items = (i['performer']['name'], i['title'], | ||
self.seconds(i['duration']), self.isHRes(i['hires'])) | ||
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) | ||
|
||
def pickResults(self): | ||
title = ('Select [space] the item(s) you want to download ' | ||
'(one or more)\nPress Ctrl + c to quit\n') | ||
quality = ('Select [intro] the quality (the quality will be automat' | ||
'ically\ndowngraded if the selected is not found)') | ||
Qualitys = ['Lossless', 'Hi-res =< 96kHz', 'Hi-Res > 96 kHz'] | ||
try: | ||
self.Selected = pick(self.Total, title, | ||
multiselect=True, min_selection_count=1) | ||
self.quality = pick(Qualitys, quality) | ||
except KeyboardInterrupt: | ||
sys.exit('Bye') |
Oops, something went wrong.