diff --git a/README.md b/README.md index 0608e0f..9c1e73e 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,13 @@ And if you really enjoy the track, you can download it by doing: audius tracks download G0wyE song.mp3 ``` +By default, `audius-py` tries to find the best player. +However, specify your player of choice using the `--player` flag: + +```shell +audius tracks play G0wyE --player vlc +``` + ### Python SDK Use the Python SDK directly: diff --git a/audius/cli/options.py b/audius/cli/options.py new file mode 100644 index 0000000..621f8a9 --- /dev/null +++ b/audius/cli/options.py @@ -0,0 +1,12 @@ +import click + +from audius.types import PlayerType + + +def player_option(): + return click.option( + "--player", + help="The player to use.", + type=click.Choice([x.value for x in PlayerType.__members__.values()], case_sensitive=False), + callback=lambda _, _2, val: PlayerType(val), + ) diff --git a/audius/cli/tracks.py b/audius/cli/tracks.py index 8cff1d4..1aee897 100644 --- a/audius/cli/tracks.py +++ b/audius/cli/tracks.py @@ -3,8 +3,11 @@ import click +from audius.cli.options import player_option from audius.cli.utils import sdk +DEFAULT_BUFFER_SIZE = 1024 * 1024 + def tracks(sdk_cls: Type): sdk.py = sdk_cls @@ -55,23 +58,27 @@ def search(sdk, query): @cli.command() @sdk.audius() @click.argument("track_id") - def play(sdk, track_id): + @player_option() + def play(sdk, track_id, player): """ Play a track. """ - sdk.tracks.play(track_id) + sdk.tracks.play(track_id, player=player) @cli.command() @sdk.audius() @click.argument("track_id") @click.argument("out_path", type=Path) - def download(sdk, track_id, out_path): + @click.option( + "--buffer-size", help="The buffer size when downloading.", default=DEFAULT_BUFFER_SIZE + ) + def download(sdk, track_id, out_path, buffer_size): """ Download a track. """ - sdk.tracks.download(track_id, out_path) + sdk.tracks.download(track_id, out_path, chunk_size=buffer_size) return cli diff --git a/audius/client.py b/audius/client.py index f021074..8761851 100644 --- a/audius/client.py +++ b/audius/client.py @@ -1,14 +1,44 @@ from functools import cached_property +from typing import TYPE_CHECKING from requests import Response, Session -from audius.config import Config +if TYPE_CHECKING: + from audius.config import Config + from audius.playlists import Playlists + from audius.sdk import Audius + from audius.tips import Tips + from audius.tracks import Tracks + from audius.users import Users class API: - def __init__(self, client: "Client", config: Config): - self.client = client - self.config = config + def __init__(self, sdk: "Audius"): + self.sdk = sdk + + @property + def client(self) -> "Client": + return self.sdk.client + + @property + def config(self) -> "Config": + return self.sdk.config + + @property + def playlists(self) -> "Playlists": + return self.sdk.playlists + + @property + def tips(self) -> "Tips": + return self.sdk.tips + + @property + def tracks(self) -> "Tracks": + return self.sdk.tracks + + @property + def users(self) -> "Users": + return self.sdk.users def _handle_id(self, _id: str) -> str: return self.config.aliases[_id] if _id in self.config.aliases else _id @@ -30,8 +60,16 @@ def get(self, url: str, **kwargs) -> dict: return self.request("GET", url, **kwargs).json() + def get_redirect_url(self, uri: str): + result = self.request("HEAD", uri, allow_redirects=True) + return result.url + def request(self, method: str, url: str, **kwargs) -> Response: - url = f"{self.host_address}/v1/{url}" + prefix = f"{self.host_address}/v1/" + uri = url.replace(prefix, "") + if "https://" not in uri: + url = f"{prefix}{uri}" + response = self.session.request(method, url, **kwargs) response.raise_for_status() return response diff --git a/audius/config.py b/audius/config.py index 9fde1b8..c77df8b 100644 --- a/audius/config.py +++ b/audius/config.py @@ -1,9 +1,12 @@ import os -from typing import Dict, Optional +from typing import Dict, Optional, Union + +from audius.player import PlayerType DEFAULT_APP_NAME = "audius-py" AUDIUS_APP_NAME_ENV_VAR = "AUDIUS_APP_NAME" AUDIUS_HOST_NAME_ENV_VAR = "AUDIUS_HOST_NAME" +AUDIUS_PLAYER_ENV_VAR = "AUDIUS_PLAYER" class Config: @@ -16,13 +19,16 @@ def __init__( app_name: str = DEFAULT_APP_NAME, host: Optional[str] = None, aliases: Optional[Dict[str, str]] = None, + player: Optional[Union[PlayerType, str]] = None, ): self.app_name = app_name self.host = host # Uses random if not provided. self.aliases = aliases or {} + self.player = PlayerType(player) if player is not None else None @classmethod def from_env(cls) -> "Config": app_name = os.environ.get(AUDIUS_APP_NAME_ENV_VAR, DEFAULT_APP_NAME) host = os.environ.get(AUDIUS_HOST_NAME_ENV_VAR) - return cls(app_name=app_name, host=host) + player = os.environ.get(AUDIUS_PLAYER_ENV_VAR) + return cls(app_name=app_name, host=host, player=player) diff --git a/audius/data.py b/audius/data.py deleted file mode 100644 index 311d7fc..0000000 --- a/audius/data.py +++ /dev/null @@ -1,5 +0,0 @@ -from pathlib import Path - - -class Data: - path: Path = Path.home() / ".audius_py" diff --git a/audius/exceptions.py b/audius/exceptions.py index cbc2da2..e29a97f 100644 --- a/audius/exceptions.py +++ b/audius/exceptions.py @@ -50,7 +50,7 @@ class MissingPlayerError(AudiusException): """ def __init__(self): - super().__init__("Missing audio player. Ensure VLC music player is installed.") + super().__init__("Missing audio player. Try installing VLC music player.") class OutputPathError(AudiusException): diff --git a/audius/player.py b/audius/player.py deleted file mode 100644 index f6b4b7f..0000000 --- a/audius/player.py +++ /dev/null @@ -1,34 +0,0 @@ -import time -from functools import cached_property -from typing import Optional - -from audius.exceptions import MissingPlayerError - - -def get_vlc(): - try: - import vlc # type: ignore - - except Exception: - raise MissingPlayerError() - - return vlc - - -class Player: - @cached_property - def vlc(self): - # Lazy load to allow SDK to work when VLC not installed. - return get_vlc() - - @cached_property - def _player(self): - return self.vlc.MediaPlayer() - - def play(self, url: str, duration: Optional[int] = None): - media = self.vlc.Media(url) - self._player.set_media(media) - self._player.play() - time.sleep(5) # Wait 5 seconds for it to start. - while self._player.is_playing(): - time.sleep(1) diff --git a/audius/player/__init__.py b/audius/player/__init__.py new file mode 100644 index 0000000..300df94 --- /dev/null +++ b/audius/player/__init__.py @@ -0,0 +1,52 @@ +from typing import TYPE_CHECKING, Dict, Optional, Type + +from audius.client import API +from audius.exceptions import MissingPlayerError +from audius.player.af import AFPlayer +from audius.player.base import BasePlayer +from audius.player.vlc import VLCPlayer +from audius.types import PlayerType + +if TYPE_CHECKING: + from audius.sdk import Audius + + +class Player(API): + def __init__(self, sdk: "Audius") -> None: + super().__init__(sdk) + self._player_classes: Dict[PlayerType, Type] = { + PlayerType.AFPLAY: AFPlayer, + PlayerType.VLC: VLCPlayer, + } + self._player_map: Dict[PlayerType, BasePlayer] = {} + + def play(self, url: str, player_type: Optional[PlayerType] = None): + player = self.get_player(player_type=player_type) + player.play(url) + + def display_now_playing(self, track: Dict, player_type: Optional[PlayerType] = None): + player = self.get_player(player_type=player_type) + player.display_now_playing(track) + + def get_player(self, player_type: Optional[PlayerType] = None) -> BasePlayer: + player_type = player_type or self.config.player + if player_type is not None: + if player_type not in self._player_classes: + raise ValueError(f"Unknown player type '{player_type}'") + + self._player_map[player_type] = self._player_classes[player_type](self.sdk) + return self._player_map[player_type] + + if self._player_map: + # Use previously connected player. + player_type = next(iter(self._player_map)) + return self._player_map[player_type] + + # Find an available player. + for player_cls in self._player_classes.values(): + player = player_cls(self.sdk) + if player.is_available(): + self._player_map[player._type] = player + return player + + raise MissingPlayerError() diff --git a/audius/player/af.py b/audius/player/af.py new file mode 100644 index 0000000..8418280 --- /dev/null +++ b/audius/player/af.py @@ -0,0 +1,41 @@ +import os +import subprocess +import tempfile +import threading +import time +from typing import TYPE_CHECKING + +from audius.player.base import BasePlayer +from audius.types import PlayerType + +if TYPE_CHECKING: + from audius.sdk import Audius + + +class AFPlayer(BasePlayer): + def __init__(self, sdk: "Audius"): + super().__init__(PlayerType.AFPLAY, sdk) + + def is_available(self): + try: + subprocess.run("afplay", stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True + except FileNotFoundError: + return False + + def play(self, url: str): + download_url = self.client.get_redirect_url(url) + with tempfile.NamedTemporaryFile(mode="w+b", delete=False) as _file: + fd3 = os.dup(_file.fileno()) + + def download(): + self.sdk.tracks.download(download_url, fd3, hide_output=True) + + # Stream the song while playing it to prevent waiting + # for entire track to finish download. + thread = threading.Thread(target=download) + thread.start() + time.sleep(5) # Buffer + subprocess.Popen(["afplay", _file.name]) + thread.join() + time.sleep(1) diff --git a/audius/player/base.py b/audius/player/base.py new file mode 100644 index 0000000..60cfc87 --- /dev/null +++ b/audius/player/base.py @@ -0,0 +1,35 @@ +from abc import abstractmethod +from typing import TYPE_CHECKING, Dict + +import click + +from audius.client import API +from audius.types import PlayerType + +if TYPE_CHECKING: + from audius.sdk import Audius + + +class BasePlayer(API): + def __init__(self, player_type: PlayerType, sdk: "Audius"): + self._type = player_type + super().__init__(sdk) + + @abstractmethod + def is_available(self) -> bool: + """ + Returns True if this play is working. + """ + + @abstractmethod + def play(self, url: str): + """ + Stream and play track from Audius. + Player-subclasses must implement this method. + """ + + def display_now_playing(self, track: Dict): + click.echo( + f"({self._type.value.lower().capitalize()}) " + f"Now playing '{track['title']}' by {track['user']['name']}" + ) diff --git a/audius/player/vlc.py b/audius/player/vlc.py new file mode 100644 index 0000000..f11bbae --- /dev/null +++ b/audius/player/vlc.py @@ -0,0 +1,45 @@ +import time +from functools import cached_property +from typing import TYPE_CHECKING + +from audius.exceptions import MissingPlayerError +from audius.player.base import BasePlayer +from audius.types import PlayerType + +if TYPE_CHECKING: + from audius.sdk import Audius + + +class VLCPlayer(BasePlayer): + def __init__(self, sdk: "Audius"): + super().__init__(PlayerType.VLC, sdk) + + @cached_property + def vlc(self): + # Lazy load to allow SDK to work when VLC not installed. + try: + import vlc # type: ignore + + except Exception: + raise MissingPlayerError() + + return vlc + + @cached_property + def _player(self): + return self.vlc.MediaPlayer() + + def is_available(self) -> bool: + try: + _ = self.vlc + return True + except MissingPlayerError: + return False + + def play(self, url: str): + media = self.vlc.Media(url) + self._player.set_media(media) + self._player.play() + time.sleep(5) # Wait 5 seconds for it to start. + while self._player.is_playing(): + time.sleep(1) diff --git a/audius/sdk.py b/audius/sdk.py index 2723c9a..8f047f1 100644 --- a/audius/sdk.py +++ b/audius/sdk.py @@ -14,7 +14,7 @@ class Audius: def __init__(self, config: Optional[Config] = None): self.config = config or Config.from_env() self.factory = ClientFactory(self.config.app_name) - self.player = Player() + self.player = Player(self) @cached_property def client(self): @@ -31,19 +31,19 @@ def client(self): @cached_property def users(self) -> Users: - return Users(self.client, self.config) + return Users(self) @cached_property def playlists(self) -> Playlists: - return Playlists(self.client, self.config) + return Playlists(self) @cached_property def tracks(self) -> Tracks: - return Tracks(self.client, self.config, self.player) + return Tracks(self) @cached_property def tips(self) -> Tips: - return Tips(self.client, self.config) + return Tips(self) @classmethod def get_hosts(cls): diff --git a/audius/tracks.py b/audius/tracks.py index ea3b3f3..ac9febc 100644 --- a/audius/tracks.py +++ b/audius/tracks.py @@ -1,14 +1,15 @@ +import os from pathlib import Path -from typing import Dict, Iterator, List, Optional +from typing import IO, Dict, Iterable, Iterator, List, Optional, Tuple, Union import click +from requests import Response from requests.exceptions import HTTPError from tqdm import tqdm # type: ignore -from audius.client import API, Client -from audius.config import Config +from audius.client import API from audius.exceptions import OutputPathError, TrackNotFoundError -from audius.player import Player +from audius.types import FileDestination, PlayerType class DownloadProgressBar(tqdm): @@ -18,11 +19,54 @@ def update_to(self, b=1, bsize=1, tsize=None): self.update(b * bsize - self.n) -class Tracks(API): - def __init__(self, client: Client, config: Config, player: Player): - self.player = player - super().__init__(client, config) +def _write_response( + output_paths: List[FileDestination], + response: Response, + progress_bar: Optional[DownloadProgressBar] = None, + chunk_size: int = 1, +): + output_files: List[Tuple[IO, bool]] = [] + for output_path in output_paths: + if isinstance(output_path, Path): + _file = open(str(output_path), "wb") + output_files.append((_file, True)) + else: + output_files.append((output_path, False)) # type: ignore + + for chunk in response.iter_content(chunk_size=chunk_size, decode_unicode=True): + if chunk: + for out_file, _ in output_files: + if isinstance(out_file, int): + # File descriptor. + os.write(out_file, chunk) + else: + out_file.write(chunk) + + if progress_bar is not None: + progress_bar.update(len(chunk)) + + for out_file, do_close in output_files: + if do_close: + out_file.close() + + +def _validate_output_paths( + output_paths: Union[FileDestination, Iterable[FileDestination]] +) -> List[FileDestination]: + output_path_ls: List[FileDestination] + if not isinstance(output_paths, (list, tuple)): + output_path_ls = [output_paths] # type: ignore + else: + output_path_ls = list(output_paths) + + for path in output_path_ls: + if isinstance(path, Path) and path.is_file(): + raise OutputPathError("File exists.") + + return output_path_ls + +class Tracks(API): def trending(self) -> Iterator[dict]: yield from self.client.get("tracks/trending").get("data", []) @@ -42,30 +86,41 @@ def search(self, query: Optional[str] = None) -> List[Dict]: result = self.client.get("tracks/search", params={"query": query}) return result.get("data", []) - def play(self, track_id: str): + def play(self, track_id: str, player: Optional[PlayerType] = None): track_id = self._handle_id(track_id) track = self.get(track_id) + self.sdk.player.display_now_playing(track, player_type=player) url = f"{self.client.host_address}/v1/tracks/{track_id}/stream" - click.echo(f"Now playing '{track['title']}' by {track['user']['name']}") - self.player.play(url) + self.sdk.player.play(url, player_type=player) - def download(self, track_id: str, output_path: Path): + def download( + self, + track_id: str, + output_paths: Union[FileDestination, Iterable[FileDestination]], + hide_output: bool = False, + chunk_size: int = 1, + ): + output_path_ls = _validate_output_paths(output_paths) track_id = self._handle_id(track_id) - if output_path.is_file(): - raise OutputPathError("File exists.") - track = self.get(track_id) - click.echo(f"Downloading '{track['title']}' by {track['user']['name']}") - click.echo(f"Saving at '{output_path}'.") + # Allow full-URLs as well, in case using a re-direct. + uri = f"tracks/{track_id}/stream" if "https://" not in track_id else track_id - uri = f"tracks/{track_id}/stream" headers = {"Accept": "application/octet-stream"} response = self.client.request("GET", uri, stream=True, headers=headers) - with DownloadProgressBar( + if hide_output: + _write_response(output_path_ls, response, chunk_size=chunk_size) + return + + # Show progress in output. + progress_bar = DownloadProgressBar( unit="B", unit_scale=True, miniters=1, desc=uri.split("/")[-1] - ) as bar: - with open(str(output_path), "wb") as out_file: - for chunk in response.iter_content(chunk_size=1, decode_unicode=True): - if chunk: - out_file.write(chunk) - bar.update(len(chunk)) + ) + track = self.get(track_id) + click.echo(f"Downloading '{track['title']}' by {track['user']['name']}") + + dest = ", ".join([str(x) for x in output_path_ls]) + click.echo(f"Saving at '{dest}'.") + + with progress_bar as bar: + _write_response(output_path_ls, response, progress_bar=bar) diff --git a/audius/types.py b/audius/types.py new file mode 100644 index 0000000..1302e18 --- /dev/null +++ b/audius/types.py @@ -0,0 +1,12 @@ +from enum import Enum +from pathlib import Path +from typing import IO, Union + + +class PlayerType(Enum): + VLC = "VLC" + AFPLAY = "AFPLAY" + + +FileDestination = Union[Path, IO, int] +"""File path, an open file stream, or a file descriptor.""" diff --git a/setup.py b/setup.py index 0878b92..9fe906b 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,9 @@ "wheel", "twine==3.8", ], + "vlc": [ + "python-vlc>=3.0.18121,<4", + ], } # NOTE: `pip install -e .[dev]` to install package @@ -36,6 +39,7 @@ + extras_require["lint"] + extras_require["doc"] + extras_require["release"] + + extras_require["vlc"] ) with open("./README.md") as readme: @@ -57,7 +61,6 @@ "requests>=2.28.2,<3", "click>=8.1.3,<9", "tqdm>=4.65.0,<5", - "python-vlc>=3.0.18121,<4", ], python_requires=">=3.9,<4", extras_require=extras_require, diff --git a/tests/conftest.py b/tests/conftest.py index d2e5bd3..06afeba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import pytest -from audius.config import AUDIUS_APP_NAME_ENV_VAR, AUDIUS_HOST_NAME_ENV_VAR +from audius.config import AUDIUS_APP_NAME_ENV_VAR, AUDIUS_HOST_NAME_ENV_VAR, AUDIUS_PLAYER_ENV_VAR @pytest.fixture @@ -16,6 +16,11 @@ def host(): return "https://audius.example.com" +@pytest.fixture +def player(): + return "VLC" + + @contextmanager def temp_set_env(key: str, value: str): existing = os.environ.get(key) @@ -37,3 +42,9 @@ def app_name_from_env(app_name): def host_from_env(host): with temp_set_env(AUDIUS_HOST_NAME_ENV_VAR, host): yield host + + +@pytest.fixture +def player_from_env(player): + with temp_set_env(AUDIUS_PLAYER_ENV_VAR, player): + yield player diff --git a/tests/test_config.py b/tests/test_config.py index fee3507..1c867bb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,5 @@ from audius.config import Config +from audius.types import PlayerType class TestConfig: @@ -9,3 +10,7 @@ def test_app_name_form_env(self, app_name_from_env): def test_host_from_env(self, host_from_env): config = Config.from_env() assert config.host == host_from_env + + def test_player_from_env(self, player_from_env): + config = Config.from_env() + assert config.player == PlayerType(player_from_env) diff --git a/tests/test_sdk.py b/tests/test_sdk.py index a81cc8f..5ca16b9 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -1,4 +1,5 @@ from audius.sdk import Audius +from audius.types import PlayerType class TestAudius: @@ -9,3 +10,7 @@ def test_app_name_form_env(self, app_name_from_env): def test_host_from_env(self, host_from_env): sdk = Audius() assert sdk.config.host == host_from_env + + def test_player_from_env(self, player_from_env): + sdk = Audius() + assert sdk.config.player == PlayerType(player_from_env)