diff --git a/streamrip/client/__init__.py b/streamrip/client/__init__.py index 022a1bc..4e5dd8c 100644 --- a/streamrip/client/__init__.py +++ b/streamrip/client/__init__.py @@ -1,9 +1,9 @@ from .client import Client -from .deezer_client import DeezerClient +from .deezer import DeezerClient from .downloadable import BasicDownloadable, Downloadable -from .qobuz_client import QobuzClient -from .soundcloud_client import SoundcloudClient -from .tidal_client import TidalClient +from .qobuz import QobuzClient +from .soundcloud import SoundcloudClient +from .tidal import TidalClient __all__ = [ "Client", diff --git a/streamrip/client/deezer_client.py b/streamrip/client/deezer.py similarity index 100% rename from streamrip/client/deezer_client.py rename to streamrip/client/deezer.py diff --git a/streamrip/client/qobuz_client.py b/streamrip/client/qobuz.py similarity index 74% rename from streamrip/client/qobuz_client.py rename to streamrip/client/qobuz.py index c76054c..2906c04 100644 --- a/streamrip/client/qobuz_client.py +++ b/streamrip/client/qobuz.py @@ -1,9 +1,13 @@ import asyncio +import base64 import hashlib import logging import re import time -from typing import AsyncGenerator, Optional +from collections import OrderedDict +from typing import AsyncGenerator, List, Optional + +import aiohttp from ..config import Config from ..exceptions import ( @@ -16,7 +20,6 @@ from ..exceptions import ( ) from .client import Client from .downloadable import BasicDownloadable, Downloadable -from .qobuz_spoofer import QobuzSpoofer logger = logging.getLogger("streamrip") @@ -41,6 +44,95 @@ QOBUZ_FEATURED_KEYS = { } +class QobuzSpoofer: + """Spoofs the information required to stream tracks from Qobuz.""" + + def __init__(self): + """Create a Spoofer.""" + self.seed_timezone_regex = ( + r'[a-z]\.initialSeed\("(?P[\w=]+)",window\.ut' + r"imezone\.(?P[a-z]+)\)" + ) + # note: {timezones} should be replaced with every capitalized timezone joined by a | + self.info_extras_regex = ( + r'name:"\w+/(?P{timezones})",info:"' + r'(?P[\w=]+)",extras:"(?P[\w=]+)"' + ) + self.app_id_regex = ( + r'production:{api:{appId:"(?P\d{9})",appSecret:"(\w{32})' + ) + self.session = None + + async def get_app_id_and_secrets(self) -> tuple[str, list[str]]: + assert self.session is not None + async with self.session.get("https://play.qobuz.com/login") as req: + login_page = await req.text() + + bundle_url_match = re.search( + r'', + login_page, + ) + assert bundle_url_match is not None + bundle_url = bundle_url_match.group(1) + + async with self.session.get("https://play.qobuz.com" + bundle_url) as req: + self.bundle = await req.text() + + match = re.search(self.app_id_regex, self.bundle) + if match is None: + raise Exception("Could not find app id.") + + app_id = str(match.group("app_id")) + + # get secrets + seed_matches = re.finditer(self.seed_timezone_regex, self.bundle) + secrets = OrderedDict() + for match in seed_matches: + seed, timezone = match.group("seed", "timezone") + secrets[timezone] = [seed] + + """ + The code that follows switches around the first and second timezone. + Qobuz uses two ternary (a shortened if statement) conditions that + should always return false. The way Javascript's ternary syntax + works, the second option listed is what runs if the condition returns + false. Because of this, we must prioritize the *second* seed/timezone + pair captured, not the first. + """ + + keypairs = list(secrets.items()) + secrets.move_to_end(keypairs[1][0], last=False) + + info_extras_regex = self.info_extras_regex.format( + timezones="|".join(timezone.capitalize() for timezone in secrets) + ) + info_extras_matches = re.finditer(info_extras_regex, self.bundle) + for match in info_extras_matches: + timezone, info, extras = match.group("timezone", "info", "extras") + secrets[timezone.lower()] += [info, extras] + + for secret_pair in secrets: + secrets[secret_pair] = base64.standard_b64decode( + "".join(secrets[secret_pair])[:-44] + ).decode("utf-8") + + vals: List[str] = list(secrets.values()) + vals.remove("") + + secrets_list = vals + + return app_id, secrets_list + + async def __aenter__(self): + self.session = aiohttp.ClientSession() + return self + + async def __aexit__(self, *_): + if self.session is not None: + await self.session.close() + self.session = None + + class QobuzClient(Client): source = "qobuz" max_quality = 4 diff --git a/streamrip/client/qobuz_spoofer.py b/streamrip/client/qobuz_spoofer.py deleted file mode 100644 index ddde6d0..0000000 --- a/streamrip/client/qobuz_spoofer.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Get app id and secrets for Qobuz. - -Credits to Dash for this tool. -""" - -import base64 -import re -from collections import OrderedDict -from typing import List - -import aiohttp - - -class QobuzSpoofer: - """Spoofs the information required to stream tracks from Qobuz.""" - - def __init__(self): - """Create a Spoofer.""" - self.seed_timezone_regex = ( - r'[a-z]\.initialSeed\("(?P[\w=]+)",window\.ut' - r"imezone\.(?P[a-z]+)\)" - ) - # note: {timezones} should be replaced with every capitalized timezone joined by a | - self.info_extras_regex = ( - r'name:"\w+/(?P{timezones})",info:"' - r'(?P[\w=]+)",extras:"(?P[\w=]+)"' - ) - self.app_id_regex = ( - r'production:{api:{appId:"(?P\d{9})",appSecret:"(\w{32})' - ) - self.session = None - - async def get_app_id_and_secrets(self) -> tuple[str, list[str]]: - assert self.session is not None - async with self.session.get("https://play.qobuz.com/login") as req: - login_page = await req.text() - - bundle_url_match = re.search( - r'', - login_page, - ) - assert bundle_url_match is not None - bundle_url = bundle_url_match.group(1) - - async with self.session.get("https://play.qobuz.com" + bundle_url) as req: - self.bundle = await req.text() - - match = re.search(self.app_id_regex, self.bundle) - if match is None: - raise Exception("Could not find app id.") - - app_id = str(match.group("app_id")) - - # get secrets - seed_matches = re.finditer(self.seed_timezone_regex, self.bundle) - secrets = OrderedDict() - for match in seed_matches: - seed, timezone = match.group("seed", "timezone") - secrets[timezone] = [seed] - - """ - The code that follows switches around the first and second timezone. - Qobuz uses two ternary (a shortened if statement) conditions that - should always return false. The way Javascript's ternary syntax - works, the second option listed is what runs if the condition returns - false. Because of this, we must prioritize the *second* seed/timezone - pair captured, not the first. - """ - - keypairs = list(secrets.items()) - secrets.move_to_end(keypairs[1][0], last=False) - - info_extras_regex = self.info_extras_regex.format( - timezones="|".join(timezone.capitalize() for timezone in secrets) - ) - info_extras_matches = re.finditer(info_extras_regex, self.bundle) - for match in info_extras_matches: - timezone, info, extras = match.group("timezone", "info", "extras") - secrets[timezone.lower()] += [info, extras] - - for secret_pair in secrets: - secrets[secret_pair] = base64.standard_b64decode( - "".join(secrets[secret_pair])[:-44] - ).decode("utf-8") - - vals: List[str] = list(secrets.values()) - vals.remove("") - - secrets_list = vals - - return app_id, secrets_list - - async def __aenter__(self): - self.session = aiohttp.ClientSession() - return self - - async def __aexit__(self, *_): - if self.session is not None: - await self.session.close() - self.session = None diff --git a/streamrip/client/soundcloud_client.py b/streamrip/client/soundcloud.py similarity index 100% rename from streamrip/client/soundcloud_client.py rename to streamrip/client/soundcloud.py diff --git a/streamrip/client/tidal_client.py b/streamrip/client/tidal.py similarity index 100% rename from streamrip/client/tidal_client.py rename to streamrip/client/tidal.py diff --git a/streamrip/config.py b/streamrip/config.py index 8036427..ccd0fc5 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -1,22 +1,19 @@ """A config class that manages arguments between the config file and CLI.""" - import copy import logging +import os +import shutil from dataclasses import dataclass, fields +from pathlib import Path +import click from tomlkit.api import dumps, parse from tomlkit.toml_document import TOMLDocument -from .user_paths import ( - DEFAULT_CONFIG_PATH, - DEFAULT_DOWNLOADS_DB_PATH, - DEFAULT_DOWNLOADS_FOLDER, - DEFAULT_FAILED_DOWNLOADS_DB_PATH, - DEFAULT_YOUTUBE_VIDEO_DOWNLOADS_FOLDER, -) - logger = logging.getLogger("streamrip") +APP_DIR = click.get_app_dir("streamrip", force_posix=True) +DEFAULT_CONFIG_PATH = os.path.join(APP_DIR, "config.toml") CURRENT_CONFIG_VERSION = "2.0" @@ -216,6 +213,17 @@ class MiscConfig: version: str +HOME = Path.home() +DEFAULT_DOWNLOADS_FOLDER = os.path.join(HOME, "StreamripDownloads") +DEFAULT_DOWNLOADS_DB_PATH = os.path.join(APP_DIR, "downloads.db") +DEFAULT_FAILED_DOWNLOADS_DB_PATH = os.path.join(APP_DIR, "failed_downloads.db") +DEFAULT_YOUTUBE_VIDEO_DOWNLOADS_FOLDER = os.path.join( + DEFAULT_DOWNLOADS_FOLDER, "YouTubeVideos" +) +BLANK_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.toml") +assert os.path.isfile(BLANK_CONFIG_PATH), "Template config not found" + + @dataclass(slots=True) class ConfigData: toml: TOMLDocument @@ -287,7 +295,7 @@ class ConfigData: @classmethod def defaults(cls): - with open(DEFAULT_CONFIG_PATH) as f: + with open(BLANK_CONFIG_PATH) as f: return cls.from_toml(f.read()) def set_modified(self): @@ -352,7 +360,7 @@ class Config: @classmethod def defaults(cls): - return cls(DEFAULT_CONFIG_PATH) + return cls(BLANK_CONFIG_PATH) def __enter__(self): return self @@ -362,10 +370,9 @@ class Config: def set_user_defaults(path: str, /): - """Update the TOML file at the path with user-specific default values. + """Update the TOML file at the path with user-specific default values.""" + shutil.copy(BLANK_CONFIG_PATH, path) - MUST copy updated blank config to `path` before calling this! - """ with open(path) as f: toml = parse(f.read()) toml["downloads"]["folder"] = DEFAULT_DOWNLOADS_FOLDER # type: ignore diff --git a/streamrip/media/__init__.py b/streamrip/media/__init__.py index 9aa0f6b..ab458dd 100644 --- a/streamrip/media/__init__.py +++ b/streamrip/media/__init__.py @@ -1,21 +1,25 @@ from .album import Album, PendingAlbum from .artist import Artist, PendingArtist +from .artwork import remove_artwork_tempdirs from .label import Label, PendingLabel -from .media import Media +from .media import Media, Pending from .playlist import PendingPlaylist, PendingPlaylistTrack, Playlist -from .track import PendingTrack, Track +from .track import PendingSingle, PendingTrack, Track __all__ = [ - "Album", - "Artist", - "Label", "Media", + "Pending", + "Album", "PendingAlbum", + "Artist", "PendingArtist", + "Label", "PendingLabel", - "PendingPlaylist", - "PendingPlaylistTrack", - "PendingTrack", "Playlist", + "PendingPlaylist", "Track", + "PendingTrack", + "PendingPlaylistTrack", + "PendingSingle", + "remove_artwork_tempdirs", ] diff --git a/streamrip/metadata/tagger.py b/streamrip/metadata/tagger.py index ca25e3b..45eb0cb 100644 --- a/streamrip/metadata/tagger.py +++ b/streamrip/metadata/tagger.py @@ -9,7 +9,7 @@ from mutagen.id3 import APIC # type: ignore from mutagen.id3 import ID3 from mutagen.mp4 import MP4, MP4Cover -from . import TrackMetadata +from .track_metadata import TrackMetadata logger = logging.getLogger("streamrip") diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index 2795da7..3a9d44b 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -11,10 +11,10 @@ from rich.logging import RichHandler from rich.prompt import Confirm from rich.traceback import install -from .config import Config, set_user_defaults -from .console import console +from ..config import DEFAULT_CONFIG_PATH, Config, set_user_defaults +from ..console import console from .main import Main -from .user_paths import BLANK_CONFIG_PATH, DEFAULT_CONFIG_PATH +from .user_paths import DEFAULT_CONFIG_PATH def coro(f): @@ -81,7 +81,6 @@ def rip(ctx, config_path, folder, no_db, quality, convert, no_progress, verbose) console.print( f"No file found at [bold cyan]{config_path}[/bold cyan], creating default config." ) - shutil.copy(BLANK_CONFIG_PATH, config_path) set_user_defaults(config_path) # pass to subcommands @@ -177,7 +176,6 @@ def config_reset(ctx, yes): console.print("[green]Reset aborted") return - shutil.copy(BLANK_CONFIG_PATH, config_path) set_user_defaults(config_path) console.print(f"Reset the config file at [bold cyan]{config_path}!") @@ -197,6 +195,7 @@ async def search(query, source): @rip.command() @click.argument("url", required=True) def lastfm(url): + """Download tracks from a last.fm playlist using a supported source.""" raise NotImplementedError diff --git a/streamrip/rip/main.py b/streamrip/rip/main.py index 7d6e0bb..16e2d22 100644 --- a/streamrip/rip/main.py +++ b/streamrip/rip/main.py @@ -1,17 +1,14 @@ import asyncio import logging -from . import db -from .artwork import remove_artwork_tempdirs -from .client import Client -from .config import Config -from .console import console -from .media import Media, Pending -from .progress import clear_progress +from .. import db +from ..client import Client, QobuzClient, SoundcloudClient +from ..config import Config +from ..console import console +from ..media import Media, Pending, remove_artwork_tempdirs +from ..progress import clear_progress +from .parse_url import parse_url from .prompter import get_prompter -from .qobuz_client import QobuzClient -from .soundcloud_client import SoundcloudClient -from .universal_url import parse_url logger = logging.getLogger("streamrip") diff --git a/streamrip/rip/parse_url.py b/streamrip/rip/parse_url.py index aa3e361..8eee745 100644 --- a/streamrip/rip/parse_url.py +++ b/streamrip/rip/parse_url.py @@ -3,27 +3,26 @@ from __future__ import annotations import re from abc import ABC, abstractmethod -from .album import PendingAlbum -from .artist import PendingArtist -from .client import Client -from .config import Config -from .db import Database -from .label import PendingLabel -from .media import Pending -from .playlist import PendingPlaylist -from .soundcloud_client import SoundcloudClient -from .track import PendingSingle +from ..client import Client, SoundcloudClient +from ..config import Config +from ..db import Database +from ..media import ( + Pending, + PendingAlbum, + PendingArtist, + PendingLabel, + PendingPlaylist, + PendingSingle, +) from .validation_regexps import ( - DEEZER_DYNAMIC_LINK_REGEX, - LASTFM_URL_REGEX, QOBUZ_INTERPRETER_URL_REGEX, SOUNDCLOUD_URL_REGEX, URL_REGEX, - YOUTUBE_URL_REGEX, ) class URL(ABC): + match: re.Match source: str def __init__(self, match: re.Match, source: str): diff --git a/streamrip/rip/prompter.py b/streamrip/rip/prompter.py index e3080dd..faa0def 100644 --- a/streamrip/rip/prompter.py +++ b/streamrip/rip/prompter.py @@ -5,13 +5,9 @@ from getpass import getpass from click import launch, secho, style -from .client import Client -from .config import Config -from .deezer_client import DeezerClient -from .exceptions import AuthenticationError, MissingCredentials -from .qobuz_client import QobuzClient -from .soundcloud_client import SoundcloudClient -from .tidal_client import TidalClient +from ..client import Client, DeezerClient, QobuzClient, SoundcloudClient, TidalClient +from ..config import Config +from ..exceptions import AuthenticationError, MissingCredentials class CredentialPrompter(ABC):