mirror of
https://github.com/nathom/streamrip.git
synced 2025-05-12 22:26:16 -04:00
Merge branch 'dev'
# Conflicts: # setup.py # streamrip/__init__.py
This commit is contained in:
commit
48fb99494b
9 changed files with 130 additions and 27 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -13,3 +13,9 @@ StreamripDownloads
|
||||||
*.wav
|
*.wav
|
||||||
*.log
|
*.log
|
||||||
*.mp4
|
*.mp4
|
||||||
|
*.opus
|
||||||
|
*.mkv
|
||||||
|
*.aac
|
||||||
|
*.pyc
|
||||||
|
*test.py
|
||||||
|
/.mypy_cache
|
||||||
|
|
|
@ -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
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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,10 +288,7 @@ 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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue