mirror of
https://github.com/nathom/streamrip.git
synced 2025-05-13 06:34:45 -04:00
Merge dev
This commit is contained in:
commit
d4c31122fa
15 changed files with 909 additions and 384 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -19,3 +19,4 @@ StreamripDownloads
|
||||||
*.pyc
|
*.pyc
|
||||||
*test.py
|
*test.py
|
||||||
/.mypy_cache
|
/.mypy_cache
|
||||||
|
/streamrip/test.yaml
|
||||||
|
|
20
.mypy.ini
Normal file
20
.mypy.ini
Normal 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
|
|
@ -1,7 +1,11 @@
|
||||||
# streamrip
|
# streamrip
|
||||||
|
|
||||||
|
[](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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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={})
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue