Skip to content

Commit 776fc1a

Browse files
program247365claude
andcommitted
feat(spotify): playlists, albums, album art, and collection name
Extend Spotify support beyond single tracks: - Resolve playlists and albums to their full tracklist, fetching track metadata (and album art) in bounded concurrent batches so large playlists resolve quickly. Order is preserved; tracks that fail to fetch are skipped. - Playlist/album tracks play through once then loop the collection. The bridge gains an end-of-track signal: in single-track mode the source loops forever, in playlist mode it ends after draining so play_loop's sink.empty() check advances to the next track. - Skip Spotify tracks in the prefetcher. They stream in-process via librespot and have no source_url to download, so the yt-dlp prefetch path was erroring on the "spotify:" scheme for upcoming playlist tracks. - Show the playlist/album name in the playback header next to the track counter, via a new optional TrackInfo.collection field plumbed into AppState. - Download album cover art from Spotify's public image CDN (i.scdn.co) into a disk cache and surface it through the existing thumbnail_path, so covers render in the visualizer like YouTube. Covers are keyed by file ID, so an album's tracks reuse one file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 196aa28 commit 776fc1a

6 files changed

Lines changed: 242 additions & 45 deletions

File tree

src/play_loop.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ fn play_file_session(
233233
service: None,
234234
thumbnail_path: None,
235235
is_live: false,
236+
collection: None,
236237
}],
237238
false,
238239
ctx,
@@ -1183,6 +1184,7 @@ fn play_single_track(
11831184
track_index,
11841185
total_tracks,
11851186
is_playlist,
1187+
collection: track.collection.clone(),
11861188
loop_start: Instant::now(),
11871189
pause_elapsed: Duration::default(),
11881190
bands: vec![0.0; N_BANDS],
@@ -1261,7 +1263,12 @@ impl PrefetchWorker {
12611263
return;
12621264
}
12631265
let track = tracks[idx].clone();
1264-
if track.pending_download.is_none() && matches!(track.playback, PlaybackInput::File(_))
1266+
// Spotify streams in-process via librespot, and local files are
1267+
// already on disk — neither is prefetchable. Only remote
1268+
// download-backed tracks go through the prefetcher.
1269+
if matches!(track.playback, PlaybackInput::Spotify { .. })
1270+
|| (track.pending_download.is_none()
1271+
&& matches!(track.playback, PlaybackInput::File(_)))
12651272
{
12661273
return;
12671274
}
@@ -1609,6 +1616,7 @@ mod tests {
16091616
track_index: 1,
16101617
total_tracks: 1,
16111618
is_playlist: false,
1619+
collection: None,
16121620
loop_start: Instant::now(),
16131621
pause_elapsed: Duration::default(),
16141622
bands: vec![0.0; N_BANDS],

src/plugin/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ pub struct TrackInfo {
2424
pub service: Option<String>,
2525
pub thumbnail_path: Option<PathBuf>,
2626
pub is_live: bool,
27+
/// Name of the playlist or album this track was resolved from, if any.
28+
/// Shown in the playback header so you know what collection is playing.
29+
pub collection: Option<String>,
2730
}
2831

2932
pub trait ServicePlugin {

src/plugin/ytdlp.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ pub fn cached_track_from_entry(entry: MetadataEntry, cache_dir: &Path, service:
9191
service: Some(service.to_string()),
9292
thumbnail_path,
9393
is_live: false,
94+
collection: None,
9495
}
9596
}
9697

@@ -105,6 +106,7 @@ pub fn streaming_track_from_entry(entry: MetadataEntry, service: &str) -> Result
105106
service: Some(service.to_string()),
106107
thumbnail_path: None,
107108
is_live: matches!(entry.live_status, LiveStatus::IsLive),
109+
collection: None,
108110
})
109111
}
110112

@@ -129,6 +131,7 @@ pub fn resolve_streaming_tracks(url: &str) -> Result<Vec<TrackInfo>> {
129131
service: Some(service),
130132
thumbnail_path: None,
131133
is_live,
134+
collection: None,
132135
})
133136
})
134137
.collect()

