Compare commits

..

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

102 changed files with 2085 additions and 7547 deletions

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

@ -15,7 +15,6 @@ builds:
- linux_amd64
- linux_arm64
- windows_amd64
- windows_arm64
- id: spogo_darwin
main: ./cmd/spogo
binary: spogo

View File

@ -1,23 +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)

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

@ -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.2.0)
One-liner: Spotify power CLI using web cookies; search + playback control.
Parser: Kong.
@ -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,167 @@ 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
case "applescript":
var fallback spotify.API
if webClient, webErr := 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,
}); webErr == nil {
fallback = webClient
}
client, err := spotify.NewAppleScriptClient(spotify.AppleScriptOptions{
Fallback: fallback,
})
if err != nil {
return nil, err
}
c.spotifyClient = client
return client, nil
default:
return nil, fmt.Errorf("unknown engine %q (use auto, web, connect, or applescript)", engine)
}
}
func (c *Context) SetSpotify(client spotify.API) {
@ -46,13 +208,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 +237,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 +265,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,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.2.0"
func New() *CLI {
return &CLI{}
@ -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

@ -15,3 +15,4 @@ func TestNewAppleScriptClient_NonDarwin(t *testing.T) {
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)
}

View File

@ -1,63 +0,0 @@
package testutil
import (
"context"
"github.com/steipete/spogo/internal/spotify"
)
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)
}

View File

@ -1,77 +0,0 @@
package testutil
import (
"context"
"github.com/steipete/spogo/internal/spotify"
)
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)
}

View File

@ -14,7 +14,6 @@ func TestSpotifyMockAllNotImplemented(t *testing.T) {
_, _ = m.GetPlaylist(context.Background(), "1")
_, _ = m.GetShow(context.Background(), "1")
_, _ = m.GetEpisode(context.Background(), "1")
_, _ = m.ArtistTopTracks(context.Background(), "1", 10)
_, _ = m.Playback(context.Background())
_ = m.Play(context.Background(), "uri")
_ = m.Pause(context.Background())

View File

@ -1,98 +0,0 @@
package testutil
import (
"context"
"github.com/steipete/spogo/internal/spotify"
)
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)
}

View File

@ -18,7 +18,6 @@ func TestSpotifyMockAllMethods(t *testing.T) {
GetPlaylistFn: func(context.Context, string) (spotify.Item, error) { return spotify.Item{}, nil },
GetShowFn: func(context.Context, string) (spotify.Item, error) { return spotify.Item{}, nil },
GetEpisodeFn: func(context.Context, string) (spotify.Item, error) { return spotify.Item{}, nil },
ArtistTopTracksFn: func(context.Context, string, int) ([]spotify.Item, error) { return nil, nil },
PlaybackFn: func(context.Context) (spotify.PlaybackStatus, error) { return spotify.PlaybackStatus{}, nil },
PlayFn: func(context.Context, string) error { return nil },
PauseFn: func(context.Context) error { return nil },
@ -50,7 +49,6 @@ func TestSpotifyMockAllMethods(t *testing.T) {
_, _ = m.GetPlaylist(context.Background(), "1")
_, _ = m.GetShow(context.Background(), "1")
_, _ = m.GetEpisode(context.Background(), "1")
_, _ = m.ArtistTopTracks(context.Background(), "1", 10)
_, _ = m.Playback(context.Background())
_ = m.Play(context.Background(), "uri")
_ = m.Pause(context.Background())
@ -87,9 +85,6 @@ func TestSpotifyMockNotImplemented(t *testing.T) {
if _, err := m.GetTrack(context.Background(), "1"); err == nil {
t.Fatalf("expected error")
}
if _, err := m.ArtistTopTracks(context.Background(), "1", 10); err == nil {
t.Fatalf("expected error")
}
if err := m.Pause(context.Background()); err == nil {
t.Fatalf("expected error")
}

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