1212
1313mod sink;
1414
15+ use std:: path:: { Path , PathBuf } ;
1516use std:: sync:: { Arc , OnceLock } ;
1617use std:: time:: Duration ;
1718
1819use color_eyre:: eyre:: { eyre, Result , WrapErr } ;
20+ use stream_download:: http:: reqwest:: Client as HttpClient ;
1921
2022use librespot_core:: authentication:: Credentials ;
2123use librespot_core:: cache:: Cache ;
2224use librespot_core:: config:: SessionConfig ;
2325use librespot_core:: session:: Session ;
2426use librespot_core:: spotify_uri:: SpotifyUri ;
25- use librespot_metadata:: { Metadata , Track } ;
27+ use librespot_metadata:: { Album , Metadata , Playlist , Track } ;
2628use librespot_oauth:: OAuthClientBuilder ;
2729use librespot_playback:: config:: PlayerConfig ;
2830use 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.
8386pub 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