Merge branch 'dev' into 429

This commit is contained in:
Nathan Thomas 2023-12-24 11:28:19 -08:00
commit 8c93b88718
18 changed files with 207 additions and 212 deletions

View file

@ -1,7 +0,0 @@
[flake8]
extend-ignore = E203, E266, E501
# line length is intentionally set to 80 here because black uses Bugbear
# See https://github.com/psf/black/blob/master/docs/the_black_code_style.md#line-length for more details
max-line-length = 80
max-complexity = 18
select = B,C,E,F,W,T4,B9

View file

View file

@ -1,53 +0,0 @@
[mypy-mutagen.*]
ignore_missing_imports = True
[mypy-tqdm.*]
ignore_missing_imports = True
[mypy-pathvalidate.*]
ignore_missing_imports = True
[mypy-pick.*]
ignore_missing_imports = True
[mypy-simple_term_menu.*]
ignore_missing_imports = True
[mypy-setuptools.*]
ignore_missing_imports = True
[mypy-requests.*]
ignore_missing_imports = True
[mypy-tomlkit.*]
ignore_missing_imports = True
[mypy-Crypto.*]
ignore_missing_imports = True
[mypy-Cryptodome.*]
ignore_missing_imports = True
[mypy-click.*]
ignore_missing_imports = True
[mypy-PIL.*]
ignore_missing_imports = True
[mypy-cleo.*]
ignore_missing_imports = True
[mypy-deezer.*]
ignore_missing_imports = True
[mypy-appdirs.*]
ignore_missing_imports = True
[mypy-m3u8.*]
ignore_missing_imports = True
[mypy-aiohttp.*]
ignore_missing_imports = True
[mypy-aiofiles.*]
ignore_missing_imports = True

View file

