mirror of
https://github.com/nathom/streamrip.git
synced 2025-05-13 06:34:45 -04:00
Last.fm support
This commit is contained in:
parent
4f8f3213c4
commit
6bd2d0cf0e
13 changed files with 418 additions and 74 deletions
|
@ -30,7 +30,7 @@ class Client(ABC):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def search(self, query: str, media_type: str, limit: int = 500) -> list[dict]:
|
async def search(self, media_type: str, query: str, limit: int = 500) -> list[dict]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|
|
@ -51,7 +51,7 @@ class DeezerClient(Client):
|
||||||
|
|
||||||
return item
|
return item
|
||||||
|
|
||||||
async def search(self, query: str, media_type: str, limit: int = 200):
|
async def search(self, media_type: str, query: str, limit: int = 200):
|
||||||
# TODO: use limit parameter
|
# TODO: use limit parameter
|
||||||
if media_type == "featured":
|
if media_type == "featured":
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -226,7 +226,9 @@ class QobuzClient(Client):
|
||||||
status, resp = await self._api_request(epoint, params)
|
status, resp = await self._api_request(epoint, params)
|
||||||
|
|
||||||
if status != 200:
|
if status != 200:
|
||||||
raise Exception(f'Error fetching metadata. "{resp["message"]}"')
|
raise NonStreamable(
|
||||||
|
f'Error fetching metadata. Message: "{resp["message"]}"'
|
||||||
|
)
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ from tomlkit.toml_document import TOMLDocument
|
||||||
|
|
||||||
logger = logging.getLogger("streamrip")
|
logger = logging.getLogger("streamrip")
|
||||||
|
|
||||||
APP_DIR = click.get_app_dir("streamrip", force_posix=True)
|
APP_DIR = click.get_app_dir("streamrip")
|
||||||
DEFAULT_CONFIG_PATH = os.path.join(APP_DIR, "config.toml")
|
DEFAULT_CONFIG_PATH = os.path.join(APP_DIR, "config.toml")
|
||||||
CURRENT_CONFIG_VERSION = "2.0"
|
CURRENT_CONFIG_VERSION = "2.0"
|
||||||
|
|
||||||
|
@ -206,6 +206,8 @@ class CliConfig:
|
||||||
text_output: bool
|
text_output: bool
|
||||||
# Show resolve, download progress bars
|
# Show resolve, download progress bars
|
||||||
progress_bars: bool
|
progress_bars: bool
|
||||||
|
# The maximum number of search results to show in the interactive menu
|
||||||
|
max_search_results: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
|
@ -81,11 +81,13 @@ download_videos = false
|
||||||
# The path to download the videos to
|
# The path to download the videos to
|
||||||
video_downloads_folder = ""
|
video_downloads_folder = ""
|
||||||
|
|
||||||
# This stores a list of item IDs so that repeats are not downloaded.
|
|
||||||
[database]
|
[database]
|
||||||
|
# Create a database that contains all the track IDs downloaded so far
|
||||||
|
# Any time a track logged in the database is requested, it is skipped
|
||||||
|
# This can be disabled temporarily with the --no-db flag
|
||||||
downloads_enabled = true
|
downloads_enabled = true
|
||||||
|
# Path to the downloads database
|
||||||
downloads_path = ""
|
downloads_path = ""
|
||||||
|
|
||||||
# If a download fails, the item ID is stored here. Then, `rip repair` can be
|
# If a download fails, the item ID is stored here. Then, `rip repair` can be
|
||||||
# called to retry the downloads
|
# called to retry the downloads
|
||||||
failed_downloads_enabled = true
|
failed_downloads_enabled = true
|
||||||
|
@ -171,7 +173,7 @@ truncate_to = 120
|
||||||
source = "qobuz"
|
source = "qobuz"
|
||||||
# If no results were found with the primary source, the item is searched for
|
# If no results were found with the primary source, the item is searched for
|
||||||
# on this one.
|
# on this one.
|
||||||
fallback_source = "deezer"
|
fallback_source = ""
|
||||||
|
|
||||||
[cli]
|
[cli]
|
||||||
# Print "Downloading {Album name}" etc. to screen
|
# Print "Downloading {Album name}" etc. to screen
|
||||||
|
|
|
@ -3,7 +3,12 @@ from .artist import Artist, PendingArtist
|
||||||
from .artwork import remove_artwork_tempdirs
|
from .artwork import remove_artwork_tempdirs
|
||||||
from .label import Label, PendingLabel
|
from .label import Label, PendingLabel
|
||||||
from .media import Media, Pending
|
from .media import Media, Pending
|
||||||
from .playlist import PendingPlaylist, PendingPlaylistTrack, Playlist
|
from .playlist import (
|
||||||
|
PendingLastfmPlaylist,
|
||||||
|
PendingPlaylist,
|
||||||
|
PendingPlaylistTrack,
|
||||||
|
Playlist,
|
||||||
|
)
|
||||||
from .track import PendingSingle, PendingTrack, Track
|
from .track import PendingSingle, PendingTrack, Track
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
@ -17,6 +22,7 @@ __all__ = [
|
||||||
"PendingLabel",
|
"PendingLabel",
|
||||||
"Playlist",
|
"Playlist",
|
||||||
"PendingPlaylist",
|
"PendingPlaylist",
|
||||||
|
"PendingLastfmPlaylist",
|
||||||
"Track",
|
"Track",
|
||||||
"PendingTrack",
|
"PendingTrack",
|
||||||
"PendingPlaylistTrack",
|
"PendingPlaylistTrack",
|
||||||
|
|
|
@ -1,14 +1,28 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from contextlib import ExitStack
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
from .. import progress
|
from .. import progress
|
||||||
from ..client import Client
|
from ..client import Client
|
||||||
from ..config import Config
|
from ..config import Config
|
||||||
|
from ..console import console
|
||||||
from ..db import Database
|
from ..db import Database
|
||||||
from ..filepath_utils import clean_filename
|
from ..filepath_utils import clean_filename
|
||||||
from ..metadata import AlbumMetadata, Covers, PlaylistMetadata, TrackMetadata
|
from ..metadata import (
|
||||||
|
AlbumMetadata,
|
||||||
|
Covers,
|
||||||
|
PlaylistMetadata,
|
||||||
|
SearchResults,
|
||||||
|
TrackMetadata,
|
||||||
|
)
|
||||||
from .artwork import download_artwork
|
from .artwork import download_artwork
|
||||||
from .media import Media, Pending
|
from .media import Media, Pending
|
||||||
from .track import Track
|
from .track import Track
|
||||||
|
@ -75,22 +89,32 @@ class Playlist(Media):
|
||||||
tracks: list[PendingPlaylistTrack]
|
tracks: list[PendingPlaylistTrack]
|
||||||
|
|
||||||
async def preprocess(self):
|
async def preprocess(self):
|
||||||
pass
|
|
||||||
|
|
||||||
async def download(self):
|
|
||||||
progress.add_title(self.name)
|
progress.add_title(self.name)
|
||||||
|
|
||||||
async def _resolve_and_download(pending: PendingPlaylistTrack):
|
async def postprocess(self):
|
||||||
track = await pending.resolve()
|
progress.remove_title(self.name)
|
||||||
|
|
||||||
|
async def download(self):
|
||||||
|
track_resolve_chunk_size = 20
|
||||||
|
|
||||||
|
async def _resolve_download(item: PendingPlaylistTrack):
|
||||||
|
track = await item.resolve()
|
||||||
if track is None:
|
if track is None:
|
||||||
return
|
return
|
||||||
await track.rip()
|
await track.rip()
|
||||||
|
|
||||||
await asyncio.gather(*[_resolve_and_download(p) for p in self.tracks])
|
batches = self.batch(
|
||||||
progress.remove_title(self.name)
|
[_resolve_download(track) for track in self.tracks],
|
||||||
|
track_resolve_chunk_size,
|
||||||
|
)
|
||||||
|
for batch in batches:
|
||||||
|
await asyncio.gather(*batch)
|
||||||
|
|
||||||
async def postprocess(self):
|
@staticmethod
|
||||||
pass
|
def batch(iterable, n=1):
|
||||||
|
l = len(iterable)
|
||||||
|
for ndx in range(0, l, n):
|
||||||
|
yield iterable[ndx : min(ndx + n, l)]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
@ -113,3 +137,199 @@ class PendingPlaylist(Pending):
|
||||||
for position, id in enumerate(meta.ids())
|
for position, id in enumerate(meta.ids())
|
||||||
]
|
]
|
||||||
return Playlist(name, self.config, self.client, tracks)
|
return Playlist(name, self.config, self.client, tracks)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PendingLastfmPlaylist(Pending):
|
||||||
|
lastfm_url: str
|
||||||
|
client: Client
|
||||||
|
fallback_client: Client | None
|
||||||
|
config: Config
|
||||||
|
db: Database
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Status:
|
||||||
|
found: int
|
||||||
|
failed: int
|
||||||
|
total: int
|
||||||
|
|
||||||
|
def text(self) -> Text:
|
||||||
|
return Text.assemble(
|
||||||
|
"Searching for last.fm tracks (",
|
||||||
|
(f"{self.found} found", "bold green"),
|
||||||
|
", ",
|
||||||
|
(f"{self.failed} failed", "bold red"),
|
||||||
|
", ",
|
||||||
|
(f"{self.total} total", "bold"),
|
||||||
|
")",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def resolve(self) -> Playlist | None:
|
||||||
|
try:
|
||||||
|
playlist_title, titles_artists = await self._parse_lastfm_playlist(
|
||||||
|
self.lastfm_url
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error occured while parsing last.fm page: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
s = self.Status(0, 0, len(titles_artists))
|
||||||
|
if self.config.session.cli.progress_bars:
|
||||||
|
with console.status(s.text(), spinner="moon") as status:
|
||||||
|
callback = lambda: status.update(s.text())
|
||||||
|
for title, artist in titles_artists:
|
||||||
|
requests.append(self._make_query(f"{title} {artist}", s, callback))
|
||||||
|
results: list[tuple[str | None, bool]] = await asyncio.gather(*requests)
|
||||||
|
else:
|
||||||
|
callback = lambda: None
|
||||||
|
for title, artist in titles_artists:
|
||||||
|
requests.append(self._make_query(f"{title} {artist}", s, callback))
|
||||||
|
results: list[tuple[str | None, bool]] = await asyncio.gather(*requests)
|
||||||
|
|
||||||
|
parent = self.config.session.downloads.folder
|
||||||
|
folder = os.path.join(parent, clean_filename(playlist_title))
|
||||||
|
|
||||||
|
pending_tracks = []
|
||||||
|
for pos, (id, from_fallback) in enumerate(results, start=1):
|
||||||
|
if id is None:
|
||||||
|
logger.warning(f"No results found for {titles_artists[pos-1]}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if from_fallback:
|
||||||
|
assert self.fallback_client is not None
|
||||||
|
client = self.fallback_client
|
||||||
|
else:
|
||||||
|
client = self.client
|
||||||
|
|
||||||
|
pending_tracks.append(
|
||||||
|
PendingPlaylistTrack(
|
||||||
|
id,
|
||||||
|
client,
|
||||||
|
self.config,
|
||||||
|
folder,
|
||||||
|
playlist_title,
|
||||||
|
pos,
|
||||||
|
self.db,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return Playlist(playlist_title, self.config, self.client, pending_tracks)
|
||||||
|
|
||||||
|
async def _make_query(
|
||||||
|
self, query: str, s: Status, callback
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
|
"""Try searching for `query` with main source. If that fails, try with next source.
|
||||||
|
|
||||||
|
If both fail, return None.
|
||||||
|
"""
|
||||||
|
with ExitStack() as stack:
|
||||||
|
# ensure `callback` is always called
|
||||||
|
stack.callback(callback)
|
||||||
|
pages = await self.client.search("track", query, limit=1)
|
||||||
|
if len(pages) > 0:
|
||||||
|
logger.debug(f"Found result for {query} on {self.client.source}")
|
||||||
|
s.found += 1
|
||||||
|
return (
|
||||||
|
SearchResults.from_pages(self.client.source, "track", pages)
|
||||||
|
.results[0]
|
||||||
|
.id
|
||||||
|
), False
|
||||||
|
|
||||||
|
if self.fallback_client is None:
|
||||||
|
logger.debug(f"No result found for {query} on {self.client.source}")
|
||||||
|
s.failed += 1
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
pages = await self.fallback_client.search("track", query, limit=1)
|
||||||
|
if len(pages) > 0:
|
||||||
|
logger.debug(f"Found result for {query} on {self.client.source}")
|
||||||
|
s.found += 1
|
||||||
|
return (
|
||||||
|
SearchResults.from_pages(
|
||||||
|
self.fallback_client.source, "track", pages
|
||||||
|
)
|
||||||
|
.results[0]
|
||||||
|
.id
|
||||||
|
), True
|
||||||
|
|
||||||
|
logger.debug(f"No result found for {query} on {self.client.source}")
|
||||||
|
s.failed += 1
|
||||||
|
return None, True
|
||||||
|
|
||||||
|
async def _parse_lastfm_playlist(
|
||||||
|
self, playlist_url: str
|
||||||
|
) -> tuple[str, list[tuple[str, str]]]:
|
||||||
|
"""From a last.fm url, return the playlist title, and a list of
|
||||||
|
track titles and artist names.
|
||||||
|
|
||||||
|
Each page contains 50 results, so `num_tracks // 50 + 1` requests
|
||||||
|
are sent per playlist.
|
||||||
|
|
||||||
|
:param url:
|
||||||
|
:type url: str
|
||||||
|
:rtype: tuple[str, list[tuple[str, str]]]
|
||||||
|
"""
|
||||||
|
logger.debug("Fetching lastfm playlist")
|
||||||
|
|
||||||
|
title_tags = re.compile(r'<a\s+href="[^"]+"\s+title="([^"]+)"')
|
||||||
|
re_total_tracks = re.compile(r'data-playlisting-entry-count="(\d+)"')
|
||||||
|
re_playlist_title_match = re.compile(
|
||||||
|
r'<h1 class="playlisting-playlist-header-title">([^<]+)</h1>'
|
||||||
|
)
|
||||||
|
|
||||||
|
def find_title_artist_pairs(page_text):
|
||||||
|
info: list[tuple[str, str]] = []
|
||||||
|
titles = title_tags.findall(page_text) # [2:]
|
||||||
|
for i in range(0, len(titles) - 1, 2):
|
||||||
|
info.append((html.unescape(titles[i]), html.unescape(titles[i + 1])))
|
||||||
|
return info
|
||||||
|
|
||||||
|
async def fetch(session: aiohttp.ClientSession, url, **kwargs):
|
||||||
|
async with session.get(url, **kwargs) as resp:
|
||||||
|
return await resp.text("utf-8")
|
||||||
|
|
||||||
|
# Create new session so we're not bound by rate limit
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
page = await fetch(session, playlist_url)
|
||||||
|
playlist_title_match = re_playlist_title_match.search(page)
|
||||||
|
if playlist_title_match is None:
|
||||||
|
raise Exception("Error finding title from response")
|
||||||
|
|
||||||
|
playlist_title: str = html.unescape(playlist_title_match.group(1))
|
||||||
|
|
||||||
|
title_artist_pairs: list[tuple[str, str]] = find_title_artist_pairs(page)
|
||||||
|
|
||||||
|
total_tracks_match = re_total_tracks.search(page)
|
||||||
|
if total_tracks_match is None:
|
||||||
|
raise Exception("Error parsing lastfm page: %s", page)
|
||||||
|
total_tracks = int(total_tracks_match.group(1))
|
||||||
|
|
||||||
|
remaining_tracks = total_tracks - 50 # already got 50 from 1st page
|
||||||
|
if remaining_tracks <= 0:
|
||||||
|
return playlist_title, title_artist_pairs
|
||||||
|
|
||||||
|
last_page = (
|
||||||
|
1 + int(remaining_tracks // 50) + int(remaining_tracks % 50 != 0)
|
||||||
|
)
|
||||||
|
requests = []
|
||||||
|
for page in range(2, last_page + 1):
|
||||||
|
requests.append(fetch(session, playlist_url, params={"page": page}))
|
||||||
|
results = await asyncio.gather(*requests)
|
||||||
|
|
||||||
|
for page in results:
|
||||||
|
title_artist_pairs.extend(find_title_artist_pairs(page))
|
||||||
|
|
||||||
|
return playlist_title, title_artist_pairs
|
||||||
|
|
||||||
|
async def _make_query_mock(
|
||||||
|
self, _: str, s: Status, callback
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
|
await asyncio.sleep(random.uniform(1, 20))
|
||||||
|
if random.randint(0, 4) >= 1:
|
||||||
|
s.found += 1
|
||||||
|
else:
|
||||||
|
s.failed += 1
|
||||||
|
callback()
|
||||||
|
return None, False
|
||||||
|
|
|
@ -1,27 +1,16 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from contextlib import nullcontext
|
||||||
|
|
||||||
from ..config import DownloadsConfig
|
from ..config import DownloadsConfig
|
||||||
|
|
||||||
INF = 9999
|
INF = 9999
|
||||||
|
|
||||||
|
|
||||||
class UnlimitedSemaphore:
|
_unlimited = nullcontext()
|
||||||
"""Can be swapped out for a real semaphore when no semaphore is needed."""
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
_unlimited = UnlimitedSemaphore()
|
|
||||||
_global_semaphore: None | tuple[int, asyncio.Semaphore] = None
|
_global_semaphore: None | tuple[int, asyncio.Semaphore] = None
|
||||||
|
|
||||||
|
|
||||||
def global_download_semaphore(
|
def global_download_semaphore(c: DownloadsConfig) -> asyncio.Semaphore | nullcontext:
|
||||||
c: DownloadsConfig,
|
|
||||||
) -> UnlimitedSemaphore | asyncio.Semaphore:
|
|
||||||
"""A global semaphore that limit the number of total tracks being downloaded
|
"""A global semaphore that limit the number of total tracks being downloaded
|
||||||
at once.
|
at once.
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ from .. import converter
|
||||||
from ..client import Client, Downloadable
|
from ..client import Client, Downloadable
|
||||||
from ..config import Config
|
from ..config import Config
|
||||||
from ..db import Database
|
from ..db import Database
|
||||||
|
from ..exceptions import NonStreamable
|
||||||
from ..filepath_utils import clean_filename
|
from ..filepath_utils import clean_filename
|
||||||
from ..metadata import AlbumMetadata, Covers, TrackMetadata, tag_file
|
from ..metadata import AlbumMetadata, Covers, TrackMetadata, tag_file
|
||||||
from ..progress import add_title, get_progress_callback, remove_title
|
from ..progress import add_title, get_progress_callback, remove_title
|
||||||
|
@ -129,7 +130,11 @@ class PendingSingle(Pending):
|
||||||
db: Database
|
db: Database
|
||||||
|
|
||||||
async def resolve(self) -> Track | None:
|
async def resolve(self) -> Track | None:
|
||||||
|
try:
|
||||||
resp = await self.client.get_metadata(self.id, "track")
|
resp = await self.client.get_metadata(self.id, "track")
|
||||||
|
except NonStreamable as e:
|
||||||
|
logger.error(f"Error fetching track {self.id}: {e}")
|
||||||
|
return None
|
||||||
# Patch for soundcloud
|
# Patch for soundcloud
|
||||||
# self.id = resp["id"]
|
# self.id = resp["id"]
|
||||||
album = AlbumMetadata.from_track_resp(resp, self.client.source)
|
album = AlbumMetadata.from_track_resp(resp, self.client.source)
|
||||||
|
|
|
@ -3,7 +3,6 @@ import re
|
||||||
import textwrap
|
import textwrap
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pprint import pprint
|
|
||||||
|
|
||||||
|
|
||||||
class Summary(ABC):
|
class Summary(ABC):
|
||||||
|
|
|
@ -11,6 +11,7 @@ from rich.logging import RichHandler
|
||||||
from rich.prompt import Confirm
|
from rich.prompt import Confirm
|
||||||
from rich.traceback import install
|
from rich.traceback import install
|
||||||
|
|
||||||
|
from .. import db
|
||||||
from ..config import DEFAULT_CONFIG_PATH, Config, set_user_defaults
|
from ..config import DEFAULT_CONFIG_PATH, Config, set_user_defaults
|
||||||
from ..console import console
|
from ..console import console
|
||||||
from .main import Main
|
from .main import Main
|
||||||
|
@ -85,8 +86,18 @@ def rip(ctx, config_path, folder, no_db, quality, convert, no_progress, verbose)
|
||||||
|
|
||||||
# pass to subcommands
|
# pass to subcommands
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
|
ctx.obj["config_path"] = config_path
|
||||||
|
|
||||||
|
try:
|
||||||
c = Config(config_path)
|
c = Config(config_path)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(
|
||||||
|
f"Error loading config from [bold cyan]{config_path}[/bold cyan]: {e}\n"
|
||||||
|
"Try running [bold]rip config reset[/bold]"
|
||||||
|
)
|
||||||
|
ctx.obj["config"] = None
|
||||||
|
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
|
c.session.database.downloads_enabled = not no_db
|
||||||
if folder is not None:
|
if folder is not None:
|
||||||
|
@ -144,7 +155,6 @@ async def file(ctx, path):
|
||||||
@rip.group()
|
@rip.group()
|
||||||
def config():
|
def config():
|
||||||
"""Manage configuration files."""
|
"""Manage configuration files."""
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@config.command("open")
|
@config.command("open")
|
||||||
|
@ -153,7 +163,8 @@ def config():
|
||||||
def config_open(ctx, vim):
|
def config_open(ctx, vim):
|
||||||
"""Open the config file in a text editor."""
|
"""Open the config file in a text editor."""
|
||||||
config_path = ctx.obj["config"].path
|
config_path = ctx.obj["config"].path
|
||||||
console.log(f"Opening file at [bold cyan]{config_path}")
|
|
||||||
|
console.print(f"Opening file at [bold cyan]{config_path}")
|
||||||
if vim:
|
if vim:
|
||||||
if shutil.which("nvim") is not None:
|
if shutil.which("nvim") is not None:
|
||||||
subprocess.run(["nvim", config_path])
|
subprocess.run(["nvim", config_path])
|
||||||
|
@ -168,7 +179,7 @@ def config_open(ctx, vim):
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def config_reset(ctx, yes):
|
def config_reset(ctx, yes):
|
||||||
"""Reset the config file."""
|
"""Reset the config file."""
|
||||||
config_path = ctx.obj["config"].path
|
config_path = ctx.obj["config_path"]
|
||||||
if not yes:
|
if not yes:
|
||||||
if not Confirm.ask(
|
if not Confirm.ask(
|
||||||
f"Are you sure you want to reset the config file at {config_path}?"
|
f"Are you sure you want to reset the config file at {config_path}?"
|
||||||
|
@ -180,6 +191,61 @@ def config_reset(ctx, yes):
|
||||||
console.print(f"Reset the config file at [bold cyan]{config_path}!")
|
console.print(f"Reset the config file at [bold cyan]{config_path}!")
|
||||||
|
|
||||||
|
|
||||||
|
@config.command("path")
|
||||||
|
@click.pass_context
|
||||||
|
def config_path(ctx):
|
||||||
|
"""Display the path of the config file."""
|
||||||
|
config_path = ctx.obj["config_path"]
|
||||||
|
console.print(f"Config path: [bold cyan]'{config_path}'")
|
||||||
|
|
||||||
|
|
||||||
|
@rip.group()
|
||||||
|
def database():
|
||||||
|
"""View and modify the downloads and failed downloads databases."""
|
||||||
|
|
||||||
|
|
||||||
|
@database.command("browse")
|
||||||
|
@click.argument("table")
|
||||||
|
@click.pass_context
|
||||||
|
def database_browse(ctx, table):
|
||||||
|
"""Browse the contents of a table.
|
||||||
|
|
||||||
|
Available tables:
|
||||||
|
|
||||||
|
* Downloads
|
||||||
|
|
||||||
|
* Failed
|
||||||
|
"""
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
cfg: Config = ctx.obj["config"]
|
||||||
|
|
||||||
|
if table.lower() == "downloads":
|
||||||
|
downloads = db.Downloads(cfg.session.database.downloads_path)
|
||||||
|
t = Table(title="Downloads database")
|
||||||
|
t.add_column("Row")
|
||||||
|
t.add_column("ID")
|
||||||
|
for i, row in enumerate(downloads.all()):
|
||||||
|
t.add_row(f"{i:02}", *row)
|
||||||
|
console.print(t)
|
||||||
|
|
||||||
|
elif table.lower() == "failed":
|
||||||
|
failed = db.Failed(cfg.session.database.failed_downloads_path)
|
||||||
|
t = Table(title="Failed downloads database")
|
||||||
|
t.add_column("Source")
|
||||||
|
t.add_column("Media Type")
|
||||||
|
t.add_column("ID")
|
||||||
|
for i, row in enumerate(failed.all()):
|
||||||
|
t.add_row(f"{i:02}", *row)
|
||||||
|
console.print(t)
|
||||||
|
|
||||||
|
else:
|
||||||
|
console.print(
|
||||||
|
f"[red]Invalid database[/red] [bold]{table}[/bold]. [red]Choose[/red] [bold]downloads "
|
||||||
|
"[red]or[/red] failed[/bold]."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@rip.command()
|
@rip.command()
|
||||||
@click.option(
|
@click.option(
|
||||||
"-f",
|
"-f",
|
||||||
|
@ -211,10 +277,42 @@ async def search(ctx, first, source, media_type, query):
|
||||||
|
|
||||||
|
|
||||||
@rip.command()
|
@rip.command()
|
||||||
|
@click.option("-s", "--source", help="The source to search tracks on.")
|
||||||
|
@click.option(
|
||||||
|
"-fs",
|
||||||
|
"--fallback-source",
|
||||||
|
help="The source to search tracks on if no results were found with the main source.",
|
||||||
|
)
|
||||||
@click.argument("url", required=True)
|
@click.argument("url", required=True)
|
||||||
def lastfm(url):
|
@click.pass_context
|
||||||
|
@coro
|
||||||
|
async def lastfm(ctx, source, fallback_source, url):
|
||||||
"""Download tracks from a last.fm playlist using a supported source."""
|
"""Download tracks from a last.fm playlist using a supported source."""
|
||||||
raise NotImplementedError
|
|
||||||
|
config = ctx.obj["config"]
|
||||||
|
if source is not None:
|
||||||
|
config.session.lastfm.source = source
|
||||||
|
if fallback_source is not None:
|
||||||
|
config.session.lastfm.fallback_source = fallback_source
|
||||||
|
with config as cfg:
|
||||||
|
async with Main(cfg) as main:
|
||||||
|
await main.resolve_lastfm(url)
|
||||||
|
await main.rip()
|
||||||
|
|
||||||
|
|
||||||
|
@rip.command()
|
||||||
|
@click.argument("source")
|
||||||
|
@click.argument("media-type")
|
||||||
|
@click.argument("id")
|
||||||
|
@click.pass_context
|
||||||
|
@coro
|
||||||
|
async def id(ctx, source, media_type, id):
|
||||||
|
"""Download an item by ID."""
|
||||||
|
with ctx.obj["config"] as cfg:
|
||||||
|
async with Main(cfg) as main:
|
||||||
|
await main.add_by_id(source, media_type, id)
|
||||||
|
await main.resolve()
|
||||||
|
await main.rip()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
@ -6,7 +6,7 @@ from .. import db
|
||||||
from ..client import Client, QobuzClient, SoundcloudClient
|
from ..client import Client, QobuzClient, SoundcloudClient
|
||||||
from ..config import Config
|
from ..config import Config
|
||||||
from ..console import console
|
from ..console import console
|
||||||
from ..media import Media, Pending, remove_artwork_tempdirs
|
from ..media import Media, Pending, PendingLastfmPlaylist, 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
|
||||||
|
@ -71,26 +71,30 @@ class Main:
|
||||||
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]
|
||||||
url_w_client = []
|
url_client_pairs = []
|
||||||
for i, p in enumerate(parsed):
|
for i, p in enumerate(parsed):
|
||||||
if p is None:
|
if p is None:
|
||||||
console.print(
|
console.print(
|
||||||
f"[red]Found invalid url [cyan]{urls[i]}[/cyan], skipping."
|
f"[red]Found invalid url [cyan]{urls[i]}[/cyan], skipping."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
url_w_client.append((p, await self.get_logged_in_client(p.source)))
|
url_client_pairs.append((p, await self.get_logged_in_client(p.source)))
|
||||||
|
|
||||||
pendings = await asyncio.gather(
|
pendings = await asyncio.gather(
|
||||||
*[
|
*[
|
||||||
url.into_pending(client, self.config, self.database)
|
url.into_pending(client, self.config, self.database)
|
||||||
for url, client in url_w_client
|
for url, client in url_client_pairs
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
self.pending.extend(pendings)
|
self.pending.extend(pendings)
|
||||||
|
|
||||||
async def get_logged_in_client(self, source: str):
|
async def get_logged_in_client(self, source: str):
|
||||||
"""Return a functioning client instance for `source`."""
|
"""Return a functioning client instance for `source`."""
|
||||||
client = self.clients[source]
|
client = self.clients.get(source)
|
||||||
|
if client is None:
|
||||||
|
raise Exception(
|
||||||
|
f"No client named {source} available. Only have {self.clients.keys()}"
|
||||||
|
)
|
||||||
if not client.logged_in:
|
if not client.logged_in:
|
||||||
prompter = get_prompter(client, self.config)
|
prompter = get_prompter(client, self.config)
|
||||||
if not prompter.has_creds():
|
if not prompter.has_creds():
|
||||||
|
@ -110,7 +114,9 @@ class Main:
|
||||||
"""Resolve all currently pending items."""
|
"""Resolve all currently pending items."""
|
||||||
with console.status("Resolving URLs...", spinner="dots"):
|
with console.status("Resolving URLs...", spinner="dots"):
|
||||||
coros = [p.resolve() for p in self.pending]
|
coros = [p.resolve() for p in self.pending]
|
||||||
new_media: list[Media] = await asyncio.gather(*coros)
|
new_media: list[Media] = [
|
||||||
|
m for m in await asyncio.gather(*coros) if m is not None
|
||||||
|
]
|
||||||
|
|
||||||
self.media.extend(new_media)
|
self.media.extend(new_media)
|
||||||
self.pending.clear()
|
self.pending.clear()
|
||||||
|
@ -129,7 +135,7 @@ class Main:
|
||||||
return
|
return
|
||||||
search_results = SearchResults.from_pages(source, media_type, pages)
|
search_results = SearchResults.from_pages(source, media_type, pages)
|
||||||
|
|
||||||
if os.name == "nt" or True:
|
if os.name == "nt":
|
||||||
from pick import pick
|
from pick import pick
|
||||||
|
|
||||||
choices = pick(
|
choices = pick(
|
||||||
|
@ -186,6 +192,24 @@ class Main:
|
||||||
first = search_results.results[0]
|
first = search_results.results[0]
|
||||||
await self.add(f"http://{source}.com/{first.media_type()}/{first.id}")
|
await self.add(f"http://{source}.com/{first.media_type()}/{first.id}")
|
||||||
|
|
||||||
|
async def resolve_lastfm(self, playlist_url: str):
|
||||||
|
"""Resolve a last.fm playlist."""
|
||||||
|
c = self.config.session.lastfm
|
||||||
|
client = await self.get_logged_in_client(c.source)
|
||||||
|
|
||||||
|
if len(c.fallback_source) > 0:
|
||||||
|
fallback_client = await self.get_logged_in_client(c.fallback_source)
|
||||||
|
else:
|
||||||
|
fallback_client = None
|
||||||
|
|
||||||
|
pending_playlist = PendingLastfmPlaylist(
|
||||||
|
playlist_url, client, fallback_client, self.config, self.database
|
||||||
|
)
|
||||||
|
playlist = await pending_playlist.resolve()
|
||||||
|
|
||||||
|
if playlist is not None:
|
||||||
|
self.media.append(playlist)
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@ -201,3 +225,6 @@ 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}")
|
||||||
|
|
|
@ -1,14 +1,18 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from getpass import getpass
|
|
||||||
|
|
||||||
from click import launch, secho, style
|
from click import launch
|
||||||
|
from rich.prompt import Prompt
|
||||||
|
|
||||||
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 ..exceptions import AuthenticationError, MissingCredentials
|
from ..exceptions import AuthenticationError, MissingCredentials
|
||||||
|
|
||||||
|
logger = logging.getLogger("streamrip")
|
||||||
|
|
||||||
|
|
||||||
class CredentialPrompter(ABC):
|
class CredentialPrompter(ABC):
|
||||||
client: Client
|
client: Client
|
||||||
|
@ -53,19 +57,18 @@ class QobuzPrompter(CredentialPrompter):
|
||||||
await self.client.login()
|
await self.client.login()
|
||||||
break
|
break
|
||||||
except AuthenticationError:
|
except AuthenticationError:
|
||||||
secho("Invalid credentials, try again.", fg="yellow")
|
console.print("[yellow]Invalid credentials, try again.")
|
||||||
self._prompt_creds_and_set_session_config()
|
self._prompt_creds_and_set_session_config()
|
||||||
except MissingCredentials:
|
except MissingCredentials:
|
||||||
self._prompt_creds_and_set_session_config()
|
self._prompt_creds_and_set_session_config()
|
||||||
|
|
||||||
def _prompt_creds_and_set_session_config(self):
|
def _prompt_creds_and_set_session_config(self):
|
||||||
secho("Enter Qobuz email: ", fg="green", nl=False)
|
email = Prompt.ask("Enter your Qobuz email")
|
||||||
email = input()
|
pwd_input = Prompt.ask("Enter your Qobuz password (invisible)", password=True)
|
||||||
secho("Enter Qobuz password (will not show on screen): ", fg="green", nl=False)
|
|
||||||
pwd = hashlib.md5(getpass(prompt="").encode("utf-8")).hexdigest()
|
pwd = hashlib.md5(pwd_input.encode("utf-8")).hexdigest()
|
||||||
secho(
|
console.print(
|
||||||
f'Credentials saved to config file at "{self.config.path}"',
|
f"[green]Credentials saved to config file at [bold cyan]{self.config.path}"
|
||||||
fg="green",
|
|
||||||
)
|
)
|
||||||
c = self.config.session.qobuz
|
c = self.config.session.qobuz
|
||||||
c.use_auth_token = False
|
c.use_auth_token = False
|
||||||
|
@ -96,9 +99,8 @@ class TidalPrompter(CredentialPrompter):
|
||||||
device_code = await self.client._get_device_code()
|
device_code = await self.client._get_device_code()
|
||||||
login_link = f"https://{device_code}"
|
login_link = f"https://{device_code}"
|
||||||
|
|
||||||
secho(
|
console.print(
|
||||||
f"Go to {login_link} to log into Tidal within 5 minutes.",
|
f"Go to [blue underline]{login_link}[/blue underline] to log into Tidal within 5 minutes.",
|
||||||
fg="blue",
|
|
||||||
)
|
)
|
||||||
launch(login_link)
|
launch(login_link)
|
||||||
|
|
||||||
|
@ -158,33 +160,25 @@ class DeezerPrompter(CredentialPrompter):
|
||||||
await self.client.login()
|
await self.client.login()
|
||||||
break
|
break
|
||||||
except AuthenticationError:
|
except AuthenticationError:
|
||||||
secho("Invalid arl, try again.", fg="yellow")
|
console.print("[yellow]Invalid arl, try again.")
|
||||||
self._prompt_creds_and_set_session_config()
|
self._prompt_creds_and_set_session_config()
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def _prompt_creds_and_set_session_config(self):
|
def _prompt_creds_and_set_session_config(self):
|
||||||
secho(
|
console.print(
|
||||||
"If you're not sure how to find the ARL cookie, see the instructions at ",
|
"If you're not sure how to find the ARL cookie, see the instructions at ",
|
||||||
nl=False,
|
"[blue underline]https://github.com/nathom/streamrip/wiki/Finding-your-Deezer-ARL-Cookie",
|
||||||
dim=True,
|
|
||||||
)
|
)
|
||||||
secho(
|
|
||||||
"https://github.com/nathom/streamrip/wiki/Finding-your-Deezer-ARL-Cookie",
|
|
||||||
underline=True,
|
|
||||||
fg="blue",
|
|
||||||
)
|
|
||||||
|
|
||||||
c = self.config.session.deezer
|
c = self.config.session.deezer
|
||||||
c.arl = input(style("ARL: ", fg="green"))
|
c.arl = Prompt.ask("Enter your [bold]ARL")
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
c = self.config.session.deezer
|
c = self.config.session.deezer
|
||||||
cf = self.config.file.deezer
|
cf = self.config.file.deezer
|
||||||
cf.arl = c.arl
|
cf.arl = c.arl
|
||||||
self.config.file.set_modified()
|
self.config.file.set_modified()
|
||||||
secho(
|
console.print(
|
||||||
f'Credentials saved to config file at "{self.config.path}"',
|
f"[green]Credentials saved to config file at [bold cyan]{self.config.path}",
|
||||||
fg="green",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def type_check_client(self, client) -> DeezerClient:
|
def type_check_client(self, client) -> DeezerClient:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue