bump up librespot version to 0.6.0, add support for year metadata, refactor cover image fetching, make username optional and take latest username automatically from credentials cache

This commit is contained in:
Erik Kaju 2025-06-20 16:46:00 +03:00
parent 54cb7f6433
commit 041a1cf54e
8 changed files with 79 additions and 63 deletions

View file

@ -10,10 +10,11 @@ repository = "https://github.com/GuillemCastro/spotify-dl"
description = "A command-line utility to download songs and playlists from Spotify" description = "A command-line utility to download songs and playlists from Spotify"
[dependencies] [dependencies]
bytes = "1"
structopt = { version = "0.3", default-features = false } structopt = { version = "0.3", default-features = false }
rpassword = "7.0" rpassword = "7.0"
indicatif = "0.17" indicatif = "0.17"
librespot = { version = "0.4.2", default-features = false } librespot = { version = "0.6.0", default-features = false }
tokio = { version = "1", features = ["full", "tracing"] } tokio = { version = "1", features = ["full", "tracing"] }
flacenc = { version = "0.4" } flacenc = { version = "0.4" }
audiotags = "0.5" audiotags = "0.5"
@ -31,6 +32,7 @@ rayon = "1.10"
hex = "0.4" hex = "0.4"
reqwest = { version = "0.11", features = ["blocking", "json"] } reqwest = { version = "0.11", features = ["blocking", "json"] }
id3 = "0.6" id3 = "0.6"
serde_json = "1.0.117"
[features] [features]
default = ["mp3"] default = ["mp3"]

View file

@ -2,6 +2,7 @@ use std::fmt::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use bytes::Bytes;
use anyhow::Result; use anyhow::Result;
use futures::StreamExt; use futures::StreamExt;
use futures::TryStreamExt; use futures::TryStreamExt;
@ -97,7 +98,7 @@ impl Downloader {
let file_size = sink.get_approximate_size(); let file_size = sink.get_approximate_size();
let (mut player, _) = Player::new( let player = Player::new(
self.player_config.clone(), self.player_config.clone(),
self.session.clone(), self.session.clone(),
self.volume_getter(), self.volume_getter(),
@ -134,12 +135,16 @@ impl Downloader {
} }
} }
tracing::info!("Fetching album cover image: {:?}", file_name);
let cover_image = self.get_cover_image(&metadata).await?;
tracing::info!("Encoding and writing track: {:?}", file_name); tracing::info!("Encoding and writing track: {:?}", file_name);
pb.set_message(format!("Encoding and writing {}", &file_name)); pb.set_message(format!("Encoding and writing {}", &file_name));
let samples = Samples::new(samples, 44100, 2, 16); let samples = Samples::new(samples, 44100, 2, 16);
let encoder = crate::encoder::get_encoder(options.format); let encoder = crate::encoder::get_encoder(options.format);
let output_path = &path; let output_path = &path;
encoder.encode(&samples, &metadata, output_path).await?;
encoder.encode(&samples, &metadata, cover_image, output_path).await?;
pb.finish_with_message(format!("Downloaded {}", &file_name)); pb.finish_with_message(format!("Downloaded {}", &file_name));
Ok(()) Ok(())
@ -188,4 +193,16 @@ impl Downloader {
} }
clean clean
} }
async fn get_cover_image(&self, metadata: &TrackMetadata) -> Result<Bytes>{
match metadata.album.cover {
Some(ref cover) => {
self.session.spclient()
.get_image(&cover.id)
.await
.map_err(|e| anyhow::anyhow!("{:?}", e))
}
None => Err(anyhow::anyhow!("No cover art!"))
}
}
} }

View file

