Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ name: CI

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
test:
Expand All @@ -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
193 changes: 154 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
44 changes: 44 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -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) |
Loading
Loading