Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

107 changed files with 2087 additions and 7921 deletions

View File

@ -13,7 +13,7 @@ jobs:
with:
go-version-file: go.mod
- run: go mod download
- uses: golangci/golangci-lint-action@v7
- uses: golangci/golangci-lint-action@v6
with:
version: v2.7.2
- run: ./scripts/check-coverage.sh 90

View File

@ -1,54 +0,0 @@
name: pages
on:
push:
branches:
- main
paths:
- "docs/**"
- "scripts/build-docs-site.mjs"
- "scripts/docs-site-assets.mjs"
- "Makefile"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Deploy docs
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check out
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
- name: Build docs site
run: make docs-site
- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Upload artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: dist/docs-site
- name: Deploy
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0

View File

@ -44,62 +44,3 @@ jobs:
args: release --clean --config /tmp/.goreleaser.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-homebrew-tap:
runs-on: ubuntu-latest
needs: goreleaser
steps:
- name: Resolve release tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "RELEASE_TAG=${{ inputs.tag }}" >> "$GITHUB_ENV"
else
echo "RELEASE_TAG=${{ github.ref_name }}" >> "$GITHUB_ENV"
fi
- name: Dispatch tap formula update
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to steipete/homebrew-tap"
exit 1
fi
request_id="spogo-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
expected_title="Update spogo for ${RELEASE_TAG} (${request_id})"
gh workflow run update-formula.yml \
--repo steipete/homebrew-tap \
--ref main \
-f formula=spogo \
-f tag="$RELEASE_TAG" \
-f repository=steipete/spogo \
-f artifact_template="{formula}_{version}_{target}.tar.gz" \
-f request_id="$request_id"
run_id=""
for _ in {1..30}; do
run_id=$(gh run list \
--repo steipete/homebrew-tap \
--workflow update-formula.yml \
--branch main \
--event workflow_dispatch \
--limit 20 \
--json databaseId,displayTitle \
--jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1)
if [ -n "$run_id" ]; then
break
fi
sleep 5
done
if [ -z "$run_id" ]; then
echo "::error::Could not find tap workflow run with title: $expected_title"
exit 1
fi
gh run watch "$run_id" \
--repo steipete/homebrew-tap \
--exit-status \
--interval 10

View File

@ -1,4 +1,4 @@
version: "2"
version: 2
run:
timeout: 5m
@ -20,3 +20,8 @@ linters:
- nilerr
- nilnil
- durationcheck
linters-settings: {}
issues:
exclude-use-default: false

View File

@ -11,19 +11,25 @@ builds:
binary: spogo
env:
- CGO_ENABLED=0
targets:
- linux_amd64
- linux_arm64
- windows_amd64
- windows_arm64
goos:
- linux
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
- id: spogo_darwin
main: ./cmd/spogo
binary: spogo
env:
- CGO_ENABLED=1
targets:
- darwin_amd64
- darwin_arm64
goos:
- darwin
goarch:
- amd64
- arm64
archives:
- builds:

View File

@ -1,28 +1,5 @@
# Changelog
## Unreleased
## 0.3.0 - 2026-05-05
- Add `auth paste`, wire `--no-input`, and improve cookie diagnostics/cleanup (`#5`, thanks @im-zayan)
- Add `play --shuffle`, Connect library/playlist support, and context-aware Connect play payloads (`#15`, thanks @StandardGage)
- Fix Connect track artist extraction for nested artist containers and minimal artist fragments (`#7`, thanks @joelbdavies)
- Fix silent `auth import` failures by retrying Spotify auth cookie lookup across related hosts and surfacing browser warnings (`#13`)
- Fix `device set` when Connect state has no origin device by falling back to Web API transfer (`#8`)
- Fix Connect liked-track listing via `fetchLibraryTracks` with Web API fallback on payload drift (`#16`, thanks @masonc15)
- Fix Connect play when no device is active by falling back to Web API playback (`#21`, thanks @prashanthbala)
- Fix Connect volume changes by sending the volume endpoint as `PUT` (`#24`, thanks @cavit99)
- Fix sparse status/search metadata so track artists and albums are populated consistently across engines.
- Fix Connect `--device` playback when no device is active without falling back to rate-limited Web API playback.
- Fix `auth paste --no-input` by accepting the documented flag order.
- Fix playlist add/remove 429s by using Connect playlist mutations with writable-playlist checks and fallback coverage across engines (`#20`).
- Release prep: bump CLI/spec version to `0.3.0`
## 0.2.0 - 2026-01-07
- Add `applescript` engine for direct Spotify.app control on macOS (thanks @adam91holt)
- CI: bump golangci-lint-action to support golangci-lint v2
## 0.1.0 - 2026-01-02
- Kong-powered CLI with global flags, config profiles, and env overrides

View File

@ -1,7 +1,4 @@
.PHONY: spogo docs-site
.PHONY: spogo
spogo:
go build -o spogo ./cmd/spogo
docs-site:
@node scripts/build-docs-site.mjs

View File

@ -65,7 +65,7 @@ Global flags:
- `--market <cc>` market country code
- `--language <tag>` language/locale (default `en`)
- `--device <name|id>` target device
- `--engine <auto|web|connect|applescript>` API engine (default `connect`, `applescript` is macOS-only)
- `--engine <auto|web|connect>` API engine (default `connect`)
- `--json` / `--plain`
- `--no-color`
- `-q, --quiet` / `-v, --verbose` / `-d, --debug`
@ -77,10 +77,10 @@ Env overrides:
Commands:
- `auth status|import|paste|clear`
- `auth status|import|clear`
- `search track|album|artist|playlist|show|episode`
- `track info`, `album info`, `artist info`, `playlist info`, `show info`, `episode info`
- `play [<id|url>] [--type ...] [--shuffle]`, `pause`, `next`, `prev`, `seek`, `volume`, `shuffle`, `repeat`, `status`
- `play [<id|url>] [--type ...]`, `pause`, `next`, `prev`, `seek`, `volume`, `shuffle`, `repeat`, `status`
- `queue add|show`
- `library tracks|albums|artists|playlists`
- `playlist create|add|remove|tracks`
@ -97,25 +97,6 @@ spogo auth import --browser chrome
```
Defaults: Chrome + Default profile. Cookies are stored under your config directory (per profile).
If import still fails, `spogo` now surfaces browser-store warnings instead of only printing `no cookies found`.
### Manual cookie paste (WSL fallback)
If WSL cookie import/decryption is broken, paste cookies from Chrome DevTools:
1) Developer Tools -> Application tab -> Cookies -> `https://open.spotify.com`
2) Copy `sp_dc` (required), `sp_key` (optional), `sp_t` (recommended for connect playback)
3) Run:
```bash
spogo auth paste
```
Non-interactive:
```bash
printf '%s\n%s\n' "sp_dc=..." "sp_t=..." | spogo auth paste --no-input
```
## Auto engine notes

View File

