Support for qobuz interpreter urls for artists

This commit is contained in:
nathom 2021-04-14 11:21:38 -07:00
parent 208bae7b35
commit f0850b989f
4 changed files with 96 additions and 61 deletions

View file

@ -133,14 +133,18 @@ FOLDER_FORMAT = (
) )
TRACK_FORMAT = "{tracknumber}. {artist} - {title}" TRACK_FORMAT = "{tracknumber}. {artist} - {title}"
# ------------------ Regexes ------------------- #
URL_REGEX = ( URL_REGEX = (
r"https?://(?:www|open|play|listen)?\.?(qobuz|tidal|deezer)\.com(?:(?:/" r"https?://(?:www|open|play|listen)?\.?(qobuz|tidal|deezer)\.com(?:(?:/"
r"(track|playlist|album|artist|label))|(?:\/[-\w]+?))+\/([-\w]+)" r"(track|playlist|album|artist|label))|(?:\/[-\w]+?))+\/([-\w]+)"
) )
SOUNDCLOUD_URL_REGEX = r"https://soundcloud.com/[-\w:/]+" SOUNDCLOUD_URL_REGEX = r"https://soundcloud.com/[-\w:/]+"
SOUNDCLOUD_CLIENT_ID = "a3e059563d7fd3372b49b37f00a00bcf" SOUNDCLOUD_CLIENT_ID = "a3e059563d7fd3372b49b37f00a00bcf"
LASTFM_URL_REGEX = r"https://www.last.fm/user/\w+/playlists/\w+" LASTFM_URL_REGEX = r"https://www.last.fm/user/\w+/playlists/\w+"
QOBUZ_INTERPRETER_URL_REGEX = (
r"https?://www\.qobuz\.com/\w\w-\w\w/interpreter/[-\w]+/[-\w]+"
)
TIDAL_MAX_Q = 7 TIDAL_MAX_Q = 7

View file

