This commit is contained in:
Guillem Castro 2024-05-10 18:33:42 +02:00
parent 868826b42d
commit ca44edf845
8 changed files with 741 additions and 295 deletions

179
src/download.rs Normal file
View file

@ -0,0 +1,179 @@
use std::fmt::Write;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use indicatif::MultiProgress;
use indicatif::ProgressBar;
use indicatif::ProgressState;
use indicatif::ProgressStyle;
use librespot::core::session::Session;
use librespot::playback::config::PlayerConfig;
use librespot::playback::mixer::NoOpVolume;
use librespot::playback::mixer::VolumeGetter;
use librespot::playback::player::Player;
use crate::file_sink::FileSink;
use crate::file_sink::SinkEvent;
use crate::track::Track;
use crate::track::TrackMetadata;
pub struct Downloader {
player_config: PlayerConfig,
session: Session,
progress_bar: MultiProgress,
}
#[derive(Debug, Clone)]
pub struct DownloadOptions {
pub destination: PathBuf,
pub compression: Option<u32>,
pub parallel: usize,
}
impl DownloadOptions {
pub fn new(destination: Option<String>, compression: Option<u32>, parallel: usize) -> Self {
let destination =
destination.map_or_else(|| std::env::current_dir().unwrap(), PathBuf::from);
DownloadOptions {
destination,
compression,
parallel,
}
}
}
impl Downloader {
pub fn new(session: Session) -> Self {
Downloader {
player_config: PlayerConfig::default(),
session,
progress_bar: MultiProgress::new(),
}
}
pub async fn download_tracks(
self,
tracks: Vec<Track>,
options: &DownloadOptions,
) -> Result<()> {
let this = Arc::new(self);
let chunks = tracks.chunks(options.parallel);
for chunk in chunks {
let mut tasks = Vec::new();
for track in chunk {
let t = track.clone();
let downloader = this.clone();
let options = options.clone();
tasks.push(tokio::spawn(async move {
downloader.download_track(t, &options).await
}));
}
for task in tasks {
task.await??;
}
}
Ok(())
}
#[tracing::instrument(name = "download_track", skip(self))]
async fn download_track(&self, track: Track, options: &DownloadOptions) -> Result<()> {
let metadata = track.metadata(&self.session).await?;
tracing::info!("Downloading track: {:?}", metadata);
let file_name = self.get_file_name(&metadata);
let path = options
.destination
.join(file_name.clone())
.with_extension("flac")
.to_str()
.ok_or(anyhow::anyhow!("Could not set the output path"))?
.to_string();
let (file_sink, mut sink_channel) = FileSink::new(path.to_string(), metadata);
let file_size = file_sink.get_approximate_size();
let (mut player, _) = Player::new(
self.player_config.clone(),
self.session.clone(),
self.volume_getter(),
move || Box::new(file_sink),
);
let pb = self.progress_bar.add(ProgressBar::new(file_size as u64));
pb.set_style(ProgressStyle::with_template("{spinner:.green} {msg} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})")?
.with_key("eta", |state: &ProgressState, w: &mut dyn Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap())
.progress_chars("#>-"));
pb.set_message(file_name.clone());
player.load(track.id, true, 0);
let name = file_name.clone();
tokio::spawn(async move {
while let Some(event) = sink_channel.recv().await {
match event {
SinkEvent::Written { bytes, total } => {
tracing::trace!("Written {} bytes out of {}", bytes, total);
pb.set_position(bytes as u64);
}
SinkEvent::Finished => {
pb.finish_with_message(format!("Downloaded {}", name));
}
}
}
});
player.await_end_of_track().await;
player.stop();
tracing::info!("Downloaded track: {:?}", file_name);
Ok(())
}
fn volume_getter(&self) -> Box<dyn VolumeGetter + Send> {
Box::new(NoOpVolume)
}
fn get_file_name(&self, metadata: &TrackMetadata) -> String {
// If there is more than 3 artists, add the first 3 and add "and others" at the end
if metadata.artists.len() > 3 {
let artists_name = metadata
.artists
.iter()
.take(3)
.map(|artist| artist.name.clone())
.collect::<Vec<String>>()
.join(", ");
return self.clean_file_name(format!(
"{}, and others - {}",
artists_name, metadata.track_name
));
}
let artists_name = metadata
.artists
.iter()
.map(|artist| artist.name.clone())
.collect::<Vec<String>>()
.join(", ");
self.clean_file_name(format!("{} - {}", artists_name, metadata.track_name))
}
fn clean_file_name(&self, file_name: String) -> String {
let invalid_chars = ['<', '>', ':', '\'', '"', '/', '\\', '|', '?', '*'];
let mut clean = String::new();
// Everything but Windows should allow non-ascii characters
let allows_non_ascii = !cfg!(windows);
for c in file_name.chars() {
if !invalid_chars.contains(&c) && (c.is_ascii() || allows_non_ascii) && !c.is_control()
{
clean.push(c);
}
}
clean
}
}

