diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46e5daa..3e4c468 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,11 @@ name: CI on: push: + branches: + - main pull_request: + branches: + - main jobs: test: @@ -16,10 +20,10 @@ jobs: enable-cache: true - name: Set up Python - run: uv python install 3.14 + run: uv python install 3.13 - name: Install dependencies - run: uv sync --group dev + run: uv sync --frozen --python 3.13 --group dev - name: Run tests - run: uv run pytest + run: uv run --frozen --python 3.13 pytest diff --git a/README.md b/README.md index 7db8c76..b54f259 100644 --- a/README.md +++ b/README.md @@ -332,80 +332,193 @@ uv tool install --from . "spotifyify[cli]" spotifyify --help ``` +Or run the checkout directly without installing a global command: + +```bash +uv sync --extra cli +uv run spotifyify --help +``` + +### Output contract + +Every command writes JSON to stdout and nothing else, regardless of whether +stdout is a terminal — so piping through `tee` or capturing the output cannot +change its shape. + +| | | +| --- | --- | +| Format | Always JSON | +| Shape | A JSON array of row objects whose keys are the command's declared columns, in a fixed order | +| Encoding | UTF-8, no ANSI escapes, no pager, no prompts | +| Errors | Plain text on stderr | +| Exit codes | `0` ok, `1` API error, `2` usage error, `3` auth error, `4` no match | + +```bash +spotifyify tracks search "Ikkimel" --limit 2 +``` + +```json +[ + { + "id": "4H0ly29pj5g6vMKum5kkhu", + "name": "WHO'S THAT", + "artists": ["Ikkimel"], + "album.name": "WHO'S THAT", + "uri": "spotify:track:4H0ly29pj5g6vMKum5kkhu" + } +] +``` + +Set `SPOTIFYIFY_RAW=1` to get the untouched Spotify payload instead, for +debugging paging metadata or a field that is not a declared column. + +```bash +SPOTIFYIFY_RAW=1 spotifyify tracks search "Daft Punk" --limit 1 +``` + +PowerShell: + +```powershell +$env:SPOTIFYIFY_RAW = "1" +spotifyify tracks search "Daft Punk" --limit 1 +Remove-Item Env:SPOTIFYIFY_RAW +``` + +### Command discovery + +Use the standard `--help` option at the root, group, or command level: + +```bash +spotifyify --help +spotifyify artists --help +spotifyify artists get --help +``` + +The short form `-h` works at every level as well. + +Resource groups use plural names consistently (`artists`, `tracks`, `albums`), +and each command has one canonical spelling. + ### Everyday usage The CLI mirrors the public namespace API from `spotifyify.namespaces`: ```bash spotifyify tracks search "Daft Punk" --limit 5 -spotifyify albums get 4aawyAB9vmqN3uQ7FjRGTy --json -spotifyify playlists list --scope playlist-read-private +spotifyify albums get 4aawyAB9vmqN3uQ7FjRGTy +spotifyify playlists list +spotifyify player state ``` -Search and read commands print compact tables by default. Mutating commands -print `OK` or a Spotify snapshot ID. Use `--json` when scripts or agents need -machine-readable output: +To find something and play it without a separate lookup: ```bash -spotifyify playlists add PLAYLIST_ID spotify:track:TRACK_ID --json -spotifyify library check-tracks TRACK_ID_1,TRACK_ID_2 --json -spotifyify users check-following artist ARTIST_ID --json +spotifyify play --artist Ikkimel --track "WHO'S THAT" ``` -### Filtering output +A track name (or free text) plays that one track; without one, `--album` plays +the album and `--artist` alone plays the artist. If Spotify reports no active +device, the CLI picks a controllable one and retries. -Use `--field`, `--fields`, or `-f` to keep only selected response fields. The -option can be repeated or passed as a comma-separated list: +### Mutations return the new state + +Commands that change something report the state they produced, so no follow-up +read is needed: ```bash -spotifyify tracks search "Daft Punk" --limit 3 --field id --field name --field uri -spotifyify tracks search "Daft Punk" --json --fields items.0.id,items.0.name -spotifyify player state --json --fields item.name,is_playing,progress_ms -spotifyify library saved-tracks --json --fields track.id,track.name,added_at +spotifyify player play --uri spotify:track:TRACK_ID ``` -IDs, URIs, scopes, and fields accept repeated values or comma-separated values: +```json +[{"state": "playing", "track": "HAMPELMANN", "artists": ["Ikkimel"], "device": "Wohnzimmer"}] +``` + +Playback commands briefly wait for Spotify to apply the change before reporting; +pass `--no-wait` to skip that and read immediately. Library and follow mutations +report the resulting saved/following state, and playlist mutations report the new +snapshot and length. + +### Filtering ```bash -spotifyify tracks get-many 4uLU6hMCjMI75M1A2tKUQC,0DiWol3AO6WpXZgp0goxAV +spotifyify tracks search "Daft Punk" --limit 3 --field id,name,uri +spotifyify playlists tracks PLAYLIST_ID --spotify-fields "items(track(id,name))" +``` + +| Option | Effect | +| --- | --- | +| `--field`, `-f` | Replace the declared columns with the given field paths | +| `--spotify-fields` | Server-side filter applied by Spotify before it sends the response | + +Rows otherwise keep the order Spotify returned them in. + +`--field` is a client-side output projection and can be repeated or receive a +comma-separated list. Nested values use dotted paths: + +```bash +spotifyify tracks get TRACK_ID --field id --field name --field album.name +``` + +### Batching + +Commands that take IDs or URIs are variadic and accept repeated or +comma-separated values. One call fans out to as many API requests as Spotify's +per-endpoint id limits require: + +```bash +spotifyify tracks get ID_1 ID_2 ID_3 +spotifyify albums get ID_1,ID_2 spotifyify playlists add PLAYLIST_ID spotify:track:ID_1 spotify:track:ID_2 -spotifyify playlists list --scope playlist-read-private,user-library-read +spotifyify player add-to-queue spotify:track:ID_1 spotify:track:ID_2 +spotifyify library save-tracks ID_1,ID_2,ID_3 ``` ### Common options | Option | Description | | ------ | ----------- | -| `--json` | Print the raw Pydantic response payload as JSON instead of a compact table | | `--field`, `--fields`, `-f` | Include only selected field paths | -| `--scope`, `-s` | Request OAuth scopes | | `--limit`, `-l` | Number of items to fetch, capped at Spotify's per-endpoint limits | -| `--offset`, `-o` | Result offset for paginated endpoints | -| `--market`, `-m` | ISO 3166-1 alpha-2 market code | -| `--device-id` | Target Spotify Connect device for playback commands | +| `--wait` / `--no-wait` | Whether playback mutations wait for the change to take effect | + +Each command already requests the OAuth scopes it needs — there is no way to +override that per call. When a command needs user authorization and no token +is configured yet, the CLI uses the same interactive Authorization Code login +and token cache as the Python client. + +### Global options -Most user-scoped commands set the matching default scope automatically. Override -or extend scopes with `--scope` when you need a different authorization grant. -When a command needs user authorization and no token is configured yet, the CLI -uses the same interactive Authorization Code login and token cache as the Python -client. +`--market` and `--device-id` apply to the whole invocation, so they go before +the group name rather than on the individual command: + +```bash +spotifyify --market DE tracks search "Daft Punk" +spotifyify --device-id kitchen player play --uri spotify:track:TRACK_ID +``` + +| Option | Description | Env var fallback | +| ------ | ----------- | ----------------- | +| `--market`, `-m` | ISO 3166-1 alpha-2 market code | `SPOTIFYIFY_MARKET` | +| `--device-id` | Target Spotify Connect device for playback commands | `SPOTIFYIFY_DEVICE_ID` | + +A flag always wins over its env var. Neither is required — omit both and +Spotify falls back to its own default market and active device. ### Command overview | Namespace | Commands | | --------- | -------- | -| `tracks` | `search`, `get`, `get-many` | -| `artists` | `search`, `get`, `get-many`, `top-tracks`, `albums`, `related` | -| `albums` | `search`, `get`, `get-many`, `tracks`, `new-releases` | +| *(top level)* | `play` | +| `tracks` | `search`, `get` | +| `artists` | `search`, `get`, `top-tracks`, `albums`, `related` | +| `albums` | `search`, `get`, `tracks`, `new-releases` | | `playlists` | `search`, `get`, `list`, `tracks`, `create`, `update`, `add`, `replace`, `remove`, `reorder`, `cover-image` | -| `shows` | `search`, `get`, `get-many`, `episodes` | -| `episodes` | `search`, `get`, `get-many` | +| `shows` | `search`, `get`, `episodes` | +| `episodes` | `search`, `get` | | `library` | `saved-tracks`, `saved-albums`, `saved-shows`, `saved-episodes`, `top-tracks`, `top-artists`, `save-*`, `remove-*`, `check-*` for tracks/albums/shows/episodes | | `player` | `state`, `play`, `pause`, `skip`, `previous`, `seek`, `repeat`, `shuffle`, `volume`, `queue`, `add-to-queue`, `transfer`, `devices`, `recently-played` | | `users` | `me`, `get`, `following`, `follow`, `unfollow`, `check-following` | -Use Typer's built-in help to inspect exact arguments and options: - ```bash spotifyify --help spotifyify playlists create --help @@ -414,9 +527,11 @@ spotifyify player play --help ## Examples -See the [`examples/`](./examples) directory for runnable scripts: +See the [`examples/`](./examples) directory for CLI recipes and runnable Python +scripts: -- [`examples/search_and_play.py`](./examples/search_and_play.py) — search for tracks and control playback -- [`examples/manage_playlist.py`](./examples/manage_playlist.py) — create and manage a playlist +- [`examples/cli/README.md`](./examples/cli/README.md) — copy-paste CLI workflows for search, playback, playlists, library, batching, and JSON output +- [`examples/player/search_and_play.py`](./examples/player/search_and_play.py) — search for tracks and control playback with the Python API +- [`examples/playlists/manage_playlist.py`](./examples/playlists/manage_playlist.py) — create and manage a playlist with the Python API - [`examples/playlists/user_token_playlist.py`](./examples/playlists/user_token_playlist.py) — create playlists with caller-supplied user tokens -- [`examples/library_stats.py`](./examples/library_stats.py) — explore your top tracks and saved library +- [`examples/library/library_stats.py`](./examples/library/library_stats.py) — explore your top tracks and saved library diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b5ef8ba --- /dev/null +++ b/examples/README.md @@ -0,0 +1,44 @@ +# Examples + +The examples are split into two styles: + +- [`cli/`](./cli) contains copy-paste command-line workflows for the optional + `spotifyify` CLI. +- The namespace directories contain runnable Python examples for the async API. + +## CLI + +Start with the [CLI recipes](./cli/README.md). They cover command discovery, +search, playback, playlists, library operations, batching, field selection, and +raw Spotify responses. + +From a development checkout, run each command as `uv run spotifyify ...`. After +a global `uv tool install --from . "spotifyify[cli]"`, use `spotifyify ...` +directly. + +## Python API + +Run a Python example from the repository root: + +```bash +uv run python examples/tracks/search_tracks.py +uv run python examples/player/search_and_play.py +uv run python examples/playlists/manage_playlist.py +``` + +Examples that access playback, private playlists, or the user's library start +the interactive Authorization Code flow when no suitable user token is already +configured. + +| Area | Examples | +| --- | --- | +| Tracks | [`tracks/search_tracks.py`](./tracks/search_tracks.py) | +| Artists | [`artists/explore_artist.py`](./artists/explore_artist.py) | +| Albums | [`albums/browse_album.py`](./albums/browse_album.py) | +| Playlists | [`playlists/list_playlists.py`](./playlists/list_playlists.py), [`playlists/manage_playlist.py`](./playlists/manage_playlist.py), [`playlists/user_token_playlist.py`](./playlists/user_token_playlist.py) | +| Playback | [`player/playback_status.py`](./player/playback_status.py), [`player/search_and_play.py`](./player/search_and_play.py) | +| Library | [`library/library_overview.py`](./library/library_overview.py), [`library/library_stats.py`](./library/library_stats.py) | +| Shows and episodes | [`shows/browse_show.py`](./shows/browse_show.py), [`episodes/search_episodes.py`](./episodes/search_episodes.py) | +| Users | [`users/profile.py`](./users/profile.py) | +| Retries | [`retries.py`](./retries.py) | +| MCP | [`mcp/mcp_server.py`](./mcp/mcp_server.py) | diff --git a/examples/cli/README.md b/examples/cli/README.md new file mode 100644 index 0000000..c3b7e77 --- /dev/null +++ b/examples/cli/README.md @@ -0,0 +1,196 @@ +# CLI examples + +These recipes use the current plural resource groups and canonical command +names. Run `spotifyify ...` after installing the CLI globally, or prefix every +command with `uv run` when working from this checkout: + +```bash +uv sync --extra cli +uv run spotifyify --help +``` + +The same Spotify credentials described in the project +[README](../../README.md#configuration) apply here. Commands that require a +user scope open the interactive login flow when necessary. + +## Discover commands + +Help is available at the root, namespace, and individual command levels: + +```bash +spotifyify -h +spotifyify playlists -h +spotifyify playlists create -h +``` + +## Search and fetch + +Search results and fetched resources are JSON arrays with stable, declared +columns: + +```bash +spotifyify tracks search "Daft Punk" --limit 3 +spotifyify artists search "Radiohead" --limit 3 +spotifyify albums get 4aawyAB9vmqN3uQ7FjRGTy +spotifyify shows search "Lex Fridman" --limit 3 +``` + +`get` accepts multiple IDs, either as separate arguments or comma-separated: + +```bash +spotifyify tracks get TRACK_ID_1 TRACK_ID_2 TRACK_ID_3 +spotifyify albums get ALBUM_ID_1,ALBUM_ID_2 +``` + +The CLI splits large ID lists into the request sizes supported by Spotify and +combines the results into one JSON array. + +## Select output fields + +Use `--field`/`-f` to replace a command's default columns. It can be repeated, +and comma-separated field paths are accepted. Nested fields use dot notation: + +```bash +spotifyify tracks search "Daft Punk" -f id,name,uri +spotifyify tracks get TRACK_ID -f id -f name -f album.name +spotifyify playlists tracks PLAYLIST_ID -f track.id,track.name,added_at +``` + +`playlists tracks` additionally supports Spotify's server-side field filter: + +```bash +spotifyify playlists tracks PLAYLIST_ID \ + --spotify-fields "items(track(id,name)),next,total" +``` + +`--spotify-fields` controls what Spotify sends. `--field` controls the final +columns printed by spotifyify. + +## Markets and devices + +`--market` and `--device-id` are root options, so place them before the resource +group: + +```bash +spotifyify --market DE tracks search "Daft Punk" +spotifyify player devices +spotifyify --device-id DEVICE_ID player state +``` + +They can also be configured with `SPOTIFYIFY_MARKET` and +`SPOTIFYIFY_DEVICE_ID`. An explicit option takes precedence over the +environment. + +## Find and play in one command + +The top-level `play` command resolves the first matching result and starts it: + +```bash +spotifyify play --artist Ikkimel --track "WHO'S THAT" +spotifyify play --artist "Daft Punk" --album "Random Access Memories" +spotifyify play --artist "Daft Punk" +spotifyify play Get Lucky +``` + +A track name or free text plays one track. With no track, an album filter plays +the album context; with only an artist, it plays the artist context. If no +active Spotify device exists, the command selects a controllable device and +retries. + +Playback mutations return the resulting state: + +```bash +spotifyify player play --uri spotify:track:TRACK_ID +spotifyify player pause +spotifyify player skip +spotifyify player seek 60000 +spotifyify player volume 35 +spotifyify player add-to-queue spotify:track:TRACK_ID_1 spotify:track:TRACK_ID_2 +``` + +They briefly wait for Spotify to reflect the change. Add `--no-wait` to a +playback command when an immediate read is preferable: + +```bash +spotifyify player skip --no-wait +``` + +## Manage a playlist + +Create a private playlist, then use the returned `id` in subsequent commands: + +```bash +spotifyify playlists create "My Boards of Canada Mix" \ + --private \ + --description "Created with spotifyify" + +spotifyify playlists add PLAYLIST_ID \ + spotify:track:TRACK_ID_1 \ + spotify:track:TRACK_ID_2 + +spotifyify playlists tracks PLAYLIST_ID +spotifyify playlists remove PLAYLIST_ID spotify:track:TRACK_ID_1 +``` + +Playlist mutations report the new snapshot and total item count: + +```json +[ + { + "playlist_id": "PLAYLIST_ID", + "snapshot_id": "NEW_SNAPSHOT_ID", + "total": 2 + } +] +``` + +## Library and following + +Commands that take IDs support the same repeated and comma-separated forms: + +```bash +spotifyify library save-tracks TRACK_ID_1,TRACK_ID_2 +spotifyify library check-tracks TRACK_ID_1 TRACK_ID_2 +spotifyify library remove-tracks TRACK_ID_1 TRACK_ID_2 + +spotifyify users follow artist ARTIST_ID_1 ARTIST_ID_2 +spotifyify users check-following artist ARTIST_ID_1,ARTIST_ID_2 +spotifyify users unfollow artist ARTIST_ID_1 ARTIST_ID_2 +``` + +Write commands read the state back and return one row per requested ID, for +example: + +```json +[ + {"id": "TRACK_ID_1", "saved": true}, + {"id": "TRACK_ID_2", "saved": true} +] +``` + +## Raw Spotify responses + +The default output deliberately contains only useful, stable columns. Set +`SPOTIFYIFY_RAW=1` to inspect the untouched Spotify payload, including paging +metadata and undeclared fields. + +Bash: + +```bash +SPOTIFYIFY_RAW=1 spotifyify tracks search "Daft Punk" --limit 1 +``` + +PowerShell: + +```powershell +$env:SPOTIFYIFY_RAW = "1" +spotifyify tracks search "Daft Punk" --limit 1 +Remove-Item Env:SPOTIFYIFY_RAW +``` + +Every successful normal command writes JSON to stdout, so its output can be +redirected or consumed by another program without changing shape: + +```bash +spotifyify tracks search "Daft Punk" --limit 5 > tracks.json +``` diff --git a/spotifyify/cli/__init__.py b/spotifyify/cli/__init__.py index 3ae6e76..6dc7309 100644 --- a/spotifyify/cli/__init__.py +++ b/spotifyify/cli/__init__.py @@ -1,34 +1,56 @@ -from __future__ import annotations +import sys +from typing import Annotated try: import typer except ImportError: # pragma: no cover - exercised by installed package users. typer = None -from ._core import ( +from .core import ( INSTALL_MESSAGE, - _filter_fields, - _get_path, - _parse_scopes, - _split_values, - _table, + apply_sort, + cell, + filter_fields, + get_path, + playback_summary, + rows, + set_default_device_id, + set_default_market, + sort_items, + split_values, ) __all__ = [ "INSTALL_MESSAGE", "main", "typer", - "_filter_fields", - "_get_path", - "_parse_scopes", - "_split_values", - "_table", + "apply_sort", + "cell", + "filter_fields", + "get_path", + "playback_summary", + "rows", + "sort_items", + "split_values", ] +def _force_utf8() -> None: + """Emit UTF-8 whatever the console codepage is. + + On Windows stdout otherwise defaults to the ANSI codepage, so a captured or + redirected result would not be valid UTF-8. + """ + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is not None: + reconfigure(encoding="utf-8", errors="replace") + + def main() -> None: if typer is None: raise SystemExit(INSTALL_MESSAGE) + _force_utf8() app() @@ -40,12 +62,25 @@ def main() -> None: library, player, playlists, + quick, shows, tracks, users, ) - app = typer.Typer(help="Command line tools for the spotifyify Spotify client.") + __all__ += ["app"] + + app = typer.Typer( + help="Command line tools for the spotifyify Spotify client.", + # Plain click help: no ANSI, no boxes, no pager. + rich_markup_mode=None, + # Completion installers are Typer's only interactive code path. + add_completion=False, + # Rich renders tracebacks as ANSI boxes; keep failures plain text. + pretty_exceptions_enable=False, + no_args_is_help=True, + context_settings={"help_option_names": ["-h", "--help"], "color": False}, + ) app.add_typer(tracks.app, name="tracks") app.add_typer(artists.app, name="artists") app.add_typer(albums.app, name="albums") @@ -55,6 +90,30 @@ def main() -> None: app.add_typer(library.app, name="library") app.add_typer(player.app, name="player") app.add_typer(users.app, name="users") + quick.register(app) + + @app.callback(invoke_without_command=True) + def _root( + market: Annotated[ + str | None, + typer.Option( + "--market", + "-m", + help="Default ISO 3166-1 alpha-2 market code for every command " + "in this invocation. Falls back to SPOTIFYIFY_MARKET.", + ), + ] = None, + device_id: Annotated[ + str | None, + typer.Option( + "--device-id", + help="Default Spotify Connect device for every playback command " + "in this invocation. Falls back to SPOTIFYIFY_DEVICE_ID.", + ), + ] = None, + ) -> None: + set_default_market(market) + set_default_device_id(device_id) if __name__ == "__main__": diff --git a/spotifyify/cli/_core.py b/spotifyify/cli/_core.py deleted file mode 100644 index 58f3395..0000000 --- a/spotifyify/cli/_core.py +++ /dev/null @@ -1,186 +0,0 @@ -from __future__ import annotations - -import json -from collections.abc import Awaitable, Callable, Mapping, Sequence -from typing import Any - -from pydantic import BaseModel - -from spotifyify import Spotifyify, SpotifyScope - -try: - import typer -except ImportError: # pragma: no cover - exercised by installed package users. - typer = None - - -Jsonable = BaseModel | list[Any] | dict[str, Any] | str | int | float | bool | None -AsyncCommand = Callable[[Spotifyify], Awaitable[Jsonable]] - -DEFAULT_LIMIT = 10 -INSTALL_MESSAGE = "Install the CLI dependencies with: uv add spotifyify[cli]" - - -def _as_jsonable(value: Jsonable) -> Any: - if isinstance(value, BaseModel): - return value.model_dump(mode="json", exclude_none=True) - if isinstance(value, list): - return [_as_jsonable(item) for item in value] - if isinstance(value, dict): - return {key: _as_jsonable(item) for key, item in value.items()} - return value - - -def _split_values(values: Sequence[str] | None) -> list[str]: - if not values: - return [] - items: list[str] = [] - for raw_value in values: - items.extend(value for value in raw_value.replace(",", " ").split() if value) - return items - - -def _parse_scopes(scope_values: Sequence[str]) -> list[SpotifyScope | str]: - scopes: list[SpotifyScope | str] = [] - for value in _split_values(scope_values): - try: - scopes.append(SpotifyScope(value)) - except ValueError: - scopes.append(value) - return scopes - - -def _coalesce_scopes(scope_values: Sequence[str] | None) -> Sequence[str]: - return scope_values or () - - -def _parse_json_object( - raw_value: str | None, option_name: str -) -> dict[str, Any] | None: - if not raw_value: - return None - try: - value = json.loads(raw_value) - except json.JSONDecodeError as exc: - message = f"{option_name} must be a JSON object: {exc.msg}" - if typer is None: - raise ValueError(message) from exc - raise typer.BadParameter(message) from exc - if not isinstance(value, dict): - message = f"{option_name} must be a JSON object" - if typer is None: - raise ValueError(message) - raise typer.BadParameter(message) - return value - - -def _get_path(value: Any, path: str) -> Any: - current = value - for part in path.split("."): - if isinstance(current, Mapping): - current = current.get(part) - elif isinstance(current, Sequence) and not isinstance(current, str): - try: - current = current[int(part)] - except (ValueError, IndexError): - return None - else: - current = getattr(current, part, None) - if current is None: - return None - return current - - -def _filter_fields(value: Any, fields: Sequence[str]) -> Any: - if not fields: - return value - if isinstance(value, list): - return [_filter_fields(item, fields) for item in value] - return {field: _get_path(value, field) for field in fields} - - -def _format_value(value: Any) -> str: - value = _as_jsonable(value) - if value is None: - return "" - if isinstance(value, list): - return ", ".join(_format_value(item) for item in value) - if isinstance(value, dict): - if "name" in value: - return str(value["name"]) - if "id" in value: - return str(value["id"]) - return json.dumps(value, ensure_ascii=False, separators=(",", ":")) - return str(value) - - -def _items(value: Any) -> list[Any]: - value = _as_jsonable(value) - if isinstance(value, dict) and isinstance(value.get("items"), list): - return value["items"] - if isinstance(value, dict) and isinstance(value.get("queue"), list): - return value["queue"] - if isinstance(value, list): - return value - if value is None: - return [] - return [value] - - -def _table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str: - if not rows: - return "No results." - - widths = [ - max(len(header), *(len(row[index]) for row in rows)) - for index, header in enumerate(headers) - ] - rendered = [ - " ".join(header.ljust(widths[index]) for index, header in enumerate(headers)) - ] - rendered.append(" ".join("-" * width for width in widths)) - rendered.extend( - " ".join(value.ljust(widths[index]) for index, value in enumerate(row)) - for row in rows - ) - return "\n".join(rendered) - - -def _print_json(value: Jsonable, *, fields: Sequence[str] = ()) -> None: - payload = _filter_fields(_as_jsonable(value), fields) - text = json.dumps(payload, indent=2, ensure_ascii=False) - if typer is None: - print(text) - return - typer.echo(text) - - -def _print_table( - value: Jsonable, - columns: Sequence[str], - *, - fields: Sequence[str] = (), -) -> None: - selected_columns = fields or columns - rows = [ - [_format_value(_get_path(item, column)) for column in selected_columns] - for item in _items(value) - ] - headers = [column.replace(".", " ").title() for column in selected_columns] - text = _table(headers, rows) - if typer is None: - print(text) - return - typer.echo(text) - - -def _print_success(value: str | None = None) -> None: - if typer is None: - print(value or "OK") - return - typer.echo(value or "OK") - - -async def _run(command: AsyncCommand, *, scopes: Sequence[str]) -> Jsonable: - async with Spotifyify(scopes=_parse_scopes(scopes)) as spotify: - return await command(spotify) diff --git a/spotifyify/cli/_options.py b/spotifyify/cli/_options.py deleted file mode 100644 index 85062d4..0000000 --- a/spotifyify/cli/_options.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Sequence -from typing import Annotated - -import typer - -from spotifyify import SpotifyAPIError, SpotifyAuthError - -from ._core import ( - AsyncCommand, - Jsonable, - _print_json, - _print_table, - _run, - _split_values, -) - -DEFAULT_LIMIT = 10 - -ScopeOption = Annotated[ - list[str] | None, - typer.Option( - "--scope", - "-s", - help="OAuth scope. Can be repeated or comma-separated.", - ), -] -JsonOption = Annotated[ - bool, - typer.Option("--json", help="Print JSON instead of a compact table."), -] -FieldsOption = Annotated[ - list[str] | None, - typer.Option( - "--field", - "--fields", - "-f", - help="Field path to include. Can be repeated or comma-separated.", - ), -] -LimitOption = Annotated[ - int, - typer.Option("--limit", "-l", min=1, max=50, help="Number of items to fetch."), -] -OffsetOption = Annotated[ - int, - typer.Option("--offset", "-o", min=0, help="Result offset."), -] -MarketOption = Annotated[ - str | None, - typer.Option("--market", "-m", help="ISO 3166-1 alpha-2 market code."), -] -DeviceOption = Annotated[ - str | None, - typer.Option("--device-id", help="Spotify device ID."), -] -IdsArgument = Annotated[ - list[str], - typer.Argument(help="One or more IDs. Values can also be comma-separated."), -] -UrisArgument = Annotated[ - list[str], - typer.Argument(help="One or more Spotify URIs. Values can be comma-separated."), -] - - -def _handle(command: AsyncCommand, *, scopes: Sequence[str]) -> Jsonable: - try: - return asyncio.run(_run(command, scopes=scopes)) - except (SpotifyAPIError, SpotifyAuthError) as exc: - typer.echo(str(exc), err=True) - raise typer.Exit(1) from exc - - -def _render( - result: Jsonable, - *, - json_output: bool, - fields: Sequence[str] | None, - columns: Sequence[str], -) -> None: - selected_fields = _split_values(fields) - if json_output: - _print_json(result, fields=selected_fields) - return - _print_table(result, columns, fields=selected_fields) diff --git a/spotifyify/cli/albums.py b/spotifyify/cli/albums.py index 24f9640..0befc9d 100644 --- a/spotifyify/cli/albums.py +++ b/spotifyify/cli/albums.py @@ -1,134 +1,99 @@ -from __future__ import annotations - from typing import Annotated import typer -from ._core import _coalesce_scopes, _split_values -from ._options import ( +from spotifyify.cli.core import ( + BATCH_ALBUMS, + default_market, + gather_batches, + split_values, + spotify_client, +) +from spotifyify.cli.options import ( DEFAULT_LIMIT, FieldsOption, IdsArgument, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - ScopeOption, - _handle, - _render, + async_command, + print_result, ) -app = typer.Typer(help="Work with Spotify albums.") +app = typer.Typer( + help="Work with Spotify albums.", + rich_markup_mode=None, + no_args_is_help=True, +) + +COLUMNS = ("id", "name", "artists", "uri") @app.command("search") -def search_albums( +@async_command +async def search_albums( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, - offset: OffsetOption = 0, - market: MarketOption = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.albums.find( - query, limit=limit, offset=offset, market=market - ), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client() as spotify: + result = await spotify.albums.find(query, limit=limit, market=default_market()) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "artists", "uri"), ) @app.command("get") -def get_album( - album_id: Annotated[str, typer.Argument(help="Spotify album ID.")], - market: MarketOption = None, - json_output: JsonOption = False, - fields: FieldsOption = None, - scope: ScopeOption = None, -) -> None: - result = _handle( - lambda spotify: spotify.albums.get(album_id, market=market), - scopes=_coalesce_scopes(scope), - ) - _render( - result, - json_output=json_output, - fields=fields, - columns=("id", "name", "artists", "uri"), - ) - - -@app.command("get-many") -def get_many_albums( +@async_command +async def get_albums( album_ids: IdsArgument, - market: MarketOption = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.albums.get_many( - _split_values(album_ids), market=market - ), - scopes=_coalesce_scopes(scope), - ) - _render( + """Fetch one or many albums in a single call.""" + ids = split_values(album_ids) + market = default_market() + async with spotify_client() as spotify: + result = await gather_batches( + lambda chunk: spotify.albums.get_many(chunk, market=market), + ids, + BATCH_ALBUMS, + ) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "artists", "uri"), ) @app.command("tracks") -def album_tracks( +@async_command +async def album_tracks( album_id: Annotated[str, typer.Argument(help="Spotify album ID.")], limit: LimitOption = 50, - offset: OffsetOption = 0, - market: MarketOption = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.albums.tracks( - album_id, limit=limit, offset=offset, market=market - ), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client() as spotify: + result = await spotify.albums.tracks( + album_id, limit=limit, market=default_market() + ) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "artists", "uri"), ) @app.command("new-releases") -def new_releases( +@async_command +async def new_releases( country: Annotated[str | None, typer.Option("--country", "-c")] = None, limit: LimitOption = 20, - offset: OffsetOption = 0, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.albums.new_releases( - country=country, limit=limit, offset=offset - ), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client() as spotify: + result = await spotify.albums.new_releases(country=country, limit=limit) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "artists", "uri"), ) diff --git a/spotifyify/cli/artists.py b/spotifyify/cli/artists.py index c1b5ae7..ecbd943 100644 --- a/spotifyify/cli/artists.py +++ b/spotifyify/cli/artists.py @@ -1,151 +1,119 @@ -from __future__ import annotations - from typing import Annotated import typer -from ._core import _coalesce_scopes, _split_values -from ._options import ( +from spotifyify.cli.core import ( + BATCH_ARTISTS, + default_market, + gather_batches, + split_values, + spotify_client, +) +from spotifyify.cli.options import ( DEFAULT_LIMIT, FieldsOption, IdsArgument, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - ScopeOption, - _handle, - _render, + async_command, + print_result, ) -app = typer.Typer(help="Work with Spotify artists.") +app = typer.Typer( + help="Work with Spotify artists.", + rich_markup_mode=None, + no_args_is_help=True, +) + +COLUMNS = ("id", "name", "uri") +TRACK_COLUMNS = ("id", "name", "artists", "uri") +ALBUM_COLUMNS = ("id", "name", "album_type", "uri") @app.command("search") -def search_artists( +@async_command +async def search_artists( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, - offset: OffsetOption = 0, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.artists.find(query, limit=limit, offset=offset), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client() as spotify: + result = await spotify.artists.find(query, limit=limit) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "uri"), ) @app.command("get") -def get_artist( - artist_id: Annotated[str, typer.Argument(help="Spotify artist ID.")], - json_output: JsonOption = False, - fields: FieldsOption = None, - scope: ScopeOption = None, -) -> None: - result = _handle( - lambda spotify: spotify.artists.get(artist_id), - scopes=_coalesce_scopes(scope), - ) - _render( - result, - json_output=json_output, - fields=fields, - columns=("id", "name", "uri"), - ) - - -@app.command("get-many") -def get_many_artists( +@async_command +async def get_artists( artist_ids: IdsArgument, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.artists.get_many(_split_values(artist_ids)), - scopes=_coalesce_scopes(scope), - ) - _render( + """Fetch one or many artists in a single call.""" + ids = split_values(artist_ids) + async with spotify_client() as spotify: + result = await gather_batches(spotify.artists.get_many, ids, BATCH_ARTISTS) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "uri"), ) @app.command("top-tracks") -def artist_top_tracks( +@async_command +async def artist_top_tracks( artist_id: Annotated[str, typer.Argument(help="Spotify artist ID.")], - market: Annotated[str, typer.Option("--market", "-m")] = "US", - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.artists.top_tracks(artist_id, market=market), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client() as spotify: + result = await spotify.artists.top_tracks( + artist_id, market=default_market() or "US" + ) + print_result( result, - json_output=json_output, + columns=TRACK_COLUMNS, fields=fields, - columns=("id", "name", "artists", "uri"), ) @app.command("albums") -def artist_albums( +@async_command +async def artist_albums( artist_id: Annotated[str, typer.Argument(help="Spotify artist ID.")], include_groups: Annotated[ str | None, typer.Option("--include-groups", help="album,single,appears_on,compilation"), ] = None, - market: MarketOption = None, limit: LimitOption = 20, - offset: OffsetOption = 0, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.artists.albums( + async with spotify_client() as spotify: + result = await spotify.artists.albums( artist_id, include_groups=include_groups, - market=market, + market=default_market(), limit=limit, - offset=offset, - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, - json_output=json_output, + columns=ALBUM_COLUMNS, fields=fields, - columns=("id", "name", "album_type", "uri"), ) @app.command("related") -def related_artists( +@async_command +async def related_artists( artist_id: Annotated[str, typer.Argument(help="Spotify artist ID.")], - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.artists.related(artist_id), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client() as spotify: + result = await spotify.artists.related(artist_id) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "uri"), ) diff --git a/spotifyify/cli/core.py b/spotifyify/cli/core.py new file mode 100644 index 0000000..599f231 --- /dev/null +++ b/spotifyify/cli/core.py @@ -0,0 +1,397 @@ +import asyncio +import json +import os +import re +from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence +from contextlib import asynccontextmanager +from typing import Any + +from pydantic import BaseModel + +from spotifyify import Spotifyify, SpotifyScope +from spotifyify.schemas import PlaybackState + +try: + import typer +except ImportError: # pragma: no cover - exercised by installed package users. + typer = None + + +Jsonable = BaseModel | list[Any] | dict[str, Any] | str | int | float | bool | None +DEFAULT_LIMIT = 10 +INSTALL_MESSAGE = "Install the CLI dependencies with: uv add spotifyify[cli]" + +RAW_ENV_VAR = "SPOTIFYIFY_RAW" +MARKET_ENV_VAR = "SPOTIFYIFY_MARKET" +DEVICE_ENV_VAR = "SPOTIFYIFY_DEVICE_ID" + +_market_override: str | None = None +_device_override: str | None = None + +# Table cells are single-line by construction, so anything that could forge a +# row boundary or smuggle escape sequences is folded into a single space. +_CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]") + + +def as_jsonable(value: Jsonable) -> Any: + if isinstance(value, BaseModel): + return value.model_dump(mode="json", exclude_none=True) + if isinstance(value, list): + return [as_jsonable(item) for item in value] + if isinstance(value, dict): + return {key: as_jsonable(item) for key, item in value.items()} + return value + + +def split_values(values: Sequence[str] | None) -> list[str]: + if not values: + return [] + items: list[str] = [] + for raw_value in values: + items.extend(value for value in raw_value.replace(",", " ").split() if value) + return items + + +def merge_scopes(*scope_groups: Sequence[SpotifyScope]) -> list[SpotifyScope]: + merged: list[SpotifyScope] = [] + for group in scope_groups: + merged.extend(scope for scope in group if scope not in merged) + return merged + + +def parse_json_object(raw_value: str | None, option_name: str) -> dict[str, Any] | None: + if not raw_value: + return None + try: + value = json.loads(raw_value) + except json.JSONDecodeError as exc: + message = f"{option_name} must be a JSON object: {exc.msg}" + if typer is None: + raise ValueError(message) from exc + raise typer.BadParameter(message) from exc + if not isinstance(value, dict): + message = f"{option_name} must be a JSON object" + if typer is None: + raise ValueError(message) + raise typer.BadParameter(message) + return value + + +def get_path(value: Any, path: str) -> Any: + current = value + for part in path.split("."): + if isinstance(current, Mapping): + current = current.get(part) + elif isinstance(current, Sequence) and not isinstance(current, str): + try: + current = current[int(part)] + except (ValueError, IndexError): + return None + else: + current = getattr(current, part, None) + if current is None: + return None + return current + + +def filter_fields(value: Any, fields: Sequence[str]) -> Any: + if not fields: + return value + if isinstance(value, list): + return [filter_fields(item, fields) for item in value] + return {field: get_path(value, field) for field in fields} + + +def _format_value(value: Any) -> str: + value = as_jsonable(value) + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, list): + return ", ".join(_format_value(item) for item in value) + if isinstance(value, dict): + if "name" in value: + return str(value["name"]) + if "id" in value: + return str(value["id"]) + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + return str(value) + + +def cell(value: Any) -> str: + """Render one field as a single-line, escape-free table cell.""" + return _CONTROL_CHARACTERS.sub(" ", _format_value(value)) + + +def _project(value: Any) -> Any: + """Reduce a field to its useful part, keeping JSON types intact. + + Nested Spotify objects collapse to their name, so a column reads + `"artists": ["Ikkimel"]` rather than two screens of artist objects, while + numbers and booleans stay numbers and booleans. + """ + value = as_jsonable(value) + if isinstance(value, list): + return [_project(item) for item in value] + if isinstance(value, dict): + for key in ("name", "id"): + if key in value: + return value[key] + return value + + +def rows(value: Any, columns: Sequence[str]) -> list[dict[str, Any]]: + """The command's declared columns, in order, for every item in the result.""" + return [ + {column: _project(get_path(item, column)) for column in columns} + for item in _items(value) + ] + + +def _items(value: Any) -> list[Any]: + value = as_jsonable(value) + if isinstance(value, dict) and isinstance(value.get("items"), list): + return value["items"] + if isinstance(value, dict) and isinstance(value.get("queue"), list): + return value["queue"] + if isinstance(value, list): + return value + if value is None: + return [] + return [value] + + +def _sort_key(value: Any) -> tuple[int, float, str]: + """Total order across mixed types so sorting never raises and never varies.""" + value = as_jsonable(value) + if value is None: + return (2, 0.0, "") + if isinstance(value, bool): + return (0, float(value), "") + if isinstance(value, (int, float)): + return (0, float(value), "") + return (1, 0.0, cell(value).casefold()) + + +def sort_items(items: Sequence[Any], specs: Sequence[str]) -> list[Any]: + """Stable multi-key sort; ties keep the order Spotify returned them in.""" + ordered = list(items) + for spec in reversed(list(specs)): + descending = spec.startswith("-") + path = spec[1:] if descending else spec + if not path: + continue + ordered.sort( + key=lambda item: _sort_key(get_path(item, path)), reverse=descending + ) + return ordered + + +def _replace_collection( + payload: Any, transform: Callable[[list[Any]], list[Any]] +) -> Any: + """Apply a row transform to the item list, wherever the envelope keeps it.""" + if isinstance(payload, dict): + for key in ("items", "queue"): + if isinstance(payload.get(key), list): + return {**payload, key: transform(payload[key])} + return payload + if isinstance(payload, list): + return transform(payload) + return payload + + +def apply_sort(value: Any, specs: Sequence[str]) -> Any: + if not specs: + return value + return _replace_collection( + as_jsonable(value), lambda items: sort_items(items, specs) + ) + + +def set_default_market(value: str | None) -> None: + """Record the --market value from the root command, for this process only.""" + global _market_override + _market_override = value + + +def set_default_device_id(value: str | None) -> None: + """Record the --device-id value from the root command, for this process only.""" + global _device_override + _device_override = value + + +def default_market() -> str | None: + """Root --market flag, then the environment, then no market at all.""" + return _market_override or os.environ.get(MARKET_ENV_VAR) or None + + +def default_device_id() -> str | None: + """Root --device-id flag, then the environment, then no device at all.""" + return _device_override or os.environ.get(DEVICE_ENV_VAR) or None + + +def is_raw_output() -> bool: + return os.environ.get(RAW_ENV_VAR) == "1" + + +def _echo(text: str) -> None: + if typer is None: + print(text) + return + typer.echo(text) + + +def print_json(value: Any) -> None: + _echo(json.dumps(as_jsonable(value), indent=2, ensure_ascii=False)) + + +@asynccontextmanager +async def spotify_client( + scopes: Sequence[SpotifyScope] = (), +) -> AsyncGenerator[Spotifyify, None]: + """Open the async Spotify client used by one explicit CLI command.""" + async with Spotifyify(scopes=scopes) as spotify: + yield spotify + + +# --------------------------------------------------------------------------- # +# Batching — one CLI call fans out to as many API calls as Spotify's id limits +# require, instead of making the caller loop. +# --------------------------------------------------------------------------- # + +# Maximum ids Spotify accepts per request, per endpoint family. +BATCH_TRACKS = 50 +BATCH_ARTISTS = 50 +BATCH_ALBUMS = 20 +BATCH_SHOWS = 50 +BATCH_EPISODES = 50 +BATCH_FOLLOW = 50 + + +def _chunked(values: Sequence[str], size: int) -> list[list[str]]: + if not values: + return [] + return [list(values[index : index + size]) for index in range(0, len(values), size)] + + +async def gather_batches( + action: Callable[[list[str]], Awaitable[Sequence[Any]]], + ids: Sequence[str], + size: int, +) -> list[Any]: + """Fan out reads concurrently, then concatenate in request order.""" + chunks = _chunked(ids, size) + if not chunks: + return [] + results = await asyncio.gather(*(action(chunk) for chunk in chunks)) + return [item for result in results for item in result] + + +async def sequential_batches( + action: Callable[[list[str]], Awaitable[Any]], + ids: Sequence[str], + size: int, +) -> None: + """Apply writes chunk by chunk so partial failures stay comprehensible.""" + for chunk in _chunked(ids, size): + await action(chunk) + + +# --------------------------------------------------------------------------- # +# Playback summaries — mutations answer with the state they produced. +# --------------------------------------------------------------------------- # + +PLAYBACK_COLUMNS = ("state", "track", "artists", "device") +SETTLE_ATTEMPTS = 4 +SETTLE_DELAY_SECONDS = 0.25 + +Predicate = Callable[[PlaybackState | None], bool] + + +def playback_summary(state: Any) -> dict[str, Any]: + """A flat, fixed-key view of playback that mutations and reads both emit.""" + if state is None: + return { + "state": "stopped", + "track": "", + "artists": [], + "album": "", + "device": "", + "progress_ms": None, + "duration_ms": None, + "shuffle": None, + "repeat": "", + "uri": "", + } + payload = as_jsonable(state) + item = payload.get("item") or {} + artists = item.get("artists") or [] + show = item.get("show") or {} + album = item.get("album") or {} + return { + "state": "playing" if payload.get("is_playing") else "paused", + "track": item.get("name") or "", + # A list here too, matching how every other command reports artists. + "artists": [artist.get("name") or "" for artist in artists] + or ([show["publisher"]] if show.get("publisher") else []), + "album": album.get("name") or show.get("name") or "", + "device": (payload.get("device") or {}).get("name") or "", + "progress_ms": payload.get("progress_ms"), + "duration_ms": item.get("duration_ms"), + "shuffle": payload.get("shuffle_state"), + "repeat": payload.get("repeat_state") or "", + "uri": item.get("uri") or "", + } + + +def is_playing(state: PlaybackState | None) -> bool: + return bool(state and state.is_playing) + + +def is_paused(state: PlaybackState | None) -> bool: + return state is not None and not state.is_playing + + +def is_fresh_track(state: PlaybackState | None) -> bool: + """A just-started track: playing and barely into its runtime.""" + return is_playing(state) and (state.progress_ms or 0) < 5000 + + +def plays_uri(uri: str | None) -> Predicate: + """Wait for the track we actually asked for. + + `is_playing` alone is satisfied immediately by whatever was already playing, + which would report the previous track as if it were the new one. + """ + if uri is None: + return is_fresh_track + + def matches(state: PlaybackState | None) -> bool: + return is_playing(state) and state.item is not None and state.item.uri == uri + + return matches + + +async def settled_playback( + spotify: Spotifyify, + *, + until: Predicate | None = None, + wait: bool = True, +) -> PlaybackState | None: + """Read playback back after a mutation, briefly waiting for it to take effect. + + Spotify applies playback commands asynchronously, so an immediate read can + still describe the previous track. Polling here costs a fraction of a second + but saves the caller a whole second round trip. + """ + attempts = SETTLE_ATTEMPTS if (wait and until is not None) else 1 + state = None + for attempt in range(attempts): + state = await spotify.player.state() + if until is None or until(state): + break + if attempt < attempts - 1: + await asyncio.sleep(SETTLE_DELAY_SECONDS) + return state diff --git a/spotifyify/cli/episodes.py b/spotifyify/cli/episodes.py index 32d8e5c..9172536 100644 --- a/spotifyify/cli/episodes.py +++ b/spotifyify/cli/episodes.py @@ -1,87 +1,68 @@ -from __future__ import annotations - from typing import Annotated import typer -from ._core import _coalesce_scopes, _split_values -from ._options import ( +from spotifyify.cli.core import ( + BATCH_EPISODES, + default_market, + gather_batches, + split_values, + spotify_client, +) +from spotifyify.cli.options import ( DEFAULT_LIMIT, FieldsOption, IdsArgument, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - ScopeOption, - _handle, - _render, + async_command, + print_result, +) + +app = typer.Typer( + help="Work with Spotify episodes.", + rich_markup_mode=None, + no_args_is_help=True, ) -app = typer.Typer(help="Work with Spotify episodes.") +SEARCH_COLUMNS = ("id", "name", "release_date", "uri") +COLUMNS = ("id", "name", "show.name", "uri") @app.command("search") -def search_episodes( +@async_command +async def search_episodes( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, - offset: OffsetOption = 0, - market: MarketOption = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.episodes.find( - query, limit=limit, offset=offset, market=market - ), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client() as spotify: + result = await spotify.episodes.find( + query, limit=limit, market=default_market() + ) + print_result( result, - json_output=json_output, + columns=SEARCH_COLUMNS, fields=fields, - columns=("id", "name", "release_date", "uri"), ) @app.command("get") -def get_episode( - episode_id: Annotated[str, typer.Argument(help="Spotify episode ID.")], - market: MarketOption = None, - json_output: JsonOption = False, - fields: FieldsOption = None, - scope: ScopeOption = None, -) -> None: - result = _handle( - lambda spotify: spotify.episodes.get(episode_id, market=market), - scopes=_coalesce_scopes(scope), - ) - _render( - result, - json_output=json_output, - fields=fields, - columns=("id", "name", "show.name", "uri"), - ) - - -@app.command("get-many") -def get_many_episodes( +@async_command +async def get_episodes( episode_ids: IdsArgument, - market: MarketOption = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.episodes.get_many( - _split_values(episode_ids), market=market - ), - scopes=_coalesce_scopes(scope), - ) - _render( + """Fetch one or many episodes in a single call.""" + ids = split_values(episode_ids) + market = default_market() + async with spotify_client() as spotify: + result = await gather_batches( + lambda chunk: spotify.episodes.get_many(chunk, market=market), + ids, + BATCH_EPISODES, + ) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "show.name", "uri"), ) diff --git a/spotifyify/cli/library.py b/spotifyify/cli/library.py index 4ce08eb..06ba02b 100644 --- a/spotifyify/cli/library.py +++ b/spotifyify/cli/library.py @@ -1,329 +1,381 @@ -from __future__ import annotations - -from collections.abc import Awaitable, Callable, Sequence from typing import Annotated import typer -from spotifyify import Spotifyify, SpotifyScope - -from ._core import ( - Jsonable, - _coalesce_scopes, - _print_json, - _print_success, - _print_table, - _split_values, +from spotifyify import SpotifyScope + +from spotifyify.cli.core import ( + BATCH_ALBUMS, + BATCH_EPISODES, + BATCH_SHOWS, + BATCH_TRACKS, + default_market, + merge_scopes, + sequential_batches, + split_values, + spotify_client, ) -from ._options import ( +from spotifyify.cli.options import ( FieldsOption, IdsArgument, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - ScopeOption, - _handle, - _render, + async_command, + print_result, ) -app = typer.Typer(help="Work with the current user's library.") - - -def _saved_scope(scope: Sequence[str] | None) -> Sequence[str]: - return _coalesce_scopes(scope) or [SpotifyScope.USER_LIBRARY_READ.value] - - -def _modify_library_scope(scope: Sequence[str] | None) -> Sequence[str]: - return _coalesce_scopes(scope) or [SpotifyScope.USER_LIBRARY_MODIFY.value] - - -def _library_action( - ids: Sequence[str], - *, - action: Callable[[Spotifyify, list[str]], Awaitable[Jsonable]], - scope: Sequence[str] | None, -) -> None: - _handle( - lambda spotify: action(spotify, _split_values(ids)), - scopes=_modify_library_scope(scope), - ) - _print_success() +app = typer.Typer( + help="Work with the current user's library.", + rich_markup_mode=None, + no_args_is_help=True, +) +SAVED_COLUMNS = ("id", "saved") -def _library_check( - ids: Sequence[str], - *, - action: Callable[[Spotifyify, list[str]], Awaitable[list[bool]]], - scope: Sequence[str] | None, - json_output: bool, -) -> None: - item_ids = _split_values(ids) - result = _handle( - lambda spotify: action(spotify, item_ids), scopes=_saved_scope(scope) - ) - payload = [ - {"id": item_id, "saved": saved} - for item_id, saved in zip(item_ids, result, strict=False) - ] - _print_json(payload) if json_output else _print_table(payload, ("id", "saved")) +READ_SCOPES = [SpotifyScope.USER_LIBRARY_READ] +MODIFY_SCOPES = [SpotifyScope.USER_LIBRARY_MODIFY] +# Save/remove report the resulting saved state, so they read it back too. +WRITE_SCOPES = merge_scopes(MODIFY_SCOPES, READ_SCOPES) @app.command("saved-tracks") -def saved_tracks( +@async_command +async def saved_tracks( limit: LimitOption = 20, - offset: OffsetOption = 0, - market: MarketOption = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.library.saved_tracks( - limit=limit, offset=offset, market=market - ), - scopes=_saved_scope(scope), - ) - _render( + async with spotify_client(READ_SCOPES) as spotify: + result = await spotify.library.saved_tracks( + limit=limit, market=default_market() + ) + print_result( result, - json_output=json_output, - fields=fields, columns=("track.id", "track.name", "track.artists", "added_at"), + fields=fields, ) @app.command("saved-albums") -def saved_albums( +@async_command +async def saved_albums( limit: LimitOption = 20, - offset: OffsetOption = 0, - market: MarketOption = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.library.saved_albums( - limit=limit, offset=offset, market=market - ), - scopes=_saved_scope(scope), - ) - _render( + async with spotify_client(READ_SCOPES) as spotify: + result = await spotify.library.saved_albums( + limit=limit, market=default_market() + ) + print_result( result, - json_output=json_output, - fields=fields, columns=("album.id", "album.name", "album.artists", "added_at"), + fields=fields, ) @app.command("saved-shows") -def saved_shows( +@async_command +async def saved_shows( limit: LimitOption = 20, - offset: OffsetOption = 0, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.library.saved_shows(limit=limit, offset=offset), - scopes=_saved_scope(scope), - ) - _render( + async with spotify_client(READ_SCOPES) as spotify: + result = await spotify.library.saved_shows(limit=limit) + print_result( result, - json_output=json_output, - fields=fields, columns=("show.id", "show.name", "show.publisher", "added_at"), + fields=fields, ) @app.command("saved-episodes") -def saved_episodes( +@async_command +async def saved_episodes( limit: LimitOption = 20, - offset: OffsetOption = 0, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.library.saved_episodes(limit=limit, offset=offset), - scopes=_saved_scope(scope), - ) - _render( + async with spotify_client(READ_SCOPES) as spotify: + result = await spotify.library.saved_episodes(limit=limit) + print_result( result, - json_output=json_output, - fields=fields, columns=("episode.id", "episode.name", "added_at"), + fields=fields, ) @app.command("top-tracks") -def library_top_tracks( +@async_command +async def library_top_tracks( time_range: Annotated[str, typer.Option("--time-range")] = "medium_term", limit: LimitOption = 20, - offset: OffsetOption = 0, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.library.top_tracks( - time_range=time_range, limit=limit, offset=offset - ), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_TOP_READ.value], - ) - _render( + async with spotify_client([SpotifyScope.USER_TOP_READ]) as spotify: + result = await spotify.library.top_tracks(time_range=time_range, limit=limit) + print_result( result, - json_output=json_output, - fields=fields, columns=("id", "name", "artists", "uri"), + fields=fields, ) @app.command("top-artists") -def library_top_artists( +@async_command +async def library_top_artists( time_range: Annotated[str, typer.Option("--time-range")] = "medium_term", limit: LimitOption = 20, - offset: OffsetOption = 0, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.library.top_artists( - time_range=time_range, limit=limit, offset=offset - ), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_TOP_READ.value], - ) - _render( + async with spotify_client([SpotifyScope.USER_TOP_READ]) as spotify: + result = await spotify.library.top_artists(time_range=time_range, limit=limit) + print_result( result, - json_output=json_output, - fields=fields, columns=("id", "name", "uri"), + fields=fields, ) @app.command("save-tracks") -def save_tracks(track_ids: IdsArgument, scope: ScopeOption = None) -> None: - _library_action( - track_ids, - action=lambda spotify, ids: spotify.library.save_tracks(ids), - scope=scope, +@async_command +async def save_tracks( + track_ids: IdsArgument, + fields: FieldsOption = None, +) -> None: + ids = split_values(track_ids) + async with spotify_client(WRITE_SCOPES) as spotify: + await sequential_batches(spotify.library.save_tracks, ids, BATCH_TRACKS) + saved = await spotify.library.check_tracks(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, + fields=fields, ) @app.command("remove-tracks") -def remove_tracks(track_ids: IdsArgument, scope: ScopeOption = None) -> None: - _library_action( - track_ids, - action=lambda spotify, ids: spotify.library.remove_tracks(ids), - scope=scope, +@async_command +async def remove_tracks( + track_ids: IdsArgument, + fields: FieldsOption = None, +) -> None: + ids = split_values(track_ids) + async with spotify_client(WRITE_SCOPES) as spotify: + await sequential_batches(spotify.library.remove_tracks, ids, BATCH_TRACKS) + saved = await spotify.library.check_tracks(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, + fields=fields, ) @app.command("check-tracks") -def check_tracks( +@async_command +async def check_tracks( track_ids: IdsArgument, - json_output: JsonOption = False, - scope: ScopeOption = None, + fields: FieldsOption = None, ) -> None: - _library_check( - track_ids, - action=lambda spotify, ids: spotify.library.check_tracks(ids), - scope=scope, - json_output=json_output, + ids = split_values(track_ids) + async with spotify_client(READ_SCOPES) as spotify: + saved = await spotify.library.check_tracks(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, + fields=fields, ) @app.command("save-albums") -def save_albums(album_ids: IdsArgument, scope: ScopeOption = None) -> None: - _library_action( - album_ids, - action=lambda spotify, ids: spotify.library.save_albums(ids), - scope=scope, +@async_command +async def save_albums( + album_ids: IdsArgument, + fields: FieldsOption = None, +) -> None: + ids = split_values(album_ids) + async with spotify_client(WRITE_SCOPES) as spotify: + await sequential_batches(spotify.library.save_albums, ids, BATCH_ALBUMS) + saved = await spotify.library.check_albums(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, + fields=fields, ) @app.command("remove-albums") -def remove_albums(album_ids: IdsArgument, scope: ScopeOption = None) -> None: - _library_action( - album_ids, - action=lambda spotify, ids: spotify.library.remove_albums(ids), - scope=scope, +@async_command +async def remove_albums( + album_ids: IdsArgument, + fields: FieldsOption = None, +) -> None: + ids = split_values(album_ids) + async with spotify_client(WRITE_SCOPES) as spotify: + await sequential_batches(spotify.library.remove_albums, ids, BATCH_ALBUMS) + saved = await spotify.library.check_albums(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, + fields=fields, ) @app.command("check-albums") -def check_albums( +@async_command +async def check_albums( album_ids: IdsArgument, - json_output: JsonOption = False, - scope: ScopeOption = None, + fields: FieldsOption = None, ) -> None: - _library_check( - album_ids, - action=lambda spotify, ids: spotify.library.check_albums(ids), - scope=scope, - json_output=json_output, + ids = split_values(album_ids) + async with spotify_client(READ_SCOPES) as spotify: + saved = await spotify.library.check_albums(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, + fields=fields, ) @app.command("save-shows") -def save_shows(show_ids: IdsArgument, scope: ScopeOption = None) -> None: - _library_action( - show_ids, - action=lambda spotify, ids: spotify.library.save_shows(ids), - scope=scope, +@async_command +async def save_shows( + show_ids: IdsArgument, + fields: FieldsOption = None, +) -> None: + ids = split_values(show_ids) + async with spotify_client(WRITE_SCOPES) as spotify: + await sequential_batches(spotify.library.save_shows, ids, BATCH_SHOWS) + saved = await spotify.library.check_shows(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, + fields=fields, ) @app.command("remove-shows") -def remove_shows(show_ids: IdsArgument, scope: ScopeOption = None) -> None: - _library_action( - show_ids, - action=lambda spotify, ids: spotify.library.remove_shows(ids), - scope=scope, +@async_command +async def remove_shows( + show_ids: IdsArgument, + fields: FieldsOption = None, +) -> None: + ids = split_values(show_ids) + async with spotify_client(WRITE_SCOPES) as spotify: + await sequential_batches(spotify.library.remove_shows, ids, BATCH_SHOWS) + saved = await spotify.library.check_shows(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, + fields=fields, ) @app.command("check-shows") -def check_shows( +@async_command +async def check_shows( show_ids: IdsArgument, - json_output: JsonOption = False, - scope: ScopeOption = None, + fields: FieldsOption = None, ) -> None: - _library_check( - show_ids, - action=lambda spotify, ids: spotify.library.check_shows(ids), - scope=scope, - json_output=json_output, + ids = split_values(show_ids) + async with spotify_client(READ_SCOPES) as spotify: + saved = await spotify.library.check_shows(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, + fields=fields, ) @app.command("save-episodes") -def save_episodes(episode_ids: IdsArgument, scope: ScopeOption = None) -> None: - _library_action( - episode_ids, - action=lambda spotify, ids: spotify.library.save_episodes(ids), - scope=scope, +@async_command +async def save_episodes( + episode_ids: IdsArgument, + fields: FieldsOption = None, +) -> None: + ids = split_values(episode_ids) + async with spotify_client(WRITE_SCOPES) as spotify: + await sequential_batches(spotify.library.save_episodes, ids, BATCH_EPISODES) + saved = await spotify.library.check_episodes(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, + fields=fields, ) @app.command("remove-episodes") -def remove_episodes(episode_ids: IdsArgument, scope: ScopeOption = None) -> None: - _library_action( - episode_ids, - action=lambda spotify, ids: spotify.library.remove_episodes(ids), - scope=scope, +@async_command +async def remove_episodes( + episode_ids: IdsArgument, + fields: FieldsOption = None, +) -> None: + ids = split_values(episode_ids) + async with spotify_client(WRITE_SCOPES) as spotify: + await sequential_batches(spotify.library.remove_episodes, ids, BATCH_EPISODES) + saved = await spotify.library.check_episodes(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, + fields=fields, ) @app.command("check-episodes") -def check_episodes( +@async_command +async def check_episodes( episode_ids: IdsArgument, - json_output: JsonOption = False, - scope: ScopeOption = None, + fields: FieldsOption = None, ) -> None: - _library_check( - episode_ids, - action=lambda spotify, ids: spotify.library.check_episodes(ids), - scope=scope, - json_output=json_output, + ids = split_values(episode_ids) + async with spotify_client(READ_SCOPES) as spotify: + saved = await spotify.library.check_episodes(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, + fields=fields, ) diff --git a/spotifyify/cli/options.py b/spotifyify/cli/options.py new file mode 100644 index 0000000..73aa274 --- /dev/null +++ b/spotifyify/cli/options.py @@ -0,0 +1,92 @@ +import asyncio +from collections.abc import Sequence +from functools import wraps +from typing import Annotated, Any, Callable, ParamSpec +from collections.abc import Awaitable + +import typer + +from spotifyify import SpotifyAPIError, SpotifyAuthError + +from spotifyify.cli.core import ( + Jsonable, + as_jsonable, + is_raw_output, + print_json, + rows, + split_values, +) + +DEFAULT_LIMIT = 10 + +EXIT_API_ERROR = 1 +EXIT_AUTH_ERROR = 3 + +FieldsOption = Annotated[ + list[str] | None, + typer.Option( + "--field", + "--fields", + "-f", + help="Field path to include. Can be repeated or comma-separated.", + ), +] +LimitOption = Annotated[ + int, + typer.Option("--limit", "-l", min=1, max=50, help="Number of items to fetch."), +] +WaitOption = Annotated[ + bool, + typer.Option( + "--wait/--no-wait", + help="Wait for playback to reflect the change before reporting state.", + ), +] +IdsArgument = Annotated[ + list[str], + typer.Argument(help="One or more IDs. Values can also be comma-separated."), +] +UrisArgument = Annotated[ + list[str], + typer.Argument(help="One or more Spotify URIs. Values can be comma-separated."), +] + + +P = ParamSpec("P") + + +def async_command(command: Callable[P, Awaitable[None]]) -> Callable[P, None]: + """Bridge one async command into Typer and translate expected API failures.""" + + @wraps(command) + def run(*args: P.args, **kwargs: P.kwargs) -> None: + try: + asyncio.run(command(*args, **kwargs)) + except SpotifyAuthError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(EXIT_AUTH_ERROR) from exc + except SpotifyAPIError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(EXIT_API_ERROR) from exc + + return run + + +def print_result( + result: Jsonable, + *, + columns: Sequence[str], + fields: Sequence[str] | None = None, + project: Callable[[Any], Any] | None = None, +) -> None: + """Emit the command's declared columns, in order, as JSON. + + SPOTIFYIFY_RAW=1 opts out into the untouched Spotify payload, for + debugging fields that are not part of any command's declared columns. + """ + if is_raw_output(): + print_json(as_jsonable(result)) + return + payload = project(result) if project is not None else result + selected_columns = split_values(fields) or list(columns) + print_json(rows(payload, selected_columns)) diff --git a/spotifyify/cli/player.py b/spotifyify/cli/player.py index 9099f36..03ae525 100644 --- a/spotifyify/cli/player.py +++ b/spotifyify/cli/player.py @@ -1,239 +1,359 @@ -from __future__ import annotations - -from typing import Annotated +from typing import Annotated, Any import typer -from spotifyify import SpotifyScope - -from ._core import _coalesce_scopes, _parse_json_object, _print_success, _split_values -from ._options import ( - DeviceOption, +from spotifyify import Spotifyify, SpotifyScope +from spotifyify.exceptions import SpotifyAPIError +from spotifyify.schemas import Device + +from spotifyify.cli.core import ( + PLAYBACK_COLUMNS, + default_device_id, + default_market, + is_raw_output, + merge_scopes, + parse_json_object, + playback_summary, + settled_playback, + sort_items, + split_values, + is_fresh_track, + is_paused, + is_playing, + plays_uri, + spotify_client, +) +from spotifyify.cli.options import ( FieldsOption, - JsonOption, LimitOption, - ScopeOption, - _handle, - _render, + UrisArgument, + WaitOption, + async_command, + print_result, ) -app = typer.Typer(help="Control and inspect Spotify playback.") +app = typer.Typer( + help="Control and inspect Spotify playback.", + rich_markup_mode=None, + no_args_is_help=True, +) + +READ_SCOPES = [SpotifyScope.USER_READ_PLAYBACK_STATE] +MODIFY_SCOPES = [SpotifyScope.USER_MODIFY_PLAYBACK_STATE] +# Playback mutations report the state they produced, so they need the read +# scope too — otherwise the caller pays a second round trip to learn what +# actually happened. +CONTROL_SCOPES = merge_scopes(MODIFY_SCOPES, READ_SCOPES) + + +def _select_fallback_device(devices: list[Device]) -> Device | None: + """Choose a controllable device reproducibly, preferring computers.""" + candidates = [ + device for device in devices if device.id and not device.is_restricted + ] + if not candidates: + return None + return min( + candidates, + key=lambda device: ( + not bool(device.is_active), + (device.type or "").casefold() != "computer", + (device.name or "").casefold(), + device.id or "", + ), + ) + + +async def _play_with_device_fallback( + spotify: Spotifyify, + *, + device_id: str | None, + context_uri: str | None = None, + uris: list[str] | None = None, + offset: dict[str, Any] | None = None, + position_ms: int | None = None, +) -> None: + """Play normally, discovering a target only when Spotify has no active one.""" + playback = { + "context_uri": context_uri, + "uris": uris, + "offset": offset, + "position_ms": position_ms, + } + try: + await spotify.player.play(device_id=device_id, **playback) + except SpotifyAPIError as error: + if device_id is not None or "no active device" not in error.message.casefold(): + raise + + fallback = _select_fallback_device(await spotify.player.devices()) + if fallback is None: + raise + await spotify.player.play(device_id=fallback.id, **playback) @app.command("state") -def player_state( - market: Annotated[ - str | None, - typer.Option("--market", "-m", help="ISO 3166-1 alpha-2 market code."), - ] = None, - json_output: JsonOption = False, +@async_command +async def player_state( fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.player.state(market=market), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_READ_PLAYBACK_STATE.value], - ) - _render( - result, - json_output=json_output, + async with spotify_client(READ_SCOPES) as spotify: + state = await spotify.player.state(market=default_market()) + print_result( + state, + columns=PLAYBACK_COLUMNS, fields=fields, - columns=("item.id", "item.name", "is_playing", "progress_ms"), + project=playback_summary, ) @app.command("play") -def player_play( - device_id: DeviceOption = None, +@async_command +async def player_play( context_uri: Annotated[str | None, typer.Option("--context-uri")] = None, uri: Annotated[list[str] | None, typer.Option("--uri")] = None, offset: Annotated[str | None, typer.Option("--offset-json")] = None, position_ms: Annotated[int | None, typer.Option("--position-ms", min=0)] = None, - scope: ScopeOption = None, + wait: WaitOption = True, + fields: FieldsOption = None, ) -> None: - _handle( - lambda spotify: spotify.player.play( - device_id=device_id, + uris = split_values(uri) or None + if uris: + # Wait for the requested track, not merely for "something is playing". + until = plays_uri(uris[0]) + elif context_uri: + until = is_fresh_track + else: + until = is_playing + async with spotify_client(CONTROL_SCOPES) as spotify: + await _play_with_device_fallback( + spotify, + device_id=default_device_id(), context_uri=context_uri, - uris=_split_values(uri) or None, - offset=_parse_json_object(offset, "--offset-json"), + uris=uris, + offset=parse_json_object(offset, "--offset-json"), position_ms=position_ms, - ), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + ) + state = await settled_playback(spotify, until=until, wait=wait) + print_result( + state, + columns=PLAYBACK_COLUMNS, + fields=fields, + project=playback_summary, ) - _print_success() @app.command("pause") -def player_pause(device_id: DeviceOption = None, scope: ScopeOption = None) -> None: - _handle( - lambda spotify: spotify.player.pause(device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], +@async_command +async def player_pause( + wait: WaitOption = True, + fields: FieldsOption = None, +) -> None: + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.pause(device_id=default_device_id()) + state = await settled_playback(spotify, until=is_paused, wait=wait) + print_result( + state, + columns=PLAYBACK_COLUMNS, + fields=fields, + project=playback_summary, ) - _print_success() @app.command("skip") -def player_skip(device_id: DeviceOption = None, scope: ScopeOption = None) -> None: - _handle( - lambda spotify: spotify.player.skip(device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], +@async_command +async def player_skip( + wait: WaitOption = True, + fields: FieldsOption = None, +) -> None: + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.skip(device_id=default_device_id()) + state = await settled_playback(spotify, until=is_fresh_track, wait=wait) + print_result( + state, + columns=PLAYBACK_COLUMNS, + fields=fields, + project=playback_summary, ) - _print_success() @app.command("previous") -def player_previous(device_id: DeviceOption = None, scope: ScopeOption = None) -> None: - _handle( - lambda spotify: spotify.player.previous(device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], +@async_command +async def player_previous( + wait: WaitOption = True, + fields: FieldsOption = None, +) -> None: + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.previous(device_id=default_device_id()) + state = await settled_playback(spotify, until=is_fresh_track, wait=wait) + print_result( + state, + columns=PLAYBACK_COLUMNS, + fields=fields, + project=playback_summary, ) - _print_success() @app.command("seek") -def player_seek( +@async_command +async def player_seek( position_ms: Annotated[int, typer.Argument(min=0)], - device_id: DeviceOption = None, - scope: ScopeOption = None, + fields: FieldsOption = None, ) -> None: - _handle( - lambda spotify: spotify.player.seek(position_ms, device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.seek(position_ms, device_id=default_device_id()) + state = await settled_playback(spotify) + print_result( + state, + columns=PLAYBACK_COLUMNS, + fields=fields, + project=playback_summary, ) - _print_success() @app.command("repeat") -def player_repeat( +@async_command +async def player_repeat( state: Annotated[str, typer.Argument(help="track, context, or off")], - device_id: DeviceOption = None, - scope: ScopeOption = None, + fields: FieldsOption = None, ) -> None: - _handle( - lambda spotify: spotify.player.repeat(state, device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.repeat(state, device_id=default_device_id()) + playback = await settled_playback(spotify) + print_result( + playback, + columns=PLAYBACK_COLUMNS, + fields=fields, + project=playback_summary, ) - _print_success() @app.command("shuffle") -def player_shuffle( +@async_command +async def player_shuffle( state: Annotated[bool, typer.Argument()], - device_id: DeviceOption = None, - scope: ScopeOption = None, + fields: FieldsOption = None, ) -> None: - _handle( - lambda spotify: spotify.player.shuffle(state, device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.shuffle(state, device_id=default_device_id()) + playback = await settled_playback(spotify) + print_result( + playback, + columns=PLAYBACK_COLUMNS, + fields=fields, + project=playback_summary, ) - _print_success() @app.command("volume") -def player_volume( +@async_command +async def player_volume( volume_percent: Annotated[int, typer.Argument(min=0, max=100)], - device_id: DeviceOption = None, - scope: ScopeOption = None, + fields: FieldsOption = None, ) -> None: - _handle( - lambda spotify: spotify.player.volume(volume_percent, device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.volume(volume_percent, device_id=default_device_id()) + state = await settled_playback(spotify) + print_result( + state, + columns=PLAYBACK_COLUMNS, + fields=fields, + project=playback_summary, ) - _print_success() @app.command("queue") -def player_queue( - json_output: JsonOption = False, +@async_command +async def player_queue( fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.player.queue(), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_READ_PLAYBACK_STATE.value], - ) - _render( + async with spotify_client(READ_SCOPES) as spotify: + result = await spotify.player.queue() + print_result( result, - json_output=json_output, - fields=fields, columns=("id", "name", "artists", "uri"), + fields=fields, ) @app.command("add-to-queue") -def add_to_queue( - uri: Annotated[str, typer.Argument(help="Track or episode URI.")], - device_id: DeviceOption = None, - scope: ScopeOption = None, +@async_command +async def add_to_queue( + uris: UrisArgument, + fields: FieldsOption = None, ) -> None: - _handle( - lambda spotify: spotify.player.add_to_queue(uri, device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + """Queue one or many tracks or episodes in a single call.""" + queued = split_values(uris) + device_id = default_device_id() + + async with spotify_client(CONTROL_SCOPES) as spotify: + # Spotify has no bulk queue endpoint and the queue is ordered, so these + # must stay sequential. + for uri in queued: + await spotify.player.add_to_queue(uri, device_id=device_id) + state = await settled_playback(spotify) + print_result( + state, + columns=PLAYBACK_COLUMNS, + fields=fields, + project=playback_summary, ) - _print_success() @app.command("transfer") -def transfer_playback( +@async_command +async def transfer_playback( device_id: Annotated[str, typer.Argument(help="Spotify device ID.")], play: Annotated[bool, typer.Option("--play/--no-play")] = False, - scope: ScopeOption = None, + wait: WaitOption = True, + fields: FieldsOption = None, ) -> None: - _handle( - lambda spotify: spotify.player.transfer(device_id, play=play), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.transfer(device_id, play=play) + state = await settled_playback( + spotify, + until=is_playing if play else None, + wait=wait, + ) + print_result( + state, + columns=PLAYBACK_COLUMNS, + fields=fields, + project=playback_summary, ) - _print_success() @app.command("devices") -def player_devices( - json_output: JsonOption = False, +@async_command +async def player_devices( fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.player.devices(), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_READ_PLAYBACK_STATE.value], - ) - _render( - result, - json_output=json_output, - fields=fields, + async with spotify_client(READ_SCOPES) as spotify: + result = await spotify.player.devices() + # Spotify returns devices in no defined order; sort so repeated calls and + # any caching built on top of them stay reproducible. + display_result = result if is_raw_output() else sort_items(result or [], ("id",)) + print_result( + display_result, columns=("id", "name", "type", "is_active", "volume_percent"), + fields=fields, ) @app.command("recently-played") -def recently_played( +@async_command +async def recently_played( limit: LimitOption = 20, after: Annotated[int | None, typer.Option("--after")] = None, before: Annotated[int | None, typer.Option("--before")] = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.player.recently_played( + async with spotify_client([SpotifyScope.USER_READ_RECENTLY_PLAYED]) as spotify: + result = await spotify.player.recently_played( limit=limit, after=after, before=before - ), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_READ_RECENTLY_PLAYED.value], - ) - _render( + ) + print_result( result, - json_output=json_output, + columns=("track.id", "track.name", "track.artists", "played_at"), fields=fields, - columns=("track.id", "track.name", "played_at"), ) diff --git a/spotifyify/cli/playlists.py b/spotifyify/cli/playlists.py index 1f3621c..3785d26 100644 --- a/spotifyify/cli/playlists.py +++ b/spotifyify/cli/playlists.py @@ -1,132 +1,116 @@ -from __future__ import annotations - from typing import Annotated import typer from spotifyify import SpotifyScope -from ._core import _coalesce_scopes, _print_json, _print_success, _split_values -from ._options import ( +from spotifyify.cli.core import default_market, split_values, spotify_client +from spotifyify.cli.options import ( DEFAULT_LIMIT, FieldsOption, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - ScopeOption, UrisArgument, - _handle, - _render, + async_command, + print_result, +) + +app = typer.Typer( + help="Work with Spotify playlists.", + rich_markup_mode=None, + no_args_is_help=True, ) -app = typer.Typer(help="Work with Spotify playlists.") +COLUMNS = ("id", "name", "owner", "uri") +SNAPSHOT_COLUMNS = ("playlist_id", "snapshot_id", "total") _MODIFY_SCOPES = [ - SpotifyScope.PLAYLIST_MODIFY_PUBLIC.value, - SpotifyScope.PLAYLIST_MODIFY_PRIVATE.value, + SpotifyScope.PLAYLIST_MODIFY_PUBLIC, + SpotifyScope.PLAYLIST_MODIFY_PRIVATE, ] @app.command("search") -def search_playlists( +@async_command +async def search_playlists( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, - offset: OffsetOption = 0, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.find(query, limit=limit, offset=offset), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client() as spotify: + result = await spotify.playlists.find(query, limit=limit) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "owner", "uri"), ) @app.command("get") -def get_playlist( +@async_command +async def get_playlist( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], - market: MarketOption = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.get(playlist_id, market=market), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client() as spotify: + result = await spotify.playlists.get(playlist_id, market=default_market()) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "owner", "uri"), ) @app.command("list") -def list_playlists( +@async_command +async def list_playlists( user_id: Annotated[str | None, typer.Option("--user-id", "-u")] = None, limit: LimitOption = 20, - offset: OffsetOption = 0, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.list( - user_id=user_id, limit=limit, offset=offset - ), - scopes=_coalesce_scopes(scope) or [SpotifyScope.PLAYLIST_READ_PRIVATE.value], - ) - _render( + async with spotify_client([SpotifyScope.PLAYLIST_READ_PRIVATE]) as spotify: + result = await spotify.playlists.list(user_id=user_id, limit=limit) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "owner", "uri"), ) @app.command("tracks") -def playlist_tracks( +@async_command +async def playlist_tracks( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], - market: MarketOption = None, - fields_query: Annotated[str | None, typer.Option("--spotify-fields")] = None, + fields_query: Annotated[ + str | None, + typer.Option( + "--spotify-fields", + help="Server-side field filter applied by Spotify before the response is sent.", + ), + ] = None, limit: LimitOption = 20, - offset: OffsetOption = 0, additional_types: Annotated[ list[str] | None, typer.Option("--additional-type") ] = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.tracks( + async with spotify_client() as spotify: + result = await spotify.playlists.tracks( playlist_id, - market=market, + market=default_market(), fields=fields_query, limit=limit, - offset=offset, - additional_types=_split_values(additional_types) or None, - ), - scopes=_coalesce_scopes(scope), - ) - _render( + additional_types=split_values(additional_types) or None, + ) + print_result( result, - json_output=json_output, + columns=("track.id", "track.name", "track.artists", "added_at"), fields=fields, - columns=("item.id", "item.name", "item.artists", "added_at"), ) @app.command("create") -def create_playlist( +@async_command +async def create_playlist( name: Annotated[str, typer.Argument(help="Playlist name.")], public: Annotated[bool, typer.Option("--public/--private")] = False, collaborative: Annotated[ @@ -134,30 +118,26 @@ def create_playlist( ] = False, description: Annotated[str, typer.Option("--description", "-d")] = "", user_id: Annotated[str | None, typer.Option("--user-id", "-u")] = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.create( + async with spotify_client(_MODIFY_SCOPES) as spotify: + result = await spotify.playlists.create( name, public=public, collaborative=collaborative, description=description, user_id=user_id, - ), - scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES, - ) - _render( + ) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "uri"), ) @app.command("update") -def update_playlist( +@async_command +async def update_playlist( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], name: Annotated[str | None, typer.Option("--name")] = None, public: Annotated[bool | None, typer.Option("--public/--private")] = None, @@ -165,127 +145,139 @@ def update_playlist( bool | None, typer.Option("--collaborative/--not-collaborative") ] = None, description: Annotated[str | None, typer.Option("--description", "-d")] = None, - scope: ScopeOption = None, + fields: FieldsOption = None, ) -> None: - _handle( - lambda spotify: spotify.playlists.update( + async with spotify_client(_MODIFY_SCOPES) as spotify: + await spotify.playlists.update( playlist_id, name=name, public=public, collaborative=collaborative, description=description, - ), - scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES, + ) + result = await spotify.playlists.get(playlist_id) + print_result( + result, + columns=("id", "name", "public", "collaborative", "description"), + fields=fields, ) - _print_success() @app.command("add") -def add_playlist_items( +@async_command +async def add_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], uris: UrisArgument, position: Annotated[int | None, typer.Option("--position")] = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.add( - playlist_id, _split_values(uris), position=position - ), - scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES, - ) - payload = {"snapshot_id": result} - ( - _print_json(payload, fields=_split_values(fields)) - if json_output - else _print_success(result) + """Add one or many URIs in a single call.""" + async with spotify_client(_MODIFY_SCOPES) as spotify: + snapshot_id = await spotify.playlists.add( + playlist_id, split_values(uris), position=position + ) + playlist = await spotify.playlists.get(playlist_id) + result = { + "playlist_id": playlist_id, + "snapshot_id": snapshot_id, + "total": getattr(getattr(playlist, "tracks", None), "total", None), + "name": getattr(playlist, "name", None), + } + print_result( + result, + columns=SNAPSHOT_COLUMNS, + fields=fields, ) @app.command("replace") -def replace_playlist_items( +@async_command +async def replace_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], uris: UrisArgument, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.replace(playlist_id, _split_values(uris)), - scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES, - ) - payload = {"snapshot_id": result} - ( - _print_json(payload, fields=_split_values(fields)) - if json_output - else _print_success(result) + async with spotify_client(_MODIFY_SCOPES) as spotify: + snapshot_id = await spotify.playlists.replace(playlist_id, split_values(uris)) + playlist = await spotify.playlists.get(playlist_id) + result = { + "playlist_id": playlist_id, + "snapshot_id": snapshot_id, + "total": getattr(getattr(playlist, "tracks", None), "total", None), + "name": getattr(playlist, "name", None), + } + print_result( + result, + columns=SNAPSHOT_COLUMNS, + fields=fields, ) @app.command("remove") -def remove_playlist_items( +@async_command +async def remove_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], uris: UrisArgument, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.remove(playlist_id, _split_values(uris)), - scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES, - ) - payload = {"snapshot_id": result} - ( - _print_json(payload, fields=_split_values(fields)) - if json_output - else _print_success(result) + async with spotify_client(_MODIFY_SCOPES) as spotify: + snapshot_id = await spotify.playlists.remove(playlist_id, split_values(uris)) + playlist = await spotify.playlists.get(playlist_id) + result = { + "playlist_id": playlist_id, + "snapshot_id": snapshot_id, + "total": getattr(getattr(playlist, "tracks", None), "total", None), + "name": getattr(playlist, "name", None), + } + print_result( + result, + columns=SNAPSHOT_COLUMNS, + fields=fields, ) @app.command("reorder") -def reorder_playlist_items( +@async_command +async def reorder_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], range_start: Annotated[int, typer.Option("--range-start", min=0)], insert_before: Annotated[int, typer.Option("--insert-before", min=0)], range_length: Annotated[int, typer.Option("--range-length", min=1)] = 1, snapshot_id: Annotated[str | None, typer.Option("--snapshot-id")] = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.reorder( + async with spotify_client(_MODIFY_SCOPES) as spotify: + new_snapshot_id = await spotify.playlists.reorder( playlist_id, range_start=range_start, insert_before=insert_before, range_length=range_length, snapshot_id=snapshot_id, - ), - scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES, - ) - payload = {"snapshot_id": result} - ( - _print_json(payload, fields=_split_values(fields)) - if json_output - else _print_success(result) + ) + playlist = await spotify.playlists.get(playlist_id) + result = { + "playlist_id": playlist_id, + "snapshot_id": new_snapshot_id, + "total": getattr(getattr(playlist, "tracks", None), "total", None), + "name": getattr(playlist, "name", None), + } + print_result( + result, + columns=SNAPSHOT_COLUMNS, + fields=fields, ) @app.command("cover-image") -def playlist_cover_image( +@async_command +async def playlist_cover_image( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.cover_image(playlist_id), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client() as spotify: + result = await spotify.playlists.cover_image(playlist_id) + print_result( result, - json_output=json_output, - fields=fields, columns=("url", "width", "height"), + fields=fields, ) diff --git a/spotifyify/cli/quick.py b/spotifyify/cli/quick.py new file mode 100644 index 0000000..9f5f31e --- /dev/null +++ b/spotifyify/cli/quick.py @@ -0,0 +1,129 @@ +from typing import Annotated, Any + +import typer + +from spotifyify.cli.core import ( + PLAYBACK_COLUMNS, + default_device_id, + default_market, + playback_summary, + settled_playback, + is_fresh_track, + plays_uri, + spotify_client, +) +from spotifyify.cli.options import ( + FieldsOption, + WaitOption, + async_command, + print_result, +) +from spotifyify.cli.player import CONTROL_SCOPES, _play_with_device_fallback + +EXIT_NO_MATCH = 4 + + +def _quoted(value: str) -> str: + """Wrap a filter value for Spotify's search grammar.""" + return '"{}"'.format(value.replace('"', " ").strip()) + + +def _build_query( + words: list[str] | None, + *, + track: str | None, + artist: str | None, + album: str | None, +) -> str: + """Turn the flags into a Spotify field-filtered search query.""" + parts: list[str] = [] + if track: + parts.append(f"track:{_quoted(track)}") + if artist: + parts.append(f"artist:{_quoted(artist)}") + if album: + parts.append(f"album:{_quoted(album)}") + parts.extend(words or []) + return " ".join(part for part in parts if part).strip() + + +def _first(paging: Any) -> Any: + items = getattr(paging, "items", None) or [] + return items[0] if items else None + + +def register(app: typer.Typer) -> None: + """Attach the top-level resolve-and-play command.""" + + @app.command("play") + @async_command + async def play( + words: Annotated[ + list[str] | None, + typer.Argument(help="Free-text search terms, added to the query as-is."), + ] = None, + track: Annotated[ + str | None, typer.Option("--track", "-t", help="Track name to match.") + ] = None, + artist: Annotated[ + str | None, typer.Option("--artist", "-a", help="Artist name to match.") + ] = None, + album: Annotated[ + str | None, typer.Option("--album", help="Album name to match.") + ] = None, + wait: WaitOption = True, + fields: FieldsOption = None, + ) -> None: + """Find something and play it in one call. + + Resolves the top search hit and starts it, so no separate search-then-play + round trip is needed: + + spotifyify play --artist Ikkimel --track "WHO'S THAT" + + A track name (or free text) plays that track. Without one, --album plays + the album and --artist alone plays the artist. + """ + query = _build_query(words, track=track, artist=artist, album=album) + if not query: + raise typer.BadParameter( + "Give search terms or at least one of --track, --artist, --album." + ) + # A track name, or bare free text, means "play this one thing". + # Otherwise the broadest given filter becomes the playback context. + wants_track = bool(track or words) + wants_album = bool(album) and not wants_track + market = default_market() + + async with spotify_client(CONTROL_SCOPES) as spotify: + if wants_track: + hit = _first(await spotify.tracks.find(query, limit=1, market=market)) + uris, context_uri = ([hit.uri] if hit else None), None + elif wants_album: + hit = _first(await spotify.albums.find(query, limit=1, market=market)) + uris, context_uri = None, (hit.uri if hit else None) + else: + hit = _first(await spotify.artists.find(query, limit=1)) + uris, context_uri = None, (hit.uri if hit else None) + + if hit is None or not (uris or context_uri): + typer.echo(f"No match for {query}", err=True) + raise typer.Exit(EXIT_NO_MATCH) + + await _play_with_device_fallback( + spotify, + device_id=default_device_id(), + uris=uris, + context_uri=context_uri, + ) + # Wait for the thing we resolved, so the reported state is not the + # track that happened to be playing already. + until = plays_uri(uris[0]) if uris else is_fresh_track + state = await settled_playback(spotify, until=until, wait=wait) + + print_result( + state, + columns=PLAYBACK_COLUMNS, + fields=fields, + project=playback_summary, + ) diff --git a/spotifyify/cli/shows.py b/spotifyify/cli/shows.py index 74e629d..e059948 100644 --- a/spotifyify/cli/shows.py +++ b/spotifyify/cli/shows.py @@ -1,109 +1,84 @@ -from __future__ import annotations - from typing import Annotated import typer -from ._core import _coalesce_scopes, _split_values -from ._options import ( +from spotifyify.cli.core import ( + BATCH_SHOWS, + default_market, + gather_batches, + split_values, + spotify_client, +) +from spotifyify.cli.options import ( DEFAULT_LIMIT, FieldsOption, IdsArgument, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - ScopeOption, - _handle, - _render, + async_command, + print_result, +) + +app = typer.Typer( + help="Work with Spotify shows.", + rich_markup_mode=None, + no_args_is_help=True, ) -app = typer.Typer(help="Work with Spotify shows.") +COLUMNS = ("id", "name", "publisher", "uri") +EPISODE_COLUMNS = ("id", "name", "release_date", "uri") @app.command("search") -def search_shows( +@async_command +async def search_shows( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, - offset: OffsetOption = 0, - market: MarketOption = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.shows.find( - query, limit=limit, offset=offset, market=market - ), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client() as spotify: + result = await spotify.shows.find(query, limit=limit, market=default_market()) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "publisher", "uri"), ) @app.command("get") -def get_show( - show_id: Annotated[str, typer.Argument(help="Spotify show ID.")], - market: MarketOption = None, - json_output: JsonOption = False, - fields: FieldsOption = None, - scope: ScopeOption = None, -) -> None: - result = _handle( - lambda spotify: spotify.shows.get(show_id, market=market), - scopes=_coalesce_scopes(scope), - ) - _render( - result, - json_output=json_output, - fields=fields, - columns=("id", "name", "publisher", "uri"), - ) - - -@app.command("get-many") -def get_many_shows( +@async_command +async def get_shows( show_ids: IdsArgument, - market: MarketOption = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.shows.get_many(_split_values(show_ids), market=market), - scopes=_coalesce_scopes(scope), - ) - _render( + """Fetch one or many shows in a single call.""" + ids = split_values(show_ids) + market = default_market() + async with spotify_client() as spotify: + result = await gather_batches( + lambda chunk: spotify.shows.get_many(chunk, market=market), + ids, + BATCH_SHOWS, + ) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "publisher", "uri"), ) @app.command("episodes") -def show_episodes( +@async_command +async def show_episodes( show_id: Annotated[str, typer.Argument(help="Spotify show ID.")], - market: MarketOption = None, limit: LimitOption = 20, - offset: OffsetOption = 0, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.shows.episodes( - show_id, market=market, limit=limit, offset=offset - ), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client() as spotify: + result = await spotify.shows.episodes( + show_id, market=default_market(), limit=limit + ) + print_result( result, - json_output=json_output, + columns=EPISODE_COLUMNS, fields=fields, - columns=("id", "name", "release_date", "uri"), ) diff --git a/spotifyify/cli/tracks.py b/spotifyify/cli/tracks.py index 1e0e484..ced8c00 100644 --- a/spotifyify/cli/tracks.py +++ b/spotifyify/cli/tracks.py @@ -1,87 +1,65 @@ -from __future__ import annotations - from typing import Annotated import typer -from ._core import _coalesce_scopes, _split_values -from ._options import ( +from spotifyify.cli.core import ( + BATCH_TRACKS, + default_market, + gather_batches, + split_values, + spotify_client, +) +from spotifyify.cli.options import ( DEFAULT_LIMIT, FieldsOption, IdsArgument, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - ScopeOption, - _handle, - _render, + async_command, + print_result, +) + +app = typer.Typer( + help="Work with Spotify tracks.", + rich_markup_mode=None, + no_args_is_help=True, ) -app = typer.Typer(help="Work with Spotify tracks.") +COLUMNS = ("id", "name", "artists", "album.name", "uri") @app.command("search") -def search_tracks( +@async_command +async def search_tracks( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, - offset: OffsetOption = 0, - market: MarketOption = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.tracks.find( - query, limit=limit, offset=offset, market=market - ), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client() as spotify: + result = await spotify.tracks.find(query, limit=limit, market=default_market()) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "artists", "album.name", "uri"), ) @app.command("get") -def get_track( - track_id: Annotated[str, typer.Argument(help="Spotify track ID.")], - market: MarketOption = None, - json_output: JsonOption = False, - fields: FieldsOption = None, - scope: ScopeOption = None, -) -> None: - result = _handle( - lambda spotify: spotify.tracks.get(track_id, market=market), - scopes=_coalesce_scopes(scope), - ) - _render( - result, - json_output=json_output, - fields=fields, - columns=("id", "name", "artists", "album.name", "uri"), - ) - - -@app.command("get-many") -def get_many_tracks( +@async_command +async def get_tracks( track_ids: IdsArgument, - market: MarketOption = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.tracks.get_many( - _split_values(track_ids), market=market - ), - scopes=_coalesce_scopes(scope), - ) - _render( + """Fetch one or many tracks in a single call.""" + ids = split_values(track_ids) + market = default_market() + async with spotify_client() as spotify: + result = await gather_batches( + lambda chunk: spotify.tracks.get_many(chunk, market=market), + ids, + BATCH_TRACKS, + ) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "name", "artists", "album.name", "uri"), ) diff --git a/spotifyify/cli/users.py b/spotifyify/cli/users.py index fc37b2b..3b945d4 100644 --- a/spotifyify/cli/users.py +++ b/spotifyify/cli/users.py @@ -1,129 +1,153 @@ -from __future__ import annotations - from typing import Annotated import typer from spotifyify import SpotifyScope -from ._core import ( - _coalesce_scopes, - _print_json, - _print_success, - _print_table, - _split_values, +from spotifyify.cli.core import ( + BATCH_FOLLOW, + merge_scopes, + sequential_batches, + split_values, + spotify_client, ) -from ._options import ( +from spotifyify.cli.options import ( FieldsOption, IdsArgument, - JsonOption, LimitOption, - ScopeOption, - _handle, - _render, + async_command, + print_result, +) + +app = typer.Typer( + help="Work with Spotify users and following.", + rich_markup_mode=None, + no_args_is_help=True, ) -app = typer.Typer(help="Work with Spotify users and following.") +COLUMNS = ("id", "display_name", "uri") +FOLLOWING_COLUMNS = ("id", "following") + +READ_SCOPES = [SpotifyScope.USER_LIBRARY_READ] +MODIFY_SCOPES = [SpotifyScope.USER_LIBRARY_MODIFY] +# follow/unfollow report the resulting state, so they read it back too. +WRITE_SCOPES = merge_scopes(MODIFY_SCOPES, READ_SCOPES) @app.command("me") -def users_me( - json_output: JsonOption = False, +@async_command +async def users_me( fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle(lambda spotify: spotify.users.me(), scopes=_coalesce_scopes(scope)) - _render( + async with spotify_client() as spotify: + result = await spotify.users.me() + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "display_name", "uri"), ) @app.command("get") -def users_get( +@async_command +async def users_get( user_id: Annotated[str, typer.Argument(help="Spotify user ID.")], - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.users.get(user_id), scopes=_coalesce_scopes(scope) - ) - _render( + async with spotify_client() as spotify: + result = await spotify.users.get(user_id) + print_result( result, - json_output=json_output, + columns=COLUMNS, fields=fields, - columns=("id", "display_name", "uri"), ) @app.command("following") -def users_following( +@async_command +async def users_following( type: Annotated[str, typer.Option("--type")] = "artist", limit: LimitOption = 20, after: Annotated[str | None, typer.Option("--after")] = None, - json_output: JsonOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.users.following(type=type, limit=limit, after=after), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_LIBRARY_READ.value], - ) - _render( + async with spotify_client(READ_SCOPES) as spotify: + result = await spotify.users.following(type=type, limit=limit, after=after) + print_result( result, - json_output=json_output, - fields=fields, columns=("id", "name", "uri"), + fields=fields, ) @app.command("follow") -def users_follow( +@async_command +async def users_follow( type: Annotated[str, typer.Argument(help="artist or user")], ids: IdsArgument, - scope: ScopeOption = None, + fields: FieldsOption = None, ) -> None: - _handle( - lambda spotify: spotify.users.follow(type, _split_values(ids)), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_LIBRARY_MODIFY.value], + item_ids = split_values(ids) + async with spotify_client(WRITE_SCOPES) as spotify: + await sequential_batches( + lambda chunk: spotify.users.follow(type, chunk), + item_ids, + BATCH_FOLLOW, + ) + following = await spotify.users.check_following(type, item_ids) + result = [ + {"id": item_id, "following": is_following} + for item_id, is_following in zip(item_ids, following, strict=False) + ] + print_result( + result, + columns=FOLLOWING_COLUMNS, + fields=fields, ) - _print_success() @app.command("unfollow") -def users_unfollow( +@async_command +async def users_unfollow( type: Annotated[str, typer.Argument(help="artist or user")], ids: IdsArgument, - scope: ScopeOption = None, + fields: FieldsOption = None, ) -> None: - _handle( - lambda spotify: spotify.users.unfollow(type, _split_values(ids)), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_LIBRARY_MODIFY.value], + item_ids = split_values(ids) + async with spotify_client(WRITE_SCOPES) as spotify: + await sequential_batches( + lambda chunk: spotify.users.unfollow(type, chunk), + item_ids, + BATCH_FOLLOW, + ) + following = await spotify.users.check_following(type, item_ids) + result = [ + {"id": item_id, "following": is_following} + for item_id, is_following in zip(item_ids, following, strict=False) + ] + print_result( + result, + columns=FOLLOWING_COLUMNS, + fields=fields, ) - _print_success() @app.command("check-following") -def users_check_following( +@async_command +async def users_check_following( type: Annotated[str, typer.Argument(help="artist or user")], ids: IdsArgument, - json_output: JsonOption = False, - scope: ScopeOption = None, + fields: FieldsOption = None, ) -> None: - item_ids = _split_values(ids) - result = _handle( - lambda spotify: spotify.users.check_following(type, item_ids), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_LIBRARY_READ.value], - ) + item_ids = split_values(ids) + async with spotify_client(READ_SCOPES) as spotify: + following_values = await spotify.users.check_following(type, item_ids) payload = [ {"id": item_id, "following": following} - for item_id, following in zip(item_ids, result, strict=False) + for item_id, following in zip(item_ids, following_values, strict=False) ] - ( - _print_json(payload) - if json_output - else _print_table(payload, ("id", "following")) + print_result( + payload, + columns=FOLLOWING_COLUMNS, + fields=fields, ) diff --git a/spotifyify/http/response.py b/spotifyify/http/response.py index 9c1535f..66494c8 100644 --- a/spotifyify/http/response.py +++ b/spotifyify/http/response.py @@ -55,7 +55,18 @@ def parse_response(response: httpx.Response) -> JsonResponse: if not response.content: return None - return response.json() + try: + return response.json() + except ValueError: + # Some playback endpoints answer 200 with an opaque, non-JSON body and + # no content type. There is nothing structured to hand back, but it is + # a success and must not surface as a decoding error. + logger.debug( + "Ignoring non-JSON success body: status_code=%d content_type=%s", + response.status_code, + response.headers.get("Content-Type"), + ) + return None def validate_response_model( diff --git a/spotifyify/namespaces/artists.py b/spotifyify/namespaces/artists.py index bdc016f..5133727 100644 --- a/spotifyify/namespaces/artists.py +++ b/spotifyify/namespaces/artists.py @@ -9,7 +9,7 @@ PagingArtist, Track, ) -from spotifyify.utils import coalesce_csv +from spotifyify.utils import coalesce_csv, deprecated class Artists: @@ -79,6 +79,10 @@ async def albums( ) return PagingArtistDiscographyAlbum.model_validate(data) + @deprecated( + "Spotify retired the Related Artists endpoint for most apps in " + "November 2024; this call will likely fail with a 404." + ) async def related(self, artist_id: str) -> list[Artist]: data = ( await self._http.get( diff --git a/spotifyify/namespaces/playlists.py b/spotifyify/namespaces/playlists.py index adf72ac..745fcf8 100644 --- a/spotifyify/namespaces/playlists.py +++ b/spotifyify/namespaces/playlists.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Any from collections.abc import Iterable diff --git a/spotifyify/spotifyify.py b/spotifyify/spotifyify.py index 4abbc55..febffc4 100644 --- a/spotifyify/spotifyify.py +++ b/spotifyify/spotifyify.py @@ -1,6 +1,6 @@ from typing import Any, Self -from collections.abc import AsyncIterator, Iterable, Iterator +from collections.abc import AsyncGenerator, Generator, Iterable from contextlib import asynccontextmanager, contextmanager from contextvars import ContextVar, Token @@ -77,7 +77,7 @@ async def close(self) -> None: await self._oauth.close() @contextmanager - def retry_hook(self, hook: OnRetryHook) -> Iterator[None]: + def retry_hook(self, hook: OnRetryHook) -> Generator[None, None, None]: token = current_retry_hook.set(hook) try: yield @@ -90,7 +90,7 @@ async def session( *, access_token: str | None = None, on_retry: OnRetryHook | None = None, - ) -> AsyncIterator[None]: + ) -> AsyncGenerator[None, None]: """Scope user-specific calls with a supplied token, otherwise use app auth.""" ctx_tokens: list[tuple[ContextVar[Any], Token]] = [] if access_token is not None: @@ -106,7 +106,7 @@ async def session( context_var.reset(token) @asynccontextmanager - async def user_token(self, access_token: str) -> AsyncIterator[None]: + async def user_token(self, access_token: str) -> AsyncGenerator[None, None]: async with self.session(access_token=access_token): yield diff --git a/spotifyify/utils.py b/spotifyify/utils.py index 773f83f..53ddeb5 100644 --- a/spotifyify/utils.py +++ b/spotifyify/utils.py @@ -1,4 +1,10 @@ -from collections.abc import Iterable +import functools +import inspect +import sys +from collections.abc import Callable, Iterable +from typing import Any, TypeVar + +F = TypeVar("F", bound=Callable[..., Any]) def coalesce_items(ids_or_uris: Iterable[str]) -> list[str]: @@ -7,3 +13,24 @@ def coalesce_items(ids_or_uris: Iterable[str]) -> list[str]: def coalesce_csv(ids_or_uris: Iterable[str]) -> str: return ",".join(coalesce_items(ids_or_uris)) + + +def deprecated(reason: str) -> Callable[[F], F]: + def decorator(func: F) -> F: + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + print(f"warning: {reason}", file=sys.stderr) + return await func(*args, **kwargs) + + return async_wrapper # type: ignore[return-value] + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + print(f"warning: {reason}", file=sys.stderr) + return func(*args, **kwargs) + + return wrapper # type: ignore[return-value] + + return decorator diff --git a/tests/cli_harness.py b/tests/cli_harness.py new file mode 100644 index 0000000..9a47c52 --- /dev/null +++ b/tests/cli_harness.py @@ -0,0 +1,61 @@ +"""Shared harness for CLI tests: drive the real Typer app over a fake client.""" + +from __future__ import annotations + +import json +import unittest +from unittest.mock import patch + +from spotifyify import cli + + +class FakeSpotifyify: + """Stands in for the real client inside the CLI's async runner.""" + + namespaces: dict = {} + instances: list[FakeSpotifyify] = [] + + def __init__(self, **kwargs): + self.scopes = list(kwargs.get("scopes") or []) + FakeSpotifyify.instances.append(self) + for name, value in self.namespaces.items(): + setattr(self, name, value) + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc_info): + return False + + +class CliTestCase(unittest.TestCase): + """Invokes the installed commands with only the Spotify client replaced.""" + + def setUp(self): + if cli.typer is None: + self.skipTest("typer is optional") + from typer.testing import CliRunner + + # Commands that wait for playback to settle poll with a real delay; a + # fake client answers instantly, so the wait is pure test runtime. + delay = patch("spotifyify.cli.core.SETTLE_DELAY_SECONDS", 0) + delay.start() + self.addCleanup(delay.stop) + + self.runner = CliRunner() + + def run_cli(self, args, namespaces=None, env=None): + FakeSpotifyify.namespaces = namespaces or {} + FakeSpotifyify.instances = [] + with patch("spotifyify.cli.core.Spotifyify", FakeSpotifyify): + return self.runner.invoke(cli.app, args, env=env) + + def run_json(self, args, namespaces=None, env=None): + """Run a command that is expected to succeed and parse its JSON.""" + result = self.run_cli(args, namespaces, env=env) + self.assertEqual(result.exit_code, 0, result.output) + return json.loads(result.output) + + def requested_scopes(self): + """Scopes the command asked the client for, in request order.""" + return [scope for client in FakeSpotifyify.instances for scope in client.scopes] diff --git a/tests/http/test_response.py b/tests/http/test_response.py index 47014de..f9bb20e 100644 --- a/tests/http/test_response.py +++ b/tests/http/test_response.py @@ -35,6 +35,12 @@ def test_204_returns_none(self): def test_empty_content_returns_none(self): self.assertIsNone(parse_response(self._make_response(200))) + def test_non_json_success_body_is_not_an_error(self): + # Some playback endpoints answer 200 with an opaque, non-JSON body. + response = self._make_response(200, content=b"0YcilXzZl6EEW9kWEytjWC7bsX4") + + self.assertIsNone(parse_response(response)) + def test_successful_json_is_returned(self): self.assertEqual( parse_response(self._make_response(200, json_data={"tracks": []})), diff --git a/tests/test_cli.py b/tests/test_cli.py index 2da09cd..1d771b9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,13 @@ +import json import pathlib import tomllib import unittest +from types import SimpleNamespace from unittest.mock import patch -from spotifyify import SpotifyScope from spotifyify import cli +from spotifyify.schemas import PlaybackState +from tests.cli_harness import CliTestCase class TestCli(unittest.TestCase): @@ -27,26 +30,9 @@ def test_main_explains_missing_optional_dependency(self): self.assertEqual(str(raised.exception), cli.INSTALL_MESSAGE) - def test_parse_scopes_accepts_repeated_and_csv_values(self): - result = cli._parse_scopes( - [ - "user-read-playback-state,playlist-read-private", - "custom-scope", - ] - ) - - self.assertEqual( - result, - [ - SpotifyScope.USER_READ_PLAYBACK_STATE, - SpotifyScope.PLAYLIST_READ_PRIVATE, - "custom-scope", - ], - ) - def test_split_values_accepts_repeated_and_csv_values(self): self.assertEqual( - cli._split_values(["a,b", "c d"]), + cli.split_values(["a,b", "c d"]), ["a", "b", "c", "d"], ) @@ -63,21 +49,10 @@ def test_filter_fields_supports_nested_paths_and_lists(self): } self.assertEqual( - cli._filter_fields(payload["items"], ["id", "album.name"]), + cli.filter_fields(payload["items"], ["id", "album.name"]), [{"id": "track_id", "album.name": "Album"}], ) - self.assertEqual(cli._get_path(payload, "items.0.name"), "Track") - - def test_table_formats_headers_and_rows(self): - table = cli._table(("ID", "Name"), [["1", "Track"], ["22", "Other"]]) - - self.assertEqual( - table, - "ID Name \n-- -----\n1 Track\n22 Other", - ) - - def test_table_handles_empty_results(self): - self.assertEqual(cli._table(("ID",), []), "No results.") + self.assertEqual(cli.get_path(payload, "items.0.name"), "Track") def test_typer_app_registers_all_namespace_groups_when_available(self): if cli.typer is None: @@ -101,3 +76,407 @@ def test_typer_app_registers_all_namespace_groups_when_available(self): "users", }, ) + + +class TestOutputContract(unittest.TestCase): + """The CLI always emits JSON, driven only by the declared columns.""" + + def test_rows_keep_declared_column_order_and_flatten_nested_objects(self): + payload = { + "items": [ + { + "id": "t1", + "name": "Track", + "artists": [{"name": "A"}, {"name": "B"}], + "album": {"name": "Album"}, + "duration_ms": 1000, + } + ] + } + + rows = cli.rows(payload, ("id", "name", "artists", "album.name", "duration_ms")) + + self.assertEqual( + list(rows[0]), + ["id", "name", "artists", "album.name", "duration_ms"], + ) + self.assertEqual(rows[0]["artists"], ["A", "B"]) + self.assertEqual(rows[0]["album.name"], "Album") + # Numbers stay numbers rather than being stringified. + self.assertEqual(rows[0]["duration_ms"], 1000) + + def test_cells_never_contain_control_characters(self): + rows = cli.rows([{"name": "a\tb\nc\x1b[31m"}], ("name",)) + + self.assertNotIn("\t", cli.cell(rows[0]["name"])) + self.assertNotIn("\n", cli.cell(rows[0]["name"])) + self.assertNotIn("\x1b", cli.cell(rows[0]["name"])) + + +class TestStableOrdering(unittest.TestCase): + def test_sort_is_stable_so_ties_keep_api_order(self): + items = [ + {"name": "same", "id": "first"}, + {"name": "same", "id": "second"}, + {"name": "same", "id": "third"}, + ] + + ordered = cli.sort_items(items, ["name"]) + + self.assertEqual([item["id"] for item in ordered], ["first", "second", "third"]) + + def test_sort_handles_missing_values_without_raising(self): + items = [{"n": 2}, {"n": None}, {"n": 1}, {}] + + ordered = cli.sort_items(items, ["n"]) + + # Numbers first in order, absent values last. + self.assertEqual([item.get("n") for item in ordered], [1, 2, None, None]) + + def test_descending_sort_uses_a_leading_dash(self): + items = [{"n": 1}, {"n": 3}, {"n": 2}] + + ordered = cli.sort_items(items, ["-n"]) + + self.assertEqual([item["n"] for item in ordered], [3, 2, 1]) + + def test_sort_applies_inside_a_paging_envelope(self): + payload = {"total": 2, "items": [{"n": 2}, {"n": 1}]} + + result = cli.apply_sort(payload, ["n"]) + + self.assertEqual(result["items"], [{"n": 1}, {"n": 2}]) + self.assertEqual(result["total"], 2) + + +class TestPlaybackSummary(unittest.TestCase): + def test_summary_of_no_playback_is_stopped(self): + summary = cli.playback_summary(None) + + self.assertEqual(summary["state"], "stopped") + self.assertEqual(summary["artists"], []) + + def test_summary_reports_state_track_artists_and_device(self): + state = PlaybackState.model_validate( + { + "is_playing": True, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": "HAMPELMANN", + "artists": [{"name": "Ikkimel"}], + }, + } + ) + + summary = cli.playback_summary(state) + + self.assertEqual(summary["state"], "playing") + self.assertEqual(summary["track"], "HAMPELMANN") + self.assertEqual(summary["artists"], ["Ikkimel"]) + self.assertEqual(summary["device"], "Wohnzimmer") + + def test_paused_playback_is_reported_as_paused(self): + state = PlaybackState.model_validate({"is_playing": False}) + + self.assertEqual(cli.playback_summary(state)["state"], "paused") + + +class TestBatching(unittest.TestCase): + def test_ids_are_chunked_to_the_endpoint_limit(self): + from spotifyify.cli.core import _chunked + + self.assertEqual(_chunked(["a", "b", "c"], 2), [["a", "b"], ["c"]]) + self.assertEqual(_chunked([], 2), []) + + +class TestCommandNaming(unittest.TestCase): + def setUp(self): + if cli.typer is None: + self.skipTest("typer is optional") + import click + + self.root = cli.typer.main.get_command(cli.app) + self.ctx = click.Context(self.root) + + def _resolves(self, name): + return self.root.get_command(self.ctx, name) + + def test_resource_groups_use_plural_names(self): + resource_groups = ( + "albums", + "artists", + "episodes", + "playlists", + "shows", + "tracks", + "users", + ) + for name in resource_groups: + self.assertIsNotNone(self._resolves(name)) + + def test_singular_group_names_do_not_resolve(self): + self.assertIsNone(self._resolves("artist")) + self.assertIsNone(self._resolves("track")) + + def test_unknown_names_still_fail(self): + self.assertIsNone(self._resolves("definitely-not-a-command")) + + def test_get_is_the_only_bulk_fetch_command_name(self): + import click + + artists = self._resolves("artists") + context = click.Context(artists) + + self.assertIsNotNone(artists.get_command(context, "get")) + self.assertIsNone(artists.get_command(context, "get-many")) + + +class TestEndToEnd(CliTestCase): + def _run(self, args, namespaces, env=None): + return self.run_cli(args, namespaces, env=env) + + def test_search_prints_declared_columns_as_json_by_default(self): + async def find(query, **kwargs): + return { + "items": [ + { + "id": "t1", + "name": "WHO'S THAT", + "artists": [{"name": "Ikkimel"}], + "album": {"name": "WHO'S THAT"}, + "uri": "spotify:track:t1", + "available_markets": ["DE"] * 100, + } + ] + } + + result = self._run( + ["tracks", "search", "Ikkimel"], + {"tracks": SimpleNamespace(find=find)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + rows = json.loads(result.output) + self.assertEqual(list(rows[0]), ["id", "name", "artists", "album.name", "uri"]) + # The noisy payload fields never reach the caller. + self.assertNotIn("available_markets", result.output) + + def test_raw_returns_the_untouched_payload(self): + async def find(query, **kwargs): + return {"items": [{"id": "t1", "available_markets": ["DE"]}], "total": 1} + + result = self._run( + ["tracks", "search", "x"], + {"tracks": SimpleNamespace(find=find)}, + env={"SPOTIFYIFY_RAW": "1"}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("available_markets", result.output) + self.assertEqual(json.loads(result.output)["total"], 1) + + def test_player_state_raw_returns_the_unprojected_playback_payload(self): + async def state(**kwargs): + return PlaybackState.model_validate( + { + "is_playing": True, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": "HAMPELMANN", + "artists": [{"name": "Ikkimel"}], + }, + } + ) + + result = self._run( + ["player", "state"], + {"player": SimpleNamespace(state=state)}, + env={"SPOTIFYIFY_RAW": "1"}, + ) + + payload = json.loads(result.output) + self.assertEqual(result.exit_code, 0, result.output) + self.assertTrue(payload["is_playing"]) + self.assertEqual(payload["item"]["name"], "HAMPELMANN") + self.assertNotIn("state", payload) + + def test_a_playback_mutation_prints_the_resulting_state(self): + played = {} + + async def play(**kwargs): + played.update(kwargs) + + async def state(**kwargs): + return PlaybackState.model_validate( + { + "is_playing": True, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": "HAMPELMANN", + "artists": [{"name": "Ikkimel"}], + }, + } + ) + + result = self._run( + ["player", "play", "--uri", "spotify:track:t1"], + {"player": SimpleNamespace(play=play, state=state)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + rows = json.loads(result.output) + self.assertEqual( + rows, + [ + { + "state": "playing", + "track": "HAMPELMANN", + "artists": ["Ikkimel"], + "device": "Wohnzimmer", + } + ], + ) + self.assertEqual(played["uris"], ["spotify:track:t1"]) + + def test_get_accepts_many_ids_in_one_call(self): + calls = [] + + async def get_many(ids, **kwargs): + calls.append(list(ids)) + return [{"id": item_id, "name": item_id} for item_id in ids] + + result = self._run( + ["tracks", "get", "a,b", "c"], + {"tracks": SimpleNamespace(get_many=get_many)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(calls, [["a", "b", "c"]]) + self.assertEqual(len(json.loads(result.output)), 3) + + def test_top_level_play_resolves_then_plays(self): + played = {} + + async def find(query, **kwargs): + find.query = query + return SimpleNamespace(items=[SimpleNamespace(uri="spotify:track:t1")]) + + async def play(**kwargs): + played.update(kwargs) + + async def state(**kwargs): + return PlaybackState.model_validate( + { + "is_playing": True, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": "WHO'S THAT", + "artists": [{"name": "Ikkimel"}], + }, + } + ) + + result = self._run( + ["play", "--artist", "Ikkimel", "--track", "WHO'S THAT"], + { + "tracks": SimpleNamespace(find=find), + "player": SimpleNamespace(play=play, state=state), + }, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(find.query, 'track:"WHO\'S THAT" artist:"Ikkimel"') + self.assertEqual(played["uris"], ["spotify:track:t1"]) + self.assertEqual(json.loads(result.output)[0]["track"], "WHO'S THAT") + + def test_play_waits_for_the_requested_track_not_the_previous_one(self): + # Spotify applies playback asynchronously, so the first read can still + # describe whatever was playing before. + states = [ + { + "is_playing": True, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": "Amber Dusk", + "uri": "spotify:track:old", + "artists": [{"name": "Caelestis Nati"}], + }, + }, + { + "is_playing": True, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": "HAMPELMANN", + "uri": "spotify:track:new", + "artists": [{"name": "Ikkimel"}], + }, + }, + ] + + async def play(**kwargs): + pass + + async def state(**kwargs): + return PlaybackState.model_validate(states.pop(0) if states else states) + + result = self._run( + ["player", "play", "--uri", "spotify:track:new"], + {"player": SimpleNamespace(play=play, state=state)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(json.loads(result.output)[0]["track"], "HAMPELMANN") + + def test_no_wait_reports_immediately(self): + reads = [] + + async def play(**kwargs): + pass + + async def state(**kwargs): + reads.append(1) + return PlaybackState.model_validate( + {"is_playing": True, "item": {"type": "track", "name": "Old"}} + ) + + result = self._run( + ["player", "play", "--uri", "spotify:track:new", "--no-wait"], + {"player": SimpleNamespace(play=play, state=state)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(len(reads), 1) + + def test_top_level_play_needs_something_to_search_for(self): + result = self._run(["play"], {}) + + self.assertNotEqual(result.exit_code, 0) + + def test_unknown_command_fails_normally(self): + result = self.runner.invoke(cli.app, ["artists", "lookup"]) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn("No such command", result.output) + self.assertNotIn("Available:", result.output) + + def test_output_carries_no_ansi_escapes(self): + async def find(query, **kwargs): + return {"items": [{"id": "t1", "name": "Track"}]} + + result = self._run( + ["tracks", "search", "x"], + {"tracks": SimpleNamespace(find=find)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertNotIn("\x1b", result.output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_core.py b/tests/test_cli_core.py new file mode 100644 index 0000000..e93bbfc --- /dev/null +++ b/tests/test_cli_core.py @@ -0,0 +1,389 @@ +import asyncio +import os +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from spotifyify import SpotifyScope +from spotifyify.cli import core +from spotifyify.schemas import PlaybackState +from tests.conftest import episode, simplified_show + + +class TestParseJsonObject(unittest.TestCase): + def setUp(self): + if core.typer is None: + self.skipTest("typer is optional") + + def test_no_value_means_no_object(self): + self.assertIsNone(core.parse_json_object(None, "--offset-json")) + self.assertIsNone(core.parse_json_object("", "--offset-json")) + + def test_an_object_is_returned_as_a_dict(self): + self.assertEqual( + core.parse_json_object('{"position": 3}', "--offset-json"), + {"position": 3}, + ) + + def test_malformed_json_names_the_option_it_came_from(self): + with self.assertRaises(core.typer.BadParameter) as raised: + core.parse_json_object("{position: 3}", "--offset-json") + + self.assertIn("--offset-json", str(raised.exception)) + + def test_json_that_is_not_an_object_is_rejected(self): + # A bare array parses fine but would build a request body Spotify rejects. + with self.assertRaises(core.typer.BadParameter): + core.parse_json_object("[1, 2]", "--offset-json") + + def test_without_typer_the_same_input_raises_a_plain_value_error(self): + with patch.object(core, "typer", None): + with self.assertRaises(ValueError): + core.parse_json_object("[1, 2]", "--offset-json") + with self.assertRaises(ValueError): + core.parse_json_object("{position: 3}", "--offset-json") + + +class TestMergeScopes(unittest.TestCase): + def test_duplicates_collapse_and_first_mention_fixes_the_order(self): + merged = core.merge_scopes( + [SpotifyScope.USER_MODIFY_PLAYBACK_STATE], + [ + SpotifyScope.USER_READ_PLAYBACK_STATE, + SpotifyScope.USER_MODIFY_PLAYBACK_STATE, + ], + ) + + self.assertEqual( + merged, + [ + SpotifyScope.USER_MODIFY_PLAYBACK_STATE, + SpotifyScope.USER_READ_PLAYBACK_STATE, + ], + ) + + def test_no_groups_means_no_scopes(self): + self.assertEqual(core.merge_scopes(), []) + + +class TestDefaults(unittest.TestCase): + """--market/--device-id from the root command, then the environment.""" + + def setUp(self): + core.set_default_market(None) + core.set_default_device_id(None) + self.addCleanup(core.set_default_market, None) + self.addCleanup(core.set_default_device_id, None) + + def test_market_falls_back_to_the_environment(self): + with patch.dict(os.environ, {core.MARKET_ENV_VAR: "DE"}): + self.assertEqual(core.default_market(), "DE") + + def test_the_root_flag_wins_over_the_environment(self): + core.set_default_market("US") + + with patch.dict(os.environ, {core.MARKET_ENV_VAR: "DE"}): + self.assertEqual(core.default_market(), "US") + + def test_an_empty_environment_value_counts_as_unset(self): + with patch.dict(os.environ, {core.MARKET_ENV_VAR: ""}): + self.assertIsNone(core.default_market()) + + def test_device_id_follows_the_same_precedence(self): + with patch.dict(os.environ, {core.DEVICE_ENV_VAR: "from-env"}): + self.assertEqual(core.default_device_id(), "from-env") + core.set_default_device_id("from-flag") + self.assertEqual(core.default_device_id(), "from-flag") + + def test_nothing_configured_means_no_default(self): + with patch.dict(os.environ, {}, clear=True): + self.assertIsNone(core.default_market()) + self.assertIsNone(core.default_device_id()) + + def test_raw_output_is_opt_in(self): + with patch.dict(os.environ, {}, clear=True): + self.assertFalse(core.is_raw_output()) + with patch.dict(os.environ, {core.RAW_ENV_VAR: "0"}): + self.assertFalse(core.is_raw_output()) + with patch.dict(os.environ, {core.RAW_ENV_VAR: "1"}): + self.assertTrue(core.is_raw_output()) + + +class TestProjection(unittest.TestCase): + def test_a_queue_envelope_is_unwrapped_like_a_paging_one(self): + payload = {"queue": [{"id": "t1", "name": "Track"}]} + + self.assertEqual( + core.rows(payload, ("id", "name")), [{"id": "t1", "name": "Track"}] + ) + + def test_a_single_object_becomes_one_row(self): + self.assertEqual(core.rows({"id": "t1"}, ("id",)), [{"id": "t1"}]) + + def test_no_payload_means_no_rows(self): + self.assertEqual(core.rows(None, ("id",)), []) + + def test_missing_paths_are_reported_as_null_rather_than_dropped(self): + # Every row keeps the same keys, so callers can index the output blindly. + rows = core.rows([{"id": "t1"}], ("id", "album.name")) + + self.assertEqual(rows, [{"id": "t1", "album.name": None}]) + + def test_an_object_without_a_name_or_id_keeps_its_fields(self): + rows = core.rows( + [{"external_urls": {"spotify": "https://x"}}], ("external_urls",) + ) + + self.assertEqual(rows[0]["external_urls"], {"spotify": "https://x"}) + + def test_filter_fields_without_fields_passes_the_value_through(self): + payload = [{"id": "t1", "name": "Track"}] + + self.assertIs(core.filter_fields(payload, []), payload) + + def test_get_path_walks_into_pydantic_models(self): + state = PlaybackState.model_validate( + {"is_playing": True, "device": {"name": "Wohnzimmer"}} + ) + + self.assertEqual(core.get_path(state, "device.name"), "Wohnzimmer") + + def test_get_path_stops_at_an_out_of_range_index(self): + self.assertIsNone(core.get_path({"items": []}, "items.0.name")) + + def test_cells_render_booleans_and_lists_readably(self): + self.assertEqual(core.cell(True), "true") + self.assertEqual(core.cell(False), "false") + self.assertEqual(core.cell(None), "") + self.assertEqual(core.cell([{"name": "A"}, {"name": "B"}]), "A, B") + + +class TestSorting(unittest.TestCase): + def test_a_queue_envelope_is_sorted_in_place_of_items(self): + payload = {"queue": [{"n": 2}, {"n": 1}]} + + self.assertEqual(core.apply_sort(payload, ["n"])["queue"], [{"n": 1}, {"n": 2}]) + + def test_later_keys_break_ties_left_by_earlier_ones(self): + items = [ + {"artist": "B", "name": "1"}, + {"artist": "A", "name": "2"}, + {"artist": "A", "name": "1"}, + ] + + ordered = core.sort_items(items, ["artist", "name"]) + + self.assertEqual( + [(item["artist"], item["name"]) for item in ordered], + [("A", "1"), ("A", "2"), ("B", "1")], + ) + + def test_mixed_types_sort_without_raising(self): + items = [{"n": "text"}, {"n": 2}, {"n": None}, {"n": True}] + + ordered = core.sort_items(items, ["n"]) + + # Numbers (and booleans) first, then text, then absent values. + self.assertEqual([item["n"] for item in ordered], [True, 2, "text", None]) + + def test_text_sorts_case_insensitively(self): + items = [{"n": "beta"}, {"n": "Alpha"}] + + self.assertEqual( + [item["n"] for item in core.sort_items(items, ["n"])], ["Alpha", "beta"] + ) + + def test_an_empty_sort_spec_is_ignored(self): + payload = {"items": [{"n": 2}, {"n": 1}]} + + self.assertIs(core.apply_sort(payload, []), payload) + + +class TestBatching(unittest.IsolatedAsyncioTestCase): + async def test_reads_are_chunked_and_concatenated_in_request_order(self): + chunks = [] + + async def action(chunk): + chunks.append(list(chunk)) + return [f"item:{value}" for value in chunk] + + result = await core.gather_batches(action, list("abcde"), 2) + + self.assertEqual(chunks, [["a", "b"], ["c", "d"], ["e"]]) + self.assertEqual(result, [f"item:{value}" for value in "abcde"]) + + async def test_read_batches_run_concurrently(self): + in_flight = 0 + peak = 0 + + async def action(chunk): + nonlocal in_flight, peak + in_flight += 1 + peak = max(peak, in_flight) + await asyncio.sleep(0) + in_flight -= 1 + return chunk + + await core.gather_batches(action, list("abcd"), 1) + + self.assertEqual(peak, 4) + + async def test_no_ids_means_no_request(self): + calls = [] + + async def action(chunk): + calls.append(chunk) + return chunk + + self.assertEqual(await core.gather_batches(action, [], 10), []) + await core.sequential_batches(action, [], 10) + + self.assertEqual(calls, []) + + async def test_writes_finish_one_chunk_before_starting_the_next(self): + # Partial failures have to stay comprehensible, so writes never overlap. + events = [] + + async def action(chunk): + events.append(("start", tuple(chunk))) + await asyncio.sleep(0) + events.append(("done", tuple(chunk))) + + await core.sequential_batches(action, list("abc"), 2) + + self.assertEqual( + events, + [ + ("start", ("a", "b")), + ("done", ("a", "b")), + ("start", ("c",)), + ("done", ("c",)), + ], + ) + + +def _state(**overrides): + payload = {"is_playing": True, "progress_ms": 0} + payload.update(overrides) + return PlaybackState.model_validate(payload) + + +class TestPlaybackPredicates(unittest.TestCase): + def test_nothing_playing_satisfies_no_predicate(self): + self.assertFalse(core.is_playing(None)) + self.assertFalse(core.is_paused(None)) + self.assertFalse(core.is_fresh_track(None)) + + def test_a_track_already_in_progress_is_not_fresh(self): + self.assertFalse(core.is_fresh_track(_state(progress_ms=60_000))) + self.assertTrue(core.is_fresh_track(_state(progress_ms=1_000))) + + def test_plays_uri_rejects_the_track_that_was_already_playing(self): + matches = core.plays_uri("spotify:track:new") + previous = _state(item={"type": "track", "uri": "spotify:track:old"}) + + self.assertFalse(matches(previous)) + self.assertTrue( + matches(_state(item={"type": "track", "uri": "spotify:track:new"})) + ) + + def test_without_a_uri_any_freshly_started_track_counts(self): + self.assertIs(core.plays_uri(None), core.is_fresh_track) + + +class TestSettledPlayback(unittest.IsolatedAsyncioTestCase): + def setUp(self): + patcher = patch.object(core, "SETTLE_DELAY_SECONDS", 0) + patcher.start() + self.addCleanup(patcher.stop) + + def _spotify(self, states): + reads = [] + + async def state(): + reads.append(1) + return states[min(len(reads) - 1, len(states) - 1)] + + return SimpleNamespace(player=SimpleNamespace(state=state)), reads + + async def test_polling_stops_as_soon_as_the_predicate_holds(self): + spotify, reads = self._spotify([_state(is_playing=False), _state()]) + + result = await core.settled_playback(spotify, until=core.is_playing) + + self.assertEqual(len(reads), 2) + self.assertTrue(result.is_playing) + + async def test_polling_gives_up_and_reports_what_it_last_saw(self): + spotify, reads = self._spotify([_state(is_playing=False)]) + + result = await core.settled_playback(spotify, until=core.is_playing) + + self.assertEqual(len(reads), core.SETTLE_ATTEMPTS) + self.assertFalse(result.is_playing) + + async def test_no_wait_reads_once_even_with_a_predicate(self): + spotify, reads = self._spotify([_state(is_playing=False)]) + + await core.settled_playback(spotify, until=core.is_playing, wait=False) + + self.assertEqual(len(reads), 1) + + async def test_without_a_predicate_there_is_nothing_to_wait_for(self): + spotify, reads = self._spotify([_state(is_playing=False)]) + + await core.settled_playback(spotify) + + self.assertEqual(len(reads), 1) + + +class TestPlaybackSummary(unittest.TestCase): + def test_an_episode_reports_its_publisher_and_show(self): + state = PlaybackState.model_validate( + { + "is_playing": True, + "item": episode( + type="episode", + name="Folge 1", + show=simplified_show(name="Der Podcast", publisher="ARD"), + ), + } + ) + + summary = core.playback_summary(state) + + # Episodes have no artists, so the publisher fills the same column. + self.assertEqual(summary["artists"], ["ARD"]) + self.assertEqual(summary["album"], "Der Podcast") + self.assertEqual(summary["track"], "Folge 1") + + def test_every_summary_has_the_same_keys(self): + state = PlaybackState.model_validate( + {"is_playing": True, "item": {"type": "track", "name": "Track"}} + ) + + self.assertEqual( + core.playback_summary(state).keys(), core.playback_summary(None).keys() + ) + + def test_progress_shuffle_and_repeat_reach_the_summary(self): + state = PlaybackState.model_validate( + { + "is_playing": True, + "progress_ms": 4200, + "shuffle_state": True, + "repeat_state": "context", + "item": {"type": "track", "name": "Track", "duration_ms": 180_000}, + } + ) + + summary = core.playback_summary(state) + + self.assertEqual(summary["progress_ms"], 4200) + self.assertEqual(summary["duration_ms"], 180_000) + self.assertTrue(summary["shuffle"]) + self.assertEqual(summary["repeat"], "context") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_library.py b/tests/test_cli_library.py new file mode 100644 index 0000000..44e6776 --- /dev/null +++ b/tests/test_cli_library.py @@ -0,0 +1,219 @@ +import unittest +from types import SimpleNamespace + +try: + import typer # noqa: F401 +except ModuleNotFoundError: + raise unittest.SkipTest("typer is an optional CLI dependency") from None + +from spotifyify.cli.core import BATCH_ALBUMS, BATCH_FOLLOW, BATCH_TRACKS +from tests.cli_harness import CliTestCase + + +class LibraryTestCase(CliTestCase): + def library(self, *, saved=None): + """A library namespace that records every write and read it receives.""" + self.written = [] + self.checked = [] + + def writer(name): + async def write(ids): + self.written.append((name, list(ids))) + + return write + + def checker(name): + async def check(ids): + self.checked.append((name, list(ids))) + return [saved if saved is not None else True] * len(ids) + + return check + + return { + "library": SimpleNamespace( + save_tracks=writer("save_tracks"), + remove_tracks=writer("remove_tracks"), + check_tracks=checker("check_tracks"), + save_albums=writer("save_albums"), + remove_albums=writer("remove_albums"), + check_albums=checker("check_albums"), + ) + } + + +class TestSaveAndRemove(LibraryTestCase): + def test_ids_beyond_spotifys_limit_are_split_across_requests(self): + ids = [f"t{index}" for index in range(BATCH_TRACKS + 1)] + + self.run_json(["library", "save-tracks", ",".join(ids)], self.library()) + + chunks = [chunk for name, chunk in self.written if name == "save_tracks"] + self.assertEqual([len(chunk) for chunk in chunks], [BATCH_TRACKS, 1]) + self.assertEqual([item for chunk in chunks for item in chunk], ids) + + def test_albums_use_their_own_smaller_limit(self): + ids = [f"a{index}" for index in range(BATCH_ALBUMS + 1)] + + self.run_json(["library", "save-albums", ",".join(ids)], self.library()) + + chunks = [chunk for name, chunk in self.written if name == "save_albums"] + self.assertEqual([len(chunk) for chunk in chunks], [BATCH_ALBUMS, 1]) + + def test_a_single_id_makes_a_single_request(self): + self.run_json(["library", "save-tracks", "t1"], self.library()) + + self.assertEqual(self.written, [("save_tracks", ["t1"])]) + + def test_the_write_is_read_back_so_the_result_is_the_real_state(self): + rows = self.run_json(["library", "save-tracks", "t1,t2"], self.library()) + + self.assertEqual(self.checked, [("check_tracks", ["t1", "t2"])]) + self.assertEqual( + rows, [{"id": "t1", "saved": True}, {"id": "t2", "saved": True}] + ) + + def test_removing_reports_the_resulting_state_too(self): + rows = self.run_json( + ["library", "remove-tracks", "t1"], self.library(saved=False) + ) + + self.assertEqual(self.written, [("remove_tracks", ["t1"])]) + self.assertEqual(rows, [{"id": "t1", "saved": False}]) + + def test_checking_alone_never_writes(self): + rows = self.run_json(["library", "check-tracks", "t1 t2"], self.library()) + + self.assertEqual(self.written, []) + self.assertEqual([row["id"] for row in rows], ["t1", "t2"]) + + def test_write_and_read_scopes_are_both_requested(self): + from spotifyify import SpotifyScope + + self.run_json(["library", "save-tracks", "t1"], self.library()) + + self.assertEqual( + set(self.requested_scopes()), + {SpotifyScope.USER_LIBRARY_MODIFY, SpotifyScope.USER_LIBRARY_READ}, + ) + + def test_a_check_alone_asks_only_for_the_read_scope(self): + from spotifyify import SpotifyScope + + self.run_json(["library", "check-tracks", "t1"], self.library()) + + self.assertEqual(self.requested_scopes(), [SpotifyScope.USER_LIBRARY_READ]) + + +class TestSavedStatePairing(LibraryTestCase): + def test_ids_keep_their_own_flag(self): + async def check_tracks(ids): + return [True, False, True] + + namespaces = self.library() + namespaces["library"].check_tracks = check_tracks + + rows = self.run_json(["library", "check-tracks", "a,b,c"], namespaces) + + self.assertEqual( + rows, + [ + {"id": "a", "saved": True}, + {"id": "b", "saved": False}, + {"id": "c", "saved": True}, + ], + ) + + def test_a_short_answer_never_pairs_an_id_with_the_wrong_flag(self): + # Better to report fewer rows than to shift flags onto the wrong ids. + async def check_tracks(ids): + return [True] + + namespaces = self.library() + namespaces["library"].check_tracks = check_tracks + + rows = self.run_json(["library", "check-tracks", "a,b,c"], namespaces) + + self.assertEqual(rows, [{"id": "a", "saved": True}]) + + +class TestFollowing(CliTestCase): + def users(self): + self.written = [] + + def writer(name): + async def write(type, ids): + self.written.append((name, type, list(ids))) + + return write + + async def check_following(type, ids): + self.checked = (type, list(ids)) + return [True] * len(ids) + + return { + "users": SimpleNamespace( + follow=writer("follow"), + unfollow=writer("unfollow"), + check_following=check_following, + ) + } + + def test_the_type_is_forwarded_with_every_chunk(self): + ids = [f"a{index}" for index in range(BATCH_FOLLOW + 1)] + + self.run_json(["users", "follow", "artist", ",".join(ids)], self.users()) + + self.assertEqual( + [len(chunk) for _, _, chunk in self.written], [BATCH_FOLLOW, 1] + ) + self.assertEqual({type for _, type, _ in self.written}, {"artist"}) + + def test_following_is_read_back_after_the_write(self): + rows = self.run_json(["users", "follow", "artist", "a1"], self.users()) + + self.assertEqual(self.checked, ("artist", ["a1"])) + self.assertEqual(rows, [{"id": "a1", "following": True}]) + + def test_unfollow_uses_the_unfollow_endpoint(self): + self.run_json(["users", "unfollow", "artist", "a1"], self.users()) + + self.assertEqual(self.written, [("unfollow", "artist", ["a1"])]) + + +class TestBulkFetch(CliTestCase): + def test_a_bulk_read_is_split_and_rejoined_in_order(self): + requested = [] + + async def get_many(ids, **kwargs): + requested.append(list(ids)) + return [{"id": item_id, "name": item_id} for item_id in ids] + + ids = [f"t{index}" for index in range(BATCH_TRACKS + 2)] + + rows = self.run_json( + ["tracks", "get", ",".join(ids)], + {"tracks": SimpleNamespace(get_many=get_many)}, + ) + + self.assertEqual([len(chunk) for chunk in requested], [BATCH_TRACKS, 2]) + self.assertEqual([row["id"] for row in rows], ids) + + def test_the_market_reaches_every_chunk(self): + markets = [] + + async def get_many(ids, **kwargs): + markets.append(kwargs.get("market")) + return [{"id": item_id} for item_id in ids] + + ids = [f"t{index}" for index in range(BATCH_TRACKS + 1)] + + self.run_json( + ["--market", "DE", "tracks", "get", ",".join(ids)], + {"tracks": SimpleNamespace(get_many=get_many)}, + ) + + self.assertEqual(markets, ["DE", "DE"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_options.py b/tests/test_cli_options.py new file mode 100644 index 0000000..e782b77 --- /dev/null +++ b/tests/test_cli_options.py @@ -0,0 +1,221 @@ +import json +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +try: + import typer # noqa: F401 +except ModuleNotFoundError: + raise unittest.SkipTest("typer is an optional CLI dependency") from None + +from spotifyify.cli import options +from spotifyify.exceptions import SpotifyAPIError, SpotifyAuthError, SpotifyifyError +from tests.cli_harness import CliTestCase + + +class TestErrorTranslation(CliTestCase): + """Expected API failures become exit codes, not tracebacks.""" + + def _failing(self, error): + async def find(query, **kwargs): + raise error + + return {"tracks": SimpleNamespace(find=find)} + + def test_an_api_error_exits_one_with_the_message_on_stderr(self): + result = self.run_cli( + ["tracks", "search", "x"], + self._failing(SpotifyAPIError(404, "Not found")), + ) + + self.assertEqual(result.exit_code, options.EXIT_API_ERROR) + self.assertIn("404: Not found", result.output) + + def test_an_auth_error_exits_three_so_callers_can_re_authenticate(self): + result = self.run_cli( + ["tracks", "search", "x"], + self._failing(SpotifyAuthError("token expired")), + ) + + self.assertEqual(result.exit_code, options.EXIT_AUTH_ERROR) + self.assertIn("token expired", result.output) + + def test_a_rate_limit_error_is_reported_as_an_api_error(self): + from spotifyify.exceptions import SpotifyRateLimitError + + result = self.run_cli( + ["tracks", "search", "x"], + self._failing(SpotifyRateLimitError("slow down", retry_after=1.0)), + ) + + self.assertEqual(result.exit_code, options.EXIT_API_ERROR) + + def test_the_two_error_exit_codes_stay_distinguishable(self): + self.assertNotEqual(options.EXIT_API_ERROR, options.EXIT_AUTH_ERROR) + + def test_a_failing_command_prints_no_partial_json(self): + result = self.run_cli( + ["tracks", "search", "x"], + self._failing(SpotifyAPIError(500, "boom")), + ) + + self.assertNotIn("[", result.stdout) + + def test_unexpected_errors_are_not_swallowed(self): + # Only the failures the CLI has a contract for get translated; anything + # else has to surface instead of masquerading as a clean exit. + async def find(query, **kwargs): + raise SpotifyifyError("something unmodelled") + + result = self.run_cli( + ["tracks", "search", "x"], + {"tracks": SimpleNamespace(find=find)}, + ) + + self.assertIsInstance(result.exception, SpotifyifyError) + + +class TestFieldSelection(CliTestCase): + def _tracks(self): + async def find(query, **kwargs): + return { + "items": [ + { + "id": "t1", + "name": "Track", + "artists": [{"name": "Ikkimel"}], + "album": {"name": "Album"}, + "uri": "spotify:track:t1", + "popularity": 73, + } + ] + } + + return {"tracks": SimpleNamespace(find=find)} + + def test_fields_replace_the_declared_columns(self): + rows = self.run_json(["tracks", "search", "x", "--field", "id"], self._tracks()) + + self.assertEqual(rows, [{"id": "t1"}]) + + def test_fields_can_be_repeated_or_comma_separated(self): + repeated = self.run_json( + ["tracks", "search", "x", "-f", "id", "-f", "name"], self._tracks() + ) + joined = self.run_json( + ["tracks", "search", "x", "-f", "id,name"], self._tracks() + ) + + self.assertEqual(repeated, joined) + self.assertEqual(list(repeated[0]), ["id", "name"]) + + def test_fields_reach_beyond_the_declared_columns(self): + rows = self.run_json( + ["tracks", "search", "x", "-f", "popularity"], self._tracks() + ) + + self.assertEqual(rows, [{"popularity": 73}]) + + def test_field_order_follows_the_command_line(self): + rows = self.run_json(["tracks", "search", "x", "-f", "name,id"], self._tracks()) + + self.assertEqual(list(rows[0]), ["name", "id"]) + + def test_fields_select_from_the_playback_summary_not_the_raw_state(self): + from spotifyify.schemas import PlaybackState + + async def state(**kwargs): + return PlaybackState.model_validate( + {"is_playing": True, "item": {"type": "track", "name": "HAMPELMANN"}} + ) + + rows = self.run_json( + ["player", "state", "-f", "state,track"], + {"player": SimpleNamespace(state=state)}, + ) + + self.assertEqual(rows, [{"state": "playing", "track": "HAMPELMANN"}]) + + def test_raw_output_ignores_the_field_selection(self): + result = self.run_cli( + ["tracks", "search", "x", "-f", "id"], + self._tracks(), + env={"SPOTIFYIFY_RAW": "1"}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("popularity", result.output) + + +class TestPrintResult(unittest.TestCase): + def test_projection_runs_before_the_columns_are_selected(self): + printed = [] + + with patch.object(options, "print_json", printed.append): + options.print_result( + {"is_playing": True}, + columns=("state",), + project=lambda value: {"state": "playing"}, + ) + + self.assertEqual(printed, [[{"state": "playing"}]]) + + def test_output_is_valid_json_even_with_no_items(self): + printed = [] + + with patch.object(options, "print_json", printed.append): + options.print_result({"items": []}, columns=("id",)) + + self.assertEqual(printed, [[]]) + + +class TestLimitBounds(CliTestCase): + def _tracks(self, seen): + async def find(query, **kwargs): + seen.update(kwargs) + return {"items": []} + + return {"tracks": SimpleNamespace(find=find)} + + def test_the_limit_reaches_the_client(self): + seen = {} + + self.run_json(["tracks", "search", "x", "--limit", "50"], self._tracks(seen)) + + self.assertEqual(seen["limit"], 50) + + def test_a_limit_beyond_what_spotify_accepts_is_rejected_before_any_call(self): + seen = {} + + result = self.run_cli( + ["tracks", "search", "x", "--limit", "51"], self._tracks(seen) + ) + + self.assertNotEqual(result.exit_code, 0) + self.assertEqual(seen, {}) + + def test_a_zero_limit_is_rejected(self): + result = self.run_cli( + ["tracks", "search", "x", "--limit", "0"], self._tracks({}) + ) + + self.assertNotEqual(result.exit_code, 0) + + +class TestOutputEncoding(CliTestCase): + def test_non_ascii_names_are_emitted_unescaped(self): + async def find(query, **kwargs): + return {"items": [{"id": "t1", "name": "Grüße 東京"}]} + + result = self.run_cli( + ["tracks", "search", "x", "-f", "name"], + {"tracks": SimpleNamespace(find=find)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("Grüße 東京", result.output) + self.assertEqual(json.loads(result.output)[0]["name"], "Grüße 東京") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_player.py b/tests/test_cli_player.py new file mode 100644 index 0000000..51a6489 --- /dev/null +++ b/tests/test_cli_player.py @@ -0,0 +1,358 @@ +import json +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, call + +try: + import typer # noqa: F401 +except ModuleNotFoundError: + raise unittest.SkipTest("typer is an optional CLI dependency") from None + +from spotifyify.cli.player import _play_with_device_fallback, _select_fallback_device +from spotifyify.exceptions import SpotifyAPIError +from spotifyify.schemas import Device, PlaybackState +from tests.cli_harness import CliTestCase + + +class TestDeviceFallback(unittest.IsolatedAsyncioTestCase): + def test_selects_active_then_computer_then_name(self): + devices = [ + Device(id="tv", name="Living Room", type="TV"), + Device(id="z", name="Workstation", type="Computer"), + Device(id="a", name="Desktop", type="Computer"), + Device( + id="restricted", + name="Restricted", + type="Computer", + is_restricted=True, + ), + ] + + self.assertEqual(_select_fallback_device(devices).id, "a") + + devices[0].is_active = True + self.assertEqual(_select_fallback_device(devices).id, "tv") + + def test_returns_none_without_a_controllable_device(self): + devices = [ + Device(id=None, name="Missing ID"), + Device(id="restricted", is_restricted=True), + ] + + self.assertIsNone(_select_fallback_device(devices)) + + async def test_discovers_and_retries_on_no_active_device(self): + player = SimpleNamespace( + play=AsyncMock( + side_effect=[ + SpotifyAPIError( + 404, "Player command failed: No active device found" + ), + None, + ] + ), + devices=AsyncMock( + return_value=[ + Device(id="tv", name="Living Room", type="TV"), + Device(id="computer", name="Desktop", type="Computer"), + ] + ), + ) + spotify = SimpleNamespace(player=player) + + await _play_with_device_fallback( + spotify, + device_id=None, + uris=["spotify:track:123"], + ) + + player.devices.assert_awaited_once_with() + self.assertEqual( + player.play.await_args_list, + [ + call( + device_id=None, + context_uri=None, + uris=["spotify:track:123"], + offset=None, + position_ms=None, + ), + call( + device_id="computer", + context_uri=None, + uris=["spotify:track:123"], + offset=None, + position_ms=None, + ), + ], + ) + + async def test_does_not_override_an_explicit_device(self): + error = SpotifyAPIError(404, "No active device found") + player = SimpleNamespace( + play=AsyncMock(side_effect=error), + devices=AsyncMock(), + ) + spotify = SimpleNamespace(player=player) + + with self.assertRaises(SpotifyAPIError) as raised: + await _play_with_device_fallback(spotify, device_id="chosen") + + self.assertIs(raised.exception, error) + player.devices.assert_not_awaited() + + async def test_reraises_when_discovery_has_no_controllable_device(self): + error = SpotifyAPIError(404, "No active device found") + player = SimpleNamespace( + play=AsyncMock(side_effect=error), + devices=AsyncMock( + return_value=[Device(id="restricted", is_restricted=True)] + ), + ) + spotify = SimpleNamespace(player=player) + + with self.assertRaises(SpotifyAPIError) as raised: + await _play_with_device_fallback(spotify, device_id=None) + + self.assertIs(raised.exception, error) + self.assertEqual(player.play.await_count, 1) + + async def test_other_failures_are_not_mistaken_for_a_missing_device(self): + error = SpotifyAPIError(403, "Premium required") + player = SimpleNamespace(play=AsyncMock(side_effect=error), devices=AsyncMock()) + spotify = SimpleNamespace(player=player) + + with self.assertRaises(SpotifyAPIError) as raised: + await _play_with_device_fallback(spotify, device_id=None) + + self.assertIs(raised.exception, error) + player.devices.assert_not_awaited() + + +def _state(**overrides): + payload = {"is_playing": True, "progress_ms": 0, "device": {"name": "Wohnzimmer"}} + payload.update(overrides) + return PlaybackState.model_validate(payload) + + +class PlayerCommandTestCase(CliTestCase): + def player(self, states=None, **overrides): + """A player namespace recording its calls, answering with `states`.""" + self.calls = [] + answers = list(states or [_state()]) + + def recorder(name): + async def command(*args, **kwargs): + self.calls.append((name, args, kwargs)) + + return command + + async def state(**kwargs): + self.calls.append(("state", (), kwargs)) + return answers.pop(0) if len(answers) > 1 else answers[0] + + namespace = SimpleNamespace( + play=recorder("play"), + pause=recorder("pause"), + skip=recorder("skip"), + previous=recorder("previous"), + seek=recorder("seek"), + shuffle=recorder("shuffle"), + repeat=recorder("repeat"), + volume=recorder("volume"), + add_to_queue=recorder("add_to_queue"), + transfer=recorder("transfer"), + state=state, + ) + for name, value in overrides.items(): + setattr(namespace, name, value) + return {"player": namespace} + + def call_names(self): + return [name for name, _, _ in self.calls] + + +class TestPlaybackReporting(PlayerCommandTestCase): + def test_nothing_playing_is_reported_as_stopped(self): + async def state(**kwargs): + return None + + rows = self.run_json(["player", "state"], self.player(state=state)) + + self.assertEqual( + rows, [{"state": "stopped", "track": "", "artists": [], "device": ""}] + ) + + def test_pause_waits_for_playback_to_actually_stop(self): + states = [_state(), _state(is_playing=False)] + + rows = self.run_json(["player", "pause"], self.player(states)) + + self.assertEqual(self.call_names().count("state"), 2) + self.assertEqual(rows[0]["state"], "paused") + + def test_skip_waits_for_a_freshly_started_track(self): + states = [_state(progress_ms=90_000), _state(progress_ms=100)] + + self.run_json(["player", "skip"], self.player(states)) + + self.assertEqual(self.call_names(), ["skip", "state", "state"]) + + def test_volume_and_seek_report_state_without_waiting_for_it(self): + # There is nothing to wait for: neither changes what is playing. + self.run_json(["player", "volume", "40"], self.player()) + self.assertEqual(self.call_names(), ["volume", "state"]) + + self.run_json(["player", "seek", "1000"], self.player()) + self.assertEqual(self.call_names(), ["seek", "state"]) + + def test_transfer_with_play_waits_for_playback_to_start(self): + states = [_state(is_playing=False), _state()] + + rows = self.run_json( + ["player", "transfer", "kitchen", "--play"], self.player(states) + ) + + self.assertEqual(self.call_names().count("state"), 2) + self.assertEqual(rows[0]["state"], "playing") + + def test_transfer_without_play_has_nothing_to_wait_for(self): + self.run_json( + ["player", "transfer", "kitchen"], self.player([_state(is_playing=False)]) + ) + + self.assertEqual(self.call_names(), ["transfer", "state"]) + + +class TestPlayerArguments(PlayerCommandTestCase): + def test_the_root_device_flag_targets_every_playback_command(self): + self.run_json(["--device-id", "kitchen", "player", "pause"], self.player()) + + self.assertEqual(self.calls[0][2]["device_id"], "kitchen") + + def test_the_device_environment_variable_does_the_same(self): + self.run_json( + ["player", "pause"], self.player(), env={"SPOTIFYIFY_DEVICE_ID": "kitchen"} + ) + + self.assertEqual(self.calls[0][2]["device_id"], "kitchen") + + def test_uris_can_be_repeated_or_comma_separated(self): + self.run_json( + ["player", "play", "--uri", "spotify:track:a,spotify:track:b"], + self.player(), + ) + + self.assertEqual( + self.calls[0][2]["uris"], ["spotify:track:a", "spotify:track:b"] + ) + + def test_a_json_offset_is_forwarded_as_an_object(self): + self.run_json( + [ + "player", + "play", + "--context-uri", + "spotify:album:a1", + "--offset-json", + '{"position": 3}', + ], + self.player(), + ) + + self.assertEqual(self.calls[0][2]["offset"], {"position": 3}) + + def test_a_malformed_offset_is_rejected_before_anything_plays(self): + result = self.run_cli( + ["player", "play", "--offset-json", "{position: 3}"], self.player() + ) + + self.assertNotEqual(result.exit_code, 0) + self.assertEqual(self.calls, []) + + def test_a_negative_position_is_rejected(self): + result = self.run_cli(["player", "seek", "--", "-1"], self.player()) + + self.assertNotEqual(result.exit_code, 0) + self.assertEqual(self.calls, []) + + def test_a_volume_above_one_hundred_is_rejected(self): + result = self.run_cli(["player", "volume", "101"], self.player()) + + self.assertNotEqual(result.exit_code, 0) + self.assertEqual(self.calls, []) + + +class TestQueueing(PlayerCommandTestCase): + def test_every_uri_is_queued_in_the_order_it_was_given(self): + self.run_json( + [ + "player", + "add-to-queue", + "spotify:track:a,spotify:track:b", + "spotify:track:c", + ], + self.player(), + ) + + queued = [args[0] for name, args, _ in self.calls if name == "add_to_queue"] + self.assertEqual( + queued, ["spotify:track:a", "spotify:track:b", "spotify:track:c"] + ) + + def test_the_queue_is_listed_with_its_declared_columns(self): + async def queue(): + return { + "queue": [ + { + "id": "t1", + "name": "HAMPELMANN", + "artists": [{"name": "Ikkimel"}], + "uri": "spotify:track:t1", + "available_markets": ["DE"], + } + ] + } + + rows = self.run_json(["player", "queue"], self.player(queue=queue)) + + self.assertEqual(list(rows[0]), ["id", "name", "artists", "uri"]) + self.assertEqual(rows[0]["artists"], ["Ikkimel"]) + + +class TestDeviceListing(PlayerCommandTestCase): + DEVICES = [ + Device(id="z", name="Workstation", type="Computer"), + Device(id="a", name="Desktop", type="Computer"), + ] + + def devices(self): + async def devices(): + return list(self.DEVICES) + + return self.player(devices=devices) + + def test_devices_are_ordered_reproducibly(self): + # Spotify returns devices in no defined order, so repeated calls (and + # anything cached on top of them) would otherwise differ run to run. + rows = self.run_json(["player", "devices"], self.devices()) + + self.assertEqual([row["id"] for row in rows], ["a", "z"]) + + def test_raw_output_keeps_the_order_spotify_sent(self): + result = self.run_cli( + ["player", "devices"], self.devices(), env={"SPOTIFYIFY_RAW": "1"} + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual( + [device["id"] for device in json.loads(result.output)], ["z", "a"] + ) + + def test_no_devices_is_an_empty_list_not_an_error(self): + async def devices(): + return [] + + rows = self.run_json(["player", "devices"], self.player(devices=devices)) + + self.assertEqual(rows, []) diff --git a/tests/test_cli_quick.py b/tests/test_cli_quick.py new file mode 100644 index 0000000..79b15d3 --- /dev/null +++ b/tests/test_cli_quick.py @@ -0,0 +1,273 @@ +import unittest +from types import SimpleNamespace + +try: + import typer # noqa: F401 +except ModuleNotFoundError: + raise unittest.SkipTest("typer is an optional CLI dependency") from None + +from spotifyify.cli import quick +from spotifyify.schemas import PlaybackState +from tests.cli_harness import CliTestCase + + +class TestQueryBuilding(unittest.TestCase): + def test_filters_are_emitted_in_track_artist_album_order(self): + query = quick._build_query( + None, track="WHO'S THAT", artist="Ikkimel", album="Chaos" + ) + + self.assertEqual(query, 'track:"WHO\'S THAT" artist:"Ikkimel" album:"Chaos"') + + def test_free_text_follows_the_filters(self): + query = quick._build_query( + ["live", "session"], track=None, artist="Ikkimel", album=None + ) + + self.assertEqual(query, 'artist:"Ikkimel" live session') + + def test_quotes_inside_a_value_cannot_break_out_of_the_filter(self): + # An unescaped quote would end the filter and turn the rest into free + # text, silently searching for something else entirely. + query = quick._build_query(None, track='a" artist:"b', artist=None, album=None) + + self.assertEqual(query, 'track:"a artist: b"') + self.assertEqual(query.count('"'), 2) + + def test_surrounding_whitespace_is_trimmed(self): + self.assertEqual( + quick._build_query(None, track=" Track ", artist=None, album=None), + 'track:"Track"', + ) + + def test_nothing_given_produces_an_empty_query(self): + self.assertEqual( + quick._build_query(None, track=None, artist=None, album=None), "" + ) + self.assertEqual( + quick._build_query([], track=None, artist=None, album=None), "" + ) + + def test_first_hit_of_an_empty_result_is_none(self): + self.assertIsNone(quick._first(SimpleNamespace(items=[]))) + self.assertIsNone(quick._first(SimpleNamespace(items=None))) + self.assertIsNone(quick._first(None)) + + +def _playing(name="HAMPELMANN", uri="spotify:track:t1"): + return PlaybackState.model_validate( + { + "is_playing": True, + "progress_ms": 0, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": name, + "uri": uri, + "artists": [{"name": "Ikkimel"}], + }, + } + ) + + +class QuickPlayTestCase(CliTestCase): + """Records what the top-level play command resolved and started.""" + + def namespaces(self, *, tracks=None, albums=None, artists=None): + self.searched = {} + self.played = {} + + def finder(kind, hit): + async def find(query, **kwargs): + self.searched[kind] = {"query": query, **kwargs} + return SimpleNamespace(items=[hit] if hit is not None else []) + + return find + + async def play(**kwargs): + self.played.update(kwargs) + + async def state(**kwargs): + return _playing() + + return { + "tracks": SimpleNamespace(find=finder("tracks", tracks)), + "albums": SimpleNamespace(find=finder("albums", albums)), + "artists": SimpleNamespace(find=finder("artists", artists)), + "player": SimpleNamespace(play=play, state=state), + } + + +class TestWhatGetsPlayed(QuickPlayTestCase): + def test_a_track_name_plays_that_one_track(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["play", "--track", "WHO'S THAT"], namespaces) + + self.assertEqual(self.played["uris"], ["spotify:track:t1"]) + self.assertIsNone(self.played["context_uri"]) + + def test_free_text_is_treated_as_a_track_search(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["play", "hampelmann"], namespaces) + + self.assertEqual(self.searched["tracks"]["query"], "hampelmann") + self.assertEqual(self.played["uris"], ["spotify:track:t1"]) + + def test_an_album_without_a_track_becomes_the_playback_context(self): + namespaces = self.namespaces(albums=SimpleNamespace(uri="spotify:album:a1")) + + self.run_json(["play", "--album", "Chaos"], namespaces) + + self.assertEqual(self.played["context_uri"], "spotify:album:a1") + self.assertIsNone(self.played["uris"]) + self.assertNotIn("tracks", self.searched) + + def test_an_artist_alone_becomes_the_playback_context(self): + namespaces = self.namespaces(artists=SimpleNamespace(uri="spotify:artist:a1")) + + self.run_json(["play", "--artist", "Ikkimel"], namespaces) + + self.assertEqual(self.played["context_uri"], "spotify:artist:a1") + self.assertIsNone(self.played["uris"]) + + def test_a_track_narrowed_by_artist_still_plays_the_track(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json( + ["play", "--artist", "Ikkimel", "--track", "WHO'S THAT"], namespaces + ) + + self.assertEqual( + self.searched["tracks"]["query"], 'track:"WHO\'S THAT" artist:"Ikkimel"' + ) + self.assertEqual(self.played["uris"], ["spotify:track:t1"]) + + def test_an_album_narrowed_by_free_text_plays_the_track(self): + # Free text names one thing, so the album is only a filter here. + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["play", "hampelmann", "--album", "Chaos"], namespaces) + + self.assertEqual(self.searched["tracks"]["query"], 'album:"Chaos" hampelmann') + self.assertEqual(self.played["uris"], ["spotify:track:t1"]) + + def test_only_one_hit_is_fetched(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["play", "--track", "x"], namespaces) + + self.assertEqual(self.searched["tracks"]["limit"], 1) + + +class TestQuickPlayFailures(QuickPlayTestCase): + def test_no_match_exits_four_and_never_starts_playback(self): + namespaces = self.namespaces(tracks=None) + + result = self.run_cli(["play", "--track", "nothing at all"], namespaces) + + self.assertEqual(result.exit_code, quick.EXIT_NO_MATCH) + self.assertIn("No match", result.output) + self.assertEqual(self.played, {}) + + def test_an_artist_search_without_a_hit_also_exits_four(self): + result = self.run_cli(["play", "--artist", "nobody"], self.namespaces()) + + self.assertEqual(result.exit_code, quick.EXIT_NO_MATCH) + + def test_nothing_to_search_for_is_a_usage_error(self): + result = self.run_cli(["play"], self.namespaces()) + + self.assertNotEqual(result.exit_code, 0) + self.assertEqual(self.searched, {}) + + +class TestQuickPlayDefaults(QuickPlayTestCase): + def test_the_root_market_flag_reaches_the_search(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["--market", "DE", "play", "--track", "x"], namespaces) + + self.assertEqual(self.searched["tracks"]["market"], "DE") + + def test_the_market_environment_variable_reaches_the_search(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json( + ["play", "--track", "x"], namespaces, env={"SPOTIFYIFY_MARKET": "DE"} + ) + + self.assertEqual(self.searched["tracks"]["market"], "DE") + + def test_the_root_device_flag_targets_playback(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["--device-id", "kitchen", "play", "--track", "x"], namespaces) + + self.assertEqual(self.played["device_id"], "kitchen") + + def test_an_artist_search_is_not_narrowed_by_market(self): + # Spotify's artist search takes no market, so passing one would 400. + namespaces = self.namespaces(artists=SimpleNamespace(uri="spotify:artist:a1")) + + self.run_json(["--market", "DE", "play", "--artist", "Ikkimel"], namespaces) + + self.assertNotIn("market", self.searched["artists"]) + + +class TestQuickPlayReporting(QuickPlayTestCase): + def test_the_resolved_track_is_reported_not_the_previous_one(self): + states = [_playing("Amber Dusk", "spotify:track:old"), _playing()] + + async def state(**kwargs): + return states.pop(0) if len(states) > 1 else states[0] + + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + namespaces["player"].state = state + + rows = self.run_json(["play", "--track", "x"], namespaces) + + self.assertEqual(rows[0]["track"], "HAMPELMANN") + + def test_no_wait_reports_the_first_read(self): + reads = [] + + async def state(**kwargs): + reads.append(1) + return _playing("Amber Dusk", "spotify:track:old") + + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + namespaces["player"].state = state + + rows = self.run_json(["play", "--track", "x", "--no-wait"], namespaces) + + self.assertEqual(len(reads), 1) + self.assertEqual(rows[0]["track"], "Amber Dusk") + + def test_playback_columns_are_reported(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + rows = self.run_json(["play", "--track", "x"], namespaces) + + self.assertEqual(list(rows[0]), ["state", "track", "artists", "device"]) + + def test_it_asks_for_both_playback_scopes(self): + from spotifyify import SpotifyScope + + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["play", "--track", "x"], namespaces) + + # Reporting the resulting state needs the read scope alongside modify. + self.assertEqual( + set(self.requested_scopes()), + { + SpotifyScope.USER_MODIFY_PLAYBACK_STATE, + SpotifyScope.USER_READ_PLAYBACK_STATE, + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 11fa526..7afd90b 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "spotifyify" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "httpx" },