Skip to content

Commit 196aa28

Browse files
program247365claude
andcommitted
feat(spotify): play single Spotify tracks via librespot
Add a Spotify playback path for Premium accounts. Spotify exposes no downloadable audio, so unlike the yt-dlp-backed services this uses librespot to authenticate as a Connect device and decode the DRM Ogg/Vorbis stream in-process. - `looper spotify login` runs the OAuth browser flow once and caches reusable credentials. - A new `PlaybackInput::Spotify` variant carries a `spotify:track:` URI. `plugin::resolve_url` intercepts Spotify links before the yt-dlp check so it works without yt-dlp installed. - The Sink->Source bridge (src/spotify/sink.rs) connects librespot's push model to rodio's pull model via a bounded channel: the sink blocks under backpressure (throttling the decoder to real time) and the source yields silence on underrun rather than ending, since track-end is driven by PlayerEvent::EndOfTrack. - AudioPlayer gains a Spotify branch that keeps the librespot Player alive and self-loops the track on end-of-track. play_loop is unchanged; the existing wall-clock loop counter handles it. Pin vergen to 9.0.6: librespot-core's build script pulls vergen-gitcl 1.0.8 (needs vergen ^9.0.6), but vergen 9.1.0 bumps its internal vergen-lib to 9.x and collides with vergen-gitcl's vergen-lib 0.1.6. Single tracks only for now; playlists/albums return a clear error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ca69d11 commit 196aa28

9 files changed

Lines changed: 1650 additions & 26 deletions

File tree

Cargo.lock

Lines changed: 1158 additions & 26 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ rodio = { version = "0.22", default-features = false, features = ["playback", "m
2929
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
3030
structopt = "0.3.21"
3131
tokio = { version = "1", features = ["rt-multi-thread"] }
32+
librespot-core = "0.8"
33+
librespot-metadata = "0.8"
34+
librespot-oauth = "0.8"
35+
librespot-playback = { version = "0.8", default-features = false }
3236

3337
[target.'cfg(target_os = "macos")'.dependencies]
3438
cocoa = "0.24"

src/audio.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ pub struct AudioPlayer {
9191
pub channels: u16,
9292
input: PlaybackInput,
9393
refillable: bool,
94+
// Keeps librespot playback alive for Spotify sources; dropping it stops the
95+
// track and its end-of-track loop listener. `None` for all other inputs.
96+
_spotify: Option<crate::spotify::SpotifyPlayback>,
9497
}
9598

9699
trait MediaReader: Read + Seek + Send + Sync {}
@@ -107,6 +110,31 @@ impl AudioPlayer {
107110
device.log_on_drop(false);
108111
let sink = Player::connect_new(device.mixer());
109112

113+
// Spotify can't be read as a file or byte stream: librespot decodes it
114+
// in-process and pushes PCM through a bridge `Source`. Wire that up and
115+
// return early — the file/stream decode path below doesn't apply.
116+
if let PlaybackInput::Spotify { track_uri } = &input {
117+
let (source, playback, sample_rate, channels, duration) =
118+
crate::spotify::open_playback(track_uri, repeat)?;
119+
let buf = Arc::new(Mutex::new(VecDeque::with_capacity(BUF_CAP)));
120+
sink.append(SampleTap {
121+
inner: source,
122+
buf: buf.clone(),
123+
});
124+
return Ok(Self {
125+
_device: device,
126+
_download_runtime: None,
127+
sink,
128+
duration,
129+
sample_buf: buf,
130+
sample_rate,
131+
channels,
132+
input,
133+
refillable: false,
134+
_spotify: Some(playback),
135+
});
136+
}
137+
110138
let (reader, duration, runtime, byte_len) = open_input(&input)?;
111139
let source = decode_input(reader, byte_len)?;
112140

@@ -144,6 +172,7 @@ impl AudioPlayer {
144172
channels,
145173
input,
146174
refillable,
175+
_spotify: None,
147176
})
148177
}
149178

@@ -275,6 +304,11 @@ fn open_input(
275304
.wrap_err("failed to open process audio stream")?;
276305
Ok((Box::new(reader), None, Some(runtime), None))
277306
}
307+
// Spotify is wired up earlier in `AudioPlayer::new` and never reaches
308+
// the file/stream reader path.
309+
PlaybackInput::Spotify { .. } => {
310+
unreachable!("Spotify inputs are handled before open_input")
311+
}
278312
}
279313
}
280314

src/main.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod play_loop;
1111
mod playback_input;
1212
mod plugin;
1313
mod schema;
14+
mod spotify;
1415
mod startup_logo;
1516
mod storage;
1617
mod tui;
@@ -112,6 +113,20 @@ EXAMPLES:
112113
#[structopt(subcommand)]
113114
cmd: ConfigCmd,
114115
},
116+
/// Manage Spotify access (Premium account required)
117+
Spotify {
118+
#[structopt(subcommand)]
119+
cmd: SpotifyCmd,
120+
},
121+
}
122+
123+
#[derive(StructOpt, Debug)]
124+
enum SpotifyCmd {
125+
/// Authorize looper with your Spotify account via your browser.
126+
///
127+
/// Run this once. Credentials are cached so future playback needs no
128+
/// re-login. Requires a Spotify Premium subscription.
129+
Login,
115130
}
116131

117132
#[derive(StructOpt, Debug)]
@@ -167,6 +182,7 @@ fn run_app(opt: Opt) -> Result<()> {
167182
let result = match opt.cmd {
168183
Some(Command::Play { url }) => play_file(&url, ctx),
169184
Some(Command::Config { cmd }) => cmd_config(cmd),
185+
Some(Command::Spotify { cmd }) => cmd_spotify(cmd),
170186
None => browse_history(ctx),
171187
};
172188
match result {
@@ -205,10 +221,17 @@ fn run_app(opt: Opt) -> Result<()> {
205221
match opt.cmd {
206222
Some(Command::Play { url }) => play_file(&url, ctx),
207223
Some(Command::Config { cmd }) => cmd_config(cmd),
224+
Some(Command::Spotify { cmd }) => cmd_spotify(cmd),
208225
None => browse_history(ctx),
209226
}
210227
}
211228

229+
fn cmd_spotify(cmd: SpotifyCmd) -> Result<()> {
230+
match cmd {
231+
SpotifyCmd::Login => spotify::login(),
232+
}
233+
}
234+
212235
fn cmd_config(cmd: ConfigCmd) -> Result<()> {
213236
match cmd {
214237
ConfigCmd::Set { key: ConfigKey::SyncFolder { path } } => {

src/playback_input.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ pub enum PlaybackInput {
1313
args: Vec<String>,
1414
format_hint: ProcessFormat,
1515
},
16+
/// A Spotify track, decoded in-process by librespot. The string is a
17+
/// canonical Spotify track URI (`spotify:track:<base62>`). Unlike the
18+
/// other variants there is no file or byte stream — `AudioPlayer` hands
19+
/// this to the librespot bridge, which pushes decoded PCM into a rodio
20+
/// `Source`.
21+
Spotify {
22+
track_uri: String,
23+
},
1624
}
1725

1826
#[derive(Clone, Debug)]
@@ -41,4 +49,10 @@ impl PlaybackInput {
4149
format_hint,
4250
}
4351
}
52+
53+
pub fn spotify(track_uri: impl Into<String>) -> Self {
54+
Self::Spotify {
55+
track_uri: track_uri.into(),
56+
}
57+
}
4458
}

src/plugin/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ pub trait ServicePlugin {
3333
}
3434

3535
pub fn resolve_url(url: &str) -> Result<Option<Vec<TrackInfo>>> {
36+
// Spotify is handled by librespot, not yt-dlp, and uses `spotify:` URIs that
37+
// aren't http(s). Intercept before the remote-URL and yt-dlp checks below.
38+
if crate::spotify::is_spotify_url(url) {
39+
return crate::spotify::resolve(url).map(Some);
40+
}
41+
3642
if !is_remote_url(url) {
3743
return Ok(None);
3844
}

0 commit comments

Comments
 (0)