View file

@ -1,44 +1,60 @@
use std::path::Path;
use audiotags::{Tag, TagType};
use librespot::playback::{
audio_backend::{Open, Sink, SinkError},
config::AudioFormat,
convert::Converter,
decoder::AudioPacket,
};
// extern crate flac_bound;
use audiotags::Tag;
use audiotags::TagType;
use librespot::playback::audio_backend::Sink;
use librespot::playback::audio_backend::SinkError;
use librespot::playback::convert::Converter;
use librespot::playback::decoder::AudioPacket;
use flac_bound::FlacEncoder;
use crate::TrackMetadata;
use crate::track::TrackMetadata;
pub enum SinkEvent {
Written { bytes: usize, total: usize },
Finished,
}
pub type SinkEventChannel = tokio::sync::mpsc::UnboundedReceiver<SinkEvent>;
pub struct FileSink {
sink: String,
content: Vec<i32>,
metadata: Option<TrackMetadata>,
metadata: TrackMetadata,
compression: u32,
event_sender: tokio::sync::mpsc::UnboundedSender<SinkEvent>,
}
impl FileSink {
pub fn add_metadata(&mut self, meta: TrackMetadata) {
self.metadata = Some(meta);
}
pub fn set_compression(&mut self, compression: u32) {
self.compression = compression;
}
}
impl Open for FileSink {
fn open(path: Option<String>, _audio_format: AudioFormat) -> Self {
let file_path = path.unwrap_or_else(|| panic!());
FileSink {
sink: file_path,
content: Vec::new(),
metadata: None,
compression: 4,
}
pub fn new(path: String, track: TrackMetadata) -> (Self, SinkEventChannel) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(
FileSink {
sink: path,
content: Vec::new(),
metadata: track,
compression: 4,
event_sender: tx,
},
rx,
)
}
pub fn get_approximate_size(&self) -> usize {
self.convert_track_duration_to_size()
}
fn convert_track_duration_to_size(&self) -> usize {
let duration = self.metadata.duration / 1000;
let sample_rate = 44100;
let channels = 2;
let bits_per_sample = 16;
let bytes_per_sample = bits_per_sample / 8;
(duration as usize) * sample_rate * channels * bytes_per_sample * 2
}
}
@ -48,42 +64,60 @@ impl Sink for FileSink {
}
fn stop(&mut self) -> Result<(), SinkError> {
tracing::info!("Writing to file: {:?}", &self.sink);
let mut encoder = FlacEncoder::new()
.unwrap()
.ok_or(SinkError::OnWrite(
"Failed to create flac encoder".to_string(),
))?
.channels(2)
.bits_per_sample(16)
.compression_level(*&self.compression)
.compression_level(self.compression)
.init_file(&self.sink)
.unwrap();
.map_err(|e| {
SinkError::OnWrite(format!("Failed to init flac encoder: {:?}", e).to_string())
})?;
encoder
.process_interleaved(self.content.as_slice(), (self.content.len() / 2) as u32)
.unwrap();
encoder.finish().unwrap();
.map_err(|_| SinkError::OnWrite("Failed to write flac".to_string()))?;
encoder
.finish()
.map_err(|_| SinkError::OnWrite("Failed to finish encondig".to_string()))?;
match &self.metadata {
Some(meta) => {
let mut tag = Tag::new()
.with_tag_type(TagType::Flac)
.read_from_path(Path::new(&self.sink))
.unwrap();
let mut tag = Tag::new()
.with_tag_type(TagType::Flac)
.read_from_path(Path::new(&self.sink))
.map_err(|_| SinkError::OnWrite("Failed to read metadata".to_string()))?;
tag.set_album_title(&meta.album);
for artist in &meta.artists {
tag.add_artist(artist);
}
tag.set_title(&meta.track_name);
tag.write_to_path(&self.sink)
.expect("Failed to write metadata");
}
None => (),
tag.set_album_title(&self.metadata.album.name);
for artist in &self.metadata.artists {
tag.add_artist(&artist.name);
}
tag.set_title(&self.metadata.track_name);
tag.write_to_path(&self.sink)
.map_err(|_| SinkError::OnWrite("Failed to write metadata".to_string()))?;
self.event_sender
.send(SinkEvent::Finished)
.map_err(|_| SinkError::OnWrite("Failed to send finished event".to_string()))?;
Ok(())
}
fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> Result<(), SinkError> {
let data = converter.f64_to_s16(packet.samples().unwrap());
let data = converter.f64_to_s16(
packet
.samples()
.map_err(|_| SinkError::OnWrite("Failed to get samples".to_string()))?,
);
let mut data32: Vec<i32> = data.iter().map(|el| i32::from(*el)).collect();
self.content.append(&mut data32);
self.event_sender
.send(SinkEvent::Written {
bytes: self.content.len() * std::mem::size_of::<i32>(),
total: self.convert_track_duration_to_size(),
})
.map_err(|_| SinkError::OnWrite("Failed to send event".to_string()))?;
Ok(())
}
}

