diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 449e476..0000000 --- a/.flake8 +++ /dev/null @@ -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 diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index e69de29..0000000 diff --git a/.mypy.ini b/.mypy.ini deleted file mode 100644 index 086d338..0000000 --- a/.mypy.ini +++ /dev/null @@ -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 diff --git a/README.md b/README.md index c99bd9a..9917eee 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,27 @@ - -![streamrip](demo/logo.svg) +![streamrip logo](https://github.com/nathom/streamrip/blob/dev/demo/logo.svg?raw=true) [![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) 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 -- 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 - Supports downloads of Spotify and Apple Music playlists through [last.fm](https://www.last.fm) - Automatically converts files to a preferred format - 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` ## 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 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). -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 @@ -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 | - ```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 ```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 ```bash -rip search 'fleetwood mac rumours' -``` - -Want to find some new music? Use the `discover` command (only on Qobuz) - -```bash -rip discover --list 'best-sellers' +rip search tidal album 'fleetwood mac rumours' ``` 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 ``` -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) @@ -177,13 +171,8 @@ Thanks to Vitiko98, Sorrow446, and DashLt for their contributions to this projec ## 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 - -Buy Me A Coffee - - -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! +Consider becoming a Github sponsor for me if you enjoy my open source software. diff --git a/demo/album_search.png b/demo/album_search.png deleted file mode 100644 index ee99b6c..0000000 Binary files a/demo/album_search.png and /dev/null differ diff --git a/demo/deezer_downloader_tutorial.png b/demo/deezer_downloader_tutorial.png deleted file mode 100644 index ec7f710..0000000 Binary files a/demo/deezer_downloader_tutorial.png and /dev/null differ diff --git a/demo/download_album.png b/demo/download_album.png index 970e9bc..d1ec63a 100644 Binary files a/demo/download_album.png and b/demo/download_album.png differ diff --git a/demo/example_help_page.png b/demo/example_help_page.png index 2373d52..f92448b 100644 Binary files a/demo/example_help_page.png and b/demo/example_help_page.png differ diff --git a/demo/playlist_search.png b/demo/playlist_search.png new file mode 100644 index 0000000..3584271 Binary files /dev/null and b/demo/playlist_search.png differ diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 9cb1a68..f085b2c 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -181,6 +181,7 @@ class DeezerClient(Client): ) dl_info["url"] = url + logger.debug("dz track info: %s", track_info) return DeezerDownloadable(self.session, dl_info) def _get_encrypted_file_url( @@ -212,5 +213,6 @@ class DeezerClient(Client): path = binascii.hexlify( AES.new(b"jo6aey6haid2Teih", AES.MODE_ECB).encrypt(info_bytes), ).decode("utf-8") - - return f"https://e-cdns-proxy-{track_hash[0]}.dzcdn.net/mobile/1/{path}" + url = f"https://e-cdns-proxy-{track_hash[0]}.dzcdn.net/mobile/1/{path}" + logger.debug("Encrypted file path %s", url) + return url diff --git a/streamrip/client/soundcloud.py b/streamrip/client/soundcloud.py index b62b10f..251adb7 100644 --- a/streamrip/client/soundcloud.py +++ b/streamrip/client/soundcloud.py @@ -1,6 +1,7 @@ import asyncio import itertools import logging +import random import re from ..config import Config @@ -8,8 +9,9 @@ from ..exceptions import NonStreamableError from .client import Client 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" -SOUNDCLOUD_USER_ID = "672320-86895-162383-801513" STOCK_URL = "https://soundcloud.com/" # for playlists @@ -36,7 +38,7 @@ class SoundcloudClient(Client): async def login(self): self.session = await self.get_session() 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() # update file and session configs and save to disk cf = self.global_config.file.soundcloud @@ -54,13 +56,14 @@ class SoundcloudClient(Client): """Fetch metadata for an item in Soundcloud API. Args: - ---- + item_id (str): Plain soundcloud item ID (e.g 1633786176) media_type (str): track or playlist Returns: - ------- - API response. + + API response. The item IDs for the tracks in the playlist are modified to + include resolution status. """ if media_type == "track": # parse custom id that we injected @@ -71,6 +74,82 @@ class SoundcloudClient(Client): else: 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): resp, status = await self._api_request(f"tracks/{item_id}") assert status == 200 @@ -140,62 +219,6 @@ class SoundcloudClient(Client): assert url is not None 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): url = f"{BASE}/{path}" 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: return await resp.content.read(), resp.status - async def _resolve_url(self, url: str) -> dict: - resp, status = await self._api_request("resolve", params={"url": url}) - assert status == 200 - return resp - - async def _announce(self): + async def _announce_success(self): url = f"{BASE}/announcements" _, status = await self._request_body(url) return status == 200 diff --git a/streamrip/media/semaphore.py b/streamrip/media/semaphore.py index 2aa2e88..c71ea89 100644 --- a/streamrip/media/semaphore.py +++ b/streamrip/media/semaphore.py @@ -3,9 +3,6 @@ from contextlib import nullcontext from ..config import DownloadsConfig -INF = 9999 - - _unlimited = nullcontext() _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 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: max_connections = 1 - assert max_connections > 0 - if max_connections == INF: + if max_connections is None: return _unlimited + if max_connections <= 0: + raise Exception(f"{max_connections = } too small") + if _global_semaphore is None: _global_semaphore = (max_connections, asyncio.Semaphore(max_connections)) diff --git a/streamrip/metadata/search_results.py b/streamrip/metadata/search_results.py index 40c204e..ac41246 100644 --- a/streamrip/metadata/search_results.py +++ b/streamrip/metadata/search_results.py @@ -39,7 +39,7 @@ class ArtistSummary(Summary): return "artist" def summarize(self) -> str: - return self.name + return clean(self.name) def preview(self) -> str: return f"{self.num_albums} Albums\n\nID: {self.id}" @@ -73,7 +73,8 @@ class TrackSummary(Summary): return "track" 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: return f"Released on:\n{self.date_released}\n\nID: {self.id}" @@ -119,7 +120,7 @@ class AlbumSummary(Summary): return "album" def summarize(self) -> str: - return f"{self.name} by {self.artist}" + return f"{clean(self.name)} by {clean(self.artist)}" def preview(self) -> str: 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 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: + desc = clean(self.description, trunc=False) 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}" @@ -214,6 +218,7 @@ class PlaylistSummary(Summary): item.get("tracks_count") or item.get("nb_tracks") or item.get("numberOfTracks") + or len(item.get("tracks", [])) or -1 ) description = item.get("description") or "No description" @@ -273,3 +278,11 @@ class SearchResults: assert ind is not None i = int(ind.group(0)) 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 diff --git a/streamrip/progress.py b/streamrip/progress.py index b388352..803a4d7 100644 --- a/streamrip/progress.py +++ b/streamrip/progress.py @@ -25,8 +25,6 @@ class ProgressManager: BarColumn(bar_width=None), "[progress.percentage]{task.percentage:>3.1f}%", "•", - # DownloadColumn(), - # "•", TransferSpeedColumn(), "•", TimeRemainingColumn(), diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index 2756d38..69a60d1 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -36,8 +36,14 @@ def coro(f): "--config-path", default=DEFAULT_CONFIG_PATH, 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( "-ndb", "--no-db", @@ -46,11 +52,14 @@ def coro(f): is_flag=True, ) @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( "-c", - "--convert", + "--codec", help="Convert the downloaded files to an audio codec (ALAC, FLAC, MP3, AAC, or OGG)", ) @click.option( @@ -66,7 +75,7 @@ def coro(f): is_flag=True, ) @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.""" global logger logging.basicConfig( @@ -112,7 +121,8 @@ def rip(ctx, config_path, folder, no_db, quality, convert, no_progress, verbose) return # 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: 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.soundcloud.quality = quality - if convert is not None: + if codec is not None: c.session.conversion.enabled = True - assert convert.upper() in ("ALAC", "FLAC", "OGG", "MP3", "AAC") - c.session.conversion.codec = convert.upper() + assert codec.upper() in ("ALAC", "FLAC", "OGG", "MP3", "AAC") + c.session.conversion.codec = codec.upper() if no_progress: c.session.cli.progress_bars = False @@ -147,7 +157,9 @@ async def url(ctx, urls): @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 @coro 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. Example: - ------- + rip search qobuz album 'rumours' """ with ctx.obj["config"] as cfg: diff --git a/streamrip/rip/main.py b/streamrip/rip/main.py index daa0f2e..a283076 100644 --- a/streamrip/rip/main.py +++ b/streamrip/rip/main.py @@ -6,7 +6,17 @@ from .. import db from ..client import Client, DeezerClient, QobuzClient, SoundcloudClient, TidalClient from ..config import Config 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 ..progress import clear_progress from .parse_url import parse_url @@ -21,6 +31,7 @@ class Main: * Logs in to Clients and prompts for credentials * Handles output logging * Handles downloading Media + * Handles interactive search User input (urls) -> Main --> Download files & Output messages to terminal """ @@ -68,6 +79,32 @@ class Main: ) 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]): """Add multiple urls concurrently as pending items.""" parsed = [parse_url(url) for url in urls] @@ -105,7 +142,6 @@ class Main: with console.status(f"[cyan]Logging into {source}", spinner="dots"): # Log into client using credentials from config await client.login() - # await client.login() assert client.logged_in return client @@ -149,8 +185,8 @@ class Main: ) assert isinstance(choices, list) - await self.add_all( - [f"http://{source}.com/{media_type}/{item.id}" for item, i in choices], + await self.add_all_by_id( + [(source, media_type, item.id) for item, _ in choices], ) else: @@ -173,11 +209,8 @@ class Main: console.print("[yellow]No items chosen. Exiting.") else: choices = search_results.get_choices(chosen_ind) - await self.add_all( - [ - f"http://{source}.com/{item.media_type()}/{item.id}" - for item in choices - ], + await self.add_all_by_id( + [(source, item.media_type(), item.id) for item in choices], ) 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 # We don't know that a cover will not be used again until end of execution 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}") diff --git a/streamrip/rip/parse_url.py b/streamrip/rip/parse_url.py index ce8c16e..75ff87e 100644 --- a/streamrip/rip/parse_url.py +++ b/streamrip/rip/parse_url.py @@ -14,11 +14,17 @@ from ..media import ( PendingPlaylist, PendingSingle, ) -from .validation_regexps import ( - QOBUZ_INTERPRETER_URL_REGEX, - SOUNDCLOUD_URL_REGEX, - URL_REGEX, + +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]+") class URL(ABC): @@ -134,7 +140,7 @@ class SoundcloudURL(URL): config: Config, db: Database, ) -> Pending: - resolved = await client._resolve_url(self.url) + resolved = await client.resolve_url(self.url) media_type = resolved["kind"] item_id = str(resolved["id"]) if media_type == "track": diff --git a/streamrip/rip/validation_regexps.py b/streamrip/rip/validation_regexps.py deleted file mode 100644 index d9108b2..0000000 --- a/streamrip/rip/validation_regexps.py +++ /dev/null @@ -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]+")