@ -1,6 +1,7 @@
use flacenc::component::BitRepr; use flacenc::component::BitRepr;
use flacenc::error::Verify; use flacenc::error::Verify;
use bytes::Bytes;
use super::Encoder; use super::Encoder;
use super::Samples; use super::Samples;
@ -9,7 +10,12 @@ pub struct FlacEncoder;
#[async_trait::async_trait] #[async_trait::async_trait]
impl Encoder for FlacEncoder { impl Encoder for FlacEncoder {
async fn encode(&self, samples: &Samples, metadata: &crate::track::TrackMetadata, output_path: &str) -> anyhow::Result<()> { async fn encode(&self, samples: &Samples, metadata: &crate::track::TrackMetadata, cover_image_bytes: Bytes, output_path: &str) -> anyhow::Result<()> {
if !cover_image_bytes.is_empty() {
tracing::info!("Cover image found but not implemented in flac encoder");
}
let file_name = &metadata.track_name; let file_name = &metadata.track_name;
tracing::info!("Writing track: {:?} to file: {}", file_name, output_path); tracing::info!("Writing track: {:?} to file: {}", file_name, output_path);
let source = flacenc::source::MemSource::from_samples( let source = flacenc::source::MemSource::from_samples(

View file

@ -2,8 +2,8 @@ mod flac;
#[cfg(feature = "mp3")] #[cfg(feature = "mp3")]
mod mp3; mod mp3;
use bytes::Bytes;
use std::{path::Path, str::FromStr}; use std::{path::Path, str::FromStr};
use anyhow::Result; use anyhow::Result;
use tokio::sync::oneshot::Sender; use tokio::sync::oneshot::Sender;
@ -55,7 +55,7 @@ pub fn get_encoder(format: Format) -> &'static dyn Encoder {
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait Encoder: Sync { pub trait Encoder: Sync {
async fn encode(&self, samples: &Samples, metadata: &crate::track::TrackMetadata, output_path: &str) -> Result<()>; async fn encode(&self, samples: &Samples, metadata: &crate::track::TrackMetadata, cover_image_bytes: Bytes, output_path: &str) -> Result<()>;
} }
pub struct Samples { pub struct Samples {

View file

@ -4,6 +4,7 @@ use mp3lame_encoder::Builder;
use mp3lame_encoder::FlushNoGap; use mp3lame_encoder::FlushNoGap;
use mp3lame_encoder::InterleavedPcm; use mp3lame_encoder::InterleavedPcm;
use id3::{Version, frame::{Picture, PictureType, Frame, Content}}; use id3::{Version, frame::{Picture, PictureType, Frame, Content}};
use bytes::Bytes;
use super::execute_with_result; use super::execute_with_result;
use super::EncodedStream; use super::EncodedStream;
@ -40,7 +41,7 @@ impl Mp3Encoder {
#[async_trait::async_trait] #[async_trait::async_trait]
impl Encoder for Mp3Encoder { impl Encoder for Mp3Encoder {
async fn encode(&self, samples: &Samples, metadata: &crate::track::TrackMetadata, output_path: &str) -> anyhow::Result<()> { async fn encode(&self, samples: &Samples, metadata: &crate::track::TrackMetadata, cover_image_bytes: Bytes, output_path: &str) -> anyhow::Result<()> {
let file_name = &metadata.track_name; let file_name = &metadata.track_name;
tracing::info!("Writing track: {:?} to file: {}", file_name, output_path); tracing::info!("Writing track: {:?} to file: {}", file_name, output_path);
let stream = self.encode_raw(samples).await?; let stream = self.encode_raw(samples).await?;
@ -49,21 +50,25 @@ impl Encoder for Mp3Encoder {
// Embed tags using id3 crate // Embed tags using id3 crate
let mut tag = id3::Tag::read_from_path(output_path).unwrap_or_else(|_| id3::Tag::new()); let mut tag = id3::Tag::read_from_path(output_path).unwrap_or_else(|_| id3::Tag::new());
tag.set_title(file_name); tag.set_title(file_name);
tag.set_album(&metadata.album.name);
let artists = metadata.artists.iter().map(|a| a.name.as_str()).collect::<Vec<_>>().join("\0"); let artists = metadata.artists.iter().map(|a| a.name.as_str()).collect::<Vec<_>>().join("\0");
tag.set_artist(&artists); tag.set_artist(&artists);
tag.set_album(&metadata.album.name);
tag.add_frame(Frame::with_content("TDRC", Content::Text(metadata.album.year.to_string())));
// Embed cover image // Embed cover image
if let Some(image_bytes) = metadata.cover_image.as_deref() { if !cover_image_bytes.is_empty() {
let picture = Picture { let picture = Picture {
mime_type: "image/jpeg".to_string(), mime_type: "image/jpeg".to_string(),
picture_type: PictureType::CoverFront, picture_type: PictureType::CoverFront,
description: "cover".to_string(), description: "cover".to_string(),
data: image_bytes.to_vec(), data: cover_image_bytes.to_vec(),
}; };
let frame = Frame::with_content("APIC", Content::Picture(picture)); tag.add_frame(Frame::with_content("APIC", Content::Picture(picture)));
tag.add_frame(frame);
} }
tag.write_to_path(output_path, Version::Id3v24)?; tag.write_to_path(output_path, Version::Id3v24)?;
Ok(()) Ok(())
} }

View file

@ -19,7 +19,7 @@ struct Opt {
)] )]
tracks: Vec<String>, tracks: Vec<String>,
#[structopt(short = "u", long = "username", help = "Your Spotify username")] #[structopt(short = "u", long = "username", help = "Your Spotify username")]
username: String, username: Option<String>,
#[structopt(short = "p", long = "password", help = "Your Spotify password")] #[structopt(short = "p", long = "password", help = "Your Spotify password")]
password: Option<String>, password: Option<String>,
#[structopt( #[structopt(
@ -45,8 +45,8 @@ struct Opt {
#[structopt( #[structopt(
short = "f", short = "f",
long = "format", long = "format",
help = "The format to download the tracks in. Default is flac.", help = "The format to download the tracks in. Default is mp3.",
default_value = "flac" default_value = "mp3"
)] )]
format: Format format: Format
} }
@ -92,8 +92,14 @@ async fn main() -> anyhow::Result<()> {
eprintln!("Compression level is not supported yet. It will be ignored."); eprintln!("Compression level is not supported yet. It will be ignored.");
} }
let session = create_session(opt.username, opt.password).await?; let user_name = opt.username.or_else(|| {
println!("No username provided via arguments. Attempting to fetch from latest credentials cache.");
std::fs::read_to_string("credentials.json").ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("username")?.as_str().map(|s| s.to_string()))
});
let session = create_session(user_name.unwrap(), opt.password).await?;
let track = get_tracks(opt.tracks, &session).await?; let track = get_tracks(opt.tracks, &session).await?;
let downloader = Downloader::new(session); let downloader = Downloader::new(session);

View file

@ -13,7 +13,8 @@ pub async fn create_session(username: String, password: Option<String>) -> Resul
cache.save_credentials(&credentials); cache.save_credentials(&credentials);
let (session, _) = Session::connect(session_config, credentials, Some(cache), false).await?; let session = Session::new(session_config, Some(cache));
session.connect(credentials, false).await?;
Ok(session) Ok(session)
} }