@ -1,27 +1,27 @@
![streamrip logo](https://github.com/nathom/streamrip/blob/dev/demo/logo.svg?raw=true)
![streamrip](demo/logo.svg)
[![Downloads](https://pepy.tech/badge/streamrip)](https://pepy.tech/project/streamrip) [![Downloads](https://pepy.tech/badge/streamrip)](https://pepy.tech/project/streamrip)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black)
A scriptable stream downloader for Qobuz, Tidal, Deezer and SoundCloud. A scriptable stream downloader for Qobuz, Tidal, Deezer and SoundCloud.
![Streamrip downloading an album](https://github.com/nathom/streamrip/blob/dev/demo/download_album.png?raw=true) ![downloading an album](https://github.com/nathom/streamrip/blob/dev/demo/download_album.png?raw=true)
## Features ## Features
- Super fast, as it utilizes concurrent downloads and conversion - Fast, concurrent downloads powered by `aiohttp`
- Downloads tracks, albums, playlists, discographies, and labels from Qobuz, Tidal, Deezer, and SoundCloud - Downloads tracks, albums, playlists, discographies, and labels from Qobuz, Tidal, Deezer, and SoundCloud
- Supports downloads of Spotify and Apple Music playlists through [last.fm](https://www.last.fm) - Supports downloads of Spotify and Apple Music playlists through [last.fm](https://www.last.fm)
- Automatically converts files to a preferred format - Automatically converts files to a preferred format
- Has a database that stores the downloaded tracks' IDs so that repeats are avoided - Has a database that stores the downloaded tracks' IDs so that repeats are avoided
- Easy to customize with the config file - Concurrency and rate limiting
- Interactive search for all sources
- Highly customizable through the config file
- Integration with `youtube-dl` - Integration with `youtube-dl`
## Installation ## Installation
First, ensure [Python](https://www.python.org/downloads/) (version 3.8 or greater) and [pip](https://pip.pypa.io/en/stable/installing/) are installed. If you are on Windows, install [Microsoft Visual C++ Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/). Then run the following in the command line: First, ensure [Python](https://www.python.org/downloads/) (version 3.10 or greater) and [pip](https://pip.pypa.io/en/stable/installing/) are installed. Then install `ffmpeg`. You may choose not to install this, but some functionality will be limited.
```bash ```bash
pip3 install streamrip --upgrade pip3 install streamrip --upgrade
@ -35,7 +35,6 @@ rip
it should show the main help page. If you have no idea what these mean, or are having other issues installing, check out the [detailed installation instructions](https://github.com/nathom/streamrip/wiki#detailed-installation-instructions). it should show the main help page. If you have no idea what these mean, or are having other issues installing, check out the [detailed installation instructions](https://github.com/nathom/streamrip/wiki#detailed-installation-instructions).
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). To download music from YouTube, install [youtube-dl](https://github.com/ytdl-org/youtube-dl#installation).
### Streamrip beta ### Streamrip beta
@ -83,29 +82,24 @@ To set the maximum quality, use the `--max-quality` option to `0, 1, 2, 3, 4`:
| 4 | 24 bit, ≤ 192 kHz | Qobuz | | 4 | 24 bit, ≤ 192 kHz | Qobuz |
```bash ```bash
rip url --max-quality 3 https://tidal.com/browse/album/147569387 rip url --quality 3 https://tidal.com/browse/album/147569387
``` ```
> Using `4` is generally a waste of space. It is impossible for humans to perceive the between sampling rates higher than 44.1 kHz. It may be useful if you're processing/slowing down the audio.
Search for albums matching `lil uzi vert` on SoundCloud Search for albums matching `lil uzi vert` on SoundCloud
```bash ```bash
rip search --source soundcloud 'lil uzi vert' rip search tidal playlist 'rap'
``` ```
![streamrip interactive search](https://github.com/nathom/streamrip/blob/dev/demo/album_search.png?raw=true) ![streamrip interactive search](https://github.com/nathom/streamrip/blob/dev/demo/playlist_search.png?raw=true)
Search for *Rumours* on Tidal, and download it Search for *Rumours* on Tidal, and download it
```bash ```bash
rip search 'fleetwood mac rumours' rip search tidal album 'fleetwood mac rumours'
```
Want to find some new music? Use the `discover` command (only on Qobuz)
```bash
rip discover --list 'best-sellers'
``` ```
Download a last.fm playlist using the lastfm command Download a last.fm playlist using the lastfm command
@ -114,18 +108,18 @@ Download a last.fm playlist using the lastfm command
rip lastfm https://www.last.fm/user/nathan3895/playlists/12126195 rip lastfm https://www.last.fm/user/nathan3895/playlists/12126195
``` ```
For extreme customization, see the config file For more customization, see the config file
``` ```
rip config --open rip config open
``` ```
If you're confused about anything, see the help pages. The main help pages can be accessed by typing `rip` by itself in the command line. The help pages for each command can be accessed with the `-h` flag. For example, to see the help page for the `url` command, type If you're confused about anything, see the help pages. The main help pages can be accessed by typing `rip` by itself in the command line. The help pages for each command can be accessed with the `-help` flag. For example, to see the help page for the `url` command, type
``` ```
rip url -h rip url --help
``` ```
![example_help_page.png](https://github.com/nathom/streamrip/blob/dev/demo/example_help_page.png?raw=true) ![example_help_page.png](https://github.com/nathom/streamrip/blob/dev/demo/example_help_page.png?raw=true)
@ -177,13 +171,8 @@ Thanks to Vitiko98, Sorrow446, and DashLt for their contributions to this projec
## Disclaimer ## Disclaimer
I will not be responsible for how **you** use `streamrip`. By using `streamrip`, you agree to the terms and conditions of the Qobuz, Tidal, and Deezer APIs.
I will not be responsible for how you use `streamrip`. By using `streamrip`, you agree to the terms and conditions of the Qobuz, Tidal, and Deezer APIs. ## Sponsorship
## Donations/Sponsorship Consider becoming a Github sponsor for me if you enjoy my open source software.
<a href="https://www.buymeacoffee.com/nathom" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174"></a>
Consider contributing some funds [here](https://www.buymeacoffee.com/nathom), which will go towards holding
the premium subscriptions that I need to debug and improve streamrip. Thanks for your support!

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 KiB

After

Width:  |  Height:  |  Size: 641 KiB

BIN
demo/playlist_search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

View file

@ -181,6 +181,7 @@ class DeezerClient(Client):
) )
dl_info["url"] = url dl_info["url"] = url
logger.debug("dz track info: %s", track_info)
return DeezerDownloadable(self.session, dl_info) return DeezerDownloadable(self.session, dl_info)
def _get_encrypted_file_url( def _get_encrypted_file_url(
@ -212,5 +213,6 @@ class DeezerClient(Client):
path = binascii.hexlify( path = binascii.hexlify(
AES.new(b"jo6aey6haid2Teih", AES.MODE_ECB).encrypt(info_bytes), AES.new(b"jo6aey6haid2Teih", AES.MODE_ECB).encrypt(info_bytes),
).decode("utf-8") ).decode("utf-8")
url = f"https://e-cdns-proxy-{track_hash[0]}.dzcdn.net/mobile/1/{path}"
return f"https://e-cdns-proxy-{track_hash[0]}.dzcdn.net/mobile/1/{path}" logger.debug("Encrypted file path %s", url)
return url

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
import itertools import itertools
import logging import logging
import random
import re import re
from ..config import Config from ..config import Config
@ -8,8 +9,9 @@ from ..exceptions import NonStreamableError
from .client import Client from .client import Client
from .downloadable import SoundcloudDownloadable from .downloadable import SoundcloudDownloadable
# e.g. 123456-293847-121314-209849
USER_ID = "-".join(str(random.randint(111111, 999999)) for _ in range(4))
BASE = "https://api-v2.soundcloud.com" BASE = "https://api-v2.soundcloud.com"
SOUNDCLOUD_USER_ID = "672320-86895-162383-801513"
STOCK_URL = "https://soundcloud.com/" STOCK_URL = "https://soundcloud.com/"
# for playlists # for playlists
@ -36,7 +38,7 @@ class SoundcloudClient(Client):
async def login(self): async def login(self):
self.session = await self.get_session() self.session = await self.get_session()
client_id, app_version = self.config.client_id, self.config.app_version client_id, app_version = self.config.client_id, self.config.app_version
if not client_id or not app_version or not (await self._announce()): if not client_id or not app_version or not (await self._announce_success()):
client_id, app_version = await self._refresh_tokens() client_id, app_version = await self._refresh_tokens()
# update file and session configs and save to disk # update file and session configs and save to disk
cf = self.global_config.file.soundcloud cf = self.global_config.file.soundcloud
@ -54,13 +56,14 @@ class SoundcloudClient(Client):
"""Fetch metadata for an item in Soundcloud API. """Fetch metadata for an item in Soundcloud API.
Args: Args:
----
item_id (str): Plain soundcloud item ID (e.g 1633786176) item_id (str): Plain soundcloud item ID (e.g 1633786176)
media_type (str): track or playlist media_type (str): track or playlist
Returns: Returns:
-------
API response. API response. The item IDs for the tracks in the playlist are modified to
include resolution status.
""" """
if media_type == "track": if media_type == "track":
# parse custom id that we injected # parse custom id that we injected
@ -71,6 +74,82 @@ class SoundcloudClient(Client):
else: else:
raise Exception(f"{media_type} not supported") raise Exception(f"{media_type} not supported")
async def search(
self,
media_type: str,
query: str,
limit: int = 50,
offset: int = 0,
) -> list[dict]:
# TODO: implement pagination
assert media_type in ("track", "playlist"), f"Cannot search for {media_type}"
params = {
"q": query,
"facet": "genre",
"user_id": USER_ID,
"limit": limit,
"offset": offset,
"linked_partitioning": "1",
}
resp, status = await self._api_request(f"search/{media_type}s", params=params)
assert status == 200
return [resp]
async def get_downloadable(self, item_info: str, _) -> SoundcloudDownloadable:
# We have `get_metadata` overwrite the "id" field so that it contains
# some extra information we need to download soundcloud tracks
# item_id is the soundcloud ID of the track
# download_url is either the url that points to an mp3 download or ""
# if download_url == '_non_streamable' then we raise an exception
infos: list[str] = item_info.split("|")
logger.debug(f"{infos=}")
assert len(infos) == 2, infos
item_id, download_info = infos
assert re.match(r"\d+", item_id) is not None
if download_info == self.NON_STREAMABLE:
raise NonStreamableError(item_info)
if download_info == self.ORIGINAL_DOWNLOAD:
resp_json, status = await self._api_request(f"tracks/{item_id}/download")
assert status == 200
return SoundcloudDownloadable(
self.session,
{"url": resp_json["redirectUri"], "type": "original"},
)
if download_info == self.NOT_RESOLVED:
raise NotImplementedError(item_info)
# download_info contains mp3 stream url
resp_json, status = await self._request(download_info)
return SoundcloudDownloadable(
self.session,
{"url": resp_json["url"], "type": "mp3"},
)
async def resolve_url(self, url: str) -> dict:
"""Get metadata of the item pointed to by a soundcloud url.
This is necessary only for soundcloud because they don't store
the item IDs in their url. See SoundcloudURL.into_pending for example
usage.
Args:
url (str): Url to resolve.
Returns:
API response for item.
"""
resp, status = await self._api_request("resolve", params={"url": url})
assert status == 200
if resp["kind"] == "track":
resp["id"] = self._get_custom_id(resp)
return resp
async def _get_track(self, item_id: str): async def _get_track(self, item_id: str):
resp, status = await self._api_request(f"tracks/{item_id}") resp, status = await self._api_request(f"tracks/{item_id}")
assert status == 200 assert status == 200
@ -140,62 +219,6 @@ class SoundcloudClient(Client):
assert url is not None assert url is not None
return f"{item_id}|{url}" return f"{item_id}|{url}"
async def get_downloadable(self, item_info: str, _) -> SoundcloudDownloadable:
# We have `get_metadata` overwrite the "id" field so that it contains
# some extra information we need to download soundcloud tracks
# item_id is the soundcloud ID of the track
# download_url is either the url that points to an mp3 download or ""
# if download_url == '_non_streamable' then we raise an exception
infos: list[str] = item_info.split("|")
logger.debug(f"{infos=}")
assert len(infos) == 2, infos
item_id, download_info = infos
assert re.match(r"\d+", item_id) is not None
if download_info == self.NON_STREAMABLE:
raise NonStreamableError(item_info)
if download_info == self.ORIGINAL_DOWNLOAD:
resp_json, status = await self._api_request(f"tracks/{item_id}/download")
assert status == 200
return SoundcloudDownloadable(
self.session,
{"url": resp_json["redirectUri"], "type": "original"},
)
if download_info == self.NOT_RESOLVED:
raise NotImplementedError(item_info)
# download_info contains mp3 stream url
resp_json, status = await self._request(download_info)
return SoundcloudDownloadable(
self.session,
{"url": resp_json["url"], "type": "mp3"},
)
async def search(
self,
media_type: str,
query: str,
limit: int = 50,
offset: int = 0,
) -> list[dict]:
# TODO: implement pagination
assert media_type in ("track", "playlist"), f"Cannot search for {media_type}"
params = {
"q": query,
"facet": "genre",
"user_id": SOUNDCLOUD_USER_ID,
"limit": limit,
"offset": offset,
"linked_partitioning": "1",
}
resp, status = await self._api_request(f"search/{media_type}s", params=params)
assert status == 200
return [resp]
async def _api_request(self, path, params=None, headers=None): async def _api_request(self, path, params=None, headers=None):
url = f"{BASE}/{path}" url = f"{BASE}/{path}"
return await self._request(url, params=params, headers=headers) return await self._request(url, params=params, headers=headers)
@ -227,12 +250,7 @@ class SoundcloudClient(Client):
async with self.session.get(url, params=_params, headers=headers) as resp: async with self.session.get(url, params=_params, headers=headers) as resp:
return await resp.content.read(), resp.status return await resp.content.read(), resp.status
async def _resolve_url(self, url: str) -> dict: async def _announce_success(self):
resp, status = await self._api_request("resolve", params={"url": url})
assert status == 200
return resp
async def _announce(self):
url = f"{BASE}/announcements" url = f"{BASE}/announcements"
_, status = await self._request_body(url) _, status = await self._request_body(url)
return status == 200 return status == 200

View file

@ -3,9 +3,6 @@ from contextlib import nullcontext
from ..config import DownloadsConfig from ..config import DownloadsConfig
INF = 9999
_unlimited = nullcontext() _unlimited = nullcontext()
_global_semaphore: None | tuple[int, asyncio.Semaphore] = None _global_semaphore: None | tuple[int, asyncio.Semaphore] = None
@ -23,14 +20,16 @@ def global_download_semaphore(c: DownloadsConfig) -> asyncio.Semaphore | nullcon
global _unlimited, _global_semaphore global _unlimited, _global_semaphore
if c.concurrency: if c.concurrency:
max_connections = c.max_connections if c.max_connections > 0 else INF max_connections = c.max_connections if c.max_connections > 0 else None
else: else:
max_connections = 1 max_connections = 1
assert max_connections > 0 if max_connections is None:
if max_connections == INF:
return _unlimited return _unlimited
if max_connections <= 0:
raise Exception(f"{max_connections = } too small")
if _global_semaphore is None: if _global_semaphore is None:
_global_semaphore = (max_connections, asyncio.Semaphore(max_connections)) _global_semaphore = (max_connections, asyncio.Semaphore(max_connections))

View file

@ -39,7 +39,7 @@ class ArtistSummary(Summary):
return "artist" return "artist"
def summarize(self) -> str: def summarize(self) -> str:
return self.name return clean(self.name)
def preview(self) -> str: def preview(self) -> str:
return f"{self.num_albums} Albums\n\nID: {self.id}" return f"{self.num_albums} Albums\n\nID: {self.id}"
@ -73,7 +73,8 @@ class TrackSummary(Summary):
return "track" return "track"
def summarize(self) -> str: def summarize(self) -> str:
return f"{self.name} by {self.artist}" # This char breaks the menu for some reason
return f"{clean(self.name)} by {clean(self.artist)}"
def preview(self) -> str: def preview(self) -> str:
return f"Released on:\n{self.date_released}\n\nID: {self.id}" return f"Released on:\n{self.date_released}\n\nID: {self.id}"
@ -119,7 +120,7 @@ class AlbumSummary(Summary):
return "album" return "album"
def summarize(self) -> str: def summarize(self) -> str:
return f"{self.name} by {self.artist}" return f"{clean(self.name)} by {clean(self.artist)}"
def preview(self) -> str: def preview(self) -> str:
return f"Date released:\n{self.date_released}\n\n{self.num_tracks} Tracks\n\nID: {self.id}" return f"Date released:\n{self.date_released}\n\n{self.num_tracks} Tracks\n\nID: {self.id}"
@ -188,11 +189,14 @@ class PlaylistSummary(Summary):
description: str description: str
def summarize(self) -> str: def summarize(self) -> str:
return f"{self.name} by {self.creator}" name = clean(self.name)
creator = clean(self.creator)
return f"{name} by {creator}"
def preview(self) -> str: def preview(self) -> str:
desc = clean(self.description, trunc=False)
wrapped = "\n".join( wrapped = "\n".join(
textwrap.wrap(self.description, os.get_terminal_size().columns - 4 or 70), textwrap.wrap(desc, os.get_terminal_size().columns - 4 or 70),
) )
return f"{self.num_tracks} tracks\n\nDescription:\n{wrapped}\n\nID: {self.id}" return f"{self.num_tracks} tracks\n\nDescription:\n{wrapped}\n\nID: {self.id}"
@ -214,6 +218,7 @@ class PlaylistSummary(Summary):
item.get("tracks_count") item.get("tracks_count")
or item.get("nb_tracks") or item.get("nb_tracks")
or item.get("numberOfTracks") or item.get("numberOfTracks")
or len(item.get("tracks", []))
or -1 or -1
) )
description = item.get("description") or "No description" description = item.get("description") or "No description"
@ -273,3 +278,11 @@ class SearchResults:
assert ind is not None assert ind is not None
i = int(ind.group(0)) i = int(ind.group(0))
return self.results[i - 1].preview() return self.results[i - 1].preview()
def clean(s: str, trunc=True) -> str:
s = s.replace("|", "").replace("\n", "")
if trunc:
max_chars = 50
return s[:max_chars]
return s

View file

@ -25,8 +25,6 @@ class ProgressManager:
BarColumn(bar_width=None), BarColumn(bar_width=None),
"[progress.percentage]{task.percentage:>3.1f}%", "[progress.percentage]{task.percentage:>3.1f}%",
"", "",
# DownloadColumn(),
# "•",
TransferSpeedColumn(), TransferSpeedColumn(),
"", "",
TimeRemainingColumn(), TimeRemainingColumn(),

View file

@ -36,8 +36,14 @@ def coro(f):
"--config-path", "--config-path",
default=DEFAULT_CONFIG_PATH, default=DEFAULT_CONFIG_PATH,
help="Path to the configuration file", help="Path to the configuration file",
type=click.Path(readable=True, writable=True),
)
@click.option(
"-f",
"--folder",
help="The folder to download items into.",
type=click.Path(file_okay=False, dir_okay=True),
) )
@click.option("-f", "--folder", help="The folder to download items into.")
@click.option( @click.option(
"-ndb", "-ndb",
"--no-db", "--no-db",
@ -46,11 +52,14 @@ def coro(f):
is_flag=True, is_flag=True,
) )
@click.option( @click.option(
"-q", "--quality", help="The maximum quality allowed to download", type=int "-q",
"--quality",
help="The maximum quality allowed to download",
type=click.IntRange(min=0, max=4),
) )
@click.option( @click.option(
"-c", "-c",
"--convert", "--codec",
help="Convert the downloaded files to an audio codec (ALAC, FLAC, MP3, AAC, or OGG)", help="Convert the downloaded files to an audio codec (ALAC, FLAC, MP3, AAC, or OGG)",
) )
@click.option( @click.option(
@ -66,7 +75,7 @@ def coro(f):
is_flag=True, is_flag=True,
) )
@click.pass_context @click.pass_context
def rip(ctx, config_path, folder, no_db, quality, convert, no_progress, verbose): def rip(ctx, config_path, folder, no_db, quality, codec, no_progress, verbose):
"""Streamrip: the all in one music downloader.""" """Streamrip: the all in one music downloader."""
global logger global logger
logging.basicConfig( logging.basicConfig(
@ -112,7 +121,8 @@ def rip(ctx, config_path, folder, no_db, quality, convert, no_progress, verbose)
return return
# set session config values to command line args # set session config values to command line args
c.session.database.downloads_enabled = not no_db if no_db:
c.session.database.downloads_enabled = False
if folder is not None: if folder is not None:
c.session.downloads.folder = folder c.session.downloads.folder = folder
@ -122,10 +132,10 @@ def rip(ctx, config_path, folder, no_db, quality, convert, no_progress, verbose)
c.session.deezer.quality = quality c.session.deezer.quality = quality
c.session.soundcloud.quality = quality c.session.soundcloud.quality = quality
if convert is not None: if codec is not None:
c.session.conversion.enabled = True c.session.conversion.enabled = True
assert convert.upper() in ("ALAC", "FLAC", "OGG", "MP3", "AAC") assert codec.upper() in ("ALAC", "FLAC", "OGG", "MP3", "AAC")
c.session.conversion.codec = convert.upper() c.session.conversion.codec = codec.upper()
if no_progress: if no_progress:
c.session.cli.progress_bars = False c.session.cli.progress_bars = False
@ -147,7 +157,9 @@ async def url(ctx, urls):
@rip.command() @rip.command()
@click.argument("path", required=True) @click.argument(
"path", required=True, type=click.Path(file_okay=True, dir_okay=False, exists=True)
)
@click.pass_context @click.pass_context
@coro @coro
async def file(ctx, path): async def file(ctx, path):
@ -275,7 +287,7 @@ async def search(ctx, first, source, media_type, query):
"""Search for content using a specific source. """Search for content using a specific source.
Example: Example:
-------
rip search qobuz album 'rumours' rip search qobuz album 'rumours'
""" """
with ctx.obj["config"] as cfg: with ctx.obj["config"] as cfg:

View file

@ -6,7 +6,17 @@ from .. import db
from ..client import Client, DeezerClient, QobuzClient, SoundcloudClient, TidalClient from ..client import Client, DeezerClient, QobuzClient, SoundcloudClient, TidalClient
from ..config import Config from ..config import Config
from ..console import console from ..console import console
from ..media import Media, Pending, PendingLastfmPlaylist, remove_artwork_tempdirs from ..media import (
Media,
Pending,
PendingAlbum,
PendingArtist,
PendingLabel,
PendingLastfmPlaylist,
PendingPlaylist,
PendingSingle,
remove_artwork_tempdirs,
)
from ..metadata import SearchResults from ..metadata import SearchResults
from ..progress import clear_progress from ..progress import clear_progress
from .parse_url import parse_url from .parse_url import parse_url
@ -21,6 +31,7 @@ class Main:
* Logs in to Clients and prompts for credentials * Logs in to Clients and prompts for credentials
* Handles output logging * Handles output logging
* Handles downloading Media * Handles downloading Media
* Handles interactive search
User input (urls) -> Main --> Download files & Output messages to terminal User input (urls) -> Main --> Download files & Output messages to terminal
""" """
@ -68,6 +79,32 @@ class Main:
) )
logger.debug("Added url=%s", url) logger.debug("Added url=%s", url)
async def add_by_id(self, source: str, media_type: str, id: str):
client = await self.get_logged_in_client(source)
self._add_by_id_client(client, media_type, id)
async def add_all_by_id(self, info: list[tuple[str, str, str]]):
sources = set(s for s, _, _ in info)
clients = {s: await self.get_logged_in_client(s) for s in sources}
for source, media_type, id in info:
self._add_by_id_client(clients[source], media_type, id)
def _add_by_id_client(self, client: Client, media_type: str, id: str):
if media_type == "track":
item = PendingSingle(id, client, self.config, self.database)
elif media_type == "album":
item = PendingAlbum(id, client, self.config, self.database)
elif media_type == "playlist":
item = PendingPlaylist(id, client, self.config, self.database)
elif media_type == "label":
item = PendingLabel(id, client, self.config, self.database)
elif media_type == "artist":
item = PendingArtist(id, client, self.config, self.database)
else:
raise Exception(media_type)
self.pending.append(item)
async def add_all(self, urls: list[str]): async def add_all(self, urls: list[str]):
"""Add multiple urls concurrently as pending items.""" """Add multiple urls concurrently as pending items."""
parsed = [parse_url(url) for url in urls] parsed = [parse_url(url) for url in urls]
@ -105,7 +142,6 @@ class Main:
with console.status(f"[cyan]Logging into {source}", spinner="dots"): with console.status(f"[cyan]Logging into {source}", spinner="dots"):
# Log into client using credentials from config # Log into client using credentials from config
await client.login() await client.login()
# await client.login()
assert client.logged_in assert client.logged_in
return client return client
@ -149,8 +185,8 @@ class Main:
) )
assert isinstance(choices, list) assert isinstance(choices, list)
await self.add_all( await self.add_all_by_id(
[f"http://{source}.com/{media_type}/{item.id}" for item, i in choices], [(source, media_type, item.id) for item, _ in choices],
) )
else: else:
@ -173,11 +209,8 @@ class Main:
console.print("[yellow]No items chosen. Exiting.") console.print("[yellow]No items chosen. Exiting.")
else: else:
choices = search_results.get_choices(chosen_ind) choices = search_results.get_choices(chosen_ind)
await self.add_all( await self.add_all_by_id(
[ [(source, item.media_type(), item.id) for item in choices],
f"http://{source}.com/{item.media_type()}/{item.id}"
for item in choices
],
) )
async def search_take_first(self, source: str, media_type: str, query: str): async def search_take_first(self, source: str, media_type: str, query: str):
@ -229,6 +262,3 @@ class Main:
# may be able to share downloaded artwork in the same `rip` session # may be able to share downloaded artwork in the same `rip` session
# We don't know that a cover will not be used again until end of execution # We don't know that a cover will not be used again until end of execution
remove_artwork_tempdirs() remove_artwork_tempdirs()
async def add_by_id(self, source: str, media_type: str, id: str):
await self.add(f"http://{source}.com/{media_type}/{id}")

View file

@ -14,11 +14,17 @@ from ..media import (
PendingPlaylist, PendingPlaylist,
PendingSingle, PendingSingle,
) )
from .validation_regexps import (
QOBUZ_INTERPRETER_URL_REGEX, URL_REGEX = re.compile(
SOUNDCLOUD_URL_REGEX, r"https?://(?:www|open|play|listen)?\.?(qobuz|tidal|deezer)\.com(?:(?:/(album|artist|track|playlist|video|label))|(?:\/[-\w]+?))+\/([-\w]+)",
URL_REGEX,
) )
SOUNDCLOUD_URL_REGEX = re.compile(r"https://soundcloud.com/[-\w:/]+")
LASTFM_URL_REGEX = re.compile(r"https://www.last.fm/user/\w+/playlists/\w+")
QOBUZ_INTERPRETER_URL_REGEX = re.compile(
r"https?://www\.qobuz\.com/\w\w-\w\w/interpreter/[-\w]+/[-\w]+",
)
DEEZER_DYNAMIC_LINK_REGEX = re.compile(r"https://deezer\.page\.link/\w+")
YOUTUBE_URL_REGEX = re.compile(r"https://www\.youtube\.com/watch\?v=[-\w]+")
class URL(ABC): class URL(ABC):
@ -134,7 +140,7 @@ class SoundcloudURL(URL):
config: Config, config: Config,
db: Database, db: Database,
) -> Pending: ) -> Pending:
resolved = await client._resolve_url(self.url) resolved = await client.resolve_url(self.url)
media_type = resolved["kind"] media_type = resolved["kind"]
item_id = str(resolved["id"]) item_id = str(resolved["id"])
if media_type == "track": if media_type == "track":

View file

@ -1,12 +0,0 @@
import re
URL_REGEX = re.compile(
r"https?://(?:www|open|play|listen)?\.?(qobuz|tidal|deezer)\.com(?:(?:/(album|artist|track|playlist|video|label))|(?:\/[-\w]+?))+\/([-\w]+)",
)
SOUNDCLOUD_URL_REGEX = re.compile(r"https://soundcloud.com/[-\w:/]+")
LASTFM_URL_REGEX = re.compile(r"https://www.last.fm/user/\w+/playlists/\w+")
QOBUZ_INTERPRETER_URL_REGEX = re.compile(
r"https?://www\.qobuz\.com/\w\w-\w\w/interpreter/[-\w]+/[-\w]+",
)
DEEZER_DYNAMIC_LINK_REGEX = re.compile(r"https://deezer\.page\.link/\w+")
YOUTUBE_URL_REGEX = re.compile(r"https://www\.youtube\.com/watch\?v=[-\w]+")