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"
[dependencies]
bytes = "1"
structopt = { version = "0.3", default-features = false }
rpassword = "7.0"
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"] }
flacenc = { version = "0.4" }
audiotags = "0.5"
@ -31,6 +32,7 @@ rayon = "1.10"
hex = "0.4"
reqwest = { version = "0.11", features = ["blocking", "json"] }
id3 = "0.6"
serde_json = "1.0.117"
[features]
default = ["mp3"]

View file

@ -2,6 +2,7 @@ use std::fmt::Write;
use std::path::PathBuf;
use std::time::Duration;
use bytes::Bytes;
use anyhow::Result;
use futures::StreamExt;
use futures::TryStreamExt;
@ -97,7 +98,7 @@ impl Downloader {
let file_size = sink.get_approximate_size();
let (mut player, _) = Player::new(
let player = Player::new(
self.player_config.clone(),
self.session.clone(),
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);
pb.set_message(format!("Encoding and writing {}", &file_name));
let samples = Samples::new(samples, 44100, 2, 16);
let encoder = crate::encoder::get_encoder(options.format);
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));
Ok(())
@ -188,4 +193,16 @@ impl Downloader {
}
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::error::Verify;
use bytes::Bytes;
use super::Encoder;
use super::Samples;
@ -9,7 +10,12 @@ pub struct FlacEncoder;
#[async_trait::async_trait]
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;
tracing::info!("Writing track: {:?} to file: {}", file_name, output_path);
let source = flacenc::source::MemSource::from_samples(

View file

@ -2,8 +2,8 @@ mod flac;
#[cfg(feature = "mp3")]
mod mp3;
use bytes::Bytes;
use std::{path::Path, str::FromStr};
use anyhow::Result;
use tokio::sync::oneshot::Sender;
@ -55,7 +55,7 @@ pub fn get_encoder(format: Format) -> &'static dyn Encoder {
#[async_trait::async_trait]
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 {

View file

@ -4,6 +4,7 @@ use mp3lame_encoder::Builder;
use mp3lame_encoder::FlushNoGap;
use mp3lame_encoder::InterleavedPcm;
use id3::{Version, frame::{Picture, PictureType, Frame, Content}};
use bytes::Bytes;
use super::execute_with_result;
use super::EncodedStream;
@ -40,7 +41,7 @@ impl Mp3Encoder {
#[async_trait::async_trait]
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;
tracing::info!("Writing track: {:?} to file: {}", file_name, output_path);
let stream = self.encode_raw(samples).await?;
@ -49,21 +50,25 @@ impl Encoder for Mp3Encoder {
// Embed tags using id3 crate
let mut tag = id3::Tag::read_from_path(output_path).unwrap_or_else(|_| id3::Tag::new());
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");
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
if let Some(image_bytes) = metadata.cover_image.as_deref() {
if !cover_image_bytes.is_empty() {
let picture = Picture {
mime_type: "image/jpeg".to_string(),
picture_type: PictureType::CoverFront,
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);
tag.add_frame(Frame::with_content("APIC", Content::Picture(picture)));
}
tag.write_to_path(output_path, Version::Id3v24)?;
Ok(())
}

View file

@ -19,7 +19,7 @@ struct Opt {
)]
tracks: Vec<String>,
#[structopt(short = "u", long = "username", help = "Your Spotify username")]
username: String,
username: Option<String>,
#[structopt(short = "p", long = "password", help = "Your Spotify password")]
password: Option<String>,
#[structopt(
@ -45,8 +45,8 @@ struct Opt {
#[structopt(
short = "f",
long = "format",
help = "The format to download the tracks in. Default is flac.",
default_value = "flac"
help = "The format to download the tracks in. Default is mp3.",
default_value = "mp3"
)]
format: Format
}
@ -92,8 +92,14 @@ async fn main() -> anyhow::Result<()> {
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 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);
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)
}

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