View file

@ -15,19 +15,16 @@ pub async fn get_tracks(spotify_ids: Vec<String>, session: &Session) -> Result<V
let mut tracks: Vec<Track> = Vec::new(); let mut tracks: Vec<Track> = Vec::new();
for id in spotify_ids { for id in spotify_ids {
tracing::debug!("Getting tracks for: {}", id); tracing::debug!("Getting tracks for: {}", id);
let id = parse_uri_or_url(&id).ok_or(anyhow::anyhow!("Invalid track"))?; let id = parse_uri_or_url(&id).ok_or(anyhow::anyhow!("Invalid track `{id}`"))?;
let new_tracks = match id.audio_type { let new_tracks = match id.item_type {
librespot::core::spotify_id::SpotifyAudioType::Track => vec![Track::from_id(id)], librespot::core::spotify_id::SpotifyItemType::Track => vec![Track::from_id(id)],
librespot::core::spotify_id::SpotifyAudioType::Podcast => vec![Track::from_id(id)], librespot::core::spotify_id::SpotifyItemType::Episode => vec![Track::from_id(id)],
librespot::core::spotify_id::SpotifyAudioType::NonPlayable => { librespot::core::spotify_id::SpotifyItemType::Album => Album::from_id(id).get_tracks(session).await,
if Album::is_album(id, session).await { librespot::core::spotify_id::SpotifyItemType::Playlist => Playlist::from_id(id).get_tracks(session).await,
Album::from_id(id).get_tracks(session).await librespot::core::spotify_id::SpotifyItemType::Show => vec![],
} else if Playlist::is_playlist(id, session).await { librespot::core::spotify_id::SpotifyItemType::Artist => vec![],
Playlist::from_id(id).get_tracks(session).await librespot::core::spotify_id::SpotifyItemType::Local => vec![],
} else { librespot::core::spotify_id::SpotifyItemType::Unknown => vec![],
vec![]
}
}
}; };
tracks.extend(new_tracks); tracks.extend(new_tracks);
} }
@ -76,41 +73,24 @@ impl Track {
} }
pub async fn metadata(&self, session: &Session) -> Result<TrackMetadata> { pub async fn metadata(&self, session: &Session) -> Result<TrackMetadata> {
let metadata = librespot::metadata::Track::get(session, self.id) let metadata = librespot::metadata::Track::get(session, &self.id)
.await .await
.map_err(|_| anyhow::anyhow!("Failed to get metadata"))?; .map_err(|_| anyhow::anyhow!("Failed to get metadata"))?;
let mut artists = Vec::new(); let mut artists = Vec::new();
for artist in &metadata.artists { for artist in metadata.artists.iter() {
artists.push( artists.push(
librespot::metadata::Artist::get(session, *artist) librespot::metadata::Artist::get(session, &artist.id)
.await .await
.map_err(|_| anyhow::anyhow!("Failed to get artist"))?, .map_err(|_| anyhow::anyhow!("Failed to get artist"))?,
); );
} }
let album = librespot::metadata::Album::get(session, metadata.album) let album = librespot::metadata::Album::get(session, &metadata.album.id)
.await .await
.map_err(|_| anyhow::anyhow!("Failed to get album"))?; .map_err(|_| anyhow::anyhow!("Failed to get album"))?;
// Fetch cover image bytes if available Ok(TrackMetadata::from(metadata, artists, album))
let cover_image = if let Some(cover) = album.covers.first() {
let file_id_hex = hex::encode(cover.0);
let url = format!("https://i.scdn.co/image/{}", file_id_hex);
match reqwest::get(&url).await {
Ok(response) if response.status().is_success() => {
match response.bytes().await {
Ok(bytes) => Some(bytes.to_vec()),
Err(_) => None,
}
}
_ => None,
}
} else {
None
};
Ok(TrackMetadata::from(metadata, artists, album, cover_image))
} }
} }
@ -136,19 +116,18 @@ impl Album {
} }
pub async fn is_album(id: SpotifyId, session: &Session) -> bool { pub async fn is_album(id: SpotifyId, session: &Session) -> bool {
librespot::metadata::Album::get(session, id).await.is_ok() librespot::metadata::Album::get(session, &id).await.is_ok()
} }
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl TrackCollection for Album { impl TrackCollection for Album {
async fn get_tracks(&self, session: &Session) -> Vec<Track> { async fn get_tracks(&self, session: &Session) -> Vec<Track> {
let album = librespot::metadata::Album::get(session, self.id) let album = librespot::metadata::Album::get(session, &self.id)
.await .await
.expect("Failed to get album"); .expect("Failed to get album");
album album
.tracks .tracks()
.iter()
.map(|track| Track::from_id(*track)) .map(|track| Track::from_id(*track))
.collect() .collect()
} }
@ -169,7 +148,7 @@ impl Playlist {
} }
pub async fn is_playlist(id: SpotifyId, session: &Session) -> bool { pub async fn is_playlist(id: SpotifyId, session: &Session) -> bool {
librespot::metadata::Playlist::get(session, id) librespot::metadata::Playlist::get(session, &id)
.await .await
.is_ok() .is_ok()
} }
@ -178,12 +157,11 @@ impl Playlist {
#[async_trait::async_trait] #[async_trait::async_trait]
impl TrackCollection for Playlist { impl TrackCollection for Playlist {
async fn get_tracks(&self, session: &Session) -> Vec<Track> { async fn get_tracks(&self, session: &Session) -> Vec<Track> {
let playlist = librespot::metadata::Playlist::get(session, self.id) let playlist = librespot::metadata::Playlist::get(session, &self.id)
.await .await
.expect("Failed to get playlist"); .expect("Failed to get playlist");
playlist playlist
.tracks .tracks()
.iter()
.map(|track| Track::from_id(*track)) .map(|track| Track::from_id(*track))
.collect() .collect()
} }
@ -195,7 +173,6 @@ pub struct TrackMetadata {
pub track_name: String, pub track_name: String,
pub album: AlbumMetadata, pub album: AlbumMetadata,
pub duration: i32, pub duration: i32,
pub cover_image: Option<Vec<u8>>,
} }
impl TrackMetadata { impl TrackMetadata {
@ -203,7 +180,6 @@ impl TrackMetadata {
track: librespot::metadata::Track, track: librespot::metadata::Track,
artists: Vec<librespot::metadata::Artist>, artists: Vec<librespot::metadata::Artist>,
album: librespot::metadata::Album, album: librespot::metadata::Album,
cover_image: Option<Vec<u8>>,
) -> Self { ) -> Self {
let artists = artists let artists = artists
.iter() .iter()
@ -217,7 +193,6 @@ impl TrackMetadata {
track_name: track.name.clone(), track_name: track.name.clone(),
album, album,
duration: track.duration, duration: track.duration,
cover_image,
} }
} }
} }
@ -238,12 +213,16 @@ impl From<librespot::metadata::Artist> for ArtistMetadata {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AlbumMetadata { pub struct AlbumMetadata {
pub name: String, pub name: String,
pub year: i32,
pub cover: Option<librespot::metadata::image::Image>,
} }
impl From<librespot::metadata::Album> for AlbumMetadata { impl From<librespot::metadata::Album> for AlbumMetadata {
fn from(album: librespot::metadata::Album) -> Self { fn from(album: librespot::metadata::Album) -> Self {
AlbumMetadata { AlbumMetadata {
name: album.name.clone(), name: album.name.clone(),
year: album.date.as_utc().year(),
cover: album.covers.first().cloned()
} }
} }
} }