Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- run: go mod download
|
||||
- uses: golangci/golangci-lint-action@v7
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v2.7.2
|
||||
- run: ./scripts/check-coverage.sh 90
|
||||
|
||||
54
.github/workflows/pages.yml
vendored
54
.github/workflows/pages.yml
vendored
@ -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
|
||||
59
.github/workflows/release.yml
vendored
59
.github/workflows/release.yml
vendored
@ -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
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
version: "2"
|
||||
version: 2
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
@ -20,3 +20,8 @@ linters:
|
||||
- nilerr
|
||||
- nilnil
|
||||
- durationcheck
|
||||
|
||||
linters-settings: {}
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
|
||||
@ -11,19 +11,25 @@ builds:
|
||||
binary: spogo
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
targets:
|
||||
- linux_amd64
|
||||
- linux_arm64
|
||||
- windows_amd64
|
||||
- windows_arm64
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
- id: spogo_darwin
|
||||
main: ./cmd/spogo
|
||||
binary: spogo
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
targets:
|
||||
- darwin_amd64
|
||||
- darwin_arm64
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
archives:
|
||||
- builds:
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@ -1,28 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.3.0 - 2026-05-05
|
||||
|
||||
- Add `auth paste`, wire `--no-input`, and improve cookie diagnostics/cleanup (`#5`, thanks @im-zayan)
|
||||
- Add `play --shuffle`, Connect library/playlist support, and context-aware Connect play payloads (`#15`, thanks @StandardGage)
|
||||
- Fix Connect track artist extraction for nested artist containers and minimal artist fragments (`#7`, thanks @joelbdavies)
|
||||
- Fix silent `auth import` failures by retrying Spotify auth cookie lookup across related hosts and surfacing browser warnings (`#13`)
|
||||
- Fix `device set` when Connect state has no origin device by falling back to Web API transfer (`#8`)
|
||||
- Fix Connect liked-track listing via `fetchLibraryTracks` with Web API fallback on payload drift (`#16`, thanks @masonc15)
|
||||
- Fix Connect play when no device is active by falling back to Web API playback (`#21`, thanks @prashanthbala)
|
||||
- Fix Connect volume changes by sending the volume endpoint as `PUT` (`#24`, thanks @cavit99)
|
||||
- Fix sparse status/search metadata so track artists and albums are populated consistently across engines.
|
||||
- Fix Connect `--device` playback when no device is active without falling back to rate-limited Web API playback.
|
||||
- Fix `auth paste --no-input` by accepting the documented flag order.
|
||||
- Fix playlist add/remove 429s by using Connect playlist mutations with writable-playlist checks and fallback coverage across engines (`#20`).
|
||||
- Release prep: bump CLI/spec version to `0.3.0`
|
||||
|
||||
## 0.2.0 - 2026-01-07
|
||||
|
||||
- Add `applescript` engine for direct Spotify.app control on macOS (thanks @adam91holt)
|
||||
- CI: bump golangci-lint-action to support golangci-lint v2
|
||||
|
||||
## 0.1.0 - 2026-01-02
|
||||
|
||||
- Kong-powered CLI with global flags, config profiles, and env overrides
|
||||
|
||||
5
Makefile
5
Makefile
@ -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
|
||||
|
||||
25
README.md
25
README.md
@ -65,7 +65,7 @@ Global flags:
|
||||
- `--market <cc>` market country code
|
||||
- `--language <tag>` language/locale (default `en`)
|
||||
- `--device <name|id>` target device
|
||||
- `--engine <auto|web|connect|applescript>` API engine (default `connect`, `applescript` is macOS-only)
|
||||
- `--engine <auto|web|connect>` API engine (default `connect`)
|
||||
- `--json` / `--plain`
|
||||
- `--no-color`
|
||||
- `-q, --quiet` / `-v, --verbose` / `-d, --debug`
|
||||
@ -77,10 +77,10 @@ Env overrides:
|
||||
|
||||
Commands:
|
||||
|
||||
- `auth status|import|paste|clear`
|
||||
- `auth status|import|clear`
|
||||
- `search track|album|artist|playlist|show|episode`
|
||||
- `track info`, `album info`, `artist info`, `playlist info`, `show info`, `episode info`
|
||||
- `play [<id|url>] [--type ...] [--shuffle]`, `pause`, `next`, `prev`, `seek`, `volume`, `shuffle`, `repeat`, `status`
|
||||
- `play [<id|url>] [--type ...]`, `pause`, `next`, `prev`, `seek`, `volume`, `shuffle`, `repeat`, `status`
|
||||
- `queue add|show`
|
||||
- `library tracks|albums|artists|playlists`
|
||||
- `playlist create|add|remove|tracks`
|
||||
@ -97,25 +97,6 @@ spogo auth import --browser chrome
|
||||
```
|
||||
|
||||
Defaults: Chrome + Default profile. Cookies are stored under your config directory (per profile).
|
||||
If import still fails, `spogo` now surfaces browser-store warnings instead of only printing `no cookies found`.
|
||||
|
||||
### Manual cookie paste (WSL fallback)
|
||||
|
||||
If WSL cookie import/decryption is broken, paste cookies from Chrome DevTools:
|
||||
|
||||
1) Developer Tools -> Application tab -> Cookies -> `https://open.spotify.com`
|
||||
2) Copy `sp_dc` (required), `sp_key` (optional), `sp_t` (recommended for connect playback)
|
||||
3) Run:
|
||||
|
||||
```bash
|
||||
spogo auth paste
|
||||
```
|
||||
|
||||
Non-interactive:
|
||||
|
||||
```bash
|
||||
printf '%s\n%s\n' "sp_dc=..." "sp_t=..." | spogo auth paste --no-input
|
||||
```
|
||||
|
||||
## Auto engine notes
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1 +0,0 @@
|
||||
spogo.sh
|
||||
150
docs/agents.md
150
docs/agents.md
@ -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).
|
||||
132
docs/auth.md
132
docs/auth.md
@ -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.
|
||||
154
docs/commands.md
154
docs/commands.md
@ -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.
|
||||
@ -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.
|
||||
114
docs/engines.md
114
docs/engines.md
@ -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).
|
||||
@ -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.
|
||||
@ -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.
|
||||
133
docs/library.md
133
docs/library.md
@ -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.
|
||||
120
docs/output.md
120
docs/output.md
@ -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
|
||||
```
|
||||
122
docs/playback.md
122
docs/playback.md
@ -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`.
|
||||
@ -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.
|
||||
@ -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 |
@ -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 |
12
docs/spec.md
12
docs/spec.md
@ -1,4 +1,4 @@
|
||||
# spogo CLI spec (v0.3.0)
|
||||
# spogo CLI spec (v0.1.0)
|
||||
|
||||
One-liner: Spotify power CLI using web cookies; search + playback control.
|
||||
Parser: Kong.
|
||||
@ -29,7 +29,7 @@ spogo [global flags] <command> [args]
|
||||
- `--market <cc>` default: account market or `US`
|
||||
- `--language <tag>` default: `en`
|
||||
- `--device <name|id>` default: active device
|
||||
- `--engine <auto|web|connect|applescript>` default: `connect` (`applescript` is macOS-only)
|
||||
- `--engine <auto|web|connect>` default: `connect`
|
||||
- `--no-input`
|
||||
|
||||
## Commands
|
||||
@ -42,12 +42,6 @@ spogo [global flags] <command> [args]
|
||||
- `--browser-profile <name>`
|
||||
- `--cookie-path <file>`
|
||||
- `--domain <host>` default `spotify.com`
|
||||
- when browser reads fail, surface underlying browser-store warnings
|
||||
- `spogo auth paste`
|
||||
- reads cookie values from stdin (prompts when interactive)
|
||||
- `--cookie-path <file>`
|
||||
- `--domain <suffix>` default `spotify.com`
|
||||
- `--path <path>` default `/`
|
||||
- `spogo auth clear`
|
||||
|
||||
### search
|
||||
@ -72,7 +66,6 @@ spogo [global flags] <command> [args]
|
||||
|
||||
- `spogo play [<id|url>]` (track/album/playlist/show)
|
||||
- optional: `--type <track|album|playlist|show|episode>` for raw IDs
|
||||
- optional: `--shuffle` enable shuffle before playing (randomizes first track for context URIs)
|
||||
- artist URIs play top tracks (starts with the first)
|
||||
- `spogo pause`
|
||||
- `spogo next`
|
||||
@ -113,7 +106,6 @@ spogo [global flags] <command> [args]
|
||||
|
||||
- `spogo device list`
|
||||
- `spogo device set <name|id>`
|
||||
- falls back to Web API transfer when Connect state has no origin device
|
||||
|
||||
## Output contract
|
||||
|
||||
|
||||
@ -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
30
go.mod
@ -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
79
go.sum
@ -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=
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/steipete/spogo/internal/config"
|
||||
"github.com/steipete/spogo/internal/cookies"
|
||||
"github.com/steipete/spogo/internal/output"
|
||||
"github.com/steipete/spogo/internal/spotify"
|
||||
)
|
||||
@ -24,7 +27,6 @@ type Settings struct {
|
||||
Quiet bool
|
||||
Verbose bool
|
||||
Debug bool
|
||||
NoInput bool
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
@ -36,7 +38,146 @@ type Context struct {
|
||||
Output *output.Writer
|
||||
|
||||
spotifyClient spotify.API
|
||||
commandCtx context.Context
|
||||
}
|
||||
|
||||
func NewContext(settings Settings) (*Context, error) {
|
||||
configPath := settings.ConfigPath
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if configPath == "" {
|
||||
configPath, err = config.DefaultPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
profileKey := settings.Profile
|
||||
if profileKey == "" {
|
||||
profileKey = cfg.DefaultProfile
|
||||
}
|
||||
if profileKey == "" {
|
||||
profileKey = config.DefaultProfile
|
||||
}
|
||||
profile := cfg.Profile(profileKey)
|
||||
if settings.Market != "" {
|
||||
profile.Market = settings.Market
|
||||
}
|
||||
if settings.Language != "" {
|
||||
profile.Language = settings.Language
|
||||
}
|
||||
if settings.Device != "" {
|
||||
profile.Device = settings.Device
|
||||
}
|
||||
if settings.Engine != "" {
|
||||
profile.Engine = settings.Engine
|
||||
}
|
||||
format := settings.Format
|
||||
if format == "" {
|
||||
format = output.FormatHuman
|
||||
}
|
||||
colorEnabled := isColorEnabled(format, settings.NoColor)
|
||||
writer, err := output.New(output.Options{
|
||||
Format: format,
|
||||
Color: colorEnabled,
|
||||
Quiet: settings.Quiet,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Context{
|
||||
Settings: settings,
|
||||
Config: cfg,
|
||||
ConfigPath: configPath,
|
||||
Profile: profile,
|
||||
ProfileKey: profileKey,
|
||||
Output: writer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Context) Spotify() (spotify.API, error) {
|
||||
if c == nil {
|
||||
return nil, errors.New("nil context")
|
||||
}
|
||||
if c.spotifyClient != nil {
|
||||
return c.spotifyClient, nil
|
||||
}
|
||||
source, err := c.cookieSource()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
engine := strings.ToLower(strings.TrimSpace(c.Profile.Engine))
|
||||
if engine == "" {
|
||||
engine = "connect"
|
||||
}
|
||||
switch engine {
|
||||
case "connect":
|
||||
client, err := spotify.NewConnectClient(spotify.ConnectOptions{
|
||||
Source: source,
|
||||
Market: c.Profile.Market,
|
||||
Language: c.Profile.Language,
|
||||
Device: c.Profile.Device,
|
||||
Timeout: c.Settings.Timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.spotifyClient = client
|
||||
return client, nil
|
||||
case "web":
|
||||
provider := spotify.CookieTokenProvider{
|
||||
Source: source,
|
||||
}
|
||||
webClient, err := spotify.NewClient(spotify.Options{
|
||||
TokenProvider: provider,
|
||||
Market: c.Profile.Market,
|
||||
Language: c.Profile.Language,
|
||||
Device: c.Profile.Device,
|
||||
Timeout: c.Settings.Timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := spotify.API(webClient)
|
||||
if connectClient, connectErr := spotify.NewConnectClient(spotify.ConnectOptions{
|
||||
Source: source,
|
||||
Market: c.Profile.Market,
|
||||
Language: c.Profile.Language,
|
||||
Device: c.Profile.Device,
|
||||
Timeout: c.Settings.Timeout,
|
||||
}); connectErr == nil {
|
||||
client = spotify.NewPlaybackFallbackClient(webClient, connectClient)
|
||||
}
|
||||
c.spotifyClient = client
|
||||
return client, nil
|
||||
case "auto":
|
||||
webClient, err := spotify.NewClient(spotify.Options{
|
||||
TokenProvider: spotify.CookieTokenProvider{
|
||||
Source: source,
|
||||
},
|
||||
Market: c.Profile.Market,
|
||||
Language: c.Profile.Language,
|
||||
Device: c.Profile.Device,
|
||||
Timeout: c.Settings.Timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := spotify.API(webClient)
|
||||
if connectClient, connectErr := spotify.NewConnectClient(spotify.ConnectOptions{
|
||||
Source: source,
|
||||
Market: c.Profile.Market,
|
||||
Language: c.Profile.Language,
|
||||
Device: c.Profile.Device,
|
||||
Timeout: c.Settings.Timeout,
|
||||
}); connectErr == nil {
|
||||
client = spotify.NewAutoClient(connectClient, webClient)
|
||||
}
|
||||
c.spotifyClient = client
|
||||
return client, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown engine %q (use auto, web, or connect)", engine)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Context) SetSpotify(client spotify.API) {
|
||||
@ -46,13 +187,25 @@ func (c *Context) SetSpotify(client spotify.API) {
|
||||
c.spotifyClient = client
|
||||
}
|
||||
|
||||
func (c *Context) cookieSource() (cookies.Source, error) {
|
||||
if c.Profile.CookiePath != "" {
|
||||
return cookies.FileSource{Path: c.Profile.CookiePath}, nil
|
||||
}
|
||||
browser := c.Profile.Browser
|
||||
if strings.TrimSpace(browser) == "" {
|
||||
browser = "chrome"
|
||||
}
|
||||
return cookies.BrowserSource{
|
||||
Browser: browser,
|
||||
Profile: c.Profile.BrowserProfile,
|
||||
Domain: "spotify.com",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Context) SaveProfile(profile config.Profile) error {
|
||||
if c == nil {
|
||||
return errors.New("nil context")
|
||||
}
|
||||
if c.Config == nil {
|
||||
return errors.New("nil config")
|
||||
}
|
||||
cfg := c.Config
|
||||
cfg.SetProfile(c.ProfileKey, profile)
|
||||
cfg.DefaultProfile = c.ProfileKey
|
||||
@ -63,6 +216,23 @@ func (c *Context) SaveProfile(profile config.Profile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isColorEnabled(format output.Format, noColor bool) bool {
|
||||
if format != output.FormatHuman {
|
||||
return false
|
||||
}
|
||||
if noColor {
|
||||
return false
|
||||
}
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
return false
|
||||
}
|
||||
term := strings.ToLower(os.Getenv("TERM"))
|
||||
if term == "dumb" {
|
||||
return false
|
||||
}
|
||||
return isatty.IsTerminal(os.Stdout.Fd())
|
||||
}
|
||||
|
||||
func (c *Context) ResolveCookiePath() string {
|
||||
return config.CookiePath(c.ConfigPath, c.ProfileKey)
|
||||
}
|
||||
@ -74,24 +244,6 @@ func (c *Context) EnsureTimeout() time.Duration {
|
||||
return 10 * time.Second
|
||||
}
|
||||
|
||||
func (c *Context) CommandContext() context.Context {
|
||||
if c == nil || c.commandCtx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return c.commandCtx
|
||||
}
|
||||
|
||||
func (c *Context) SetCommandContext(ctx context.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
c.commandCtx = context.Background()
|
||||
return
|
||||
}
|
||||
c.commandCtx = ctx
|
||||
}
|
||||
|
||||
func (c *Context) ValidateProfile() error {
|
||||
if c.Profile.Market != "" && len(c.Profile.Market) != 2 {
|
||||
return fmt.Errorf("market must be 2-letter country code")
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
//go:build !darwin
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/spogo/internal/config"
|
||||
)
|
||||
|
||||
func TestSpotifyAppleScriptEngine_NonDarwin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := &Context{Profile: config.Profile{CookiePath: "/tmp/cookies.json", Engine: "applescript"}}
|
||||
if _, err := ctx.Spotify(); err == nil || !strings.Contains(err.Error(), "only available on macOS") {
|
||||
t.Fatalf("expected macOS-only error, got: %v", err)
|
||||
}
|
||||
if ctx.spotifyClient != nil {
|
||||
t.Fatalf("expected no cached client on error")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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), "\"'")
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -10,7 +10,7 @@ import (
|
||||
"github.com/steipete/spogo/internal/output"
|
||||
)
|
||||
|
||||
const Version = "0.3.0"
|
||||
const Version = "0.1.0"
|
||||
|
||||
func New() *CLI {
|
||||
return &CLI{}
|
||||
@ -50,7 +50,7 @@ type Globals struct {
|
||||
Market string `help:"Market country code." env:"SPOGO_MARKET"`
|
||||
Language string `help:"Language/locale." env:"SPOGO_LANGUAGE"`
|
||||
Device string `help:"Device name or id." env:"SPOGO_DEVICE"`
|
||||
Engine string `help:"Engine (auto|web|connect|applescript)." env:"SPOGO_ENGINE"`
|
||||
Engine string `help:"Engine (auto|web|connect)." env:"SPOGO_ENGINE"`
|
||||
JSON bool `help:"JSON output." env:"SPOGO_JSON"`
|
||||
Plain bool `help:"Plain output." env:"SPOGO_PLAIN"`
|
||||
NoColor bool `help:"Disable color output." env:"SPOGO_NO_COLOR"`
|
||||
@ -79,7 +79,6 @@ func (g Globals) Settings() (app.Settings, error) {
|
||||
Quiet: g.Quiet,
|
||||
Verbose: g.Verbose,
|
||||
Debug: g.Debug,
|
||||
NoInput: g.NoInput,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
@ -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)})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -1,300 +0,0 @@
|
||||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AppleScriptClient struct {
|
||||
fallback API
|
||||
}
|
||||
|
||||
type AppleScriptOptions struct {
|
||||
Fallback API
|
||||
}
|
||||
|
||||
func NewAppleScriptClient(opts AppleScriptOptions) (API, error) {
|
||||
return &AppleScriptClient{
|
||||
fallback: opts.Fallback,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) runScript(ctx context.Context, script string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "osascript", "-e", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
return "", fmt.Errorf("applescript error: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("applescript error: %w (%s)", err, msg)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Play(ctx context.Context, uri string) error {
|
||||
var script string
|
||||
if uri == "" {
|
||||
script = `tell application "Spotify" to play`
|
||||
} else {
|
||||
script = fmt.Sprintf(`tell application "Spotify" to play track "%s"`, uri)
|
||||
}
|
||||
_, err := c.runScript(ctx, script)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Pause(ctx context.Context) error {
|
||||
_, err := c.runScript(ctx, `tell application "Spotify" to pause`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Next(ctx context.Context) error {
|
||||
_, err := c.runScript(ctx, `tell application "Spotify" to next track`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Previous(ctx context.Context) error {
|
||||
_, err := c.runScript(ctx, `tell application "Spotify" to previous track`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Seek(ctx context.Context, positionMS int) error {
|
||||
positionSec := positionMS / 1000
|
||||
script := fmt.Sprintf(`tell application "Spotify" to set player position to %d`, positionSec)
|
||||
_, err := c.runScript(ctx, script)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Volume(ctx context.Context, volume int) error {
|
||||
script := fmt.Sprintf(`tell application "Spotify" to set sound volume to %d`, volume)
|
||||
_, err := c.runScript(ctx, script)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Shuffle(ctx context.Context, enabled bool) error {
|
||||
val := "false"
|
||||
if enabled {
|
||||
val = "true"
|
||||
}
|
||||
script := fmt.Sprintf(`tell application "Spotify" to set shuffling to %s`, val)
|
||||
_, err := c.runScript(ctx, script)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Repeat(ctx context.Context, mode string) error {
|
||||
val := "false"
|
||||
if mode == "track" || mode == "context" {
|
||||
val = "true"
|
||||
}
|
||||
script := fmt.Sprintf(`tell application "Spotify" to set repeating to %s`, val)
|
||||
_, err := c.runScript(ctx, script)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Playback(ctx context.Context) (PlaybackStatus, error) {
|
||||
script := `tell application "Spotify"
|
||||
set trackName to name of current track
|
||||
set trackArtist to artist of current track
|
||||
set trackAlbum to album of current track
|
||||
set trackID to id of current track
|
||||
set trackDuration to duration of current track
|
||||
set playerPos to player position
|
||||
set playerState to player state as string
|
||||
set vol to sound volume
|
||||
set isShuffling to shuffling
|
||||
set isRepeating to repeating
|
||||
return trackName & "|||" & trackArtist & "|||" & trackAlbum & "|||" & trackID & "|||" & trackDuration & "|||" & playerPos & "|||" & playerState & "|||" & vol & "|||" & isShuffling & "|||" & isRepeating
|
||||
end tell`
|
||||
out, err := c.runScript(ctx, script)
|
||||
if err != nil {
|
||||
return PlaybackStatus{}, err
|
||||
}
|
||||
parts := strings.Split(out, "|||")
|
||||
if len(parts) < 10 {
|
||||
return PlaybackStatus{}, fmt.Errorf("unexpected applescript output: %s", out)
|
||||
}
|
||||
durationMS, _ := strconv.Atoi(parts[4])
|
||||
positionSec, _ := strconv.ParseFloat(parts[5], 64)
|
||||
volume, _ := strconv.Atoi(parts[7])
|
||||
isPlaying := parts[6] == "playing"
|
||||
shuffle := parts[8] == "true"
|
||||
repeat := "off"
|
||||
if parts[9] == "true" {
|
||||
repeat = "context"
|
||||
}
|
||||
item := &Item{
|
||||
URI: parts[3],
|
||||
Name: parts[0],
|
||||
Artists: []string{parts[1]},
|
||||
Album: parts[2],
|
||||
DurationMS: durationMS,
|
||||
}
|
||||
return PlaybackStatus{
|
||||
IsPlaying: isPlaying,
|
||||
ProgressMS: int(positionSec * 1000),
|
||||
Item: item,
|
||||
Device: Device{
|
||||
ID: "local",
|
||||
Name: "Local Spotify",
|
||||
Type: "COMPUTER",
|
||||
Volume: volume,
|
||||
Active: true,
|
||||
},
|
||||
Shuffle: shuffle,
|
||||
Repeat: repeat,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Devices(ctx context.Context) ([]Device, error) {
|
||||
return []Device{
|
||||
{
|
||||
ID: "local",
|
||||
Name: "Local Spotify",
|
||||
Type: "COMPUTER",
|
||||
Active: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Transfer(ctx context.Context, deviceID string) error {
|
||||
return ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) QueueAdd(ctx context.Context, uri string) error {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.QueueAdd(ctx, uri)
|
||||
}
|
||||
return ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Queue(ctx context.Context) (Queue, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.Queue(ctx)
|
||||
}
|
||||
return Queue{}, ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Search(ctx context.Context, kind, query string, limit, offset int) (SearchResult, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.Search(ctx, kind, query, limit, offset)
|
||||
}
|
||||
return SearchResult{}, ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) GetTrack(ctx context.Context, id string) (Item, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.GetTrack(ctx, id)
|
||||
}
|
||||
return Item{}, ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) GetAlbum(ctx context.Context, id string) (Item, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.GetAlbum(ctx, id)
|
||||
}
|
||||
return Item{}, ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) GetArtist(ctx context.Context, id string) (Item, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.GetArtist(ctx, id)
|
||||
}
|
||||
return Item{}, ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) GetPlaylist(ctx context.Context, id string) (Item, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.GetPlaylist(ctx, id)
|
||||
}
|
||||
return Item{}, ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) GetShow(ctx context.Context, id string) (Item, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.GetShow(ctx, id)
|
||||
}
|
||||
return Item{}, ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) GetEpisode(ctx context.Context, id string) (Item, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.GetEpisode(ctx, id)
|
||||
}
|
||||
return Item{}, ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) LibraryTracks(ctx context.Context, limit, offset int) ([]Item, int, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.LibraryTracks(ctx, limit, offset)
|
||||
}
|
||||
return nil, 0, ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) LibraryAlbums(ctx context.Context, limit, offset int) ([]Item, int, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.LibraryAlbums(ctx, limit, offset)
|
||||
}
|
||||
return nil, 0, ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) LibraryModify(ctx context.Context, path string, ids []string, method string) error {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.LibraryModify(ctx, path, ids, method)
|
||||
}
|
||||
return ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) FollowArtists(ctx context.Context, ids []string, method string) error {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.FollowArtists(ctx, ids, method)
|
||||
}
|
||||
return ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) FollowedArtists(ctx context.Context, limit int, after string) ([]Item, int, string, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.FollowedArtists(ctx, limit, after)
|
||||
}
|
||||
return nil, 0, "", ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) Playlists(ctx context.Context, limit, offset int) ([]Item, int, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.Playlists(ctx, limit, offset)
|
||||
}
|
||||
return nil, 0, ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) PlaylistTracks(ctx context.Context, id string, limit, offset int) ([]Item, int, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.PlaylistTracks(ctx, id, limit, offset)
|
||||
}
|
||||
return nil, 0, ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) CreatePlaylist(ctx context.Context, name string, public, collaborative bool) (Item, error) {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.CreatePlaylist(ctx, name, public, collaborative)
|
||||
}
|
||||
return Item{}, ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) AddTracks(ctx context.Context, playlistID string, uris []string) error {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.AddTracks(ctx, playlistID, uris)
|
||||
}
|
||||
return ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *AppleScriptClient) RemoveTracks(ctx context.Context, playlistID string, uris []string) error {
|
||||
if c.fallback != nil {
|
||||
return c.fallback.RemoveTracks(ctx, playlistID, uris)
|
||||
}
|
||||
return ErrUnsupported
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
//go:build !darwin
|
||||
// +build !darwin
|
||||
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
type AppleScriptClient struct{}
|
||||
|
||||
type AppleScriptOptions struct {
|
||||
Fallback API
|
||||
}
|
||||
|
||||
func NewAppleScriptClient(opts AppleScriptOptions) (API, error) {
|
||||
return nil, errors.New("applescript engine is only available on macOS")
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
//go:build !darwin
|
||||
|
||||
package spotify
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewAppleScriptClient_NonDarwin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, err := NewAppleScriptClient(AppleScriptOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if client != nil {
|
||||
t.Fatal("expected nil client")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 ""
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
358
internal/spotify/connect_mapping.go
Normal file
358
internal/spotify/connect_mapping.go
Normal 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
|
||||
}
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -42,3 +42,220 @@ type SpotifyMock struct {
|
||||
AddTracksFn func(context.Context, string, []string) error
|
||||
RemoveTracksFn func(context.Context, string, []string) error
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Search(ctx context.Context, kind, query string, limit, offset int) (spotify.SearchResult, error) {
|
||||
if m.SearchFn == nil {
|
||||
return spotify.SearchResult{}, ErrNotImplemented
|
||||
}
|
||||
return m.SearchFn(ctx, kind, query, limit, offset)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) GetTrack(ctx context.Context, id string) (spotify.Item, error) {
|
||||
if m.GetTrackFn == nil {
|
||||
return spotify.Item{}, ErrNotImplemented
|
||||
}
|
||||
return m.GetTrackFn(ctx, id)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) GetAlbum(ctx context.Context, id string) (spotify.Item, error) {
|
||||
if m.GetAlbumFn == nil {
|
||||
return spotify.Item{}, ErrNotImplemented
|
||||
}
|
||||
return m.GetAlbumFn(ctx, id)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) GetArtist(ctx context.Context, id string) (spotify.Item, error) {
|
||||
if m.GetArtistFn == nil {
|
||||
return spotify.Item{}, ErrNotImplemented
|
||||
}
|
||||
return m.GetArtistFn(ctx, id)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) GetPlaylist(ctx context.Context, id string) (spotify.Item, error) {
|
||||
if m.GetPlaylistFn == nil {
|
||||
return spotify.Item{}, ErrNotImplemented
|
||||
}
|
||||
return m.GetPlaylistFn(ctx, id)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) GetShow(ctx context.Context, id string) (spotify.Item, error) {
|
||||
if m.GetShowFn == nil {
|
||||
return spotify.Item{}, ErrNotImplemented
|
||||
}
|
||||
return m.GetShowFn(ctx, id)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) GetEpisode(ctx context.Context, id string) (spotify.Item, error) {
|
||||
if m.GetEpisodeFn == nil {
|
||||
return spotify.Item{}, ErrNotImplemented
|
||||
}
|
||||
return m.GetEpisodeFn(ctx, id)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) ArtistTopTracks(ctx context.Context, id string, limit int) ([]spotify.Item, error) {
|
||||
if m.ArtistTopTracksFn == nil {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
return m.ArtistTopTracksFn(ctx, id, limit)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Playback(ctx context.Context) (spotify.PlaybackStatus, error) {
|
||||
if m.PlaybackFn == nil {
|
||||
return spotify.PlaybackStatus{}, ErrNotImplemented
|
||||
}
|
||||
return m.PlaybackFn(ctx)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Play(ctx context.Context, uri string) error {
|
||||
if m.PlayFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.PlayFn(ctx, uri)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Pause(ctx context.Context) error {
|
||||
if m.PauseFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.PauseFn(ctx)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Next(ctx context.Context) error {
|
||||
if m.NextFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.NextFn(ctx)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Previous(ctx context.Context) error {
|
||||
if m.PreviousFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.PreviousFn(ctx)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Seek(ctx context.Context, positionMS int) error {
|
||||
if m.SeekFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.SeekFn(ctx, positionMS)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Volume(ctx context.Context, volume int) error {
|
||||
if m.VolumeFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.VolumeFn(ctx, volume)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Shuffle(ctx context.Context, enabled bool) error {
|
||||
if m.ShuffleFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.ShuffleFn(ctx, enabled)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Repeat(ctx context.Context, mode string) error {
|
||||
if m.RepeatFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.RepeatFn(ctx, mode)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Devices(ctx context.Context) ([]spotify.Device, error) {
|
||||
if m.DevicesFn == nil {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
return m.DevicesFn(ctx)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Transfer(ctx context.Context, deviceID string) error {
|
||||
if m.TransferFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.TransferFn(ctx, deviceID)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) QueueAdd(ctx context.Context, uri string) error {
|
||||
if m.QueueAddFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.QueueAddFn(ctx, uri)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Queue(ctx context.Context) (spotify.Queue, error) {
|
||||
if m.QueueFn == nil {
|
||||
return spotify.Queue{}, ErrNotImplemented
|
||||
}
|
||||
return m.QueueFn(ctx)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) LibraryTracks(ctx context.Context, limit, offset int) ([]spotify.Item, int, error) {
|
||||
if m.LibraryTracksFn == nil {
|
||||
return nil, 0, ErrNotImplemented
|
||||
}
|
||||
return m.LibraryTracksFn(ctx, limit, offset)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) LibraryAlbums(ctx context.Context, limit, offset int) ([]spotify.Item, int, error) {
|
||||
if m.LibraryAlbumsFn == nil {
|
||||
return nil, 0, ErrNotImplemented
|
||||
}
|
||||
return m.LibraryAlbumsFn(ctx, limit, offset)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) LibraryModify(ctx context.Context, path string, ids []string, method string) error {
|
||||
if m.LibraryModifyFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.LibraryModifyFn(ctx, path, ids, method)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) FollowArtists(ctx context.Context, ids []string, method string) error {
|
||||
if m.FollowArtistsFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.FollowArtistsFn(ctx, ids, method)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) FollowedArtists(ctx context.Context, limit int, after string) ([]spotify.Item, int, string, error) {
|
||||
if m.FollowedArtistsFn == nil {
|
||||
return nil, 0, "", ErrNotImplemented
|
||||
}
|
||||
return m.FollowedArtistsFn(ctx, limit, after)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) Playlists(ctx context.Context, limit, offset int) ([]spotify.Item, int, error) {
|
||||
if m.PlaylistsFn == nil {
|
||||
return nil, 0, ErrNotImplemented
|
||||
}
|
||||
return m.PlaylistsFn(ctx, limit, offset)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) PlaylistTracks(ctx context.Context, id string, limit, offset int) ([]spotify.Item, int, error) {
|
||||
if m.PlaylistTracksFn == nil {
|
||||
return nil, 0, ErrNotImplemented
|
||||
}
|
||||
return m.PlaylistTracksFn(ctx, id, limit, offset)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) CreatePlaylist(ctx context.Context, name string, public, collaborative bool) (spotify.Item, error) {
|
||||
if m.CreatePlaylistFn == nil {
|
||||
return spotify.Item{}, ErrNotImplemented
|
||||
}
|
||||
return m.CreatePlaylistFn(ctx, name, public, collaborative)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) AddTracks(ctx context.Context, playlistID string, uris []string) error {
|
||||
if m.AddTracksFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.AddTracksFn(ctx, playlistID, uris)
|
||||
}
|
||||
|
||||
func (m *SpotifyMock) RemoveTracks(ctx context.Context, playlistID string, uris []string) error {
|
||||
if m.RemoveTracksFn == nil {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
return m.RemoveTracksFn(ctx, playlistID, uris)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user