Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
vitiko98 committed Aug 10, 2020
0 parents commit fd1e0b8
Show file tree
Hide file tree
Showing 15 changed files with 1,203 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
'Qobuz Downloads'
*__pycache*
674 changes: 674 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

60 changes: 60 additions & 0 deletions README.md
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
4 changes: 4 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"email": "",
"password": ""
}
Binary file added demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
101 changes: 101 additions & 0 deletions main.py
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 added qo_utils/__init__py
Empty file.
86 changes: 86 additions & 0 deletions qo_utils/downloader.py
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')
39 changes: 39 additions & 0 deletions qo_utils/metadata.py
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))
56 changes: 56 additions & 0 deletions qo_utils/search.py
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')
Loading

0 comments on commit fd1e0b8

Please sign in to comment.