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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"name": "save-to-spotify",
"source": "./plugin",
"description": "Create polished audio episodes and save them to Spotify via the save-to-spotify CLI.",
"version": "0.1.5",
"version": "0.2.0",
"category": "audio",
"keywords": ["spotify", "audio", "podcast", "tts"]
}
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "save-to-spotify",
"version": "0.1.5",
"version": "0.2.0",
"description": "Create polished audio episodes with TTS narration, rich timelines, cover images, and save them to Spotify via the save-to-spotify CLI.",
"author": {
"name": "Spotify",
Expand Down
36 changes: 33 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@

A command-line tool for saving audio content to Spotify. Built for agents and automation, generate a daily briefing, language lesson, or meeting recap, then push it to Spotify where it's available alongside your other listening.

## What you can make

Point your agent at content you have, and get a finished episode on Spotify:

- **Morning briefing** — your calendar, tasks, and overnight repo activity as a 3-minute daily brief
- **Deep dive** — turn a topic, article, or PDF into a personalized audio explainer
- **Meeting recap** — drop in a transcript, get a 2-minute summary with action items
- **Travel guide** — a private audio itinerary for your next trip
- **News roundup** — a daily roundup from the newsletters, feeds, and sites you follow
- **Bedtime story** — a custom story using your kid's name and favorite setting

Describe what you want in plain English; the agent sources, scripts, narrates, and saves it.

## Making the audio

This CLI *saves* audio to Spotify — it does not generate speech itself. You bring a voice engine:

- **Kokoro** (recommended) — free, local, no API key, no limits (~340 MB)
- **OpenAI TTS** — high quality, needs `OPENAI_API_KEY`
- **ElevenLabs** — most natural, needs `ELEVENLABS_API_KEY`

No key? Kokoro is the default. Already have an OpenAI or ElevenLabs key? The agent detects it and offers to use it — no extra setup.

Run `save-to-spotify tts status` to see what's available, or `save-to-spotify tts add` to register any other engine.

## Quick Start

Prompt your agent to install:
Expand All @@ -28,7 +53,7 @@ Common options:

```bash
# Pin a specific version
curl -fsSL https://saveto.spotify.com/install.sh | bash -s -- --version 0.1.5
curl -fsSL https://saveto.spotify.com/install.sh | bash -s -- --version 0.2.0

# Custom install directory
curl -fsSL https://saveto.spotify.com/install.sh | bash -s -- --dir ~/.local/bin
Expand Down Expand Up @@ -69,12 +94,17 @@ If you can't run the install script:
1. Download `save-to-spotify-{os}-{arch}-v{version}.zip` and its matching `.sha256` from the [releases page](https://github.com/spotify/save-to-spotify/releases).
2. Verify integrity:
```bash
shasum -c save-to-spotify-darwin-arm64-v0.1.5.zip.sha256
shasum -c save-to-spotify-darwin-arm64-v0.2.0.zip.sha256
```
3. Unzip, you get the binary plus a `skills/save-to-spotify/` tree.
4. Move the binary to a directory on your `PATH` and `chmod +x` it.
4. Move the binary to a directory on your `PATH` and `chmod +x` it (not needed on Windows).
5. (Optional) Copy `skills/save-to-spotify/` into your agent's skill directory, e.g. `~/.claude/skills/save-to-spotify/` or `~/.cursor/skills/save-to-spotify/`.

### Windows notes

- Run the install script in **Git Bash** (ships with Git for Windows); it installs `save-to-spotify.exe` to `~/.local/bin` and offers a PATH fix.
- The Windows binaries are not code-signed yet: SmartScreen may block the first run. Unblock with right-click → Properties → Unblock, or `Unblock-File save-to-spotify.exe` in PowerShell.

### Build from source

Requires [Go](https://go.dev/dl/) 1.21+.
Expand Down
63 changes: 50 additions & 13 deletions auth/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,69 @@ import (
"fmt"
"os/exec"
"runtime"
"time"
)

// openerGraceWindow is how long a Linux opener is watched after launch:
// failures (missing handler/tool) surface within it, while a still-running
// process is assumed to be the browser and left alone.
const openerGraceWindow = time.Second

// launchAndWatch starts cmd and observes it for the grace window. A quick
// exit propagates the result (an opener failing fast returns its error); a
// process still running when the window closes is treated as success and
// reaped by the background Wait whenever it eventually exits, so no zombie
// is left behind.
func launchAndWatch(cmd *exec.Cmd, grace time.Duration) error {
if err := cmd.Start(); err != nil {
return err
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
return err
case <-time.After(grace):
return nil
}
}

// LinuxOpeners are the URL-opener binaries probed on Linux, in preference
// order. Shared with the browser-capability detection in cmd — keep the two
// behaviors in sync by keeping the list in one place.
var LinuxOpeners = []string{"xdg-open", "sensible-browser", "x-www-browser", "gnome-open"}

// OpenBrowser attempts to open a URL in the default browser.
// Returns an error if it can't (e.g., headless server).
//
// This function launches unconditionally — it does not re-check for a
// display session. Callers on flows where headlessness matters are expected
// to gate on the capability detection in cmd (isHeadless/canOpenBrowser)
// first, or to handle the returned error with a manual fallback.
func OpenBrowser(url string) error {
var cmd *exec.Cmd

switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
// `open` is a dispatcher that exits promptly — Run (not Start)
// makes a launch failure (e.g. no WindowServer under launchd)
// observable to the caller instead of a silent 5-minute callback
// wait.
return exec.Command("open", url).Run()
case "linux":
// Try xdg-open first, fall back to common browsers
for _, opener := range []string{"xdg-open", "sensible-browser", "x-www-browser", "gnome-open"} {
// Try xdg-open first, fall back to common browsers. These can't
// simply Run: some openers exec the browser directly and only
// return when it exits. But a plain Start would discard quick
// failures (xdg-open exits 3/4 when no handler or tool exists) —
// watch the process briefly instead.
for _, opener := range LinuxOpeners {
if path, err := exec.LookPath(opener); err == nil {
cmd = exec.Command(path, url)
break
return launchAndWatch(exec.Command(path, url), openerGraceWindow)
}
}
if cmd == nil {
return fmt.Errorf("no browser found — use --no-browser flag for manual auth")
}
return fmt.Errorf("no browser found — use --no-browser flag for manual auth")
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
// rundll32's FileProtocolHandler dispatches and exits promptly.
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Run()
default:
return fmt.Errorf("unsupported platform %s — use --no-browser flag", runtime.GOOS)
}

return cmd.Start()
}
39 changes: 39 additions & 0 deletions auth/browser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package auth

import (
"os/exec"
"runtime"
"testing"
"time"
)

func TestLaunchAndWatch(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test commands use sh")
}

t.Run("fast failure is propagated", func(t *testing.T) {
// Mirrors xdg-open exiting 3/4 when no handler or tool exists.
err := launchAndWatch(exec.Command("sh", "-c", "exit 3"), time.Second)
if err == nil {
t.Fatal("expected the opener's non-zero exit to surface as an error")
}
})

t.Run("fast clean exit succeeds", func(t *testing.T) {
if err := launchAndWatch(exec.Command("sh", "-c", "exit 0"), time.Second); err != nil {
t.Fatalf("clean exit should succeed, got %v", err)
}
})

t.Run("long-running process is assumed to be the browser", func(t *testing.T) {
start := time.Now()
err := launchAndWatch(exec.Command("sleep", "5"), 100*time.Millisecond)
if err != nil {
t.Fatalf("still-running process should count as success, got %v", err)
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Fatalf("returned after %v — should return at the grace window, not block on the process", elapsed)
}
})
}
5 changes: 5 additions & 0 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ Flags:
}
}

if !noBrowser && isHeadless() {
info("Detected headless environment — using manual auth flow.\n")
noBrowser = true
}

verifier, err := auth.GenerateCodeVerifier()
if err != nil {
return fmt.Errorf("failed to generate code verifier: %w", err)
Expand Down
Loading