Merge dev

This commit is contained in:
nathom 2021-05-06 22:03:55 -07:00
commit d4c31122fa
15 changed files with 909 additions and 384 deletions

1
.gitignore vendored
View file

@ -19,3 +19,4 @@ StreamripDownloads
*.pyc *.pyc
*test.py *test.py
/.mypy_cache /.mypy_cache
/streamrip/test.yaml

20
.mypy.ini Normal file
View file

@ -0,0 +1,20 @@
[mypy-mutagen.*]
ignore_missing_imports = True
[mypy-tqdm.*]
ignore_missing_imports = True
[mypy-pathvalidate.*]
ignore_missing_imports = True
[mypy-packaging.*]
ignore_missing_imports = True
[mypy-ruamel.yaml.*]
ignore_missing_imports = True
[mypy-pick.*]
ignore_missing_imports = True
[mypy-simple_term_menu.*]
ignore_missing_imports = True

View file

@ -1,7 +1,11 @@
# streamrip # streamrip
[![Downloads](https://static.pepy.tech/personalized-badge/streamrip?period=total&units=international_system&left_color=black&right_color=green&left_text=Downloads)](https://pepy.tech/project/streamrip)
A scriptable stream downloader for Qobuz, Tidal, Deezer and SoundCloud. A scriptable stream downloader for Qobuz, Tidal, Deezer and SoundCloud.
## Features ## Features
- Super fast, as it utilizes concurrent downloads and conversion - Super fast, as it utilizes concurrent downloads and conversion
@ -31,8 +35,8 @@ pip3 install streamrip windows-curses --upgrade
``` ```
If you would like to use `streamrip`'s conversion capabilities, download TIDAL videos, or download music from SoundCloud, install [ffmpeg](https://ffmpeg.org/download.html).
If you would like to use `streamrip`'s conversion capabilities, or download music from SoundCloud, install [ffmpeg](https://ffmpeg.org/download.html). To download streams from YouTube, install `youtube-dl`.
## Example Usage ## Example Usage

View file

@ -1,4 +1,6 @@
"""These are the lower level classes that are handled by Album, Playlist, """Bases that handle parsing and downloading media.
These are the lower level classes that are handled by Album, Playlist,
and the other objects. They can also be downloaded individually, for example, and the other objects. They can also be downloaded individually, for example,
as a single track. as a single track.
""" """
@ -86,6 +88,10 @@ class Track:
self.downloaded = False self.downloaded = False
self.tagged = False self.tagged = False
self.converted = False self.converted = False
self.final_path: str
self.container: str
# TODO: find better solution # TODO: find better solution
for attr in ("quality", "folder", "meta"): for attr in ("quality", "folder", "meta"):
setattr(self, attr, None) setattr(self, attr, None)
@ -99,7 +105,6 @@ class Track:
def load_meta(self): def load_meta(self):
"""Send a request to the client to get metadata for this Track.""" """Send a request to the client to get metadata for this Track."""
assert self.id is not None, "id must be set before loading metadata" assert self.id is not None, "id must be set before loading metadata"
self.resp = self.client.get(self.id, media_type="track") self.resp = self.client.get(self.id, media_type="track")
@ -124,7 +129,8 @@ class Track:
self.cover_url = None self.cover_url = None
def _prepare_download(self, **kwargs): def _prepare_download(self, **kwargs):
"""This function does preprocessing to prepare for downloading tracks. """Do preprocessing before downloading items.
It creates the directories, downloads cover art, and (optionally) It creates the directories, downloads cover art, and (optionally)
downloads booklets. downloads booklets.
@ -198,6 +204,7 @@ class Track:
return False return False
if self.client.source == "qobuz": if self.client.source == "qobuz":
assert isinstance(dl_info, dict) # for typing
if not self.__validate_qobuz_dl_info(dl_info): if not self.__validate_qobuz_dl_info(dl_info):
click.secho("Track is not available for download", fg="red") click.secho("Track is not available for download", fg="red")
return False return False
@ -207,6 +214,7 @@ class Track:
# --------- Download Track ---------- # --------- Download Track ----------
if self.client.source in ("qobuz", "tidal", "deezer"): if self.client.source in ("qobuz", "tidal", "deezer"):
assert isinstance(dl_info, dict)
logger.debug("Downloadable URL found: %s", dl_info.get("url")) logger.debug("Downloadable URL found: %s", dl_info.get("url"))
try: try:
tqdm_download( tqdm_download(
@ -214,11 +222,12 @@ class Track:
) # downloads file ) # downloads file
except NonStreamable: except NonStreamable:
click.secho( click.secho(
"Track {self!s} is not available for download, skipping.", fg="red" f"Track {self!s} is not available for download, skipping.", fg="red"
) )
return False return False
elif self.client.source == "soundcloud": elif self.client.source == "soundcloud":
assert isinstance(dl_info, dict)
self._soundcloud_download(dl_info) self._soundcloud_download(dl_info)
else: else:
@ -236,12 +245,10 @@ class Track:
if not kwargs.get("stay_temp", False): if not kwargs.get("stay_temp", False):
self.move(self.final_path) self.move(self.final_path)
try: database = kwargs.get("database")
database = kwargs.get("database") if database:
database.add(self.id) database.add(self.id)
logger.debug(f"{self.id} added to database") logger.debug(f"{self.id} added to database")
except AttributeError: # assume database=None was passed
pass
logger.debug("Downloaded: %s -> %s", self.path, self.final_path) logger.debug("Downloaded: %s -> %s", self.path, self.final_path)
@ -264,7 +271,7 @@ class Track:
) )
def move(self, path: str): def move(self, path: str):
"""Moves the Track and sets self.path to the new path. """Move the Track and set self.path to the new path.
:param path: :param path:
:type path: str :type path: str
@ -273,9 +280,11 @@ class Track:
shutil.move(self.path, path) shutil.move(self.path, path)
self.path = path self.path = path
def _soundcloud_download(self, dl_info: dict) -> str: def _soundcloud_download(self, dl_info: dict):
"""Downloads a soundcloud track. This requires a seperate function """Download a soundcloud track.
because there are three methods that can be used to download a track:
This requires a seperate function because there are three methods that
can be used to download a track:
* original file downloads * original file downloads
* direct mp3 downloads * direct mp3 downloads
* hls stream ripping * hls stream ripping
@ -314,15 +323,14 @@ class Track:
@property @property
def _progress_desc(self) -> str: def _progress_desc(self) -> str:
"""The description that is used on the progress bar. """Get the description that is used on the progress bar.
:rtype: str :rtype: str
""" """
return click.style(f"Track {int(self.meta.tracknumber):02}", fg="blue") return click.style(f"Track {int(self.meta.tracknumber):02}", fg="blue")
def download_cover(self): def download_cover(self):
"""Downloads the cover art, if cover_url is given.""" """Download the cover art, if cover_url is given."""
if not hasattr(self, "cover_url"): if not hasattr(self, "cover_url"):
return False return False
@ -357,8 +365,7 @@ class Track:
@classmethod @classmethod
def from_album_meta(cls, album: TrackMetadata, track: dict, client: Client): def from_album_meta(cls, album: TrackMetadata, track: dict, client: Client):
"""Return a new Track object initialized with info from the album dicts """Return a new Track object initialized with info.
returned by client.get calls.
:param album: album metadata returned by API :param album: album metadata returned by API
:param pos: index of the track :param pos: index of the track
@ -366,14 +373,12 @@ class Track:
:type client: Client :type client: Client
:raises IndexError :raises IndexError
""" """
meta = TrackMetadata(album=album, track=track, source=client.source) meta = TrackMetadata(album=album, track=track, source=client.source)
return cls(client=client, meta=meta, id=track["id"]) return cls(client=client, meta=meta, id=track["id"])
@classmethod @classmethod
def from_api(cls, item: dict, client: Client): def from_api(cls, item: dict, client: Client):
"""Given a track dict from an API, return a new Track object """Return a new Track initialized from search result.
initialized with the proper values.
:param item: :param item:
:type item: dict :type item: dict
@ -401,7 +406,7 @@ class Track:
cover_url=cover_url, cover_url=cover_url,
) )
def tag( def tag( # noqa
self, self,
album_meta: dict = None, album_meta: dict = None,
cover: Union[Picture, APIC, MP4Cover] = None, cover: Union[Picture, APIC, MP4Cover] = None,
@ -496,7 +501,7 @@ class Track:
self.tagged = True self.tagged = True
def convert(self, codec: str = "ALAC", **kwargs): def convert(self, codec: str = "ALAC", **kwargs):
"""Converts the track to another codec. """Convert the track to another codec.
Valid values for codec: Valid values for codec:
* FLAC * FLAC
@ -560,7 +565,7 @@ class Track:
@property @property
def title(self) -> str: def title(self) -> str:
"""The title of the track. """Get the title of the track.
:rtype: str :rtype: str
""" """
@ -581,8 +586,9 @@ class Track:
return safe_get(self.meta, *keys, default=default) return safe_get(self.meta, *keys, default=default)
def set(self, key, val): def set(self, key, val):
"""Equivalent to __setitem__. Implemented only for """Set attribute `key` to `val`.
consistency.
Equivalent to __setitem__. Implemented only for consistency.
:param key: :param key:
:param val: :param val:
@ -612,8 +618,7 @@ class Track:
return f"<Track - {self['title']}>" return f"<Track - {self['title']}>"
def __str__(self) -> str: def __str__(self) -> str:
"""Return a readable string representation of """Return a readable string representation of this track.
this track.
:rtype: str :rtype: str
""" """
@ -624,6 +629,14 @@ class Video:
"""Only for Tidal.""" """Only for Tidal."""
def __init__(self, client: Client, id: str, **kwargs): def __init__(self, client: Client, id: str, **kwargs):
"""Initialize a Video object.
:param client:
:type client: Client
:param id: The TIDAL Video ID
:type id: str
:param kwargs: title, explicit, and tracknumber
"""
self.id = id self.id = id
self.client = client self.client = client
self.title = kwargs.get("title", "MusicVideo") self.title = kwargs.get("title", "MusicVideo")
@ -654,12 +667,21 @@ class Video:
return False # so that it is not tagged return False # so that it is not tagged
def tag(self, *args, **kwargs):
"""Return False.
This is a dummy method.
:param args:
:param kwargs:
"""
return False
@classmethod @classmethod
def from_album_meta(cls, track: dict, client: Client): def from_album_meta(cls, track: dict, client: Client):
"""Given an video response dict from an album, return a new """Return a new Video object given an album API response.
Video object from the information.
:param track: :param track: track dict from album
:type track: dict :type track: dict
:param client: :param client:
:type client: Client :type client: Client
@ -674,7 +696,7 @@ class Video:
@property @property
def path(self) -> str: def path(self) -> str:
"""The path to download the mp4 file. """Get path to download the mp4 file.
:rtype: str :rtype: str
""" """
@ -688,9 +710,17 @@ class Video:
return os.path.join(self.parent_folder, f"{fname}.mp4") return os.path.join(self.parent_folder, f"{fname}.mp4")
def __str__(self) -> str: def __str__(self) -> str:
"""Return the title.
:rtype: str
"""
return self.title return self.title
def __repr__(self) -> str: def __repr__(self) -> str:
"""Return a string representation of self.
:rtype: str
"""
return f"<Video - {self.title}>" return f"<Video - {self.title}>"
@ -698,9 +728,9 @@ class Booklet:
"""Only for Qobuz.""" """Only for Qobuz."""
def __init__(self, resp: dict): def __init__(self, resp: dict):
"""Initialized from the `goodies` field of the Qobuz API """Initialize from the `goodies` field of the Qobuz API response.
response.
Usage:
>>> album_meta = client.get('v4m7e0qiorycb', 'album') >>> album_meta = client.get('v4m7e0qiorycb', 'album')
>>> booklet = Booklet(album_meta['goodies'][0]) >>> booklet = Booklet(album_meta['goodies'][0])
>>> booklet.download() >>> booklet.download()
@ -708,6 +738,9 @@ class Booklet:
:param resp: :param resp:
:type resp: dict :type resp: dict
""" """
self.url: str
self.description: str
self.__dict__.update(resp) self.__dict__.update(resp)
def download(self, parent_folder: str, **kwargs): def download(self, parent_folder: str, **kwargs):
@ -734,8 +767,7 @@ class Tracklist(list):
essence_regex = re.compile(r"([^\(]+)(?:\s*[\(\[][^\)][\)\]])*") essence_regex = re.compile(r"([^\(]+)(?:\s*[\(\[][^\)][\)\]])*")
def download(self, **kwargs): def download(self, **kwargs):
"""Uses the _prepare_download and _download_item methods to download """Download all of the items in the tracklist.
all of the tracks contained in the Tracklist.
:param kwargs: :param kwargs:
""" """
@ -774,7 +806,7 @@ class Tracklist(list):
self.downloaded = True self.downloaded = True
def _download_and_convert_item(self, item, **kwargs): def _download_and_convert_item(self, item, **kwargs):
"""Downloads and converts an item. """Download and convert an item.
:param item: :param item:
:param kwargs: should contain a `conversion` dict. :param kwargs: should contain a `conversion` dict.
@ -782,7 +814,7 @@ class Tracklist(list):
if self._download_item(item, **kwargs): if self._download_item(item, **kwargs):
item.convert(**kwargs["conversion"]) item.convert(**kwargs["conversion"])
def _download_item(item, **kwargs): def _download_item(item, *args: Any, **kwargs: Any) -> bool:
"""Abstract method. """Abstract method.
:param item: :param item:
@ -798,7 +830,7 @@ class Tracklist(list):
raise NotImplementedError raise NotImplementedError
def get(self, key: Union[str, int], default=None): def get(self, key: Union[str, int], default=None):
"""A safe `get` method similar to `dict.get`. """Get an item if key is int, otherwise get an attr.
:param key: If it is a str, get an attribute. If an int, get the item :param key: If it is a str, get an attribute. If an int, get the item
at the index. at the index.
@ -826,13 +858,14 @@ class Tracklist(list):
self.__setitem__(key, val) self.__setitem__(key, val)
def convert(self, codec="ALAC", **kwargs): def convert(self, codec="ALAC", **kwargs):
"""Converts every item in `self`. """Convert every item in `self`.
Deprecated. Use _download_and_convert_item instead. Deprecated. Use _download_and_convert_item instead.
:param codec: :param codec:
:param kwargs: :param kwargs:
""" """
if (sr := kwargs.get("sampling_rate")) : if sr := kwargs.get("sampling_rate"):
if sr < 44100: if sr < 44100:
logger.warning( logger.warning(
"Sampling rate %d is lower than 44.1kHz." "Sampling rate %d is lower than 44.1kHz."
@ -847,8 +880,7 @@ class Tracklist(list):
@classmethod @classmethod
def from_api(cls, item: dict, client: Client): def from_api(cls, item: dict, client: Client):
"""Create an Album object from the api response of Qobuz, Tidal, """Create an Album object from an API response.
or Deezer.
:param resp: response dict :param resp: response dict
:type resp: dict :type resp: dict
@ -858,18 +890,15 @@ class Tracklist(list):
info = cls._parse_get_resp(item, client=client) info = cls._parse_get_resp(item, client=client)
# equivalent to Album(client=client, **info) # equivalent to Album(client=client, **info)
return cls(client=client, **info) return cls(client=client, **info) # type: ignore
@staticmethod @staticmethod
def get_cover_obj( def get_cover_obj(cover_path: str, container: str, source: str):
cover_path: str, container: str, source: str """Return an initialized cover object that is reused for every track.
) -> Union[Picture, APIC]:
"""Given the path to an image and a quality id, return an initialized
cover object that can be used for every track in the album.
:param cover_path: :param cover_path: Path to the image, must be a JPEG.
:type cover_path: str :type cover_path: str
:param quality: :param quality: quality ID
:type quality: int :type quality: int
:rtype: Union[Picture, APIC] :rtype: Union[Picture, APIC]
""" """
@ -907,8 +936,8 @@ class Tracklist(list):
with open(cover_path, "rb") as img: with open(cover_path, "rb") as img:
return cover(img.read(), imageformat=MP4Cover.FORMAT_JPEG) return cover(img.read(), imageformat=MP4Cover.FORMAT_JPEG)
def download_message(self) -> str: def download_message(self):
"""The message to display after calling `Tracklist.download`. """Get the message to display after calling `Tracklist.download`.
:rtype: str :rtype: str
""" """
@ -929,6 +958,7 @@ class Tracklist(list):
@staticmethod @staticmethod
def essence(album: str) -> str: def essence(album: str) -> str:
"""Ignore text in parens/brackets, return all lowercase. """Ignore text in parens/brackets, return all lowercase.
Used to group two albums that may be named similarly, but not exactly Used to group two albums that may be named similarly, but not exactly
the same. the same.
""" """
@ -938,14 +968,23 @@ class Tracklist(list):
return album return album
def __getitem__(self, key: Union[str, int]): def __getitem__(self, key):
"""Get an item if key is int, otherwise get an attr.
:param key:
"""
if isinstance(key, str): if isinstance(key, str):
return getattr(self, key) return getattr(self, key)
if isinstance(key, int): if isinstance(key, int):
return super().__getitem__(key) return super().__getitem__(key)
def __setitem__(self, key: Union[str, int], val: Any): def __setitem__(self, key, val):
"""Set an item if key is int, otherwise set an attr.
:param key:
:param val:
"""
if isinstance(key, str): if isinstance(key, str):
setattr(self, key, val) setattr(self, key, val)
@ -957,19 +996,37 @@ class YoutubeVideo:
"""Dummy class implemented for consistency with the Media API.""" """Dummy class implemented for consistency with the Media API."""
class DummyClient: class DummyClient:
"""Used because YouTube downloads use youtube-dl, not a client."""
source = "youtube" source = "youtube"
def __init__(self, url: str): def __init__(self, url: str):
"""Create a YoutubeVideo object.
:param url: URL to the youtube video.
:type url: str
"""
self.url = url self.url = url
self.client = self.DummyClient() self.client = self.DummyClient()
def download( def download(
self, self,
parent_folder="StreamripDownloads", parent_folder: str = "StreamripDownloads",
download_youtube_videos=False, download_youtube_videos: bool = False,
youtube_video_downloads_folder="StreamripDownloads", youtube_video_downloads_folder: str = "StreamripDownloads",
**kwargs, **kwargs,
): ):
"""Download the video using 'youtube-dl'.
:param parent_folder:
:type parent_folder: str
:param download_youtube_videos: True if the video should be downloaded.
:type download_youtube_videos: bool
:param youtube_video_downloads_folder: Folder to put videos if
downloaded.
:type youtube_video_downloads_folder: str
:param kwargs:
"""
click.secho(f"Downloading url {self.url}", fg="blue") click.secho(f"Downloading url {self.url}", fg="blue")
filename_formatter = "%(track_number)s.%(track)s.%(container)s" filename_formatter = "%(track_number)s.%(track)s.%(container)s"
filename = os.path.join(parent_folder, filename_formatter) filename = os.path.join(parent_folder, filename_formatter)
@ -1006,7 +1063,21 @@ class YoutubeVideo:
p.wait() p.wait()
def load_meta(self, *args, **kwargs): def load_meta(self, *args, **kwargs):
"""Return None.
Dummy method.
:param args:
:param kwargs:
"""
pass pass
def tag(self, *args, **kwargs): def tag(self, *args, **kwargs):
"""Return None.
Dummy method.
:param args:
:param kwargs:
"""
pass pass

View file

@ -1,3 +1,5 @@
"""The streamrip command line interface."""
import logging import logging
import os import os
from getpass import getpass from getpass import getpass
@ -34,6 +36,7 @@ if not os.path.isdir(CACHE_DIR):
@click.option("-t", "--text", metavar="PATH") @click.option("-t", "--text", metavar="PATH")
@click.option("-nd", "--no-db", is_flag=True) @click.option("-nd", "--no-db", is_flag=True)
@click.option("--debug", is_flag=True) @click.option("--debug", is_flag=True)
@click.version_option(prog_name="streamrip")
@click.pass_context @click.pass_context
def cli(ctx, **kwargs): def cli(ctx, **kwargs):
"""Streamrip: The all-in-one Qobuz, Tidal, SoundCloud, and Deezer music downloader. """Streamrip: The all-in-one Qobuz, Tidal, SoundCloud, and Deezer music downloader.
@ -124,7 +127,6 @@ def filter_discography(ctx, **kwargs):
For basic filtering, use the `--repeats` and `--features` filters. For basic filtering, use the `--repeats` and `--features` filters.
""" """
filters = kwargs.copy() filters = kwargs.copy()
filters.pop("urls") filters.pop("urls")
config.session["filters"] = filters config.session["filters"] = filters
@ -178,7 +180,7 @@ def search(ctx, **kwargs):
@click.option("-l", "--list", default="ideal-discography") @click.option("-l", "--list", default="ideal-discography")
@click.pass_context @click.pass_context
def discover(ctx, **kwargs): def discover(ctx, **kwargs):
"""Searches for albums in Qobuz's featured lists. """Search for albums in Qobuz's featured lists.
Avaiable options for `--list`: Avaiable options for `--list`:
@ -229,7 +231,7 @@ def discover(ctx, **kwargs):
@click.argument("URL") @click.argument("URL")
@click.pass_context @click.pass_context
def lastfm(ctx, source, url): def lastfm(ctx, source, url):
"""Searches for tracks from a last.fm playlist on a given source. """Search for tracks from a last.fm playlist on a given source.
Examples: Examples:
@ -241,7 +243,6 @@ def lastfm(ctx, source, url):
Download a playlist using Tidal as the source Download a playlist using Tidal as the source
""" """
if source is not None: if source is not None:
config.session["lastfm"]["source"] = source config.session["lastfm"]["source"] = source
@ -290,8 +291,10 @@ def config(ctx, **kwargs):
def none_chosen(): def none_chosen():
"""Print message if nothing was chosen."""
click.secho("No items chosen, exiting.", fg="bright_red") click.secho("No items chosen, exiting.", fg="bright_red")
def main(): def main():
"""Run the main program."""
cli(obj={}) cli(obj={})

View file

@ -1,3 +1,5 @@
"""The clients that interact with the service APIs."""
import base64 import base64
import hashlib import hashlib
import json import json
@ -44,6 +46,10 @@ class Client(ABC):
it is merely a template. it is merely a template.
""" """
source: str
max_quality: int
logged_in: bool
@abstractmethod @abstractmethod
def login(self, **kwargs): def login(self, **kwargs):
"""Authenticate the client. """Authenticate the client.
@ -72,35 +78,26 @@ class Client(ABC):
pass pass
@abstractmethod @abstractmethod
def get_file_url(self, track_id, quality=3) -> Union[dict]: def get_file_url(self, track_id, quality=3) -> Union[dict, str]:
"""Get the direct download url dict for a file. """Get the direct download url dict for a file.
:param track_id: id of the track :param track_id: id of the track
""" """
pass pass
@property
@abstractmethod
def source(self):
"""Source from which the Client retrieves data."""
pass
@property
@abstractmethod
def max_quality(self):
"""The maximum quality that the Client supports."""
pass
class QobuzClient(Client): class QobuzClient(Client):
"""QobuzClient."""
source = "qobuz" source = "qobuz"
max_quality = 4 max_quality = 4
# ------- Public Methods ------------- # ------- Public Methods -------------
def __init__(self): def __init__(self):
"""Create a QobuzClient object."""
self.logged_in = False self.logged_in = False
def login(self, email: str, pwd: str, **kwargs): def login(self, **kwargs):
"""Authenticate the QobuzClient. Must have a paid membership. """Authenticate the QobuzClient. Must have a paid membership.
If `app_id` and `secrets` are not provided, this will run the If `app_id` and `secrets` are not provided, this will run the
@ -114,6 +111,8 @@ class QobuzClient(Client):
:param kwargs: app_id: str, secrets: list, return_secrets: bool :param kwargs: app_id: str, secrets: list, return_secrets: bool
""" """
click.secho(f"Logging into {self.source}", fg="green") click.secho(f"Logging into {self.source}", fg="green")
email: str = kwargs["email"]
pwd: str = kwargs["pwd"]
if self.logged_in: if self.logged_in:
logger.debug("Already logged in") logger.debug("Already logged in")
return return
@ -140,6 +139,12 @@ class QobuzClient(Client):
self.logged_in = True self.logged_in = True
def get_tokens(self) -> Tuple[str, Sequence[str]]: def get_tokens(self) -> Tuple[str, Sequence[str]]:
"""Return app id and secrets.
These can be saved and reused.
:rtype: Tuple[str, Sequence[str]]
"""
return self.app_id, self.secrets return self.app_id, self.secrets
def search( def search(
@ -178,18 +183,31 @@ class QobuzClient(Client):
return self._api_search(query, media_type, limit) return self._api_search(query, media_type, limit)
def get(self, item_id: Union[str, int], media_type: str = "album") -> dict: def get(self, item_id: Union[str, int], media_type: str = "album") -> dict:
"""Get an item from the API.
:param item_id:
:type item_id: Union[str, int]
:param media_type:
:type media_type: str
:rtype: dict
"""
resp = self._api_get(media_type, item_id=item_id) resp = self._api_get(media_type, item_id=item_id)
logger.debug(resp) logger.debug(resp)
return resp return resp
def get_file_url(self, item_id, quality=3) -> dict: def get_file_url(self, item_id, quality=3) -> dict:
"""Get the downloadble file url for a track.
:param item_id:
:param quality:
:rtype: dict
"""
return self._api_get_file_url(item_id, quality=quality) return self._api_get_file_url(item_id, quality=quality)
# ---------- Private Methods --------------- # ---------- Private Methods ---------------
def _gen_pages(self, epoint: str, params: dict) -> dict: def _gen_pages(self, epoint: str, params: dict) -> Generator:
"""When there are multiple pages of results, this lazily """When there are multiple pages of results, this yields them.
yields them.
:param epoint: :param epoint:
:type epoint: str :type epoint: str
@ -218,7 +236,7 @@ class QobuzClient(Client):
yield page yield page
def _validate_secrets(self): def _validate_secrets(self):
"""Checks if the secrets are usable.""" """Check if the secrets are usable."""
for secret in self.secrets: for secret in self.secrets:
if self._test_secret(secret): if self._test_secret(secret):
self.sec = secret self.sec = secret
@ -228,8 +246,7 @@ class QobuzClient(Client):
raise InvalidAppSecretError(f"Invalid secrets: {self.secrets}") raise InvalidAppSecretError(f"Invalid secrets: {self.secrets}")
def _api_get(self, media_type: str, **kwargs) -> dict: def _api_get(self, media_type: str, **kwargs) -> dict:
"""Internal function that sends the request for metadata to the """Request metadata from the Qobuz API.
Qobuz API.
:param media_type: :param media_type:
:type media_type: str :type media_type: str
@ -262,7 +279,7 @@ class QobuzClient(Client):
return response return response
def _api_search(self, query: str, media_type: str, limit: int = 500) -> Generator: def _api_search(self, query: str, media_type: str, limit: int = 500) -> Generator:
"""Internal function that sends a search request to the API. """Send a search request to the API.
:param query: :param query:
:type query: str :type query: str
@ -297,8 +314,7 @@ class QobuzClient(Client):
return self._gen_pages(epoint, params) return self._gen_pages(epoint, params)
def _api_login(self, email: str, pwd: str): def _api_login(self, email: str, pwd: str):
"""Internal function that logs into the api to get the user """Log into the api to get the user authentication token.
authentication token.
:param email: :param email:
:type email: str :type email: str
@ -330,7 +346,7 @@ class QobuzClient(Client):
def _api_get_file_url( def _api_get_file_url(
self, track_id: Union[str, int], quality: int = 3, sec: str = None self, track_id: Union[str, int], quality: int = 3, sec: str = None
) -> dict: ) -> dict:
"""Internal function that gets the file url given an id. """Get the file url given a track id.
:param track_id: :param track_id:
:type track_id: Union[str, int] :type track_id: Union[str, int]
@ -355,7 +371,7 @@ class QobuzClient(Client):
else: else:
raise InvalidAppSecretError("Cannot find app secret") raise InvalidAppSecretError("Cannot find app secret")
quality = get_quality(quality, self.source) quality = int(get_quality(quality, self.source))
r_sig = f"trackgetFileUrlformat_id{quality}intentstreamtrack_id{track_id}{unix_ts}{secret}" r_sig = f"trackgetFileUrlformat_id{quality}intentstreamtrack_id{track_id}{unix_ts}{secret}"
logger.debug("Raw request signature: %s", r_sig) logger.debug("Raw request signature: %s", r_sig)
r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest() r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest()
@ -375,7 +391,7 @@ class QobuzClient(Client):
return response return response
def _api_request(self, epoint: str, params: dict) -> Tuple[dict, int]: def _api_request(self, epoint: str, params: dict) -> Tuple[dict, int]:
"""The function that handles all requests to the API. """Send a request to the API.
:param epoint: :param epoint:
:type epoint: str :type epoint: str
@ -392,7 +408,7 @@ class QobuzClient(Client):
raise raise
def _test_secret(self, secret: str) -> bool: def _test_secret(self, secret: str) -> bool:
"""Tests a secret. """Test the authenticity of a secret.
:param secret: :param secret:
:type secret: str :type secret: str
@ -407,10 +423,13 @@ class QobuzClient(Client):
class DeezerClient(Client): class DeezerClient(Client):
"""DeezerClient."""
source = "deezer" source = "deezer"
max_quality = 2 max_quality = 2
def __init__(self): def __init__(self):
"""Create a DeezerClient."""
self.session = gen_threadsafe_session() self.session = gen_threadsafe_session()
# no login required # no login required
@ -426,16 +445,21 @@ class DeezerClient(Client):
:param limit: :param limit:
:type limit: int :type limit: int
""" """
# TODO: more robust url sanitize
query = query.replace(" ", "+")
# TODO: use limit parameter # TODO: use limit parameter
response = self.session.get(f"{DEEZER_BASE}/search/{media_type}?q={query}") response = self.session.get(
f"{DEEZER_BASE}/search/{media_type}", params={"q": query}
)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def login(self, **kwargs): def login(self, **kwargs):
"""Return None.
Dummy method.
:param kwargs:
"""
logger.debug("Deezer does not require login call, returning") logger.debug("Deezer does not require login call, returning")
def get(self, meta_id: Union[str, int], media_type: str = "album"): def get(self, meta_id: Union[str, int], media_type: str = "album"):
@ -449,7 +473,7 @@ class DeezerClient(Client):
url = f"{DEEZER_BASE}/{media_type}/{meta_id}" url = f"{DEEZER_BASE}/{media_type}/{meta_id}"
item = self.session.get(url).json() item = self.session.get(url).json()
if media_type in ("album", "playlist"): if media_type in ("album", "playlist"):
tracks = self.session.get(f"{url}/tracks").json() tracks = self.session.get(f"{url}/tracks", params={"limit": 1000}).json()
item["tracks"] = tracks["data"] item["tracks"] = tracks["data"]
item["track_total"] = len(tracks["data"]) item["track_total"] = len(tracks["data"])
elif media_type == "artist": elif media_type == "artist":
@ -461,6 +485,13 @@ class DeezerClient(Client):
@staticmethod @staticmethod
def get_file_url(meta_id: Union[str, int], quality: int = 6): def get_file_url(meta_id: Union[str, int], quality: int = 6):
"""Get downloadable url for a track.
:param meta_id: The track ID.
:type meta_id: Union[str, int]
:param quality:
:type quality: int
"""
quality = min(DEEZER_MAX_Q, quality) quality = min(DEEZER_MAX_Q, quality)
url = f"{DEEZER_DL}/{get_quality(quality, 'deezer')}/{DEEZER_BASE}/track/{meta_id}" url = f"{DEEZER_DL}/{get_quality(quality, 'deezer')}/{DEEZER_BASE}/track/{meta_id}"
logger.debug(f"Download url {url}") logger.debug(f"Download url {url}")
@ -468,12 +499,15 @@ class DeezerClient(Client):
class TidalClient(Client): class TidalClient(Client):
"""TidalClient."""
source = "tidal" source = "tidal"
max_quality = 3 max_quality = 3
# ----------- Public Methods -------------- # ----------- Public Methods --------------
def __init__(self): def __init__(self):
"""Create a TidalClient."""
self.logged_in = False self.logged_in = False
self.device_code = None self.device_code = None
@ -582,7 +616,7 @@ class TidalClient(Client):
} }
def get_tokens(self) -> dict: def get_tokens(self) -> dict:
"""Used for saving them for later use. """Return tokens to save for later use.
:rtype: dict :rtype: dict
""" """
@ -599,10 +633,11 @@ class TidalClient(Client):
# ------------ Utilities to login ------------- # ------------ Utilities to login -------------
def _login_new_user(self, launch=True): def _login_new_user(self, launch: bool = True):
"""This will launch the browser and ask the user to log into tidal. """Create app url where the user can log in.
:param launch: :param launch: Launch the browser.
:type launch: bool
""" """
login_link = f"https://{self._get_device_code()}" login_link = f"https://{self._get_device_code()}"
@ -613,7 +648,7 @@ class TidalClient(Client):
click.launch(login_link) click.launch(login_link)
start = time.time() start = time.time()
elapsed = 0 elapsed = 0.0
while elapsed < 600: # 5 mins to login while elapsed < 600: # 5 mins to login
elapsed = time.time() - start elapsed = time.time() - start
status = self._check_auth_status() status = self._check_auth_status()
@ -694,7 +729,9 @@ class TidalClient(Client):
return True return True
def _refresh_access_token(self): def _refresh_access_token(self):
"""The access token expires in a week, so it must be refreshed. """Refresh the access token given a refresh token.
The access token expires in a week, so it must be refreshed.
Requires a refresh token. Requires a refresh token.
""" """
data = { data = {
@ -719,7 +756,9 @@ class TidalClient(Client):
self._update_authorization() self._update_authorization()
def _login_by_access_token(self, token, user_id=None): def _login_by_access_token(self, token, user_id=None):
"""This is the method used to login after the access token has been saved. """Login using the access token.
Used after the initial authorization.
:param token: :param token:
:param user_id: Not necessary. :param user_id: Not necessary.
@ -745,7 +784,7 @@ class TidalClient(Client):
@property @property
def authorization(self): def authorization(self):
"""The auth header.""" """Get the auth header."""
return {"authorization": f"Bearer {self.access_token}"} return {"authorization": f"Bearer {self.access_token}"}
# ------------- Fetch data ------------------ # ------------- Fetch data ------------------
@ -781,7 +820,7 @@ class TidalClient(Client):
return item return item
def _api_request(self, path: str, params=None) -> dict: def _api_request(self, path: str, params=None) -> dict:
"""The function that handles all tidal API requests. """Handle Tidal API requests.
:param path: :param path:
:type path: str :type path: str
@ -797,8 +836,7 @@ class TidalClient(Client):
return r return r
def _get_video_stream_url(self, video_id: str) -> str: def _get_video_stream_url(self, video_id: str) -> str:
"""Videos have to be ripped from an hls stream, so they require """Get the HLS video stream url.
seperate processing.
:param video_id: :param video_id:
:type video_id: str :type video_id: str
@ -824,7 +862,7 @@ class TidalClient(Client):
return url_info[-1] return url_info[-1]
def _api_post(self, url, data, auth=None): def _api_post(self, url, data, auth=None):
"""Function used for posting to tidal API. """Post to the Tidal API.
:param url: :param url:
:param data: :param data:
@ -835,11 +873,14 @@ class TidalClient(Client):
class SoundCloudClient(Client): class SoundCloudClient(Client):
"""SoundCloudClient."""
source = "soundcloud" source = "soundcloud"
max_quality = 0 max_quality = 0
logged_in = True logged_in = True
def __init__(self): def __init__(self):
"""Create a SoundCloudClient."""
self.session = gen_threadsafe_session(headers={"User-Agent": AGENT}) self.session = gen_threadsafe_session(headers={"User-Agent": AGENT})
def login(self): def login(self):
@ -864,7 +905,7 @@ class SoundCloudClient(Client):
logger.debug(resp) logger.debug(resp)
return resp return resp
def get_file_url(self, track: dict, quality) -> dict: def get_file_url(self, track, quality):
"""Get the streamable file url from soundcloud. """Get the streamable file url from soundcloud.
It will most likely be an hls stream, which will have to be manually It will most likely be an hls stream, which will have to be manually
@ -875,6 +916,9 @@ class SoundCloudClient(Client):
:param quality: :param quality:
:rtype: dict :rtype: dict
""" """
# TODO: find better solution for typing
assert isinstance(track, dict)
if not track["streamable"] or track["policy"] == "BLOCK": if not track["streamable"] or track["policy"] == "BLOCK":
raise Exception raise Exception
@ -908,8 +952,7 @@ class SoundCloudClient(Client):
return resp return resp
def _get(self, path, params=None, no_base=False, resp_obj=False): def _get(self, path, params=None, no_base=False, resp_obj=False):
"""The lower level of `SoundCloudClient.get` that handles request """Send a request to the SoundCloud API.
parameters and other options.
:param path: :param path:
:param params: :param params:

View file

@ -1,9 +1,12 @@
"""A config class that manages arguments between the config file and CLI.""" """A config class that manages arguments between the config file and CLI."""
import copy import copy
from collections import OrderedDict
import logging import logging
import os import os
import re import re
from pprint import pformat from pprint import pformat
from typing import Any, Dict, List
from ruamel.yaml import YAML from ruamel.yaml import YAML
@ -22,29 +25,21 @@ yaml = YAML()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ---------- Utilities -------------
def _set_to_none(d: dict):
for k, v in d.items():
if isinstance(v, dict):
_set_to_none(v)
else:
d[k] = None
class Config: class Config:
"""Config class that handles command line args and config files. """Config class that handles command line args and config files.
Usage: Usage:
>>> config = Config('test_config.yaml')
>>> config.defaults['qobuz']['quality'] >>> config = Config('test_config.yaml')
3 >>> config.defaults['qobuz']['quality']
3
If test_config was already initialized with values, this will load them If test_config was already initialized with values, this will load them
into `config`. Otherwise, a new config file is created with the default into `config`. Otherwise, a new config file is created with the default
values. values.
""" """
defaults = { defaults: Dict[str, Any] = {
"qobuz": { "qobuz": {
"quality": 3, "quality": 3,
"download_booklets": True, "download_booklets": True,
@ -105,9 +100,16 @@ class Config:
} }
def __init__(self, path: str = None): def __init__(self, path: str = None):
"""Create a Config object with state.
A YAML file is created at `path` if there is none.
:param path:
:type path: str
"""
# to access settings loaded from yaml file # to access settings loaded from yaml file
self.file = copy.deepcopy(self.defaults) self.file: Dict[str, Any] = copy.deepcopy(self.defaults)
self.session = copy.deepcopy(self.defaults) self.session: Dict[str, Any] = copy.deepcopy(self.defaults)
if path is None: if path is None:
self._path = CONFIG_PATH self._path = CONFIG_PATH
@ -121,7 +123,7 @@ class Config:
self.load() self.load()
def update(self): def update(self):
"""Resets the config file except for credentials.""" """Reset the config file except for credentials."""
self.reset() self.reset()
temp = copy.deepcopy(self.defaults) temp = copy.deepcopy(self.defaults)
temp["qobuz"].update(self.file["qobuz"]) temp["qobuz"].update(self.file["qobuz"])
@ -130,12 +132,10 @@ class Config:
def save(self): def save(self):
"""Save the config state to file.""" """Save the config state to file."""
self.dump(self.file) self.dump(self.file)
def reset(self): def reset(self):
"""Reset the config file.""" """Reset the config file."""
if not os.path.isdir(CONFIG_DIR): if not os.path.isdir(CONFIG_DIR):
os.makedirs(CONFIG_DIR, exist_ok=True) os.makedirs(CONFIG_DIR, exist_ok=True)
@ -143,7 +143,6 @@ class Config:
def load(self): def load(self):
"""Load infomation from the config files, making a deepcopy.""" """Load infomation from the config files, making a deepcopy."""
with open(self._path) as cfg: with open(self._path) as cfg:
for k, v in yaml.load(cfg).items(): for k, v in yaml.load(cfg).items():
self.file[k] = v self.file[k] = v
@ -197,24 +196,18 @@ class Config:
if source == "tidal": if source == "tidal":
return self.tidal_creds return self.tidal_creds
if source == "deezer" or source == "soundcloud": if source == "deezer" or source == "soundcloud":
return dict() return {}
raise InvalidSourceError(source) raise InvalidSourceError(source)
def __getitem__(self, key):
assert key in ("file", "defaults", "session")
return getattr(self, key)
def __setitem__(self, key, val):
assert key in ("file", "session")
setattr(self, key, val)
def __repr__(self): def __repr__(self):
"""Return a string representation of the config."""
return f"Config({pformat(self.session)})" return f"Config({pformat(self.session)})"
class ConfigDocumentation: class ConfigDocumentation:
"""Documentation is stored in this docstring. """Documentation is stored in this docstring.
qobuz: qobuz:
quality: 1: 320kbps MP3, 2: 16/44.1, 3: 24/<=96, 4: 24/>=96 quality: 1: 320kbps MP3, 2: 16/44.1, 3: 24/<=96, 4: 24/>=96
download_booklets: This will download booklet pdfs that are included with some albums download_booklets: This will download booklet pdfs that are included with some albums
@ -260,12 +253,13 @@ class ConfigDocumentation:
""" """
def __init__(self): def __init__(self):
"""Create a new ConfigDocumentation object."""
# not using ruamel because its super slow # not using ruamel because its super slow
self.docs = [] self.docs = []
doctext = self.__doc__ doctext = self.__doc__
# get indent level, key, and documentation # get indent level, key, and documentation
keyval = re.compile(r"( *)([\w_]+):\s*(.*)") keyval = re.compile(r"( *)([\w_]+):\s*(.*)")
lines = (line[4:] for line in doctext.split("\n")[1:-1]) lines = (line[4:] for line in doctext.split("\n")[2:-1])
for line in lines: for line in lines:
info = list(keyval.match(line).groups()) info = list(keyval.match(line).groups())
@ -318,13 +312,133 @@ class ConfigDocumentation:
# key, doc pairs are unique # key, doc pairs are unique
self.docs.remove(to_remove) self.docs.remove(to_remove)
def _get_key_regex(self, spaces, key): def _get_key_regex(self, spaces: str, key: str) -> re.Pattern:
"""Get a regex that matches a key in YAML.
:param spaces: a string spaces that represent the indent level.
:type spaces: str
:param key: the key to match.
:type key: str
:rtype: re.Pattern
"""
regex = rf"{spaces}{key}:(?:$|\s+?(.+))" regex = rf"{spaces}{key}:(?:$|\s+?(.+))"
return re.compile(regex) return re.compile(regex)
def strip_comments(self, path: str): def strip_comments(self, path: str):
"""Remove single-line comments from a file.
:param path:
:type path: str
"""
with open(path, "r") as f: with open(path, "r") as f:
lines = [line for line in f.readlines() if not line.strip().startswith("#")] lines = [line for line in f.readlines() if not line.strip().startswith("#")]
with open(path, "w") as f: with open(path, "w") as f:
f.write("".join(lines)) f.write("".join(lines))
# ------------- ~~ Experimental ~~ ----------------- #
def load_yaml(path: str):
"""Load a streamrip config YAML file.
Warning: this is not fully compliant with YAML. It was made for use
with streamrip.
:param path:
:type path: str
"""
with open(path) as f:
lines = f.readlines()
settings = OrderedDict()
type_dict = {t.__name__: t for t in (list, dict, str)}
for line in lines:
key_l: List[str] = []
val_l: List[str] = []
chars = StringWalker(line)
level = 0
# get indent level of line
while next(chars).isspace():
level += 1
chars.prev()
if (c := next(chars)) == "#":
# is a comment
continue
elif c == "-":
# is an item in a list
next(chars)
val_l = list(chars)
level += 2 # it is a child of the previous key
item_type = "list"
else:
# undo char read
chars.prev()
if not val_l:
while (c := next(chars)) != ":":
key_l.append(c)
val_l = list("".join(chars).strip())
if val_l:
val = "".join(val_l)
else:
# start of a section
item_type = "dict"
val = type_dict[item_type]()
key = "".join(key_l)
if level == 0:
settings[key] = val
elif level == 2:
parent = settings[tuple(settings.keys())[-1]]
if isinstance(parent, dict):
parent[key] = val
elif isinstance(parent, list):
parent.append(val)
else:
raise Exception(f"level too high: {level}")
return settings
class StringWalker:
"""A fancier str iterator."""
def __init__(self, s: str):
"""Create a StringWalker object.
:param s:
:type s: str
"""
self.__val = s.replace("\n", "")
self.__pos = 0
def __next__(self) -> str:
"""Get the next char.
:rtype: str
"""
try:
c = self.__val[self.__pos]
self.__pos += 1
return c
except IndexError:
raise StopIteration
def __iter__(self):
"""Get an iterator."""
return self
def prev(self, step: int = 1):
"""Un-read a character.
:param step: The number of steps backward to take.
:type step: int
"""
self.__pos -= step

View file

@ -1,3 +1,5 @@
"""Constants that are kept in one place."""
import os import os
from pathlib import Path from pathlib import Path
@ -68,6 +70,7 @@ __MP4_KEYS = (
"disk", "disk",
None, None,
None, None,
None,
) )
__MP3_KEYS = ( __MP3_KEYS = (
@ -91,6 +94,7 @@ __MP3_KEYS = (
id3.TPOS, id3.TPOS,
None, None,
None, None,
None,
) )
__METADATA_TYPES = ( __METADATA_TYPES = (
@ -114,6 +118,7 @@ __METADATA_TYPES = (
"discnumber", "discnumber",
"tracktotal", "tracktotal",
"disctotal", "disctotal",
"date",
) )

View file

@ -1,3 +1,5 @@
"""Wrapper classes over FFMPEG."""
import logging import logging
import os import os
import shutil import shutil
@ -15,11 +17,11 @@ SAMPLING_RATES = (44100, 48000, 88200, 96000, 176400, 192000)
class Converter: class Converter:
"""Base class for audio codecs.""" """Base class for audio codecs."""
codec_name = None codec_name: str
codec_lib = None codec_lib: str
container = None container: str
lossless = False lossless: bool = False
default_ffmpeg_arg = "" default_ffmpeg_arg: str = ""
def __init__( def __init__(
self, self,
@ -31,7 +33,8 @@ class Converter:
remove_source: bool = False, remove_source: bool = False,
show_progress: bool = False, show_progress: bool = False,
): ):
""" """Create a Converter object.
:param filename: :param filename:
:type filename: str :type filename: str
:param ffmpeg_arg: The codec ffmpeg argument (defaults to an "optimal value") :param ffmpeg_arg: The codec ffmpeg argument (defaults to an "optimal value")
@ -42,7 +45,7 @@ class Converter:
:type bit_depth: Optional[int] :type bit_depth: Optional[int]
:param copy_art: Embed the cover art (if found) into the encoded file :param copy_art: Embed the cover art (if found) into the encoded file
:type copy_art: bool :type copy_art: bool
:param remove_source: :param remove_source: Remove the source file after conversion.
:type remove_source: bool :type remove_source: bool
""" """
logger.debug(locals()) logger.debug(locals())
@ -148,7 +151,8 @@ class Converter:
class FLAC(Converter): class FLAC(Converter):
" Class for FLAC converter. " """Class for FLAC converter."""
codec_name = "flac" codec_name = "flac"
codec_lib = "flac" codec_lib = "flac"
container = "flac" container = "flac"
@ -156,8 +160,9 @@ class FLAC(Converter):
class LAME(Converter): class LAME(Converter):
""" """Class for libmp3lame converter.
Class for libmp3lame converter. Defaul ffmpeg_arg: `-q:a 0`.
Default ffmpeg_arg: `-q:a 0`.
See available options: See available options:
https://trac.ffmpeg.org/wiki/Encode/MP3 https://trac.ffmpeg.org/wiki/Encode/MP3
@ -170,7 +175,8 @@ class LAME(Converter):
class ALAC(Converter): class ALAC(Converter):
" Class for ALAC converter. " """Class for ALAC converter."""
codec_name = "alac" codec_name = "alac"
codec_lib = "alac" codec_lib = "alac"
container = "m4a" container = "m4a"
@ -178,8 +184,9 @@ class ALAC(Converter):
class Vorbis(Converter): class Vorbis(Converter):
""" """Class for libvorbis converter.
Class for libvorbis converter. Default ffmpeg_arg: `-q:a 6`.
Default ffmpeg_arg: `-q:a 6`.
See available options: See available options:
https://trac.ffmpeg.org/wiki/TheoraVorbisEncodingGuide https://trac.ffmpeg.org/wiki/TheoraVorbisEncodingGuide
@ -192,8 +199,9 @@ class Vorbis(Converter):
class OPUS(Converter): class OPUS(Converter):
""" """Class for libopus.
Class for libopus. Default ffmpeg_arg: `-b:a 128 -vbr on`.
Default ffmpeg_arg: `-b:a 128 -vbr on`.
See more: See more:
http://ffmpeg.org/ffmpeg-codecs.html#libopus-1 http://ffmpeg.org/ffmpeg-codecs.html#libopus-1
@ -206,8 +214,9 @@ class OPUS(Converter):
class AAC(Converter): class AAC(Converter):
""" """Class for libfdk_aac converter.
Class for libfdk_aac converter. Default ffmpeg_arg: `-b:a 256k`.
Default ffmpeg_arg: `-b:a 256k`.
See available options: See available options:
https://trac.ffmpeg.org/wiki/Encode/AAC https://trac.ffmpeg.org/wiki/Encode/AAC

View file

@ -1,4 +1,7 @@
"""The stuff that ties everything together for the CLI to use."""
import concurrent.futures import concurrent.futures
import html
import logging import logging
import os import os
import re import re
@ -6,14 +9,14 @@ import sys
from getpass import getpass from getpass import getpass
from hashlib import md5 from hashlib import md5
from string import Formatter from string import Formatter
from typing import Generator, Optional, Tuple, Union from typing import Dict, Generator, List, Optional, Tuple, Type, Union
import click import click
import requests import requests
from tqdm import tqdm from tqdm import tqdm
from .bases import Track, Video, YoutubeVideo from .bases import Track, Video, YoutubeVideo
from .clients import DeezerClient, QobuzClient, SoundCloudClient, TidalClient from .clients import DeezerClient, QobuzClient, SoundCloudClient, TidalClient, Client
from .config import Config from .config import Config
from .constants import ( from .constants import (
CONFIG_PATH, CONFIG_PATH,
@ -38,7 +41,10 @@ from .utils import extract_interpreter_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MEDIA_CLASS = { Media = Union[
Type[Album], Type[Playlist], Type[Artist], Type[Track], Type[Label], Type[Video]
]
MEDIA_CLASS: Dict[str, Media] = {
"album": Album, "album": Album,
"playlist": Playlist, "playlist": Playlist,
"artist": Artist, "artist": Artist,
@ -46,24 +52,31 @@ MEDIA_CLASS = {
"label": Label, "label": Label,
"video": Video, "video": Video,
} }
Media = Union[Album, Playlist, Artist, Track]
class MusicDL(list): class MusicDL(list):
"""MusicDL."""
def __init__( def __init__(
self, self,
config: Optional[Config] = None, config: Optional[Config] = None,
): ):
"""Create a MusicDL object.
:param config:
:type config: Optional[Config]
"""
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.interpreter_url_parse = re.compile(QOBUZ_INTERPRETER_URL_REGEX)
self.youtube_url_parse = re.compile(YOUTUBE_URL_REGEX) self.youtube_url_parse = re.compile(YOUTUBE_URL_REGEX)
self.config = config self.config: Config
if self.config is None: if config is None:
self.config = Config(CONFIG_PATH) self.config = Config(CONFIG_PATH)
else:
self.config = config
self.clients = { self.clients = {
"qobuz": QobuzClient(), "qobuz": QobuzClient(),
@ -72,25 +85,25 @@ class MusicDL(list):
"soundcloud": SoundCloudClient(), "soundcloud": SoundCloudClient(),
} }
if config.session["database"]["enabled"]: self.db: Union[MusicDB, list]
if config.session["database"]["path"] is not None: if self.config.session["database"]["enabled"]:
self.db = MusicDB(config.session["database"]["path"]) if self.config.session["database"]["path"] is not None:
self.db = MusicDB(self.config.session["database"]["path"])
else: else:
self.db = MusicDB(DB_PATH) self.db = MusicDB(DB_PATH)
config.file["database"]["path"] = DB_PATH self.config.file["database"]["path"] = DB_PATH
config.save() self.config.save()
else: else:
self.db = [] self.db = []
def handle_urls(self, url: str): def handle_urls(self, url: str):
"""Download a url """Download a url.
:param url: :param url:
:type url: str :type url: str
:raises InvalidSourceError :raises InvalidSourceError
:raises ParsingError :raises ParsingError
""" """
# youtube is handled by youtube-dl, so much of the # youtube is handled by youtube-dl, so much of the
# processing is not necessary # processing is not necessary
youtube_urls = self.youtube_url_parse.findall(url) youtube_urls = self.youtube_url_parse.findall(url)
@ -115,6 +128,15 @@ class MusicDL(list):
self.handle_item(source, url_type, item_id) self.handle_item(source, url_type, item_id)
def handle_item(self, source: str, media_type: str, item_id: str): def handle_item(self, source: str, media_type: str, item_id: str):
"""Get info and parse into a Media object.
:param source:
:type source: str
:param media_type:
:type media_type: str
:param item_id:
:type item_id: str
"""
self.assert_creds(source) self.assert_creds(source)
client = self.get_client(source) client = self.get_client(source)
@ -128,6 +150,10 @@ class MusicDL(list):
self.append(item) self.append(item)
def _get_download_args(self) -> dict: def _get_download_args(self) -> dict:
"""Get the arguments to pass to Media.download.
:rtype: dict
"""
return { return {
"database": self.db, "database": self.db,
"parent_folder": self.config.session["downloads"]["folder"], "parent_folder": self.config.session["downloads"]["folder"],
@ -156,6 +182,7 @@ class MusicDL(list):
} }
def download(self): def download(self):
"""Download all the items in self."""
try: try:
arguments = self._get_download_args() arguments = self._get_download_args()
except KeyError: except KeyError:
@ -216,7 +243,13 @@ 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)
def get_client(self, source: str): def get_client(self, source: str) -> Client:
"""Get a client given the source and log in.
:param source:
:type source: str
:rtype: Client
"""
client = self.clients[source] client = self.clients[source]
if not client.logged_in: if not client.logged_in:
self.assert_creds(source) self.assert_creds(source)
@ -224,6 +257,10 @@ class MusicDL(list):
return client return client
def login(self, client): def login(self, client):
"""Log into a client, if applicable.
:param client:
"""
creds = self.config.creds(client.source) creds = self.config.creds(client.source)
if not client.logged_in: if not client.logged_in:
while True: while True:
@ -247,8 +284,8 @@ class MusicDL(list):
self.config.file["tidal"].update(client.get_tokens()) self.config.file["tidal"].update(client.get_tokens())
self.config.save() self.config.save()
def parse_urls(self, url: str) -> Tuple[str, str]: def parse_urls(self, url: str) -> List[Tuple[str, str, str]]:
"""Returns the type of the url and the id. """Return the type of the url and the id.
Compatible with urls of the form: Compatible with urls of the form:
https://www.qobuz.com/us-en/{type}/{name}/{id} https://www.qobuz.com/us-en/{type}/{name}/{id}
@ -261,8 +298,7 @@ class MusicDL(list):
:raises exceptions.ParsingError :raises exceptions.ParsingError
""" """
parsed: List[Tuple[str, str, str]] = []
parsed = []
interpreter_urls = self.interpreter_url_parse.findall(url) interpreter_urls = self.interpreter_url_parse.findall(url)
if interpreter_urls: if interpreter_urls:
@ -290,15 +326,31 @@ class MusicDL(list):
return parsed return parsed
def handle_lastfm_urls(self, urls): def handle_lastfm_urls(self, urls: str):
"""Get info from lastfm url, and parse into Media objects.
This works by scraping the last.fm page and using a regex to
find the track titles and artists. The information is queried
in a Client.search(query, 'track') call and the first result is
used.
:param urls:
"""
# For testing:
# https://www.last.fm/user/nathan3895/playlists/12058911 # https://www.last.fm/user/nathan3895/playlists/12058911
user_regex = re.compile(r"https://www\.last\.fm/user/([^/]+)/playlists/\d+") user_regex = re.compile(r"https://www\.last\.fm/user/([^/]+)/playlists/\d+")
lastfm_urls = self.lastfm_url_parse.findall(urls) lastfm_urls = self.lastfm_url_parse.findall(urls)
lastfm_source = self.config.session["lastfm"]["source"] lastfm_source = self.config.session["lastfm"]["source"]
tracks_not_found = 0
def search_query(query: str, playlist: Playlist): def search_query(query: str, playlist: Playlist) -> bool:
global tracks_not_found """Search for a query and add the first result to playlist.
:param query:
:type query: str
:param playlist:
:type playlist: Playlist
:rtype: bool
"""
try: try:
track = next(self.search(lastfm_source, query, media_type="track")) track = next(self.search(lastfm_source, query, media_type="track"))
if self.config.session["metadata"]["set_playlist_to_album"]: if self.config.session["metadata"]["set_playlist_to_album"]:
@ -307,29 +359,33 @@ class MusicDL(list):
track.meta.version = track.meta.work = None track.meta.version = track.meta.work = None
playlist.append(track) playlist.append(track)
return True
except NoResultsFound: except NoResultsFound:
tracks_not_found += 1 return False
return
for purl in lastfm_urls: for purl in lastfm_urls:
click.secho(f"Fetching playlist at {purl}", fg="blue") click.secho(f"Fetching playlist at {purl}", fg="blue")
title, queries = self.get_lastfm_playlist(purl) title, queries = self.get_lastfm_playlist(purl)
pl = Playlist(client=self.get_client(lastfm_source), name=title) pl = Playlist(client=self.get_client(lastfm_source), name=title)
pl.creator = user_regex.search(purl).group(1) creator_match = user_regex.search(purl)
if creator_match is not None:
pl.creator = creator_match.group(1)
tracks_not_found: int = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=15) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=15) as executor:
futures = [ futures = [
executor.submit(search_query, f"{title} {artist}", pl) executor.submit(search_query, f"{title} {artist}", pl)
for title, artist in queries for title, artist in queries
] ]
# only for the progress bar # only for the progress bar
for f in tqdm( for search_attempt in tqdm(
concurrent.futures.as_completed(futures), concurrent.futures.as_completed(futures),
total=len(futures), total=len(futures),
desc="Searching", desc="Searching",
): ):
pass if not search_attempt.result():
tracks_not_found += 1
pl.loaded = True pl.loaded = True
click.secho(f"{tracks_not_found} tracks not found.", fg="yellow") click.secho(f"{tracks_not_found} tracks not found.", fg="yellow")
@ -350,6 +406,18 @@ class MusicDL(list):
def search( def search(
self, source: str, query: str, media_type: str = "album", limit: int = 200 self, source: str, query: str, media_type: str = "album", limit: int = 200
) -> Generator: ) -> Generator:
"""Universal search.
:param source:
:type source: str
:param query:
:type query: str
:param media_type:
:type media_type: str
:param limit:
:type limit: int
:rtype: Generator
"""
client = self.get_client(source) client = self.get_client(source)
results = client.search(query, media_type) results = client.search(query, media_type)
@ -362,7 +430,7 @@ class MusicDL(list):
else page["albums"]["items"] else page["albums"]["items"]
) )
for item in tracklist: for item in tracklist:
yield MEDIA_CLASS[ yield MEDIA_CLASS[ # type: ignore
media_type if media_type != "featured" else "album" media_type if media_type != "featured" else "album"
].from_api(item, client) ].from_api(item, client)
i += 1 i += 1
@ -376,12 +444,16 @@ class MusicDL(list):
raise NoResultsFound(query) raise NoResultsFound(query)
for item in items: for item in items:
yield MEDIA_CLASS[media_type].from_api(item, client) yield MEDIA_CLASS[media_type].from_api(item, client) # type: ignore
i += 1 i += 1
if i > limit: if i > limit:
return return
def preview_media(self, media): def preview_media(self, media) -> str:
"""Return a preview string of a Media object.
:param media:
"""
if isinstance(media, Album): if isinstance(media, Album):
fmt = ( fmt = (
"{albumartist} - {album}\n" "{albumartist} - {album}\n"
@ -408,9 +480,18 @@ class MusicDL(list):
ret = fmt.format(**{k: media.get(k, default="Unknown") for k in fields}) ret = fmt.format(**{k: media.get(k, default="Unknown") for k in fields})
return ret return ret
def interactive_search( def interactive_search( # noqa
self, query: str, source: str = "qobuz", media_type: str = "album" self, query: str, source: str = "qobuz", media_type: str = "album"
): ):
"""Show an interactive menu that contains search results.
:param query:
:type query: str
:param source:
:type source: str
:param media_type:
:type media_type: str
"""
results = tuple(self.search(source, query, media_type, limit=50)) results = tuple(self.search(source, query, media_type, limit=50))
def title(res): def title(res):
@ -491,6 +572,15 @@ class MusicDL(list):
return True return True
def get_lastfm_playlist(self, url: str) -> Tuple[str, list]: def get_lastfm_playlist(self, url: str) -> Tuple[str, list]:
"""From a last.fm url, find the playlist title and tracks.
Each page contains 50 results, so `num_tracks // 50 + 1` requests
are sent per playlist.
:param url:
:type url: str
:rtype: Tuple[str, list]
"""
info = [] info = []
words = re.compile(r"[\w\s]+") words = re.compile(r"[\w\s]+")
title_tags = re.compile('title="([^"]+)"') title_tags = re.compile('title="([^"]+)"')
@ -506,13 +596,21 @@ class MusicDL(list):
r = requests.get(url) r = requests.get(url)
get_titles(r.text) get_titles(r.text)
remaining_tracks = ( remaining_tracks_match = re.search(
int(re.search(r'data-playlisting-entry-count="(\d+)"', r.text).group(1)) r'data-playlisting-entry-count="(\d+)"', r.text
- 50
) )
playlist_title = re.search( if remaining_tracks_match is not None:
remaining_tracks = int(remaining_tracks_match.group(1)) - 50
else:
raise Exception("Error parsing lastfm page")
playlist_title_match = re.search(
r'<h1 class="playlisting-playlist-header-title">([^<]+)</h1>', r.text r'<h1 class="playlisting-playlist-header-title">([^<]+)</h1>', r.text
).group(1) )
if playlist_title_match is not None:
playlist_title = html.unescape(playlist_title_match.group(1))
else:
raise Exception("Error finding title from response")
page = 1 page = 1
while remaining_tracks > 0: while remaining_tracks > 0:
@ -550,6 +648,11 @@ class MusicDL(list):
raise Exception raise Exception
def assert_creds(self, source: str): def assert_creds(self, source: str):
"""Ensure that the credentials for `source` are valid.
:param source:
:type source: str
"""
assert source in ( assert source in (
"qobuz", "qobuz",
"tidal", "tidal",

View file

@ -1,6 +1,4 @@
"""A simple wrapper over an sqlite database that stores """Wrapper over a database that stores item IDs."""
the downloaded media IDs.
"""
import logging import logging
import os import os
@ -14,7 +12,7 @@ class MusicDB:
"""Simple interface for the downloaded track database.""" """Simple interface for the downloaded track database."""
def __init__(self, db_path: Union[str, os.PathLike]): def __init__(self, db_path: Union[str, os.PathLike]):
"""Create a MusicDB object """Create a MusicDB object.
:param db_path: filepath of the database :param db_path: filepath of the database
:type db_path: Union[str, os.PathLike] :type db_path: Union[str, os.PathLike]
@ -24,7 +22,7 @@ class MusicDB:
self.create() self.create()
def create(self): def create(self):
"""Create a database at `self.path`""" """Create a database at `self.path`."""
with sqlite3.connect(self.path) as conn: with sqlite3.connect(self.path) as conn:
try: try:
conn.execute("CREATE TABLE downloads (id TEXT UNIQUE NOT NULL);") conn.execute("CREATE TABLE downloads (id TEXT UNIQUE NOT NULL);")
@ -35,7 +33,7 @@ class MusicDB:
return self.path return self.path
def __contains__(self, item_id: Union[str, int]) -> bool: def __contains__(self, item_id: Union[str, int]) -> bool:
"""Checks whether the database contains an id. """Check whether the database contains an id.
:param item_id: the id to check :param item_id: the id to check
:type item_id: str :type item_id: str
@ -51,7 +49,7 @@ class MusicDB:
) )
def add(self, item_id: str): def add(self, item_id: str):
"""Adds an id to the database. """Add an id to the database.
:param item_id: :param item_id:
:type item_id: str :type item_id: str

View file

@ -1,8 +1,11 @@
"""Manages the information that will be embeded in the audio file. """ """Manages the information that will be embeded in the audio file."""
from __future__ import annotations
import logging import logging
import re import re
from collections import OrderedDict from collections import OrderedDict
from typing import Generator, Hashable, Optional, Tuple, Union from typing import Generator, Hashable, Iterable, Optional, Union
from .constants import ( from .constants import (
COPYRIGHT, COPYRIGHT,
@ -22,8 +25,8 @@ logger = logging.getLogger(__name__)
class TrackMetadata: class TrackMetadata:
"""Contains all of the metadata needed to tag the file. """Contains all of the metadata needed to tag the file.
Tags contained:
Tags contained:
* title * title
* artist * artist
* album * album
@ -44,14 +47,15 @@ class TrackMetadata:
* discnumber * discnumber
* tracktotal * tracktotal
* disctotal * disctotal
""" """
def __init__( def __init__(
self, track: Optional[dict] = None, album: Optional[dict] = None, source="qobuz" self,
track: Optional[Union[TrackMetadata, dict]] = None,
album: Optional[Union[TrackMetadata, dict]] = None,
source="qobuz",
): ):
"""Creates a TrackMetadata object optionally initialized with """Create a TrackMetadata object.
dicts returned by the Qobuz API.
:param track: track dict from API :param track: track dict from API
:type track: Optional[dict] :type track: Optional[dict]
@ -59,34 +63,37 @@ class TrackMetadata:
:type album: Optional[dict] :type album: Optional[dict]
""" """
# embedded information # embedded information
self.title = None self.title: str
self.album = None self.album: str
self.albumartist = None self.albumartist: str
self.composer = None self.composer: Optional[str] = None
self.comment = None self.comment: Optional[str] = None
self.description = None self.description: Optional[str] = None
self.purchase_date = None self.purchase_date: Optional[str] = None
self.grouping = None self.grouping: Optional[str] = None
self.lyrics = None self.lyrics: Optional[str] = None
self.encoder = None self.encoder: Optional[str] = None
self.compilation = None self.compilation: Optional[str] = None
self.cover = None self.cover: Optional[str] = None
self.tracktotal = None self.tracktotal: int
self.tracknumber = None self.tracknumber: int
self.discnumber = None self.discnumber: int
self.disctotal = None self.disctotal: int
# not included in tags # not included in tags
self.explicit = False self.explicit: Optional[bool] = False
self.quality = None self.quality: Optional[int] = None
self.sampling_rate = None self.sampling_rate: Optional[int] = None
self.bit_depth = None self.bit_depth: Optional[int] = None
self.booklets = None self.booklets = None
self.cover_urls = Optional[OrderedDict]
self.work: Optional[str]
self.id: Optional[str]
# Internals # Internals
self._artist = None self._artist: Optional[str] = None
self._copyright = None self._copyright: Optional[str] = None
self._genres = None self._genres: Optional[Iterable] = None
self.__source = source self.__source = source
@ -100,9 +107,8 @@ class TrackMetadata:
elif album is not None: elif album is not None:
self.add_album_meta(album) self.add_album_meta(album)
def update(self, meta): def update(self, meta: TrackMetadata):
"""Given a TrackMetadata object (usually from an album), the fields """Update the attributes from another TrackMetadata object.
of the current object are updated.
:param meta: :param meta:
:type meta: TrackMetadata :type meta: TrackMetadata
@ -114,14 +120,13 @@ class TrackMetadata:
setattr(self, k, v) setattr(self, k, v)
def add_album_meta(self, resp: dict): def add_album_meta(self, resp: dict):
"""Parse the metadata from an resp dict returned by the """Parse the metadata from an resp dict returned by the API.
API.
:param dict resp: from API :param dict resp: from API
""" """
if self.__source == "qobuz": if self.__source == "qobuz":
# Tags # Tags
self.album = resp.get("title") self.album = resp.get("title", "Unknown Album")
self.tracktotal = resp.get("tracks_count", 1) self.tracktotal = resp.get("tracks_count", 1)
self.genre = resp.get("genres_list") or resp.get("genre") self.genre = resp.get("genres_list") or resp.get("genre")
self.date = resp.get("release_date_original") or resp.get("release_date") self.date = resp.get("release_date_original") or resp.get("release_date")
@ -144,7 +149,7 @@ class TrackMetadata:
# Non-embedded information # Non-embedded information
self.version = resp.get("version") self.version = resp.get("version")
self.cover_urls = OrderedDict(resp.get("image")) self.cover_urls = OrderedDict(resp["image"])
self.cover_urls["original"] = self.cover_urls["large"].replace("600", "org") self.cover_urls["original"] = self.cover_urls["large"].replace("600", "org")
self.streamable = resp.get("streamable", False) self.streamable = resp.get("streamable", False)
self.bit_depth = resp.get("maximum_bit_depth") self.bit_depth = resp.get("maximum_bit_depth")
@ -156,14 +161,14 @@ class TrackMetadata:
self.sampling_rate *= 1000 self.sampling_rate *= 1000
elif self.__source == "tidal": elif self.__source == "tidal":
self.album = resp.get("title") self.album = resp.get("title", "Unknown Album")
self.tracktotal = resp.get("numberOfTracks", 1) self.tracktotal = resp.get("numberOfTracks", 1)
# genre not returned by API # genre not returned by API
self.date = resp.get("releaseDate") self.date = resp.get("releaseDate")
self.copyright = resp.get("copyright") self.copyright = resp.get("copyright")
self.albumartist = safe_get(resp, "artist", "name") self.albumartist = safe_get(resp, "artist", "name")
self.disctotal = resp.get("numberOfVolumes") self.disctotal = resp.get("numberOfVolumes", 1)
self.isrc = resp.get("isrc") self.isrc = resp.get("isrc")
# label not returned by API # label not returned by API
@ -185,8 +190,8 @@ class TrackMetadata:
self.sampling_rate = 44100 self.sampling_rate = 44100
elif self.__source == "deezer": elif self.__source == "deezer":
self.album = resp.get("title") self.album = resp.get("title", "Unknown Album")
self.tracktotal = resp.get("track_total") or resp.get("nb_tracks") self.tracktotal = resp.get("track_total", 0) or resp.get("nb_tracks", 0)
self.disctotal = ( self.disctotal = (
max(track.get("disk_number") for track in resp.get("tracks", [{}])) or 1 max(track.get("disk_number") for track in resp.get("tracks", [{}])) or 1
) )
@ -218,41 +223,37 @@ class TrackMetadata:
raise InvalidSourceError(self.__source) raise InvalidSourceError(self.__source)
def add_track_meta(self, track: dict): def add_track_meta(self, track: dict):
"""Parse the metadata from a track dict returned by an """Parse the metadata from a track dict returned by an API.
API.
:param track: :param track:
""" """
if self.__source == "qobuz": if self.__source == "qobuz":
self.title = track.get("title").strip() self.title = track["title"].strip()
self._mod_title(track.get("version"), track.get("work")) self._mod_title(track.get("version"), track.get("work"))
self.composer = track.get("composer", {}).get("name") self.composer = track.get("composer", {}).get("name")
self.tracknumber = track.get("track_number", 1) self.tracknumber = track.get("track_number", 1)
self.discnumber = track.get("media_number", 1) self.discnumber = track.get("media_number", 1)
self.artist = safe_get(track, "performer", "name") self.artist = safe_get(track, "performer", "name")
if self.artist is None:
self.artist = self.get("albumartist")
elif self.__source == "tidal": elif self.__source == "tidal":
self.title = track.get("title").strip() self.title = track["title"].strip()
self._mod_title(track.get("version"), None) self._mod_title(track.get("version"), None)
self.tracknumber = track.get("trackNumber", 1) self.tracknumber = track.get("trackNumber", 1)
self.discnumber = track.get("volumeNumber") self.discnumber = track.get("volumeNumber", 1)
self.artist = track.get("artist", {}).get("name") self.artist = track.get("artist", {}).get("name")
elif self.__source == "deezer": elif self.__source == "deezer":
self.title = track.get("title").strip() self.title = track["title"].strip()
self._mod_title(track.get("version"), None) self._mod_title(track.get("version"), None)
self.tracknumber = track.get("track_position", 1) self.tracknumber = track.get("track_position", 1)
self.discnumber = track.get("disk_number") self.discnumber = track.get("disk_number", 1)
self.artist = track.get("artist", {}).get("name") self.artist = track.get("artist", {}).get("name")
elif self.__source == "soundcloud": elif self.__source == "soundcloud":
self.title = track["title"].strip() self.title = track["title"].strip()
self.genre = track["genre"] self.genre = track["genre"]
self.artist = track["user"]["username"] self.artist = self.albumartist = track["user"]["username"]
self.albumartist = self.artist
self.year = track["created_at"][:4] self.year = track["created_at"][:4]
self.label = track["label_name"] self.label = track["label_name"]
self.description = track["description"] self.description = track["description"]
@ -265,7 +266,14 @@ class TrackMetadata:
if track.get("album"): if track.get("album"):
self.add_album_meta(track["album"]) self.add_album_meta(track["album"])
def _mod_title(self, version, work): def _mod_title(self, version: Optional[str], work: Optional[str]):
"""Modify title using the version and work.
:param version:
:type version: str
:param work:
:type work: str
"""
if version is not None: if version is not None:
self.title = f"{self.title} ({version})" self.title = f"{self.title} ({version})"
if work is not None: if work is not None:
@ -274,6 +282,10 @@ class TrackMetadata:
@property @property
def album(self) -> str: def album(self) -> str:
"""Return the album of the track.
:rtype: str
"""
assert hasattr(self, "_album"), "Must set album before accessing" assert hasattr(self, "_album"), "Must set album before accessing"
album = self._album album = self._album
@ -287,19 +299,21 @@ class TrackMetadata:
return album return album
@album.setter @album.setter
def album(self, val) -> str: def album(self, val):
"""Set the value of the album.
:param val:
"""
self._album = val self._album = val
@property @property
def artist(self) -> Optional[str]: def artist(self) -> Optional[str]:
"""Returns the value to set for the artist tag. Defaults to """Return the value to set for the artist tag.
`self.albumartist` if there is no track artist.
Defaults to `self.albumartist` if there is no track artist.
:rtype: str :rtype: str
""" """
if self._artist is None and self.albumartist is not None:
return self.albumartist
if self._artist is not None: if self._artist is not None:
return self._artist return self._artist
@ -307,7 +321,7 @@ class TrackMetadata:
@artist.setter @artist.setter
def artist(self, val: str): def artist(self, val: str):
"""Sets the internal artist variable to val. """Set the internal artist variable to val.
:param val: :param val:
:type val: str :type val: str
@ -316,10 +330,12 @@ class TrackMetadata:
@property @property
def genre(self) -> Optional[str]: def genre(self) -> Optional[str]:
"""Formats the genre list returned by the Qobuz API. """Format the genre list returned by an API.
>>> meta.genre = ['Pop/Rock', 'Pop/Rock→Rock', 'Pop/Rock→Rock→Alternatif et Indé']
>>> meta.genre It cleans up the Qobuz Response:
'Pop, Rock, Alternatif et Indé' >>> meta.genre = ['Pop/Rock', 'Pop/Rock→Rock', 'Pop/Rock→Rock→Alternatif et Indé']
>>> meta.genre
'Pop, Rock, Alternatif et Indé'
:rtype: str :rtype: str
""" """
@ -331,7 +347,7 @@ class TrackMetadata:
if isinstance(self._genres, list): if isinstance(self._genres, list):
if self.__source == "qobuz": if self.__source == "qobuz":
genres = re.findall(r"([^\u2192\/]+)", "/".join(self._genres)) genres: Iterable = re.findall(r"([^\u2192\/]+)", "/".join(self._genres))
genres = set(genres) genres = set(genres)
elif self.__source == "deezer": elif self.__source == "deezer":
genres = ", ".join(g["name"] for g in self._genres) genres = ", ".join(g["name"] for g in self._genres)
@ -344,8 +360,9 @@ class TrackMetadata:
raise TypeError(f"Genre must be list or str, not {type(self._genres)}") raise TypeError(f"Genre must be list or str, not {type(self._genres)}")
@genre.setter @genre.setter
def genre(self, val: Union[str, list]): def genre(self, val: Union[Iterable, dict]):
"""Sets the internal `genre` field to the given list. """Set the internal `genre` field to the given list.
It is not formatted until it is requested with `meta.genre`. It is not formatted until it is requested with `meta.genre`.
:param val: :param val:
@ -354,25 +371,25 @@ class TrackMetadata:
self._genres = val self._genres = val
@property @property
def copyright(self) -> Union[str, None]: def copyright(self) -> Optional[str]:
"""Formats the copyright string to use nice-looking unicode """Format the copyright string to use unicode characters.
characters.
:rtype: str, None :rtype: str, None
""" """
if hasattr(self, "_copyright"): if hasattr(self, "_copyright"):
if self._copyright is None: if self._copyright is None:
return None return None
copyright = re.sub(r"(?i)\(P\)", PHON_COPYRIGHT, self._copyright) copyright: str = re.sub(r"(?i)\(P\)", PHON_COPYRIGHT, self._copyright)
copyright = re.sub(r"(?i)\(C\)", COPYRIGHT, copyright) copyright = re.sub(r"(?i)\(C\)", COPYRIGHT, copyright)
return copyright return copyright
logger.debug("Accessed copyright tag before setting, return None") logger.debug("Accessed copyright tag before setting, returning None")
return None return None
@copyright.setter @copyright.setter
def copyright(self, val: str): def copyright(self, val: str):
"""Sets the internal copyright variable to the given value. """Set the internal copyright variable to the given value.
Only formatted when requested. Only formatted when requested.
:param val: :param val:
@ -382,7 +399,7 @@ class TrackMetadata:
@property @property
def year(self) -> Optional[str]: def year(self) -> Optional[str]:
"""Returns the year published of the track. """Return the year published of the track.
:rtype: str :rtype: str
""" """
@ -397,14 +414,14 @@ class TrackMetadata:
@year.setter @year.setter
def year(self, val): def year(self, val):
"""Sets the internal year variable to val. """Set the internal year variable to val.
:param val: :param val:
""" """
self._year = val self._year = val
def get_formatter(self) -> dict: def get_formatter(self) -> dict:
"""Returns a dict that is used to apply values to file format strings. """Return a dict that is used to apply values to file format strings.
:rtype: dict :rtype: dict
""" """
@ -412,21 +429,22 @@ class TrackMetadata:
return {k: getattr(self, k) for k in TRACK_KEYS} return {k: getattr(self, k) for k in TRACK_KEYS}
def tags(self, container: str = "flac") -> Generator: def tags(self, container: str = "flac") -> Generator:
"""Return a generator of (key, value) pairs to use for tagging """Create a generator of key, value pairs for use with mutagen.
files with mutagen. The *_KEY dicts are organized in the format
>>> {attribute_name: key_to_use_for_metadata} The *_KEY dicts are organized in the format:
>>> {attribute_name: key_to_use_for_metadata}
They are then converted to the format They are then converted to the format
>>> {key_to_use_for_metadata: value_of_attribute} >>> {key_to_use_for_metadata: value_of_attribute}
so that they can be used like this: so that they can be used like this:
>>> audio = MP4(path) >>> audio = MP4(path)
>>> for k, v in meta.tags(container='MP4'): >>> for k, v in meta.tags(container='MP4'):
... audio[k] = v ... audio[k] = v
>>> audio.save() >>> audio.save()
:param container: the container format :param container: the container format
:type container: str :type container: str
@ -442,7 +460,7 @@ class TrackMetadata:
raise InvalidContainerError(f"Invalid container {container}") raise InvalidContainerError(f"Invalid container {container}")
def __gen_flac_tags(self) -> Tuple[str, str]: def __gen_flac_tags(self) -> Generator:
"""Generate key, value pairs to tag FLAC files. """Generate key, value pairs to tag FLAC files.
:rtype: Tuple[str, str] :rtype: Tuple[str, str]
@ -456,7 +474,7 @@ class TrackMetadata:
logger.debug("Adding tag %s: %s", v, tag) logger.debug("Adding tag %s: %s", v, tag)
yield (v, str(tag)) yield (v, str(tag))
def __gen_mp3_tags(self) -> Tuple[str, str]: def __gen_mp3_tags(self) -> Generator:
"""Generate key, value pairs to tag MP3 files. """Generate key, value pairs to tag MP3 files.
:rtype: Tuple[str, str] :rtype: Tuple[str, str]
@ -472,9 +490,8 @@ class TrackMetadata:
if text is not None and v is not None: if text is not None and v is not None:
yield (v.__name__, v(encoding=3, text=text)) yield (v.__name__, v(encoding=3, text=text))
def __gen_mp4_tags(self) -> Tuple[str, Union[str, int, tuple]]: def __gen_mp4_tags(self) -> Generator:
"""Generate key, value pairs to tag ALAC or AAC files in """Generate key, value pairs to tag ALAC or AAC files.
an MP4 container.
:rtype: Tuple[str, str] :rtype: Tuple[str, str]
""" """
@ -490,6 +507,10 @@ class TrackMetadata:
yield (v, text) yield (v, text)
def asdict(self) -> dict: def asdict(self) -> dict:
"""Return a dict representation of self.
:rtype: dict
"""
ret = {} ret = {}
for attr in dir(self): for attr in dir(self):
if not attr.startswith("_") and not callable(getattr(self, attr)): if not attr.startswith("_") and not callable(getattr(self, attr)):
@ -512,9 +533,8 @@ class TrackMetadata:
""" """
return getattr(self, key) return getattr(self, key)
def get(self, key, default=None) -> str: def get(self, key, default=None):
"""Returns the requested attribute of the object, with """Return the requested attribute of the object, with a default value.
a default value.
:param key: :param key:
:param default: :param default:
@ -529,8 +549,10 @@ class TrackMetadata:
return default return default
def set(self, key, val) -> str: def set(self, key, val) -> str:
"""Equivalent to """Set an attribute.
>>> meta[key] = val
Equivalent to:
>>> meta[key] = val
:param key: :param key:
:param val: :param val:
@ -539,10 +561,16 @@ class TrackMetadata:
return self.__setitem__(key, val) return self.__setitem__(key, val)
def __hash__(self) -> int: def __hash__(self) -> int:
"""Get a hash of this.
Warning: slow.
:rtype: int
"""
return sum(hash(v) for v in self.asdict().values() if isinstance(v, Hashable)) return sum(hash(v) for v in self.asdict().values() if isinstance(v, Hashable))
def __repr__(self) -> str: def __repr__(self) -> str:
"""Returns the string representation of the metadata object. """Return the string representation of the metadata object.
:rtype: str :rtype: str
""" """

View file

@ -1,4 +1,7 @@
# Credits to Dash for this tool. """Get app id and secrets for Qobuz.
Credits to Dash for this tool.
"""
import base64 import base64
import re import re
@ -8,7 +11,10 @@ import requests
class Spoofer: class Spoofer:
"""Spoofs the information required to stream tracks from Qobuz."""
def __init__(self): def __init__(self):
"""Create a Spoofer."""
self.seed_timezone_regex = ( self.seed_timezone_regex = (
r'[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.ut' r'[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.ut'
r"imezone\.(?P<timezone>[a-z]+)\)" r"imezone\.(?P<timezone>[a-z]+)\)"
@ -33,11 +39,19 @@ class Spoofer:
bundle_req = requests.get("https://play.qobuz.com" + bundle_url) bundle_req = requests.get("https://play.qobuz.com" + bundle_url)
self.bundle = bundle_req.text self.bundle = bundle_req.text
def get_app_id(self): def get_app_id(self) -> str:
match = re.search(self.app_id_regex, self.bundle).group("app_id") """Get the app id.
return str(match)
:rtype: str
"""
match = re.search(self.app_id_regex, self.bundle)
if match is not None:
return str(match.group("app_id"))
raise Exception("Could not find app id.")
def get_secrets(self): def get_secrets(self):
"""Get secrets."""
seed_matches = re.finditer(self.seed_timezone_regex, self.bundle) seed_matches = re.finditer(self.seed_timezone_regex, self.bundle)
secrets = OrderedDict() secrets = OrderedDict()
for match in seed_matches: for match in seed_matches:

View file

@ -1,13 +1,13 @@
"""These classes parse information from Clients into a universal, """These classes parse information from Clients into a universal, downloadable form."""
downloadable form.
""" from __future__ import annotations
import functools import functools
import logging import logging
import os import os
import re import re
from tempfile import gettempdir from tempfile import gettempdir
from typing import Dict, Generator, Iterable, Union from typing import Dict, Generator, Iterable, Optional, Union
import click import click
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
@ -52,7 +52,11 @@ class Album(Tracklist):
self.sampling_rate = None self.sampling_rate = None
self.bit_depth = None self.bit_depth = None
self.container = None self.container: Optional[str] = None
self.disctotal: int
self.tracktotal: int
self.albumartist: str
# usually an unpacked TrackMetadata.asdict() # usually an unpacked TrackMetadata.asdict()
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
@ -66,7 +70,6 @@ class Album(Tracklist):
def load_meta(self): def load_meta(self):
"""Load detailed metadata from API using the id.""" """Load detailed metadata from API using the id."""
assert hasattr(self, "id"), "id must be set to load metadata" assert hasattr(self, "id"), "id must be set to load metadata"
resp = self.client.get(self.id, media_type="album") resp = self.client.get(self.id, media_type="album")
@ -82,6 +85,13 @@ class Album(Tracklist):
@classmethod @classmethod
def from_api(cls, resp: dict, client: Client): def from_api(cls, resp: dict, client: Client):
"""Create an Album object from an API response.
:param resp:
:type resp: dict
:param client:
:type client: Client
"""
if client.source == "soundcloud": if client.source == "soundcloud":
return Playlist.from_api(resp, client) return Playlist.from_api(resp, client)
@ -89,6 +99,10 @@ class Album(Tracklist):
return cls(client, **info.asdict()) return cls(client, **info.asdict())
def _prepare_download(self, **kwargs): def _prepare_download(self, **kwargs):
"""Prepare the download of the album.
:param kwargs:
"""
# Generate the folder name # Generate the folder name
self.folder_format = kwargs.get("folder_format", FOLDER_FORMAT) self.folder_format = kwargs.get("folder_format", FOLDER_FORMAT)
self.quality = min(kwargs.get("quality", 3), self.client.max_quality) self.quality = min(kwargs.get("quality", 3), self.client.max_quality)
@ -146,13 +160,24 @@ class Album(Tracklist):
for item in self.booklets: for item in self.booklets:
Booklet(item).download(parent_folder=self.folder) Booklet(item).download(parent_folder=self.folder)
def _download_item( def _download_item( # type: ignore
self, self,
track: Union[Track, Video], track: Union[Track, Video],
quality: int = 3, quality: int = 3,
database: MusicDB = None, database: MusicDB = None,
**kwargs, **kwargs,
) -> bool: ) -> bool:
"""Download an item.
:param track: The item.
:type track: Union[Track, Video]
:param quality:
:type quality: int
:param database:
:type database: MusicDB
:param kwargs:
:rtype: bool
"""
logger.debug("Downloading track to %s", self.folder) logger.debug("Downloading track to %s", self.folder)
if self.disctotal > 1 and isinstance(track, Track): if self.disctotal > 1 and isinstance(track, Track):
disc_folder = os.path.join(self.folder, f"Disc {track.meta.discnumber}") disc_folder = os.path.join(self.folder, f"Disc {track.meta.discnumber}")
@ -171,7 +196,7 @@ class Album(Tracklist):
return True return True
@staticmethod @staticmethod
def _parse_get_resp(resp: dict, client: Client) -> dict: def _parse_get_resp(resp: dict, client: Client) -> TrackMetadata:
"""Parse information from a client.get(query, 'album') call. """Parse information from a client.get(query, 'album') call.
:param resp: :param resp:
@ -183,8 +208,7 @@ class Album(Tracklist):
return meta return meta
def _load_tracks(self, resp): def _load_tracks(self, resp):
"""Given an album metadata dict returned by the API, append all of its """Load the tracks into self from an API response.
tracks to `self`.
This uses a classmethod to convert an item into a Track object, which This uses a classmethod to convert an item into a Track object, which
stores the metadata inside a TrackMetadata object. stores the metadata inside a TrackMetadata object.
@ -208,6 +232,10 @@ class Album(Tracklist):
) )
def _get_formatter(self) -> dict: def _get_formatter(self) -> dict:
"""Get a formatter that is used for previews in core.py.
:rtype: dict
"""
fmt = dict() fmt = dict()
for key in ALBUM_KEYS: for key in ALBUM_KEYS:
# default to None # default to None
@ -222,6 +250,14 @@ class Album(Tracklist):
return fmt return fmt
def _get_formatted_folder(self, parent_folder: str, quality: int) -> str: def _get_formatted_folder(self, parent_folder: str, quality: int) -> str:
"""Generate the folder name for this album.
:param parent_folder:
:type parent_folder: str
:param quality:
:type quality: int
:rtype: str
"""
# necessary to format the folder # necessary to format the folder
self.container = get_container(quality, self.client.source) self.container = get_container(quality, self.client.source)
if self.container in ("AAC", "MP3"): if self.container in ("AAC", "MP3"):
@ -234,10 +270,19 @@ class Album(Tracklist):
@property @property
def title(self) -> str: def title(self) -> str:
"""Get the title of the album.
:rtype: str
"""
return self.album return self.album
@title.setter @title.setter
def title(self, val: str): def title(self, val: str):
"""Set the title of the Album.
:param val:
:type val: str
"""
self.album = val self.album = val
def __repr__(self) -> str: def __repr__(self) -> str:
@ -252,17 +297,21 @@ class Album(Tracklist):
return f"<Album: V/A - {self.title}>" return f"<Album: V/A - {self.title}>"
def __str__(self) -> str: def __str__(self) -> str:
"""Return a readable string representation of """Return a readable string representation of this album.
this album.
:rtype: str :rtype: str
""" """
return f"{self['albumartist']} - {self['title']}" return f"{self['albumartist']} - {self['title']}"
def __len__(self) -> int: def __len__(self) -> int:
"""Get the length of the album.
:rtype: int
"""
return self.tracktotal return self.tracktotal
def __hash__(self): def __hash__(self):
"""Hash the album."""
return hash(self.id) return hash(self.id)
@ -297,8 +346,7 @@ class Playlist(Tracklist):
@classmethod @classmethod
def from_api(cls, resp: dict, client: Client): def from_api(cls, resp: dict, client: Client):
"""Return a Playlist object initialized with information from """Return a Playlist object from an API response.
a search result returned by the API.
:param resp: a single search result entry of a playlist :param resp: a single search result entry of a playlist
:type resp: dict :type resp: dict
@ -321,7 +369,7 @@ class Playlist(Tracklist):
self.loaded = True self.loaded = True
def _load_tracks(self, new_tracknumbers: bool = True): def _load_tracks(self, new_tracknumbers: bool = True):
"""Parses the tracklist returned by the API. """Parse the tracklist returned by the API.
:param new_tracknumbers: replace tracknumber tag with playlist position :param new_tracknumbers: replace tracknumber tag with playlist position
:type new_tracknumbers: bool :type new_tracknumbers: bool
@ -409,7 +457,7 @@ class Playlist(Tracklist):
self.__download_index = 1 # used for tracknumbers self.__download_index = 1 # used for tracknumbers
self.download_message() self.download_message()
def _download_item(self, item: Track, **kwargs): def _download_item(self, item: Track, **kwargs) -> bool: # type: ignore
kwargs["parent_folder"] = self.folder kwargs["parent_folder"] = self.folder
if self.client.source == "soundcloud": if self.client.source == "soundcloud":
item.load_meta() item.load_meta()
@ -433,8 +481,7 @@ class Playlist(Tracklist):
@staticmethod @staticmethod
def _parse_get_resp(item: dict, client: Client) -> dict: def _parse_get_resp(item: dict, client: Client) -> dict:
"""Parses information from a search result returned """Parse information from a search result returned by a client.search call.
by a client.search call.
:param item: :param item:
:type item: dict :type item: dict
@ -469,6 +516,10 @@ class Playlist(Tracklist):
@property @property
def title(self) -> str: def title(self) -> str:
"""Get the title.
:rtype: str
"""
return self.name return self.name
def __repr__(self) -> str: def __repr__(self) -> str:
@ -479,8 +530,7 @@ class Playlist(Tracklist):
return f"<Playlist: {self.name}>" return f"<Playlist: {self.name}>"
def __str__(self) -> str: def __str__(self) -> str:
"""Return a readable string representation of """Return a readable string representation of this track.
this track.
:rtype: str :rtype: str
""" """
@ -524,13 +574,18 @@ class Artist(Tracklist):
# override # override
def download(self, **kwargs): def download(self, **kwargs):
"""Download all items in self.
:param kwargs:
"""
iterator = self._prepare_download(**kwargs) iterator = self._prepare_download(**kwargs)
for item in iterator: for item in iterator:
self._download_item(item, **kwargs) self._download_item(item, **kwargs)
def _load_albums(self): def _load_albums(self):
"""From the discography returned by client.get(query, 'artist'), """Load Album objects to self.
generate album objects and append them to self.
This parses the response of client.get(query, 'artist') responses.
""" """
if self.client.source == "qobuz": if self.client.source == "qobuz":
self.name = self.meta["name"] self.name = self.meta["name"]
@ -554,13 +609,23 @@ class Artist(Tracklist):
def _prepare_download( def _prepare_download(
self, parent_folder: str = "StreamripDownloads", filters: tuple = (), **kwargs self, parent_folder: str = "StreamripDownloads", filters: tuple = (), **kwargs
) -> Iterable: ) -> Iterable:
"""Prepare the download.
:param parent_folder:
:type parent_folder: str
:param filters:
:type filters: tuple
:param kwargs:
:rtype: Iterable
"""
folder = sanitize_filename(self.name) folder = sanitize_filename(self.name)
folder = os.path.join(parent_folder, folder) self.folder = os.path.join(parent_folder, folder)
logger.debug("Artist folder: %s", folder) logger.debug("Artist folder: %s", folder)
logger.debug(f"Length of tracklist {len(self)}") logger.debug(f"Length of tracklist {len(self)}")
logger.debug(f"Filters: {filters}") logger.debug(f"Filters: {filters}")
final: Iterable
if "repeats" in filters: if "repeats" in filters:
final = self._remove_repeats(bit_depth=max, sampling_rate=min) final = self._remove_repeats(bit_depth=max, sampling_rate=min)
filters = tuple(f for f in filters if f != "repeats") filters = tuple(f for f in filters if f != "repeats")
@ -575,7 +640,7 @@ class Artist(Tracklist):
self.download_message() self.download_message()
return final return final
def _download_item( def _download_item( # type: ignore
self, self,
item, item,
parent_folder: str = "StreamripDownloads", parent_folder: str = "StreamripDownloads",
@ -583,15 +648,27 @@ class Artist(Tracklist):
database: MusicDB = None, database: MusicDB = None,
**kwargs, **kwargs,
) -> bool: ) -> bool:
"""Download an item.
:param item:
:param parent_folder:
:type parent_folder: str
:param quality:
:type quality: int
:param database:
:type database: MusicDB
:param kwargs:
:rtype: bool
"""
try: try:
item.load_meta() item.load_meta()
except NonStreamable: except NonStreamable:
logger.info("Skipping album, not available to stream.") logger.info("Skipping album, not available to stream.")
return return False
# always an Album # always an Album
status = item.download( status = item.download(
parent_folder=parent_folder, parent_folder=self.folder,
quality=quality, quality=quality,
database=database, database=database,
**kwargs, **kwargs,
@ -600,12 +677,17 @@ class Artist(Tracklist):
@property @property
def title(self) -> str: def title(self) -> str:
"""Get the artist name.
Implemented for consistency.
:rtype: str
"""
return self.name return self.name
@classmethod @classmethod
def from_api(cls, item: dict, client: Client, source: str = "qobuz"): def from_api(cls, item: dict, client: Client, source: str = "qobuz"):
"""Create an Artist object from the api response of Qobuz, Tidal, """Create an Artist object from the api response of Qobuz, Tidal, or Deezer.
or Deezer.
:param resp: response dict :param resp: response dict
:type resp: dict :type resp: dict
@ -652,8 +734,9 @@ class Artist(Tracklist):
} }
def _remove_repeats(self, bit_depth=max, sampling_rate=max) -> Generator: def _remove_repeats(self, bit_depth=max, sampling_rate=max) -> Generator:
"""Remove the repeated albums from self. May remove different """Remove the repeated albums from self.
versions of the same album.
May remove different versions of the same album.
:param bit_depth: either max or min functions :param bit_depth: either max or min functions
:param sampling_rate: either max or min functions :param sampling_rate: either max or min functions
@ -674,9 +757,7 @@ class Artist(Tracklist):
break break
def _non_studio_albums(self, album: Album) -> bool: def _non_studio_albums(self, album: Album) -> bool:
"""Passed as a parameter by the user. """Filter non-studio-albums.
This will download only studio albums.
:param artist: usually self :param artist: usually self
:param album: the album to check :param album: the album to check
@ -689,7 +770,7 @@ class Artist(Tracklist):
) )
def _features(self, album: Album) -> bool: def _features(self, album: Album) -> bool:
"""Passed as a parameter by the user. """Filter features.
This will download only albums where the requested This will download only albums where the requested
artist is the album artist. artist is the album artist.
@ -702,9 +783,7 @@ class Artist(Tracklist):
return self["name"] == album["albumartist"] return self["name"] == album["albumartist"]
def _extras(self, album: Album) -> bool: def _extras(self, album: Album) -> bool:
"""Passed as a parameter by the user. """Filter extras.
This will skip any extras.
:param artist: usually self :param artist: usually self
:param album: the album to check :param album: the album to check
@ -714,9 +793,7 @@ class Artist(Tracklist):
return self.TYPE_REGEXES["extra"].search(album.title) is None return self.TYPE_REGEXES["extra"].search(album.title) is None
def _non_remasters(self, album: Album) -> bool: def _non_remasters(self, album: Album) -> bool:
"""Passed as a parameter by the user. """Filter non remasters.
This will download only remasterd albums.
:param artist: usually self :param artist: usually self
:param album: the album to check :param album: the album to check
@ -726,7 +803,7 @@ class Artist(Tracklist):
return self.TYPE_REGEXES["remaster"].search(album.title) is not None return self.TYPE_REGEXES["remaster"].search(album.title) is not None
def _non_albums(self, album: Album) -> bool: def _non_albums(self, album: Album) -> bool:
"""This will ignore non-album releases. """Filter releases that are not albums.
:param artist: usually self :param artist: usually self
:param album: the album to check :param album: the album to check
@ -745,19 +822,22 @@ class Artist(Tracklist):
return f"<Artist: {self.name}>" return f"<Artist: {self.name}>"
def __str__(self) -> str: def __str__(self) -> str:
"""Return a readable string representation of """Return a readable string representation of this Artist.
this Artist.
:rtype: str :rtype: str
""" """
return self.name return self.name
def __hash__(self): def __hash__(self):
"""Hash self."""
return hash(self.id) return hash(self.id)
class Label(Artist): class Label(Artist):
"""Represents a downloadable Label."""
def load_meta(self): def load_meta(self):
"""Load metadata given an id."""
assert self.client.source == "qobuz", "Label source must be qobuz" assert self.client.source == "qobuz", "Label source must be qobuz"
resp = self.client.get(self.id, "label") resp = self.client.get(self.id, "label")
@ -768,11 +848,11 @@ class Label(Artist):
self.loaded = True self.loaded = True
def __repr__(self): def __repr__(self):
"""Return a string representation of the Label."""
return f"<Label - {self.name}>" return f"<Label - {self.name}>"
def __str__(self) -> str: def __str__(self) -> str:
"""Return a readable string representation of """Return the name of the Label.
this track.
:rtype: str :rtype: str
""" """
@ -783,7 +863,7 @@ class Label(Artist):
def _get_tracklist(resp: dict, source: str) -> list: def _get_tracklist(resp: dict, source: str) -> list:
"""Returns the tracklist from an API response. """Return the tracklist from an API response.
:param resp: :param resp:
:type resp: dict :type resp: dict

View file

@ -1,3 +1,5 @@
"""Miscellaneous utility functions."""
import base64 import base64
import contextlib import contextlib
import logging import logging
@ -5,7 +7,7 @@ import os
import re import re
import sys import sys
from string import Formatter from string import Formatter
from typing import Hashable, Optional, Union from typing import Dict, Hashable, Optional, Union
import click import click
import requests import requests
@ -24,12 +26,20 @@ logger = logging.getLogger(__name__)
def safe_get(d: dict, *keys: Hashable, default=None): def safe_get(d: dict, *keys: Hashable, default=None):
"""A replacement for chained `get()` statements on dicts: """Traverse dict layers safely.
>>> d = {'foo': {'bar': 'baz'}}
>>> _safe_get(d, 'baz') Usage:
None >>> d = {'foo': {'bar': 'baz'}}
>>> _safe_get(d, 'foo', 'bar') >>> _safe_get(d, 'baz')
'baz' None
>>> _safe_get(d, 'foo', 'bar')
'baz'
:param d:
:type d: dict
:param keys:
:type keys: Hashable
:param default: the default value to use if a key isn't found
""" """
curr = d curr = d
res = default res = default
@ -43,15 +53,15 @@ def safe_get(d: dict, *keys: Hashable, default=None):
def get_quality(quality_id: int, source: str) -> Union[str, int]: def get_quality(quality_id: int, source: str) -> Union[str, int]:
"""Given the quality id in (0, 1, 2, 3, 4), return the streaming quality """Get the source-specific quality id.
value to send to the api for a given source.
:param quality_id: the quality id :param quality_id: the universal quality id (0, 1, 2, 4)
:type quality_id: int :type quality_id: int
:param source: qobuz, tidal, or deezer :param source: qobuz, tidal, or deezer
:type source: str :type source: str
:rtype: Union[str, int] :rtype: Union[str, int]
""" """
q_map: Dict[int, Union[int, str]]
if source == "qobuz": if source == "qobuz":
q_map = { q_map = {
1: 5, 1: 5,
@ -81,15 +91,15 @@ def get_quality(quality_id: int, source: str) -> Union[str, int]:
def get_quality_id(bit_depth: Optional[int], sampling_rate: Optional[int]): def get_quality_id(bit_depth: Optional[int], sampling_rate: Optional[int]):
"""Return a quality id in (5, 6, 7, 27) from bit depth and """Get the universal quality id from bit depth and sampling rate.
sampling rate. If None is provided, mp3/lossy is assumed.
:param bit_depth: :param bit_depth:
:type bit_depth: Optional[int] :type bit_depth: Optional[int]
:param sampling_rate: :param sampling_rate:
:type sampling_rate: Optional[int] :type sampling_rate: Optional[int]
""" """
if not (bit_depth or sampling_rate): # is lossy # XXX: Should `0` quality be supported?
if bit_depth is None or sampling_rate is None: # is lossy
return 1 return 1
if bit_depth == 16: if bit_depth == 16:
@ -102,22 +112,8 @@ def get_quality_id(bit_depth: Optional[int], sampling_rate: Optional[int]):
return 4 return 4
@contextlib.contextmanager
def std_out_err_redirect_tqdm():
orig_out_err = sys.stdout, sys.stderr
try:
sys.stdout, sys.stderr = map(DummyTqdmFile, orig_out_err)
yield orig_out_err[0]
# Relay exceptions
except Exception as exc:
raise exc
# Always restore sys.stdout/err if necessary
finally:
sys.stdout, sys.stderr = orig_out_err
def tqdm_download(url: str, filepath: str, params: dict = None, desc: str = None): def tqdm_download(url: str, filepath: str, params: dict = None, desc: str = None):
"""Downloads a file with a progress bar. """Download a file with a progress bar.
:param url: url to direct download :param url: url to direct download
:param filepath: file to write :param filepath: file to write
@ -157,7 +153,7 @@ def tqdm_download(url: str, filepath: str, params: dict = None, desc: str = None
def clean_format(formatter: str, format_info): def clean_format(formatter: str, format_info):
"""Formats track or folder names sanitizing every formatter key. """Format track or folder names sanitizing every formatter key.
:param formatter: :param formatter:
:type formatter: str :type formatter: str
@ -180,6 +176,11 @@ def clean_format(formatter: str, format_info):
def tidal_cover_url(uuid, size): def tidal_cover_url(uuid, size):
"""Generate a tidal cover url.
:param uuid:
:param size:
"""
possibles = (80, 160, 320, 640, 1280) possibles = (80, 160, 320, 640, 1280)
assert size in possibles, f"size must be in {possibles}" assert size in possibles, f"size must be in {possibles}"
@ -202,6 +203,12 @@ def init_log(path: Optional[str] = None, level: str = "DEBUG"):
def decrypt_mqa_file(in_path, out_path, encryption_key): def decrypt_mqa_file(in_path, out_path, encryption_key):
"""Decrypt an MQA file.
:param in_path:
:param out_path:
:param encryption_key:
"""
# Do not change this # Do not change this
master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754=" master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754="
@ -233,6 +240,13 @@ def decrypt_mqa_file(in_path, out_path, encryption_key):
def ext(quality: int, source: str): def ext(quality: int, source: str):
"""Get the extension of an audio file.
:param quality:
:type quality: int
:param source:
:type source: str
"""
if quality <= 1: if quality <= 1:
if source == "tidal": if source == "tidal":
return ".m4a" return ".m4a"
@ -245,6 +259,16 @@ def ext(quality: int, source: str):
def gen_threadsafe_session( def gen_threadsafe_session(
headers: dict = None, pool_connections: int = 100, pool_maxsize: int = 100 headers: dict = None, pool_connections: int = 100, pool_maxsize: int = 100
) -> requests.Session: ) -> requests.Session:
"""Create a new Requests session with a large poolsize.
:param headers:
:type headers: dict
:param pool_connections:
:type pool_connections: int
:param pool_maxsize:
:type pool_maxsize: int
:rtype: requests.Session
"""
if headers is None: if headers is None:
headers = {} headers = {}
@ -266,6 +290,9 @@ def decho(message, fg=None):
logger.debug(message) logger.debug(message)
interpreter_artist_regex = re.compile(r"getSimilarArtist\(\s*'(\w+)'")
def extract_interpreter_url(url: str) -> str: def extract_interpreter_url(url: str) -> str:
"""Extract artist ID from a Qobuz interpreter url. """Extract artist ID from a Qobuz interpreter url.
@ -275,13 +302,20 @@ def extract_interpreter_url(url: str) -> str:
""" """
session = gen_threadsafe_session({"User-Agent": AGENT}) session = gen_threadsafe_session({"User-Agent": AGENT})
r = session.get(url) r = session.get(url)
artist_id = re.search(r"getSimilarArtist\(\s*'(\w+)'", r.text).group(1) match = interpreter_artist_regex.search(r.text)
return artist_id if match:
return match.group(1)
raise Exception(
"Unable to extract artist id from interpreter url. Use a "
"url that contains an artist id."
)
def get_container(quality: int, source: str) -> str: def get_container(quality: int, source: str) -> str:
"""Get the "container" given the quality. `container` can also be the """Get the file container given the quality.
the codec; both work.
`container` can also be the the codec; both work.
:param quality: quality id :param quality: quality id
:type quality: int :type quality: int
@ -290,11 +324,9 @@ def get_container(quality: int, source: str) -> str:
:rtype: str :rtype: str
""" """
if quality >= 2: if quality >= 2:
container = "FLAC" return "FLAC"
else:
if source == "tidal":
container = "AAC"
else:
container = "MP3"
return container if source == "tidal":
return "AAC"
return "MP3"