4
src/lib.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod download;
pub mod file_sink;
pub mod session;
pub mod track;

View file

@ -1,27 +1,10 @@
mod file_sink;
extern crate rpassword;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use librespot::core::config::SessionConfig;
use librespot::core::session::Session;
use librespot::core::spotify_id::SpotifyId;
use librespot::playback::config::PlayerConfig;
use librespot::playback::mixer::NoOpVolume;
use librespot::{core::authentication::Credentials, metadata::Playlist};
use librespot::playback::audio_backend::Open;
use librespot::playback::player::Player;
use librespot::metadata::{Album, Artist, Metadata, Track};
use regex::Regex;
use spotify_dl::download::{DownloadOptions, Downloader};
use spotify_dl::session::create_session;
use spotify_dl::track::get_tracks;
use structopt::StructOpt;
use indicatif::{ProgressBar, ProgressStyle};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{fmt, EnvFilter};
#[derive(Debug, StructOpt)]
#[structopt(
@ -29,7 +12,10 @@ use indicatif::{ProgressBar, ProgressStyle};
about = "A commandline utility to download music directly from Spotify"
)]
struct Opt {
#[structopt(help = "A list of Spotify URIs (songs, podcasts, playlists or albums)")]
#[structopt(
help = "A list of Spotify URIs or URLs (songs, podcasts, playlists or albums)",
required = true
)]
tracks: Vec<String>,
#[structopt(short = "u", long = "username", help = "Your Spotify username")]
username: String,
@ -38,16 +24,9 @@ struct Opt {
#[structopt(
short = "d",
long = "destination",
default_value = ".",
help = "The directory where the songs will be downloaded"
)]
destination: String,
#[structopt(
short = "o",
long = "ordered",
help = "Prefixing the filename with its index in the playlist"
)]
ordered: bool,
destination: Option<String>,
#[structopt(
short = "c",
long = "compression",
@ -55,202 +34,53 @@ struct Opt {
8 (slowest, most compression). A value larger than 8 will be Treated as 8. Default is 4."
)]
compression: Option<u32>,
#[structopt(
short = "t",
long = "parallel",
help = "Number of parallel downloads. Default is 5.",
default_value = "5"
)]
parallel: usize,
}
#[derive(Clone)]
pub struct TrackMetadata {
artists: Vec<String>,
track_name: String,
album: String,
pub fn configure_logger() {
tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_default_env())
.init();
}
async fn create_session(credentials: Credentials) -> Session {
let mut session_config = SessionConfig::default();
session_config.device_id = machine_uid::get().unwrap();
let (session, _) = Session::connect(session_config, credentials, None, true)
.await
.unwrap();
session
}
fn make_filename_compatible(filename: &str) -> String {
let invalid_chars = ['<', '>', ':', '\'', '"', '/', '\\', '|', '?', '*'];
let mut clean = String::new();
for c in filename.chars() {
if !invalid_chars.contains(&c) && c.is_ascii() && !c.is_control() && c.len_utf8() == 1 {
clean.push(c);
}
}
clean
}
async fn download_tracks(
session: &Session,
destination: PathBuf,
tracks: Vec<SpotifyId>,
ordered: bool,
compression: Option<u32>,
) {
let player_config = PlayerConfig::default();
let bar_style = ProgressStyle::default_bar()
.template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} (ETA: {eta}) {msg}").unwrap()
.progress_chars("##-");
let bar = ProgressBar::new(tracks.len() as u64);
bar.set_style(bar_style);
bar.enable_steady_tick(Duration::from_millis(100));
for (i, track) in tracks.iter().enumerate() {
let track_item = Track::get(&session, *track).await.unwrap();
let artist_name: String;
let mut metadata = TrackMetadata {
artists: Vec::new(),
track_name: track_item.name,
album: Album::get(session, track_item.album).await.unwrap().name,
};
if track_item.artists.len() > 1 {
let mut tmp: String = String::new();
for artist in track_item.artists {
let artist_item = Artist::get(&session, artist).await.unwrap();
metadata.artists.push(artist_item.name.clone());
tmp.push_str(artist_item.name.as_str());
tmp.push_str(", ");
}
artist_name = String::from(tmp.trim_end_matches(", "));
} else {
artist_name = Artist::get(&session, track_item.artists[0])
.await
.unwrap()
.name;
metadata.artists.push(artist_name.clone());
}
let full_track_name = format!("{} - {}", artist_name, metadata.track_name);
let full_track_name_clean = make_filename_compatible(full_track_name.as_str());
//let filename = format!("{}.flac", full_track_name_clean);
let filename: String;
if ordered {
filename = format!("{:03} - {}.flac", i + 1, full_track_name_clean);
} else {
filename = format!("{}.flac", full_track_name_clean);
}
let joined_path = destination.join(&filename);
let path = joined_path.to_str().unwrap();
bar.set_message(full_track_name_clean);
let file_name = Path::new(path).file_stem().unwrap().to_str().unwrap();
let path_parent = Path::new(path).parent().unwrap();
let entries = path_parent.read_dir().unwrap();
let mut file_exists = false;
for entry in entries {
let entry = entry.unwrap();
let entry_path = entry.path();
let entry_file_name = entry_path.file_stem().unwrap().to_str().unwrap();
if entry_file_name == file_name {
file_exists = true;
break;
}
}
if !file_exists {
let mut file_sink = file_sink::FileSink::open(
Some(path.to_owned()),
librespot::playback::config::AudioFormat::S16,
);
file_sink.add_metadata(metadata);
file_sink.set_compression(compression.unwrap_or(4));
let (mut player, _) =
Player::new(player_config.clone(), session.clone(), Box::new(NoOpVolume{}), move || {
Box::new(file_sink)
});
player.load(*track, true, 0);
player.await_end_of_track().await;
player.stop();
bar.inc(1);
} else {
// println!("File with the same name already exists, skipping: {}", path);
bar.inc(1);
}
}
bar.finish();
}
async fn get_tracks_from_playlist_or_album(session: &Session, track: SpotifyId, track_url: &String) -> Vec<SpotifyId> {
match Playlist::get(&session, track).await {
Ok(playlist) => {
println!(
"Adding all songs from playlist {} (by {}) to the queue",
&playlist.name, &playlist.user
);
return playlist.tracks;
},
_ => ()
}
match Album::get(&session, track).await {
Ok(album) => {
println!(
"Adding all songs from album {} (by {:?}) to the queue",
&album.name, &album.artists
);
return album.tracks;
}
Err(_) => {
println!("Unsupported URI {}", &track_url);
vec![]
pub fn create_destination_if_required(destination: Option<String>) -> anyhow::Result<()> {
if let Some(destination) = destination {
if !std::path::Path::new(&destination).exists() {
tracing::info!("Creating destination directory: {}", destination);
std::fs::create_dir_all(destination)?;
}
}
Ok(())
}
#[tokio::main]
async fn main() {
async fn main() -> anyhow::Result<()> {
configure_logger();
let opt = Opt::from_args();
create_destination_if_required(opt.destination.clone())?;
let username = opt.username;
let password = opt
.password
.unwrap_or_else(|| rpassword::prompt_password("Password: ").unwrap());
let credentials = Credentials::with_password(username, password);
let session = create_session(credentials.clone()).await;
let mut tracks: Vec<SpotifyId> = Vec::new();
for track_url in opt.tracks {
let track = SpotifyId::from_uri(track_url.as_str()).unwrap_or_else(|_| {
let regex = Regex::new(r"https://open.spotify.com/(\w+)/(.*)\?").unwrap();
let results = regex.captures(track_url.as_str()).unwrap();
let uri = format!(
"spotify:{}:{}",
results.get(1).unwrap().as_str(),
results.get(2).unwrap().as_str()
);
SpotifyId::from_uri(&uri).unwrap()
});
match &track.audio_type {
librespot::core::spotify_id::SpotifyAudioType::Track => {
tracks.push(track);
}
librespot::core::spotify_id::SpotifyAudioType::Podcast => {
tracks.push(track);
}
librespot::core::spotify_id::SpotifyAudioType::NonPlayable => {
let mut multiple_tracks = get_tracks_from_playlist_or_album(&session, track, &track_url).await;
tracks.append(&mut multiple_tracks)
}
}
if opt.tracks.is_empty() {
eprintln!("No tracks provided");
std::process::exit(1);
}
download_tracks(
&session,
PathBuf::from(opt.destination),
tracks,
opt.ordered,
opt.compression,
)
.await;
let session = create_session(opt.username, opt.password).await?;
let track = get_tracks(opt.tracks, &session).await?;
let downloader = Downloader::new(session);
downloader
.download_tracks(
track,
&DownloadOptions::new(opt.destination, opt.compression, opt.parallel),
)
.await
}

36
src/session.rs Normal file
View file

@ -0,0 +1,36 @@
use anyhow::Result;
use librespot::core::cache::Cache;
use librespot::core::config::SessionConfig;
use librespot::core::session::Session;
use librespot::discovery::Credentials;
pub async fn create_session(username: String, password: Option<String>) -> Result<Session> {
let credentials_store = dirs::home_dir().map(|p| p.join(".spotify-dl"));
let cache = Cache::new(credentials_store, None, None, None)?;
let session_config = SessionConfig::default();
let credentials = get_credentials(username, password, &cache);
cache.save_credentials(&credentials);
let (session, _) = Session::connect(session_config, credentials, Some(cache), false).await?;
Ok(session)
}
fn prompt_password() -> Result<String> {
tracing::info!("Spotify password was not provided. Please enter your Spotify password below");
rpassword::prompt_password("Password: ").map_err(|e| e.into())
}
fn get_credentials(username: String, password: Option<String>, cache: &Cache) -> Credentials {
match password {
Some(password) => Credentials::with_password(username, password),
None => cache.credentials().unwrap_or_else(|| {
tracing::warn!("No credentials found in cache");
Credentials::with_password(
username,
prompt_password().unwrap_or_else(|_| panic!("Failed to get password")),
)
}),
}
}

225
src/track.rs Normal file
View file

@ -0,0 +1,225 @@
use anyhow::Result;
use lazy_static::lazy_static;
use librespot::core::session::Session;
use librespot::core::spotify_id::SpotifyId;
use librespot::metadata::Metadata;
use regex::Regex;
#[async_trait::async_trait]
trait TrackCollection {
async fn get_tracks(&self, session: &Session) -> Vec<Track>;
}
#[tracing::instrument(name = "get_tracks", skip(session), level = "debug")]
pub async fn get_tracks(spotify_ids: Vec<String>, session: &Session) -> Result<Vec<Track>> {
let mut tracks: Vec<Track> = Vec::new();
for id in spotify_ids {
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![]
}
}
};
tracks.extend(new_tracks);
}
tracing::debug!("Got tracks: {:?}", tracks);
Ok(tracks)
}
fn parse_uri_or_url(track: &str) -> Option<SpotifyId> {
parse_uri(track).or_else(|| parse_url(track))
}
fn parse_uri(track_uri: &str) -> Option<SpotifyId> {
SpotifyId::from_uri(track_uri).ok()
}
fn parse_url(track_url: &str) -> Option<SpotifyId> {
let results = SPOTIFY_URL_REGEX.captures(track_url)?;
let uri = format!(
"spotify:{}:{}",
results.get(1)?.as_str(),
results.get(2)?.as_str()
);
SpotifyId::from_uri(&uri).ok()
}
#[derive(Clone, Debug)]
pub struct Track {
pub id: SpotifyId,
}
lazy_static! {
static ref SPOTIFY_URL_REGEX: Regex =
Regex::new(r"https://open.spotify.com/(\w+)/(.*)\?").unwrap();
}
impl Track {
pub fn new(track: &str) -> Result<Self> {
let id = parse_uri_or_url(track).ok_or(anyhow::anyhow!("Invalid track"))?;
Ok(Track { id })
}
pub fn from_id(id: SpotifyId) -> Self {
Track { id }
}
pub async fn metadata(&self, session: &Session) -> Result<TrackMetadata> {
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 {
artists.push(
librespot::metadata::Artist::get(session, *artist)
.await
.map_err(|_| anyhow::anyhow!("Failed to get artist"))?,
);
}
let album = librespot::metadata::Album::get(session, metadata.album)
.await
.map_err(|_| anyhow::anyhow!("Failed to get album"))?;
Ok(TrackMetadata::from(metadata, artists, album))
}
}
#[async_trait::async_trait]
impl TrackCollection for Track {
async fn get_tracks(&self, _session: &Session) -> Vec<Track> {
vec![self.clone()]
}
}
pub struct Album {
id: SpotifyId,
}
impl Album {
pub fn new(album: &str) -> Result<Self> {
let id = parse_uri_or_url(album).ok_or(anyhow::anyhow!("Invalid album"))?;
Ok(Album { id })
}
pub fn from_id(id: SpotifyId) -> Self {
Album { id }
}
pub async fn is_album(id: SpotifyId, session: &Session) -> bool {
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)
.await
.expect("Failed to get album");
album
.tracks
.iter()
.map(|track| Track::from_id(*track))
.collect()
}
}
pub struct Playlist {
id: SpotifyId,
}
impl Playlist {
pub fn new(playlist: &str) -> Result<Self> {
let id = parse_uri_or_url(playlist).ok_or(anyhow::anyhow!("Invalid playlist"))?;
Ok(Playlist { id })
}
pub fn from_id(id: SpotifyId) -> Self {
Playlist { id }
}
pub async fn is_playlist(id: SpotifyId, session: &Session) -> bool {
librespot::metadata::Playlist::get(session, id)
.await
.is_ok()
}
}
#[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)
.await
.expect("Failed to get playlist");
playlist
.tracks
.iter()
.map(|track| Track::from_id(*track))
.collect()
}
}
#[derive(Clone, Debug)]
pub struct TrackMetadata {
pub artists: Vec<ArtistMetadata>,
pub track_name: String,
pub album: AlbumMetadata,
pub duration: i32,
}
impl TrackMetadata {
pub fn from(
track: librespot::metadata::Track,
artists: Vec<librespot::metadata::Artist>,
album: librespot::metadata::Album,
) -> Self {
let artists = artists
.iter()
.map(|artist| ArtistMetadata::from(artist.clone()))
.collect();
let album = AlbumMetadata::from(album);
TrackMetadata {
artists,
track_name: track.name.clone(),
album,
duration: track.duration,
}
}
}
#[derive(Clone, Debug)]
pub struct ArtistMetadata {
pub name: String,
}
impl From<librespot::metadata::Artist> for ArtistMetadata {
fn from(artist: librespot::metadata::Artist) -> Self {
ArtistMetadata {
name: artist.name.clone(),
}
}
}
#[derive(Clone, Debug)]
pub struct AlbumMetadata {
pub name: String,
}
impl From<librespot::metadata::Album> for AlbumMetadata {
fn from(album: librespot::metadata::Album) -> Self {
AlbumMetadata {
name: album.name.clone(),
}
}
}