@ -1,7 +1,6 @@
package main
import (
"context"
"fmt"
"io"
"os"
@ -34,7 +33,6 @@ func run(args []string, out io.Writer, errOut io.Writer) int {
_, _ = fmt.Fprintln(errOut, err)
return 2
}
args = normalizeArgs(args)
kctx, err := parser.Parse(args)
if exitCode >= 0 {
return exitCode
@ -53,7 +51,6 @@ func run(args []string, out io.Writer, errOut io.Writer) int {
_, _ = fmt.Fprintln(errOut, err)
return 1
}
ctx.SetCommandContext(context.Background())
if err := ctx.ValidateProfile(); err != nil {
_, _ = fmt.Fprintln(errOut, err)
return 2
@ -64,25 +61,3 @@ func run(args []string, out io.Writer, errOut io.Writer) int {
}
return 0
}
func normalizeArgs(args []string) []string {
if len(args) == 0 {
return args
}
front := make([]string, 0, 1)
rest := make([]string, 0, len(args))
for _, arg := range args {
if arg == "--no-input" {
front = append(front, arg)
continue
}
rest = append(rest, arg)
}
if len(front) == 0 {
return args
}
normalized := make([]string, 0, len(args))
normalized = append(normalized, front...)
normalized = append(normalized, rest...)
return normalized
}

View File

@ -91,14 +91,6 @@ func TestRunAuthStatus(t *testing.T) {
}
}
func TestNormalizeArgsMovesNoInput(t *testing.T) {
got := normalizeArgs([]string{"auth", "paste", "--no-input", "--cookie-path", "cookies.json"})
want := []string{"--no-input", "auth", "paste", "--cookie-path", "cookies.json"}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestMain(t *testing.T) {
origArgs := os.Args
origExit := exitFunc

View File

@ -1 +0,0 @@
spogo.sh

View File

@ -1,150 +0,0 @@
---
title: Agents & Automation
description: "Use spogo from shell scripts, CI, cron, and AI coding agents — patterns, exit codes, and safety."
---
# Agents & Automation
spogo is built to be driven by something other than a human at a terminal — shell scripts, cron jobs, CI pipelines, AI coding agents. The same properties that make it pleasant interactively (stable JSON, plain mode, predictable exit codes, stderr-only logs) also make it safe to script.
## The contract
A script-friendly CLI must guarantee:
- **Stable output.** spogo's `--json` and `--plain` modes don't change keys/columns between releases without a major bump.
- **Stable exit codes.** `0` success, `1` generic, `2` usage, `3` auth, `4` network. Branch on these.
- **stdout is data.** stderr carries everything else. Pipes always work.
- **No interactive surprises.** When stdin is not a TTY, spogo refuses to prompt. Pass `--no-input` to be explicit.
- **No tight rate limits.** Cookie auth via Connect avoids the public Web API throttle.
See [Output](output.md) for the full contract.
## Wiring spogo into a shell script
```bash
#!/usr/bin/env bash
set -euo pipefail
# Make sure auth still works
if ! spogo auth status >/dev/null 2>&1; then
echo "spogo: cookies missing or stale; re-run 'spogo auth import'" >&2
exit 3
fi
# Capture the currently playing track ID
track_id=$(spogo status --json | jq -r '.item.id // empty')
if [[ -z "$track_id" ]]; then
echo "Nothing playing" >&2
exit 0
fi
# Save it
spogo library tracks add "$track_id"
```
Pin to a specific spogo version in CI to avoid silent JSON drift across releases.
## Common patterns
### Save the currently playing track
```bash
id=$(spogo status --json | jq -r '.item.id')
spogo library tracks add "$id"
```
### Build a playlist from a search
```bash
spogo playlist create "Weekly Lo-Fi"
spogo search track "lo-fi 2026" --limit 30 --plain |
awk '{print $1}' |
xargs spogo playlist add "Weekly Lo-Fi"
```
### Snapshot library to JSON
```bash
spogo library tracks list --limit 1000 --json > snapshots/tracks.$(date +%F).json
```
### Move playback to a specific room when leaving home
```bash
spogo device set "Phone"
```
### "Sleep timer" — pause after N minutes
```bash
sleep "${1:-1800}" && spogo pause
```
## Cron / launchd / systemd
spogo writes nothing to stdout that isn't useful and nothing to stderr unless something happened — perfect for cron tail logs.
```cron
# Snapshot liked tracks daily at 04:00
0 4 * * * /usr/local/bin/spogo library tracks list --limit 1000 --json > "$HOME/snapshots/tracks-$(date +\%F).json" 2>&1
```
For headless servers / CI runners, copy a working cookie jar (from a machine where you ran `auth import`) into the runner's spogo config directory rather than trying to import from a browser that doesn't exist.
## CI
GitHub Actions example:
```yaml
- name: Install spogo
run: brew install steipete/tap/spogo
- name: Restore cookies
run: |
mkdir -p "$HOME/.config/spogo/default"
echo "$SPOGO_COOKIES" > "$HOME/.config/spogo/default/cookies.json"
env:
SPOGO_COOKIES: ${{ secrets.SPOGO_COOKIES }}
- name: Snapshot library
run: spogo library tracks list --json --limit 1000 > tracks.json
```
Treat the cookie jar like a credential — it's tied to your Spotify session.
## Coding agents
spogo is a good fit for AI coding agents (Claude Code, Codex, Cursor) because:
- **Self-documenting.** `spogo --help` and `spogo <subcommand> --help` describe the entire surface. The [Spec](spec.md) is short and stable.
- **Deterministic.** Stable JSON keys mean the agent's parsing doesn't drift across releases.
- **Safe-ish.** The destructive surface is small (`library tracks remove`, `playlist remove`, `auth clear`). Wrap those behind explicit confirmation in your agent prompt.
Recommended agent rules:
- Always pass `--json` or `--plain` for output the agent will parse.
- Always pass `--no-input` so spogo cannot block on a prompt.
- Branch on exit code, not stderr text.
- Pin spogo version in the agent's environment.
A starter system prompt fragment for an agent:
> You can use the `spogo` CLI to control Spotify. Always pass `--json` and `--no-input`. Read `spogo --help` and `spogo <cmd> --help` before invoking unfamiliar commands. Treat exit code `3` as "needs auth" — surface that to the user, don't try to recover automatically.
## Safety
spogo has no built-in command allowlist or read-only mode. If you're handing it to an unattended process or untrusted agent and want to restrict it:
- Run inside a separate spogo profile (`SPOGO_PROFILE=automation`) with cookies for an account that has limited permissions.
- Wrap spogo in a thin shell script that whitelists subcommands.
- Use the `applescript` engine on macOS — no cookies, no remote mutations possible (only local app control).
## Debugging an automation
Always re-run with `-v` (or `-d` for full HTTP traces) when something misbehaves — diagnostic output goes to stderr and won't pollute pipelines:
```bash
spogo -d status 2>spogo.log | jq .
```
See [Troubleshooting](troubleshooting.md).

View File

@ -1,132 +0,0 @@
---
title: Auth
description: "How spogo authenticates with Spotify using your browser cookies — import, paste, status, troubleshooting."
---
# Auth
spogo does not use the Spotify Developer API. It reads the cookies your browser already has for `open.spotify.com` and uses them to fetch a web access token. That means **no app registration, no client ID, no redirect URI** — just log in to Spotify in your browser, then import.
The cookie machinery comes from [steipete/sweetcookie](https://github.com/steipete/sweetcookie).
## What spogo needs
The minimum cookies for authentication:
- `sp_dc` — required. Long-lived web session cookie.
- `sp_key` — optional, helps with rotation.
- `sp_t` — recommended for `connect` engine playback control.
These cookies live in your browser's cookie store and rotate on their own; spogo refreshes its cached access token using them as needed.
## Importing from a browser
```bash
spogo auth import --browser chrome
```
Defaults: Chrome + `Default` profile + `spotify.com` domain. Cookies are stored under your config directory keyed by profile.
### Pick a different browser
```bash
spogo auth import --browser brave
spogo auth import --browser edge
spogo auth import --browser firefox
spogo auth import --browser safari
```
### Pick a non-default profile
Chrome / Brave / Edge keep profiles in directories like `Default`, `Profile 1`, `Profile 2`. Pass the directory name:
```bash
spogo auth import --browser chrome --browser-profile "Profile 1"
```
### Use a specific cookie store file
If you have an exported cookie jar already:
```bash
spogo auth import --cookie-path /path/to/cookies.sqlite
```
### Limit the cookie scope
```bash
spogo auth import --domain spotify.com
```
When the browser-store read returns nothing, spogo now surfaces the underlying warning (locked keychain, missing profile, decryption failure) instead of just printing `no cookies found`.
## Manual paste (WSL fallback)
If WSL cookie decryption is broken, or you need to copy cookies from a Chromium DevTools session, paste the values straight in:
1. In Chrome, open DevTools → Application → Cookies → `https://open.spotify.com`.
2. Copy the values for `sp_dc` (required), `sp_key` (optional), `sp_t` (recommended).
3. Run:
```bash
spogo auth paste
```
spogo prompts for each cookie. To skip the prompts (CI, scripts):
```bash
printf '%s\n%s\n' "sp_dc=..." "sp_t=..." | spogo auth paste --no-input
```
Other paste flags:
- `--cookie-path <file>` — write the resulting cookie jar to a custom path.
- `--domain <suffix>` — override the cookie domain (default `spotify.com`).
- `--path <path>` — override the cookie path (default `/`).
## Status & clearing
```bash
spogo auth status # which profile, when imported, what cookies exist
spogo auth clear # delete the stored cookies for the current profile
```
`auth status` does not call Spotify; it only inspects the local store. To verify cookies actually work, run any read command:
```bash
spogo status
spogo search track "test" --limit 1
```
A `401`/`403` from those means the cookies are stale — re-import.
## Where cookies are stored
- macOS: `~/Library/Application Support/spogo/<profile>/cookies.json`
- Linux: `~/.config/spogo/<profile>/cookies.json`
- Windows: `%APPDATA%\spogo\<profile>\cookies.json`
`<profile>` defaults to `default` — override with `--profile <name>` or `SPOGO_PROFILE`.
## Multiple accounts
Use profiles to keep multiple Spotify logins side by side:
```bash
spogo --profile work auth import --browser chrome --browser-profile "Profile 1"
spogo --profile personal auth import --browser chrome --browser-profile "Default"
spogo --profile work status
spogo --profile personal play spotify:track:...
```
Set the default for a shell with `export SPOGO_PROFILE=work`.
## Troubleshooting
- **"no cookies found"** — pass `--browser-profile`, double-check you're logged in to `open.spotify.com` in that browser, and check the warning spogo prints (it now surfaces the real reason).
- **Locked keychain (macOS Chrome)** — unlock the login keychain, then re-run `auth import`.
- **WSL Chrome** — cookie decryption is unreliable; use [paste](#manual-paste-wsl-fallback).
- **Auth works locally but not in CI** — copy the cookie jar file (the path printed by `auth status`) into the CI runner before running spogo.
See [Troubleshooting](troubleshooting.md) for more.

View File

@ -1,154 +0,0 @@
---
title: Command Reference
description: "Every spogo subcommand and flag, organized by topic. The machine-readable spec is at spec.md."
---
# Command Reference
Hand-written index of every `spogo` subcommand. The fully normative spec lives in [spec.md](spec.md); this page is the readable browse.
For deeper guides, see [Auth](auth.md), [Playback](playback.md), [Library](library.md), [Queue](queue.md), [Devices](devices.md), [Engines](engines.md), and [Output](output.md).
## Global flags
Apply to every command.
| Flag | Default | Purpose |
| --- | --- | --- |
| `-h`, `--help` | — | Show contextual help. |
| `--version` | — | Print the spogo version. |
| `--config <path>` | platform default | Path to a config file. |
| `--profile <name>` | `default` | Named profile (separate cookies + config). |
| `--timeout <dur>` | `10s` | HTTP timeout for any single request. |
| `--market <cc>` | account market or `US` | Two-letter market code. |
| `--language <tag>` | `en` | Language/locale. |
| `--device <name|id>` | active | Target a specific Connect device. |
| `--engine <name>` | `connect` | `auto` / `connect` / `web` / `applescript`. |
| `--json` | off | JSON output. |
| `--plain` | off | Plain (TSV) output. |
| `--no-color` | auto | Disable color in human output. |
| `-q`, `--quiet` | off | Suppress non-essential stderr. |
| `-v`, `--verbose` | off | Verbose stderr. |
| `-d`, `--debug` | off | Debug stderr (HTTP traces). |
| `--no-input` | auto when not a TTY | Refuse interactive prompts. |
Env overrides: every global flag has a `SPOGO_<NAME>` env equivalent. Two extras:
| Env | Purpose |
| --- | --- |
| `SPOGO_TOTP_SECRET_URL` | Override TOTP secret source (`http(s)` or `file://`). |
| `SPOGO_CONNECT_VERSION` | Override Connect client version sent to playback endpoints. |
## auth
Cookie management. See [Auth](auth.md).
| Command | Purpose |
| --- | --- |
| `spogo auth status` | Show stored cookie state for the current profile. |
| `spogo auth import [--browser <name>] [--browser-profile <name>] [--cookie-path <file>] [--domain <host>]` | Pull cookies from a browser store. |
| `spogo auth paste [--cookie-path <file>] [--domain <suffix>] [--path <path>]` | Read cookies from stdin (interactive prompts unless `--no-input`). |
| `spogo auth clear` | Delete stored cookies for the current profile. |
## search
Browse the catalog. Each subcommand takes a query plus `--limit N` and `--offset N`.
| Command | Returns |
| --- | --- |
| `spogo search track <query>` | Tracks. |
| `spogo search album <query>` | Albums. |
| `spogo search artist <query>` | Artists. |
| `spogo search playlist <query>` | Playlists. |
| `spogo search show <query>` | Podcast shows. |
| `spogo search episode <query>` | Podcast episodes. |
## info
Fetch a single item by ID, URI, or URL.
| Command | Returns |
| --- | --- |
| `spogo track info <id|url>` | One track. |
| `spogo album info <id|url>` | One album with track listing. |
| `spogo artist info <id|url>` | One artist + top tracks. |
| `spogo playlist info <id|url>` | One playlist's metadata. |
| `spogo show info <id|url>` | One show with episodes. |
| `spogo episode info <id|url>` | One episode. |
## playback
Drive what's playing. See [Playback](playback.md).
| Command | Purpose |
| --- | --- |
| `spogo play [<id|url>] [--type <kind>] [--shuffle]` | Resume, or start a track / album / playlist / show / artist. |
| `spogo pause` | Pause current playback. |
| `spogo next` | Skip to the next item. |
| `spogo prev` | Previous (restart current if past ~3s). |
| `spogo seek <ms|mm:ss>` | Seek within the current item. |
| `spogo volume <0-100>` | Set device volume. |
| `spogo shuffle <on|off>` | Toggle shuffle. |
| `spogo repeat <off|track|context>` | Set repeat mode. |
| `spogo status` | Print currently playing item + device. |
## queue
Up-next list. See [Queue](queue.md).
| Command | Purpose |
| --- | --- |
| `spogo queue add <id|url>` | Append one item to the queue. |
| `spogo queue show` | Print currently playing + queued items. |
| `spogo queue clear` | Not supported by Spotify's API; use `spogo play <something>` to replace the context. |
## library
Saved tracks, albums, followed artists, owned/followed playlists. See [Library](library.md).
| Command | Purpose |
| --- | --- |
| `spogo library tracks list [--limit N]` | List saved tracks. |
| `spogo library tracks add <id|url...>` | Save tracks. |
| `spogo library tracks remove <id|url...>` | Unsave tracks. |
| `spogo library albums list [--limit N]` | List saved albums. |
| `spogo library albums add <id|url...>` | Save albums. |
| `spogo library albums remove <id|url...>` | Unsave albums. |
| `spogo library artists list [--limit N] [--after <artist-id>]` | List followed artists. |
| `spogo library artists follow <id|url...>` | Follow artists. |
| `spogo library artists unfollow <id|url...>` | Unfollow artists. |
| `spogo library playlists list [--limit N]` | List owned/followed playlists. |
## playlist
Mutate playlists. See [Library](library.md).
| Command | Purpose |
| --- | --- |
| `spogo playlist create <name> [--public] [--collab]` | Create a new playlist. |
| `spogo playlist add <playlist> <track...>` | Append tracks. |
| `spogo playlist remove <playlist> <track...>` | Remove tracks. |
| `spogo playlist tracks <playlist> [--limit N]` | List a playlist's items. |
`<playlist>` accepts a playlist ID, URI, URL, or owned-playlist name.
## device
Connect devices. See [Devices](devices.md).
| Command | Purpose |
| --- | --- |
| `spogo device list` | List Connect-visible devices. |
| `spogo device set <name|id>` | Transfer playback to a device. |
## Exit codes
| Code | Meaning |
| --- | --- |
| `0` | Success |
| `1` | Generic failure |
| `2` | Invalid usage / validation |
| `3` | Auth / cookies missing or invalid |
| `4` | Network / timeouts |
See [Output](output.md) for the full output contract.

View File

@ -1,62 +0,0 @@
---
title: Devices
description: "List Spotify Connect devices, switch active playback, target a specific device per command."
---
# Devices
Every Spotify-capable thing you've signed in on — phone, desktop app, web player, smart speaker, console — is a Spotify Connect device. spogo can list them and route playback to whichever you want.
## device list
```bash
spogo device list
spogo device list --plain
spogo device list --json
```
Prints every device Spotify Connect currently knows about, with the active one marked. Plain mode is one device per line: `id`, `name`, `type`, `is_active`, `volume_percent`.
## device set
```bash
spogo device set "Kitchen"
spogo device set 0d1841b0976bae2a3a310dd74c0f3df354899bc8
```
Transfers playback to the named device (case-insensitive substring match) or device ID. If the current Connect state has no origin device, spogo falls back to the Web API transfer endpoint instead of failing.
## --device flag (per-command)
Every playback / queue / status command accepts `--device <name|id>`:
```bash
spogo play spotify:track:... --device "Kitchen"
spogo volume 30 --device "MacBook Pro"
spogo status --device "Living Room"
```
This temporarily targets a specific device for one command without changing the active device.
## Default device
Set a per-shell default with the env var:
```bash
export SPOGO_DEVICE="Kitchen"
spogo play spotify:track:... # goes to Kitchen
spogo play spotify:track:... --device "Phone" # overrides
```
## Discovery tips
- A device only shows up after it has been opened/played to recently. If your speaker isn't listed, open Spotify on it once.
- The Spotify desktop app shows up as the device name from the OS (`MacBook Pro`, `peter-laptop`).
- Web players appear as `Web Player (Chrome)` and similar; they don't persist after the tab closes.
- Sonos / Google Cast / AirPlay endpoints appear when they're being used by a Spotify session — not always at idle.
## Errors
- **`device not found`** — open the device's Spotify session once, then re-run.
- **`PREMIUM_REQUIRED`** — Connect transfer needs Premium.
- **`Connect state has no origin device`** — happens when no device is currently active; spogo retries via the Web API transfer.

View File

@ -1,114 +0,0 @@
---
title: Engines
description: "Choose between connect, web, auto, and applescript — what each engine talks to and when to use it."
---
# Engines
spogo can talk to Spotify through one of four engines. Pick whichever matches what you need, or let `auto` decide.
## Quick pick
| Need | Engine |
| --- | --- |
| Default; works for almost everything | `connect` |
| Account where Connect is unavailable | `web` |
| "I just want it to work" | `auto` |
| Drive Spotify.app on macOS, no network needed | `applescript` |
Set with `--engine <name>` per call, or globally with `SPOGO_ENGINE`.
## connect (default)
Talks to Spotify's internal Connect endpoints — the same ones the official desktop and mobile apps use to coordinate playback across devices. spogo's first choice for everything.
**Best for**
- Playback control (play, pause, next, prev, seek, volume, shuffle, repeat).
- Device discovery and transfer.
- Playlist mutations under heavy use (Connect doesn't hit the Web API rate limits).
- Search and item info via the internal GraphQL surface.
**Tradeoffs**
- A handful of features fall back to the Web API automatically (e.g. transfers when Connect has no origin device, volume on certain hardware that needs `PUT`).
- Search/info uses GraphQL hashes; if a hash can't be resolved, falls back to web search.
## web
The public Spotify Web API. Slower, lower throughput, and rate-limited (~180 req/min/account before backoff), but it works on accounts where Connect is unavailable.
**Best for**
- Accounts that can't use Connect (rare — usually corporate or family-restricted).
- Forcing the documented public API for a reproducible test.
- Anything that requires Web API specific endpoints not yet in Connect.
**Tradeoffs**
- Rate limits will bite under bulk operations. If you see `429`, switch to `connect` or `auto`.
- Search/info/playback auto-fall-back to Connect when rate limited, so practical behavior is closer to `auto`.
## auto
Try `connect` first; fall back to `web` for unsupported features or when Connect signals the call won't work. The friendliest default if you're not sure.
```bash
spogo --engine auto play spotify:playlist:...
```
Most users don't need this — `connect` already falls back to web for the specific paths where it has to. `auto` is useful when you want **explicit** fallback behavior across all calls.
## applescript (macOS only)
Drives the local Spotify desktop app via AppleScript. No network, no cookies, no rate limits — but only the Mac you're on can be controlled, and you only see the local app's view (no Connect device list).
```bash
spogo --engine applescript play
spogo --engine applescript pause
spogo --engine applescript next
spogo --engine applescript status
```
**Best for**
- Quick local hotkeys / shortcuts (Raycast, Alfred, sketchybar, etc.) where network round trips are wasted.
- Sandboxed environments where cookie auth is awkward.
- Scripts that just need "pause my Mac's Spotify" without touching cloud state.
**Tradeoffs**
- macOS only.
- No Connect device list (`device list` shows just the Mac), no transfers.
- Search uses the Mac app's local search; results may differ from web search.
- Library/playlist mutations are not supported via AppleScript — fall back to `connect` or `web` for those.
## Setting an engine
Per command:
```bash
spogo --engine connect play
spogo --engine web search track "weezer"
spogo --engine applescript pause
```
Per shell:
```bash
export SPOGO_ENGINE=connect
```
In a config profile (`~/.config/spogo/<profile>/config.toml` or platform equivalent):
```toml
engine = "connect"
```
## Diagnosing engine issues
```bash
spogo --debug status
```
Debug logging on stderr shows which engine handled each call and any fallbacks that fired. See [Output](output.md) and [Troubleshooting](troubleshooting.md).

View File

@ -1,37 +0,0 @@
---
title: Overview
permalink: /
description: "spogo is a Spotify power CLI for search, playback, library, playlists, devices, and scripting — auth via browser cookies, output as human, plain, or JSON."
---
# spogo
A single Go binary for Spotify on the command line. Search the catalog, drive playback, manage your library and playlists, pick devices, and pipe stable JSON or plain output into anything.
## Why spogo
- **No app registration.** spogo authenticates with the cookies your browser already has — `spogo auth import --browser chrome` and you're authenticated. No client ID, no redirect URI, no developer dashboard.
- **No tight rate limits.** Talks to Spotify's internal web endpoints (the same ones `open.spotify.com` uses), so search, info, and playback are usable for automation that the public Web API would throttle.
- **Predictable output.** `--json` for tools, `--plain` for `awk`/`cut`, color human output by default. `NO_COLOR`, `TERM=dumb`, and `--no-color` all respected.
- **Multiple engines.** `connect` (internal), `web` (public Web API), `auto` (connect first, fall back to web), and `applescript` (drive Spotify.app on macOS) — pick what works.
- **Built for agents.** Stable exit codes, structured errors, machine output — drop spogo into a shell script or hand it to a coding agent and it'll behave.
## Pick your path
- **Trying it.** Read [Install](install.md) then [Quickstart](quickstart.md). About five minutes from `brew install` to playing a track from the terminal.
- **Cookies are confusing.** Read [Auth](auth.md) for browser import, manual paste, the WSL fallback, and the `auth status` checks.
- **Picking an engine.** Read [Engines](engines.md) for the tradeoffs between `connect`, `web`, `auto`, and `applescript`.
- **Wiring up a script or agent.** Read [Output](output.md) for the JSON/plain contract, then [Agents](agents.md) for end-to-end automation patterns.
- **Looking up a flag.** Open the [Command Reference](commands.md) — every subcommand is listed with its flags. The full machine-readable spec lives in [Spec](spec.md).
## Status
Actively developed. The [CHANGELOG](https://github.com/steipete/spogo/blob/main/CHANGELOG.md) tracks every shipping release. Current line is `v0.3.x`.
## Out of scope
- MCP server — this is a CLI.
- Hosted runtime, web UI, or GUI.
- A library — spogo is a tool, not a Go SDK; internal packages are not stable.
Released under the [MIT license](https://github.com/steipete/spogo/blob/main/LICENSE). Not affiliated with Spotify. Spotify is a trademark of Spotify AB.

View File

@ -1,58 +0,0 @@
---
title: Install
description: "Install spogo via Homebrew, go install, or a release binary. macOS, Linux, and Windows are all supported."
---
# Install
spogo ships as a single static Go binary. Pick whichever path matches how you usually install CLIs.
## Homebrew (macOS, Linux)
```bash
brew install steipete/tap/spogo
```
That's it — the formula pulls a signed binary from the latest GitHub release.
## go install (any platform)
```bash
go install github.com/steipete/spogo/cmd/spogo@latest
```
Builds from source against your local Go toolchain. Requires Go 1.22+.
## Pre-built release binaries
Grab a tarball or zip for your OS/arch from the [releases page](https://github.com/steipete/spogo/releases) and drop the `spogo` binary somewhere on `PATH`:
```bash
curl -L https://github.com/steipete/spogo/releases/latest/download/spogo_$(uname -s)_$(uname -m).tar.gz | tar xz
sudo mv spogo /usr/local/bin/
spogo --version
```
## Build from source
```bash
git clone https://github.com/steipete/spogo.git
cd spogo
make spogo
./spogo --version
```
## Verify
```bash
spogo --version
spogo --help
```
If `--help` lists `auth`, `search`, `play`, `library`, `playlist`, `device`, and friends, you're done. Next stop: [Quickstart](quickstart.md).
## Uninstall
- Homebrew: `brew uninstall spogo`
- go install: `rm $(which spogo)`
- Manual: delete the binary; remove `~/Library/Application Support/spogo` (macOS), `~/.config/spogo` (Linux), or `%APPDATA%\spogo` (Windows) to clear cached cookies and config.

View File

@ -1,133 +0,0 @@
---
title: Library & Playlists
description: "List, save, remove tracks/albums/artists, and create or mutate playlists from the terminal."
---
# Library & Playlists
Your saved tracks, albums, followed artists, and playlists — all listable, mutable, and pipeable.
## library tracks
```bash
spogo library tracks list [--limit N]
spogo library tracks add <id|url...>
spogo library tracks remove <id|url...>
```
`add`/`remove` accept multiple IDs or URLs in one call:
```bash
spogo library tracks add \
spotify:track:7hQJA50XrCWABAu5v6QZ4i \
https://open.spotify.com/track/4PTG3Z6ehGkBFwjybzWkR8
```
## library albums
```bash
spogo library albums list [--limit N]
spogo library albums add <id|url...>
spogo library albums remove <id|url...>
```
## library artists
```bash
spogo library artists list [--limit N] [--after <artist-id>]
spogo library artists follow <id|url...>
spogo library artists unfollow <id|url...>
```
`--after` paginates by artist ID — pass the last ID from the previous page to fetch the next.
## library playlists
```bash
spogo library playlists list [--limit N]
```
Lists every playlist you own or follow. To list **tracks** in a playlist, use `playlist tracks` below.
## playlist create
```bash
spogo playlist create "Road Trip"
spogo playlist create "Team Mix" --public
spogo playlist create "Shared Notes" --collab
```
`--public` marks the playlist as discoverable; `--collab` makes it editable by collaborators (collaborative playlists must be private).
## playlist add / remove
```bash
spogo playlist add <playlist> <track...>
spogo playlist remove <playlist> <track...>
```
`<playlist>` is a playlist ID, `spotify:playlist:...` URI, `https://open.spotify.com/playlist/...` URL, or **the playlist name** if you own it. Tracks accept the same flexible forms as `library tracks add`.
```bash
spogo playlist add "Road Trip" \
spotify:track:7hQJA50XrCWABAu5v6QZ4i \
spotify:track:0sf12qNH5qcw8qpgymFOqD
spogo playlist remove 37i9dQZF1DXcBWIGoYBM5M spotify:track:7hQJA50XrCWABAu5v6QZ4i
```
Playlist mutations route through Connect by default — Connect avoids the Web API rate limits that bite when you script bulk add/remove. spogo automatically detects writable playlists and falls back to Web API where Connect can't help.
## playlist tracks
```bash
spogo playlist tracks <playlist> [--limit N]
```
Lists the items inside a playlist:
```bash
spogo playlist tracks "Road Trip" --plain | head
spogo playlist tracks 37i9dQZF1DXcBWIGoYBM5M --json | jq '.tracks[].name'
```
## Common patterns
### Save the currently playing track
```bash
id=$(spogo status --json | jq -r '.item.id')
spogo library tracks add "$id"
```
### Build a playlist from a search
```bash
spogo playlist create "Lo-Fi Coding"
spogo search track "lo-fi" --limit 20 --plain |
awk '{print $1}' |
xargs spogo playlist add "Lo-Fi Coding"
```
### Snapshot all liked tracks to a file
```bash
spogo library tracks list --limit 1000 --json > liked-tracks.json
```
### Remove duplicates from a playlist
```bash
spogo playlist tracks "Road Trip" --plain |
awk '{print $1}' |
sort | uniq -d |
xargs -I {} spogo playlist remove "Road Trip" {}
```
## Errors
- **`playlist not found`** — confirm spelling, or pass the URI/URL instead of the name.
- **`not collaborative`** — only owners and explicitly added collaborators can mutate a playlist.
- **`429 too many requests`** — should not happen with Connect; if you see it on `web`, switch engines or insert a sleep.
See [Engines](engines.md) and [Output](output.md) for output and engine details.

View File

@ -1,120 +0,0 @@
---
title: Output
description: "spogo's output contract — human, plain, and JSON modes; stdout vs stderr; color and verbosity controls."
---
# Output
spogo follows a strict separation: **stdout** carries data, **stderr** carries logs and errors. Pipes always work — `spogo X | tool Y` never gets contaminated with progress bars or color codes when the destination isn't a TTY.
## Three output modes
### Human (default)
Coloured, formatted, friendly. Tables, headings, dimmed metadata. What you want when you're at a terminal.
```bash
spogo status
```
Color is automatic when stdout is a TTY. It is disabled when:
- `--no-color` is passed.
- `NO_COLOR` is set (any value).
- `TERM=dumb`.
- stdout is not a TTY (piped, redirected).
### `--plain`
Line-oriented, tab-separated, **stable**. Designed for `awk`, `cut`, `xargs`, and shell pipelines:
```bash
spogo search track "weezer" --limit 3 --plain
# spotify:track:7hQJA50XrCWABAu5v6QZ4i Say It Ain't So Weezer
# spotify:track:0sf12qNH5qcw8qpgymFOqD Buddy Holly Weezer
# spotify:track:4PTG3Z6ehGkBFwjybzWkR8 Undone — The Sweater Song Weezer
```
Field order per command is documented in the [Spec](spec.md). Tabs are the only delimiter; values containing tabs are escaped.
### `--json`
Structured, **stable** keys. Use `jq` (or any JSON tool) downstream:
```bash
spogo status --json | jq -r '.item.name + " — " + (.item.artists|map(.name)|join(", "))'
```
JSON shapes match the [Spec](spec.md). Fields may be added; existing keys are not renamed or removed without a major version bump.
## Verbosity
| Flag | Effect |
| --- | --- |
| (default) | Normal: prints results to stdout, errors to stderr. |
| `-q`, `--quiet` | Suppress non-essential stderr output. |
| `-v`, `--verbose` | Extra context on stderr (engine choices, fallbacks, timings). |
| `-d`, `--debug` | Everything `-v` plus HTTP request/response details. |
Debug mode is the right escalation when something is misbehaving — see [Troubleshooting](troubleshooting.md).
## Stdout vs stderr
- **stdout**: command results only.
- **stderr**: warnings, errors, debug logs, prompts.
This is invariant — every spogo command in every mode follows it. That means `2>/dev/null` mutes diagnostic noise without losing data, and `>file.json` always captures clean output.
```bash
spogo library tracks list --json --limit 100 > tracks.json 2>/dev/null
```
## No prompts in pipelines
When stdin is not a TTY, spogo never prompts — commands that would normally ask for input return an error instead. Force prompts off explicitly with `--no-input`.
```bash
spogo auth paste --no-input < cookies.txt
```
## Color in CI
CI logs usually want color stripped. spogo respects `NO_COLOR` and detects non-TTY stdout, so the default behavior is correct in GitHub Actions, GitLab CI, etc. — no flag needed.
## Exit codes
| Code | Meaning |
| --- | --- |
| `0` | Success |
| `1` | Generic failure |
| `2` | Invalid usage / validation error |
| `3` | Auth / cookies missing or invalid |
| `4` | Network / timeout |
Use these in scripts:
```bash
if ! spogo auth status >/dev/null 2>&1; then
case $? in
3) echo "Need to re-import cookies" >&2 ;;
*) echo "Auth check failed" >&2 ;;
esac
exit 1
fi
```
## Examples
```bash
# Pipe-friendly: just the URI
spogo search track "weezer" --limit 1 --plain | awk '{print $1}'
# Capture full JSON, pluck a field
spogo status --json | jq -r '.item.id'
# Mute stderr noise but keep stdout
spogo library tracks list --json 2>/dev/null > tracks.json
# Force color even in a pipe (rare; for fancy renderers)
spogo --no-color=false status | bat -l ansi
```

View File

@ -1,122 +0,0 @@
---
title: Playback
description: "Play, pause, seek, volume, shuffle, repeat — drive Spotify playback from the terminal."
---
# Playback
All playback commands act on the currently active Spotify Connect device unless you pass `--device`. Use [`spogo device list`](devices.md) to see what's available and `spogo device set <name|id>` to switch.
## play
```bash
spogo play [<id|url>] [--type <track|album|playlist|show|episode>] [--shuffle]
```
Accepts:
- A Spotify URI: `spotify:track:7hQJA50XrCWABAu5v6QZ4i`, `spotify:album:...`, `spotify:playlist:...`, `spotify:show:...`, `spotify:episode:...`, `spotify:artist:...`.
- A web URL: `https://open.spotify.com/track/7hQJA50XrCWABAu5v6QZ4i`.
- A bare ID — combine with `--type` to disambiguate.
- No argument — resumes the current item.
Behavior:
- **Tracks** start immediately.
- **Albums / playlists / shows** start a context — `spogo next` walks through items.
- **Artists** start with the artist's top tracks (first track first).
- `--shuffle` enables shuffle on the device before play, randomizing the first track for context URIs.
Examples:
```bash
spogo play # resume
spogo play spotify:track:7hQJA50XrCWABAu5v6QZ4i # one track
spogo play https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy
spogo play 37i9dQZF1DXcBWIGoYBM5M --type playlist # bare ID
spogo play spotify:playlist:37i9dQZF1DXcBWIGoYBM5M --shuffle # shuffle on
spogo play spotify:artist:6sFIWsNpZYqfjUpaCgueju # top tracks
```
## pause / resume
```bash
spogo pause
spogo play # no argument resumes
```
## next / prev
```bash
spogo next
spogo prev # restart current track if past ~3s, else previous
```
## seek
```bash
spogo seek 90000 # milliseconds
spogo seek 1:30 # mm:ss
spogo seek 0 # back to start
```
## volume
```bash
spogo volume 60 # 0-100
spogo volume 0 # mute
```
Some devices ignore volume changes (e.g. Spotify Connect on hardware that exposes its own volume).
## shuffle / repeat
```bash
spogo shuffle on
spogo shuffle off
spogo repeat off
spogo repeat track # loop current track
spogo repeat context # loop current album/playlist
```
## status
```bash
spogo status # human, color
spogo status --plain # tab-separated key value
spogo status --json # full payload
```
JSON shape includes `is_playing`, `progress_ms`, `device`, `item` (track or episode), `context`, `repeat_state`, `shuffle_state`. Use `jq` to pluck what you need:
```bash
spogo status --json | jq -r '.item.name + " — " + (.item.artists|map(.name)|join(", "))'
```
## Targeting a specific device
Every playback command accepts `--device <name|id>`:
```bash
spogo play spotify:track:... --device "Kitchen"
spogo volume 30 --device "MacBook Pro"
spogo pause --device 0d1841b0976bae2a3a310dd74c0f3df354899bc8
```
When Connect state has no origin device, `spogo` falls back to the Web API transfer endpoint instead of failing.
## Engine notes
- **`connect`** (default) — playback control via Spotify's internal Connect endpoints. Best fidelity for transitions, queueing, and device transfer; auto-falls-back to Web API for transfers when no origin device exists.
- **`web`** — the public Web API. Slower, rate-limited, but the only option for accounts with restrictive Connect availability.
- **`auto`** — Connect first, fall back to Web on Connect-unsupported features.
- **`applescript`** (macOS only) — drive Spotify.app directly via AppleScript. No network, but only sees the local Mac app.
See [Engines](engines.md) for the full breakdown.
## Errors
- `no active device` — open Spotify on a phone/desktop/Connect speaker first, or pass `--device`.
- `403 PREMIUM_REQUIRED` — playback requires a Spotify Premium account.
- `429 too many requests` — Connect engine handles rate limits internally; if you see this on `web`, switch to `auto` or `connect`.

View File

@ -1,71 +0,0 @@
---
title: Queue
description: "Add tracks to and inspect the playback queue."
---
# Queue
The queue is the up-next list managed by Spotify Connect. It survives device transfers and persists across pause/resume.
## queue add
```bash
spogo queue add <id|url>
```
Appends one item to the queue. Accepts a track URI, URL, or bare ID (combine with `--type` for non-tracks):
```bash
spogo queue add spotify:track:7hQJA50XrCWABAu5v6QZ4i
spogo queue add https://open.spotify.com/track/4PTG3Z6ehGkBFwjybzWkR8
spogo queue add 0sf12qNH5qcw8qpgymFOqD --type track
```
`queue add` requires an active device. Open Spotify on a phone/desktop or pass `--device <name|id>`.
## queue show
```bash
spogo queue show
spogo queue show --plain
spogo queue show --json
```
Prints the currently-playing item plus the upcoming queue. Plain mode emits one item per line:
```
spotify:track:... Track Name Artist Name
spotify:track:... Another Track Another Artist
```
JSON mode includes `currently_playing` and a `queue` array with full track objects.
## queue clear
Spotify's API does not currently expose a way to clear the queue programmatically. The cleanest workaround is to start a new context, which replaces the queue:
```bash
spogo play spotify:track:7hQJA50XrCWABAu5v6QZ4i # any single track
```
## Patterns
### Queue up the top results of a search
```bash
spogo search track "miles davis" --limit 5 --plain |
awk '{print $1}' |
while read uri; do spogo queue add "$uri"; done
```
### Queue an entire playlist's worth of next-up
`queue add` only takes one item — to queue every track from a playlist:
```bash
spogo playlist tracks "Road Trip" --plain |
awk '{print $1}' |
while read uri; do spogo queue add "$uri"; done
```
For long playlists this is N HTTP calls — usually faster to just `play` the playlist as a context.

View File

@ -1,87 +0,0 @@
---
title: Quickstart
description: "From install to playing your first track in five minutes — cookie import, search, play, status."
---
# Quickstart
Five minutes from `brew install` to controlling Spotify from the terminal.
## 1. Install
```bash
brew install steipete/tap/spogo
```
Other options live in [Install](install.md).
## 2. Import your browser cookies
spogo authenticates by reading the cookies your browser already has for `open.spotify.com`. Make sure you're logged in there in Chrome (or Brave, Edge, Firefox, Safari), then:
```bash
spogo auth import --browser chrome
```
Defaults: Chrome, the `Default` profile. To pick a non-default profile:
```bash
spogo auth import --browser chrome --browser-profile "Profile 1"
```
If something goes wrong (locked keychain, weird WSL setup), see [Auth](auth.md) for `auth paste` and other fallbacks.
Verify:
```bash
spogo auth status
```
## 3. Find something to play
```bash
spogo search track "weezer say it ain't so" --limit 3
```
Add `--json` if you want structured output, or `--plain` for tab-separated lines.
## 4. Play it
```bash
spogo play spotify:track:7hQJA50XrCWABAu5v6QZ4i
```
You can also pass an `https://open.spotify.com/...` URL, or a playlist/album/show URI. spogo figures out the type from the URI; for raw IDs use `--type`.
## 5. Drive the rest
```bash
spogo status # what's playing
spogo pause
spogo next
spogo volume 60
spogo shuffle on
spogo queue add spotify:track:6rqhFgbbKwnb9MLmUQDhG6
spogo device list # available speakers/players
spogo device set "Kitchen" # switch playback there
```
## 6. Pipe it into something
```bash
# What track is playing right now, machine-readable?
spogo status --json | jq -r '.item.name'
# Save the top-5 search results into a playlist
spogo search track "lo-fi" --limit 5 --plain |
awk '{print $1}' |
xargs spogo playlist add "Lo-Fi Coding"
```
## Where to next
- [Auth](auth.md) — cookie details, manual paste, troubleshooting.
- [Engines](engines.md) — when to choose `connect`, `web`, `auto`, or `applescript`.
- [Output](output.md) — the JSON / plain contract.
- [Agents](agents.md) — end-to-end automation patterns.
- [Command Reference](commands.md) — every subcommand and flag.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

View File

@ -1,132 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
<title id="title">spogo social card</title>
<desc id="desc">spogo: Spotify, but make it terminal. A Go CLI for Spotify search, playback, library, playlists, queue, and devices.</desc>
<defs>
<radialGradient id="bg" cx="100%" cy="0%" r="120%">
<stop offset="0" stop-color="#143a26"/>
<stop offset="0.45" stop-color="#0c1816"/>
<stop offset="1" stop-color="#06090c"/>
</radialGradient>
<radialGradient id="bgWarm" cx="0%" cy="100%" r="60%">
<stop offset="0" stop-color="#5a2410" stop-opacity="0.55"/>
<stop offset="1" stop-color="#06090c" stop-opacity="0"/>
</radialGradient>
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0f172a"/>
<stop offset="1" stop-color="#070b15"/>
</linearGradient>
<linearGradient id="panelBar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1b2438"/>
<stop offset="1" stop-color="#141c2e"/>
</linearGradient>
<linearGradient id="brandSweep" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#1ed760"/>
<stop offset="0.4" stop-color="#ffd95e"/>
<stop offset="0.7" stop-color="#ff7a3d"/>
<stop offset="1" stop-color="#ff6ea1"/>
</linearGradient>
<linearGradient id="titleSweep" x1="0" y1="0" x2="1" y2="0.2">
<stop offset="0" stop-color="#1ed760"/>
<stop offset="0.55" stop-color="#ff7a3d"/>
<stop offset="1" stop-color="#ff6ea1"/>
</linearGradient>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="24" stdDeviation="28" flood-color="#000000" flood-opacity="0.45"/>
</filter>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="10" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<rect width="1200" height="630" fill="url(#bg)"/>
<rect width="1200" height="630" fill="url(#bgWarm)"/>
<!-- Brand mark: equalizer bars in tile -->
<g transform="translate(76 76)">
<rect x="0" y="0" width="118" height="118" rx="26" fill="#0a0e12" stroke="#1d2a30"/>
<rect x="30" y="62" width="14" height="36" rx="3" fill="#0f7c34"/>
<rect x="52" y="32" width="14" height="66" rx="3" fill="#1ed760"/>
<rect x="74" y="44" width="14" height="54" rx="3" fill="#ff7a3d"/>
</g>
<!-- Title -->
<text x="76" y="290" fill="#f5f8f6" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="116" font-weight="800" letter-spacing="-2">spogo</text>
<!-- Tagline with gradient terminal accent -->
<text x="76" y="350" fill="#e8efeb" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="36" font-weight="700" letter-spacing="-0.5">
Spotify, but make it <tspan fill="url(#titleSweep)" font-weight="800">terminal</tspan>.
</text>
<!-- Description (two lines) -->
<text x="76" y="402" fill="#9aa9a3" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="22" font-weight="500">One Go binary. Search, playback, library,</text>
<text x="76" y="430" fill="#9aa9a3" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="22" font-weight="500">playlists, queue, devices. JSON or plain.</text>
<!-- Multi-color accent bar -->
<rect x="76" y="454" width="280" height="4" rx="2" fill="url(#brandSweep)"/>
<!-- Bottom row: install pill + URL pill -->
<g transform="translate(76 490)">
<rect x="0" y="0" width="372" height="52" rx="12" fill="#0a0e12" stroke="#1d2a30"/>
<text x="22" y="34" fill="#1ed760" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="18" font-weight="700">$</text>
<text x="44" y="34" fill="#e6f7ec" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="18" font-weight="600">brew install steipete/tap/spogo</text>
<rect x="392" y="0" width="148" height="52" rx="12" fill="#1ed760"/>
<text x="466" y="34" fill="#06281a" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="20" font-weight="800" text-anchor="middle">spogo.sh</text>
</g>
<!-- Right-side terminal mockup -->
<g transform="translate(672 142)" filter="url(#shadow)">
<rect x="0" y="0" width="464" height="346" rx="18" fill="url(#panel)" stroke="#1d2a30"/>
<rect x="0" y="0" width="464" height="42" rx="18" fill="url(#panelBar)"/>
<rect x="0" y="22" width="464" height="20" fill="url(#panelBar)"/>
<circle cx="24" cy="21" r="6" fill="#ff7a3d"/>
<circle cx="46" cy="21" r="6" fill="#ffd95e"/>
<circle cx="68" cy="21" r="6" fill="#1ed760"/>
<text x="232" y="27" fill="#94a3b8" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13" font-weight="500" text-anchor="middle">spogo — open.spotify.com</text>
<!-- Terminal content -->
<text x="22" y="80" fill="#1ed760" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="15" font-weight="600">$ spogo status</text>
<text x="22" y="106" fill="#cbd5e1" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">▶ Buddy Holly — Weezer</text>
<text x="22" y="128" fill="#94a3b8" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13"> Pinkerton · 1:48 / 2:39</text>
<text x="22" y="150" fill="#94a3b8" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13"> Kitchen · vol 60</text>
<text x="22" y="186" fill="#1ed760" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="15" font-weight="600">$ spogo status --json | jq .item.id</text>
<rect x="22" y="204" width="420" height="118" rx="10" fill="#06090c" stroke="#1d2a30"/>
<text x="38" y="232" fill="#fbbf24" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">{</text>
<text x="54" y="252" fill="#a5b4fc" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">"is_playing": true,</text>
<text x="54" y="272" fill="#a5b4fc" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">"item": {</text>
<text x="74" y="292" fill="#a7f3d0" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">"name": "Buddy Holly",</text>
<text x="74" y="312" fill="#a7f3d0" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">"id": "0sf12qNH5qcw8qpgymFOqD"</text>
</g>
<!-- Service pills below terminal -->
<g transform="translate(672 514)" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="14" font-weight="600">
<g>
<rect x="0" y="0" width="86" height="32" rx="16" fill="#0a0e12" stroke="#1d2a30"/>
<circle cx="16" cy="16" r="4" fill="#1ed760"/>
<text x="52" y="21" fill="#e8efeb" text-anchor="middle">Search</text>
</g>
<g transform="translate(98 0)">
<rect x="0" y="0" width="98" height="32" rx="16" fill="#0a0e12" stroke="#1d2a30"/>
<circle cx="16" cy="16" r="4" fill="#ff7a3d"/>
<text x="58" y="21" fill="#e8efeb" text-anchor="middle">Playback</text>
</g>
<g transform="translate(208 0)">
<rect x="0" y="0" width="86" height="32" rx="16" fill="#0a0e12" stroke="#1d2a30"/>
<circle cx="16" cy="16" r="4" fill="#ff6ea1"/>
<text x="52" y="21" fill="#e8efeb" text-anchor="middle">Library</text>
</g>
<g transform="translate(306 0)">
<rect x="0" y="0" width="78" height="32" rx="16" fill="#0a0e12" stroke="#1d2a30"/>
<circle cx="16" cy="16" r="4" fill="#8a6dff"/>
<text x="48" y="21" fill="#e8efeb" text-anchor="middle">Queue</text>
</g>
<g transform="translate(396 0)">
<rect x="0" y="0" width="68" height="32" rx="16" fill="#0a0e12" stroke="#1d2a30"/>
<circle cx="16" cy="16" r="4" fill="#4ea0ff"/>
<text x="42" y="21" fill="#e8efeb" text-anchor="middle">JSON</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -1,4 +1,4 @@
# spogo CLI spec (v0.3.0)
# spogo CLI spec (v0.1.0)
One-liner: Spotify power CLI using web cookies; search + playback control.
Parser: Kong.
@ -29,7 +29,7 @@ spogo [global flags] <command> [args]
- `--market <cc>` default: account market or `US`
- `--language <tag>` default: `en`
- `--device <name|id>` default: active device
- `--engine <auto|web|connect|applescript>` default: `connect` (`applescript` is macOS-only)
- `--engine <auto|web|connect>` default: `connect`
- `--no-input`
## Commands
@ -42,12 +42,6 @@ spogo [global flags] <command> [args]
- `--browser-profile <name>`
- `--cookie-path <file>`
- `--domain <host>` default `spotify.com`
- when browser reads fail, surface underlying browser-store warnings
- `spogo auth paste`
- reads cookie values from stdin (prompts when interactive)
- `--cookie-path <file>`
- `--domain <suffix>` default `spotify.com`
- `--path <path>` default `/`
- `spogo auth clear`
### search
@ -72,7 +66,6 @@ spogo [global flags] <command> [args]
- `spogo play [<id|url>]` (track/album/playlist/show)
- optional: `--type <track|album|playlist|show|episode>` for raw IDs
- optional: `--shuffle` enable shuffle before playing (randomizes first track for context URIs)
- artist URIs play top tracks (starts with the first)
- `spogo pause`
- `spogo next`
@ -113,7 +106,6 @@ spogo [global flags] <command> [args]
- `spogo device list`
- `spogo device set <name|id>`
- falls back to Web API transfer when Connect state has no origin device
## Output contract

View File

@ -1,151 +0,0 @@
---
title: Troubleshooting
description: "Common spogo failures — auth, devices, rate limits, WSL — and how to fix them."
---
# Troubleshooting
The first move when anything misbehaves is to re-run with `-d`:
```bash
spogo -d <command>
```
Debug logs go to stderr and include engine choices, fallbacks, HTTP status codes, and request IDs. They will not pollute a `--json` or `--plain` pipeline.
## Auth
### `no cookies found`
spogo couldn't read cookies from the browser store. Check:
1. You're actually logged in to `https://open.spotify.com` in that browser.
2. You picked the right `--browser-profile` (Chrome's default is `Default`, but most users have `Profile 1`, `Profile 2`).
3. spogo printed an underlying warning — recent versions surface the real reason (locked keychain, decryption failure, missing profile dir).
If browser-store reads keep failing, fall back to `auth paste` (see [Auth](auth.md#manual-paste-wsl-fallback)).
### `401 Unauthorized` / `403 Forbidden` from any read command
Cookies are stale. Re-import:
```bash
spogo auth import --browser chrome
spogo auth status
```
If that doesn't help, your browser's session may have expired. Visit `https://open.spotify.com`, log back in, then re-import.
### macOS Chrome keychain prompt
The first cookie import will trigger a "Chrome wants to use your confidential information from your keychain" dialog. Click **Always Allow**. If you mis-click **Deny**, fix it via:
`Keychain Access → login → Passwords → search "Chrome Safe Storage" → right-click → Get Info → Access Control → +` add `spogo`.
### WSL: cookie decryption fails
Chrome on WSL has a fragile DPAPI/Linux-keyring combo. Use `auth paste` instead:
1. DevTools → Application → Cookies → `https://open.spotify.com` → copy `sp_dc`, `sp_t`.
2. `spogo auth paste`.
## Playback
### `no active device`
Open Spotify on a phone, desktop, or Connect speaker once so it registers, or pass `--device <name|id>`:
```bash
spogo device list
spogo play spotify:track:... --device "Kitchen"
```
### `403 PREMIUM_REQUIRED`
Playback control (play, pause, seek, transfer, queue) requires a Spotify Premium account. spogo's read-only commands (`status`, `search`, `library tracks list`, etc.) work on free accounts.
### Volume command does nothing
Some Connect endpoints expose their own hardware volume and ignore Spotify's volume command. Try `--device` to a different target, or set the volume on the device itself.
### `device set` fails with "Connect state has no origin device"
Recent spogo versions auto-fall-back to the Web API transfer endpoint here. If you're on an older version, upgrade:
```bash
brew upgrade spogo
```
## Search & info
### Empty results for a search that should match
The internal GraphQL search uses query hashes that occasionally roll. spogo falls back to web search when a hash can't be resolved; if both fail, try:
```bash
spogo --engine web search track "your query"
```
### `track info` returns sparse data
Older spogo versions had Connect responses missing artist/album for some track shapes. Upgrade to the latest release.
## Rate limits
### `429 too many requests`
Should not happen on the `connect` engine for normal usage. If you see it:
- You're on `--engine web` — switch to `connect` or `auto`.
- You're hammering the API in a tight loop — add `sleep 0.2` between calls, or batch via `--limit`.
- Multiple spogo profiles are sharing the same Spotify account at the same time — the throttle is per-account, not per-process.
## Output
### Color codes leaking into a file
You're capturing stdout but spogo thinks stdout is a TTY. This shouldn't happen — spogo detects TTY correctly — but if it does, force off:
```bash
spogo --no-color status > out.txt
NO_COLOR=1 spogo status > out.txt
```
### `jq` complains the JSON is malformed
You're capturing stderr too. Redirect it:
```bash
spogo status --json 2>/dev/null | jq .
```
### Pipe is empty / silent
The command may be writing to stderr only (errors, prompts). Re-run without redirecting stderr to see what happened:
```bash
spogo <command>
```
## Engines
### "Connect engine returned X" — what does that mean?
Run with `-d` and look for the `engine=` line in the debug output. spogo logs which engine handled each call and any fallback that fired.
### AppleScript engine: "spotify not running"
Open Spotify.app first. AppleScript can't launch the app reliably; spogo expects it to already be open.
### AppleScript engine: search results differ from web
The Mac app uses local search. Switch to `connect` or `web` for canonical results.
## Diagnostics to share when filing an issue
```bash
spogo --version
spogo -d <failing command> 2> spogo.log
```
Attach `spogo.log` (redact any cookie values it contains) to a [GitHub issue](https://github.com/steipete/spogo/issues).

30
go.mod
View File

@ -1,19 +1,20 @@
module github.com/steipete/spogo
go 1.25.0
go 1.24.0
require (
github.com/alecthomas/kong v1.15.0
github.com/alecthomas/kong v1.13.0
github.com/coder/websocket v1.8.14
github.com/daixiang0/gci v0.13.7
github.com/fatih/color v1.19.0
github.com/mattn/go-isatty v0.0.22
github.com/pelletier/go-toml/v2 v2.3.1
github.com/steipete/sweetcookie v0.0.0-20260427094007-8d5619cc372e
github.com/fatih/color v1.18.0
github.com/mattn/go-isatty v0.0.20
github.com/pelletier/go-toml/v2 v2.2.4
github.com/steipete/sweetcookie v0.0.0-20260102214724-68ec5a0bced4
mvdan.cc/gofumpt v0.9.2
)
require (
al.essio.dev/pkg/shellescape v1.6.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
@ -27,19 +28,20 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cobra v1.6.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/zalando/go-keyring v0.2.8 // indirect
github.com/zalando/go-keyring v0.2.6 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.0 // indirect
modernc.org/sqlite v1.42.2 // indirect
)

79
go.sum
View File

@ -1,7 +1,9 @@
al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA=
al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI=
github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
github.com/alecthomas/kong v1.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA=
github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
@ -18,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
@ -30,6 +32,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@ -44,12 +48,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -64,16 +68,16 @@ github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUq
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/steipete/sweetcookie v0.0.0-20260427094007-8d5619cc372e h1:R0MrtYtXlwA75Im59KUV21A0j9LHxrFhU+IphvYI13w=
github.com/steipete/sweetcookie v0.0.0-20260427094007-8d5619cc372e/go.mod h1:gf54ol7miGY/Vdw0Gcbml4HpgRLaYF/7lZX5pcaNH5g=
github.com/steipete/sweetcookie v0.0.0-20260102214724-68ec5a0bced4 h1:jdI+t1kNHJYY9+JT1pAJPYJbidXmvz/3sioWnGCh5/g=
github.com/steipete/sweetcookie v0.0.0-20260102214724-68ec5a0bced4/go.mod h1:V5bJFG7tBLlDP2/pBqafwOP2ooEtrqdrVMsh1VEAG5s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
@ -82,45 +86,48 @@ go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@ -1,12 +1,15 @@
package app
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/mattn/go-isatty"
"github.com/steipete/spogo/internal/config"
"github.com/steipete/spogo/internal/cookies"
"github.com/steipete/spogo/internal/output"
"github.com/steipete/spogo/internal/spotify"
)
@ -24,7 +27,6 @@ type Settings struct {
Quiet bool
Verbose bool
Debug bool
NoInput bool
}
type Context struct {
@ -36,7 +38,146 @@ type Context struct {
Output *output.Writer
spotifyClient spotify.API
commandCtx context.Context
}
func NewContext(settings Settings) (*Context, error) {
configPath := settings.ConfigPath
cfg, err := config.Load(configPath)
if err != nil {
return nil, err
}
if configPath == "" {
configPath, err = config.DefaultPath()
if err != nil {
return nil, err
}
}
profileKey := settings.Profile
if profileKey == "" {
profileKey = cfg.DefaultProfile
}
if profileKey == "" {
profileKey = config.DefaultProfile
}
profile := cfg.Profile(profileKey)
if settings.Market != "" {
profile.Market = settings.Market
}
if settings.Language != "" {
profile.Language = settings.Language
}
if settings.Device != "" {
profile.Device = settings.Device
}
if settings.Engine != "" {
profile.Engine = settings.Engine
}
format := settings.Format
if format == "" {
format = output.FormatHuman
}
colorEnabled := isColorEnabled(format, settings.NoColor)
writer, err := output.New(output.Options{
Format: format,
Color: colorEnabled,
Quiet: settings.Quiet,
})
if err != nil {
return nil, err
}
return &Context{
Settings: settings,
Config: cfg,
ConfigPath: configPath,
Profile: profile,
ProfileKey: profileKey,
Output: writer,
}, nil
}
func (c *Context) Spotify() (spotify.API, error) {
if c == nil {
return nil, errors.New("nil context")
}
if c.spotifyClient != nil {
return c.spotifyClient, nil
}
source, err := c.cookieSource()
if err != nil {
return nil, err
}
engine := strings.ToLower(strings.TrimSpace(c.Profile.Engine))
if engine == "" {
engine = "connect"
}
switch engine {
case "connect":
client, err := spotify.NewConnectClient(spotify.ConnectOptions{
Source: source,
Market: c.Profile.Market,
Language: c.Profile.Language,
Device: c.Profile.Device,
Timeout: c.Settings.Timeout,
})
if err != nil {
return nil, err
}
c.spotifyClient = client
return client, nil
case "web":
provider := spotify.CookieTokenProvider{
Source: source,
}
webClient, err := spotify.NewClient(spotify.Options{
TokenProvider: provider,
Market: c.Profile.Market,
Language: c.Profile.Language,
Device: c.Profile.Device,
Timeout: c.Settings.Timeout,
})
if err != nil {
return nil, err
}
client := spotify.API(webClient)
if connectClient, connectErr := spotify.NewConnectClient(spotify.ConnectOptions{
Source: source,
Market: c.Profile.Market,
Language: c.Profile.Language,
Device: c.Profile.Device,
Timeout: c.Settings.Timeout,
}); connectErr == nil {
client = spotify.NewPlaybackFallbackClient(webClient, connectClient)
}
c.spotifyClient = client
return client, nil
case "auto":
webClient, err := spotify.NewClient(spotify.Options{
TokenProvider: spotify.CookieTokenProvider{
Source: source,
},
Market: c.Profile.Market,
Language: c.Profile.Language,
Device: c.Profile.Device,
Timeout: c.Settings.Timeout,
})
if err != nil {
return nil, err
}
client := spotify.API(webClient)
if connectClient, connectErr := spotify.NewConnectClient(spotify.ConnectOptions{
Source: source,
Market: c.Profile.Market,
Language: c.Profile.Language,
Device: c.Profile.Device,
Timeout: c.Settings.Timeout,
}); connectErr == nil {
client = spotify.NewAutoClient(connectClient, webClient)
}
c.spotifyClient = client
return client, nil
default:
return nil, fmt.Errorf("unknown engine %q (use auto, web, or connect)", engine)
}
}
func (c *Context) SetSpotify(client spotify.API) {
@ -46,13 +187,25 @@ func (c *Context) SetSpotify(client spotify.API) {
c.spotifyClient = client
}
func (c *Context) cookieSource() (cookies.Source, error) {
if c.Profile.CookiePath != "" {
return cookies.FileSource{Path: c.Profile.CookiePath}, nil
}
browser := c.Profile.Browser
if strings.TrimSpace(browser) == "" {
browser = "chrome"
}
return cookies.BrowserSource{
Browser: browser,
Profile: c.Profile.BrowserProfile,
Domain: "spotify.com",
}, nil
}
func (c *Context) SaveProfile(profile config.Profile) error {
if c == nil {
return errors.New("nil context")
}
if c.Config == nil {
return errors.New("nil config")
}
cfg := c.Config
cfg.SetProfile(c.ProfileKey, profile)
cfg.DefaultProfile = c.ProfileKey
@ -63,6 +216,23 @@ func (c *Context) SaveProfile(profile config.Profile) error {
return nil
}
func isColorEnabled(format output.Format, noColor bool) bool {
if format != output.FormatHuman {
return false
}
if noColor {
return false
}
if os.Getenv("NO_COLOR") != "" {
return false
}
term := strings.ToLower(os.Getenv("TERM"))
if term == "dumb" {
return false
}
return isatty.IsTerminal(os.Stdout.Fd())
}
func (c *Context) ResolveCookiePath() string {
return config.CookiePath(c.ConfigPath, c.ProfileKey)
}
@ -74,24 +244,6 @@ func (c *Context) EnsureTimeout() time.Duration {
return 10 * time.Second
}
func (c *Context) CommandContext() context.Context {
if c == nil || c.commandCtx == nil {
return context.Background()
}
return c.commandCtx
}
func (c *Context) SetCommandContext(ctx context.Context) {
if c == nil {
return
}
if ctx == nil {
c.commandCtx = context.Background()
return
}
c.commandCtx = ctx
}
func (c *Context) ValidateProfile() error {
if c.Profile.Market != "" && len(c.Profile.Market) != 2 {
return fmt.Errorf("market must be 2-letter country code")

View File

@ -1,22 +0,0 @@
//go:build darwin
// +build darwin
package app
import (
"testing"
"github.com/steipete/spogo/internal/config"
"github.com/steipete/spogo/internal/cookies"
)
func TestNewAppleScriptClientDarwin(t *testing.T) {
ctx := &Context{Profile: config.Profile{Engine: "applescript"}}
client, err := ctx.newAppleScriptClient(cookies.FileSource{Path: "/tmp/missing-spogo-cookies.json"})
if err != nil {
t.Fatalf("new applescript client: %v", err)
}
if client == nil {
t.Fatalf("expected client")
}
}

View File

@ -1,22 +0,0 @@
//go:build !darwin
package app
import (
"strings"
"testing"
"github.com/steipete/spogo/internal/config"
)
func TestSpotifyAppleScriptEngine_NonDarwin(t *testing.T) {
t.Parallel()
ctx := &Context{Profile: config.Profile{CookiePath: "/tmp/cookies.json", Engine: "applescript"}}
if _, err := ctx.Spotify(); err == nil || !strings.Contains(err.Error(), "only available on macOS") {
t.Fatalf("expected macOS-only error, got: %v", err)
}
if ctx.spotifyClient != nil {
t.Fatalf("expected no cached client on error")
}
}

View File

@ -1,142 +0,0 @@
package app
import (
"errors"
"fmt"
"os"
"strings"
"github.com/steipete/spogo/internal/cookies"
"github.com/steipete/spogo/internal/spotify"
)
type engineName string
const (
engineConnect engineName = "connect"
engineWeb engineName = "web"
engineAuto engineName = "auto"
engineAppleScript engineName = "applescript"
)
func (c *Context) Spotify() (spotify.API, error) {
if c == nil {
return nil, errors.New("nil context")
}
if c.spotifyClient != nil {
return c.spotifyClient, nil
}
source, err := c.cookieSource()
if err != nil {
return nil, err
}
client, err := c.buildSpotifyClient(source)
if err != nil {
return nil, err
}
c.spotifyClient = client
return client, nil
}
func (c *Context) buildSpotifyClient(source cookies.Source) (spotify.API, error) {
switch c.engine() {
case engineConnect:
return c.newConnectClient(source)
case engineWeb:
return c.newPlaybackFallbackClient(source)
case engineAuto:
return c.newAutoClient(source)
case engineAppleScript:
return c.newAppleScriptClient(source)
default:
return nil, fmt.Errorf("unknown engine %q (use auto, web, connect, or applescript)", c.engine())
}
}
func (c *Context) newPlaybackFallbackClient(source cookies.Source) (spotify.API, error) {
webClient, err := c.newWebClient(source)
if err != nil {
return nil, err
}
client := spotify.API(webClient)
if connectClient, connectErr := c.newConnectClient(source); connectErr == nil {
client = spotify.NewPlaybackFallbackClient(webClient, connectClient)
}
return client, nil
}
func (c *Context) newAutoClient(source cookies.Source) (spotify.API, error) {
webClient, err := c.newWebClient(source)
if err != nil {
return nil, err
}
client := spotify.API(webClient)
if connectClient, connectErr := c.newConnectClient(source); connectErr == nil {
client = spotify.NewAutoClient(connectClient, webClient)
}
return client, nil
}
func (c *Context) newAppleScriptClient(source cookies.Source) (spotify.API, error) {
var fallback spotify.API
if webClient, webErr := c.newWebClient(source); webErr == nil {
fallback = webClient
if connectClient, connectErr := c.newConnectClient(source); connectErr == nil {
fallback = spotify.NewPlaybackFallbackClient(webClient, connectClient)
}
}
return spotify.NewAppleScriptClient(spotify.AppleScriptOptions{Fallback: fallback})
}
func (c *Context) newConnectClient(source cookies.Source) (*spotify.ConnectClient, error) {
return spotify.NewConnectClient(spotify.ConnectOptions{
Source: source,
Market: c.Profile.Market,
Language: c.Profile.Language,
Device: c.Profile.Device,
Timeout: c.Settings.Timeout,
})
}
func (c *Context) newWebClient(source cookies.Source) (*spotify.Client, error) {
return spotify.NewClient(spotify.Options{
TokenProvider: spotify.CookieTokenProvider{Source: source},
Market: c.Profile.Market,
Language: c.Profile.Language,
Device: c.Profile.Device,
Timeout: c.Settings.Timeout,
})
}
func (c *Context) engine() engineName {
engine := engineName(strings.ToLower(strings.TrimSpace(c.Profile.Engine)))
if engine == "" {
return engineConnect
}
return engine
}
func (c *Context) cookieSource() (cookies.Source, error) {
if c.Profile.CookiePath != "" {
return cookies.FileSource{Path: c.Profile.CookiePath}, nil
}
defaultPath := c.ResolveCookiePath()
if defaultPath != "" {
if _, err := os.Stat(defaultPath); err == nil {
return cookies.FileSource{Path: defaultPath}, nil
}
}
return cookies.BrowserSource{
Browser: defaultBrowser(c.Profile.Browser),
Profile: c.Profile.BrowserProfile,
Domain: "spotify.com",
}, nil
}
func defaultBrowser(browser string) string {
browser = strings.TrimSpace(browser)
if browser == "" {
return "chrome"
}
return browser
}

View File

@ -1,66 +0,0 @@
package app
import (
"context"
"github.com/steipete/spogo/internal/config"
)
func NewContext(settings Settings) (*Context, error) {
configPath, err := resolveConfigPath(settings.ConfigPath)
if err != nil {
return nil, err
}
cfg, err := config.Load(configPath)
if err != nil {
return nil, err
}
profileKey := resolveProfileKey(cfg, settings.Profile)
profile := applySettings(cfg.Profile(profileKey), settings)
writer, err := newOutputWriter(settings)
if err != nil {
return nil, err
}
return &Context{
Settings: settings,
Config: cfg,
ConfigPath: configPath,
Profile: profile,
ProfileKey: profileKey,
Output: writer,
commandCtx: context.Background(),
}, nil
}
func resolveConfigPath(configPath string) (string, error) {
if configPath != "" {
return configPath, nil
}
return config.DefaultPath()
}
func resolveProfileKey(cfg *config.Config, requested string) string {
if requested != "" {
return requested
}
if cfg != nil && cfg.DefaultProfile != "" {
return cfg.DefaultProfile
}
return config.DefaultProfile
}
func applySettings(profile config.Profile, settings Settings) config.Profile {
if settings.Market != "" {
profile.Market = settings.Market
}
if settings.Language != "" {
profile.Language = settings.Language
}
if settings.Device != "" {
profile.Device = settings.Device
}
if settings.Engine != "" {
profile.Engine = settings.Engine
}
return profile
}

View File

@ -1,38 +0,0 @@
package app
import (
"os"
"strings"
"github.com/mattn/go-isatty"
"github.com/steipete/spogo/internal/output"
)
func isColorEnabled(format output.Format, noColor bool) bool {
if format != output.FormatHuman {
return false
}
if noColor {
return false
}
if os.Getenv("NO_COLOR") != "" {
return false
}
term := strings.ToLower(os.Getenv("TERM"))
if term == "dumb" {
return false
}
return isatty.IsTerminal(os.Stdout.Fd())
}
func newOutputWriter(settings Settings) (*output.Writer, error) {
format := settings.Format
if format == "" {
format = output.FormatHuman
}
return output.New(output.Options{
Format: format,
Color: isColorEnabled(format, settings.NoColor),
Quiet: settings.Quiet,
})
}

View File

@ -225,99 +225,6 @@ func TestSetSpotify(t *testing.T) {
}
}
func TestCommandContextFallbacks(t *testing.T) {
var nilCtx *Context
if nilCtx.CommandContext() == nil {
t.Fatalf("expected background context for nil app context")
}
ctx := &Context{}
if ctx.CommandContext() == nil {
t.Fatalf("expected background context for empty app context")
}
}
func TestSetCommandContext(t *testing.T) {
ctx := &Context{}
cmdCtx, cancel := context.WithCancel(context.Background())
ctx.SetCommandContext(cmdCtx)
cancel()
select {
case <-ctx.CommandContext().Done():
default:
t.Fatalf("expected stored command context")
}
var nilContext context.Context
ctx.SetCommandContext(nilContext)
if ctx.CommandContext() == nil {
t.Fatalf("expected background context after nil reset")
}
var nilCtx *Context
nilCtx.SetCommandContext(cmdCtx)
}
func TestNewContextAppliesRequestedProfileAndSettings(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
cfg := config.Default()
cfg.SetProfile("work", config.Profile{
Market: "US",
Language: "en",
Device: "speaker",
Engine: "web",
})
if err := config.Save(path, cfg); err != nil {
t.Fatalf("save: %v", err)
}
ctx, err := NewContext(Settings{
ConfigPath: path,
Profile: "work",
Market: "DE",
Language: "de",
Device: "desktop",
Engine: "auto",
Format: output.FormatPlain,
})
if err != nil {
t.Fatalf("new context: %v", err)
}
if ctx.ProfileKey != "work" {
t.Fatalf("profile key: %s", ctx.ProfileKey)
}
if ctx.Profile.Market != "DE" || ctx.Profile.Language != "de" || ctx.Profile.Device != "desktop" || ctx.Profile.Engine != "auto" {
t.Fatalf("profile overrides not applied: %+v", ctx.Profile)
}
}
func TestResolveProfileKeyFallbacks(t *testing.T) {
if got := resolveProfileKey(nil, "work"); got != "work" {
t.Fatalf("requested profile: %s", got)
}
if got := resolveProfileKey(&config.Config{DefaultProfile: "primary"}, ""); got != "primary" {
t.Fatalf("default profile: %s", got)
}
if got := resolveProfileKey(nil, ""); got != config.DefaultProfile {
t.Fatalf("fallback profile: %s", got)
}
}
func TestApplySettingsKeepsEmptyValues(t *testing.T) {
profile := applySettings(config.Profile{
Market: "US",
Language: "en",
Device: "speaker",
Engine: "web",
}, Settings{})
if profile.Market != "US" || profile.Language != "en" || profile.Device != "speaker" || profile.Engine != "web" {
t.Fatalf("profile changed unexpectedly: %+v", profile)
}
}
func TestValidateProfileOK(t *testing.T) {
ctx := &Context{Profile: config.Profile{Market: "US"}}
if err := ctx.ValidateProfile(); err != nil {

View File

@ -1,9 +1,20 @@
package cli
import (
"context"
"fmt"
"net/http"
"os/exec"
"strings"
"github.com/steipete/spogo/internal/app"
"github.com/steipete/spogo/internal/cookies"
"github.com/steipete/spogo/internal/output"
)
type AuthCmd struct {
Status AuthStatusCmd `kong:"cmd,help='Show cookie status.'"`
Import AuthImportCmd `kong:"cmd,help='Import browser cookies.'"`
Paste AuthPasteCmd `kong:"cmd,help='Paste cookie values from the browser.'"`
Clear AuthClearCmd `kong:"cmd,help='Clear stored cookies.'"`
}
@ -16,18 +27,136 @@ type AuthImportCmd struct {
Domain string `help:"Cookie domain suffix." default:"spotify.com"`
}
type AuthPasteCmd struct {
CookiePath string `help:"Cookie cache file path."`
Domain string `help:"Cookie domain suffix." default:"spotify.com"`
Path string `help:"Cookie path." default:"/"`
}
type AuthClearCmd struct{}
type authStatusPayload struct {
CookieCount int `json:"cookie_count"`
HasSPDC bool `json:"has_sp_dc"`
HasSPT bool `json:"has_sp_t"`
HasSPKey bool `json:"has_sp_key"`
Source string `json:"source"`
}
func (cmd *AuthStatusCmd) Run(ctx *app.Context) error {
cookiesList, sourceLabel, err := readCookies(ctx)
if err != nil {
return err
}
hasSPDC := false
for _, cookie := range cookiesList {
if cookie.Name == "sp_dc" {
hasSPDC = true
break
}
}
payload := authStatusPayload{
CookieCount: len(cookiesList),
HasSPDC: hasSPDC,
Source: sourceLabel,
}
plain := []string{fmt.Sprintf("%d\t%t\t%s", payload.CookieCount, payload.HasSPDC, payload.Source)}
human := []string{fmt.Sprintf("Cookies: %d (%s)", payload.CookieCount, payload.Source)}
if hasSPDC {
human = append(human, "Session cookie: sp_dc")
} else {
human = append(human, "Session cookie: missing sp_dc")
}
return ctx.Output.Emit(payload, plain, human)
}
func (cmd *AuthImportCmd) Run(ctx *app.Context) error {
browser := strings.ToLower(strings.TrimSpace(cmd.Browser))
profile := strings.TrimSpace(cmd.Profile)
if browser == "" {
browser = ctx.Profile.Browser
}
if browser == "" {
browser = "chrome"
}
if profile == "" {
profile = ctx.Profile.BrowserProfile
}
domain := strings.TrimSpace(cmd.Domain)
source := cookies.BrowserSource{
Browser: browser,
Profile: profile,
Domain: domain,
}
if ctx.Output.Format == output.FormatHuman {
_ = ctx.Output.WriteLines([]string{"Reading browser cookies..."})
}
cookiesList, err := source.Cookies(context.Background())
if err != nil {
return err
}
path := cmd.CookiePath
if path == "" {
path = ctx.ResolveCookiePath()
}
if err := cookies.Write(path, cookiesList); err != nil {
return err
}
profileCfg := ctx.Profile
profileCfg.CookiePath = path
if browser != "" {
profileCfg.Browser = browser
}
if profile != "" {
profileCfg.BrowserProfile = profile
}
if err := ctx.SaveProfile(profileCfg); err != nil {
return err
}
human := []string{fmt.Sprintf("Saved %d cookies to %s", len(cookiesList), path)}
plain := []string{fmt.Sprintf("%d\t%s", len(cookiesList), path)}
payload := map[string]any{
"cookie_count": len(cookiesList),
"path": path,
}
return ctx.Output.Emit(payload, plain, human)
}
func (cmd *AuthClearCmd) Run(ctx *app.Context) error {
path := ctx.ResolveCookiePath()
if path == "" {
return fmt.Errorf("no cookie path configured")
}
if err := trashFile(path); err != nil {
return err
}
plain := []string{"ok"}
human := []string{fmt.Sprintf("Moved %s to Trash", path)}
return ctx.Output.Emit(map[string]string{"status": "ok"}, plain, human)
}
func readCookies(ctx *app.Context) ([]*http.Cookie, string, error) {
cookiePath := ctx.Profile.CookiePath
if cookiePath == "" {
cookiePath = ctx.ResolveCookiePath()
}
if cookiePath != "" {
fileCookies, err := cookies.Read(cookiePath)
if err == nil {
return fileCookies, "file", nil
}
}
browser := strings.ToLower(strings.TrimSpace(ctx.Profile.Browser))
if browser == "" {
browser = "chrome"
}
browserSource := cookies.BrowserSource{Browser: browser, Profile: ctx.Profile.BrowserProfile, Domain: "spotify.com"}
browserCookies, err := browserSource.Cookies(context.Background())
if err != nil {
return nil, "", err
}
return browserCookies, "browser", nil
}
func trashFile(path string) error {
if _, err := exec.LookPath("trash"); err != nil {
return fmt.Errorf("trash command not found; delete %s manually", path)
}
cmd := exec.Command("trash", path)
if err := cmd.Run(); err != nil {
return fmt.Errorf("trash failed: %w", err)
}
return nil
}

View File

@ -1,69 +0,0 @@
package cli
import (
"fmt"
"net/http"
"strings"
"github.com/steipete/spogo/internal/app"
"github.com/steipete/spogo/internal/config"
"github.com/steipete/spogo/internal/cookies"
"github.com/steipete/spogo/internal/output"
)
func (cmd *AuthImportCmd) Run(ctx *app.Context) error {
source := cookies.BrowserSource{
Browser: normalizeBrowserName(cmd.Browser, ctx.Profile.Browser),
Profile: normalizeBrowserProfile(cmd.Profile, ctx.Profile.BrowserProfile),
Domain: strings.TrimSpace(cmd.Domain),
}
if ctx.Output.Format == output.FormatHuman {
_ = ctx.Output.WriteLines([]string{"Reading browser cookies..."})
}
cookiesList, err := source.Cookies(ctx.CommandContext())
if err != nil {
return err
}
profileCfg := ctx.Profile
profileCfg.Browser = source.Browser
if source.Profile != "" {
profileCfg.BrowserProfile = source.Profile
}
return saveCookies(ctx, cmd.CookiePath, cookiesList, profileCfg)
}
func normalizeBrowserName(primary, fallback string) string {
browser := strings.ToLower(strings.TrimSpace(primary))
if browser == "" {
browser = strings.TrimSpace(fallback)
}
if browser == "" {
return "chrome"
}
return browser
}
func normalizeBrowserProfile(primary, fallback string) string {
profile := strings.TrimSpace(primary)
if profile == "" {
return strings.TrimSpace(fallback)
}
return profile
}
func saveCookies(ctx *app.Context, path string, cookiesList []*http.Cookie, profileCfg config.Profile) error {
if path == "" {
path = ctx.ResolveCookiePath()
}
if err := cookies.Write(path, cookiesList); err != nil {
return err
}
profileCfg.CookiePath = path
if err := ctx.SaveProfile(profileCfg); err != nil {
return err
}
human := []string{fmt.Sprintf("Saved %d cookies to %s", len(cookiesList), path)}
plain := []string{fmt.Sprintf("%d\t%s", len(cookiesList), path)}
payload := map[string]any{"cookie_count": len(cookiesList), "path": path}
return ctx.Output.Emit(payload, plain, human)
}

View File

@ -1,191 +0,0 @@
package cli
import (
"bufio"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"github.com/mattn/go-isatty"
"github.com/steipete/spogo/internal/app"
"github.com/steipete/spogo/internal/output"
)
type pastedCookies struct {
spdc string
spkey string
spt string
}
func (cmd *AuthPasteCmd) Run(ctx *app.Context) error {
stdinIsTTY := isatty.IsTerminal(os.Stdin.Fd())
if ctx.Settings.NoInput && stdinIsTTY {
return errors.New("--no-input set; pipe cookie values via stdin")
}
values, err := readPastedCookies(os.Stdin, ctx.Output, stdinIsTTY && !ctx.Settings.NoInput)
if err != nil {
return err
}
if values.spdc == "" {
return errors.New("sp_dc required")
}
cookiesList := buildPastedCookies(values, normalizeCookieDomain(cmd.Domain), normalizeCookiePath(cmd.Path))
if values.spt == "" && warnsOnMissingDeviceCookie(ctx.Profile.Engine) {
_, _ = fmt.Fprintln(ctx.Output.Err, "warning: missing sp_t; playback may fail (grab sp_t from DevTools)")
}
return saveCookies(ctx, cmd.CookiePath, cookiesList, ctx.Profile)
}
func readPastedCookies(r io.Reader, out *output.Writer, interactive bool) (pastedCookies, error) {
if interactive {
return promptPastedCookies(out)
}
return parsePastedCookies(r)
}
func promptPastedCookies(out *output.Writer) (pastedCookies, error) {
reader := bufio.NewReader(os.Stdin)
spdc, err := readPromptCookieValue(reader, out, "sp_dc", true)
if err != nil {
return pastedCookies{}, err
}
spkey, err := readPromptCookieValue(reader, out, "sp_key", false)
if err != nil {
return pastedCookies{}, err
}
spt, err := readPromptCookieValue(reader, out, "sp_t", false)
if err != nil {
return pastedCookies{}, err
}
return pastedCookies{spdc: spdc, spkey: spkey, spt: spt}, nil
}
func parsePastedCookies(r io.Reader) (pastedCookies, error) {
if r == nil {
r = os.Stdin
}
scanner := bufio.NewScanner(r)
values := pastedCookies{}
for scanner.Scan() {
line := scanner.Text()
if value, ok := extractNamedCookieValue(line, "sp_dc"); ok {
values.spdc = value
}
if value, ok := extractNamedCookieValue(line, "sp_key"); ok {
values.spkey = value
}
if value, ok := extractNamedCookieValue(line, "sp_t"); ok {
values.spt = value
}
}
if err := scanner.Err(); err != nil {
return pastedCookies{}, err
}
return values, nil
}
func readPromptCookieValue(reader *bufio.Reader, out *output.Writer, name string, required bool) (string, error) {
if reader == nil {
reader = bufio.NewReader(os.Stdin)
}
if out != nil {
_, _ = fmt.Fprintf(out.Err, "Paste %s value: ", name)
}
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
value := normalizePromptCookieValue(line, name)
if value == "" && required {
return "", fmt.Errorf("%s required", name)
}
return value, nil
}
func buildPastedCookies(values pastedCookies, domain, path string) []*http.Cookie {
cookiesList := []*http.Cookie{newCookie("sp_dc", values.spdc, domain, path)}
if values.spkey != "" {
cookiesList = append(cookiesList, newCookie("sp_key", values.spkey, domain, path))
}
if values.spt != "" {
cookiesList = append(cookiesList, newCookie("sp_t", values.spt, domain, path))
}
return cookiesList
}
func newCookie(name, value, domain, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Domain: domain,
Path: path,
Secure: true,
HttpOnly: true,
}
}
func warnsOnMissingDeviceCookie(engine string) bool {
switch strings.ToLower(strings.TrimSpace(engine)) {
case "", "connect", "auto":
return true
default:
return false
}
}
func normalizeCookieDomain(domain string) string {
trimmed := strings.TrimSpace(domain)
if trimmed == "" {
trimmed = "spotify.com"
}
if strings.Contains(trimmed, "://") {
if parsed, err := url.Parse(trimmed); err == nil && parsed.Hostname() != "" {
trimmed = parsed.Hostname()
}
}
if !strings.HasPrefix(trimmed, ".") {
trimmed = "." + trimmed
}
return trimmed
}
func normalizeCookiePath(path string) string {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
return "/"
}
return trimmed
}
func normalizePromptCookieValue(value, name string) string {
if parsed, ok := extractNamedCookieValue(value, name); ok {
return parsed
}
return trimCookieValue(value)
}
func extractNamedCookieValue(value, name string) (string, bool) {
trimmed := strings.Trim(strings.TrimSpace(value), "\"'")
if trimmed == "" {
return "", false
}
for _, part := range strings.Split(trimmed, ";") {
part = strings.TrimSpace(part)
key, val, found := strings.Cut(part, "=")
if !found {
continue
}
if strings.EqualFold(strings.TrimSpace(key), name) {
return trimCookieValue(val), true
}
}
return "", false
}
func trimCookieValue(value string) string {
return strings.Trim(strings.TrimSpace(value), "\"'")
}

View File

@ -1,144 +0,0 @@
package cli
import (
"bufio"
"path/filepath"
"strings"
"testing"
"github.com/steipete/spogo/internal/config"
"github.com/steipete/spogo/internal/cookies"
"github.com/steipete/spogo/internal/output"
"github.com/steipete/spogo/internal/testutil"
)
func TestNormalizeCookieDomain(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", ".spotify.com"},
{"spotify.com", ".spotify.com"},
{".spotify.com", ".spotify.com"},
{"https://open.spotify.com/", ".open.spotify.com"},
}
for _, tc := range cases {
if got := normalizeCookieDomain(tc.in); got != tc.want {
t.Fatalf("normalizeCookieDomain(%q)=%q, want %q", tc.in, got, tc.want)
}
}
}
func TestNormalizeCookieValue(t *testing.T) {
if got, ok := extractNamedCookieValue("sp_dc=token; Path=/; Secure", "sp_dc"); !ok || got != "token" {
t.Fatalf("expected token, got %q", got)
}
if got := normalizePromptCookieValue("\"token\"", "sp_dc"); got != "token" {
t.Fatalf("expected token, got %q", got)
}
if got := normalizePromptCookieValue("token", "sp_dc"); got != "token" {
t.Fatalf("expected token, got %q", got)
}
}
func TestReadPromptCookieValueEOF(t *testing.T) {
reader := bufio.NewReader(strings.NewReader("sp_dc=token"))
value, err := readPromptCookieValue(reader, nil, "sp_dc", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if value != "token" {
t.Fatalf("expected token, got %q", value)
}
}
func TestReadPromptCookieValueRequiredRejectsEmpty(t *testing.T) {
reader := bufio.NewReader(strings.NewReader("\n"))
_, err := readPromptCookieValue(reader, nil, "sp_dc", true)
if err == nil {
t.Fatalf("expected error")
}
}
func TestParsePastedCookiesAnyOrder(t *testing.T) {
values, err := parsePastedCookies(strings.NewReader("sp_t=device\nsp_dc=token\nsp_key=key\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if values.spdc != "token" || values.spkey != "key" || values.spt != "device" {
t.Fatalf("unexpected values: %#v", values)
}
}
func TestAuthPasteCmdFromStdin(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
ctx.Config = config.Default()
ctx.ConfigPath = filepath.Join(t.TempDir(), "config.toml")
ctx.ProfileKey = "default"
dest := filepath.Join(t.TempDir(), "out.json")
withStdin(t, "sp_t=device\nsp_dc=token\nsp_key=key\n", func() {
cmd := AuthPasteCmd{CookiePath: dest, Domain: "spotify.com"}
if err := cmd.Run(ctx); err != nil {
t.Fatalf("run: %v", err)
}
})
cookiesList, err := cookies.Read(dest)
if err != nil {
t.Fatalf("read cookies: %v", err)
}
if len(cookiesList) != 3 {
t.Fatalf("expected 3 cookies, got %d", len(cookiesList))
}
if ctx.Profile.CookiePath != dest {
t.Fatalf("expected profile cookie path %s, got %s", dest, ctx.Profile.CookiePath)
}
}
func TestAuthPasteCmdWarnsWhenMissingSPT(t *testing.T) {
ctx, _, errOut := testutil.NewTestContext(t, output.FormatPlain)
ctx.Config = config.Default()
ctx.ConfigPath = filepath.Join(t.TempDir(), "config.toml")
ctx.ProfileKey = "default"
ctx.Profile = config.Profile{Engine: "connect"}
dest := filepath.Join(t.TempDir(), "out.json")
withStdin(t, "sp_dc=token\n", func() {
cmd := AuthPasteCmd{CookiePath: dest}
if err := cmd.Run(ctx); err != nil {
t.Fatalf("run: %v", err)
}
})
if !strings.Contains(errOut.String(), "missing sp_t") {
t.Fatalf("expected warning in stderr, got %q", errOut.String())
}
}
func TestAuthPasteCmdRequiresSPDC(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
withStdin(t, "", func() {
cmd := AuthPasteCmd{}
if err := cmd.Run(ctx); err == nil {
t.Fatalf("expected error")
}
})
}
func TestAuthPasteCmdNoInputFromStdin(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
ctx.Config = config.Default()
ctx.ConfigPath = filepath.Join(t.TempDir(), "config.toml")
ctx.ProfileKey = "default"
ctx.Settings.NoInput = true
dest := filepath.Join(t.TempDir(), "out.json")
withStdin(t, "sp_dc=token\nsp_t=device\n", func() {
cmd := AuthPasteCmd{CookiePath: dest}
if err := cmd.Run(ctx); err != nil {
t.Fatalf("run: %v", err)
}
})
if _, err := cookies.Read(dest); err != nil {
t.Fatalf("expected cookies file")
}
}

View File

@ -1,50 +0,0 @@
package cli
import (
"fmt"
"net/http"
"github.com/steipete/spogo/internal/app"
)
func (cmd *AuthStatusCmd) Run(ctx *app.Context) error {
cookiesList, sourceLabel, err := readCookies(ctx)
if err != nil {
return err
}
payload := authStatusPayload{
CookieCount: len(cookiesList),
HasSPDC: hasCookie(cookiesList, "sp_dc"),
HasSPT: hasCookie(cookiesList, "sp_t"),
HasSPKey: hasCookie(cookiesList, "sp_key"),
Source: sourceLabel,
}
plain := []string{fmt.Sprintf("%d\t%t\t%t\t%t\t%s", payload.CookieCount, payload.HasSPDC, payload.HasSPT, payload.HasSPKey, payload.Source)}
human := []string{fmt.Sprintf("Cookies: %d (%s)", payload.CookieCount, payload.Source)}
human = append(human, cookieStatusLine("Session cookie", "sp_dc", payload.HasSPDC, ""))
human = append(human, cookieStatusLine("Device cookie", "sp_t", payload.HasSPT, "needed for connect playback"))
if payload.HasSPKey {
human = append(human, "Optional cookie: sp_key")
}
return ctx.Output.Emit(payload, plain, human)
}
func hasCookie(cookiesList []*http.Cookie, name string) bool {
for _, cookie := range cookiesList {
if cookie.Name == name {
return true
}
}
return false
}
func cookieStatusLine(label, name string, present bool, missingHint string) string {
if present {
return fmt.Sprintf("%s: %s", label, name)
}
line := fmt.Sprintf("%s: missing %s", label, name)
if missingHint != "" {
line += " (" + missingHint + ")"
}
return line
}

View File

@ -1,80 +0,0 @@
package cli
import (
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"github.com/steipete/spogo/internal/app"
"github.com/steipete/spogo/internal/cookies"
)
func (cmd *AuthClearCmd) Run(ctx *app.Context) error {
path := activeCookiePath(ctx)
if path == "" {
return fmt.Errorf("no cookie path configured")
}
if _, err := os.Stat(path); err == nil {
if err := trashFile(path); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
profileCfg := ctx.Profile
profileCfg.CookiePath = ""
if err := ctx.SaveProfile(profileCfg); err != nil {
return err
}
return ctx.Output.Emit(map[string]string{"status": "ok"}, []string{"ok"}, []string{fmt.Sprintf("Moved %s to Trash", path)})
}
func readCookies(ctx *app.Context) ([]*http.Cookie, string, error) {
path := activeCookiePath(ctx)
if path != "" {
fileCookies, err := cookies.Read(path)
if err == nil {
return fileCookies, "file", nil
}
}
source := cookies.BrowserSource{
Browser: defaultBrowserName(ctx.Profile.Browser),
Profile: ctx.Profile.BrowserProfile,
Domain: "spotify.com",
}
browserCookies, err := source.Cookies(ctx.CommandContext())
if err != nil {
return nil, "", err
}
return browserCookies, "browser", nil
}
func activeCookiePath(ctx *app.Context) string {
path := strings.TrimSpace(ctx.Profile.CookiePath)
if path == "" {
path = ctx.ResolveCookiePath()
}
return path
}
func defaultBrowserName(browser string) string {
browser = strings.TrimSpace(browser)
if browser == "" {
return "chrome"
}
return browser
}
func trashFile(path string) error {
if _, err := exec.LookPath("trash"); err != nil {
return fmt.Errorf("trash command not found; delete %s manually", path)
}
cmd := exec.Command("trash", path)
if err := cmd.Run(); err != nil {
return fmt.Errorf("trash failed: %w", err)
}
return nil
}

View File

@ -1,54 +0,0 @@
package cli
import (
"os"
"path/filepath"
"testing"
"github.com/steipete/spogo/internal/config"
"github.com/steipete/spogo/internal/output"
"github.com/steipete/spogo/internal/testutil"
)
func TestAuthClearCmdNoPath(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
cmd := AuthClearCmd{}
if err := cmd.Run(ctx); err == nil {
t.Fatalf("expected error")
}
}
func TestAuthClearCmdSuccess(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
dir := t.TempDir()
ctx.Config = config.Default()
ctx.ConfigPath = filepath.Join(dir, "config.toml")
ctx.ProfileKey = "default"
path := filepath.Join(dir, "cookies", "default.json")
ctx.Profile.CookiePath = path
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte("[]"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
script := filepath.Join(dir, "trash")
if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write: %v", err)
}
t.Setenv("PATH", dir)
cmd := AuthClearCmd{}
if err := cmd.Run(ctx); err != nil {
t.Fatalf("run: %v", err)
}
if ctx.Profile.CookiePath != "" {
t.Fatalf("expected profile cookie path cleared, got %q", ctx.Profile.CookiePath)
}
}
func TestTrashFileMissing(t *testing.T) {
t.Setenv("PATH", "")
if err := trashFile("/tmp/missing"); err == nil {
t.Fatalf("expected error")
}
}

View File

@ -3,6 +3,7 @@ package cli
import (
"context"
"net/http"
"os"
"path/filepath"
"testing"
@ -97,19 +98,52 @@ func TestAuthImportCmdDefaultPath(t *testing.T) {
}
}
func TestGlobalsSettingsPassesNoInput(t *testing.T) {
settings, err := (Globals{NoInput: true}).Settings()
if err != nil {
t.Fatalf("settings: %v", err)
}
if !settings.NoInput {
t.Fatalf("expected no_input true")
}
}
func TestGlobalsSettingsRejectsPlainAndJSON(t *testing.T) {
_, err := (Globals{JSON: true, Plain: true}).Settings()
if err == nil {
func TestAuthClearCmdNoPath(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
cmd := AuthClearCmd{}
if err := cmd.Run(ctx); err == nil {
t.Fatalf("expected error")
}
}
func TestAuthClearCmdSuccess(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
dir := t.TempDir()
ctx.ConfigPath = filepath.Join(dir, "config.toml")
ctx.ProfileKey = "default"
path := filepath.Join(dir, "cookies", "default.json")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte("[]"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
script := filepath.Join(dir, "trash")
if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write: %v", err)
}
t.Setenv("PATH", dir)
cmd := AuthClearCmd{}
if err := cmd.Run(ctx); err != nil {
t.Fatalf("run: %v", err)
}
}
func TestTrashFileMissing(t *testing.T) {
t.Setenv("PATH", "")
if err := trashFile("/tmp/missing"); err == nil {
t.Fatalf("expected error")
}
}
func TestTrashFileSuccess(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "trash")
if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write: %v", err)
}
t.Setenv("PATH", dir)
if err := trashFile("/tmp/missing"); err != nil {
t.Fatalf("expected success")
}
}

View File

@ -1,25 +0,0 @@
package cli
import (
"os"
"testing"
)
func withStdin(t *testing.T, contents string, fn func()) {
t.Helper()
old := os.Stdin
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
if _, err := w.WriteString(contents); err != nil {
_ = r.Close()
_ = w.Close()
t.Fatalf("write: %v", err)
}
_ = w.Close()
os.Stdin = r
t.Cleanup(func() { os.Stdin = old })
fn()
_ = r.Close()
}

View File

@ -10,7 +10,7 @@ import (
"github.com/steipete/spogo/internal/output"
)
const Version = "0.3.0"
const Version = "0.1.0"
func New() *CLI {
return &CLI{}
@ -50,7 +50,7 @@ type Globals struct {
Market string `help:"Market country code." env:"SPOGO_MARKET"`
Language string `help:"Language/locale." env:"SPOGO_LANGUAGE"`
Device string `help:"Device name or id." env:"SPOGO_DEVICE"`
Engine string `help:"Engine (auto|web|connect|applescript)." env:"SPOGO_ENGINE"`
Engine string `help:"Engine (auto|web|connect)." env:"SPOGO_ENGINE"`
JSON bool `help:"JSON output." env:"SPOGO_JSON"`
Plain bool `help:"Plain output." env:"SPOGO_PLAIN"`
NoColor bool `help:"Disable color output." env:"SPOGO_NO_COLOR"`
@ -79,7 +79,6 @@ func (g Globals) Settings() (app.Settings, error) {
Quiet: g.Quiet,
Verbose: g.Verbose,
Debug: g.Debug,
NoInput: g.NoInput,
}, nil
}

View File

@ -1,6 +1,7 @@
package cli
import (
"context"
"fmt"
"strings"
@ -19,11 +20,11 @@ type DeviceSetCmd struct {
}
func (cmd *DeviceListCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
devices, err := client.Devices(cmdCtx)
devices, err := client.Devices(context.Background())
if err != nil {
return err
}
@ -41,11 +42,11 @@ func (cmd *DeviceListCmd) Run(ctx *app.Context) error {
}
func (cmd *DeviceSetCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
devices, err := client.Devices(cmdCtx)
devices, err := client.Devices(context.Background())
if err != nil {
return err
}
@ -56,10 +57,10 @@ func (cmd *DeviceSetCmd) Run(ctx *app.Context) error {
break
}
}
if err := client.Transfer(cmdCtx, id); err != nil {
if err := client.Transfer(context.Background(), id); err != nil {
return err
}
return emitOK(ctx, map[string]any{"status": "ok", "device": id}, fmt.Sprintf("Switched to %s", id))
return ctx.Output.Emit(map[string]any{"status": "ok", "device": id}, []string{"ok"}, []string{fmt.Sprintf("Switched to %s", id)})
}
func activeMarker(active bool) string {

View File

@ -1,66 +0,0 @@
package cli
import (
"context"
"errors"
"fmt"
"github.com/steipete/spogo/internal/app"
"github.com/steipete/spogo/internal/spotify"
)
type itemLookup func(context.Context, spotify.API, string) (spotify.Item, error)
func spotifyClient(ctx *app.Context) (spotify.API, context.Context, error) {
if ctx == nil {
return nil, context.Background(), errors.New("nil context")
}
client, err := ctx.Spotify()
if err != nil {
return nil, ctx.CommandContext(), err
}
return client, ctx.CommandContext(), nil
}
func runInfoLookup(ctx *app.Context, input string, kind string, lookup itemLookup) error {
client, cmdCtx, err := spotifyClient(ctx)
if err != nil {
return err
}
res, err := spotify.ParseTypedID(input, kind)
if err != nil {
return err
}
item, err := lookup(cmdCtx, client, res.ID)
if err != nil {
return err
}
return emitItem(ctx, item)
}
func emitItem(ctx *app.Context, item spotify.Item) error {
return ctx.Output.Emit(item, []string{itemPlain(item)}, []string{itemHuman(ctx.Output, item)})
}
func emitItems(ctx *app.Context, items []spotify.Item, total int, extras map[string]any) error {
plain, human := renderItems(ctx.Output, items)
payload := map[string]any{
"total": total,
"items": items,
}
for key, value := range extras {
payload[key] = value
}
return ctx.Output.Emit(payload, plain, human)
}
func emitOK(ctx *app.Context, payload map[string]any, human string) error {
if payload == nil {
payload = map[string]any{"status": "ok"}
}
return ctx.Output.Emit(payload, []string{"ok"}, []string{human})
}
func emitCountStatus(ctx *app.Context, count int, label string) error {
return emitOK(ctx, map[string]any{"status": "ok", "count": count}, fmt.Sprintf("%s %d items", label, count))
}

View File

@ -52,37 +52,97 @@ type InfoShowCmd struct{ InfoArgs }
type InfoEpisodeCmd struct{ InfoArgs }
func (cmd *InfoTrackCmd) Run(ctx *app.Context) error {
return runInfoLookup(ctx, cmd.ID, "track", func(cmdCtx context.Context, client spotify.API, id string) (spotify.Item, error) {
return client.GetTrack(cmdCtx, id)
})
client, err := ctx.Spotify()
if err != nil {
return err
}
res, err := spotify.ParseTypedID(cmd.ID, "track")
if err != nil {
return err
}
item, err := client.GetTrack(context.Background(), res.ID)
if err != nil {
return err
}
return ctx.Output.Emit(item, []string{itemPlain(item)}, []string{itemHuman(ctx.Output, item)})
}
func (cmd *InfoAlbumCmd) Run(ctx *app.Context) error {
return runInfoLookup(ctx, cmd.ID, "album", func(cmdCtx context.Context, client spotify.API, id string) (spotify.Item, error) {
return client.GetAlbum(cmdCtx, id)
})
client, err := ctx.Spotify()
if err != nil {
return err
}
res, err := spotify.ParseTypedID(cmd.ID, "album")
if err != nil {
return err
}
item, err := client.GetAlbum(context.Background(), res.ID)
if err != nil {
return err
}
return ctx.Output.Emit(item, []string{itemPlain(item)}, []string{itemHuman(ctx.Output, item)})
}
func (cmd *InfoArtistCmd) Run(ctx *app.Context) error {
return runInfoLookup(ctx, cmd.ID, "artist", func(cmdCtx context.Context, client spotify.API, id string) (spotify.Item, error) {
return client.GetArtist(cmdCtx, id)
})
client, err := ctx.Spotify()
if err != nil {
return err
}
res, err := spotify.ParseTypedID(cmd.ID, "artist")
if err != nil {
return err
}
item, err := client.GetArtist(context.Background(), res.ID)
if err != nil {
return err
}
return ctx.Output.Emit(item, []string{itemPlain(item)}, []string{itemHuman(ctx.Output, item)})
}
func (cmd *InfoPlaylistCmd) Run(ctx *app.Context) error {
return runInfoLookup(ctx, cmd.ID, "playlist", func(cmdCtx context.Context, client spotify.API, id string) (spotify.Item, error) {
return client.GetPlaylist(cmdCtx, id)
})
client, err := ctx.Spotify()
if err != nil {
return err
}
res, err := spotify.ParseTypedID(cmd.ID, "playlist")
if err != nil {
return err
}
item, err := client.GetPlaylist(context.Background(), res.ID)
if err != nil {
return err
}
return ctx.Output.Emit(item, []string{itemPlain(item)}, []string{itemHuman(ctx.Output, item)})
}
func (cmd *InfoShowCmd) Run(ctx *app.Context) error {
return runInfoLookup(ctx, cmd.ID, "show", func(cmdCtx context.Context, client spotify.API, id string) (spotify.Item, error) {
return client.GetShow(cmdCtx, id)
})
client, err := ctx.Spotify()
if err != nil {
return err
}
res, err := spotify.ParseTypedID(cmd.ID, "show")
if err != nil {
return err
}
item, err := client.GetShow(context.Background(), res.ID)
if err != nil {
return err
}
return ctx.Output.Emit(item, []string{itemPlain(item)}, []string{itemHuman(ctx.Output, item)})
}
func (cmd *InfoEpisodeCmd) Run(ctx *app.Context) error {
return runInfoLookup(ctx, cmd.ID, "episode", func(cmdCtx context.Context, client spotify.API, id string) (spotify.Item, error) {
return client.GetEpisode(cmdCtx, id)
})
client, err := ctx.Spotify()
if err != nil {
return err
}
res, err := spotify.ParseTypedID(cmd.ID, "episode")
if err != nil {
return err
}
item, err := client.GetEpisode(context.Background(), res.ID)
if err != nil {
return err
}
return ctx.Output.Emit(item, []string{itemPlain(item)}, []string{itemHuman(ctx.Output, item)})
}

View File

@ -1,6 +1,7 @@
package cli
import (
"context"
"fmt"
"strings"
@ -83,16 +84,18 @@ type LibraryPlaylistsListCmd struct {
}
func (cmd *LibraryTracksListCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
limit := clampLimit(cmd.Limit)
items, total, err := client.LibraryTracks(cmdCtx, limit, cmd.Offset)
items, total, err := client.LibraryTracks(context.Background(), limit, cmd.Offset)
if err != nil {
return err
}
return emitItems(ctx, items, total, nil)
plain, human := renderItems(ctx.Output, items)
payload := map[string]any{"total": total, "items": items}
return ctx.Output.Emit(payload, plain, human)
}
func (cmd *LibraryTracksAddCmd) Run(ctx *app.Context) error {
@ -100,14 +103,14 @@ func (cmd *LibraryTracksAddCmd) Run(ctx *app.Context) error {
if err != nil {
return err
}
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
if err := client.LibraryModify(cmdCtx, "/me/tracks", ids, "PUT"); err != nil {
if err := client.LibraryModify(context.Background(), "/me/tracks", ids, "PUT"); err != nil {
return err
}
return emitCountStatus(ctx, len(ids), "Updated")
return ok(ctx, len(ids))
}
func (cmd *LibraryTracksRemoveCmd) Run(ctx *app.Context) error {
@ -115,27 +118,29 @@ func (cmd *LibraryTracksRemoveCmd) Run(ctx *app.Context) error {
if err != nil {
return err
}
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
if err := client.LibraryModify(cmdCtx, "/me/tracks", ids, "DELETE"); err != nil {
if err := client.LibraryModify(context.Background(), "/me/tracks", ids, "DELETE"); err != nil {
return err
}
return emitCountStatus(ctx, len(ids), "Updated")
return ok(ctx, len(ids))
}
func (cmd *LibraryAlbumsListCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
limit := clampLimit(cmd.Limit)
items, total, err := client.LibraryAlbums(cmdCtx, limit, cmd.Offset)
items, total, err := client.LibraryAlbums(context.Background(), limit, cmd.Offset)
if err != nil {
return err
}
return emitItems(ctx, items, total, nil)
plain, human := renderItems(ctx.Output, items)
payload := map[string]any{"total": total, "items": items}
return ctx.Output.Emit(payload, plain, human)
}
func (cmd *LibraryAlbumsAddCmd) Run(ctx *app.Context) error {
@ -143,14 +148,14 @@ func (cmd *LibraryAlbumsAddCmd) Run(ctx *app.Context) error {
if err != nil {
return err
}
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
if err := client.LibraryModify(cmdCtx, "/me/albums", ids, "PUT"); err != nil {
if err := client.LibraryModify(context.Background(), "/me/albums", ids, "PUT"); err != nil {
return err
}
return emitCountStatus(ctx, len(ids), "Updated")
return ok(ctx, len(ids))
}
func (cmd *LibraryAlbumsRemoveCmd) Run(ctx *app.Context) error {
@ -158,18 +163,18 @@ func (cmd *LibraryAlbumsRemoveCmd) Run(ctx *app.Context) error {
if err != nil {
return err
}
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
if err := client.LibraryModify(cmdCtx, "/me/albums", ids, "DELETE"); err != nil {
if err := client.LibraryModify(context.Background(), "/me/albums", ids, "DELETE"); err != nil {
return err
}
return emitCountStatus(ctx, len(ids), "Updated")
return ok(ctx, len(ids))
}
func (cmd *LibraryArtistsListCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
@ -177,7 +182,7 @@ func (cmd *LibraryArtistsListCmd) Run(ctx *app.Context) error {
return fmt.Errorf("offset not supported; use --after with an artist id")
}
limit := clampLimit(cmd.Limit)
items, total, next, err := client.FollowedArtists(cmdCtx, limit, cmd.After)
items, total, next, err := client.FollowedArtists(context.Background(), limit, cmd.After)
if err != nil {
return err
}
@ -191,14 +196,14 @@ func (cmd *LibraryArtistsFollowCmd) Run(ctx *app.Context) error {
if err != nil {
return err
}
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
if err := client.FollowArtists(cmdCtx, ids, "PUT"); err != nil {
if err := client.FollowArtists(context.Background(), ids, "PUT"); err != nil {
return err
}
return emitCountStatus(ctx, len(ids), "Updated")
return ok(ctx, len(ids))
}
func (cmd *LibraryArtistsUnfollowCmd) Run(ctx *app.Context) error {
@ -206,27 +211,29 @@ func (cmd *LibraryArtistsUnfollowCmd) Run(ctx *app.Context) error {
if err != nil {
return err
}
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
if err := client.FollowArtists(cmdCtx, ids, "DELETE"); err != nil {
if err := client.FollowArtists(context.Background(), ids, "DELETE"); err != nil {
return err
}
return emitCountStatus(ctx, len(ids), "Updated")
return ok(ctx, len(ids))
}
func (cmd *LibraryPlaylistsListCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
limit := clampLimit(cmd.Limit)
items, total, err := client.Playlists(cmdCtx, limit, cmd.Offset)
items, total, err := client.Playlists(context.Background(), limit, cmd.Offset)
if err != nil {
return err
}
return emitItems(ctx, items, total, nil)
plain, human := renderItems(ctx.Output, items)
payload := map[string]any{"total": total, "items": items}
return ctx.Output.Emit(payload, plain, human)
}
func parseIDs(inputs []string, kind string) ([]string, error) {
@ -240,3 +247,10 @@ func parseIDs(inputs []string, kind string) ([]string, error) {
}
return ids, nil
}
func ok(ctx *app.Context, count int) error {
payload := map[string]any{"status": "ok", "count": count}
plain := []string{"ok"}
human := []string{fmt.Sprintf("Updated %d items", count)}
return ctx.Output.Emit(payload, plain, human)
}

View File

@ -13,9 +13,8 @@ import (
)
type PlayCmd struct {
Item string `arg:"" optional:"" help:"Spotify ID/URL/URI."`
Type string `help:"Type for raw IDs (track|album|playlist|show|episode)."`
Shuffle bool `help:"Enable shuffle before playing."`
Item string `arg:"" optional:"" help:"Spotify ID/URL/URI."`
Type string `help:"Type for raw IDs (track|album|playlist|show|episode)."`
}
type PauseCmd struct{}
@ -47,7 +46,7 @@ type artistTopTracks interface {
}
func (cmd *PlayCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
@ -69,11 +68,11 @@ func (cmd *PlayCmd) Run(ctx *app.Context) error {
if !ok {
return errors.New("artist playback not supported by engine")
}
tracks, err := topTracks.ArtistTopTracks(cmdCtx, res.ID, 10)
tracks, err := topTracks.ArtistTopTracks(context.Background(), res.ID, 10)
if err == nil && len(tracks) > 0 {
uri = tracks[0].URI
} else {
artist, aerr := client.GetArtist(cmdCtx, res.ID)
artist, aerr := client.GetArtist(context.Background(), res.ID)
if aerr != nil || artist.Name == "" {
if err != nil {
return err
@ -81,7 +80,7 @@ func (cmd *PlayCmd) Run(ctx *app.Context) error {
return errors.New("no artist tracks found")
}
query := fmt.Sprintf("artist:%q", artist.Name)
search, serr := client.Search(cmdCtx, "track", query, 1, 0)
search, serr := client.Search(context.Background(), "track", query, 1, 0)
if serr != nil {
if err != nil {
return err
@ -100,52 +99,47 @@ func (cmd *PlayCmd) Run(ctx *app.Context) error {
uri = res.URI
}
}
if cmd.Shuffle {
if err := client.Shuffle(cmdCtx, true); err != nil {
return err
}
}
if err := client.Play(cmdCtx, uri); err != nil {
if err := client.Play(context.Background(), uri); err != nil {
return err
}
return emitOK(ctx, nil, "Playback started")
return ctx.Output.Emit(map[string]string{"status": "ok"}, []string{"ok"}, []string{"Playback started"})
}
func (cmd *PauseCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
if err := client.Pause(cmdCtx); err != nil {
if err := client.Pause(context.Background()); err != nil {
return err
}
return emitOK(ctx, nil, "Playback paused")
return ctx.Output.Emit(map[string]string{"status": "ok"}, []string{"ok"}, []string{"Playback paused"})
}
func (cmd *NextCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
if err := client.Next(cmdCtx); err != nil {
if err := client.Next(context.Background()); err != nil {
return err
}
return emitOK(ctx, nil, "Skipped to next")
return ctx.Output.Emit(map[string]string{"status": "ok"}, []string{"ok"}, []string{"Skipped to next"})
}
func (cmd *PrevCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
if err := client.Previous(cmdCtx); err != nil {
if err := client.Previous(context.Background()); err != nil {
return err
}
return emitOK(ctx, nil, "Skipped to previous")
return ctx.Output.Emit(map[string]string{"status": "ok"}, []string{"ok"}, []string{"Skipped to previous"})
}
func (cmd *SeekCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
@ -153,24 +147,24 @@ func (cmd *SeekCmd) Run(ctx *app.Context) error {
if err != nil {
return err
}
if err := client.Seek(cmdCtx, position); err != nil {
if err := client.Seek(context.Background(), position); err != nil {
return err
}
return emitOK(ctx, map[string]any{"status": "ok", "position_ms": position}, fmt.Sprintf("Seeked to %s", humanDuration(position)))
return ctx.Output.Emit(map[string]any{"status": "ok", "position_ms": position}, []string{"ok"}, []string{fmt.Sprintf("Seeked to %s", humanDuration(position))})
}
func (cmd *VolumeCmd) Run(ctx *app.Context) error {
if cmd.Level < 0 || cmd.Level > 100 {
return fmt.Errorf("volume must be 0-100")
}
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
if err := client.Volume(cmdCtx, cmd.Level); err != nil {
if err := client.Volume(context.Background(), cmd.Level); err != nil {
return err
}
return emitOK(ctx, map[string]any{"status": "ok", "volume": cmd.Level}, fmt.Sprintf("Volume %d", cmd.Level))
return ctx.Output.Emit(map[string]any{"status": "ok", "volume": cmd.Level}, []string{"ok"}, []string{fmt.Sprintf("Volume %d", cmd.Level)})
}
func (cmd *ShuffleCmd) Run(ctx *app.Context) error {
@ -178,14 +172,14 @@ func (cmd *ShuffleCmd) Run(ctx *app.Context) error {
if err != nil {
return err
}
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
if err := client.Shuffle(cmdCtx, state); err != nil {
if err := client.Shuffle(context.Background(), state); err != nil {
return err
}
return emitOK(ctx, map[string]any{"status": "ok", "shuffle": state}, fmt.Sprintf("Shuffle %s", onOff(state)))
return ctx.Output.Emit(map[string]any{"status": "ok", "shuffle": state}, []string{"ok"}, []string{fmt.Sprintf("Shuffle %s", onOff(state))})
}
func (cmd *RepeatCmd) Run(ctx *app.Context) error {
@ -193,22 +187,22 @@ func (cmd *RepeatCmd) Run(ctx *app.Context) error {
if mode != "off" && mode != "track" && mode != "context" {
return fmt.Errorf("repeat must be off|track|context")
}
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
if err := client.Repeat(cmdCtx, mode); err != nil {
if err := client.Repeat(context.Background(), mode); err != nil {
return err
}
return emitOK(ctx, map[string]any{"status": "ok", "repeat": mode}, fmt.Sprintf("Repeat %s", mode))
return ctx.Output.Emit(map[string]any{"status": "ok", "repeat": mode}, []string{"ok"}, []string{fmt.Sprintf("Repeat %s", mode)})
}
func (cmd *StatusCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
status, err := client.Playback(cmdCtx)
status, err := client.Playback(context.Background())
if err != nil {
return err
}

View File

@ -1,92 +0,0 @@
package cli
import (
"context"
"errors"
"testing"
"github.com/steipete/spogo/internal/output"
"github.com/steipete/spogo/internal/spotify"
"github.com/steipete/spogo/internal/testutil"
)
func TestPlayCmdArtistTopTrack(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
mock := &testutil.SpotifyMock{
ArtistTopTracksFn: func(ctx context.Context, id string, limit int) ([]spotify.Item, error) {
if id != "abc" {
t.Fatalf("id %s", id)
}
if limit != 10 {
t.Fatalf("limit %d", limit)
}
return []spotify.Item{{URI: "spotify:track:top"}}, nil
},
PlayFn: func(ctx context.Context, uri string) error {
if uri != "spotify:track:top" {
t.Fatalf("uri %s", uri)
}
return nil
},
}
ctx.SetSpotify(mock)
cmd := PlayCmd{Item: "spotify:artist:abc"}
if err := cmd.Run(ctx); err != nil {
t.Fatalf("run: %v", err)
}
}
func TestPlayCmdArtistFallbackSearch(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
mock := &testutil.SpotifyMock{
ArtistTopTracksFn: func(context.Context, string, int) ([]spotify.Item, error) { return nil, errors.New("rate limit") },
GetArtistFn: func(context.Context, string) (spotify.Item, error) { return spotify.Item{Name: "Artist"}, nil },
SearchFn: func(context.Context, string, string, int, int) (spotify.SearchResult, error) {
return spotify.SearchResult{Items: []spotify.Item{{URI: "spotify:track:found"}}}, nil
},
PlayFn: func(context.Context, string) error { return nil },
}
ctx.SetSpotify(mock)
if err := (&PlayCmd{Item: "spotify:artist:abc"}).Run(ctx); err != nil {
t.Fatalf("run: %v", err)
}
}
func TestPlayCmdArtistFallbackArtistError(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
ctx.SetSpotify(&testutil.SpotifyMock{
ArtistTopTracksFn: func(context.Context, string, int) ([]spotify.Item, error) { return nil, errors.New("boom") },
GetArtistFn: func(context.Context, string) (spotify.Item, error) { return spotify.Item{}, errors.New("missing") },
})
if err := (&PlayCmd{Item: "spotify:artist:abc"}).Run(ctx); err == nil {
t.Fatalf("expected error")
}
}
func TestPlayCmdArtistFallbackSearchEmpty(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
ctx.SetSpotify(&testutil.SpotifyMock{
ArtistTopTracksFn: func(context.Context, string, int) ([]spotify.Item, error) { return nil, nil },
GetArtistFn: func(context.Context, string) (spotify.Item, error) { return spotify.Item{Name: "Artist"}, nil },
SearchFn: func(context.Context, string, string, int, int) (spotify.SearchResult, error) {
return spotify.SearchResult{}, nil
},
})
if err := (&PlayCmd{Item: "spotify:artist:abc"}).Run(ctx); err == nil {
t.Fatalf("expected error")
}
}
func TestPlayCmdArtistFallbackSearchError(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
ctx.SetSpotify(&testutil.SpotifyMock{
ArtistTopTracksFn: func(context.Context, string, int) ([]spotify.Item, error) { return nil, errors.New("boom") },
GetArtistFn: func(context.Context, string) (spotify.Item, error) { return spotify.Item{Name: "Artist"}, nil },
SearchFn: func(context.Context, string, string, int, int) (spotify.SearchResult, error) {
return spotify.SearchResult{}, errors.New("search fail")
},
})
if err := (&PlayCmd{Item: "spotify:artist:abc"}).Run(ctx); err == nil {
t.Fatalf("expected error")
}
}

View File

@ -145,15 +145,3 @@ func TestParseToggleOff(t *testing.T) {
t.Fatalf("expected false")
}
}
func TestPlayCmdShuffleError(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
ctx.SetSpotify(&testutil.SpotifyMock{
ShuffleFn: func(ctx context.Context, enabled bool) error { return errors.New("boom") },
PlayFn: func(ctx context.Context, uri string) error { t.Fatalf("play should not run"); return nil },
})
cmd := PlayCmd{Item: "spotify:track:t1", Shuffle: true}
if err := cmd.Run(ctx); err == nil {
t.Fatalf("expected error")
}
}

View File

@ -2,6 +2,7 @@ package cli
import (
"context"
"errors"
"testing"
"github.com/steipete/spogo/internal/output"
@ -43,32 +44,107 @@ func TestPlayCmdWithType(t *testing.T) {
}
}
func TestPlayCmdShuffle(t *testing.T) {
func TestPlayCmdArtistTopTrack(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
calls := []string{}
mock := &testutil.SpotifyMock{
ShuffleFn: func(ctx context.Context, enabled bool) error {
if !enabled {
t.Fatalf("expected shuffle enabled")
ArtistTopTracksFn: func(ctx context.Context, id string, limit int) ([]spotify.Item, error) {
if id != "abc" {
t.Fatalf("id %s", id)
}
calls = append(calls, "shuffle")
return nil
if limit != 10 {
t.Fatalf("limit %d", limit)
}
return []spotify.Item{{URI: "spotify:track:top"}}, nil
},
PlayFn: func(ctx context.Context, uri string) error {
if uri != "spotify:track:abc" {
if uri != "spotify:track:top" {
t.Fatalf("uri %s", uri)
}
calls = append(calls, "play")
return nil
},
}
ctx.SetSpotify(mock)
cmd := PlayCmd{Item: "abc", Type: "track", Shuffle: true}
cmd := PlayCmd{Item: "spotify:artist:abc"}
if err := cmd.Run(ctx); err != nil {
t.Fatalf("run: %v", err)
}
if len(calls) != 2 || calls[0] != "shuffle" || calls[1] != "play" {
t.Fatalf("unexpected call order: %#v", calls)
}
func TestPlayCmdArtistFallbackSearch(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
mock := &testutil.SpotifyMock{
ArtistTopTracksFn: func(context.Context, string, int) ([]spotify.Item, error) {
return nil, errors.New("rate limit")
},
GetArtistFn: func(context.Context, string) (spotify.Item, error) {
return spotify.Item{Name: "Artist"}, nil
},
SearchFn: func(context.Context, string, string, int, int) (spotify.SearchResult, error) {
return spotify.SearchResult{Items: []spotify.Item{{URI: "spotify:track:found"}}}, nil
},
PlayFn: func(context.Context, string) error { return nil },
}
ctx.SetSpotify(mock)
cmd := PlayCmd{Item: "spotify:artist:abc"}
if err := cmd.Run(ctx); err != nil {
t.Fatalf("run: %v", err)
}
}
func TestPlayCmdArtistFallbackArtistError(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
mock := &testutil.SpotifyMock{
ArtistTopTracksFn: func(context.Context, string, int) ([]spotify.Item, error) {
return nil, errors.New("boom")
},
GetArtistFn: func(context.Context, string) (spotify.Item, error) {
return spotify.Item{}, errors.New("missing")
},
}
ctx.SetSpotify(mock)
cmd := PlayCmd{Item: "spotify:artist:abc"}
if err := cmd.Run(ctx); err == nil {
t.Fatalf("expected error")
}
}
func TestPlayCmdArtistFallbackSearchEmpty(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
mock := &testutil.SpotifyMock{
ArtistTopTracksFn: func(context.Context, string, int) ([]spotify.Item, error) {
return nil, nil
},
GetArtistFn: func(context.Context, string) (spotify.Item, error) {
return spotify.Item{Name: "Artist"}, nil
},
SearchFn: func(context.Context, string, string, int, int) (spotify.SearchResult, error) {
return spotify.SearchResult{}, nil
},
}
ctx.SetSpotify(mock)
cmd := PlayCmd{Item: "spotify:artist:abc"}
if err := cmd.Run(ctx); err == nil {
t.Fatalf("expected error")
}
}
func TestPlayCmdArtistFallbackSearchError(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain)
mock := &testutil.SpotifyMock{
ArtistTopTracksFn: func(context.Context, string, int) ([]spotify.Item, error) {
return nil, errors.New("boom")
},
GetArtistFn: func(context.Context, string) (spotify.Item, error) {
return spotify.Item{Name: "Artist"}, nil
},
SearchFn: func(context.Context, string, string, int, int) (spotify.SearchResult, error) {
return spotify.SearchResult{}, errors.New("search fail")
},
}
ctx.SetSpotify(mock)
cmd := PlayCmd{Item: "spotify:artist:abc"}
if err := cmd.Run(ctx); err == nil {
t.Fatalf("expected error")
}
}

View File

@ -1,6 +1,7 @@
package cli
import (
"context"
"fmt"
"strings"
@ -32,19 +33,21 @@ type PlaylistTracksCmd struct {
}
func (cmd *PlaylistCreateCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
item, err := client.CreatePlaylist(cmdCtx, cmd.Name, cmd.Public, cmd.Collab)
item, err := client.CreatePlaylist(context.Background(), cmd.Name, cmd.Public, cmd.Collab)
if err != nil {
return err
}
return ctx.Output.Emit(item, []string{itemPlain(item)}, []string{fmt.Sprintf("Created %s", itemHuman(ctx.Output, item))})
plain := []string{itemPlain(item)}
human := []string{fmt.Sprintf("Created %s", itemHuman(ctx.Output, item))}
return ctx.Output.Emit(item, plain, human)
}
func (cmd *PlaylistAddCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
@ -56,14 +59,16 @@ func (cmd *PlaylistAddCmd) Run(ctx *app.Context) error {
if err != nil {
return err
}
if err := client.AddTracks(cmdCtx, playlist.ID, uris); err != nil {
if err := client.AddTracks(context.Background(), playlist.ID, uris); err != nil {
return err
}
return emitCountStatus(ctx, len(uris), "Added")
plain := []string{"ok"}
human := []string{fmt.Sprintf("Added %d tracks", len(uris))}
return ctx.Output.Emit(map[string]any{"status": "ok", "count": len(uris)}, plain, human)
}
func (cmd *PlaylistRemoveCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
@ -75,14 +80,16 @@ func (cmd *PlaylistRemoveCmd) Run(ctx *app.Context) error {
if err != nil {
return err
}
if err := client.RemoveTracks(cmdCtx, playlist.ID, uris); err != nil {
if err := client.RemoveTracks(context.Background(), playlist.ID, uris); err != nil {
return err
}
return emitCountStatus(ctx, len(uris), "Removed")
plain := []string{"ok"}
human := []string{fmt.Sprintf("Removed %d tracks", len(uris))}
return ctx.Output.Emit(map[string]any{"status": "ok", "count": len(uris)}, plain, human)
}
func (cmd *PlaylistTracksCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
@ -91,7 +98,7 @@ func (cmd *PlaylistTracksCmd) Run(ctx *app.Context) error {
return err
}
limit := clampLimit(cmd.Limit)
items, total, err := client.PlaylistTracks(cmdCtx, playlist.ID, limit, cmd.Offset)
items, total, err := client.PlaylistTracks(context.Background(), playlist.ID, limit, cmd.Offset)
if err != nil {
return err
}

View File

@ -1,6 +1,7 @@
package cli
import (
"context"
"fmt"
"github.com/steipete/spogo/internal/app"
@ -23,7 +24,7 @@ type QueueShowCmd struct{}
type QueueClearCmd struct{}
func (cmd *QueueAddCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
@ -34,18 +35,18 @@ func (cmd *QueueAddCmd) Run(ctx *app.Context) error {
if res.URI == "" {
return fmt.Errorf("invalid track")
}
if err := client.QueueAdd(cmdCtx, res.URI); err != nil {
if err := client.QueueAdd(context.Background(), res.URI); err != nil {
return err
}
return emitOK(ctx, nil, "Queued")
return ctx.Output.Emit(map[string]string{"status": "ok"}, []string{"ok"}, []string{"Queued"})
}
func (cmd *QueueShowCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
queue, err := client.Queue(cmdCtx)
queue, err := client.Queue(context.Background())
if err != nil {
return err
}

View File

@ -49,10 +49,7 @@ func itemHuman(w *output.Writer, item spotify.Item) string {
case "artist":
return fmt.Sprintf("%s %s", accent(item.Name), muted(fmt.Sprintf("· %d followers", item.Followers)))
case "playlist":
if item.TotalTracks > 0 {
return fmt.Sprintf("%s — %s %s", accent(item.Name), item.Owner, muted(fmt.Sprintf("· %d tracks", item.TotalTracks)))
}
return fmt.Sprintf("%s — %s", accent(item.Name), item.Owner)
return fmt.Sprintf("%s — %s %s", accent(item.Name), item.Owner, muted(fmt.Sprintf("· %d tracks", item.TotalTracks)))
case "show":
return fmt.Sprintf("%s — %s %s", accent(item.Name), item.Publisher, muted(fmt.Sprintf("· %d episodes", item.TotalEpisodes)))
case "episode":

View File

@ -1,7 +1,6 @@
package cli
import (
"strings"
"testing"
"github.com/steipete/spogo/internal/output"
@ -32,11 +31,3 @@ func TestPlaybackFormatting(t *testing.T) {
t.Fatalf("expected human")
}
}
func TestPlaylistRenderOmitsZeroTracks(t *testing.T) {
ctx, _, _ := testutil.NewTestContext(t, output.FormatHuman)
line := itemHuman(ctx.Output, spotify.Item{Type: "playlist", Name: "List", Owner: "Peter"})
if strings.Contains(line, "0 tracks") {
t.Fatalf("unexpected track count: %s", line)
}
}

View File

@ -1,6 +1,7 @@
package cli
import (
"context"
"fmt"
"strings"
@ -60,7 +61,7 @@ func (cmd *SearchShowCmd) Run(ctx *app.Context) error {
}
func runSearch(ctx *app.Context, kind string, args SearchArgs) error {
client, cmdCtx, err := spotifyClient(ctx)
client, err := ctx.Spotify()
if err != nil {
return err
}
@ -68,7 +69,7 @@ func runSearch(ctx *app.Context, kind string, args SearchArgs) error {
if args.Limit != limit {
ctx.Output.Errorf("limit capped at %d", limit)
}
res, err := client.Search(cmdCtx, kind, args.Query, limit, args.Offset)
res, err := client.Search(context.Background(), kind, args.Query, limit, args.Offset)
if err != nil {
return err
}

View File

@ -6,16 +6,8 @@ import (
"testing"
)
func isolateConfigHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("HOME", dir)
return dir
}
func TestLoadDefaultWhenMissing(t *testing.T) {
isolateConfigHome(t)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg, err := Load("")
if err != nil {
t.Fatalf("load default: %v", err)
@ -26,7 +18,8 @@ func TestLoadDefaultWhenMissing(t *testing.T) {
}
func TestDefaultPath(t *testing.T) {
isolateConfigHome(t)
base := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", base)
path, err := DefaultPath()
if err != nil {
t.Fatalf("default path: %v", err)
@ -96,7 +89,7 @@ func TestSaveNilConfig(t *testing.T) {
}
func TestSaveDefaultPath(t *testing.T) {
isolateConfigHome(t)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg := Default()
if err := Save("", cfg); err != nil {
t.Fatalf("save: %v", err)

View File

@ -3,10 +3,8 @@ package cookies
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"
@ -15,8 +13,6 @@ import (
var readCookies = sweetcookie.Get
var authCookieNames = []string{"sp_dc", "sp_key", "sp_t"}
// SetReadCookies overrides the internal cookie reader and returns a restore func.
// Intended for tests.
func SetReadCookies(fn func(context.Context, sweetcookie.Options) (sweetcookie.Result, error)) func() {
@ -44,20 +40,50 @@ type FileSource struct {
}
func (s BrowserSource) Cookies(ctx context.Context) ([]*http.Cookie, error) {
result, err := readCookies(ctx, s.cookieOptions(false))
domain := strings.TrimSpace(s.Domain)
if domain == "" {
domain = "spotify.com"
}
host := domain
if strings.Contains(domain, "://") {
if parsed, err := url.Parse(domain); err == nil && parsed.Hostname() != "" {
host = parsed.Hostname()
}
}
url := "https://" + host
origins := []string{}
if strings.Contains(host, "spotify.com") {
if host != "open.spotify.com" {
origins = append(origins, "https://open.spotify.com")
}
if host != "spotify.com" {
origins = append(origins, "https://spotify.com")
}
}
opts := sweetcookie.Options{
URL: url,
Origins: origins,
Mode: sweetcookie.ModeFirst,
Timeout: 5 * time.Second,
}
if s.Browser != "" {
browser := sweetcookie.Browser(strings.ToLower(s.Browser))
opts.Browsers = []sweetcookie.Browser{browser}
if s.Profile != "" {
opts.Profiles = map[sweetcookie.Browser]string{browser: s.Profile}
}
} else if s.Profile != "" {
opts.Profiles = map[sweetcookie.Browser]string{}
for _, browser := range sweetcookie.DefaultBrowsers() {
opts.Profiles[browser] = s.Profile
}
}
result, err := readCookies(ctx, opts)
if err != nil {
return nil, err
}
if len(result.Cookies) == 0 && s.shouldRetryAcrossHosts() {
retry, retryErr := readCookies(ctx, s.cookieOptions(true))
if retryErr != nil {
return nil, retryErr
}
result.Cookies = retry.Cookies
result.Warnings = append(result.Warnings, retry.Warnings...)
}
if len(result.Cookies) == 0 {
return nil, browserCookiesNotFoundError(result.Warnings)
return nil, errors.New("no cookies found")
}
ret := make([]*http.Cookie, 0, len(result.Cookies))
for _, c := range result.Cookies {
@ -77,101 +103,6 @@ func (s BrowserSource) Cookies(ctx context.Context) ([]*http.Cookie, error) {
return ret, nil
}
func (s BrowserSource) cookieOptions(allowAllHosts bool) sweetcookie.Options {
host := s.host()
opts := sweetcookie.Options{
Mode: sweetcookie.ModeFirst,
Timeout: 5 * time.Second,
Names: authCookieNames,
}
if allowAllHosts {
opts.AllowAllHosts = true
} else {
opts.URL = "https://" + host
opts.Origins = spotifyOrigins(host)
}
if s.Browser != "" {
browser := sweetcookie.Browser(strings.ToLower(s.Browser))
opts.Browsers = []sweetcookie.Browser{browser}
if s.Profile != "" {
opts.Profiles = map[sweetcookie.Browser]string{browser: s.Profile}
}
} else if s.Profile != "" {
opts.Profiles = map[sweetcookie.Browser]string{}
for _, browser := range sweetcookie.DefaultBrowsers() {
opts.Profiles[browser] = s.Profile
}
}
return opts
}
func (s BrowserSource) host() string {
domain := strings.TrimSpace(s.Domain)
if domain == "" {
domain = "spotify.com"
}
if strings.Contains(domain, "://") {
if parsed, err := url.Parse(domain); err == nil && parsed.Hostname() != "" {
return parsed.Hostname()
}
}
return domain
}
func (s BrowserSource) shouldRetryAcrossHosts() bool {
host := normalizeCookieHost(s.host())
return host == "spotify.com" || strings.HasSuffix(host, ".spotify.com")
}
func spotifyOrigins(host string) []string {
host = normalizeCookieHost(host)
if host == "" {
return nil
}
if !strings.Contains(host, "spotify.com") {
return nil
}
origins := []string{}
for _, candidate := range []string{"spotify.com", "open.spotify.com", "accounts.spotify.com"} {
if host == candidate {
continue
}
origins = append(origins, "https://"+candidate)
}
return origins
}
func normalizeCookieHost(host string) string {
return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(host), "."))
}
func browserCookiesNotFoundError(warnings []string) error {
warnings = compactWarnings(warnings)
if len(warnings) == 0 {
return errors.New("no cookies found")
}
return fmt.Errorf("no cookies found; %s", strings.Join(warnings, "; "))
}
func compactWarnings(warnings []string) []string {
out := make([]string, 0, len(warnings))
for _, warning := range warnings {
warning = strings.TrimSpace(warning)
if warning == "" {
continue
}
out = append(out, warning)
}
if len(out) == 0 {
return nil
}
out = slices.Compact(out)
if len(out) > 3 {
return out[:3]
}
return out
}
func (s FileSource) Cookies(ctx context.Context) ([]*http.Cookie, error) {
_ = ctx
return Read(s.Path)

View File

@ -4,7 +4,6 @@ import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"
@ -40,40 +39,6 @@ func TestBrowserSourceNoCookies(t *testing.T) {
}
}
func TestBrowserSourceRetriesAcrossSpotifyHosts(t *testing.T) {
var calls []sweetcookie.Options
restore := SetReadCookies(func(ctx context.Context, opts sweetcookie.Options) (sweetcookie.Result, error) {
calls = append(calls, opts)
if len(calls) == 1 {
return sweetcookie.Result{}, nil
}
return sweetcookie.Result{
Cookies: []sweetcookie.Cookie{{Name: "sp_dc", Value: "token", Domain: ".accounts.spotify.com"}},
}, nil
})
defer restore()
src := BrowserSource{Browser: "chrome", Domain: "spotify.com"}
cookies, err := src.Cookies(context.Background())
if err != nil {
t.Fatalf("cookies: %v", err)
}
if len(cookies) != 1 || cookies[0].Name != "sp_dc" {
t.Fatalf("unexpected cookies: %#v", cookies)
}
if len(calls) != 2 {
t.Fatalf("expected 2 calls, got %d", len(calls))
}
if calls[0].AllowAllHosts {
t.Fatalf("expected filtered first pass")
}
if !calls[1].AllowAllHosts {
t.Fatalf("expected allow-all fallback")
}
if len(calls[1].Names) != len(authCookieNames) {
t.Fatalf("expected auth cookie allowlist")
}
}
func TestSetReadCookies(t *testing.T) {
restore := SetReadCookies(nil)
restore()
@ -94,45 +59,6 @@ func TestBrowserSourceError(t *testing.T) {
}
}
func TestBrowserSourceNoCookiesIncludesWarnings(t *testing.T) {
restore := SetReadCookies(func(ctx context.Context, opts sweetcookie.Options) (sweetcookie.Result, error) {
return sweetcookie.Result{Warnings: []string{"sweetcookie: chrome cookie store not found"}}, nil
})
defer restore()
src := BrowserSource{Browser: "chrome", Domain: "spotify.com"}
_, err := src.Cookies(context.Background())
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "chrome cookie store not found") {
t.Fatalf("expected warning in error, got %v", err)
}
}
func TestBrowserSourceUsesSpotifyOrigins(t *testing.T) {
var got sweetcookie.Options
restore := SetReadCookies(func(ctx context.Context, opts sweetcookie.Options) (sweetcookie.Result, error) {
got = opts
return sweetcookie.Result{
Cookies: []sweetcookie.Cookie{{Name: "sp_dc", Value: "token", Domain: ".spotify.com"}},
}, nil
})
defer restore()
src := BrowserSource{Browser: "chrome", Domain: "spotify.com"}
if _, err := src.Cookies(context.Background()); err != nil {
t.Fatalf("expected cookies: %v", err)
}
if len(got.Origins) != 2 {
t.Fatalf("expected 2 spotify origins, got %v", got.Origins)
}
if got.Origins[0] != "https://open.spotify.com" && got.Origins[1] != "https://open.spotify.com" {
t.Fatalf("expected open.spotify.com origin, got %v", got.Origins)
}
if got.Origins[0] != "https://accounts.spotify.com" && got.Origins[1] != "https://accounts.spotify.com" {
t.Fatalf("expected accounts.spotify.com origin, got %v", got.Origins)
}
}
func TestBrowserSourceDefaultDomain(t *testing.T) {
restore := SetReadCookies(func(ctx context.Context, opts sweetcookie.Options) (sweetcookie.Result, error) {
return sweetcookie.Result{

View File

@ -1,300 +0,0 @@
//go:build darwin
// +build darwin
package spotify
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
)
type AppleScriptClient struct {
fallback API
}
type AppleScriptOptions struct {
Fallback API
}
func NewAppleScriptClient(opts AppleScriptOptions) (API, error) {
return &AppleScriptClient{
fallback: opts.Fallback,
}, nil
}
func (c *AppleScriptClient) runScript(ctx context.Context, script string) (string, error) {
cmd := exec.CommandContext(ctx, "osascript", "-e", script)
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
return "", fmt.Errorf("applescript error: %w", err)
}
return "", fmt.Errorf("applescript error: %w (%s)", err, msg)
}
return strings.TrimSpace(string(out)), nil
}
func (c *AppleScriptClient) Play(ctx context.Context, uri string) error {
var script string
if uri == "" {
script = `tell application "Spotify" to play`
} else {
script = fmt.Sprintf(`tell application "Spotify" to play track "%s"`, uri)
}
_, err := c.runScript(ctx, script)
return err
}
func (c *AppleScriptClient) Pause(ctx context.Context) error {
_, err := c.runScript(ctx, `tell application "Spotify" to pause`)
return err
}
func (c *AppleScriptClient) Next(ctx context.Context) error {
_, err := c.runScript(ctx, `tell application "Spotify" to next track`)
return err
}
func (c *AppleScriptClient) Previous(ctx context.Context) error {
_, err := c.runScript(ctx, `tell application "Spotify" to previous track`)
return err
}
func (c *AppleScriptClient) Seek(ctx context.Context, positionMS int) error {
positionSec := positionMS / 1000
script := fmt.Sprintf(`tell application "Spotify" to set player position to %d`, positionSec)
_, err := c.runScript(ctx, script)
return err
}
func (c *AppleScriptClient) Volume(ctx context.Context, volume int) error {
script := fmt.Sprintf(`tell application "Spotify" to set sound volume to %d`, volume)
_, err := c.runScript(ctx, script)
return err
}
func (c *AppleScriptClient) Shuffle(ctx context.Context, enabled bool) error {
val := "false"
if enabled {
val = "true"
}
script := fmt.Sprintf(`tell application "Spotify" to set shuffling to %s`, val)
_, err := c.runScript(ctx, script)
return err
}
func (c *AppleScriptClient) Repeat(ctx context.Context, mode string) error {
val := "false"
if mode == "track" || mode == "context" {
val = "true"
}
script := fmt.Sprintf(`tell application "Spotify" to set repeating to %s`, val)
_, err := c.runScript(ctx, script)
return err
}
func (c *AppleScriptClient) Playback(ctx context.Context) (PlaybackStatus, error) {
script := `tell application "Spotify"
set trackName to name of current track
set trackArtist to artist of current track
set trackAlbum to album of current track
set trackID to id of current track
set trackDuration to duration of current track
set playerPos to player position
set playerState to player state as string
set vol to sound volume
set isShuffling to shuffling
set isRepeating to repeating
return trackName & "|||" & trackArtist & "|||" & trackAlbum & "|||" & trackID & "|||" & trackDuration & "|||" & playerPos & "|||" & playerState & "|||" & vol & "|||" & isShuffling & "|||" & isRepeating
end tell`
out, err := c.runScript(ctx, script)
if err != nil {
return PlaybackStatus{}, err
}
parts := strings.Split(out, "|||")
if len(parts) < 10 {
return PlaybackStatus{}, fmt.Errorf("unexpected applescript output: %s", out)
}
durationMS, _ := strconv.Atoi(parts[4])
positionSec, _ := strconv.ParseFloat(parts[5], 64)
volume, _ := strconv.Atoi(parts[7])
isPlaying := parts[6] == "playing"
shuffle := parts[8] == "true"
repeat := "off"
if parts[9] == "true" {
repeat = "context"
}
item := &Item{
URI: parts[3],
Name: parts[0],
Artists: []string{parts[1]},
Album: parts[2],
DurationMS: durationMS,
}
return PlaybackStatus{
IsPlaying: isPlaying,
ProgressMS: int(positionSec * 1000),
Item: item,
Device: Device{
ID: "local",
Name: "Local Spotify",
Type: "COMPUTER",
Volume: volume,
Active: true,
},
Shuffle: shuffle,
Repeat: repeat,
}, nil
}
func (c *AppleScriptClient) Devices(ctx context.Context) ([]Device, error) {
return []Device{
{
ID: "local",
Name: "Local Spotify",
Type: "COMPUTER",
Active: true,
},
}, nil
}
func (c *AppleScriptClient) Transfer(ctx context.Context, deviceID string) error {
return ErrUnsupported
}
func (c *AppleScriptClient) QueueAdd(ctx context.Context, uri string) error {
if c.fallback != nil {
return c.fallback.QueueAdd(ctx, uri)
}
return ErrUnsupported
}
func (c *AppleScriptClient) Queue(ctx context.Context) (Queue, error) {
if c.fallback != nil {
return c.fallback.Queue(ctx)
}
return Queue{}, ErrUnsupported
}
func (c *AppleScriptClient) Search(ctx context.Context, kind, query string, limit, offset int) (SearchResult, error) {
if c.fallback != nil {
return c.fallback.Search(ctx, kind, query, limit, offset)
}
return SearchResult{}, ErrUnsupported
}
func (c *AppleScriptClient) GetTrack(ctx context.Context, id string) (Item, error) {
if c.fallback != nil {
return c.fallback.GetTrack(ctx, id)
}
return Item{}, ErrUnsupported
}
func (c *AppleScriptClient) GetAlbum(ctx context.Context, id string) (Item, error) {
if c.fallback != nil {
return c.fallback.GetAlbum(ctx, id)
}
return Item{}, ErrUnsupported
}
func (c *AppleScriptClient) GetArtist(ctx context.Context, id string) (Item, error) {
if c.fallback != nil {
return c.fallback.GetArtist(ctx, id)
}
return Item{}, ErrUnsupported
}
func (c *AppleScriptClient) GetPlaylist(ctx context.Context, id string) (Item, error) {
if c.fallback != nil {
return c.fallback.GetPlaylist(ctx, id)
}
return Item{}, ErrUnsupported
}
func (c *AppleScriptClient) GetShow(ctx context.Context, id string) (Item, error) {
if c.fallback != nil {
return c.fallback.GetShow(ctx, id)
}
return Item{}, ErrUnsupported
}
func (c *AppleScriptClient) GetEpisode(ctx context.Context, id string) (Item, error) {
if c.fallback != nil {
return c.fallback.GetEpisode(ctx, id)
}
return Item{}, ErrUnsupported
}
func (c *AppleScriptClient) LibraryTracks(ctx context.Context, limit, offset int) ([]Item, int, error) {
if c.fallback != nil {
return c.fallback.LibraryTracks(ctx, limit, offset)
}
return nil, 0, ErrUnsupported
}
func (c *AppleScriptClient) LibraryAlbums(ctx context.Context, limit, offset int) ([]Item, int, error) {
if c.fallback != nil {
return c.fallback.LibraryAlbums(ctx, limit, offset)
}
return nil, 0, ErrUnsupported
}
func (c *AppleScriptClient) LibraryModify(ctx context.Context, path string, ids []string, method string) error {
if c.fallback != nil {
return c.fallback.LibraryModify(ctx, path, ids, method)
}
return ErrUnsupported
}
func (c *AppleScriptClient) FollowArtists(ctx context.Context, ids []string, method string) error {
if c.fallback != nil {
return c.fallback.FollowArtists(ctx, ids, method)
}
return ErrUnsupported
}
func (c *AppleScriptClient) FollowedArtists(ctx context.Context, limit int, after string) ([]Item, int, string, error) {
if c.fallback != nil {
return c.fallback.FollowedArtists(ctx, limit, after)
}
return nil, 0, "", ErrUnsupported
}
func (c *AppleScriptClient) Playlists(ctx context.Context, limit, offset int) ([]Item, int, error) {
if c.fallback != nil {
return c.fallback.Playlists(ctx, limit, offset)
}
return nil, 0, ErrUnsupported
}
func (c *AppleScriptClient) PlaylistTracks(ctx context.Context, id string, limit, offset int) ([]Item, int, error) {
if c.fallback != nil {
return c.fallback.PlaylistTracks(ctx, id, limit, offset)
}
return nil, 0, ErrUnsupported
}
func (c *AppleScriptClient) CreatePlaylist(ctx context.Context, name string, public, collaborative bool) (Item, error) {
if c.fallback != nil {
return c.fallback.CreatePlaylist(ctx, name, public, collaborative)
}
return Item{}, ErrUnsupported
}
func (c *AppleScriptClient) AddTracks(ctx context.Context, playlistID string, uris []string) error {
if c.fallback != nil {
return c.fallback.AddTracks(ctx, playlistID, uris)
}
return ErrUnsupported
}
func (c *AppleScriptClient) RemoveTracks(ctx context.Context, playlistID string, uris []string) error {
if c.fallback != nil {
return c.fallback.RemoveTracks(ctx, playlistID, uris)
}
return ErrUnsupported
}

View File

@ -1,18 +0,0 @@
//go:build !darwin
// +build !darwin
package spotify
import (
"errors"
)
type AppleScriptClient struct{}
type AppleScriptOptions struct {
Fallback API
}
func NewAppleScriptClient(opts AppleScriptOptions) (API, error) {
return nil, errors.New("applescript engine is only available on macOS")
}

View File

@ -1,17 +0,0 @@
//go:build !darwin
package spotify
import "testing"
func TestNewAppleScriptClient_NonDarwin(t *testing.T) {
t.Parallel()
client, err := NewAppleScriptClient(AppleScriptOptions{})
if err == nil {
t.Fatal("expected error")
}
if client != nil {
t.Fatal("expected nil client")
}
}

View File

@ -1,232 +0,0 @@
//go:build darwin
// +build darwin
package spotify
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAppleScriptClientLocalCommands(t *testing.T) {
logPath := installFakeOsaScript(t)
t.Setenv("SPOGO_OSASCRIPT_OUTPUT", "Song|||Artist|||Album|||spotify:track:1|||180000|||12.5|||playing|||42|||true|||true")
client := &AppleScriptClient{}
ctx := context.Background()
calls := []func() error{
func() error { return client.Play(ctx, "") },
func() error { return client.Play(ctx, "spotify:track:1") },
func() error { return client.Pause(ctx) },
func() error { return client.Next(ctx) },
func() error { return client.Previous(ctx) },
func() error { return client.Seek(ctx, 12500) },
func() error { return client.Volume(ctx, 42) },
func() error { return client.Shuffle(ctx, true) },
func() error { return client.Shuffle(ctx, false) },
func() error { return client.Repeat(ctx, "track") },
func() error { return client.Repeat(ctx, "off") },
}
for i, call := range calls {
if err := call(); err != nil {
t.Fatalf("call %d: %v", i, err)
}
}
status, err := client.Playback(ctx)
if err != nil {
t.Fatalf("playback: %v", err)
}
if !status.IsPlaying || status.ProgressMS != 12500 || status.Device.Volume != 42 || !status.Shuffle || status.Repeat != "context" {
t.Fatalf("unexpected status: %+v", status)
}
if status.Item == nil || status.Item.Name != "Song" || status.Item.URI != "spotify:track:1" {
t.Fatalf("unexpected item: %+v", status.Item)
}
logData, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read log: %v", err)
}
log := string(logData)
for _, want := range []string{
"to play",
`play track "spotify:track:1"`,
"to pause",
"to next track",
"to previous track",
"player position to 12",
"sound volume to 42",
"shuffling to true",
"shuffling to false",
"repeating to true",
"repeating to false",
} {
if !strings.Contains(log, want) {
t.Fatalf("script log missing %q:\n%s", want, log)
}
}
}
func TestAppleScriptPlaybackErrors(t *testing.T) {
installFakeOsaScript(t)
client := &AppleScriptClient{}
ctx := context.Background()
t.Setenv("SPOGO_OSASCRIPT_OUTPUT", "too few fields")
if _, err := client.Playback(ctx); err == nil {
t.Fatalf("expected parse error")
}
t.Setenv("SPOGO_OSASCRIPT_ERROR", "boom")
if err := client.Pause(ctx); err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected osascript error, got %v", err)
}
}
func TestAppleScriptDevicesAndFallbacks(t *testing.T) {
client, err := NewAppleScriptClient(AppleScriptOptions{})
if err != nil {
t.Fatalf("new client: %v", err)
}
apple, ok := client.(*AppleScriptClient)
if !ok {
t.Fatalf("unexpected client type %T", client)
}
devices, err := apple.Devices(context.Background())
if err != nil || len(devices) != 1 || devices[0].ID != "local" {
t.Fatalf("devices: %+v err=%v", devices, err)
}
if err := apple.Transfer(context.Background(), "device"); !errors.Is(err, ErrUnsupported) {
t.Fatalf("transfer: %v", err)
}
unsupported := []func() error{
func() error { return apple.QueueAdd(context.Background(), "spotify:track:1") },
func() error {
_, err := apple.Queue(context.Background())
return err
},
func() error {
_, err := apple.Search(context.Background(), "track", "query", 1, 0)
return err
},
func() error {
_, err := apple.GetTrack(context.Background(), "track")
return err
},
func() error {
_, err := apple.GetAlbum(context.Background(), "album")
return err
},
func() error {
_, err := apple.GetArtist(context.Background(), "artist")
return err
},
func() error {
_, err := apple.GetPlaylist(context.Background(), "playlist")
return err
},
func() error {
_, err := apple.GetShow(context.Background(), "show")
return err
},
func() error {
_, err := apple.GetEpisode(context.Background(), "episode")
return err
},
func() error {
_, _, err := apple.LibraryTracks(context.Background(), 1, 0)
return err
},
func() error {
_, _, err := apple.LibraryAlbums(context.Background(), 1, 0)
return err
},
func() error { return apple.LibraryModify(context.Background(), "tracks", []string{"id"}, "put") },
func() error { return apple.FollowArtists(context.Background(), []string{"id"}, "put") },
func() error {
_, _, _, err := apple.FollowedArtists(context.Background(), 1, "")
return err
},
func() error {
_, _, err := apple.Playlists(context.Background(), 1, 0)
return err
},
func() error {
_, _, err := apple.PlaylistTracks(context.Background(), "playlist", 1, 0)
return err
},
func() error {
_, err := apple.CreatePlaylist(context.Background(), "mix", false, false)
return err
},
func() error { return apple.AddTracks(context.Background(), "playlist", []string{"track"}) },
func() error { return apple.RemoveTracks(context.Background(), "playlist", []string{"track"}) },
}
for i, call := range unsupported {
if err := call(); !errors.Is(err, ErrUnsupported) {
t.Fatalf("unsupported call %d: %v", i, err)
}
}
calls := map[string]int{}
apple.fallback = apiStub{calls: calls}
_ = apple.QueueAdd(context.Background(), "spotify:track:1")
_, _ = apple.Queue(context.Background())
_, _ = apple.Search(context.Background(), "track", "query", 1, 0)
_, _ = apple.GetTrack(context.Background(), "track")
_, _ = apple.GetAlbum(context.Background(), "album")
_, _ = apple.GetArtist(context.Background(), "artist")
_, _ = apple.GetPlaylist(context.Background(), "playlist")
_, _ = apple.GetShow(context.Background(), "show")
_, _ = apple.GetEpisode(context.Background(), "episode")
_, _, _ = apple.LibraryTracks(context.Background(), 1, 0)
_, _, _ = apple.LibraryAlbums(context.Background(), 1, 0)
_ = apple.LibraryModify(context.Background(), "tracks", []string{"id"}, "put")
_ = apple.FollowArtists(context.Background(), []string{"id"}, "put")
_, _, _, _ = apple.FollowedArtists(context.Background(), 1, "")
_, _, _ = apple.Playlists(context.Background(), 1, 0)
_, _, _ = apple.PlaylistTracks(context.Background(), "playlist", 1, 0)
_, _ = apple.CreatePlaylist(context.Background(), "mix", false, false)
_ = apple.AddTracks(context.Background(), "playlist", []string{"track"})
_ = apple.RemoveTracks(context.Background(), "playlist", []string{"track"})
for _, want := range []string{
"QueueAdd", "Queue", "Search", "GetTrack", "GetAlbum", "GetArtist", "GetPlaylist", "GetShow", "GetEpisode",
"LibraryTracks", "LibraryAlbums", "LibraryModify", "FollowArtists", "FollowedArtists", "Playlists", "PlaylistTracks",
"CreatePlaylist", "AddTracks", "RemoveTracks",
} {
if calls[want] != 1 {
t.Fatalf("fallback %s calls=%d", want, calls[want])
}
}
}
func installFakeOsaScript(t *testing.T) string {
t.Helper()
dir := t.TempDir()
logPath := filepath.Join(dir, "osascript.log")
scriptPath := filepath.Join(dir, "osascript")
script := `#!/bin/sh
printf '%s\n' "$2" >> "$SPOGO_OSASCRIPT_LOG"
if [ -n "$SPOGO_OSASCRIPT_ERROR" ]; then
printf '%s\n' "$SPOGO_OSASCRIPT_ERROR"
exit 1
fi
printf '%s\n' "$SPOGO_OSASCRIPT_OUTPUT"
`
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatalf("write osascript: %v", err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("SPOGO_OSASCRIPT_LOG", logPath)
return logPath
}

View File

@ -176,11 +176,6 @@ func (c *Client) Playback(ctx context.Context) (PlaybackStatus, error) {
if raw.Item.ID != "" {
item := mapTrack(raw.Item)
status.Item = &item
if itemNeedsTrackMetadata(status.Item) {
if full, err := c.GetTrack(ctx, status.Item.ID); err == nil {
mergeItemMetadata(status.Item, full)
}
}
}
return status, nil
}

View File

@ -76,19 +76,16 @@ func (c *Client) send(ctx context.Context, method, path string, params url.Value
if err != nil {
return err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
token, err := c.token(ctx)
if err != nil {
return err
}
contentType := ""
if payload != nil {
contentType = "application/json"
}
applyRequestHeaders(req, requestHeaders{
AccessToken: token,
Accept: "application/json",
ContentType: contentType,
})
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", defaultUserAgent())
resp, err := c.client.Do(req)
if err != nil {
return err

View File

@ -84,74 +84,11 @@ func TestPlaybackNoContent(t *testing.T) {
func TestPlaybackWithItem(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/me/player":
payload := playbackResponse{
IsPlaying: true,
ProgressMS: 1000,
Device: deviceItem{Name: "Desk"},
Item: trackItem{ID: "t1", Name: "Song", Artists: []artistRef{{Name: "Artist"}}},
}
_ = json.NewEncoder(w).Encode(payload)
default:
w.WriteHeader(http.StatusNotFound)
}
})
client, closeFn := newTestClient(t, handler)
defer closeFn()
status, err := client.Playback(context.Background())
if err != nil {
t.Fatalf("playback: %v", err)
}
if status.Item == nil || status.Item.Name != "Song" {
t.Fatalf("expected item")
}
}
func TestPlaybackHydratesSparseItem(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/me/player":
payload := playbackResponse{
IsPlaying: true,
Item: trackItem{ID: "t1", URI: "spotify:track:t1", Name: "Song"},
}
_ = json.NewEncoder(w).Encode(payload)
case "/tracks/t1":
payload := trackItem{
ID: "t1",
URI: "spotify:track:t1",
Name: "Song",
Album: albumRef{Name: "Album"},
Artists: []artistRef{{Name: "Artist"}},
}
_ = json.NewEncoder(w).Encode(payload)
default:
w.WriteHeader(http.StatusNotFound)
}
})
client, closeFn := newTestClient(t, handler)
defer closeFn()
status, err := client.Playback(context.Background())
if err != nil {
t.Fatalf("playback: %v", err)
}
if status.Item == nil || len(status.Item.Artists) != 1 || status.Item.Artists[0] != "Artist" || status.Item.Album != "Album" {
t.Fatalf("expected hydrated item: %#v", status.Item)
}
}
func TestPlaybackKeepsSparseItemWhenHydrationFails(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/me/player" {
w.WriteHeader(http.StatusTooManyRequests)
return
}
payload := playbackResponse{
IsPlaying: true,
ProgressMS: 1000,
Device: deviceItem{Name: "Desk"},
Item: trackItem{ID: "t1", URI: "spotify:track:t1", Name: "Song"},
Item: trackItem{ID: "t1", Name: "Song", Artists: []artistRef{{Name: "Artist"}}},
}
_ = json.NewEncoder(w).Encode(payload)
})

View File

@ -3,6 +3,7 @@ package spotify
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
@ -137,115 +138,41 @@ func (c *ConnectClient) Queue(ctx context.Context) (Queue, error) {
}
func (c *ConnectClient) LibraryTracks(ctx context.Context, limit, offset int) ([]Item, int, error) {
return withWebCollectionFallback(c, func() ([]Item, int, error) {
return c.libraryTracks(ctx, limit, offset)
}, func(web *Client) ([]Item, int, error) {
return web.LibraryTracks(ctx, limit, offset)
})
return nil, 0, fmt.Errorf("%w: library tracks not supported in connect engine yet", ErrUnsupported)
}
func (c *ConnectClient) LibraryAlbums(ctx context.Context, limit, offset int) ([]Item, int, error) {
return withWebCollectionFallback(c, func() ([]Item, int, error) {
return c.libraryAlbums(ctx, limit, offset)
}, func(web *Client) ([]Item, int, error) {
return web.LibraryAlbums(ctx, limit, offset)
})
return nil, 0, fmt.Errorf("%w: library albums not supported in connect engine yet", ErrUnsupported)
}
func (c *ConnectClient) LibraryModify(ctx context.Context, path string, ids []string, method string) error {
return withWebFallback(c, func(web *Client) error {
return web.LibraryModify(ctx, path, ids, method)
})
return fmt.Errorf("%w: library modify not supported in connect engine yet", ErrUnsupported)
}
func (c *ConnectClient) FollowArtists(ctx context.Context, ids []string, method string) error {
return withWebFallback(c, func(web *Client) error {
return web.FollowArtists(ctx, ids, method)
})
return fmt.Errorf("%w: follow artists not supported in connect engine yet", ErrUnsupported)
}
func (c *ConnectClient) FollowedArtists(ctx context.Context, limit int, after string) ([]Item, int, string, error) {
return withWebCursorFallback(c, func(web *Client) ([]Item, int, string, error) {
return web.FollowedArtists(ctx, limit, after)
})
return nil, 0, "", fmt.Errorf("%w: followed artists not supported in connect engine yet", ErrUnsupported)
}
func (c *ConnectClient) Playlists(ctx context.Context, limit, offset int) ([]Item, int, error) {
return withWebCollectionFallback(c, func() ([]Item, int, error) {
return c.playlists(ctx, limit, offset)
}, func(web *Client) ([]Item, int, error) {
return web.Playlists(ctx, limit, offset)
})
return nil, 0, fmt.Errorf("%w: playlists not supported in connect engine yet", ErrUnsupported)
}
func (c *ConnectClient) PlaylistTracks(ctx context.Context, id string, limit, offset int) ([]Item, int, error) {
return withWebCollectionFallback(c, func() ([]Item, int, error) {
return c.playlistTracks(ctx, id, limit, offset)
}, func(web *Client) ([]Item, int, error) {
return web.PlaylistTracks(ctx, id, limit, offset)
})
return nil, 0, fmt.Errorf("%w: playlist tracks not supported in connect engine yet", ErrUnsupported)
}
func (c *ConnectClient) CreatePlaylist(ctx context.Context, name string, public, collaborative bool) (Item, error) {
return withWebItemFallback(c, func(web *Client) (Item, error) {
return web.CreatePlaylist(ctx, name, public, collaborative)
})
return Item{}, fmt.Errorf("%w: create playlist not supported in connect engine yet", ErrUnsupported)
}
func (c *ConnectClient) AddTracks(ctx context.Context, playlistID string, uris []string) error {
if err := c.addTracks(ctx, playlistID, uris); err == nil {
return nil
} else if errors.Is(err, errPlaylistNotWritable) {
return err
}
return withWebFallback(c, func(web *Client) error {
return web.AddTracks(ctx, playlistID, uris)
})
return fmt.Errorf("%w: add tracks not supported in connect engine yet", ErrUnsupported)
}
func (c *ConnectClient) RemoveTracks(ctx context.Context, playlistID string, uris []string) error {
if err := c.removeTracks(ctx, playlistID, uris); err == nil {
return nil
} else if errors.Is(err, errPlaylistNotWritable) {
return err
}
return withWebFallback(c, func(web *Client) error {
return web.RemoveTracks(ctx, playlistID, uris)
})
}
func withWebCollectionFallback(c *ConnectClient, primary func() ([]Item, int, error), fallback func(*Client) ([]Item, int, error)) ([]Item, int, error) {
items, total, err := primary()
if err == nil {
return items, total, nil
}
web, werr := c.webClient()
if werr != nil {
return nil, 0, err
}
return fallback(web)
}
func withWebCursorFallback(c *ConnectClient, fallback func(*Client) ([]Item, int, string, error)) ([]Item, int, string, error) {
web, err := c.webClient()
if err != nil {
return nil, 0, "", err
}
return fallback(web)
}
func withWebItemFallback(c *ConnectClient, fallback func(*Client) (Item, error)) (Item, error) {
web, err := c.webClient()
if err != nil {
return Item{}, err
}
return fallback(web)
}
func withWebFallback(c *ConnectClient, fallback func(*Client) error) error {
web, err := c.webClient()
if err != nil {
return err
}
return fallback(web)
return fmt.Errorf("%w: remove tracks not supported in connect engine yet", ErrUnsupported)
}

View File

@ -1,236 +0,0 @@
package spotify
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
)
func (c *ConnectClient) playback(ctx context.Context) (PlaybackStatus, error) {
return withConnectState(ctx, c, func(state connectState) (PlaybackStatus, error) {
status := mapPlaybackStatus(state)
if itemNeedsTrackMetadata(status.Item) {
if full, err := c.trackInfo(ctx, status.Item.ID); err == nil {
mergeItemMetadata(status.Item, full)
}
}
return status, nil
})
}
func (c *ConnectClient) devices(ctx context.Context) ([]Device, error) {
return withConnectState(ctx, c, func(state connectState) ([]Device, error) {
return mapDevices(state), nil
})
}
func (c *ConnectClient) transfer(ctx context.Context, deviceID string) error {
return withConnectStateErr(ctx, c, func(state connectState) error {
fromID := connectTransferSourceID(state)
if fromID == "" {
return c.transferViaWebAPI(ctx, deviceID)
}
return c.sendConnectCommand(ctx, fmt.Sprintf("%s/connect/transfer/from/%s/to/%s", connectStateBase, fromID, deviceID), map[string]any{
"transfer_options": map[string]any{
"restore_paused": "resume",
},
"command_id": randomHex(32),
})
})
}
func (c *ConnectClient) transferViaWebAPI(ctx context.Context, deviceID string) error {
return withWebFallback(c, func(web *Client) error {
return web.Transfer(ctx, deviceID)
})
}
func (c *ConnectClient) play(ctx context.Context, uri string) error {
return withConnectStateErr(ctx, c, func(state connectState) error {
if state.activeDeviceID == "" {
if targetID := resolveConnectTargetDeviceID(state, c.device); targetID != "" {
state.activeDeviceID = targetID
} else {
return c.playViaWebAPI(ctx, uri)
}
}
if uri == "" {
return c.sendPlayerCommand(ctx, state, "resume", nil)
}
return c.sendPlayerCommand(ctx, state, "play", playCommandPayload(uri))
})
}
func (c *ConnectClient) playViaWebAPI(ctx context.Context, uri string) error {
return withWebFallback(c, func(web *Client) error {
return web.Play(ctx, uri)
})
}
func (c *ConnectClient) pause(ctx context.Context) error {
return c.sendStateCommand(ctx, "pause", nil)
}
func (c *ConnectClient) next(ctx context.Context) error {
return c.sendStateCommand(ctx, "skip_next", nil)
}
func (c *ConnectClient) previous(ctx context.Context) error {
return c.sendStateCommand(ctx, "skip_prev", nil)
}
func (c *ConnectClient) seek(ctx context.Context, positionMS int) error {
if positionMS < 0 {
positionMS = 0
}
return c.sendStateCommand(ctx, "seek_to", map[string]any{
"command": map[string]any{
"endpoint": "seek_to",
"value": positionMS,
"logging_params": map[string]any{
"command_id": randomHex(32),
},
},
})
}
func (c *ConnectClient) volume(ctx context.Context, volume int) error {
volume = clampVolume(volume)
return withConnectStateErr(ctx, c, func(state connectState) error {
fromID := connectTransferSourceID(state)
if fromID == "" || state.activeDeviceID == "" {
return errors.New("missing device id")
}
url := fmt.Sprintf("%s/connect/volume/from/%s/to/%s", connectStateBase, fromID, state.activeDeviceID)
return c.sendConnectRequest(ctx, http.MethodPut, url, map[string]any{
"volume": int(float64(volume) / 100 * 65535),
})
})
}
func (c *ConnectClient) shuffle(ctx context.Context, enabled bool) error {
return c.sendStateCommand(ctx, "set_shuffling_context", map[string]any{
"command": map[string]any{
"endpoint": "set_shuffling_context",
"value": enabled,
"logging_params": map[string]any{
"command_id": randomHex(32),
},
},
})
}
func (c *ConnectClient) repeat(ctx context.Context, mode string) error {
command := map[string]any{
"endpoint": "set_options",
"logging_params": map[string]any{
"command_id": randomHex(32),
},
}
repeatingTrack, repeatingContext := repeatFlags(mode)
command["repeating_track"] = repeatingTrack
command["repeating_context"] = repeatingContext
return c.sendStateCommand(ctx, "set_options", map[string]any{"command": command})
}
func (c *ConnectClient) queueAdd(ctx context.Context, uri string) error {
return c.sendStateCommand(ctx, "add_to_queue", map[string]any{
"command": map[string]any{
"endpoint": "add_to_queue",
"track": map[string]any{
"uri": uri,
},
"logging_params": map[string]any{
"command_id": randomHex(32),
},
},
})
}
func (c *ConnectClient) queue(ctx context.Context) (Queue, error) {
return withConnectState(ctx, c, func(state connectState) (Queue, error) {
return mapQueue(state), nil
})
}
func (c *ConnectClient) sendStateCommand(ctx context.Context, endpoint string, payload map[string]any) error {
return withConnectStateErr(ctx, c, func(state connectState) error {
return c.sendPlayerCommand(ctx, state, endpoint, payload)
})
}
func withConnectState[T any](ctx context.Context, c *ConnectClient, fn func(connectState) (T, error)) (T, error) {
state, err := c.connectState(ctx)
if err != nil {
var zero T
return zero, err
}
return fn(state)
}
func withConnectStateErr(ctx context.Context, c *ConnectClient, fn func(connectState) error) error {
_, err := withConnectState(ctx, c, func(state connectState) (struct{}, error) {
return struct{}{}, fn(state)
})
return err
}
func connectTransferSourceID(state connectState) string {
fromID := state.originDeviceID
if fromID == "" {
fromID = state.activeDeviceID
}
return fromID
}
func resolveConnectTargetDeviceID(state connectState, selector string) string {
selector = strings.TrimSpace(selector)
if selector == "" {
return ""
}
for _, device := range mapDevices(state) {
if strings.EqualFold(device.ID, selector) || strings.EqualFold(device.Name, selector) {
return device.ID
}
}
return ""
}
func playCommandPayload(uri string) map[string]any {
command := map[string]any{
"endpoint": "play",
"logging_params": map[string]any{
"command_id": randomHex(32),
},
}
command["context"] = map[string]any{"uri": uri, "url": "context://" + uri}
if !isContextURI(uri) {
command["options"] = map[string]any{
"skip_to": map[string]any{"track_uri": uri},
}
}
return map[string]any{"command": command}
}
func clampVolume(volume int) int {
if volume < 0 {
return 0
}
if volume > 100 {
return 100
}
return volume
}
func repeatFlags(mode string) (bool, bool) {
switch strings.ToLower(mode) {
case "track":
return true, false
case "context":
return false, true
default:
return false, false
}
}

View File

@ -1,152 +0,0 @@
package spotify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
)
func TestGetConnectionID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("accept: %v", err)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
data, _ := json.Marshal(map[string]any{"headers": map[string]any{"Spotify-Connection-Id": "conn-id"}})
if err := conn.Write(r.Context(), websocket.MessageText, data); err != nil {
t.Fatalf("write: %v", err)
}
}))
defer srv.Close()
prev := dealerURL
dealerURL = "ws" + strings.TrimPrefix(srv.URL, "http")
t.Cleanup(func() { dealerURL = prev })
id, err := getConnectionID(context.Background(), "token")
if err != nil {
t.Fatalf("getConnectionID: %v", err)
}
if id != "conn-id" {
t.Fatalf("unexpected id: %s", id)
}
}
func TestEnsureConnectDeviceRegisters(t *testing.T) {
wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("accept: %v", err)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
data, _ := json.Marshal(map[string]any{"headers": map[string]any{"Spotify-Connection-Id": "conn-xyz"}})
if err := conn.Write(r.Context(), websocket.MessageText, data); err != nil {
t.Fatalf("write: %v", err)
}
}))
defer wsServer.Close()
prev := dealerURL
dealerURL = "ws" + strings.TrimPrefix(wsServer.URL, "http")
t.Cleanup(func() { dealerURL = prev })
client := newConnectClientForTests(roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return textResponse(http.StatusOK, "ok"), nil
}))
client.session.connectDeviceID = "device"
client.session.connectionID = ""
client.session.registeredAt = time.Time{}
auth := connectAuth{AccessToken: "access", ClientToken: "client-token", ClientVersion: "1.0.0"}
if err := client.ensureConnectDevice(context.Background(), auth); err != nil {
t.Fatalf("ensure: %v", err)
}
if client.session.connectionID == "" {
t.Fatalf("expected connection id")
}
}
func TestGetConnectionIDMissingHeader(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("accept: %v", err)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
data, _ := json.Marshal(map[string]any{"headers": map[string]any{"Other": "nope"}})
if err := conn.Write(r.Context(), websocket.MessageText, data); err != nil {
t.Fatalf("write: %v", err)
}
}))
defer srv.Close()
prev := dealerURL
dealerURL = "ws" + strings.TrimPrefix(srv.URL, "http")
t.Cleanup(func() { dealerURL = prev })
if _, err := getConnectionID(context.Background(), "token"); err == nil {
t.Fatalf("expected error")
}
}
func TestGetConnectionIDBadHeadersType(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("accept: %v", err)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
data, _ := json.Marshal(map[string]any{"headers": "bad"})
if err := conn.Write(r.Context(), websocket.MessageText, data); err != nil {
t.Fatalf("write: %v", err)
}
}))
defer srv.Close()
prev := dealerURL
dealerURL = "ws" + strings.TrimPrefix(srv.URL, "http")
t.Cleanup(func() { dealerURL = prev })
if _, err := getConnectionID(context.Background(), "token"); err == nil {
t.Fatalf("expected error")
}
}
func TestGetConnectionIDDialError(t *testing.T) {
prev := dealerURL
dealerURL = "ws://127.0.0.1:1"
t.Cleanup(func() { dealerURL = prev })
if _, err := getConnectionID(context.Background(), "token"); err == nil {
t.Fatalf("expected error")
}
}
func TestGetConnectionIDBadJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("accept: %v", err)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
if err := conn.Write(r.Context(), websocket.MessageText, []byte("nope")); err != nil {
t.Fatalf("write: %v", err)
}
}))
defer srv.Close()
prev := dealerURL
dealerURL = "ws" + strings.TrimPrefix(srv.URL, "http")
t.Cleanup(func() { dealerURL = prev })
if _, err := getConnectionID(context.Background(), "token"); err == nil {
t.Fatalf("expected error")
}
}

View File

@ -1,115 +0,0 @@
package spotify
import "fmt"
// extractLibraryV3Items navigates the specific libraryV3 response path
// data.me.libraryV3.items[i].item.data to extract items of the given kind.
// Using a targeted path avoids the duplicates and fake sort-category entries
// that a full recursive walk would produce.
func extractLibraryV3Items(payload map[string]any, kind string) ([]Item, int) {
lib, ok := getMap(payload, "data", "me", "libraryV3")
if !ok {
return nil, 0
}
return extractWrappedCollectionItems(lib, "items", "item", "data", "totalCount", kind)
}
// extractFetchLibraryTracks navigates the fetchLibraryTracks response path
// data.me.library.tracks.items[i].track.data to extract track items.
// The track URI lives at items[i].track._uri (not inside .data), so we
// inject it into the data map before passing it to extractItem.
func extractFetchLibraryTracks(payload map[string]any) ([]Item, int, error) {
tracks, ok := getMap(payload, "data", "me", "library", "tracks")
if !ok {
return nil, 0, fmt.Errorf("fetchLibraryTracks payload missing data.me.library.tracks")
}
rawItemsValue, ok := tracks["items"]
if !ok {
return nil, 0, fmt.Errorf("fetchLibraryTracks payload missing data.me.library.tracks.items")
}
rawItems, ok := rawItemsValue.([]any)
if !ok {
return nil, 0, fmt.Errorf("fetchLibraryTracks payload has invalid data.me.library.tracks.items")
}
items := make([]Item, 0, len(rawItems))
seen := map[string]struct{}{}
for _, raw := range rawItems {
m, ok := raw.(map[string]any)
if !ok {
continue
}
wrapper, ok := m["track"].(map[string]any)
if !ok {
continue
}
dataM, ok := wrapper["data"].(map[string]any)
if !ok {
continue
}
// _uri is on the wrapper, not inside data
if uri, ok := wrapper["_uri"].(string); ok && getString(dataM, "uri") == "" {
dataM["uri"] = uri
}
item, ok := extractItem(dataM, "track")
if !ok {
continue
}
if _, dup := seen[item.URI]; dup {
continue
}
seen[item.URI] = struct{}{}
items = append(items, item)
}
total := getInt(tracks, "totalCount")
if total == 0 {
total = len(items)
}
return items, total, nil
}
func extractPlaylistContentItems(payload map[string]any, kind string) ([]Item, int) {
content, ok := getMap(payload, "data", "playlistV2", "content")
if !ok {
return nil, 0
}
return extractWrappedCollectionItems(content, "items", "itemV2", "data", "totalCount", kind)
}
func extractWrappedCollectionItems(container map[string]any, itemsKey, wrapperKey, dataKey, totalKey, kind string) ([]Item, int) {
rawItems, _ := container[itemsKey].([]any)
items := make([]Item, 0, len(rawItems))
seen := map[string]struct{}{}
for _, raw := range rawItems {
dataM, ok := extractWrappedData(raw, wrapperKey, dataKey)
if !ok {
continue
}
item, ok := extractItem(dataM, kind)
if !ok {
continue
}
if _, dup := seen[item.URI]; dup {
continue
}
seen[item.URI] = struct{}{}
items = append(items, item)
}
total := getInt(container, totalKey)
if total == 0 {
total = len(items)
}
return items, total
}
func extractWrappedData(raw any, wrapperKey, dataKey string) (map[string]any, bool) {
m, ok := raw.(map[string]any)
if !ok {
return nil, false
}
wrapper, ok := m[wrapperKey].(map[string]any)
if !ok {
return nil, false
}
dataM, ok := wrapper[dataKey].(map[string]any)
return dataM, ok
}

View File

@ -1,159 +0,0 @@
package spotify
import (
"fmt"
"strings"
)
func collectItemsByKind(value any, kind string) []Item {
items := []Item{}
visitItems(value, kind, &items)
return items
}
func visitItems(value any, kind string, items *[]Item) {
switch typed := value.(type) {
case map[string]any:
if item, ok := extractItem(typed, kind); ok {
*items = append(*items, item)
}
for _, child := range typed {
visitItems(child, kind, items)
}
case []any:
for _, child := range typed {
visitItems(child, kind, items)
}
}
}
func extractItem(value any, kind string) (Item, bool) {
m, ok := value.(map[string]any)
if !ok {
return Item{}, false
}
if kind == "track" {
if inner, ok := m["track"].(map[string]any); ok {
m = inner
}
}
uri := getString(m, "uri")
if uri == "" && kind != "" {
if id := getString(m, "id"); id != "" {
uri = "spotify:" + kind + ":" + id
}
}
if uri == "" {
if inner := findFirstURI(m, kind); inner != "" {
uri = inner
}
}
if uri == "" {
return Item{}, false
}
if kind != "" && !strings.HasPrefix(uri, "spotify:"+kind+":") {
return Item{}, false
}
name := getString(m, "name")
if name == "" {
name = getString(m, "title")
}
if name == "" {
name = findFirstName(m)
}
item := Item{
URI: uri,
ID: idFromURI(uri),
Name: name,
Type: typeFromURI(uri),
}
item.URL = fmt.Sprintf("https://open.spotify.com/%s/%s", item.Type, item.ID)
item.Artists = extractArtistNames(m)
if len(item.Artists) == 0 && item.Type == "track" {
item.Artists = findFirstArtistNames(m)
}
if item.Type == "track" {
if album := extractAlbumName(m); album != "" {
item.Album = album
}
}
item.Explicit = getBool(m, "explicit")
item.DurationMS = getInt(m, "duration_ms")
if item.DurationMS == 0 {
item.DurationMS = getInt(m, "durationMs")
}
item.Owner = extractOwnerName(m)
item.TotalTracks = getInt(m, "totalTracks")
if item.TotalTracks == 0 {
item.TotalTracks = getInt(m, "total")
}
item.ReleaseDate = getString(m, "releaseDate")
item.Description = getString(m, "description")
item.IsPlayable = getBool(m, "isPlayable")
item.Publisher = getString(m, "publisher")
item.TotalEpisodes = getInt(m, "totalEpisodes")
return item, true
}
func idFromURI(uri string) string {
parts := strings.Split(uri, ":")
if len(parts) >= 3 {
return parts[len(parts)-1]
}
return uri
}
func typeFromURI(uri string) string {
parts := strings.Split(uri, ":")
if len(parts) >= 3 {
return parts[len(parts)-2]
}
return ""
}
func findFirstURI(value any, kind string) string {
switch typed := value.(type) {
case map[string]any:
if uri, ok := typed["uri"].(string); ok {
if kind == "" || strings.HasPrefix(uri, "spotify:"+kind+":") {
return uri
}
}
for _, child := range typed {
if uri := findFirstURI(child, kind); uri != "" {
return uri
}
}
case []any:
for _, child := range typed {
if uri := findFirstURI(child, kind); uri != "" {
return uri
}
}
}
return ""
}
func findFirstName(value any) string {
switch typed := value.(type) {
case map[string]any:
if name, ok := typed["name"].(string); ok {
return name
}
if title, ok := typed["title"].(string); ok {
return title
}
for _, child := range typed {
if name := findFirstName(child); name != "" {
return name
}
}
case []any:
for _, child := range typed {
if name := findFirstName(child); name != "" {
return name
}
}
}
return ""
}

View File

@ -1,171 +0,0 @@
package spotify
import "strings"
func extractArtistNames(value any) []string {
artists := []string{}
m, ok := value.(map[string]any)
if !ok {
return nil
}
if list, ok := m["artists"].([]any); ok {
appendArtistNames(&artists, list)
}
if group, ok := m["artists"].(map[string]any); ok {
if list, ok := group["items"].([]any); ok {
appendArtistNames(&artists, list)
}
if list, ok := group["nodes"].([]any); ok {
appendArtistNames(&artists, list)
}
if list, ok := group["edges"].([]any); ok {
appendArtistNames(&artists, list)
}
}
if group, ok := m["firstArtist"].(map[string]any); ok {
if list, ok := group["items"].([]any); ok {
appendArtistNames(&artists, list)
}
if list, ok := group["nodes"].([]any); ok {
appendArtistNames(&artists, list)
}
if list, ok := group["edges"].([]any); ok {
appendArtistNames(&artists, list)
}
}
if group, ok := m["otherArtists"].(map[string]any); ok {
if list, ok := group["items"].([]any); ok {
appendArtistNames(&artists, list)
}
if list, ok := group["nodes"].([]any); ok {
appendArtistNames(&artists, list)
}
if list, ok := group["edges"].([]any); ok {
appendArtistNames(&artists, list)
}
}
if len(artists) == 0 {
if name := getString(m, "artistName"); name != "" {
artists = append(artists, name)
}
}
return dedupeStrings(artists)
}
func appendArtistNames(artists *[]string, entries []any) {
for _, entry := range entries {
if name := artistNameFromValue(entry); name != "" {
*artists = append(*artists, name)
}
}
}
func findFirstArtistNames(value any) []string {
var artists []string
walkMap(value, func(m map[string]any) {
if len(artists) > 0 {
return
}
if found := extractArtistNames(m); len(found) > 0 {
artists = found
}
})
return artists
}
func artistNameFromValue(value any) string {
m, ok := value.(map[string]any)
if !ok {
return ""
}
if profile, ok := m["profile"].(map[string]any); ok {
if name := getString(profile, "name"); name != "" {
return name
}
}
if node, ok := m["node"].(map[string]any); ok {
if name := artistNameFromValue(node); name != "" {
return name
}
}
if artist, ok := m["artist"].(map[string]any); ok {
if name := artistNameFromValue(artist); name != "" {
return name
}
}
name := getString(m, "name")
if name == "" {
return ""
}
if len(m) == 1 || isArtistMap(m) || getString(m, "id") != "" {
return name
}
return ""
}
func isArtistMap(m map[string]any) bool {
if uri := getString(m, "uri"); strings.HasPrefix(uri, "spotify:artist:") {
return true
}
if typ := getString(m, "type"); typ == "artist" {
return true
}
if _, ok := m["profile"]; ok {
return true
}
return false
}
func extractAlbumName(value any) string {
var album string
walkMap(value, func(m map[string]any) {
if album != "" {
return
}
if inner, ok := m["album"].(map[string]any); ok {
if name := getString(inner, "name"); name != "" {
album = name
}
}
if inner, ok := m["albumOfTrack"].(map[string]any); ok {
if name := getString(inner, "name"); name != "" {
album = name
}
}
})
return album
}
func extractOwnerName(value any) string {
var owner string
walkMap(value, func(m map[string]any) {
if owner != "" {
return
}
if inner, ok := m["owner"].(map[string]any); ok {
if name := getString(inner, "name"); name != "" {
owner = name
}
}
if inner, ok := m["user"].(map[string]any); ok {
if name := getString(inner, "name"); name != "" {
owner = name
}
}
})
return owner
}
func walkMap(value any, fn func(map[string]any)) {
switch typed := value.(type) {
case map[string]any:
fn(typed)
for _, child := range typed {
walkMap(child, fn)
}
case []any:
for _, child := range typed {
walkMap(child, fn)
}
}
}

View File

@ -1,72 +0,0 @@
package spotify
func extractSearchItems(payload map[string]any, kind string) ([]Item, int) {
for _, path := range searchPaths(kind) {
if container, ok := getMap(payload, path...); ok {
items := extractItemsFromContainer(container, kind)
total := getInt(container, "totalCount")
if total == 0 {
total = len(items)
}
return items, total
}
}
items := collectItemsByKind(payload, kind)
return items, len(items)
}
func extractItemFromPayload(payload map[string]any, kind string) (Item, bool) {
if kind == "track" {
if m, ok := getMap(payload, "data", "trackUnion"); ok {
if item, ok := extractItem(m, kind); ok {
return item, true
}
}
if m, ok := getMap(payload, "data", "track"); ok {
if item, ok := extractItem(m, kind); ok {
return item, true
}
}
}
items := collectItemsByKind(payload, kind)
if len(items) == 0 {
return Item{}, false
}
return items[0], true
}
func searchPaths(kind string) [][]string {
switch kind {
case "track":
return [][]string{{"data", "searchV2", "tracksV2"}}
case "album":
return [][]string{{"data", "searchV2", "albumsV2"}, {"data", "searchV2", "albums"}}
case "artist":
return [][]string{{"data", "searchV2", "artists"}}
case "playlist":
return [][]string{{"data", "searchV2", "playlists"}}
case "show":
return [][]string{{"data", "searchV2", "podcasts"}, {"data", "searchV2", "shows"}}
case "episode":
return [][]string{{"data", "searchV2", "episodes"}}
default:
return nil
}
}
func extractItemsFromContainer(container map[string]any, kind string) []Item {
itemsRaw, ok := container["items"].([]any)
if !ok {
return collectItemsByKind(container, kind)
}
items := make([]Item, 0, len(itemsRaw))
for _, raw := range itemsRaw {
if item, ok := extractItem(raw, kind); ok {
items = append(items, item)
}
}
if len(items) == 0 {
return collectItemsByKind(container, kind)
}
return items
}

View File

@ -1,70 +0,0 @@
package spotify
import "strings"
func getMap(value any, path ...string) (map[string]any, bool) {
current := value
for _, key := range path {
m, ok := current.(map[string]any)
if !ok {
return nil, false
}
next, ok := m[key]
if !ok {
return nil, false
}
current = next
}
m, ok := current.(map[string]any)
return m, ok
}
func getString(m map[string]any, key string) string {
if m == nil {
return ""
}
if value, ok := m[key].(string); ok {
return value
}
return ""
}
func getInt(m map[string]any, key string) int {
if m == nil {
return 0
}
switch value := m[key].(type) {
case int:
return value
case float64:
return int(value)
}
return 0
}
func getBool(m map[string]any, key string) bool {
if m == nil {
return false
}
if value, ok := m[key].(bool); ok {
return value
}
return false
}
func dedupeStrings(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}

View File

@ -125,7 +125,7 @@ func (h *hashResolver) fetchWebPlayerHTML(ctx context.Context) (string, error) {
if err != nil {
return "", err
}
applyRequestHeaders(req, requestHeaders{})
req.Header.Set("User-Agent", defaultUserAgent())
resp, err := h.client.Do(req)
if err != nil {
return "", err
@ -146,7 +146,7 @@ func (h *hashResolver) fetchText(ctx context.Context, url string) (string, error
if err != nil {
return "", err
}
applyRequestHeaders(req, requestHeaders{})
req.Header.Set("User-Agent", defaultUserAgent())
resp, err := h.client.Do(req)
if err != nil {
return "", err

View File

@ -1,100 +0,0 @@
package spotify
import (
"context"
"fmt"
)
func (c *ConnectClient) trackInfo(ctx context.Context, id string) (Item, error) {
return c.infoWithWebFallback(ctx, id, "track", func() (Item, error) {
return c.infoByOperation(ctx, "getTrack", map[string]any{"uri": "spotify:track:" + id}, "track")
}, func(web *Client) (Item, error) {
return web.GetTrack(ctx, id)
})
}
func (c *ConnectClient) albumInfo(ctx context.Context, id string) (Item, error) {
return c.infoWithWebFallback(ctx, id, "album", func() (Item, error) {
return c.infoByOperation(ctx, "getAlbum", map[string]any{"uri": "spotify:album:" + id}, "album")
}, func(web *Client) (Item, error) {
return web.GetAlbum(ctx, id)
})
}
func (c *ConnectClient) artistInfo(ctx context.Context, id string) (Item, error) {
return c.infoWithWebFallback(ctx, id, "artist", func() (Item, error) {
return c.infoByOperation(ctx, "queryArtistOverview", map[string]any{
"uri": "spotify:artist:" + id,
"locale": c.language,
}, "artist")
}, func(web *Client) (Item, error) {
return web.GetArtist(ctx, id)
})
}
func (c *ConnectClient) playlistInfo(ctx context.Context, id string) (Item, error) {
return c.infoWithWebFallback(ctx, id, "playlist", func() (Item, error) {
return c.infoByOperation(ctx, "fetchPlaylist", map[string]any{
"uri": "spotify:playlist:" + id,
"offset": 0,
"limit": 25,
"enableWatchFeedEntrypoint": false,
}, "playlist")
}, func(web *Client) (Item, error) {
return web.GetPlaylist(ctx, id)
})
}
func (c *ConnectClient) showInfo(ctx context.Context, id string) (Item, error) {
return c.infoWithWebFallback(ctx, id, "show", func() (Item, error) {
return c.infoByOperation(ctx, "queryPodcastEpisodes", map[string]any{
"uri": "spotify:show:" + id,
"offset": 0,
"limit": 25,
}, "show")
}, func(web *Client) (Item, error) {
return web.GetShow(ctx, id)
})
}
func (c *ConnectClient) episodeInfo(ctx context.Context, id string) (Item, error) {
return c.infoWithWebFallback(ctx, id, "episode", func() (Item, error) {
return c.infoByOperation(ctx, "getEpisodeOrChapter", map[string]any{
"uri": "spotify:episode:" + id,
}, "episode")
}, func(web *Client) (Item, error) {
return web.GetEpisode(ctx, id)
})
}
func (c *ConnectClient) ArtistTopTracks(ctx context.Context, id string, limit int) ([]Item, error) {
web, err := c.webClient()
if err != nil {
return nil, err
}
return web.ArtistTopTracks(ctx, id, limit)
}
func (c *ConnectClient) infoByOperation(ctx context.Context, operation string, variables map[string]any, kind string) (Item, error) {
payload, err := c.graphQL(ctx, operation, variables)
if err != nil {
return Item{}, err
}
item, ok := extractItemFromPayload(payload, kind)
if !ok {
return Item{}, fmt.Errorf("no %s found", kind)
}
return item, nil
}
func (c *ConnectClient) infoWithWebFallback(ctx context.Context, id, kind string, connectLookup func() (Item, error), webLookup func(*Client) (Item, error)) (Item, error) {
item, err := connectLookup()
if err == nil {
return item, nil
}
web, werr := c.webClient()
if werr != nil {
return Item{}, err
}
return webLookup(web)
}

View File

@ -1,82 +0,0 @@
package spotify
import "context"
func (c *ConnectClient) playlists(ctx context.Context, limit, offset int) ([]Item, int, error) {
payload, err := c.graphQL(ctx, "libraryV3", libraryV3Variables("Playlists", normalizeLibraryLimit(limit), offset))
if err != nil {
return nil, 0, err
}
items, total := extractLibraryV3Items(payload, "playlist")
return items, total, nil
}
func (c *ConnectClient) playlistTracks(ctx context.Context, id string, limit, offset int) ([]Item, int, error) {
payload, err := c.graphQL(ctx, "fetchPlaylist", playlistTrackVariables(id, normalizePlaylistTrackLimit(limit), offset))
if err != nil {
return nil, 0, err
}
items, total := extractPlaylistContentItems(payload, "track")
return items, total, nil
}
func (c *ConnectClient) libraryTracks(ctx context.Context, limit, offset int) ([]Item, int, error) {
vars := map[string]any{
"uri": "spotify:collection:tracks",
"offset": offset,
"limit": normalizeLibraryLimit(limit),
}
payload, err := c.graphQL(ctx, "fetchLibraryTracks", vars)
if err != nil {
return nil, 0, err
}
return extractFetchLibraryTracks(payload)
}
func (c *ConnectClient) libraryAlbums(ctx context.Context, limit, offset int) ([]Item, int, error) {
payload, err := c.graphQL(ctx, "libraryV3", libraryV3Variables("Albums", normalizeLibraryLimit(limit), offset))
if err != nil {
return nil, 0, err
}
items, total := extractLibraryV3Items(payload, "album")
return items, total, nil
}
func normalizeLibraryLimit(limit int) int {
if limit <= 0 {
return 50
}
return limit
}
func normalizePlaylistTrackLimit(limit int) int {
if limit <= 0 {
return 25
}
return limit
}
func libraryV3Variables(filter string, limit, offset int) map[string]any {
return map[string]any{
"filters": []any{filter},
"order": nil,
"textFilter": "",
"features": []any{"LIKED_SONGS", "YOUR_EPISODES"},
"limit": limit,
"offset": offset,
"flatten": false,
"expandedFolders": []any{},
"folderUri": nil,
"includeFoldersWhenFlattening": true,
"withCuration": false,
}
}
func playlistTrackVariables(id string, limit, offset int) map[string]any {
return map[string]any{
"uri": "spotify:playlist:" + id,
"offset": offset,
"limit": limit,
"enableWatchFeedEntrypoint": false,
}
}

View File

@ -1,30 +0,0 @@
package spotify
import "testing"
func TestNormalizeConnectLimits(t *testing.T) {
if got := normalizeLibraryLimit(0); got != 50 {
t.Fatalf("library default = %d", got)
}
if got := normalizeLibraryLimit(7); got != 7 {
t.Fatalf("library custom = %d", got)
}
if got := normalizePlaylistTrackLimit(0); got != 25 {
t.Fatalf("playlist default = %d", got)
}
if got := normalizePlaylistTrackLimit(9); got != 9 {
t.Fatalf("playlist custom = %d", got)
}
if got := normalizeSearchLimit(0); got != 10 {
t.Fatalf("search default = %d", got)
}
if got := normalizeSearchLimit(4); got != 4 {
t.Fatalf("search custom = %d", got)
}
if got := normalizeOffset(-1); got != 0 {
t.Fatalf("offset default = %d", got)
}
if got := normalizeOffset(3); got != 3 {
t.Fatalf("offset custom = %d", got)
}
}

View File

@ -0,0 +1,358 @@
package spotify
import (
"fmt"
"strings"
)
func extractSearchItems(payload map[string]any, kind string) ([]Item, int) {
paths := searchPaths(kind)
for _, path := range paths {
if container, ok := getMap(payload, path...); ok {
items := extractItemsFromContainer(container, kind)
total := getInt(container, "totalCount")
if total == 0 {
total = len(items)
}
return items, total
}
}
items := collectItemsByKind(payload, kind)
return items, len(items)
}
func extractItemFromPayload(payload map[string]any, kind string) (Item, bool) {
items := collectItemsByKind(payload, kind)
if len(items) == 0 {
return Item{}, false
}
return items[0], true
}
func searchPaths(kind string) [][]string {
switch kind {
case "track":
return [][]string{{"data", "searchV2", "tracksV2"}}
case "album":
return [][]string{
{"data", "searchV2", "albumsV2"},
{"data", "searchV2", "albums"},
}
case "artist":
return [][]string{{"data", "searchV2", "artists"}}
case "playlist":
return [][]string{{"data", "searchV2", "playlists"}}
case "show":
return [][]string{
{"data", "searchV2", "podcasts"},
{"data", "searchV2", "shows"},
}
case "episode":
return [][]string{{"data", "searchV2", "episodes"}}
default:
return nil
}
}
func extractItemsFromContainer(container map[string]any, kind string) []Item {
itemsRaw, ok := container["items"].([]any)
if !ok {
return collectItemsByKind(container, kind)
}
items := make([]Item, 0, len(itemsRaw))
for _, raw := range itemsRaw {
item, ok := extractItem(raw, kind)
if ok {
items = append(items, item)
}
}
if len(items) == 0 {
return collectItemsByKind(container, kind)
}
return items
}
func collectItemsByKind(value any, kind string) []Item {
items := []Item{}
visitItems(value, kind, &items)
return items
}
func visitItems(value any, kind string, items *[]Item) {
switch typed := value.(type) {
case map[string]any:
if item, ok := extractItem(typed, kind); ok {
*items = append(*items, item)
}
for _, child := range typed {
visitItems(child, kind, items)
}
case []any:
for _, child := range typed {
visitItems(child, kind, items)
}
}
}
func extractItem(value any, kind string) (Item, bool) {
m, ok := value.(map[string]any)
if !ok {
return Item{}, false
}
uri := getString(m, "uri")
if uri == "" && kind != "" {
if id := getString(m, "id"); id != "" {
uri = "spotify:" + kind + ":" + id
}
}
if uri == "" {
if inner := findFirstURI(m, kind); inner != "" {
uri = inner
}
}
if uri == "" {
return Item{}, false
}
if kind != "" && !strings.HasPrefix(uri, "spotify:"+kind+":") {
return Item{}, false
}
name := getString(m, "name")
if name == "" {
name = getString(m, "title")
}
if name == "" {
name = findFirstName(m)
}
item := Item{
URI: uri,
ID: idFromURI(uri),
Name: name,
Type: typeFromURI(uri),
}
item.URL = fmt.Sprintf("https://open.spotify.com/%s/%s", item.Type, item.ID)
item.Artists = extractArtistNames(m)
if album := extractAlbumName(m); album != "" {
item.Album = album
}
item.Explicit = getBool(m, "explicit")
item.DurationMS = getInt(m, "duration_ms")
if item.DurationMS == 0 {
item.DurationMS = getInt(m, "durationMs")
}
item.Owner = extractOwnerName(m)
item.TotalTracks = getInt(m, "totalTracks")
if item.TotalTracks == 0 {
item.TotalTracks = getInt(m, "total")
}
item.ReleaseDate = getString(m, "releaseDate")
item.Description = getString(m, "description")
item.IsPlayable = getBool(m, "isPlayable")
item.Publisher = getString(m, "publisher")
item.TotalEpisodes = getInt(m, "totalEpisodes")
return item, true
}
func idFromURI(uri string) string {
parts := strings.Split(uri, ":")
if len(parts) >= 3 {
return parts[len(parts)-1]
}
return uri
}
func typeFromURI(uri string) string {
parts := strings.Split(uri, ":")
if len(parts) >= 3 {
return parts[len(parts)-2]
}
return ""
}
func findFirstURI(value any, kind string) string {
switch typed := value.(type) {
case map[string]any:
if uri, ok := typed["uri"].(string); ok {
if kind == "" || strings.HasPrefix(uri, "spotify:"+kind+":") {
return uri
}
}
for _, child := range typed {
if uri := findFirstURI(child, kind); uri != "" {
return uri
}
}
case []any:
for _, child := range typed {
if uri := findFirstURI(child, kind); uri != "" {
return uri
}
}
}
return ""
}
func findFirstName(value any) string {
switch typed := value.(type) {
case map[string]any:
if name, ok := typed["name"].(string); ok {
return name
}
if title, ok := typed["title"].(string); ok {
return title
}
for _, child := range typed {
if name := findFirstName(child); name != "" {
return name
}
}
case []any:
for _, child := range typed {
if name := findFirstName(child); name != "" {
return name
}
}
}
return ""
}
func extractArtistNames(value any) []string {
artists := []string{}
walkMap(value, func(m map[string]any) {
if list, ok := m["artists"].([]any); ok {
for _, entry := range list {
if name := findFirstName(entry); name != "" {
artists = append(artists, name)
}
}
}
})
if len(artists) == 0 {
if m, ok := value.(map[string]any); ok {
if name := getString(m, "artistName"); name != "" {
artists = append(artists, name)
}
}
}
return dedupeStrings(artists)
}
func extractAlbumName(value any) string {
var album string
walkMap(value, func(m map[string]any) {
if album != "" {
return
}
if inner, ok := m["album"].(map[string]any); ok {
if name := getString(inner, "name"); name != "" {
album = name
}
}
if inner, ok := m["albumOfTrack"].(map[string]any); ok {
if name := getString(inner, "name"); name != "" {
album = name
}
}
})
return album
}
func extractOwnerName(value any) string {
var owner string
walkMap(value, func(m map[string]any) {
if owner != "" {
return
}
if inner, ok := m["owner"].(map[string]any); ok {
if name := getString(inner, "name"); name != "" {
owner = name
}
}
if inner, ok := m["user"].(map[string]any); ok {
if name := getString(inner, "name"); name != "" {
owner = name
}
}
})
return owner
}
func walkMap(value any, fn func(map[string]any)) {
switch typed := value.(type) {
case map[string]any:
fn(typed)
for _, child := range typed {
walkMap(child, fn)
}
case []any:
for _, child := range typed {
walkMap(child, fn)
}
}
}
func getMap(value any, path ...string) (map[string]any, bool) {
current := value
for _, key := range path {
m, ok := current.(map[string]any)
if !ok {
return nil, false
}
next, ok := m[key]
if !ok {
return nil, false
}
current = next
}
m, ok := current.(map[string]any)
return m, ok
}
func getString(m map[string]any, key string) string {
if m == nil {
return ""
}
if value, ok := m[key].(string); ok {
return value
}
return ""
}
func getInt(m map[string]any, key string) int {
if m == nil {
return 0
}
switch value := m[key].(type) {
case int:
return value
case float64:
return int(value)
}
return 0
}
func getBool(m map[string]any, key string) bool {
if m == nil {
return false
}
if value, ok := m[key].(bool); ok {
return value
}
return false
}
func dedupeStrings(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}

View File

@ -23,93 +23,6 @@ func TestExtractItem(t *testing.T) {
}
}
func TestExtractFetchLibraryTracks(t *testing.T) {
payload := map[string]any{
"data": map[string]any{"me": map[string]any{"library": map[string]any{"tracks": map[string]any{
"totalCount": 2,
"items": []any{
map[string]any{"track": map[string]any{
"_uri": "spotify:track:t1",
"data": map[string]any{"name": "Song One"},
}},
map[string]any{"track": map[string]any{
"_uri": "spotify:track:t2",
"data": map[string]any{"name": "Song Two"},
}},
},
}}}},
}
items, total, err := extractFetchLibraryTracks(payload)
if err != nil {
t.Fatalf("extract: %v", err)
}
if total != 2 || len(items) != 2 {
t.Fatalf("expected 2 items, got %d (total %d)", len(items), total)
}
if items[0].ID != "t1" || items[0].Name != "Song One" {
t.Fatalf("unexpected first item: %#v", items[0])
}
if items[1].ID != "t2" || items[1].Name != "Song Two" {
t.Fatalf("unexpected second item: %#v", items[1])
}
}
func TestExtractFetchLibraryTracksDedupes(t *testing.T) {
payload := map[string]any{
"data": map[string]any{"me": map[string]any{"library": map[string]any{"tracks": map[string]any{
"totalCount": 1,
"items": []any{
map[string]any{"track": map[string]any{
"_uri": "spotify:track:t1",
"data": map[string]any{"name": "Song"},
}},
map[string]any{"track": map[string]any{
"_uri": "spotify:track:t1",
"data": map[string]any{"name": "Song"},
}},
},
}}}},
}
items, _, err := extractFetchLibraryTracks(payload)
if err != nil {
t.Fatalf("extract: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 deduped item, got %d", len(items))
}
}
func TestExtractFetchLibraryTracksMissingPath(t *testing.T) {
items, total, err := extractFetchLibraryTracks(map[string]any{})
if err == nil {
t.Fatalf("expected error, got %d items (total %d)", len(items), total)
}
}
func TestExtractFetchLibraryTracksSkipsMalformed(t *testing.T) {
payload := map[string]any{
"data": map[string]any{"me": map[string]any{"library": map[string]any{"tracks": map[string]any{
"totalCount": 0,
"items": []any{
"not a map",
map[string]any{"track": "not a map"},
map[string]any{"track": map[string]any{"_uri": "spotify:track:t1"}},
map[string]any{"track": map[string]any{
"_uri": "spotify:track:t2",
"data": map[string]any{"name": "Valid"},
}},
},
}}}},
}
items, _, err := extractFetchLibraryTracks(payload)
if err != nil {
t.Fatalf("extract: %v", err)
}
if len(items) != 1 || items[0].ID != "t2" {
t.Fatalf("expected 1 valid item, got %#v", items)
}
}
func TestSearchPaths(t *testing.T) {
paths := searchPaths("track")
if len(paths) == 0 {
@ -149,209 +62,6 @@ func TestExtractItemFallbacks(t *testing.T) {
}
}
func TestExtractItemArtistsContainers(t *testing.T) {
raw := map[string]any{
"uri": "spotify:track:abc",
"name": "Song",
"artists": map[string]any{
"items": []any{
map[string]any{"name": "Artist One"},
map[string]any{"name": "Artist Two"},
},
},
}
item, ok := extractItem(raw, "track")
if !ok {
t.Fatalf("expected item")
}
if len(item.Artists) != 2 || item.Artists[0] != "Artist One" || item.Artists[1] != "Artist Two" {
t.Fatalf("unexpected artists: %#v", item.Artists)
}
}
func TestExtractItemArtistsEdges(t *testing.T) {
raw := map[string]any{
"uri": "spotify:track:abc",
"name": "Song",
"artists": map[string]any{
"edges": []any{
map[string]any{"node": map[string]any{"name": "Artist One"}},
map[string]any{"node": map[string]any{"name": "Artist Two"}},
},
},
}
item, ok := extractItem(raw, "track")
if !ok {
t.Fatalf("expected item")
}
if len(item.Artists) != 2 || item.Artists[0] != "Artist One" || item.Artists[1] != "Artist Two" {
t.Fatalf("unexpected artists: %#v", item.Artists)
}
}
func TestExtractItemFirstArtistItems(t *testing.T) {
raw := map[string]any{
"uri": "spotify:track:abc",
"name": "Song",
"firstArtist": map[string]any{
"items": []any{
map[string]any{"profile": map[string]any{"name": "Artist One"}},
},
},
}
item, ok := extractItem(raw, "track")
if !ok {
t.Fatalf("expected item")
}
if len(item.Artists) != 1 || item.Artists[0] != "Artist One" {
t.Fatalf("unexpected artists: %#v", item.Artists)
}
}
func TestExtractItemOtherArtistsItems(t *testing.T) {
raw := map[string]any{
"uri": "spotify:track:abc",
"name": "Song",
"firstArtist": map[string]any{
"items": []any{
map[string]any{"profile": map[string]any{"name": "Artist One"}},
},
},
"otherArtists": map[string]any{
"items": []any{
map[string]any{"profile": map[string]any{"name": "Artist Two"}},
},
},
}
item, ok := extractItem(raw, "track")
if !ok {
t.Fatalf("expected item")
}
if len(item.Artists) != 2 || item.Artists[0] != "Artist One" || item.Artists[1] != "Artist Two" {
t.Fatalf("unexpected artists: %#v", item.Artists)
}
}
func TestExtractSearchTrackItemNestedArtists(t *testing.T) {
payload := map[string]any{
"data": map[string]any{
"searchV2": map[string]any{
"tracksV2": map[string]any{
"totalCount": 1,
"items": []any{
map[string]any{
"item": map[string]any{
"data": map[string]any{
"uri": "spotify:track:abc",
"name": "Song",
"artists": map[string]any{
"items": []any{
map[string]any{"profile": map[string]any{"name": "Artist"}},
},
},
"albumOfTrack": map[string]any{"name": "Album"},
},
},
},
},
},
},
},
}
items, total := extractSearchItems(payload, "track")
if total != 1 || len(items) != 1 {
t.Fatalf("unexpected items: %#v total=%d", items, total)
}
if len(items[0].Artists) != 1 || items[0].Artists[0] != "Artist" || items[0].Album != "Album" {
t.Fatalf("unexpected metadata: %#v", items[0])
}
}
func TestExtractPlaylistDoesNotLeakNestedTrackAlbum(t *testing.T) {
payload := map[string]any{
"data": map[string]any{
"playlistV2": map[string]any{
"uri": "spotify:playlist:p1",
"name": "Playlist",
"content": map[string]any{
"items": []any{
map[string]any{"itemV2": map[string]any{"data": map[string]any{
"track": map[string]any{
"uri": "spotify:track:t1",
"name": "Song",
"album": map[string]any{"name": "Album"},
},
}}},
},
},
},
},
}
item, ok := extractItemFromPayload(payload, "playlist")
if !ok {
t.Fatalf("expected playlist")
}
if item.Album != "" {
t.Fatalf("playlist leaked album metadata: %#v", item)
}
}
func TestExtractItemFromPayloadPrefersTrackUnion(t *testing.T) {
payload := map[string]any{
"data": map[string]any{
"trackUnion": map[string]any{
"uri": "spotify:track:primary",
"name": "Primary",
"artists": []any{
map[string]any{"name": "Main Artist"},
},
},
"track": map[string]any{
"uri": "spotify:track:secondary",
"name": "Secondary",
"artists": []any{
map[string]any{"name": "Wrong Artist"},
},
},
"other": map[string]any{
"items": []any{
map[string]any{
"uri": "spotify:track:secondary",
"name": "Secondary",
"artists": []any{
map[string]any{"name": "Wrong Artist"},
},
},
},
},
},
}
item, ok := extractItemFromPayload(payload, "track")
if !ok {
t.Fatalf("expected item")
}
if item.ID != "primary" || len(item.Artists) != 1 || item.Artists[0] != "Main Artist" {
t.Fatalf("unexpected item: %#v", item)
}
}
func TestExtractItemArtistsIDName(t *testing.T) {
raw := map[string]any{
"uri": "spotify:track:abc",
"name": "Song",
"artists": []any{
map[string]any{"id": "ar1", "name": "Artist One"},
},
}
item, ok := extractItem(raw, "track")
if !ok {
t.Fatalf("expected item")
}
if len(item.Artists) != 1 || item.Artists[0] != "Artist One" {
t.Fatalf("unexpected artists: %#v", item.Artists)
}
}
func TestExtractSearchItemsFallback(t *testing.T) {
payload := map[string]any{
"data": map[string]any{

View File

@ -4,16 +4,15 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)
const pathfinderURL = "https://api-partner.spotify.com/pathfinder/v1/query"
func (c *ConnectClient) graphQL(ctx context.Context, operation string, variables map[string]any) (map[string]any, error) {
if c.session == nil {
return nil, errors.New("connect client not initialized")
}
auth, err := c.session.auth(ctx)
if err != nil {
return nil, err
@ -46,14 +45,15 @@ func (c *ConnectClient) graphQL(ctx context.Context, operation string, variables
if err != nil {
return nil, err
}
applyRequestHeaders(req, requestHeaders{
AccessToken: auth.AccessToken,
ClientToken: auth.ClientToken,
ClientVersion: auth.ClientVersion,
Accept: "application/json",
Language: c.language,
AppPlatform: defaultSpotifyAppPlatform,
})
req.Header.Set("Authorization", "Bearer "+auth.AccessToken)
req.Header.Set("Client-Token", auth.ClientToken)
req.Header.Set("Spotify-App-Version", auth.ClientVersion)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", defaultUserAgent())
if c.language != "" {
req.Header.Set("Accept-Language", c.language)
}
req.Header.Set("app-platform", "WebPlayer")
client := c.searchClient
if client == nil {
client = c.client
@ -95,3 +95,238 @@ func pathfinderError(payload map[string]any) error {
}
return errors.New(message)
}
func (c *ConnectClient) search(ctx context.Context, kind, query string, limit, offset int) (SearchResult, error) {
if strings.TrimSpace(query) == "" {
return SearchResult{}, errors.New("query required")
}
if limit <= 0 {
limit = 10
}
if offset < 0 {
offset = 0
}
variables := map[string]any{
"searchTerm": query,
"offset": offset,
"limit": limit,
"numberOfTopResults": 5,
"includeAudiobooks": true,
"includePreReleases": true,
"includeLocalConcertsField": false,
"includeArtistHasConcertsField": false,
}
payload, err := c.graphQL(ctx, "searchDesktop", variables)
if err != nil {
fallback, ferr := c.searchViaWeb(ctx, kind, query, limit, offset)
if ferr == nil {
return fallback, nil
}
return SearchResult{}, ferr
}
items, total := extractSearchItems(payload, kind)
return SearchResult{
Type: kind,
Limit: limit,
Offset: offset,
Total: total,
Items: items,
}, nil
}
func (c *ConnectClient) trackInfo(ctx context.Context, id string) (Item, error) {
item, err := c.infoByOperation(ctx, "getTrack", map[string]any{"uri": "spotify:track:" + id}, "track")
if err == nil {
return item, nil
}
web, ferr := c.webClient()
if ferr != nil {
return Item{}, err
}
return web.GetTrack(ctx, id)
}
func (c *ConnectClient) albumInfo(ctx context.Context, id string) (Item, error) {
item, err := c.infoByOperation(ctx, "getAlbum", map[string]any{"uri": "spotify:album:" + id}, "album")
if err == nil {
return item, nil
}
web, ferr := c.webClient()
if ferr != nil {
return Item{}, err
}
return web.GetAlbum(ctx, id)
}
func (c *ConnectClient) artistInfo(ctx context.Context, id string) (Item, error) {
item, err := c.infoByOperation(ctx, "queryArtistOverview", map[string]any{
"uri": "spotify:artist:" + id,
"locale": c.language,
}, "artist")
if err == nil {
return item, nil
}
web, ferr := c.webClient()
if ferr != nil {
return Item{}, err
}
return web.GetArtist(ctx, id)
}
func (c *ConnectClient) playlistInfo(ctx context.Context, id string) (Item, error) {
item, err := c.infoByOperation(ctx, "fetchPlaylist", map[string]any{
"uri": "spotify:playlist:" + id,
"offset": 0,
"limit": 25,
"enableWatchFeedEntrypoint": false,
}, "playlist")
if err == nil {
return item, nil
}
web, ferr := c.webClient()
if ferr != nil {
return Item{}, err
}
return web.GetPlaylist(ctx, id)
}
func (c *ConnectClient) showInfo(ctx context.Context, id string) (Item, error) {
item, err := c.infoByOperation(ctx, "queryPodcastEpisodes", map[string]any{
"uri": "spotify:show:" + id,
"offset": 0,
"limit": 25,
}, "show")
if err == nil {
return item, nil
}
web, ferr := c.webClient()
if ferr != nil {
return Item{}, err
}
return web.GetShow(ctx, id)
}
func (c *ConnectClient) episodeInfo(ctx context.Context, id string) (Item, error) {
item, err := c.infoByOperation(ctx, "getEpisodeOrChapter", map[string]any{
"uri": "spotify:episode:" + id,
}, "episode")
if err == nil {
return item, nil
}
web, ferr := c.webClient()
if ferr != nil {
return Item{}, err
}
return web.GetEpisode(ctx, id)
}
func (c *ConnectClient) ArtistTopTracks(ctx context.Context, id string, limit int) ([]Item, error) {
web, err := c.webClient()
if err != nil {
return nil, err
}
return web.ArtistTopTracks(ctx, id, limit)
}
func (c *ConnectClient) infoByOperation(ctx context.Context, operation string, variables map[string]any, kind string) (Item, error) {
payload, err := c.graphQL(ctx, operation, variables)
if err != nil {
return Item{}, err
}
item, ok := extractItemFromPayload(payload, kind)
if !ok {
return Item{}, fmt.Errorf("no %s found", kind)
}
return item, nil
}
func (c *ConnectClient) searchViaWeb(ctx context.Context, kind, query string, limit, offset int) (SearchResult, error) {
return c.searchViaWebAPI(ctx, kind, query, limit, offset)
}
func (c *ConnectClient) searchViaWebAPI(ctx context.Context, kind, query string, limit, offset int) (SearchResult, error) {
auth, err := c.session.auth(ctx)
if err != nil {
return SearchResult{}, err
}
params := url.Values{}
params.Set("q", query)
params.Set("type", kind)
params.Set("limit", fmt.Sprint(limit))
params.Set("offset", fmt.Sprint(offset))
if c.market != "" && params.Get("market") == "" {
params.Set("market", c.market)
}
if c.language != "" && params.Get("locale") == "" {
params.Set("locale", c.language)
}
searchURL := c.searchURL
if searchURL == "" {
searchURL = "https://api.spotify.com/v1/search"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL+"?"+params.Encode(), nil)
if err != nil {
return SearchResult{}, err
}
req.Header.Set("Authorization", "Bearer "+auth.AccessToken)
req.Header.Set("Client-Token", auth.ClientToken)
req.Header.Set("Spotify-App-Version", auth.ClientVersion)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", defaultUserAgent())
req.Header.Set("app-platform", "WebPlayer")
if c.language != "" {
req.Header.Set("Accept-Language", c.language)
}
resp, err := c.client.Do(req)
if err != nil {
return SearchResult{}, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return SearchResult{}, apiErrorFromResponse(resp)
}
var response map[string]searchContainer
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return SearchResult{}, err
}
container, ok := response[kind]
if !ok {
return SearchResult{}, fmt.Errorf("missing %s result", kind)
}
items := make([]Item, 0, len(container.Items))
for _, raw := range container.Items {
item, err := mapSearchItem(kind, raw)
if err != nil {
return SearchResult{}, err
}
items = append(items, item)
}
return SearchResult{
Type: kind,
Limit: container.Limit,
Offset: container.Offset,
Total: container.Total,
Items: items,
}, nil
}
func (c *ConnectClient) webClient() (*Client, error) {
c.webMu.Lock()
defer c.webMu.Unlock()
if c.web != nil {
return c.web, nil
}
provider := CookieTokenProvider{Source: c.source, Client: c.client}
client, err := NewClient(Options{
TokenProvider: provider,
HTTPClient: c.client,
Market: c.market,
Language: c.language,
Device: c.device,
})
if err != nil {
return nil, err
}
c.web = client
return client, nil
}

View File

@ -1,203 +0,0 @@
package spotify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestConnectLibraryV3Helpers(t *testing.T) {
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Query().Get("operationName") {
case "libraryV3":
query := req.URL.Query()
variables := query.Get("variables")
switch {
case strings.Contains(variables, `"Playlists"`):
return jsonResponse(http.StatusOK, map[string]any{
"data": map[string]any{"me": map[string]any{"libraryV3": map[string]any{
"totalCount": 1,
"items": []any{map[string]any{"item": map[string]any{"data": map[string]any{
"uri": "spotify:playlist:p1",
"name": "Playlist",
"ownerV2": map[string]any{"data": map[string]any{"name": "Owner"}},
"totalTracks": 12,
}}}},
}}},
}), nil
case strings.Contains(variables, `"Albums"`):
return jsonResponse(http.StatusOK, map[string]any{
"data": map[string]any{"me": map[string]any{"libraryV3": map[string]any{
"totalCount": 1,
"items": []any{map[string]any{"item": map[string]any{"data": map[string]any{
"uri": "spotify:album:a1",
"name": "Album",
}}}},
}}},
}), nil
}
case "fetchLibraryTracks":
var vars map[string]any
if err := json.Unmarshal([]byte(req.URL.Query().Get("variables")), &vars); err != nil {
t.Fatalf("unmarshal fetchLibraryTracks variables: %v", err)
}
if got := getString(vars, "uri"); got != "spotify:collection:tracks" {
t.Fatalf("fetchLibraryTracks uri = %q", got)
}
if got := getInt(vars, "limit"); got != 10 {
t.Fatalf("fetchLibraryTracks limit = %d", got)
}
if got := getInt(vars, "offset"); got != 0 {
t.Fatalf("fetchLibraryTracks offset = %d", got)
}
return jsonResponse(http.StatusOK, map[string]any{
"data": map[string]any{"me": map[string]any{"library": map[string]any{"tracks": map[string]any{
"totalCount": 1,
"items": []any{map[string]any{"track": map[string]any{
"_uri": "spotify:track:t1",
"data": map[string]any{
"name": "Song",
},
}}},
}}}},
}), nil
case "fetchPlaylist":
return jsonResponse(http.StatusOK, map[string]any{
"data": map[string]any{"playlistV2": map[string]any{"content": map[string]any{
"totalCount": 1,
"items": []any{map[string]any{"itemV2": map[string]any{"data": map[string]any{
"track": map[string]any{"uri": "spotify:track:t1", "name": "Song"},
}}}},
}}},
}), nil
}
return textResponse(http.StatusNotFound, "missing"), nil
})
client := newConnectClientForTests(transport)
for _, op := range []string{"libraryV3", "fetchPlaylist", "fetchLibraryTracks"} {
client.hashes.hashes[op] = "hash"
}
playlists, total, err := client.playlists(context.Background(), 10, 0)
if err != nil || total != 1 || len(playlists) != 1 || playlists[0].ID != "p1" {
t.Fatalf("playlists: items=%#v total=%d err=%v", playlists, total, err)
}
tracks, total, err := client.playlistTracks(context.Background(), "p1", 10, 0)
if err != nil || total != 1 || len(tracks) != 1 || tracks[0].ID != "t1" {
t.Fatalf("playlist tracks: items=%#v total=%d err=%v", tracks, total, err)
}
libraryTracks, total, err := client.libraryTracks(context.Background(), 10, 0)
if err != nil || total != 1 || len(libraryTracks) != 1 || libraryTracks[0].ID != "t1" {
t.Fatalf("library tracks: items=%#v total=%d err=%v", libraryTracks, total, err)
}
albums, total, err := client.libraryAlbums(context.Background(), 10, 0)
if err != nil || total != 1 || len(albums) != 1 || albums[0].ID != "a1" {
t.Fatalf("library albums: items=%#v total=%d err=%v", albums, total, err)
}
}
func TestConnectLibraryTracksFallsBackWhenFetchLibraryTracksPayloadDrifts(t *testing.T) {
webServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/me/tracks":
_ = json.NewEncoder(w).Encode(libraryResponse{
Items: []struct {
Track trackItem `json:"track"`
Album albumItem `json:"album"`
}{
{Track: trackItem{ID: "t1", URI: "spotify:track:t1", Name: "Song"}},
},
Total: 1,
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(webServer.Close)
webClient, err := NewClient(Options{
TokenProvider: staticTokenProvider{},
BaseURL: webServer.URL,
HTTPClient: webServer.Client(),
})
if err != nil {
t.Fatalf("web client: %v", err)
}
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Query().Get("operationName") != "fetchLibraryTracks" {
return textResponse(http.StatusNotFound, "missing"), nil
}
return jsonResponse(http.StatusOK, map[string]any{
"data": map[string]any{"me": map[string]any{"library": map[string]any{}}},
}), nil
})
client := newConnectClientForTests(transport)
client.web = webClient
client.hashes.hashes["fetchLibraryTracks"] = "hash"
items, total, err := client.LibraryTracks(context.Background(), 10, 0)
if err != nil || total != 1 || len(items) != 1 || items[0].ID != "t1" {
t.Fatalf("library tracks fallback: items=%#v total=%d err=%v", items, total, err)
}
}
func TestConnectInfoFallbackToWeb(t *testing.T) {
infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/tracks/t1":
_ = json.NewEncoder(w).Encode(trackItem{ID: "t1", URI: "spotify:track:t1", Name: "Song", Artists: []artistRef{{Name: "Artist"}}})
case "/albums/a1":
_ = json.NewEncoder(w).Encode(albumItem{ID: "a1", URI: "spotify:album:a1", Name: "Album", Artists: []artistRef{{Name: "Artist"}}})
case "/artists/ar1":
_ = json.NewEncoder(w).Encode(artistItem{ID: "ar1", URI: "spotify:artist:ar1", Name: "Artist"})
case "/playlists/p1":
_ = json.NewEncoder(w).Encode(playlistItem{ID: "p1", URI: "spotify:playlist:p1", Name: "Playlist"})
case "/shows/s1":
_ = json.NewEncoder(w).Encode(showItem{ID: "s1", URI: "spotify:show:s1", Name: "Show"})
case "/episodes/e1":
_ = json.NewEncoder(w).Encode(episodeItem{ID: "e1", URI: "spotify:episode:e1", Name: "Episode"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(infoServer.Close)
webClient, err := NewClient(Options{
TokenProvider: staticTokenProvider{},
BaseURL: infoServer.URL,
HTTPClient: infoServer.Client(),
})
if err != nil {
t.Fatalf("web client: %v", err)
}
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return textResponse(http.StatusInternalServerError, "fail"), nil
})
client := newConnectClientForTests(transport)
client.web = webClient
client.hashes = &hashResolver{client: &http.Client{Transport: transport}, session: client.session, hashes: map[string]string{}}
if item, err := client.GetTrack(context.Background(), "t1"); err != nil || item.ID != "t1" {
t.Fatalf("track: %#v err=%v", item, err)
}
if item, err := client.GetAlbum(context.Background(), "a1"); err != nil || item.ID != "a1" {
t.Fatalf("album: %#v err=%v", item, err)
}
if item, err := client.GetArtist(context.Background(), "ar1"); err != nil || item.ID != "ar1" {
t.Fatalf("artist: %#v err=%v", item, err)
}
if item, err := client.GetPlaylist(context.Background(), "p1"); err != nil || item.ID != "p1" {
t.Fatalf("playlist: %#v err=%v", item, err)
}
if item, err := client.GetShow(context.Background(), "s1"); err != nil || item.ID != "s1" {
t.Fatalf("show: %#v err=%v", item, err)
}
if item, err := client.GetEpisode(context.Background(), "e1"); err != nil || item.ID != "e1" {
t.Fatalf("episode: %#v err=%v", item, err)
}
}

View File

@ -2,10 +2,27 @@ package spotify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func newConnectClientForTests(transport http.RoundTripper) *ConnectClient {
client := &http.Client{Transport: transport}
session := &connectSession{
client: client,
token: Token{AccessToken: "access", ExpiresAt: time.Now().Add(time.Hour), ClientID: "client"},
clientToken: "client-token",
clientTokenT: time.Now().Add(time.Hour),
clientVer: "1.0.0",
deviceID: "device",
}
hashes := &hashResolver{client: client, session: session, hashes: map[string]string{}}
return &ConnectClient{client: client, session: session, hashes: hashes}
}
func TestPathfinderSearch(t *testing.T) {
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
payload := map[string]any{
@ -116,12 +133,6 @@ func TestGraphQLHTTPError(t *testing.T) {
}
}
func TestGraphQLRequiresInitializedClient(t *testing.T) {
if _, err := (&ConnectClient{}).graphQL(context.Background(), "searchDesktop", map[string]any{}); err == nil {
t.Fatalf("expected error")
}
}
func TestPathfinderFallbackToWeb(t *testing.T) {
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "api-partner.spotify.com" {
@ -195,3 +206,60 @@ func TestSearchViaWebAPIMissingKind(t *testing.T) {
t.Fatalf("expected error")
}
}
func TestConnectInfoFallbackToWeb(t *testing.T) {
infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/tracks/t1":
_ = json.NewEncoder(w).Encode(trackItem{ID: "t1", URI: "spotify:track:t1", Name: "Song", Artists: []artistRef{{Name: "Artist"}}})
case "/albums/a1":
_ = json.NewEncoder(w).Encode(albumItem{ID: "a1", URI: "spotify:album:a1", Name: "Album", Artists: []artistRef{{Name: "Artist"}}})
case "/artists/ar1":
_ = json.NewEncoder(w).Encode(artistItem{ID: "ar1", URI: "spotify:artist:ar1", Name: "Artist"})
case "/playlists/p1":
_ = json.NewEncoder(w).Encode(playlistItem{ID: "p1", URI: "spotify:playlist:p1", Name: "Playlist"})
case "/shows/s1":
_ = json.NewEncoder(w).Encode(showItem{ID: "s1", URI: "spotify:show:s1", Name: "Show"})
case "/episodes/e1":
_ = json.NewEncoder(w).Encode(episodeItem{ID: "e1", URI: "spotify:episode:e1", Name: "Episode"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(infoServer.Close)
webClient, err := NewClient(Options{
TokenProvider: staticTokenProvider{},
BaseURL: infoServer.URL,
HTTPClient: infoServer.Client(),
})
if err != nil {
t.Fatalf("web client: %v", err)
}
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return textResponse(http.StatusInternalServerError, "fail"), nil
})
client := newConnectClientForTests(transport)
client.web = webClient
client.hashes = &hashResolver{client: &http.Client{Transport: transport}, session: client.session, hashes: map[string]string{}}
if item, err := client.GetTrack(context.Background(), "t1"); err != nil || item.ID != "t1" {
t.Fatalf("track: %#v err=%v", item, err)
}
if item, err := client.GetAlbum(context.Background(), "a1"); err != nil || item.ID != "a1" {
t.Fatalf("album: %#v err=%v", item, err)
}
if item, err := client.GetArtist(context.Background(), "ar1"); err != nil || item.ID != "ar1" {
t.Fatalf("artist: %#v err=%v", item, err)
}
if item, err := client.GetPlaylist(context.Background(), "p1"); err != nil || item.ID != "p1" {
t.Fatalf("playlist: %#v err=%v", item, err)
}
if item, err := client.GetShow(context.Background(), "s1"); err != nil || item.ID != "s1" {
t.Fatalf("show: %#v err=%v", item, err)
}
if item, err := client.GetEpisode(context.Background(), "e1"); err != nil || item.ID != "e1" {
t.Fatalf("episode: %#v err=%v", item, err)
}
}

View File

@ -23,6 +23,206 @@ const (
var dealerURL = "wss://dealer.spotify.com/"
func (c *ConnectClient) playback(ctx context.Context) (PlaybackStatus, error) {
state, err := c.connectState(ctx)
if err != nil {
return PlaybackStatus{}, err
}
return mapPlaybackStatus(state), nil
}
func (c *ConnectClient) devices(ctx context.Context) ([]Device, error) {
state, err := c.connectState(ctx)
if err != nil {
return nil, err
}
return mapDevices(state), nil
}
func (c *ConnectClient) transfer(ctx context.Context, deviceID string) error {
state, err := c.connectState(ctx)
if err != nil {
return err
}
fromID := state.originDeviceID
if fromID == "" {
fromID = state.activeDeviceID
}
if fromID == "" {
return errors.New("missing origin device id")
}
return c.sendConnectCommand(ctx, fmt.Sprintf("%s/connect/transfer/from/%s/to/%s", connectStateBase, fromID, deviceID), map[string]any{
"transfer_options": map[string]any{
"restore_paused": "resume",
},
"command_id": randomHex(32),
})
}
func (c *ConnectClient) play(ctx context.Context, uri string) error {
state, err := c.connectState(ctx)
if err != nil {
return err
}
if uri == "" {
return c.sendPlayerCommand(ctx, state, "resume", nil)
}
payload := map[string]any{
"command": map[string]any{
"endpoint": "play",
"logging_params": map[string]any{
"command_id": randomHex(32),
},
"options": map[string]any{
"skip_to": map[string]any{
"track_uri": uri,
},
},
},
}
return c.sendPlayerCommand(ctx, state, "play", payload)
}
func (c *ConnectClient) pause(ctx context.Context) error {
state, err := c.connectState(ctx)
if err != nil {
return err
}
return c.sendPlayerCommand(ctx, state, "pause", nil)
}
func (c *ConnectClient) next(ctx context.Context) error {
state, err := c.connectState(ctx)
if err != nil {
return err
}
return c.sendPlayerCommand(ctx, state, "skip_next", nil)
}
func (c *ConnectClient) previous(ctx context.Context) error {
state, err := c.connectState(ctx)
if err != nil {
return err
}
return c.sendPlayerCommand(ctx, state, "skip_prev", nil)
}
func (c *ConnectClient) seek(ctx context.Context, positionMS int) error {
if positionMS < 0 {
positionMS = 0
}
state, err := c.connectState(ctx)
if err != nil {
return err
}
payload := map[string]any{
"command": map[string]any{
"endpoint": "seek_to",
"value": positionMS,
"logging_params": map[string]any{
"command_id": randomHex(32),
},
},
}
return c.sendPlayerCommand(ctx, state, "seek_to", payload)
}
func (c *ConnectClient) volume(ctx context.Context, volume int) error {
if volume < 0 {
volume = 0
}
if volume > 100 {
volume = 100
}
state, err := c.connectState(ctx)
if err != nil {
return err
}
fromID := state.originDeviceID
if fromID == "" {
fromID = state.activeDeviceID
}
if fromID == "" || state.activeDeviceID == "" {
return errors.New("missing device id")
}
value := int(float64(volume) / 100 * 65535)
return c.sendConnectCommand(ctx, fmt.Sprintf("%s/connect/volume/from/%s/to/%s", connectStateBase, fromID, state.activeDeviceID), map[string]any{
"volume": value,
})
}
func (c *ConnectClient) shuffle(ctx context.Context, enabled bool) error {
state, err := c.connectState(ctx)
if err != nil {
return err
}
payload := map[string]any{
"command": map[string]any{
"endpoint": "set_shuffling_context",
"value": enabled,
"logging_params": map[string]any{
"command_id": randomHex(32),
},
},
}
return c.sendPlayerCommand(ctx, state, "set_shuffling_context", payload)
}
func (c *ConnectClient) repeat(ctx context.Context, mode string) error {
state, err := c.connectState(ctx)
if err != nil {
return err
}
mode = strings.ToLower(mode)
payload := map[string]any{
"command": map[string]any{
"endpoint": "set_options",
"logging_params": map[string]any{
"command_id": randomHex(32),
},
},
}
switch mode {
case "track":
payload["command"].(map[string]any)["repeating_track"] = true
payload["command"].(map[string]any)["repeating_context"] = false
case "context":
payload["command"].(map[string]any)["repeating_track"] = false
payload["command"].(map[string]any)["repeating_context"] = true
default:
payload["command"].(map[string]any)["repeating_track"] = false
payload["command"].(map[string]any)["repeating_context"] = false
}
return c.sendPlayerCommand(ctx, state, "set_options", payload)
}
func (c *ConnectClient) queueAdd(ctx context.Context, uri string) error {
state, err := c.connectState(ctx)
if err != nil {
return err
}
payload := map[string]any{
"command": map[string]any{
"endpoint": "add_to_queue",
"track": map[string]any{
"uri": uri,
},
"logging_params": map[string]any{
"command_id": randomHex(32),
},
},
}
return c.sendPlayerCommand(ctx, state, "add_to_queue", payload)
}
func (c *ConnectClient) queue(ctx context.Context) (Queue, error) {
state, err := c.connectState(ctx)
if err != nil {
return Queue{}, err
}
return mapQueue(state), nil
}
type connectState struct {
raw map[string]any
playerState map[string]any
@ -59,14 +259,15 @@ func (c *ConnectClient) connectState(ctx context.Context) (connectState, error)
if err != nil {
return connectState{}, err
}
applyRequestHeaders(req, requestHeaders{
AccessToken: auth.AccessToken,
ClientToken: auth.ClientToken,
ClientVersion: connectVersion(auth),
ContentType: "application/json",
AppPlatform: defaultSpotifyAppPlatform,
ConnectionID: connectionID,
})
req.Header.Set("Authorization", "Bearer "+auth.AccessToken)
req.Header.Set("Client-Token", auth.ClientToken)
req.Header.Set("Spotify-App-Version", connectVersion(auth))
req.Header.Set("User-Agent", defaultUserAgent())
req.Header.Set("Content-Type", "application/json")
req.Header.Set("app-platform", "WebPlayer")
if connectionID != "" {
req.Header.Set("x-spotify-connection-id", connectionID)
}
resp, err := c.client.Do(req)
if err != nil {
return connectState{}, err
@ -161,13 +362,12 @@ func (c *ConnectClient) registerDevice(ctx context.Context, auth connectAuth, co
if err != nil {
return err
}
applyRequestHeaders(req, requestHeaders{
AccessToken: auth.AccessToken,
ClientToken: auth.ClientToken,
ClientVersion: connectVersion(auth),
ContentType: "application/json",
AppPlatform: defaultSpotifyAppPlatform,
})
req.Header.Set("Authorization", "Bearer "+auth.AccessToken)
req.Header.Set("Client-Token", auth.ClientToken)
req.Header.Set("Spotify-App-Version", connectVersion(auth))
req.Header.Set("User-Agent", defaultUserAgent())
req.Header.Set("Content-Type", "application/json")
req.Header.Set("app-platform", "WebPlayer")
resp, err := c.client.Do(req)
if err != nil {
return err
@ -207,25 +407,20 @@ func (c *ConnectClient) sendPlayerCommand(ctx context.Context, state connectStat
}
func (c *ConnectClient) sendConnectCommand(ctx context.Context, url string, payload map[string]any) error {
return c.sendConnectRequest(ctx, http.MethodPost, url, payload)
}
func (c *ConnectClient) sendConnectRequest(ctx context.Context, method, url string, payload map[string]any) error {
auth, err := c.session.auth(ctx)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, url, encodeJSON(payload))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, encodeJSON(payload))
if err != nil {
return err
}
applyRequestHeaders(req, requestHeaders{
AccessToken: auth.AccessToken,
ClientToken: auth.ClientToken,
ClientVersion: connectVersion(auth),
ContentType: "application/json",
AppPlatform: defaultSpotifyAppPlatform,
})
req.Header.Set("Authorization", "Bearer "+auth.AccessToken)
req.Header.Set("Client-Token", auth.ClientToken)
req.Header.Set("Spotify-App-Version", connectVersion(auth))
req.Header.Set("User-Agent", defaultUserAgent())
req.Header.Set("Content-Type", "application/json")
req.Header.Set("app-platform", "WebPlayer")
resp, err := c.client.Do(req)
if err != nil {
return err

View File

@ -2,12 +2,14 @@ package spotify
import (
"context"
"errors"
"io"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
)
func TestConnectPlaybackCommands(t *testing.T) {
@ -33,11 +35,6 @@ func TestConnectPlaybackCommands(t *testing.T) {
switch {
case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/devices/hobs_"):
return jsonResponse(http.StatusOK, statePayload), nil
case strings.Contains(req.URL.Path, "/connect/volume/"):
if req.Method != http.MethodPut {
return textResponse(http.StatusMethodNotAllowed, "method not allowed"), nil
}
return textResponse(http.StatusOK, "ok"), nil
case req.Method == http.MethodPost:
return textResponse(http.StatusOK, "ok"), nil
default:
@ -61,12 +58,6 @@ func TestConnectPlaybackCommands(t *testing.T) {
if err := client.Play(context.Background(), "spotify:track:abc"); err != nil {
t.Fatalf("play uri: %v", err)
}
if err := client.Play(context.Background(), "spotify:playlist:abc"); err != nil {
t.Fatalf("play playlist: %v", err)
}
if err := client.Play(context.Background(), "spotify:album:xyz"); err != nil {
t.Fatalf("play album: %v", err)
}
if err := client.Pause(context.Background()); err != nil {
t.Fatalf("pause: %v", err)
}
@ -108,48 +99,6 @@ func TestConnectPlaybackCommands(t *testing.T) {
}
}
func TestConnectPlaybackHydratesSparseTrack(t *testing.T) {
statePayload := map[string]any{
"devices": map[string]any{
"device-1": map[string]any{"name": "Desk", "device_type": "computer"},
},
"player_state": map[string]any{
"is_paused": true,
"track": map[string]any{
"uri": "spotify:track:t1",
"name": "Song",
},
},
"active_device_id": "device-1",
}
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/devices/hobs_"):
return jsonResponse(http.StatusOK, statePayload), nil
case req.URL.Query().Get("operationName") == "getTrack":
return jsonResponse(http.StatusOK, map[string]any{
"data": map[string]any{"track": map[string]any{
"uri": "spotify:track:t1",
"name": "Song",
"artists": []any{map[string]any{"name": "Artist"}},
"album": map[string]any{"name": "Album"},
}},
}), nil
default:
return textResponse(http.StatusNotFound, "missing"), nil
}
})
client := newRegisteredConnectClientForTests(transport)
client.hashes.hashes["getTrack"] = "hash"
status, err := client.Playback(context.Background())
if err != nil {
t.Fatalf("playback: %v", err)
}
if status.Item == nil || len(status.Item.Artists) != 1 || status.Item.Artists[0] != "Artist" || status.Item.Album != "Album" {
t.Fatalf("expected hydrated item: %#v", status.Item)
}
}
func TestConnectPlaybackActiveDeviceFromDevices(t *testing.T) {
statePayload := map[string]any{
"devices": map[string]any{
@ -190,134 +139,6 @@ func TestConnectPlaybackActiveDeviceFromDevices(t *testing.T) {
}
}
func TestConnectTransferFallsBackToWebAPIWithoutOriginDevice(t *testing.T) {
statePayload := map[string]any{
"devices": map[string]any{
"device-1": map[string]any{
"name": "Desk",
"device_type": "computer",
},
},
"player_state": map[string]any{
"is_paused": true,
},
}
var sawWebTransfer bool
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/devices/hobs_"):
return jsonResponse(http.StatusOK, statePayload), nil
case req.Method == http.MethodPut && req.URL.Path == "/v1/me/player":
sawWebTransfer = true
return textResponse(http.StatusNoContent, ""), nil
case req.Method == http.MethodPost:
t.Fatalf("unexpected connect command: %s", req.URL.Path)
return textResponse(http.StatusInternalServerError, "unexpected connect command"), nil
default:
return textResponse(http.StatusNotFound, "missing"), nil
}
})
client := newRegisteredConnectClientForTests(transport)
webClient, err := NewClient(Options{
TokenProvider: staticTokenProvider{},
HTTPClient: client.client,
})
if err != nil {
t.Fatalf("new web client: %v", err)
}
client.web = webClient
if err := client.Transfer(context.Background(), "device-1"); err != nil {
t.Fatalf("transfer: %v", err)
}
if !sawWebTransfer {
t.Fatalf("expected web transfer fallback")
}
}
func TestConnectPlayFallsBackToWebAPIWithoutActiveDevice(t *testing.T) {
statePayload := map[string]any{
"devices": map[string]any{
"device-1": map[string]any{
"name": "Desk",
"device_type": "computer",
},
},
"player_state": map[string]any{
"is_paused": true,
},
}
var sawWebPlay bool
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/devices/hobs_"):
return jsonResponse(http.StatusOK, statePayload), nil
case req.Method == http.MethodPut && req.URL.Path == "/v1/me/player/play":
sawWebPlay = true
return textResponse(http.StatusNoContent, ""), nil
case req.Method == http.MethodPost:
t.Fatalf("unexpected connect command: %s", req.URL.Path)
return nil, errors.New("unexpected connect command")
default:
return textResponse(http.StatusNotFound, "missing"), nil
}
})
client := newRegisteredConnectClientForTests(transport)
webClient, err := NewClient(Options{
TokenProvider: staticTokenProvider{},
HTTPClient: client.client,
})
if err != nil {
t.Fatalf("new web client: %v", err)
}
client.web = webClient
if err := client.Play(context.Background(), "spotify:track:abc"); err != nil {
t.Fatalf("play: %v", err)
}
if !sawWebPlay {
t.Fatalf("expected web play fallback")
}
}
func TestConnectPlayUsesConfiguredDeviceWithoutActiveDevice(t *testing.T) {
statePayload := map[string]any{
"devices": map[string]any{
"device-1": map[string]any{
"name": "Desk",
"device_type": "computer",
},
},
"player_state": map[string]any{
"is_paused": true,
},
}
var sawConnectPlay bool
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/devices/hobs_"):
return jsonResponse(http.StatusOK, statePayload), nil
case req.Method == http.MethodPost && strings.Contains(req.URL.Path, "/player/command/from/device/to/device-1"):
sawConnectPlay = true
return textResponse(http.StatusOK, "ok"), nil
case req.Method == http.MethodPut && req.URL.Path == "/v1/me/player/play":
t.Fatalf("unexpected web play fallback")
return nil, errors.New("unexpected web play fallback")
default:
return textResponse(http.StatusNotFound, "missing"), nil
}
})
client := newRegisteredConnectClientForTests(transport)
client.device = "Desk"
if err := client.Play(context.Background(), "spotify:track:abc"); err != nil {
t.Fatalf("play: %v", err)
}
if !sawConnectPlay {
t.Fatalf("expected connect play")
}
}
func TestSendPlayerCommandMissingDevice(t *testing.T) {
client := newConnectClientForTests(roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return textResponse(http.StatusOK, ""), nil
@ -348,6 +169,171 @@ func TestRandomHexAndOrigin(t *testing.T) {
}
}
func TestGetConnectionID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("accept: %v", err)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
payload := map[string]any{
"headers": map[string]any{
"Spotify-Connection-Id": "conn-id",
},
}
data, _ := json.Marshal(payload)
if err := conn.Write(r.Context(), websocket.MessageText, data); err != nil {
t.Fatalf("write: %v", err)
}
}))
defer srv.Close()
prev := dealerURL
dealerURL = "ws" + strings.TrimPrefix(srv.URL, "http")
t.Cleanup(func() { dealerURL = prev })
id, err := getConnectionID(context.Background(), "token")
if err != nil {
t.Fatalf("getConnectionID: %v", err)
}
if id != "conn-id" {
t.Fatalf("unexpected id: %s", id)
}
}
func TestEnsureConnectDeviceRegisters(t *testing.T) {
wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("accept: %v", err)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
payload := map[string]any{
"headers": map[string]any{
"Spotify-Connection-Id": "conn-xyz",
},
}
data, _ := json.Marshal(payload)
if err := conn.Write(r.Context(), websocket.MessageText, data); err != nil {
t.Fatalf("write: %v", err)
}
}))
defer wsServer.Close()
prev := dealerURL
dealerURL = "ws" + strings.TrimPrefix(wsServer.URL, "http")
t.Cleanup(func() { dealerURL = prev })
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
if req.Method == http.MethodPost {
return textResponse(http.StatusOK, "ok"), nil
}
return textResponse(http.StatusOK, "ok"), nil
})
client := newConnectClientForTests(transport)
client.session.connectDeviceID = "device"
client.session.connectionID = ""
client.session.registeredAt = time.Time{}
auth := connectAuth{
AccessToken: "access",
ClientToken: "client-token",
ClientVersion: "1.0.0",
}
if err := client.ensureConnectDevice(context.Background(), auth); err != nil {
t.Fatalf("ensure: %v", err)
}
if client.session.connectionID == "" {
t.Fatalf("expected connection id")
}
}
func TestGetConnectionIDMissingHeader(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("accept: %v", err)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
payload := map[string]any{
"headers": map[string]any{
"Other": "nope",
},
}
data, _ := json.Marshal(payload)
if err := conn.Write(r.Context(), websocket.MessageText, data); err != nil {
t.Fatalf("write: %v", err)
}
}))
defer srv.Close()
prev := dealerURL
dealerURL = "ws" + strings.TrimPrefix(srv.URL, "http")
t.Cleanup(func() { dealerURL = prev })
if _, err := getConnectionID(context.Background(), "token"); err == nil {
t.Fatalf("expected error")
}
}
func TestGetConnectionIDBadHeadersType(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("accept: %v", err)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
payload := map[string]any{
"headers": "bad",
}
data, _ := json.Marshal(payload)
if err := conn.Write(r.Context(), websocket.MessageText, data); err != nil {
t.Fatalf("write: %v", err)
}
}))
defer srv.Close()
prev := dealerURL
dealerURL = "ws" + strings.TrimPrefix(srv.URL, "http")
t.Cleanup(func() { dealerURL = prev })
if _, err := getConnectionID(context.Background(), "token"); err == nil {
t.Fatalf("expected error")
}
}
func TestGetConnectionIDDialError(t *testing.T) {
prev := dealerURL
dealerURL = "ws://127.0.0.1:1"
t.Cleanup(func() { dealerURL = prev })
if _, err := getConnectionID(context.Background(), "token"); err == nil {
t.Fatalf("expected error")
}
}
func TestGetConnectionIDBadJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("accept: %v", err)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
if err := conn.Write(r.Context(), websocket.MessageText, []byte("nope")); err != nil {
t.Fatalf("write: %v", err)
}
}))
defer srv.Close()
prev := dealerURL
dealerURL = "ws" + strings.TrimPrefix(srv.URL, "http")
t.Cleanup(func() { dealerURL = prev })
if _, err := getConnectionID(context.Background(), "token"); err == nil {
t.Fatalf("expected error")
}
}
func TestConnectPlaybackErrorPaths(t *testing.T) {
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return textResponse(http.StatusInternalServerError, "fail"), nil
@ -386,64 +372,6 @@ func TestConnectPlaybackErrorPaths(t *testing.T) {
}
}
func TestConnectPlayContextURIPayload(t *testing.T) {
statePayload := map[string]any{
"devices": map[string]any{
"device-1": map[string]any{"name": "Desk", "device_type": "computer"},
},
"player_state": map[string]any{
"is_paused": false,
"position_ms": 0,
},
"active_device_id": "device-1",
}
var capturedBody string
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/devices/hobs_"):
return jsonResponse(http.StatusOK, statePayload), nil
case req.Method == http.MethodPost:
b, _ := io.ReadAll(req.Body)
capturedBody = string(b)
return textResponse(http.StatusOK, "ok"), nil
default:
return textResponse(http.StatusNotFound, "missing"), nil
}
})
newClient := func() *ConnectClient {
c := newConnectClientForTests(transport)
c.session.connectDeviceID = "device"
c.session.connectionID = "conn"
c.session.registeredAt = time.Now()
return c
}
// Context URI (playlist) — must use "context" field, not "track_uri"
capturedBody = ""
if err := newClient().Play(context.Background(), "spotify:playlist:pl1"); err != nil {
t.Fatalf("play playlist: %v", err)
}
if !strings.Contains(capturedBody, `"context"`) {
t.Errorf("playlist play: expected context field in body, got: %s", capturedBody)
}
if strings.Contains(capturedBody, `"track_uri"`) {
t.Errorf("playlist play: unexpected track_uri in body: %s", capturedBody)
}
// Track URI — must use "track_uri" and also include "context" (track as its own context)
capturedBody = ""
if err := newClient().Play(context.Background(), "spotify:track:t1"); err != nil {
t.Fatalf("play track: %v", err)
}
if !strings.Contains(capturedBody, `"track_uri"`) {
t.Errorf("track play: expected track_uri field in body, got: %s", capturedBody)
}
if !strings.Contains(capturedBody, `"context"`) {
t.Errorf("track play: expected context field in body, got: %s", capturedBody)
}
}
func TestSendConnectCommandHTTPError(t *testing.T) {
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return textResponse(http.StatusInternalServerError, "fail"), nil

View File

@ -1,131 +0,0 @@
package spotify
import (
"context"
"errors"
"fmt"
)
var errPlaylistNotWritable = errors.New("playlist is not writable")
func (c *ConnectClient) addTracks(ctx context.Context, playlistID string, uris []string) error {
if err := c.ensurePlaylistWritable(ctx, playlistID); err != nil {
return err
}
_, err := c.graphQL(ctx, "addToPlaylist", map[string]any{
"playlistUri": "spotify:playlist:" + playlistID,
"playlistItemUris": uris,
"newPosition": map[string]any{
"moveType": "TOP_OF_PLAYLIST",
"fromUid": nil,
},
})
return err
}
func (c *ConnectClient) removeTracks(ctx context.Context, playlistID string, uris []string) error {
if err := c.ensurePlaylistWritable(ctx, playlistID); err != nil {
return err
}
uids, err := c.playlistTrackUIDs(ctx, playlistID, uris)
if err != nil {
return err
}
_, err = c.graphQL(ctx, "removeFromPlaylist", map[string]any{
"playlistUri": "spotify:playlist:" + playlistID,
"uids": uids,
})
return err
}
func (c *ConnectClient) playlistTrackUIDs(ctx context.Context, playlistID string, uris []string) ([]string, error) {
if len(uris) == 0 {
return nil, fmt.Errorf("track uri required")
}
need := map[string]int{}
for _, uri := range uris {
need[uri]++
}
uids := make([]string, 0, len(uris))
offset := 0
const limit = 100
for len(uids) < len(uris) {
payload, err := c.graphQL(ctx, "fetchPlaylist", playlistTrackVariables(playlistID, limit, offset))
if err != nil {
return nil, err
}
found, total := extractPlaylistTrackUIDs(payload, need)
uids = append(uids, found...)
if total <= 0 || offset+limit >= total {
break
}
offset += limit
}
if len(uids) != len(uris) {
return nil, fmt.Errorf("playlist items not found for removal")
}
return uids, nil
}
func extractPlaylistTrackUIDs(payload map[string]any, need map[string]int) ([]string, int) {
content, ok := getMap(payload, "data", "playlistV2", "content")
if !ok {
return nil, 0
}
rawItems, _ := content["items"].([]any)
uids := make([]string, 0)
for _, raw := range rawItems {
m, ok := raw.(map[string]any)
if !ok {
continue
}
wrapper, ok := m["itemV2"].(map[string]any)
if !ok {
continue
}
uid := getString(wrapper, "uid")
if uid == "" {
uid = getString(m, "uid")
}
dataM, _ := wrapper["data"].(map[string]any)
uri := playlistTrackURI(dataM)
if uid == "" || uri == "" || need[uri] <= 0 {
continue
}
need[uri]--
uids = append(uids, uid)
}
return uids, getInt(content, "totalCount")
}
func playlistTrackURI(data map[string]any) string {
if data == nil {
return ""
}
if uri := getString(data, "uri"); uri != "" {
return uri
}
if track, ok := data["track"].(map[string]any); ok {
if uri := getString(track, "uri"); uri != "" {
return uri
}
}
return findFirstURI(data, "track")
}
func (c *ConnectClient) ensurePlaylistWritable(ctx context.Context, playlistID string) error {
payload, err := c.graphQL(ctx, "playlistPermissions", map[string]any{
"uri": "spotify:playlist:" + playlistID,
})
if err != nil {
return err
}
caps, ok := getMap(payload, "data", "playlistV2", "currentUserCapabilities")
if !ok {
return fmt.Errorf("playlist permissions missing")
}
if !getBool(caps, "canEditItems") {
return fmt.Errorf("%w: %s", errPlaylistNotWritable, playlistID)
}
return nil
}

View File

@ -1,265 +0,0 @@
package spotify
import (
"context"
"encoding/json"
"errors"
"net/http"
"testing"
)
func TestConnectAddTracksUsesPathfinderMutation(t *testing.T) {
var variables map[string]any
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Query().Get("operationName") {
case "playlistPermissions":
return jsonResponse(http.StatusOK, playlistWritablePayload(true)), nil
case "addToPlaylist":
if err := json.Unmarshal([]byte(req.URL.Query().Get("variables")), &variables); err != nil {
t.Fatalf("variables: %v", err)
}
return jsonResponse(http.StatusOK, map[string]any{"data": map[string]any{"addToPlaylist": true}}), nil
default:
return textResponse(http.StatusNotFound, "missing"), nil
}
})
client := newConnectClientForTests(transport)
client.hashes.hashes["playlistPermissions"] = "hash"
client.hashes.hashes["addToPlaylist"] = "hash"
err := client.AddTracks(context.Background(), "p1", []string{"spotify:track:t1"})
if err != nil {
t.Fatalf("add tracks: %v", err)
}
if got := getString(variables, "playlistUri"); got != "spotify:playlist:p1" {
t.Fatalf("playlistUri = %q", got)
}
uris, _ := variables["playlistItemUris"].([]any)
if len(uris) != 1 || uris[0] != "spotify:track:t1" {
t.Fatalf("playlistItemUris = %#v", variables["playlistItemUris"])
}
position, _ := variables["newPosition"].(map[string]any)
if got := getString(position, "moveType"); got != "TOP_OF_PLAYLIST" {
t.Fatalf("moveType = %q", got)
}
}
func TestConnectRemoveTracksUsesResolvedPlaylistUIDs(t *testing.T) {
operations := []string{}
var removeVariables map[string]any
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
op := req.URL.Query().Get("operationName")
operations = append(operations, op)
switch op {
case "playlistPermissions":
return jsonResponse(http.StatusOK, playlistWritablePayload(true)), nil
case "fetchPlaylist":
return jsonResponse(http.StatusOK, map[string]any{
"data": map[string]any{"playlistV2": map[string]any{"content": map[string]any{
"totalCount": 2,
"items": []any{
map[string]any{"itemV2": map[string]any{
"uid": "uid-1",
"data": map[string]any{"track": map[string]any{"uri": "spotify:track:t1"}},
}},
map[string]any{"itemV2": map[string]any{
"uid": "uid-2",
"data": map[string]any{"track": map[string]any{"uri": "spotify:track:t2"}},
}},
},
}}},
}), nil
case "removeFromPlaylist":
if err := json.Unmarshal([]byte(req.URL.Query().Get("variables")), &removeVariables); err != nil {
t.Fatalf("variables: %v", err)
}
return jsonResponse(http.StatusOK, map[string]any{"data": map[string]any{"removeFromPlaylist": true}}), nil
default:
return textResponse(http.StatusNotFound, "missing"), nil
}
})
client := newConnectClientForTests(transport)
client.hashes.hashes["playlistPermissions"] = "hash"
client.hashes.hashes["fetchPlaylist"] = "hash"
client.hashes.hashes["removeFromPlaylist"] = "hash"
err := client.RemoveTracks(context.Background(), "p1", []string{"spotify:track:t2"})
if err != nil {
t.Fatalf("remove tracks: %v", err)
}
if len(operations) != 3 || operations[0] != "playlistPermissions" || operations[1] != "fetchPlaylist" || operations[2] != "removeFromPlaylist" {
t.Fatalf("operations = %#v", operations)
}
if got := getString(removeVariables, "playlistUri"); got != "spotify:playlist:p1" {
t.Fatalf("playlistUri = %q", got)
}
uids, _ := removeVariables["uids"].([]any)
if len(uids) != 1 || uids[0] != "uid-2" {
t.Fatalf("uids = %#v", removeVariables["uids"])
}
}
func TestConnectRemoveTracksFindsUIDOnLaterPlaylistPage(t *testing.T) {
fetches := 0
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Query().Get("operationName") {
case "playlistPermissions":
return jsonResponse(http.StatusOK, playlistWritablePayload(true)), nil
case "fetchPlaylist":
fetches++
var vars map[string]any
if err := json.Unmarshal([]byte(req.URL.Query().Get("variables")), &vars); err != nil {
t.Fatalf("variables: %v", err)
}
items := []any{map[string]any{"itemV2": map[string]any{
"uid": "uid-other",
"data": map[string]any{"track": map[string]any{"uri": "spotify:track:other"}},
}}}
if getInt(vars, "offset") == 100 {
items = []any{map[string]any{"itemV2": map[string]any{
"uid": "uid-target",
"data": map[string]any{"track": map[string]any{"uri": "spotify:track:target"}},
}}}
}
return jsonResponse(http.StatusOK, map[string]any{
"data": map[string]any{"playlistV2": map[string]any{"content": map[string]any{
"totalCount": 150,
"items": items,
}}},
}), nil
case "removeFromPlaylist":
return jsonResponse(http.StatusOK, map[string]any{"data": map[string]any{"removeFromPlaylist": true}}), nil
default:
return textResponse(http.StatusNotFound, "missing"), nil
}
})
client := newConnectClientForTests(transport)
client.hashes.hashes["playlistPermissions"] = "hash"
client.hashes.hashes["fetchPlaylist"] = "hash"
client.hashes.hashes["removeFromPlaylist"] = "hash"
err := client.RemoveTracks(context.Background(), "p1", []string{"spotify:track:target"})
if err != nil {
t.Fatalf("remove tracks: %v", err)
}
if fetches != 2 {
t.Fatalf("fetches = %d", fetches)
}
}
func TestConnectAddTracksFallsBackToWeb(t *testing.T) {
webCalled := false
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return textResponse(http.StatusInternalServerError, "fail"), nil
})
client := newConnectClientForTests(transport)
client.hashes.hashes["playlistPermissions"] = "hash"
client.hashes.hashes["addToPlaylist"] = "hash"
client.web = mustNewWebClientForPlaylistMutationTest(t, func(w http.ResponseWriter, r *http.Request) {
webCalled = true
w.WriteHeader(http.StatusNoContent)
})
err := client.AddTracks(context.Background(), "p1", []string{"spotify:track:t1"})
if err != nil {
t.Fatalf("add tracks fallback: %v", err)
}
if !webCalled {
t.Fatalf("expected web fallback")
}
}
func TestConnectAddTracksRejectsNonWritablePlaylist(t *testing.T) {
webCalled := false
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Query().Get("operationName") != "playlistPermissions" {
return textResponse(http.StatusNotFound, "missing"), nil
}
return jsonResponse(http.StatusOK, playlistWritablePayload(false)), nil
})
client := newConnectClientForTests(transport)
client.hashes.hashes["playlistPermissions"] = "hash"
client.hashes.hashes["addToPlaylist"] = "hash"
client.web = mustNewWebClientForPlaylistMutationTest(t, func(w http.ResponseWriter, r *http.Request) {
webCalled = true
w.WriteHeader(http.StatusNoContent)
})
err := client.AddTracks(context.Background(), "p1", []string{"spotify:track:t1"})
if !errors.Is(err, errPlaylistNotWritable) {
t.Fatalf("expected not writable error, got %v", err)
}
if webCalled {
t.Fatalf("did not expect web fallback")
}
}
func TestPlaylistTrackUIDExtractionVariants(t *testing.T) {
need := map[string]int{
"spotify:track:direct": 1,
"spotify:track:nested": 1,
"spotify:track:deep": 1,
}
uids, total := extractPlaylistTrackUIDs(map[string]any{
"data": map[string]any{"playlistV2": map[string]any{"content": map[string]any{
"totalCount": 3,
"items": []any{
map[string]any{"uid": "uid-direct", "itemV2": map[string]any{
"data": map[string]any{"uri": "spotify:track:direct"},
}},
map[string]any{"itemV2": map[string]any{
"uid": "uid-nested",
"data": map[string]any{"track": map[string]any{"uri": "spotify:track:nested"}},
}},
map[string]any{"itemV2": map[string]any{
"uid": "uid-deep",
"data": map[string]any{"wrapper": map[string]any{"uri": "spotify:track:deep"}},
}},
},
}}},
}, need)
if total != 3 {
t.Fatalf("total = %d", total)
}
if len(uids) != 3 || uids[0] != "uid-direct" || uids[1] != "uid-nested" || uids[2] != "uid-deep" {
t.Fatalf("uids = %#v", uids)
}
}
func TestConnectRemoveTracksRequiresTrackURI(t *testing.T) {
client := newConnectClientForTests(roundTripperFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Query().Get("operationName") == "playlistPermissions" {
return jsonResponse(http.StatusOK, playlistWritablePayload(true)), nil
}
return textResponse(http.StatusNotFound, "missing"), nil
}))
client.hashes.hashes["playlistPermissions"] = "hash"
if err := client.removeTracks(context.Background(), "p1", nil); err == nil {
t.Fatalf("expected error")
}
}
func TestEnsurePlaylistWritableRequiresCapabilities(t *testing.T) {
client := newConnectClientForTests(roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return jsonResponse(http.StatusOK, map[string]any{"data": map[string]any{"playlistV2": map[string]any{}}}), nil
}))
client.hashes.hashes["playlistPermissions"] = "hash"
if err := client.ensurePlaylistWritable(context.Background(), "p1"); err == nil {
t.Fatalf("expected error")
}
}
func playlistWritablePayload(writable bool) map[string]any {
return map[string]any{
"data": map[string]any{"playlistV2": map[string]any{
"currentUserCapabilities": map[string]any{"canEditItems": writable},
}},
}
}
func mustNewWebClientForPlaylistMutationTest(t *testing.T, handler http.HandlerFunc) *Client {
t.Helper()
client, closeFn := newTestClient(t, handler)
t.Cleanup(closeFn)
return client
}

View File

@ -1,56 +0,0 @@
package spotify
import (
"context"
"errors"
"strings"
)
func (c *ConnectClient) search(ctx context.Context, kind, query string, limit, offset int) (SearchResult, error) {
if strings.TrimSpace(query) == "" {
return SearchResult{}, errors.New("query required")
}
limit = normalizeSearchLimit(limit)
offset = normalizeOffset(offset)
payload, err := c.graphQL(ctx, "searchDesktop", searchVariables(query, limit, offset))
if err != nil {
fallback, ferr := c.searchViaWeb(ctx, kind, query, limit, offset)
if ferr == nil {
return fallback, nil
}
return SearchResult{}, ferr
}
items, total := extractSearchItems(payload, kind)
return SearchResult{Type: kind, Limit: limit, Offset: offset, Total: total, Items: items}, nil
}
func (c *ConnectClient) searchViaWeb(ctx context.Context, kind, query string, limit, offset int) (SearchResult, error) {
return c.searchViaWebAPI(ctx, kind, query, limit, offset)
}
func normalizeSearchLimit(limit int) int {
if limit <= 0 {
return 10
}
return limit
}
func normalizeOffset(offset int) int {
if offset < 0 {
return 0
}
return offset
}
func searchVariables(query string, limit, offset int) map[string]any {
return map[string]any{
"searchTerm": query,
"offset": offset,
"limit": limit,
"numberOfTopResults": 5,
"includeAudiobooks": true,
"includePreReleases": true,
"includeLocalConcertsField": false,
"includeArtistHasConcertsField": false,
}
}

View File

@ -100,7 +100,7 @@ func (s *connectSession) ensureAppConfigLocked(ctx context.Context) error {
}
}
if deviceID == "" {
return errors.New("missing sp_t cookie (run `spogo auth paste` and include sp_t from DevTools)")
return errors.New("missing sp_t cookie")
}
jar, err := cookiejar.New(nil)
if err != nil {
@ -112,7 +112,7 @@ func (s *connectSession) ensureAppConfigLocked(ctx context.Context) error {
if err != nil {
return err
}
applyRequestHeaders(req, requestHeaders{})
req.Header.Set("User-Agent", defaultUserAgent())
client := *s.client
client.Jar = jar
resp, err := client.Do(req)
@ -190,10 +190,9 @@ func (s *connectSession) ensureClientTokenLocked(ctx context.Context) error {
if err != nil {
return err
}
applyRequestHeaders(req, requestHeaders{
ContentType: "application/json",
Accept: "application/json",
})
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", defaultUserAgent())
resp, err := s.client.Do(req)
if err != nil {
return err

View File

@ -1,28 +0,0 @@
package spotify
import (
"net/http"
"time"
)
func newConnectClientForTests(transport http.RoundTripper) *ConnectClient {
client := &http.Client{Transport: transport}
session := &connectSession{
client: client,
token: Token{AccessToken: "access", ExpiresAt: time.Now().Add(time.Hour), ClientID: "client"},
clientToken: "client-token",
clientTokenT: time.Now().Add(time.Hour),
clientVer: "1.0.0",
deviceID: "device",
}
hashes := &hashResolver{client: client, session: session, hashes: map[string]string{}}
return &ConnectClient{client: client, session: session, hashes: hashes}
}
func newRegisteredConnectClientForTests(transport http.RoundTripper) *ConnectClient {
client := newConnectClientForTests(transport)
client.session.connectDeviceID = "device"
client.session.connectionID = "conn"
client.session.registeredAt = time.Now()
return client
}

View File

@ -1,94 +0,0 @@
package spotify
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
func (c *ConnectClient) searchViaWebAPI(ctx context.Context, kind, query string, limit, offset int) (SearchResult, error) {
auth, err := c.session.auth(ctx)
if err != nil {
return SearchResult{}, err
}
params := url.Values{}
params.Set("q", query)
params.Set("type", kind)
params.Set("limit", fmt.Sprint(limit))
params.Set("offset", fmt.Sprint(offset))
if c.market != "" && params.Get("market") == "" {
params.Set("market", c.market)
}
if c.language != "" && params.Get("locale") == "" {
params.Set("locale", c.language)
}
searchURL := c.searchURL
if searchURL == "" {
searchURL = "https://api.spotify.com/v1/search"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL+"?"+params.Encode(), nil)
if err != nil {
return SearchResult{}, err
}
applyRequestHeaders(req, requestHeaders{
AccessToken: auth.AccessToken,
ClientToken: auth.ClientToken,
ClientVersion: auth.ClientVersion,
Accept: "application/json",
Language: c.language,
AppPlatform: defaultSpotifyAppPlatform,
})
resp, err := c.client.Do(req)
if err != nil {
return SearchResult{}, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return SearchResult{}, apiErrorFromResponse(resp)
}
var response map[string]searchContainer
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return SearchResult{}, err
}
container, ok := response[kind]
if !ok {
return SearchResult{}, fmt.Errorf("missing %s result", kind)
}
items := make([]Item, 0, len(container.Items))
for _, raw := range container.Items {
item, err := mapSearchItem(kind, raw)
if err != nil {
return SearchResult{}, err
}
items = append(items, item)
}
return SearchResult{
Type: kind,
Limit: container.Limit,
Offset: container.Offset,
Total: container.Total,
Items: items,
}, nil
}
func (c *ConnectClient) webClient() (*Client, error) {
c.webMu.Lock()
defer c.webMu.Unlock()
if c.web != nil {
return c.web, nil
}
client, err := NewClient(Options{
TokenProvider: CookieTokenProvider{Source: c.source, Client: c.client},
HTTPClient: c.client,
Market: c.market,
Language: c.language,
Device: c.device,
})
if err != nil {
return nil, err
}
c.web = client
return client, nil
}

View File

@ -208,13 +208,9 @@ func (c *fallbackClient) CreatePlaylist(ctx context.Context, name string, public
}
func (c *fallbackClient) AddTracks(ctx context.Context, playlistID string, uris []string) error {
return fallbackVoid(c, true, func(api API) error {
return api.AddTracks(ctx, playlistID, uris)
})
return c.web.AddTracks(ctx, playlistID, uris)
}
func (c *fallbackClient) RemoveTracks(ctx context.Context, playlistID string, uris []string) error {
return fallbackVoid(c, true, func(api API) error {
return api.RemoveTracks(ctx, playlistID, uris)
})
return c.web.RemoveTracks(ctx, playlistID, uris)
}

View File

@ -15,8 +15,6 @@ type apiStub struct {
libraryModifyFn func(context.Context, string, []string, string) error
followedArtistsFn func(context.Context, int, string) ([]Item, int, string, error)
artistTopTracksFn func(context.Context, string, int) ([]Item, error)
addTracksFn func(context.Context, string, []string) error
removeTracksFn func(context.Context, string, []string) error
}
func (a apiStub) Search(ctx context.Context, kind, query string, limit, offset int) (SearchResult, error) {
@ -188,19 +186,13 @@ func (a apiStub) CreatePlaylist(context.Context, string, bool, bool) (Item, erro
return Item{}, nil
}
func (a apiStub) AddTracks(ctx context.Context, playlistID string, uris []string) error {
func (a apiStub) AddTracks(context.Context, string, []string) error {
a.note("AddTracks")
if a.addTracksFn != nil {
return a.addTracksFn(ctx, playlistID, uris)
}
return nil
}
func (a apiStub) RemoveTracks(ctx context.Context, playlistID string, uris []string) error {
func (a apiStub) RemoveTracks(context.Context, string, []string) error {
a.note("RemoveTracks")
if a.removeTracksFn != nil {
return a.removeTracksFn(ctx, playlistID, uris)
}
return nil
}
@ -338,31 +330,6 @@ func TestFallbackPauseOnRateLimit(t *testing.T) {
}
}
func TestFallbackPlaylistWritesOnRateLimit(t *testing.T) {
ctx := context.Background()
calls := map[string]int{}
web := apiStub{
calls: calls,
addTracksFn: func(context.Context, string, []string) error {
return APIError{Status: 429, Message: "rate limit"}
},
removeTracksFn: func(context.Context, string, []string) error {
return APIError{Status: 429, Message: "rate limit"}
},
}
connect := apiStub{calls: calls}
client := NewPlaybackFallbackClient(web, connect)
if err := client.AddTracks(ctx, "p1", []string{"spotify:track:t1"}); err != nil {
t.Fatalf("add tracks: %v", err)
}
if err := client.RemoveTracks(ctx, "p1", []string{"spotify:track:t1"}); err != nil {
t.Fatalf("remove tracks: %v", err)
}
if calls["AddTracks"] != 2 || calls["RemoveTracks"] != 2 {
t.Fatalf("unexpected calls: %#v", calls)
}
}
func TestFallbackDelegatesToWeb(t *testing.T) {
ctx := context.Background()
calls := map[string]int{}

View File

@ -1,47 +0,0 @@
package spotify
import "net/http"
const defaultSpotifyAppPlatform = "WebPlayer"
type requestHeaders struct {
AccessToken string
ClientToken string
ClientVersion string
Accept string
ContentType string
Language string
AppPlatform string
ConnectionID string
}
func applyRequestHeaders(req *http.Request, headers requestHeaders) {
if req == nil {
return
}
req.Header.Set("User-Agent", defaultUserAgent())
if headers.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+headers.AccessToken)
}
if headers.ClientToken != "" {
req.Header.Set("Client-Token", headers.ClientToken)
}
if headers.ClientVersion != "" {
req.Header.Set("Spotify-App-Version", headers.ClientVersion)
}
if headers.Accept != "" {
req.Header.Set("Accept", headers.Accept)
}
if headers.ContentType != "" {
req.Header.Set("Content-Type", headers.ContentType)
}
if headers.Language != "" {
req.Header.Set("Accept-Language", headers.Language)
}
if headers.AppPlatform != "" {
req.Header.Set("app-platform", headers.AppPlatform)
}
if headers.ConnectionID != "" {
req.Header.Set("x-spotify-connection-id", headers.ConnectionID)
}
}

View File

@ -150,49 +150,3 @@ func externalURL(urls map[string]string) string {
}
return ""
}
func itemNeedsTrackMetadata(item *Item) bool {
if item == nil || item.ID == "" {
return false
}
if item.Type != "" && item.Type != "track" {
return false
}
return item.Name == "" || len(item.Artists) == 0 || item.Album == ""
}
func mergeItemMetadata(dst *Item, src Item) {
if dst == nil {
return
}
if dst.ID == "" {
dst.ID = src.ID
}
if dst.URI == "" {
dst.URI = src.URI
}
if dst.Name == "" {
dst.Name = src.Name
}
if dst.Type == "" {
dst.Type = src.Type
}
if dst.URL == "" {
dst.URL = src.URL
}
if len(dst.Artists) == 0 {
dst.Artists = src.Artists
}
if dst.Album == "" {
dst.Album = src.Album
}
if dst.DurationMS == 0 {
dst.DurationMS = src.DurationMS
}
if !dst.Explicit {
dst.Explicit = src.Explicit
}
if !dst.IsPlayable {
dst.IsPlayable = src.IsPlayable
}
}

View File

@ -81,11 +81,9 @@ func (p CookieTokenProvider) Token(ctx context.Context) (Token, error) {
if err != nil {
return Token{}, err
}
applyRequestHeaders(req, requestHeaders{
Accept: "application/json",
Language: "en-US,en;q=0.9",
AppPlatform: defaultSpotifyAppPlatform,
})
req.Header.Set("User-Agent", defaultUserAgent())
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
req.Header.Set("Origin", "https://open.spotify.com")
req.Header.Set("Referer", "https://open.spotify.com/")
req.Header.Set("Sec-Fetch-Site", "same-origin")
@ -94,6 +92,7 @@ func (p CookieTokenProvider) Token(ctx context.Context) (Token, error) {
req.Header.Set("Sec-CH-UA", "\"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\", \"Google Chrome\";v=\"131\"")
req.Header.Set("Sec-CH-UA-Platform", "\"macOS\"")
req.Header.Set("Sec-CH-UA-Mobile", "?0")
req.Header.Set("app-platform", "WebPlayer")
resp, err := client.Do(req)
if err != nil {
return Token{}, err

View File

@ -42,3 +42,220 @@ type SpotifyMock struct {
AddTracksFn func(context.Context, string, []string) error
RemoveTracksFn func(context.Context, string, []string) error
}
func (m *SpotifyMock) Search(ctx context.Context, kind, query string, limit, offset int) (spotify.SearchResult, error) {
if m.SearchFn == nil {
return spotify.SearchResult{}, ErrNotImplemented
}
return m.SearchFn(ctx, kind, query, limit, offset)
}
func (m *SpotifyMock) GetTrack(ctx context.Context, id string) (spotify.Item, error) {
if m.GetTrackFn == nil {
return spotify.Item{}, ErrNotImplemented
}
return m.GetTrackFn(ctx, id)
}
func (m *SpotifyMock) GetAlbum(ctx context.Context, id string) (spotify.Item, error) {
if m.GetAlbumFn == nil {
return spotify.Item{}, ErrNotImplemented
}
return m.GetAlbumFn(ctx, id)
}
func (m *SpotifyMock) GetArtist(ctx context.Context, id string) (spotify.Item, error) {
if m.GetArtistFn == nil {
return spotify.Item{}, ErrNotImplemented
}
return m.GetArtistFn(ctx, id)
}
func (m *SpotifyMock) GetPlaylist(ctx context.Context, id string) (spotify.Item, error) {
if m.GetPlaylistFn == nil {
return spotify.Item{}, ErrNotImplemented
}
return m.GetPlaylistFn(ctx, id)
}
func (m *SpotifyMock) GetShow(ctx context.Context, id string) (spotify.Item, error) {
if m.GetShowFn == nil {
return spotify.Item{}, ErrNotImplemented
}
return m.GetShowFn(ctx, id)
}
func (m *SpotifyMock) GetEpisode(ctx context.Context, id string) (spotify.Item, error) {
if m.GetEpisodeFn == nil {
return spotify.Item{}, ErrNotImplemented
}
return m.GetEpisodeFn(ctx, id)
}
func (m *SpotifyMock) ArtistTopTracks(ctx context.Context, id string, limit int) ([]spotify.Item, error) {
if m.ArtistTopTracksFn == nil {
return nil, ErrNotImplemented
}
return m.ArtistTopTracksFn(ctx, id, limit)
}
func (m *SpotifyMock) Playback(ctx context.Context) (spotify.PlaybackStatus, error) {
if m.PlaybackFn == nil {
return spotify.PlaybackStatus{}, ErrNotImplemented
}
return m.PlaybackFn(ctx)
}
func (m *SpotifyMock) Play(ctx context.Context, uri string) error {
if m.PlayFn == nil {
return ErrNotImplemented
}
return m.PlayFn(ctx, uri)
}
func (m *SpotifyMock) Pause(ctx context.Context) error {
if m.PauseFn == nil {
return ErrNotImplemented
}
return m.PauseFn(ctx)
}
func (m *SpotifyMock) Next(ctx context.Context) error {
if m.NextFn == nil {
return ErrNotImplemented
}
return m.NextFn(ctx)
}
func (m *SpotifyMock) Previous(ctx context.Context) error {
if m.PreviousFn == nil {
return ErrNotImplemented
}
return m.PreviousFn(ctx)
}
func (m *SpotifyMock) Seek(ctx context.Context, positionMS int) error {
if m.SeekFn == nil {
return ErrNotImplemented
}
return m.SeekFn(ctx, positionMS)
}
func (m *SpotifyMock) Volume(ctx context.Context, volume int) error {
if m.VolumeFn == nil {
return ErrNotImplemented
}
return m.VolumeFn(ctx, volume)
}
func (m *SpotifyMock) Shuffle(ctx context.Context, enabled bool) error {
if m.ShuffleFn == nil {
return ErrNotImplemented
}
return m.ShuffleFn(ctx, enabled)
}
func (m *SpotifyMock) Repeat(ctx context.Context, mode string) error {
if m.RepeatFn == nil {
return ErrNotImplemented
}
return m.RepeatFn(ctx, mode)
}
func (m *SpotifyMock) Devices(ctx context.Context) ([]spotify.Device, error) {
if m.DevicesFn == nil {
return nil, ErrNotImplemented
}
return m.DevicesFn(ctx)
}
func (m *SpotifyMock) Transfer(ctx context.Context, deviceID string) error {
if m.TransferFn == nil {
return ErrNotImplemented
}
return m.TransferFn(ctx, deviceID)
}
func (m *SpotifyMock) QueueAdd(ctx context.Context, uri string) error {
if m.QueueAddFn == nil {
return ErrNotImplemented
}
return m.QueueAddFn(ctx, uri)
}
func (m *SpotifyMock) Queue(ctx context.Context) (spotify.Queue, error) {
if m.QueueFn == nil {
return spotify.Queue{}, ErrNotImplemented
}
return m.QueueFn(ctx)
}
func (m *SpotifyMock) LibraryTracks(ctx context.Context, limit, offset int) ([]spotify.Item, int, error) {
if m.LibraryTracksFn == nil {
return nil, 0, ErrNotImplemented
}
return m.LibraryTracksFn(ctx, limit, offset)
}
func (m *SpotifyMock) LibraryAlbums(ctx context.Context, limit, offset int) ([]spotify.Item, int, error) {
if m.LibraryAlbumsFn == nil {
return nil, 0, ErrNotImplemented
}
return m.LibraryAlbumsFn(ctx, limit, offset)
}
func (m *SpotifyMock) LibraryModify(ctx context.Context, path string, ids []string, method string) error {
if m.LibraryModifyFn == nil {
return ErrNotImplemented
}
return m.LibraryModifyFn(ctx, path, ids, method)
}
func (m *SpotifyMock) FollowArtists(ctx context.Context, ids []string, method string) error {
if m.FollowArtistsFn == nil {
return ErrNotImplemented
}
return m.FollowArtistsFn(ctx, ids, method)
}
func (m *SpotifyMock) FollowedArtists(ctx context.Context, limit int, after string) ([]spotify.Item, int, string, error) {
if m.FollowedArtistsFn == nil {
return nil, 0, "", ErrNotImplemented
}
return m.FollowedArtistsFn(ctx, limit, after)
}
func (m *SpotifyMock) Playlists(ctx context.Context, limit, offset int) ([]spotify.Item, int, error) {
if m.PlaylistsFn == nil {
return nil, 0, ErrNotImplemented
}
return m.PlaylistsFn(ctx, limit, offset)
}
func (m *SpotifyMock) PlaylistTracks(ctx context.Context, id string, limit, offset int) ([]spotify.Item, int, error) {
if m.PlaylistTracksFn == nil {
return nil, 0, ErrNotImplemented
}
return m.PlaylistTracksFn(ctx, id, limit, offset)
}
func (m *SpotifyMock) CreatePlaylist(ctx context.Context, name string, public, collaborative bool) (spotify.Item, error) {
if m.CreatePlaylistFn == nil {
return spotify.Item{}, ErrNotImplemented
}
return m.CreatePlaylistFn(ctx, name, public, collaborative)
}
func (m *SpotifyMock) AddTracks(ctx context.Context, playlistID string, uris []string) error {
if m.AddTracksFn == nil {
return ErrNotImplemented
}
return m.AddTracksFn(ctx, playlistID, uris)
}
func (m *SpotifyMock) RemoveTracks(ctx context.Context, playlistID string, uris []string) error {
if m.RemoveTracksFn == nil {
return ErrNotImplemented
}
return m.RemoveTracksFn(ctx, playlistID, uris)
}

Some files were not shown because too many files have changed in this diff Show More