mirror of
https://github.com/nathom/streamrip.git
synced 2025-05-13 06:34:45 -04:00
More restructuring
This commit is contained in:
parent
7a35d31c4b
commit
ba05436fec
13 changed files with 158 additions and 164 deletions
|
@ -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",
|
||||
|
|
|
@ -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<seed>[\w=]+)",window\.ut'
|
||||
r"imezone\.(?P<timezone>[a-z]+)\)"
|
||||
)
|
||||
# note: {timezones} should be replaced with every capitalized timezone joined by a |
|
||||
self.info_extras_regex = (
|
||||
r'name:"\w+/(?P<timezone>{timezones})",info:"'
|
||||
r'(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"'
|
||||
)
|
||||
self.app_id_regex = (
|
||||
r'production:{api:{appId:"(?P<app_id>\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'<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>',
|
||||
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
|
|
@ -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<seed>[\w=]+)",window\.ut'
|
||||
r"imezone\.(?P<timezone>[a-z]+)\)"
|
||||
)
|
||||
# note: {timezones} should be replaced with every capitalized timezone joined by a |
|
||||
self.info_extras_regex = (
|
||||
r'name:"\w+/(?P<timezone>{timezones})",info:"'
|
||||
r'(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"'
|
||||
)
|
||||
self.app_id_regex = (
|
||||
r'production:{api:{appId:"(?P<app_id>\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'<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>',
|
||||
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
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue