Merge branch 'dev'

# Conflicts:
#	setup.py
#	streamrip/__init__.py
This commit is contained in:
nathom 2021-04-26 16:37:13 -07:00
commit 48fb99494b
9 changed files with 130 additions and 27 deletions

6
.gitignore vendored
View file

@ -13,3 +13,9 @@ StreamripDownloads
*.wav *.wav
*.log *.log
*.mp4 *.mp4
*.opus
*.mkv
*.aac
*.pyc
*test.py
/.mypy_cache

View file

@ -165,6 +165,14 @@ can be accessed with `rip config --open`.
``` ```
## Integration with macOS Music app
`streamrip` was designed to be used seamlessly with the macOS Music app. To set it up, you need to find the `Automatically Add to Music.localized` folder inside the file given at `Music.app -> Preferences -> Files -> Music Media folder location`. Set the downloads folder to the path in the config file.
Next, enable `conversion` and set the `codec` to `alac`. If you want to save space, set `sampling_rate` to `48000`. Finally, set `keep_hires_cover` to `false`.
Now, you can download anything and it will appear in your Library!
## Troubleshooting ## Troubleshooting

View file

@ -14,7 +14,7 @@ requirements = read_file("requirements.txt").strip().split()
# https://github.com/pypa/sampleproject/blob/main/setup.py # https://github.com/pypa/sampleproject/blob/main/setup.py
setup( setup(
name=pkg_name, name=pkg_name,
version="0.4.4", version="0.5",
author="Nathan", author="Nathan",
author_email="nathanthomas707@gmail.com", author_email="nathanthomas707@gmail.com",
keywords="lossless, hi-res, qobuz, tidal, deezer, audio, convert, soundcloud, mp3", keywords="lossless, hi-res, qobuz, tidal, deezer, audio, convert, soundcloud, mp3",

View file

@ -1,4 +1,4 @@
"""streamrip: the all in one music downloader. """streamrip: the all in one music downloader.
""" """
__version__ = "0.4.4" __version__ = "0.5"

View file

@ -765,7 +765,7 @@ class Tracklist(list):
else: else:
for item in self: for item in self:
if self.client.source != 'soundcloud': if self.client.source != "soundcloud":
# soundcloud only gets metadata after `target` is called # soundcloud only gets metadata after `target` is called
# message will be printed in `target` # message will be printed in `target`
click.secho(f'\nDownloading "{item!s}"', fg="blue") click.secho(f'\nDownloading "{item!s}"', fg="blue")
@ -951,3 +951,62 @@ class Tracklist(list):
if isinstance(key, int): if isinstance(key, int):
super().__setitem__(key, val) super().__setitem__(key, val)
class YoutubeVideo:
"""Dummy class implemented for consistency with the Media API."""
class DummyClient:
source = "youtube"
def __init__(self, url: str):
self.url = url
self.client = self.DummyClient()
def download(
self,
parent_folder="StreamripDownloads",
download_youtube_videos=False,
youtube_video_downloads_folder="StreamripDownloads",
**kwargs,
):
click.secho(f"Downloading url {self.url}", fg="blue")
filename_formatter = "%(track_number)s.%(track)s.%(container)s"
filename = os.path.join(parent_folder, filename_formatter)
p = subprocess.Popen(
[
"youtube-dl",
"-x", # audio only
"-q", # quiet mode
"--add-metadata",
"--audio-format",
"mp3",
"--embed-thumbnail",
"-o",
filename,
self.url,
]
)
if download_youtube_videos:
click.secho("Downloading video stream", fg='blue')
pv = subprocess.Popen(
[
"youtube-dl",
"-q",
"-o",
os.path.join(
youtube_video_downloads_folder, "%(title)s.%(container)s"
),
self.url,
]
)
pv.wait()
p.wait()
def load_meta(self, *args, **kwargs):
pass
def tag(self, *args, **kwargs):
pass

View file

@ -68,6 +68,11 @@ class Config:
"soundcloud": { "soundcloud": {
"quality": 0, "quality": 0,
}, },
"youtube": {
"quality": 0,
"download_videos": False,
"video_downloads_folder": DOWNLOADS_DIR,
},
"database": {"enabled": True, "path": None}, "database": {"enabled": True, "path": None},
"conversion": { "conversion": {
"enabled": False, "enabled": False,
@ -225,6 +230,10 @@ class ConfigDocumentation:
quality: 0, 1, or 2 quality: 0, 1, or 2
soundcloud: soundcloud:
quality: Only 0 is available quality: Only 0 is available
youtube:
quality: Only 0 is available for now
download_videos: Download the video along with the audio
video_downloads_folder: The path to download the videos to
database: This stores a list of item IDs so that repeats are not downloaded. database: This stores a list of item IDs so that repeats are not downloaded.
filters: Filter a Qobuz artist's discography. Set to 'true' to turn on a filter. filters: Filter a Qobuz artist's discography. Set to 'true' to turn on a filter.
extras: Remove Collectors Editions, live recordings, etc. extras: Remove Collectors Editions, live recordings, etc.

View file

@ -12,7 +12,8 @@ CONFIG_PATH = os.path.join(CONFIG_DIR, "config.yaml")
LOG_DIR = click.get_app_dir(APPNAME) LOG_DIR = click.get_app_dir(APPNAME)
DB_PATH = os.path.join(LOG_DIR, "downloads.db") DB_PATH = os.path.join(LOG_DIR, "downloads.db")
DOWNLOADS_DIR = os.path.join(Path.home(), "StreamripDownloads") HOME = Path.home()
DOWNLOADS_DIR = os.path.join(HOME, "StreamripDownloads")
AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0" AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"
@ -145,7 +146,7 @@ LASTFM_URL_REGEX = r"https://www.last.fm/user/\w+/playlists/\w+"
QOBUZ_INTERPRETER_URL_REGEX = ( QOBUZ_INTERPRETER_URL_REGEX = (
r"https?://www\.qobuz\.com/\w\w-\w\w/interpreter/[-\w]+/[-\w]+" r"https?://www\.qobuz\.com/\w\w-\w\w/interpreter/[-\w]+/[-\w]+"
) )
YOUTUBE_URL_REGEX = r"https://www\.youtube\.com/watch\?v=[-\w]+"
TIDAL_MAX_Q = 7 TIDAL_MAX_Q = 7

View file

@ -12,13 +12,14 @@ import click
import requests import requests
from tqdm import tqdm from tqdm import tqdm
from .bases import Track, Video from .bases import Track, Video, YoutubeVideo
from .clients import DeezerClient, QobuzClient, SoundCloudClient, TidalClient from .clients import DeezerClient, QobuzClient, SoundCloudClient, TidalClient
from .config import Config from .config import Config
from .constants import ( from .constants import (
CONFIG_PATH, CONFIG_PATH,
DB_PATH, DB_PATH,
LASTFM_URL_REGEX, LASTFM_URL_REGEX,
YOUTUBE_URL_REGEX,
MEDIA_TYPES, MEDIA_TYPES,
QOBUZ_INTERPRETER_URL_REGEX, QOBUZ_INTERPRETER_URL_REGEX,
SOUNDCLOUD_URL_REGEX, SOUNDCLOUD_URL_REGEX,
@ -58,6 +59,7 @@ class MusicDL(list):
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.config = config self.config = config
if self.config is None: if self.config is None:
@ -89,7 +91,17 @@ class MusicDL(list):
:raises ParsingError :raises ParsingError
""" """
for source, url_type, item_id in self.parse_urls(url): # youtube is handled by youtube-dl, so much of the
# processing is not necessary
youtube_urls = self.youtube_url_parse.findall(url)
if youtube_urls != []:
self.extend(YoutubeVideo(u) for u in youtube_urls)
parsed = self.parse_urls(url)
if not parsed and len(self) == 0:
raise ParsingError(url)
for source, url_type, item_id in parsed:
if item_id in self.db: if item_id in self.db:
logger.info( logger.info(
f"ID {item_id} already downloaded, use --no-db to override." f"ID {item_id} already downloaded, use --no-db to override."
@ -135,6 +147,12 @@ class MusicDL(list):
], ],
"download_videos": self.config.session["tidal"]["download_videos"], "download_videos": self.config.session["tidal"]["download_videos"],
"download_booklets": self.config.session["qobuz"]["download_booklets"], "download_booklets": self.config.session["qobuz"]["download_booklets"],
"download_youtube_videos": self.config.session["youtube"][
"download_videos"
],
"youtube_video_downloads_folder": self.config.session["youtube"][
"video_downloads_folder"
],
} }
def download(self): def download(self):
@ -157,7 +175,7 @@ class MusicDL(list):
) )
click.secho("rip config --reset ", fg="yellow", nl=False) click.secho("rip config --reset ", fg="yellow", nl=False)
click.secho("to reset it. You will need to log in again.", fg="red") click.secho("to reset it. You will need to log in again.", fg="red")
logger.debug(err) click.secho(err, fg='red')
exit() exit()
logger.debug("Arguments from config: %s", arguments) logger.debug("Arguments from config: %s", arguments)
@ -170,6 +188,10 @@ class MusicDL(list):
item.client.source item.client.source
) )
if item is YoutubeVideo:
item.download(**arguments)
continue
arguments["quality"] = self.config.session[item.client.source]["quality"] arguments["quality"] = self.config.session[item.client.source]["quality"]
if isinstance(item, Artist): if isinstance(item, Artist):
filters_ = tuple( filters_ = tuple(
@ -266,11 +288,8 @@ class MusicDL(list):
logger.debug(f"Parsed urls: {parsed}") logger.debug(f"Parsed urls: {parsed}")
if parsed != []:
return parsed return parsed
raise ParsingError(f"Error parsing URL: `{url}`")
def handle_lastfm_urls(self, urls): def handle_lastfm_urls(self, urls):
# 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+")

View file

@ -7,7 +7,7 @@ import logging
import os import os
import re import re
from tempfile import gettempdir from tempfile import gettempdir
from typing import Generator, Iterable, Union from typing import Dict, Generator, Iterable, Union
import click import click
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
@ -28,11 +28,6 @@ from .utils import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TYPE_REGEXES = {
"remaster": re.compile(r"(?i)(re)?master(ed)?"),
"extra": re.compile(r"(?i)(anniversary|deluxe|live|collector|demo|expanded|remix)"),
}
class Album(Tracklist): class Album(Tracklist):
"""Represents a downloadable album. """Represents a downloadable album.
@ -415,10 +410,10 @@ class Playlist(Tracklist):
self.download_message() self.download_message()
def _download_item(self, item: Track, **kwargs): def _download_item(self, item: Track, **kwargs):
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()
click.secho(f"Downloading {item!s}", fg='blue') click.secho(f"Downloading {item!s}", fg="blue")
if kwargs.get("set_playlist_to_album", False): if kwargs.get("set_playlist_to_album", False):
item["album"] = self.name item["album"] = self.name
@ -649,6 +644,13 @@ class Artist(Tracklist):
# ----------- Filters -------------- # ----------- Filters --------------
TYPE_REGEXES = {
"remaster": re.compile(r"(?i)(re)?master(ed)?"),
"extra": re.compile(
r"(?i)(anniversary|deluxe|live|collector|demo|expanded|remix)"
),
}
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. May remove different
versions of the same album. versions of the same album.
@ -656,7 +658,7 @@ class Artist(Tracklist):
: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
""" """
groups = dict() groups: Dict[str, list] = {}
for album in self: for album in self:
if (t := self.essence(album.title)) not in groups: if (t := self.essence(album.title)) not in groups:
groups[t] = [] groups[t] = []
@ -683,7 +685,7 @@ class Artist(Tracklist):
""" """
return ( return (
album["albumartist"] != "Various Artists" album["albumartist"] != "Various Artists"
and TYPE_REGEXES["extra"].search(album.title) is None and self.TYPE_REGEXES["extra"].search(album.title) is None
) )
def _features(self, album: Album) -> bool: def _features(self, album: Album) -> bool:
@ -709,7 +711,7 @@ class Artist(Tracklist):
:type album: Album :type album: Album
:rtype: bool :rtype: bool
""" """
return 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. """Passed as a parameter by the user.
@ -721,7 +723,7 @@ class Artist(Tracklist):
:type album: Album :type album: Album
:rtype: bool :rtype: bool
""" """
return 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. """This will ignore non-album releases.
@ -731,8 +733,7 @@ class Artist(Tracklist):
:type album: Album :type album: Album
:rtype: bool :rtype: bool
""" """
# Doesn't work yet return len(album) > 1
return album["release_type"] == "album"
# --------- Magic Methods -------- # --------- Magic Methods --------
@ -751,7 +752,7 @@ class Artist(Tracklist):
""" """
return self.name return self.name
def __hash__(self) -> int: def __hash__(self):
return hash(self.id) return hash(self.id)