src/spotify/mod.rs

Lines changed: 184 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,19 @@
1212
1313
mod sink;
1414

15+
use std::path::{Path, PathBuf};
1516
use std::sync::{Arc, OnceLock};
1617
use std::time::Duration;
1718

1819
use color_eyre::eyre::{eyre, Result, WrapErr};
20+
use stream_download::http::reqwest::Client as HttpClient;
1921

2022
use librespot_core::authentication::Credentials;
2123
use librespot_core::cache::Cache;
2224
use librespot_core::config::SessionConfig;
2325
use librespot_core::session::Session;
2426
use librespot_core::spotify_uri::SpotifyUri;
25-
use librespot_metadata::{Metadata, Track};
27+
use librespot_metadata::{Album, Metadata, Playlist, Track};
2628
use librespot_oauth::OAuthClientBuilder;
2729
use librespot_playback::config::PlayerConfig;
2830
use librespot_playback::mixer::NoOpVolume;
@@ -79,34 +81,169 @@ pub fn login() -> Result<()> {
7981
Ok(())
8082
}
8183

82-
/// Resolve a Spotify URL/URI to playable tracks. Thin slice: single tracks only.
84+
/// Resolve a Spotify URL/URI to playable tracks: a single track, or every
85+
/// track of a playlist or album.
8386
pub fn resolve(url: &str) -> Result<Vec<TrackInfo>> {
8487
let uri = parse_uri(url)?;
88+
let ctx = ctx()?;
8589
match &uri {
8690
SpotifyUri::Track { .. } => {
87-
let ctx = ctx()?;
88-
let track = ctx
89-
.runtime
90-
.block_on(Track::get(&ctx.session, &uri))
91-
.map_err(|e| eyre!("failed to fetch Spotify track metadata: {e}"))?;
92-
let track_uri = uri
93-
.to_uri()
94-
.map_err(|e| eyre!("failed to build Spotify track URI: {e}"))?;
95-
Ok(vec![TrackInfo {
96-
title: track.name,
97-
duration_secs: Some((track.duration.max(0) as f64) / 1000.0),
98-
playback: PlaybackInput::spotify(track_uri),
99-
source_url: Some(url.to_string()),
100-
pending_download: None,
101-
service: Some("Spotify".to_string()),
102-
thumbnail_path: None,
103-
is_live: false,
104-
}])
91+
let info = ctx.runtime.block_on(async {
92+
let track = Track::get(&ctx.session, &uri)
93+
.await
94+
.map_err(|e| eyre!("failed to fetch Spotify track metadata: {e}"))?;
95+
let track_uri = uri
96+
.to_uri()
97+
.map_err(|e| eyre!("failed to build Spotify track URI: {e}"))?;
98+
let thumbnail = download_cover(&HttpClient::new(), &track, &art_dir()?, 0).await;
99+
Ok::<_, color_eyre::eyre::Report>(track_info(track, track_uri, None, thumbnail))
100+
})?;
101+
Ok(vec![info])
102+
}
103+
SpotifyUri::Playlist { .. } => {
104+
let tracks = ctx.runtime.block_on(async {
105+
let playlist = Playlist::get(&ctx.session, &uri)
106+
.await
107+
.map_err(|e| eyre!("failed to fetch Spotify playlist: {e}"))?;
108+
let name = Some(playlist.name().to_string());
109+
let uris: Vec<SpotifyUri> = playlist.tracks().cloned().collect();
110+
fetch_track_infos(&ctx.session, uris, name).await
111+
})?;
112+
ensure_nonempty(tracks, "playlist")
113+
}
114+
SpotifyUri::Album { .. } => {
115+
let tracks = ctx.runtime.block_on(async {
116+
let album = Album::get(&ctx.session, &uri)
117+
.await
118+
.map_err(|e| eyre!("failed to fetch Spotify album: {e}"))?;
119+
let name = Some(album.name.clone());
120+
let uris: Vec<SpotifyUri> = album.tracks().cloned().collect();
121+
fetch_track_infos(&ctx.session, uris, name).await
122+
})?;
123+
ensure_nonempty(tracks, "album")
105124
}
106-
SpotifyUri::Playlist { .. } | SpotifyUri::Album { .. } => Err(eyre!(
107-
"Spotify playlists and albums aren't supported yet — pass a track URL for now"
125+
_ => Err(eyre!(
126+
"unsupported Spotify link — pass a track, playlist, or album URL"
108127
)),
109-
_ => Err(eyre!("unsupported Spotify link — pass a track URL")),
128+
}
129+
}
130+
131+
/// Build a `TrackInfo` from track metadata and its canonical URI. The URI is
132+
/// both the playback target and the history identity (it's a valid replay URL).
133+
/// `collection` is the playlist/album name this track came from, if any;
134+
/// `thumbnail_path` is its downloaded album cover, if any.
135+
fn track_info(
136+
track: Track,
137+
track_uri: String,
138+
collection: Option<String>,
139+
thumbnail_path: Option<PathBuf>,
140+
) -> TrackInfo {
141+
TrackInfo {
142+
title: track.name,
143+
duration_secs: Some((track.duration.max(0) as f64) / 1000.0),
144+
playback: PlaybackInput::spotify(track_uri.clone()),
145+
source_url: Some(track_uri),
146+
pending_download: None,
147+
service: Some("Spotify".to_string()),
148+
thumbnail_path,
149+
is_live: false,
150+
collection,
151+
}
152+
}
153+
154+
/// Fetch metadata (and album art) for many track URIs, preserving order. Runs
155+
/// in bounded concurrent batches so a large playlist resolves quickly without
156+
/// firing hundreds of simultaneous requests. Tracks that fail to fetch are
157+
/// dropped. `collection` (the playlist/album name) is stamped onto every track.
158+
async fn fetch_track_infos(
159+
session: &Session,
160+
uris: Vec<SpotifyUri>,
161+
collection: Option<String>,
162+
) -> Result<Vec<TrackInfo>> {
163+
const CONCURRENCY: usize = 16;
164+
let client = HttpClient::new();
165+
let art = art_dir()?;
166+
let mut infos: Vec<Option<TrackInfo>> = (0..uris.len()).map(|_| None).collect();
167+
let mut base = 0;
168+
for chunk in uris.chunks(CONCURRENCY) {
169+
let mut set = tokio::task::JoinSet::new();
170+
for (offset, uri) in chunk.iter().enumerate() {
171+
let session = session.clone();
172+
let client = client.clone();
173+
let art = art.clone();
174+
let collection = collection.clone();
175+
let uri = uri.clone();
176+
let position = base + offset;
177+
set.spawn(async move {
178+
let info = match Track::get(&session, &uri).await {
179+
Ok(track) => match uri.to_uri() {
180+
Ok(track_uri) => {
181+
let thumbnail = download_cover(&client, &track, &art, position).await;
182+
Some(track_info(track, track_uri, collection, thumbnail))
183+
}
184+
Err(_) => None,
185+
},
186+
Err(_) => None,
187+
};
188+
(position, info)
189+
});
190+
}
191+
while let Some(joined) = set.join_next().await {
192+
if let Ok((position, Some(info))) = joined {
193+
infos[position] = Some(info);
194+
}
195+
}
196+
base += chunk.len();
197+
}
198+
Ok(infos.into_iter().flatten().collect())
199+
}
200+
201+
/// Download a track's album cover (the largest size) from Spotify's public
202+
/// image CDN into the art cache, returning its path. Covers are keyed by file
203+
/// ID, so tracks sharing an album reuse one cached file. Best-effort: any
204+
/// failure yields `None` (art is optional).
205+
async fn download_cover(
206+
client: &HttpClient,
207+
track: &Track,
208+
art_dir: &Path,
209+
temp_tag: usize,
210+
) -> Option<PathBuf> {
211+
let cover = track.album.covers.iter().max_by_key(|image| image.width)?;
212+
let hex = cover.id.to_base16().ok()?;
213+
let target = art_dir.join(format!("{hex}.jpg"));
214+
if target.exists() {
215+
return Some(target);
216+
}
217+
let url = format!("https://i.scdn.co/image/{hex}");
218+
let bytes = client
219+
.get(url)
220+
.send()
221+
.await
222+
.ok()?
223+
.error_for_status()
224+
.ok()?
225+
.bytes()
226+
.await
227+
.ok()?;
228+
// Write to a per-task temp file then atomically rename, so concurrent
229+
// downloads of the same shared album cover can't corrupt the target.
230+
let temp = art_dir.join(format!("{hex}.{temp_tag}.tmp"));
231+
std::fs::write(&temp, &bytes).ok()?;
232+
std::fs::rename(&temp, &target).ok()?;
233+
Some(target)
234+
}
235+
236+
fn art_dir() -> Result<PathBuf> {
237+
let dir = crate::plugin::cache_dir_path()?.join("spotify").join("art");
238+
std::fs::create_dir_all(&dir).wrap_err("failed to create Spotify art cache directory")?;
239+
Ok(dir)
240+
}
241+
242+
fn ensure_nonempty(tracks: Vec<TrackInfo>, kind: &str) -> Result<Vec<TrackInfo>> {
243+
if tracks.is_empty() {
244+
Err(eyre!("Spotify {kind} has no playable tracks"))
245+
} else {
246+
Ok(tracks)
110247
}
111248
}
112249

@@ -133,7 +270,7 @@ pub fn open_playback(
133270
let ctx = ctx()?;
134271
let uri =
135272
SpotifyUri::from_uri(track_uri).map_err(|e| eyre!("invalid Spotify track URI: {e}"))?;
136-
let (spotify_sink, source) = sink::bridge();
273+
let (spotify_sink, source, end_signal) = sink::bridge();
137274

138275
let (player, duration, listener) = ctx.runtime.block_on(async move {
139276
let duration = match Track::get(&ctx.session, &uri).await {
@@ -151,15 +288,22 @@ pub fn open_playback(
151288
let mut events = player.get_player_event_channel();
152289
player.load(uri.clone(), true, 0);
153290

154-
// Re-load the same track each time it ends to loop it. The bridge
155-
// emits silence during the brief reload gap, so audio never stops.
291+
// On end-of-track: in single-track mode (`repeat`) re-load the same
292+
// track to loop it — the bridge emits silence during the brief reload
293+
// gap so audio never stops. In playlist mode, signal the source to end
294+
// so the sink empties and play_loop advances to the next track.
156295
let listener = {
157296
let player = player.clone();
158297
let loop_uri = uri.clone();
159298
tokio::spawn(async move {
160299
while let Some(event) = events.recv().await {
161-
if repeat && matches!(event, PlayerEvent::EndOfTrack { .. }) {
162-
player.load(loop_uri.clone(), true, 0);
300+
if matches!(event, PlayerEvent::EndOfTrack { .. }) {
301+
if repeat {
302+
player.load(loop_uri.clone(), true, 0);
303+
} else {
304+
end_signal.finish();
305+
break;
306+
}
163307
}
164308
}
165309
})
@@ -283,4 +427,16 @@ mod tests {
283427
parse_uri("https://open.spotify.com/intl-de/track/4uLU6hMCjMI75M1A2tKUQC").unwrap();
284428
assert_eq!(uri.to_uri().unwrap(), "spotify:track:4uLU6hMCjMI75M1A2tKUQC");
285429
}
430+
431+
#[test]
432+
fn parses_playlist_and_album_urls() {
433+
assert!(matches!(
434+
parse_uri("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M").unwrap(),
435+
SpotifyUri::Playlist { .. }
436+
));
437+
assert!(matches!(
438+
parse_uri("https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy?si=x").unwrap(),
439+
SpotifyUri::Album { .. }
440+
));
441+
}
286442
}

0 commit comments

Comments
 (0)