@ -19,6 +19,7 @@ from .constants import (
DB_PATH, DB_PATH,
LASTFM_URL_REGEX, LASTFM_URL_REGEX,
MEDIA_TYPES, MEDIA_TYPES,
QOBUZ_INTERPRETER_URL_REGEX,
SOUNDCLOUD_URL_REGEX, SOUNDCLOUD_URL_REGEX,
URL_REGEX, URL_REGEX,
) )
@ -30,7 +31,7 @@ from .exceptions import (
NoResultsFound, NoResultsFound,
ParsingError, ParsingError,
) )
from .utils import capitalize from .utils import capitalize, extract_interpreter_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -54,6 +55,7 @@ class MusicDL(list):
self.url_parse = re.compile(URL_REGEX) self.url_parse = re.compile(URL_REGEX)
self.soundcloud_url_parse = re.compile(SOUNDCLOUD_URL_REGEX) self.soundcloud_url_parse = re.compile(SOUNDCLOUD_URL_REGEX)
self.lastfm_url_parse = re.compile(LASTFM_URL_REGEX) self.lastfm_url_parse = re.compile(LASTFM_URL_REGEX)
self.interpreter_url_parse = re.compile(QOBUZ_INTERPRETER_URL_REGEX)
self.config = config self.config = config
if self.config is None: if self.config is None:
@ -76,48 +78,6 @@ class MusicDL(list):
else: else:
self.db = [] self.db = []
def prompt_creds(self, source: str):
"""Prompt the user for credentials.
:param source:
:type source: str
"""
if source == "qobuz":
click.secho(f"Enter {capitalize(source)} email:", fg="green")
self.config.file[source]["email"] = input()
click.secho(
f"Enter {capitalize(source)} password (will not show on screen):",
fg="green",
)
self.config.file[source]["password"] = md5(
getpass(prompt="").encode("utf-8")
).hexdigest()
self.config.save()
click.secho(f'Credentials saved to config file at "{self.config._path}"')
else:
raise Exception
def assert_creds(self, source: str):
assert source in (
"qobuz",
"tidal",
"deezer",
"soundcloud",
), f"Invalid source {source}"
if source == "deezer":
# no login for deezer
return
if source == "soundcloud":
return
if source == "qobuz" and (
self.config.file[source]["email"] is None
or self.config.file[source]["password"] is None
):
self.prompt_creds(source)
def handle_urls(self, url: str): def handle_urls(self, url: str):
"""Download a url """Download a url
@ -217,9 +177,6 @@ class MusicDL(list):
if self.db != [] and hasattr(item, "id"): if self.db != [] and hasattr(item, "id"):
self.db.add(item.id) self.db.add(item.id)
# if self.config.session["conversion"]["enabled"]:
# item.convert(**self.config.session["conversion"])
def get_client(self, source: str): def get_client(self, source: str):
client = self.clients[source] client = self.clients[source]
if not client.logged_in: if not client.logged_in:
@ -265,7 +222,23 @@ class MusicDL(list):
:raises exceptions.ParsingError :raises exceptions.ParsingError
""" """
parsed = self.url_parse.findall(url) # Qobuz, Tidal, Dezer
parsed = []
interpreter_urls = self.interpreter_url_parse.findall(url)
if interpreter_urls:
click.secho(
"Extracting IDs from Qobuz interpreter urls. Use urls "
"that include the artist ID for faster preprocessing.",
fg="yellow",
)
parsed.extend(
("qobuz", "artist", extract_interpreter_url(u))
for u in interpreter_urls
)
url = self.interpreter_url_parse.sub("", url)
parsed.extend(self.url_parse.findall(url)) # Qobuz, Tidal, Dezer
soundcloud_urls = self.soundcloud_url_parse.findall(url) soundcloud_urls = self.soundcloud_url_parse.findall(url)
soundcloud_items = [self.clients["soundcloud"].get(u) for u in soundcloud_urls] soundcloud_items = [self.clients["soundcloud"].get(u) for u in soundcloud_urls]
@ -503,3 +476,46 @@ class MusicDL(list):
def __get_source_subdir(self, source: str) -> str: def __get_source_subdir(self, source: str) -> str:
path = self.config.session["downloads"]["folder"] path = self.config.session["downloads"]["folder"]
return os.path.join(path, capitalize(source)) return os.path.join(path, capitalize(source))
def prompt_creds(self, source: str):
"""Prompt the user for credentials.
:param source:
:type source: str
"""
if source == "qobuz":
click.secho(f"Enter {capitalize(source)} email:", fg="green")
self.config.file[source]["email"] = input()
click.secho(
f"Enter {capitalize(source)} password (will not show on screen):",
fg="green",
)
self.config.file[source]["password"] = md5(
getpass(prompt="").encode("utf-8")
).hexdigest()
self.config.save()
click.secho(f'Credentials saved to config file at "{self.config._path}"')
else:
raise Exception
def assert_creds(self, source: str):
assert source in (
"qobuz",
"tidal",
"deezer",
"soundcloud",
), f"Invalid source {source}"
if source == "deezer":
# no login for deezer
return
if source == "soundcloud":
return
if source == "qobuz" and (
self.config.file[source]["email"] is None
or self.config.file[source]["password"] is None
):
self.prompt_creds(source)

View file

@ -16,7 +16,7 @@ from mutagen.flac import FLAC, Picture
from mutagen.id3 import APIC, ID3, ID3NoHeaderError from mutagen.id3 import APIC, ID3, ID3NoHeaderError
from mutagen.mp4 import MP4, MP4Cover from mutagen.mp4 import MP4, MP4Cover
from pathvalidate import sanitize_filename, sanitize_filepath from pathvalidate import sanitize_filename, sanitize_filepath
from requests.packages import urllib3 from tqdm import tqdm
from . import converter from . import converter
from .clients import Client from .clients import Client
@ -45,8 +45,6 @@ from .utils import (
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
urllib3.disable_warnings()
TYPE_REGEXES = { TYPE_REGEXES = {
"remaster": re.compile(r"(?i)(re)?master(ed)?"), "remaster": re.compile(r"(?i)(re)?master(ed)?"),
@ -90,22 +88,19 @@ class Track:
self.id = None self.id = None
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
# adjustments after blind attribute sets # TODO: remove these
self.container = "FLAC" self.container = "FLAC"
self.sampling_rate = 44100 self.sampling_rate = 44100
self.bit_depth = 16 self.bit_depth = 16
self.downloaded = False self.downloaded = False
self.tagged = False self.tagged = False
# TODO: find better solution
for attr in ("quality", "folder", "meta"): for attr in ("quality", "folder", "meta"):
setattr(self, attr, None) setattr(self, attr, None)
if isinstance(kwargs.get("meta"), TrackMetadata): if isinstance(kwargs.get("meta"), TrackMetadata):
self.meta = kwargs["meta"] self.meta = kwargs["meta"]
else:
self.meta = None
# `load_meta` must be called at some point
logger.debug("Track: meta not provided")
if (u := kwargs.get("cover_url")) is not None: if (u := kwargs.get("cover_url")) is not None:
logger.debug(f"Cover url: {u}") logger.debug(f"Cover url: {u}")
@ -195,7 +190,12 @@ class Track:
:param progress_bar: turn on/off progress bar :param progress_bar: turn on/off progress bar
:type progress_bar: bool :type progress_bar: bool
""" """
if not self._prepare_download(quality=quality, parent_folder=parent_folder, progress_bar=progress_bar, **kwargs): if not self._prepare_download(
quality=quality,
parent_folder=parent_folder,
progress_bar=progress_bar,
**kwargs,
):
return False return False
if self.client.source == "soundcloud": if self.client.source == "soundcloud":
@ -617,6 +617,7 @@ class Tracklist(list):
concurrent.futures.wait(futures) concurrent.futures.wait(futures)
except (KeyboardInterrupt, SystemExit): except (KeyboardInterrupt, SystemExit):
executor.shutdown() executor.shutdown()
tqdm.write("Aborted! May take some time to shutdown.")
exit("Aborted!") exit("Aborted!")
else: else:
@ -807,8 +808,8 @@ class Album(Tracklist):
self.meta = self.client.get(self.id, media_type="album") self.meta = self.client.get(self.id, media_type="album")
# update attributes based on response # update attributes based on response
for k, v in self._parse_get_resp(self.meta, self.client).items(): info = self._parse_get_resp(self.meta, self.client).items()
setattr(self, k, v) # prefer to __dict__.update for properties self.__dict__.update(info)
if not self.get("streamable", False): if not self.get("streamable", False):
raise NonStreamable(f"This album is not streamable ({self.id} ID)") raise NonStreamable(f"This album is not streamable ({self.id} ID)")

View file

@ -1,8 +1,9 @@
import base64 import base64
import contextlib import contextlib
import sys
import logging import logging
import os import os
import re
import sys
from string import Formatter from string import Formatter
from typing import Hashable, Optional, Union from typing import Hashable, Optional, Union
@ -15,7 +16,7 @@ from requests.packages import urllib3
from tqdm import tqdm from tqdm import tqdm
from tqdm.contrib import DummyTqdmFile from tqdm.contrib import DummyTqdmFile
from .constants import LOG_DIR, TIDAL_COVER_URL from .constants import LOG_DIR, TIDAL_COVER_URL, AGENT
from .exceptions import InvalidSourceError, NonStreamable from .exceptions import InvalidSourceError, NonStreamable
urllib3.disable_warnings() urllib3.disable_warnings()
@ -267,3 +268,16 @@ def decho(message, fg=None):
""" """
click.secho(message, fg=fg) click.secho(message, fg=fg)
logger.debug(message) logger.debug(message)
def extract_interpreter_url(url: str) -> str:
"""Extract artist ID from a Qobuz interpreter url.
:param url: Urls of the form "https://www.qobuz.com/us-en/interpreter/{artist}/download-streaming-albums"
:type url: str
:rtype: str
"""
session = gen_threadsafe_session({'User-Agent': AGENT})
r = session.get(url)
artist_id = re.search(r"getSimilarArtist\(\s*'(\w+)'", r.text).group(1)
return artist_id