diff --git a/Cargo.toml b/Cargo.toml index 3ee99fa..d30fe73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,13 +12,12 @@ keywords = ["audio", "music"] categories = ["multimedia::audio"] [dependencies] -file-format = { version = "0.17.3", features = ["reader", "serde"] } -lofty = "0.14.0" -rusqlite = { version = "0.29.0", features = ["bundled"] } -serde = { version = "1.0.164", features = ["derive"] } +file-format = { version = "0.22.0", features = ["reader-asf", "reader-ebml", "reader-mp4", "reader-rm", "reader-txt", "reader-xml", "serde"] } +lofty = "0.16.1" +serde = { version = "1.0.191", features = ["derive"] } time = "0.3.22" toml = "0.7.5" -walkdir = "2.3.3" +walkdir = "2.4.0" cpal = "0.15.2" heapless = "0.7.16" rb = "0.4.1" @@ -32,3 +31,12 @@ futures = "0.3.28" rubato = "0.12.0" arrayvec = "0.7.4" discord-presence = "0.5.18" +chrono = { version = "0.4.31", features = ["serde"] } +bincode = { version = "2.0.0-rc.3", features = ["serde"] } +unidecode = "0.3.0" +rayon = "1.8.0" +log = "0.4" +pretty_env_logger = "0.4" +base64 = "0.21.5" +snap = "1.1.0" +rcue = "0.1.3" diff --git a/src/lib.rs b/src/lib.rs index 3a25b3d..7aa37eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,20 +5,17 @@ pub mod music_tracker { pub mod music_storage { pub mod music_db; pub mod playlist; -} - -pub mod music_processor { - pub mod music_processor; + mod utils; } pub mod music_player { - pub mod music_player; pub mod music_output; + pub mod music_player; pub mod music_resampler; } pub mod music_controller { - pub mod music_controller; pub mod config; pub mod init; -} \ No newline at end of file + pub mod music_controller; +} diff --git a/src/music_controller/config.rs b/src/music_controller/config.rs index 034f12d..82cbd22 100644 --- a/src/music_controller/config.rs +++ b/src/music_controller/config.rs @@ -1,10 +1,10 @@ -use std::path::PathBuf; -use std::fs::read_to_string; use std::fs; +use std::fs::read_to_string; +use std::path::PathBuf; use serde::{Deserialize, Serialize}; -use crate::music_tracker::music_tracker::{LastFMConfig, DiscordRPCConfig, ListenBrainzConfig}; +use crate::music_tracker::music_tracker::{DiscordRPCConfig, LastFMConfig, ListenBrainzConfig}; #[derive(Serialize, Deserialize, PartialEq, Eq)] pub struct Config { @@ -16,57 +16,59 @@ pub struct Config { impl Default for Config { fn default() -> Self { - let path = PathBuf::from("./music_database.db3"); + let path = PathBuf::from("./music_database"); return Config { db_path: Box::new(path), lastfm: None, - + discord: Some(DiscordRPCConfig { enabled: true, dango_client_id: 1144475145864499240, dango_icon: String::from("flat"), }), - + listenbrainz: Some(ListenBrainzConfig { enabled: false, api_url: String::from("https://api.listenbrainz.org"), auth_token: String::from(""), - }) + }), }; } } impl Config { /// Creates and saves a new config with default values - pub fn new(config_file: &PathBuf) -> std::io::Result { + pub fn new(config_file: &PathBuf) -> std::io::Result { let config = Config::default(); config.save(config_file)?; - + Ok(config) } /// Loads config from given file path pub fn from(config_file: &PathBuf) -> std::result::Result { - return toml::from_str(&read_to_string(config_file) - .expect("Failed to initalize music config: File not found!")); + return toml::from_str( + &read_to_string(config_file) + .expect("Failed to initalize music config: File not found!"), + ); } - + /// Saves config to given path /// Saves -> temp file, if successful, removes old config, and renames temp to given path pub fn save(&self, config_file: &PathBuf) -> std::io::Result<()> { let toml = toml::to_string_pretty(self).unwrap(); - + let mut temp_file = config_file.clone(); temp_file.set_extension("tomltemp"); - + fs::write(&temp_file, toml)?; // If configuration file already exists, delete it match fs::metadata(config_file) { Ok(_) => fs::remove_file(config_file)?, - Err(_) => {}, + Err(_) => {} } fs::rename(temp_file, config_file)?; diff --git a/src/music_controller/init.rs b/src/music_controller/init.rs index 80b91bf..38b101f 100644 --- a/src/music_controller/init.rs +++ b/src/music_controller/init.rs @@ -1,9 +1,7 @@ -use std::path::Path; use std::fs::File; +use std::path::Path; -pub fn init() { - -} +pub fn init() {} fn init_config() { let config_path = "./config.toml"; @@ -13,7 +11,4 @@ fn init_config() { } } -fn init_db() { - -} - +fn init_db() {} diff --git a/src/music_controller/music_controller.rs b/src/music_controller/music_controller.rs index da4f676..51be432 100644 --- a/src/music_controller/music_controller.rs +++ b/src/music_controller/music_controller.rs @@ -1,68 +1,77 @@ use std::path::PathBuf; -use std::sync::{RwLock, Arc}; - -use rusqlite::Result; +use std::sync::{Arc, RwLock}; use crate::music_controller::config::Config; -use crate::music_player::music_player::{MusicPlayer, PlayerStatus, DecoderMessage, DSPMessage}; -use crate::music_storage::music_db::Song; +use crate::music_player::music_player::{DecoderMessage, MusicPlayer, PlayerStatus}; +use crate::music_storage::music_db::{MusicLibrary, Song, Tag}; pub struct MusicController { pub config: Arc>, + pub library: MusicLibrary, music_player: MusicPlayer, } impl MusicController { /// Creates new MusicController with config at given path - pub fn new(config_path: &PathBuf) -> Result{ + pub fn new(config_path: &PathBuf) -> Result> { let config = Arc::new(RwLock::new(Config::new(config_path)?)); let music_player = MusicPlayer::new(config.clone()); - + let library = match MusicLibrary::init(config.clone()) { + Ok(library) => library, + Err(error) => return Err(error), + }; + let controller = MusicController { config, + library, music_player, }; - - return Ok(controller) + + return Ok(controller); } - + /// Creates new music controller from a config at given path - pub fn from(config_path: &PathBuf) -> std::result::Result { + pub fn from(config_path: &PathBuf) -> Result> { let config = Arc::new(RwLock::new(Config::from(config_path)?)); let music_player = MusicPlayer::new(config.clone()); - + let library = match MusicLibrary::init(config.clone()) { + Ok(library) => library, + Err(error) => return Err(error), + }; + let controller = MusicController { config, + library, music_player, }; - - return Ok(controller) + + return Ok(controller); } - + /// Sends given message to control music player pub fn song_control(&mut self, message: DecoderMessage) { self.music_player.send_message(message); } - + /// Gets status of the music player pub fn player_status(&mut self) -> PlayerStatus { return self.music_player.get_status(); } - + /// Gets current song being controlled, if any pub fn get_current_song(&self) -> Option { return self.music_player.get_current_song(); } - - /// Gets audio playback volume - pub fn get_vol(&self) -> f32 { - return self.music_player.music_processor.audio_volume; + + /// Queries the [MusicLibrary], returning a `Vec` + pub fn query_library( + &self, + query_string: &String, + target_tags: Vec, + search_location: bool, + sort_by: Vec, + ) -> Option> { + self.library + .query_tracks(query_string, &target_tags, &sort_by) } - - /// Sets audio playback volume on a scale of 0.0 to 1.0 - pub fn set_vol(&mut self, volume: f32) { - self.music_player.music_processor.audio_volume = volume; - self.song_control(DecoderMessage::DSP(DSPMessage::UpdateProcessor(Box::new(self.music_player.music_processor.clone())))); - } - } diff --git a/src/music_player/music_output.rs b/src/music_player/music_output.rs index 795e9e9..76e6d3b 100644 --- a/src/music_player/music_output.rs +++ b/src/music_player/music_output.rs @@ -1,7 +1,7 @@ use std::{result, thread}; -use symphonia::core::audio::{AudioBufferRef, SignalSpec, RawSample, SampleBuffer}; -use symphonia::core::conv::{ConvertibleSample, IntoSample, FromSample}; +use symphonia::core::audio::{AudioBufferRef, RawSample, SampleBuffer, SignalSpec}; +use symphonia::core::conv::{ConvertibleSample, FromSample, IntoSample}; use symphonia::core::units::Duration; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; @@ -25,10 +25,21 @@ pub enum AudioOutputError { pub type Result = result::Result; -pub trait OutputSample: SizedSample + FromSample + IntoSample +cpal::Sample + ConvertibleSample + RawSample + std::marker::Send + 'static {} +pub trait OutputSample: + SizedSample + + FromSample + + IntoSample + + cpal::Sample + + ConvertibleSample + + RawSample + + std::marker::Send + + 'static +{ +} pub struct AudioOutput -where T: OutputSample, +where + T: OutputSample, { ring_buf_producer: rb::Producer, sample_buf: SampleBuffer, @@ -48,80 +59,112 @@ impl OutputSample for f64 {} //create a new trait with functions, then impl that somehow pub fn open_stream(spec: SignalSpec, duration: Duration) -> Result> { - let host = cpal::default_host(); - - // Uses default audio device - let device = match host.default_output_device() { - Some(device) => device, - _ => return Err(AudioOutputError::OpenStreamError), - }; - - let config = match device.default_output_config() { - Ok(config) => config, - Err(err) => return Err(AudioOutputError::OpenStreamError), - }; - - return match config.sample_format(){ - cpal::SampleFormat::I8 => AudioOutput::::create_stream(spec, &device, &config.into(), duration), - cpal::SampleFormat::I16 => AudioOutput::::create_stream(spec, &device, &config.into(), duration), - cpal::SampleFormat::I32 => AudioOutput::::create_stream(spec, &device, &config.into(), duration), - //cpal::SampleFormat::I64 => AudioOutput::::create_stream(spec, &device, &config.into(), duration), - cpal::SampleFormat::U8 => AudioOutput::::create_stream(spec, &device, &config.into(), duration), - cpal::SampleFormat::U16 => AudioOutput::::create_stream(spec, &device, &config.into(), duration), - cpal::SampleFormat::U32 => AudioOutput::::create_stream(spec, &device, &config.into(), duration), - //cpal::SampleFormat::U64 => AudioOutput::::create_stream(spec, &device, &config.into(), duration), - cpal::SampleFormat::F32 => AudioOutput::::create_stream(spec, &device, &config.into(), duration), - cpal::SampleFormat::F64 => AudioOutput::::create_stream(spec, &device, &config.into(), duration), - _ => todo!(), - }; + let host = cpal::default_host(); + + // Uses default audio device + let device = match host.default_output_device() { + Some(device) => device, + _ => return Err(AudioOutputError::OpenStreamError), + }; + + let config = match device.default_output_config() { + Ok(config) => config, + Err(err) => return Err(AudioOutputError::OpenStreamError), + }; + + return match config.sample_format() { + cpal::SampleFormat::I8 => { + AudioOutput::::create_stream(spec, &device, &config.into(), duration) + } + cpal::SampleFormat::I16 => { + AudioOutput::::create_stream(spec, &device, &config.into(), duration) + } + cpal::SampleFormat::I32 => { + AudioOutput::::create_stream(spec, &device, &config.into(), duration) + } + //cpal::SampleFormat::I64 => AudioOutput::::create_stream(spec, &device, &config.into(), duration), + cpal::SampleFormat::U8 => { + AudioOutput::::create_stream(spec, &device, &config.into(), duration) + } + cpal::SampleFormat::U16 => { + AudioOutput::::create_stream(spec, &device, &config.into(), duration) + } + cpal::SampleFormat::U32 => { + AudioOutput::::create_stream(spec, &device, &config.into(), duration) + } + //cpal::SampleFormat::U64 => AudioOutput::::create_stream(spec, &device, &config.into(), duration), + cpal::SampleFormat::F32 => { + AudioOutput::::create_stream(spec, &device, &config.into(), duration) + } + cpal::SampleFormat::F64 => { + AudioOutput::::create_stream(spec, &device, &config.into(), duration) + } + _ => todo!(), + }; } impl AudioOutput { // Creates the stream (TODO: Merge w/open_stream?) - fn create_stream(spec: SignalSpec, device: &cpal::Device, config: &cpal::StreamConfig, duration: Duration) -> Result> { + fn create_stream( + spec: SignalSpec, + device: &cpal::Device, + config: &cpal::StreamConfig, + duration: Duration, + ) -> Result> { let num_channels = config.channels as usize; - + // Ring buffer is created with 200ms audio capacity let ring_len = ((50 * config.sample_rate.0 as usize) / 1000) * num_channels; - let ring_buf= rb::SpscRb::new(ring_len); - + let ring_buf = rb::SpscRb::new(ring_len); + let ring_buf_producer = ring_buf.producer(); let ring_buf_consumer = ring_buf.consumer(); - + let stream_result = device.build_output_stream( - config, + config, move |data: &mut [T], _: &cpal::OutputCallbackInfo| { // Writes samples in the ring buffer to the audio output let written = ring_buf_consumer.read(data).unwrap_or(0); - + // Mutes non-written samples - data[written..].iter_mut().for_each(|sample| *sample = T::MID); + data[written..] + .iter_mut() + .for_each(|sample| *sample = T::MID); }, //TODO: Handle error here properly move |err| println!("Yeah we erroring out here"), - None + None, ); - + if let Err(err) = stream_result { return Err(AudioOutputError::OpenStreamError); } - + let stream = stream_result.unwrap(); - + //Start output stream if let Err(err) = stream.play() { return Err(AudioOutputError::PlayStreamError); } - + let sample_buf = SampleBuffer::::new(duration, spec); - + let mut resampler = None; if spec.rate != config.sample_rate.0 { println!("Resampling enabled"); - resampler = Some(Resampler::new(spec, config.sample_rate.0 as usize, duration)) + resampler = Some(Resampler::new( + spec, + config.sample_rate.0 as usize, + duration, + )) } - - Ok(Box::new(AudioOutput { ring_buf_producer, sample_buf, stream, resampler})) + + Ok(Box::new(AudioOutput { + ring_buf_producer, + sample_buf, + stream, + resampler, + })) } } @@ -131,7 +174,7 @@ impl AudioStream for AudioOutput { if decoded.frames() == 0 { return Ok(()); } - + let mut samples: &[T] = if let Some(resampler) = &mut self.resampler { // Resamples if required match resampler.resample(decoded) { @@ -142,25 +185,25 @@ impl AudioStream for AudioOutput { self.sample_buf.copy_interleaved_ref(decoded); self.sample_buf.samples() }; - + // Write samples into ring buffer while let Some(written) = self.ring_buf_producer.write_blocking(samples) { samples = &samples[written..]; } - + Ok(()) } - + // Flushes resampler if needed fn flush(&mut self) { if let Some(resampler) = &mut self.resampler { - let mut stale_samples = resampler.flush().unwrap_or_default(); - + let mut stale_samples = resampler.flush().unwrap_or_default(); + while let Some(written) = self.ring_buf_producer.write_blocking(stale_samples) { stale_samples = &stale_samples[written..]; } } - + let _ = self.stream.pause(); } -} \ No newline at end of file +} diff --git a/src/music_player/music_player.rs b/src/music_player/music_player.rs index 5ab0ad9..e3c43ad 100644 --- a/src/music_player/music_player.rs +++ b/src/music_player/music_player.rs @@ -1,31 +1,31 @@ -use std::sync::mpsc::{self, Sender, Receiver}; +use std::io::SeekFrom; +use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::{Arc, RwLock}; use std::thread; -use std::io::SeekFrom; use async_std::io::ReadExt; use async_std::task; use futures::future::join_all; -use symphonia::core::codecs::{CODEC_TYPE_NULL, DecoderOptions, Decoder}; +use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::errors::Error; use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo}; -use symphonia::core::io::{MediaSourceStream, MediaSource}; +use symphonia::core::io::{MediaSource, MediaSourceStream}; use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; -use symphonia::core::errors::Error; use symphonia::core::units::{Time, TimeBase}; use futures::AsyncBufRead; use crate::music_controller::config::Config; use crate::music_player::music_output::AudioStream; -use crate::music_processor::music_processor::MusicProcessor; -use crate::music_storage::music_db::{URI, Song}; -use crate::music_tracker::music_tracker::{MusicTracker, TrackerError, LastFM, DiscordRPC, ListenBrainz}; +use crate::music_storage::music_db::{Song, URI}; +use crate::music_tracker::music_tracker::{ + DiscordRPC, LastFM, ListenBrainz, MusicTracker, TrackerError, +}; // Struct that controls playback of music pub struct MusicPlayer { - pub music_processor: MusicProcessor, player_status: PlayerStatus, music_trackers: Vec>, current_song: Arc>>, @@ -49,18 +49,12 @@ pub enum DecoderMessage { Pause, Stop, SeekTo(u64), - DSP(DSPMessage) } #[derive(Clone)] pub enum TrackerMessage { Track(Song), - TrackNow(Song) -} - -#[derive(Debug, Clone)] -pub enum DSPMessage { - UpdateProcessor(Box) + TrackNow(Song), } // Holds a song decoder reader, etc @@ -75,48 +69,59 @@ struct SongHandler { impl SongHandler { pub fn new(uri: &URI) -> Result { // Opens remote/local source and creates MediaSource for symphonia - let config = RemoteOptions {media_buffer_len: 10000, forward_buffer_len: 10000}; + let config = RemoteOptions { + media_buffer_len: 10000, + forward_buffer_len: 10000, + }; let src: Box = match uri { - URI::Local(path) => { - match std::fs::File::open(path) { - Ok(file) => Box::new(file), - Err(_) => return Err(()), - } + URI::Local(path) => match std::fs::File::open(path) { + Ok(file) => Box::new(file), + Err(_) => return Err(()), }, URI::Remote(_, location) => { - match RemoteSource::new(location.as_ref(), &config) { + match RemoteSource::new(location.to_str().unwrap(), &config) { Ok(remote_source) => Box::new(remote_source), Err(_) => return Err(()), } - }, + } + _ => todo!(), }; - + let mss = MediaSourceStream::new(src, Default::default()); - + // Use default metadata and format options let meta_opts: MetadataOptions = Default::default(); let fmt_opts: FormatOptions = Default::default(); - let mut hint = Hint::new(); - - let probed = symphonia::default::get_probe().format(&hint, mss, &fmt_opts, &meta_opts).expect("Unsupported format"); - - let mut reader = probed.format; - - let track = reader.tracks() - .iter() - .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) - .expect("no supported audio tracks"); - + let hint = Hint::new(); + + let probed = symphonia::default::get_probe() + .format(&hint, mss, &fmt_opts, &meta_opts) + .expect("Unsupported format"); + + let reader = probed.format; + + let track = reader + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .expect("no supported audio tracks"); + let time_base = track.codec_params.time_base; let duration = track.codec_params.n_frames; - + let dec_opts: DecoderOptions = Default::default(); - - let mut decoder = symphonia::default::get_codecs().make(&track.codec_params, &dec_opts) - .expect("unsupported codec"); - - return Ok(SongHandler {reader, decoder, time_base, duration}); + + let decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &dec_opts) + .expect("unsupported codec"); + + return Ok(SongHandler { + reader, + decoder, + time_base, + duration, + }); } } @@ -126,11 +131,15 @@ impl MusicPlayer { let (message_sender, message_receiver) = mpsc::channel(); let (status_sender, status_receiver) = mpsc::channel(); let current_song = Arc::new(RwLock::new(None)); - - MusicPlayer::start_player(message_receiver, status_sender, config.clone(), current_song.clone()); - + + MusicPlayer::start_player( + message_receiver, + status_sender, + config.clone(), + current_song.clone(), + ); + MusicPlayer { - music_processor: MusicProcessor::new(), music_trackers: Vec::new(), player_status: PlayerStatus::Stopped, current_song, @@ -139,15 +148,19 @@ impl MusicPlayer { config, } } - - fn start_tracker(status_sender: Sender>, tracker_receiver: Receiver, config: Arc>) { + + fn start_tracker( + status_sender: Sender>, + tracker_receiver: Receiver, + config: Arc>, + ) { thread::spawn(move || { let global_config = &*config.read().unwrap(); // Sets local config for trackers to detect changes let local_config = global_config.clone(); let mut trackers: Vec> = Vec::new(); // Updates local trackers to the music controller config // TODO: refactor - let update_trackers = |trackers: &mut Vec>|{ + let update_trackers = |trackers: &mut Vec>| { if let Some(lastfm_config) = global_config.lastfm.clone() { if lastfm_config.enabled { trackers.push(Box::new(LastFM::new(&lastfm_config))); @@ -170,20 +183,24 @@ impl MusicPlayer { if local_config != global_config { update_trackers(&mut trackers); } - + let mut results = Vec::new(); task::block_on(async { let mut futures = Vec::new(); for tracker in trackers.iter_mut() { match message.clone() { - Ok(TrackerMessage::Track(song)) => futures.push(tracker.track_song(song)), - Ok(TrackerMessage::TrackNow(song)) => futures.push(tracker.track_now(song)), - Err(_) => {}, + Ok(TrackerMessage::Track(song)) => { + futures.push(tracker.track_song(song)) + } + Ok(TrackerMessage::TrackNow(song)) => { + futures.push(tracker.track_now(song)) + } + Err(_) => {} } } results = join_all(futures).await; }); - + for result in results { status_sender.send(result).unwrap_or_default() } @@ -191,30 +208,39 @@ impl MusicPlayer { } }); } - + // Opens and plays song with given path in separate thread - fn start_player(message_receiver: Receiver, status_sender: Sender, config: Arc>, current_song: Arc>>) { + fn start_player( + message_receiver: Receiver, + status_sender: Sender, + config: Arc>, + current_song: Arc>>, + ) { // Creates thread that audio is decoded in thread::spawn(move || { let current_song = current_song; - + let mut song_handler = None; - + let mut seek_time: Option = None; - + let mut audio_output: Option> = None; - - let mut music_processor = MusicProcessor::new(); - - let (tracker_sender, tracker_receiver): (Sender, Receiver) = mpsc::channel(); - let (tracker_status_sender, tracker_status_receiver): (Sender>, Receiver>) = mpsc::channel(); - + + let (tracker_sender, tracker_receiver): ( + Sender, + Receiver, + ) = mpsc::channel(); + let (tracker_status_sender, tracker_status_receiver): ( + Sender>, + Receiver>, + ) = mpsc::channel(); + MusicPlayer::start_tracker(tracker_status_sender, tracker_receiver, config); - + let mut song_tracked = false; let mut song_time = 0.0; let mut paused = true; - 'main_decode: loop { + 'main_decode: loop { 'handle_message: loop { let message = if paused { // Pauses playback by blocking on waiting for new player messages @@ -232,7 +258,7 @@ impl MusicPlayer { // Handles message received from MusicPlayer struct match message { Some(DecoderMessage::OpenSong(song)) => { - let song_uri = song.path.clone(); + let song_uri = song.location.clone(); match SongHandler::new(&song_uri) { Ok(new_handler) => { song_handler = Some(new_handler); @@ -253,26 +279,29 @@ impl MusicPlayer { status_sender.send(PlayerStatus::Paused).unwrap(); } Some(DecoderMessage::SeekTo(time)) => seek_time = Some(time), - Some(DecoderMessage::DSP(dsp_message)) => { - match dsp_message { - DSPMessage::UpdateProcessor(new_processor) => music_processor = *new_processor, - } - } // Exits main decode loop and subsequently ends thread Some(DecoderMessage::Stop) => { status_sender.send(PlayerStatus::Stopped).unwrap(); - break 'main_decode + break 'main_decode; } - None => {}, + None => {} } status_sender.send(PlayerStatus::Error).unwrap(); - } + } // In theory this check should not need to occur? - if let (Some(song_handler), current_song) = (&mut song_handler, &*current_song.read().unwrap()) { + if let (Some(song_handler), current_song) = + (&mut song_handler, &*current_song.read().unwrap()) + { match seek_time { Some(time) => { - let seek_to = SeekTo::Time { time: Time::from(time), track_id: Some(0) }; - song_handler.reader.seek(SeekMode::Accurate, seek_to).unwrap(); + let seek_to = SeekTo::Time { + time: Time::from(time), + track_id: Some(0), + }; + song_handler + .reader + .seek(SeekMode::Accurate, seek_to) + .unwrap(); seek_time = None; } None => {} //Nothing to do! @@ -285,60 +314,58 @@ impl MusicPlayer { panic!("{}", err); } }; - + if let (Some(time_base), Some(song)) = (song_handler.time_base, current_song) { let time_units = time_base.calc_time(packet.ts); song_time = time_units.seconds as f64 + time_units.frac; // Tracks song now if song has just started if song_time == 0.0 { - tracker_sender.send(TrackerMessage::TrackNow(song.clone())).unwrap(); + tracker_sender + .send(TrackerMessage::TrackNow(song.clone())) + .unwrap(); } - + if let Some(duration) = song_handler.duration { let song_duration = time_base.calc_time(duration); - let song_duration_secs = song_duration.seconds as f64 + song_duration.frac; + let song_duration_secs = + song_duration.seconds as f64 + song_duration.frac; // Tracks song if current time is past half of total song duration or past 4 minutes - if (song_duration_secs / 2.0 < song_time || song_time > 240.0) && !song_tracked { + if (song_duration_secs / 2.0 < song_time || song_time > 240.0) + && !song_tracked + { song_tracked = true; - tracker_sender.send(TrackerMessage::Track(song.clone())).unwrap(); + tracker_sender + .send(TrackerMessage::Track(song.clone())) + .unwrap(); } } } - - status_sender.send(PlayerStatus::Playing(song_time)).unwrap(); - + + status_sender + .send(PlayerStatus::Playing(song_time)) + .unwrap(); + match song_handler.decoder.decode(&packet) { Ok(decoded) => { // Opens audio stream if there is not one if audio_output.is_none() { let spec = *decoded.spec(); let duration = decoded.capacity() as u64; - - audio_output.replace(crate::music_player::music_output::open_stream(spec, duration).unwrap()); + + audio_output.replace( + crate::music_player::music_output::open_stream(spec, duration) + .unwrap(), + ); } - // Handles audio normally provided there is an audio stream - if let Some(ref mut audio_output) = audio_output { - // Changes buffer of the MusicProcessor if the packet has a differing capacity or spec - if music_processor.audio_buffer.capacity() != decoded.capacity() ||music_processor.audio_buffer.spec() != decoded.spec() { - let spec = *decoded.spec(); - let duration = decoded.capacity() as u64; - - music_processor.set_buffer(duration, spec); - } - let transformed_audio = music_processor.process(&decoded); - - // Writes transformed packet to audio out - audio_output.write(transformed_audio).unwrap() - } - }, + } Err(Error::IoError(_)) => { // rest in peace packet continue; - }, + } Err(Error::DecodeError(_)) => { // may you one day be decoded continue; - }, + } Err(err) => { // Unrecoverable, though shouldn't panic here panic!("{}", err); @@ -348,28 +375,28 @@ impl MusicPlayer { } }); } - + // Updates status by checking on messages from spawned thread fn update_player(&mut self) { for message in self.status_receiver.try_recv() { self.player_status = message; } } - - pub fn get_current_song(&self) -> Option{ + + pub fn get_current_song(&self) -> Option { match self.current_song.try_read() { Ok(song) => return (*song).clone(), Err(_) => return None, } } - + // Sends message to spawned thread pub fn send_message(&mut self, message: DecoderMessage) { self.update_player(); // Checks that message sender exists before sending a message off self.message_sender.send(message).unwrap(); } - + pub fn get_status(&mut self) -> PlayerStatus { self.update_player(); return self.player_status; @@ -393,7 +420,7 @@ impl Default for RemoteOptions { media_buffer_len: 100000, forward_buffer_len: 1024, } - } + } } /// A remote source of media @@ -407,12 +434,12 @@ struct RemoteSource { impl RemoteSource { /// Creates a new RemoteSource with given uri and configuration pub fn new(uri: &str, config: &RemoteOptions) -> Result { - let mut response = task::block_on(async { + let mut response = task::block_on(async { return surf::get(uri).await; })?; - + let reader = response.take_body().into_reader(); - + Ok(RemoteSource { reader, media_buffer: Vec::new(), @@ -432,16 +459,16 @@ impl std::io::Read for RemoteSource { Ok(_) => { self.media_buffer.extend_from_slice(&buffer); return Ok(()); - }, + } Err(err) => return Err(err), } }); match read_bytes { Err(err) => return Err(err), - _ => {}, + _ => {} } } - // Reads bytes from the media buffer into the buffer given by + // Reads bytes from the media buffer into the buffer given by let mut bytes_read = 0; for location in 0..1024 { if (location + self.offset as usize) < self.media_buffer.len() { @@ -449,7 +476,7 @@ impl std::io::Read for RemoteSource { bytes_read += 1; } } - + self.offset += bytes_read; return Ok(bytes_read as usize); } @@ -462,13 +489,13 @@ impl std::io::Seek for RemoteSource { match pos { // Offset is set to given position SeekFrom::Start(pos) => { - if pos > self.media_buffer.len() as u64{ + if pos > self.media_buffer.len() as u64 { self.offset = self.media_buffer.len() as u64; } else { self.offset = pos; } return Ok(self.offset); - }, + } // Offset is set to length of buffer + given position SeekFrom::End(pos) => { if self.media_buffer.len() as u64 + pos as u64 > self.media_buffer.len() as u64 { @@ -477,16 +504,16 @@ impl std::io::Seek for RemoteSource { self.offset = self.media_buffer.len() as u64 + pos as u64; } return Ok(self.offset); - }, + } // Offset is set to current offset + given position SeekFrom::Current(pos) => { - if self.offset + pos as u64 > self.media_buffer.len() as u64{ + if self.offset + pos as u64 > self.media_buffer.len() as u64 { self.offset = self.media_buffer.len() as u64; } else { self.offset += pos as u64 } return Ok(self.offset); - }, + } } } } @@ -495,7 +522,7 @@ impl MediaSource for RemoteSource { fn is_seekable(&self) -> bool { return true; } - + fn byte_len(&self) -> Option { return None; } diff --git a/src/music_player/music_resampler.rs b/src/music_player/music_resampler.rs index f654a17..4040835 100644 --- a/src/music_player/music_resampler.rs +++ b/src/music_player/music_resampler.rs @@ -48,7 +48,8 @@ where // Interleave the planar samples from Rubato. let num_channels = self.output.len(); - self.interleaved.resize(num_channels * self.output[0].len(), T::MID); + self.interleaved + .resize(num_channels * self.output[0].len(), T::MID); for (i, frame) in self.interleaved.chunks_exact_mut(num_channels).enumerate() { for (ch, s) in frame.iter_mut().enumerate() { @@ -81,7 +82,13 @@ where let input = vec![Vec::with_capacity(duration); num_channels]; - Self { resampler, input, output, duration, interleaved: Default::default() } + Self { + resampler, + input, + output, + duration, + interleaved: Default::default(), + } } /// Resamples a planar/non-interleaved input. @@ -144,4 +151,4 @@ where let src = input.chan(c); dst.extend(src.iter().map(|&s| s.into_sample())); } -} \ No newline at end of file +} diff --git a/src/music_processor/music_processor.rs b/src/music_processor/music_processor.rs deleted file mode 100644 index e6d6608..0000000 --- a/src/music_processor/music_processor.rs +++ /dev/null @@ -1,43 +0,0 @@ -use std::fmt::Debug; - -use symphonia::core::audio::{AudioBuffer, AudioBufferRef, Signal, AsAudioBufferRef, SignalSpec}; - -#[derive(Clone)] -pub struct MusicProcessor { - pub audio_buffer: AudioBuffer, - pub audio_volume: f32, -} - -impl Debug for MusicProcessor { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("MusicProcessor").field("audio_volume", &self.audio_volume).finish() - } -} - -impl MusicProcessor { - /// Returns new MusicProcessor with blank buffer and 100% volume - pub fn new() -> Self { - MusicProcessor { - audio_buffer: AudioBuffer::unused(), - audio_volume: 1.0, - } - } - - /// Processes audio samples - /// - /// Currently only supports transformations of volume - pub fn process(&mut self, audio_buffer_ref: &AudioBufferRef) -> AudioBufferRef { - audio_buffer_ref.convert(&mut self.audio_buffer); - - let process = |sample| sample * self.audio_volume; - - self.audio_buffer.transform(process); - - return self.audio_buffer.as_audio_buffer_ref(); - } - - /// Sets buffer of the MusicProcessor - pub fn set_buffer(&mut self, duration: u64, spec: SignalSpec) { - self.audio_buffer = AudioBuffer::new(duration, spec); - } -} \ No newline at end of file diff --git a/src/music_storage/music_db.rs b/src/music_storage/music_db.rs index 093d7c5..3372323 100644 --- a/src/music_storage/music_db.rs +++ b/src/music_storage/music_db.rs @@ -1,397 +1,870 @@ -use file_format::{FileFormat, Kind}; -use serde::Deserialize; -use lofty::{Accessor, AudioFile, Probe, TaggedFileExt, ItemKey, ItemValue, TagType}; -use rusqlite::{params, Connection}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::Duration; -use time::Date; -use walkdir::WalkDir; - +// Crate things +use super::utils::{normalize, read_library, write_library, find_images}; use crate::music_controller::config::Config; -#[derive(Debug, Clone)] +// Various std things +use std::collections::BTreeMap; +use std::error::Error; +use std::ops::ControlFlow::{Break, Continue}; + +// Files +use rcue::parser::parse_from_file; +use file_format::{FileFormat, Kind}; +use walkdir::WalkDir; +use lofty::{AudioFile, ItemKey, ItemValue, Probe, TagType, TaggedFileExt}; +use std::fs; +use std::path::{Path, PathBuf}; + +// Time +use chrono::{serde::ts_seconds_option, DateTime, Utc}; +use std::time::Duration; + +// Serialization/Compression +use base64::{engine::general_purpose, Engine as _}; +use serde::{Deserialize, Serialize}; + +// Fun parallel stuff +use rayon::prelude::*; +use std::sync::{Arc, Mutex, RwLock}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub enum AlbumArt { + Embedded(usize), + External(URI), +} + +impl AlbumArt { + pub fn uri(&self) -> Option<&URI> { + match self { + Self::Embedded(_) => None, + Self::External(uri) => Some(uri), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum Tag { + Title, + Album, + Artist, + AlbumArtist, + Genre, + Comment, + Track, + Disk, + Key(String), + Field(String), +} + +impl ToString for Tag { + fn to_string(&self) -> String { + match self { + Self::Title => "TrackTitle".into(), + Self::Album => "AlbumTitle".into(), + Self::Artist => "TrackArtist".into(), + Self::AlbumArtist => "AlbumArtist".into(), + Self::Genre => "Genre".into(), + Self::Comment => "Comment".into(), + Self::Track => "TrackNumber".into(), + Self::Disk => "DiscNumber".into(), + Self::Key(key) => key.into(), + Self::Field(f) => f.into(), + } + } +} + +/// Stores information about a single song +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Song { - pub path: URI, - pub title: Option, - pub album: Option, - pub tracknum: Option, - pub artist: Option, - pub date: Option, - pub genre: Option, - pub plays: Option, - pub favorited: Option, - pub format: Option, // TODO: Make this a proper FileFormat eventually - pub duration: Option, - pub custom_tags: Option>, + pub location: URI, + pub plays: i32, + pub skips: i32, + pub favorited: bool, + pub rating: Option, + pub format: Option, + pub duration: Duration, + pub play_time: Duration, + #[serde(with = "ts_seconds_option")] + pub last_played: Option>, + #[serde(with = "ts_seconds_option")] + pub date_added: Option>, + #[serde(with = "ts_seconds_option")] + pub date_modified: Option>, + pub album_art: Vec, + pub tags: BTreeMap, } -#[derive(Clone, Debug)] -pub enum URI{ - Local(String), - Remote(Service, String), +impl Song { + /** + * Get a tag's value + * + * ``` + * // Assuming an already created song: + * + * let tag = this_song.get_tag(Tag::Title); + * + * assert_eq!(tag, "Some Song Title"); + * ``` + **/ + pub fn get_tag(&self, target_key: &Tag) -> Option<&String> { + self.tags.get(target_key) + } + + pub fn get_field(&self, target_field: &str) -> Option { + match target_field { + "location" => Some(self.location.clone().path_string()), + "plays" => Some(self.plays.clone().to_string()), + "format" => match self.format { + Some(format) => match format.short_name() { + Some(short) => Some(short.to_string()), + None => None, + }, + None => None, + }, + _ => todo!(), // Other field types are not yet supported + } + } } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum URI { + Local(PathBuf), + Cue { + location: PathBuf, + index: usize, + start: Duration, + end: Duration, + }, + Remote(Service, PathBuf), +} + +impl URI { + pub fn index(&self) -> Result<&usize, Box> { + match self { + URI::Local(_) => Err("\"Local\" has no stored index".into()), + URI::Remote(_, _) => Err("\"Remote\" has no stored index".into()), + URI::Cue { index, .. } => Ok(index), + } + } + + /// Returns the start time of a CUEsheet song, or an + /// error if the URI is not a Cue variant + pub fn start(&self) -> Result<&Duration, Box> { + match self { + URI::Local(_) => Err("\"Local\" has no starting time".into()), + URI::Remote(_, _) => Err("\"Remote\" has no starting time".into()), + URI::Cue { start, .. } => Ok(start), + } + } + + /// Returns the end time of a CUEsheet song, or an + /// error if the URI is not a Cue variant + pub fn end(&self) -> Result<&Duration, Box> { + match self { + URI::Local(_) => Err("\"Local\" has no starting time".into()), + URI::Remote(_, _) => Err("\"Remote\" has no starting time".into()), + URI::Cue { end, .. } => Ok(end), + } + } + + /// Returns the location as a PathBuf + pub fn path(&self) -> &PathBuf { + match self { + URI::Local(location) => location, + URI::Cue { location, .. } => location, + URI::Remote(_, location) => location, + } + } + + pub fn path_string(&self) -> String { + let path_str = match self { + URI::Local(location) => location.as_path().to_string_lossy(), + URI::Cue { location, .. } => location.as_path().to_string_lossy(), + URI::Remote(_, location) => location.as_path().to_string_lossy(), + }; + path_str.to_string() + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum Service { InternetRadio, Spotify, Youtube, + None, } +#[derive(Clone, Debug)] +pub struct Album<'a> { + title: &'a String, + artist: Option<&'a String>, + cover: Option<&'a AlbumArt>, + discs: BTreeMap>, +} + +impl Album<'_> { + /// Returns the album title + pub fn title(&self) -> &String { + self.title + } + + /// Returns the Album Artist, if they exist + pub fn artist(&self) -> Option<&String> { + self.artist + } + + /// Returns the album cover as an AlbumArt struct, if it exists + pub fn cover(&self) -> Option<&AlbumArt> { + self.cover + } + + pub fn tracks(&self) -> Vec<&Song> { + let mut songs = Vec::new(); + for disc in &self.discs { + songs.append(&mut disc.1.clone()) + } + songs + } + + pub fn discs(&self) -> &BTreeMap> { + &self.discs + } + + /// Returns the specified track at `index` from the album, returning + /// an error if the track index is out of range + pub fn track(&self, disc: usize, index: usize) -> Option<&Song> { + Some(self.discs.get(&disc)?[index]) + } + + /// Returns the number of songs in the album + pub fn len(&self) -> usize { + let mut total = 0; + for disc in &self.discs { + total += disc.1.len(); + } + total + } +} + +const BLOCKED_EXTENSIONS: [&str; 3] = ["vob", "log", "txt"]; + #[derive(Debug)] -pub struct Playlist { - title: String, - cover_art: Box, +pub struct MusicLibrary { + pub library: Vec, } -pub fn create_db() -> Result<(), rusqlite::Error> { - let path = "./music_database.db3"; - let db_connection = Connection::open(path)?; +impl MusicLibrary { + /// Initialize the database + /// + /// If the database file already exists, return the [MusicLibrary], otherwise create + /// the database first. This needs to be run before anything else to retrieve + /// the [MusicLibrary] Vec + pub fn init(config: Arc>) -> Result> { + let global_config = &*config.read().unwrap(); + let mut library: Vec = Vec::new(); + let mut backup_path = global_config.db_path.clone(); + backup_path.set_extension("bkp"); - db_connection.pragma_update(None, "synchronous", "0")?; - db_connection.pragma_update(None, "journal_mode", "WAL")?; + match global_config.db_path.exists() { + true => { + library = read_library(*global_config.db_path.clone())?; + } + false => { + // Create the database if it does not exist + // possibly from the backup file + if backup_path.exists() { + library = read_library(*backup_path.clone())?; + write_library(&library, global_config.db_path.to_path_buf(), false)?; + } else { + write_library(&library, global_config.db_path.to_path_buf(), false)?; + } + } + }; - // Create the important tables - db_connection.execute( - "CREATE TABLE music_collection ( - song_path TEXT PRIMARY KEY, - title TEXT, - album TEXT, - tracknum INTEGER, - artist TEXT, - date INTEGER, - genre TEXT, - plays INTEGER, - favorited BLOB, - format TEXT, - duration INTEGER - )", - (), // empty list of parameters. - )?; - - db_connection.execute( - "CREATE TABLE playlists ( - playlist_name TEXT NOT NULL, - song_path TEXT NOT NULL, - FOREIGN KEY(song_path) REFERENCES music_collection(song_path) - )", - (), // empty list of parameters. - )?; - - db_connection.execute( - "CREATE TABLE custom_tags ( - song_path TEXT NOT NULL, - tag TEXT NOT NULL, - tag_value TEXT, - FOREIGN KEY(song_path) REFERENCES music_collection(song_path) - )", - (), // empty list of parameters. - )?; - - Ok(()) -} - -fn path_in_db(query_path: &Path, connection: &Connection) -> bool { - let query_string = format!("SELECT EXISTS(SELECT 1 FROM music_collection WHERE song_path='{}')", query_path.to_string_lossy()); - - let mut query_statement = connection.prepare(&query_string).unwrap(); - let mut rows = query_statement.query([]).unwrap(); - - match rows.next().unwrap() { - Some(value) => value.get::(0).unwrap(), - None => false + Ok(Self { library }) } -} - -pub fn find_all_music( - config: &Config, - target_path: &str, -) -> Result<(), Box> { - let db_connection = Connection::open(&*config.db_path)?; - - db_connection.pragma_update(None, "synchronous", "0")?; - db_connection.pragma_update(None, "journal_mode", "WAL")?; - - let mut current_dir = PathBuf::new(); - for entry in WalkDir::new(target_path).follow_links(true).into_iter().filter_map(|e| e.ok()) { - let target_file = entry; - let is_file = fs::metadata(target_file.path())?.is_file(); - - // Ensure the target is a file and not a directory, if it isn't, skip this loop - if !is_file { - current_dir = target_file.into_path(); - continue; + /// Serializes the database out to the file specified in the config + pub fn save(&self, config: &Config) -> Result<(), Box> { + match config.db_path.try_exists() { + Ok(exists) => { + write_library(&self.library, config.db_path.to_path_buf(), exists)?; + } + Err(error) => return Err(error.into()), } - let format = FileFormat::from_file(target_file.path())?; - let extension = target_file - .path() - .extension() - .expect("Could not find file extension"); + Ok(()) + } - // If it's a normal file, add it to the database - // if it's a cuesheet, do a bunch of fancy stuff - if format.kind() == Kind::Audio { - add_file_to_db(target_file.path(), &db_connection) - } else if extension.to_ascii_lowercase() == "cue" { - // TODO: implement cuesheet support + /// Returns the library size in number of tracks + pub fn size(&self) -> usize { + self.library.len() + } + + /// Queries for a [Song] by its [URI], returning a single `Song` + /// with the `URI` that matches + fn query_uri(&self, path: &URI) -> Option<(&Song, usize)> { + let result = self.library.par_iter().enumerate().try_for_each(|(i, track)| { + if path == &track.location { + return std::ops::ControlFlow::Break((track, i)); + } + Continue(()) + }); + + match result { + Break(song) => Some(song), + Continue(_) => None, } } - // create the indexes after all the data is inserted - db_connection.execute( - "CREATE INDEX path_index ON music_collection (song_path)", () - )?; + /// Queries for a [Song] by its [PathBuf], returning a `Vec` + /// with matching `PathBuf`s + fn query_path(&self, path: &PathBuf) -> Option> { + let result: Arc>> = Arc::new(Mutex::new(Vec::new())); + let _ = self.library.par_iter().for_each(|track| { + if path == track.location.path() { + result.clone().lock().unwrap().push(&track); + return; + } + }); + if result.lock().unwrap().len() > 0 { + Some(Arc::try_unwrap(result).unwrap().into_inner().unwrap()) + } else { + None + } + } - db_connection.execute( - "CREATE INDEX custom_tags_index ON custom_tags (song_path)", () - )?; + /// Finds all the audio files within a specified folder + pub fn scan_folder( + &mut self, + target_path: &str, + config: &Config, + ) -> Result> { + let mut total = 0; + for target_file in WalkDir::new(target_path) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = target_file.path(); - Ok(()) -} + // Ensure the target is a file and not a directory, + // if it isn't a file, skip this loop + if !path.is_file() { + continue; + } -pub fn add_file_to_db(target_file: &Path, connection: &Connection) { - // TODO: Fix error handling here - let tagged_file = match lofty::read_from_path(target_file) { - Ok(tagged_file) => tagged_file, + /* TODO: figure out how to increase the speed of this maybe + // Check if the file path is already in the db + if self.query_uri(&URI::Local(path.to_path_buf())).is_some() { + continue; + } - Err(_) => match Probe::open(target_file) - .expect("ERROR: Bad path provided!") - .read() { + // Save periodically while scanning + i += 1; + if i % 500 == 0 { + self.save(config).unwrap(); + } + */ + + let format = FileFormat::from_file(&path)?; + let extension = match path.extension() { + Some(ext) => ext.to_string_lossy().to_ascii_lowercase(), + None => String::new(), + }; + + // If it's a normal file, add it to the database + // if it's a cuesheet, do a bunch of fancy stuff + if (format.kind() == Kind::Audio || format.kind() == Kind::Video) + && !BLOCKED_EXTENSIONS.contains(&extension.as_str()) + { + match self.add_file(&target_file.path()) { + Ok(_) => total += 1, + Err(_error) => { + println!("{}, {:?}: {}", format, target_file.file_name(), _error) + } // TODO: Handle more of these errors + }; + } else if extension == "cue" { + total += match self.add_cuesheet(&target_file.path().to_path_buf()) { + Ok(added) => added, + Err(error) => { + println!("{}", error); + 0 + } + } + } + } + + // Save the database after scanning finishes + self.save(&config).unwrap(); + + Ok(total) + } + + pub fn add_file(&mut self, target_file: &Path) -> Result<(), Box> { + // TODO: Fix error handling here + let tagged_file = match lofty::read_from_path(target_file) { + Ok(tagged_file) => tagged_file, + + Err(_) => match Probe::open(target_file)?.read() { Ok(tagged_file) => tagged_file, - Err(_) => return - } - }; - - // Ensure the tags exist, if not, insert blank data - let blank_tag = &lofty::Tag::new(TagType::Id3v2); - let tag = match tagged_file.primary_tag() { - Some(primary_tag) => primary_tag, - - None => match tagged_file.first_tag() { - Some(first_tag) => first_tag, - None => blank_tag - }, - }; - - let mut custom_insert = String::new(); - let mut loops = 0; - for item in tag.items() { - let mut custom_key = String::new(); - match item.key() { - ItemKey::TrackArtist | - ItemKey::TrackTitle | - ItemKey::AlbumTitle | - ItemKey::Genre | - ItemKey::TrackNumber | - ItemKey::Year | - ItemKey::RecordingDate => continue, - ItemKey::Unknown(unknown) => custom_key.push_str(&unknown), - custom => custom_key.push_str(&format!("{:?}", custom)) - // TODO: This is kind of cursed, maybe fix? + Err(error) => return Err(error.into()), + }, }; - let custom_value = match item.value() { - ItemValue::Text(value) => value, - ItemValue::Locator(value) => value, - ItemValue::Binary(_) => "" + // Ensure the tags exist, if not, insert blank data + let blank_tag = &lofty::Tag::new(TagType::Id3v2); + let tag = match tagged_file.primary_tag() { + Some(primary_tag) => primary_tag, + + None => match tagged_file.first_tag() { + Some(first_tag) => first_tag, + None => blank_tag, + }, }; - if loops > 0 { - custom_insert.push_str(", "); + let mut tags: BTreeMap = BTreeMap::new(); + for item in tag.items() { + let key = match item.key() { + ItemKey::TrackTitle => Tag::Title, + ItemKey::TrackNumber => Tag::Track, + ItemKey::TrackArtist => Tag::Artist, + ItemKey::AlbumArtist => Tag::AlbumArtist, + ItemKey::Genre => Tag::Genre, + ItemKey::Comment => Tag::Comment, + ItemKey::AlbumTitle => Tag::Album, + ItemKey::DiscNumber => Tag::Disk, + ItemKey::Unknown(unknown) if unknown == "ACOUSTID_FINGERPRINT" => continue, + ItemKey::Unknown(unknown) => Tag::Key(unknown.to_string()), + custom => Tag::Key(format!("{:?}", custom)), + }; + + let value = match item.value() { + ItemValue::Text(value) => value.clone(), + ItemValue::Locator(value) => value.clone(), + ItemValue::Binary(bin) => format!("BIN#{}", general_purpose::STANDARD.encode(bin)), + }; + + tags.insert(key, value); } - custom_insert.push_str(&format!(" (?1, '{}', '{}')", custom_key.replace("\'", "''"), custom_value.replace("\'", "''"))); + // Get all the album artwork information from the file + let mut album_art: Vec = Vec::new(); + for (i, _art) in tag.pictures().iter().enumerate() { + let new_art = AlbumArt::Embedded(i as usize); - loops += 1; - } - - // Get the format as a string - let short_format: Option = match FileFormat::from_file(target_file) { - Ok(fmt) => Some(fmt.to_string()), - Err(_) => None - }; - - println!("{}", short_format.as_ref().unwrap()); - - let duration = tagged_file.properties().duration().as_secs().to_string(); - - // TODO: Fix error handling - let binding = fs::canonicalize(target_file).unwrap(); - let abs_path = binding.to_str().unwrap(); - - // Add all the info into the music_collection table - connection.execute( - "INSERT INTO music_collection ( - song_path, - title, - album, - tracknum, - artist, - date, - genre, - plays, - favorited, - format, - duration - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - params![abs_path, tag.title(), tag.album(), tag.track(), tag.artist(), tag.year(), tag.genre(), 0, false, short_format, duration], - ).unwrap(); - - //TODO: Fix this, it's horrible - if custom_insert != "" { - connection.execute( - &format!("INSERT INTO custom_tags ('song_path', 'tag', 'tag_value') VALUES {}", &custom_insert), - params![ - abs_path, - ] - ).unwrap(); - } -} - -#[derive(Debug, Deserialize, Clone)] -pub enum Tag { - SongPath, - Title, - Album, - TrackNum, - Artist, - Date, - Genre, - Plays, - Favorited, - Format, - Duration, - Custom{tag: String, tag_value: String}, -} - -impl Tag { - fn as_str(&self) -> &str { - match self { - Tag::SongPath => "song_path", - Tag::Title => "title", - Tag::Album => "album", - Tag::TrackNum => "tracknum", - Tag::Artist => "artist", - Tag::Date => "date", - Tag::Genre => "genre", - Tag::Plays => "plays", - Tag::Favorited => "favorited", - Tag::Format => "format", - Tag::Duration => "duration", - Tag::Custom{tag, ..} => tag, - } - } -} - -#[derive(Debug)] -pub enum MusicObject { - Song(Song), - Album(Playlist), - Playlist(Playlist), -} - -impl MusicObject { - pub fn as_song(&self) -> Option<&Song> { - match self { - MusicObject::Song(data) => Some(data), - _ => None - } - } -} - -/// Query the database, returning a list of items -pub fn query ( - config: &Config, - text_input: &String, - queried_tags: &Vec<&Tag>, - order_by_tags: &Vec<&Tag>, -) -> Option> { - let db_connection = Connection::open(&*config.db_path).unwrap(); - - // Set up some database settings - db_connection.pragma_update(None, "synchronous", "0").unwrap(); - db_connection.pragma_update(None, "journal_mode", "WAL").unwrap(); - - // Build the "WHERE" part of the SQLite query - let mut where_string = String::new(); - let mut loops = 0; - for tag in queried_tags { - if loops > 0 { - where_string.push_str("OR "); + album_art.push(new_art) } - match tag { - Tag::Custom{tag, ..} => where_string.push_str(&format!("custom_tags.tag = '{tag}' AND custom_tags.tag_value LIKE '{text_input}' ")), - Tag::SongPath => where_string.push_str(&format!("music_collection.{} LIKE '{text_input}' ", tag.as_str())), - _ => where_string.push_str(&format!("{} LIKE '{text_input}' ", tag.as_str())) - } + // Find images around the music file that can be used + let mut found_images = find_images(&target_file.to_path_buf()).unwrap(); + album_art.append(&mut found_images); - loops += 1; - } - - // Build the "ORDER BY" part of the SQLite query - let mut order_by_string = String::new(); - let mut loops = 0; - for tag in order_by_tags { - match tag { - Tag::Custom{..} => continue, - _ => () - } - - if loops > 0 { - order_by_string.push_str(", "); - } - - order_by_string.push_str(tag.as_str()); - - loops += 1; - } - - // Build the final query string - let query_string = format!(" - SELECT music_collection.*, JSON_GROUP_ARRAY(JSON_OBJECT('Custom',JSON_OBJECT('tag', custom_tags.tag, 'tag_value', custom_tags.tag_value))) AS custom_tags - FROM music_collection - LEFT JOIN custom_tags ON music_collection.song_path = custom_tags.song_path - WHERE {where_string} - GROUP BY music_collection.song_path - ORDER BY {order_by_string} - "); - - let mut query_statement = db_connection.prepare(&query_string).unwrap(); - let mut rows = query_statement.query([]).unwrap(); - - let mut final_result:Vec = vec![]; - - while let Some(row) = rows.next().unwrap() { - let custom_tags: Vec = match row.get::(11) { - Ok(result) => serde_json::from_str(&result).unwrap_or(vec![]), - Err(_) => vec![] + // Get the format as a string + let format: Option = match FileFormat::from_file(target_file) { + Ok(fmt) => Some(fmt), + Err(_) => None, }; - let file_format: FileFormat = FileFormat::from(row.get::(9).unwrap().as_bytes()); + let duration = tagged_file.properties().duration(); + + // TODO: Fix error handling + let binding = fs::canonicalize(target_file).unwrap(); let new_song = Song { - // TODO: Implement proper errors here - path: URI::Local(String::from("URI")), - title: row.get::(1).ok(), - album: row.get::(2).ok(), - tracknum: row.get::(3).ok(), - artist: row.get::(4).ok(), - date: Date::from_calendar_date(row.get::(5).unwrap_or(0), time::Month::January, 1).ok(), // TODO: Fix this to get the actual date - genre: row.get::(6).ok(), - plays: row.get::(7).ok(), - favorited: row.get::(8).ok(), - format: Some(file_format), - duration: Some(Duration::from_secs(row.get::(10).unwrap_or(0))), - custom_tags: Some(custom_tags), + location: URI::Local(binding), + plays: 0, + skips: 0, + favorited: false, + rating: None, + format, + duration, + play_time: Duration::from_secs(0), + last_played: None, + date_added: Some(chrono::offset::Utc::now()), + date_modified: Some(chrono::offset::Utc::now()), + tags, + album_art, }; - final_result.push(MusicObject::Song(new_song)); - }; + match self.add_song(new_song) { + Ok(_) => (), + Err(error) => return Err(error), + }; - Some(final_result) + Ok(()) + } + + pub fn add_cuesheet(&mut self, cuesheet: &PathBuf) -> Result> { + let mut tracks_added = 0; + + let cue_data = parse_from_file(&cuesheet.as_path().to_string_lossy(), false).unwrap(); + + // Get album level information + let album_title = &cue_data.title; + let album_artist = &cue_data.performer; + + let parent_dir = cuesheet.parent().expect("The file has no parent path??"); + for file in cue_data.files.iter() { + let audio_location = &parent_dir.join(file.file.clone()); + + if !audio_location.exists() { + continue; + } + + // Try to remove the original audio file from the db if it exists + match self.remove_uri(&URI::Local(audio_location.clone())) { + Ok(_) => tracks_added -= 1, + Err(_) => () + }; + + let next_track = file.tracks.clone(); + let mut next_track = next_track.iter().skip(1); + for (i, track) in file.tracks.iter().enumerate() { + // Get the track timing information + let pregap = match track.pregap { + Some(pregap) => pregap, + None => Duration::from_secs(0), + }; + let postgap = match track.postgap { + Some(postgap) => postgap, + None => Duration::from_secs(0), + }; + let mut start = track.indices[0].1; + start -= pregap; + + let duration = match next_track.next() { + Some(future) => match future.indices.get(0) { + Some(val) => val.1 - start, + None => Duration::from_secs(0) + } + None => { + let tagged_file = match lofty::read_from_path(&audio_location) { + Ok(tagged_file) => tagged_file, + + Err(_) => match Probe::open(&audio_location)?.read() { + Ok(tagged_file) => tagged_file, + + Err(error) => return Err(error.into()), + }, + }; + + tagged_file.properties().duration() - start + } + }; + let end = start + duration + postgap; + + // Get the format as a string + let format: Option = match FileFormat::from_file(&audio_location) { + Ok(fmt) => Some(fmt), + Err(_) => None, + }; + + // Get some useful tags + let mut tags: BTreeMap = BTreeMap::new(); + match album_title { + Some(title) => {tags.insert(Tag::Album, title.clone());}, + None => (), + } + match album_artist { + Some(artist) => {tags.insert(Tag::Album, artist.clone());}, + None => (), + } + tags.insert(Tag::Track, track.no.parse().unwrap_or((i + 1).to_string())); + match track.title.clone() { + Some(title) => tags.insert(Tag::Title, title), + None => match track.isrc.clone() { + Some(title) => tags.insert(Tag::Title, title), + None => { + let namestr = format!("{} - {}", i, file.file.clone()); + tags.insert(Tag::Title, namestr) + } + }, + }; + match track.performer.clone() { + Some(artist) => tags.insert(Tag::Artist, artist), + None => None, + }; + + // Find images around the music file that can be used + let album_art = find_images(&audio_location.to_path_buf()).unwrap(); + + let new_song = Song { + location: URI::Cue { + location: audio_location.clone(), + index: i, + start, + end, + }, + plays: 0, + skips: 0, + favorited: false, + rating: None, + format, + duration, + play_time: Duration::from_secs(0), + last_played: None, + date_added: Some(chrono::offset::Utc::now()), + date_modified: Some(chrono::offset::Utc::now()), + tags, + album_art, + }; + + match self.add_song(new_song) { + Ok(_) => tracks_added += 1, + Err(_error) => { + //println!("{}", _error); + continue; + } + }; + } + } + + Ok(tracks_added) + } + + pub fn add_song(&mut self, new_song: Song) -> Result<(), Box> { + match self.query_uri(&new_song.location) { + Some(_) => { + return Err(format!("URI already in database: {:?}", new_song.location).into()) + } + None => (), + } + match new_song.location { + URI::Local(_) if self.query_path(&new_song.location.path()).is_some() => { + return Err(format!("Location exists for {:?}", new_song.location).into()) + } + _ => (), + } + + self.library.push(new_song); + + Ok(()) + } + + /// Removes a song indexed by URI, returning the position removed + pub fn remove_uri(&mut self, target_uri: &URI) -> Result> { + let location = match self.query_uri(target_uri) { + Some(value) => value.1, + None => return Err("URI not in database".into()), + }; + + self.library.remove(location); + + Ok(location) + } + + /// Scan the song by a location and update its tags + pub fn update_by_file(&mut self, new_tags: Song) -> Result<(), Box> { + match self.query_uri(&new_tags.location) { + Some(_) => (), + None => return Err(format!("URI not in database!").into()), + } + + todo!() + } + + /// Query the database, returning a list of [Song]s + /// + /// The order in which the sort by Vec is arranged + /// determines the output sorting. + /// + /// Example: + /// ``` + /// query_tracks( + /// &String::from("query"), + /// &vec![ + /// Tag::Title + /// ], + /// &vec![ + /// Tag::Field("location".to_string()), + /// Tag::Album, + /// Tag::Disk, + /// Tag::Track, + /// ], + /// ) + /// ``` + /// This would find all titles containing the sequence + /// "query", and would return the results sorted first + /// by path, then album, disk number, and finally track number. + pub fn query_tracks( + &self, + query_string: &String, // The query itself + target_tags: &Vec, // The tags to search + sort_by: &Vec, // Tags to sort the resulting data by + ) -> Option> { + let songs = Arc::new(Mutex::new(Vec::new())); + //let matcher = SkimMatcherV2::default(); + + self.library.par_iter().for_each(|track| { + for tag in target_tags { + let track_result = match tag { + Tag::Field(target) => match track.get_field(&target) { + Some(value) => value, + None => continue, + }, + _ => match track.get_tag(&tag) { + Some(value) => value.clone(), + None => continue, + }, + }; + + /* + let match_level = match matcher.fuzzy_match(&normalize(&track_result), &normalize(query_string)) { + Some(conf) => conf, + None => continue + }; + + if match_level > 100 { + songs.lock().unwrap().push(track); + return; + } + */ + + if normalize(&track_result.to_string()).contains(&normalize(&query_string.to_owned())) { + songs.lock().unwrap().push(track); + return; + } + } + }); + + let lock = Arc::try_unwrap(songs).expect("Lock still has multiple owners!"); + let mut new_songs = lock.into_inner().expect("Mutex cannot be locked!"); + + // Sort the returned list of songs + new_songs.par_sort_by(|a, b| { + for sort_option in sort_by { + let tag_a = match sort_option { + Tag::Field(field_selection) => match a.get_field(field_selection) { + Some(field_value) => field_value, + None => continue, + }, + _ => match a.get_tag(&sort_option) { + Some(tag_value) => tag_value.to_owned(), + None => continue, + }, + }; + + let tag_b = match sort_option { + Tag::Field(field_selection) => match b.get_field(field_selection) { + Some(field_value) => field_value, + None => continue, + }, + _ => match b.get_tag(&sort_option) { + Some(tag_value) => tag_value.to_owned(), + None => continue, + }, + }; + + if let (Ok(num_a), Ok(num_b)) = (tag_a.parse::(), tag_b.parse::()) { + // If parsing succeeds, compare as numbers + return num_a.cmp(&num_b); + } else { + // If parsing fails, compare as strings + return tag_a.cmp(&tag_b); + } + } + + // If all tags are equal, sort by Track number + let path_a = PathBuf::from(a.get_field("location").unwrap()); + let path_b = PathBuf::from(b.get_field("location").unwrap()); + + path_a.file_name().cmp(&path_b.file_name()) + }); + + if new_songs.len() > 0 { + Some(new_songs) + } else { + None + } + } + + /// Generates all albums from the track list + pub fn albums(&self) -> BTreeMap { + let mut albums: BTreeMap = BTreeMap::new(); + for result in &self.library { + let title = match result.get_tag(&Tag::Album) { + Some(title) => title, + None => continue, + }; + let norm_title = normalize(title); + + let disc_num = result + .get_tag(&Tag::Disk) + .unwrap_or(&"".to_string()) + .parse::() + .unwrap_or(1); + + match albums.get_mut(&norm_title) { + // If the album is in the list, add the track to the appropriate disc in it + Some(album) => match album.discs.get_mut(&disc_num) { + Some(disc) => disc.push(result), + None => { + album.discs.insert(disc_num, vec![result]); + } + }, + // If the album is not in the list, make a new one and add it + None => { + let album_art = result.album_art.get(0); + + let new_album = Album { + title, + artist: result.get_tag(&Tag::AlbumArtist), + discs: BTreeMap::from([(disc_num, vec![result])]), + cover: album_art, + }; + albums.insert(norm_title, new_album); + } + } + } + + // Sort the tracks in each disk in each album + let blank = String::from(""); + albums.par_iter_mut().for_each(|album| { + for disc in &mut album.1.discs { + disc.1.par_sort_by(|a, b| { + let a_track = a.get_tag(&Tag::Track).unwrap_or(&blank); + let b_track = b.get_tag(&Tag::Track).unwrap_or(&blank); + + if let (Ok(num_a), Ok(num_b)) = (a_track.parse::(), b_track.parse::()) + { + // If parsing the track numbers succeeds, compare as numbers + num_a.cmp(&num_b) + } else { + // If parsing doesn't succeed, compare the locations + let path_a = PathBuf::from(a.get_field("location").unwrap()); + let path_b = PathBuf::from(b.get_field("location").unwrap()); + + path_a.file_name().cmp(&path_b.file_name()) + } + }); + } + }); + + // Return the albums! + albums + } + + /// Queries a list of albums by title + pub fn query_albums( + &self, + query_string: &String, // The query itself + ) -> Result, Box> { + let all_albums = self.albums(); + + let normalized_query = normalize(query_string); + let albums: Vec = all_albums + .par_iter() + .filter_map(|album| { + if normalize(album.0).contains(&normalized_query) { + Some(album.1.clone()) + } else { + None + } + }) + .collect(); + + Ok(albums) + } } diff --git a/src/music_storage/playlist.rs b/src/music_storage/playlist.rs index f6fc9b7..797d0d2 100644 --- a/src/music_storage/playlist.rs +++ b/src/music_storage/playlist.rs @@ -1,27 +1,6 @@ -use std::path::Path; use crate::music_controller::config::Config; -use rusqlite::{params, Connection}; +use std::path::Path; -pub fn playlist_add( - config: &Config, - playlist_name: &str, - song_paths: &Vec<&Path> -) { - let db_connection = Connection::open(&*config.db_path).unwrap(); - - for song_path in song_paths { - db_connection.execute( - "INSERT INTO playlists ( - playlist_name, - song_path - ) VALUES ( - ?1, - ?2 - )", - params![ - playlist_name, - song_path.to_str().unwrap() - ], - ).unwrap(); - } +pub fn playlist_add(config: &Config, playlist_name: &str, song_paths: &Vec<&Path>) { + unimplemented!() } diff --git a/src/music_storage/utils.rs b/src/music_storage/utils.rs new file mode 100644 index 0000000..0847ae0 --- /dev/null +++ b/src/music_storage/utils.rs @@ -0,0 +1,97 @@ +use std::io::{BufReader, BufWriter}; +use std::{error::Error, fs, path::PathBuf}; +use walkdir::WalkDir; +use file_format::{FileFormat, Kind}; + +use snap; + +use super::music_db::{Song, AlbumArt, URI}; +use unidecode::unidecode; + +pub(super) fn normalize(input_string: &String) -> String { + let mut normalized = unidecode(input_string); + + // Remove non alphanumeric characters + normalized.retain(|c| c.is_alphanumeric()); + + normalized +} + +pub(super) fn read_library(path: PathBuf) -> Result, Box> { + // Create a new snap reader over the database file + let database = fs::File::open(path)?; + let reader = BufReader::new(database); + let mut d = snap::read::FrameDecoder::new(reader); + + // Decode the library from the serialized data into the vec + let library: Vec = bincode::serde::decode_from_std_read( + &mut d, + bincode::config::standard() + .with_little_endian() + .with_variable_int_encoding(), + )?; + Ok(library) +} + +pub(super) fn write_library( + library: &Vec, + path: PathBuf, + take_backup: bool, +) -> Result<(), Box> { + // Create 2 new names for the file, a temporary one for writing out, and a backup + let mut writer_name = path.clone(); + writer_name.set_extension("tmp"); + let mut backup_name = path.clone(); + backup_name.set_extension("bkp"); + + // Create a new BufWriter on the file and make a snap frame encoer for it too + let writer = BufWriter::new(fs::File::create(writer_name.to_path_buf())?); + let mut e = snap::write::FrameEncoder::new(writer); + + // Write out the data using bincode + bincode::serde::encode_into_std_write( + &library, + &mut e, + bincode::config::standard() + .with_little_endian() + .with_variable_int_encoding(), + )?; + + if path.exists() && take_backup { + fs::rename(&path, backup_name)?; + } + fs::rename(writer_name, &path)?; + + Ok(()) +} + +pub fn find_images(song_path: &PathBuf) -> Result, Box> { + let mut images: Vec = Vec::new(); + + let song_dir = song_path.parent().ok_or("")?; + for target_file in WalkDir::new(song_dir) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + { + if target_file.depth() >= 3 { // Don't recurse very deep + break + } + + let path = target_file.path(); + if !path.is_file() { + continue; + } + + let format = FileFormat::from_file(&path)?.kind(); + if format != Kind::Image { + break + } + + let image_uri = URI::Local(path.to_path_buf().canonicalize().unwrap()); + + images.push(AlbumArt::External(image_uri)); + } + + Ok(images) +} diff --git a/src/music_tracker/music_tracker.rs b/src/music_tracker/music_tracker.rs index 213a5f1..9ffd73d 100644 --- a/src/music_tracker/music_tracker.rs +++ b/src/music_tracker/music_tracker.rs @@ -1,26 +1,26 @@ -use std::time::{SystemTime, UNIX_EPOCH}; -use std::collections::BTreeMap; use serde_json::json; +use std::collections::BTreeMap; +use std::time::{SystemTime, UNIX_EPOCH}; use async_trait::async_trait; -use serde::{Serialize, Deserialize}; -use md5::{Md5, Digest}; -use discord_presence::{Event}; +use discord_presence::Event; +use md5::{Digest, Md5}; +use serde::{Deserialize, Serialize}; use surf::StatusCode; -use crate::music_storage::music_db::Song; +use crate::music_storage::music_db::{Song, Tag}; #[async_trait] pub trait MusicTracker { /// Adds one listen to a song halfway through playback async fn track_song(&mut self, song: Song) -> Result<(), TrackerError>; - + /// Adds a 'listening' status to the music tracker service of choice async fn track_now(&mut self, song: Song) -> Result<(), TrackerError>; - + /// Reads config files, and attempts authentication with service async fn test_tracker(&mut self) -> Result<(), TrackerError>; - + /// Returns plays for a given song according to tracker service async fn get_times_tracked(&mut self, song: &Song) -> Result; } @@ -50,7 +50,7 @@ impl TrackerError { StatusCode::ServiceUnavailable => TrackerError::ServiceUnavailable, StatusCode::NotFound => TrackerError::ServiceUnavailable, _ => TrackerError::Unknown, - } + }; } } @@ -63,62 +63,66 @@ pub struct LastFMConfig { } pub struct LastFM { - config: LastFMConfig + config: LastFMConfig, } #[async_trait] impl MusicTracker for LastFM { async fn track_song(&mut self, song: Song) -> Result<(), TrackerError> { let mut params: BTreeMap<&str, &str> = BTreeMap::new(); - + // Sets timestamp of song beginning play time - let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("Your time is off.").as_secs() - 30; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Your time is off.") + .as_secs() + - 30; let string_timestamp = timestamp.to_string(); - - let (artist, track) = match (song.artist, song.title) { + + let (artist, track) = match (song.get_tag(&Tag::Artist), song.get_tag(&Tag::Title)) { (Some(artist), Some(track)) => (artist, track), - _ => return Err(TrackerError::InvalidSong) + _ => return Err(TrackerError::InvalidSong), }; - + params.insert("method", "track.scrobble"); params.insert("artist", &artist); params.insert("track", &track); params.insert("timestamp", &string_timestamp); - + return match self.api_request(params).await { Ok(_) => Ok(()), Err(err) => Err(TrackerError::from_surf_error(err)), - } + }; } - + async fn track_now(&mut self, song: Song) -> Result<(), TrackerError> { let mut params: BTreeMap<&str, &str> = BTreeMap::new(); - - let (artist, track) = match (song.artist, song.title) { + + let (artist, track) = match (song.get_tag(&Tag::Artist), song.get_tag(&Tag::Title)) { (Some(artist), Some(track)) => (artist, track), - _ => return Err(TrackerError::InvalidSong) + _ => return Err(TrackerError::InvalidSong), }; - + params.insert("method", "track.updateNowPlaying"); params.insert("artist", &artist); params.insert("track", &track); - + return match self.api_request(params).await { Ok(_) => Ok(()), Err(err) => Err(TrackerError::from_surf_error(err)), - } + }; } - + async fn test_tracker(&mut self) -> Result<(), TrackerError> { let mut params: BTreeMap<&str, &str> = BTreeMap::new(); params.insert("method", "chart.getTopArtists"); - + return match self.api_request(params).await { Ok(_) => Ok(()), Err(err) => Err(TrackerError::from_surf_error(err)), - } + }; } - + async fn get_times_tracked(&mut self, song: &Song) -> Result { todo!(); } @@ -126,7 +130,7 @@ impl MusicTracker for LastFM { #[derive(Deserialize, Serialize)] struct AuthToken { - token: String + token: String, } #[derive(Deserialize, Serialize, Debug)] @@ -138,72 +142,85 @@ struct SessionResponse { #[derive(Deserialize, Serialize, Debug)] struct Session { - session: SessionResponse + session: SessionResponse, } impl LastFM { /// Returns a url to be approved by the user along with the auth token pub async fn get_auth(api_key: &String) -> Result { let method = String::from("auth.gettoken"); - let api_request_url = format!("http://ws.audioscrobbler.com/2.0/?method={method}&api_key={api_key}&format=json"); - + let api_request_url = format!( + "http://ws.audioscrobbler.com/2.0/?method={method}&api_key={api_key}&format=json" + ); + let auth_token: AuthToken = surf::get(api_request_url).await?.body_json().await?; - - let auth_url = format!("http://www.last.fm/api/auth/?api_key={api_key}&token={}", auth_token.token); - + + let auth_url = format!( + "http://www.last.fm/api/auth/?api_key={api_key}&token={}", + auth_token.token + ); + return Ok(auth_url); } - + /// Returns a LastFM session key - pub async fn get_session_key(api_key: &String, shared_secret: &String, auth_token: &String) -> Result { + pub async fn get_session_key( + api_key: &String, + shared_secret: &String, + auth_token: &String, + ) -> Result { let method = String::from("auth.getSession"); // Creates api_sig as defined in last.fm documentation - let api_sig = format!("api_key{api_key}methodauth.getSessiontoken{auth_token}{shared_secret}"); + let api_sig = + format!("api_key{api_key}methodauth.getSessiontoken{auth_token}{shared_secret}"); // Creates insecure MD5 hash for last.fm api sig let mut hasher = Md5::new(); hasher.update(api_sig); let hash_result = hasher.finalize(); let hex_string_hash = format!("{:#02x}", hash_result); - + let api_request_url = format!("http://ws.audioscrobbler.com/2.0/?method={method}&api_key={api_key}&token={auth_token}&api_sig={hex_string_hash}&format=json"); - + let response = surf::get(api_request_url).recv_string().await?; - + // Sets session key from received response let session_response: Session = serde_json::from_str(&response)?; return Ok(session_response.session.key); } - + /// Creates a new LastFM struct with a given config pub fn new(config: &LastFMConfig) -> LastFM { let last_fm = LastFM { - config: config.clone() + config: config.clone(), }; return last_fm; } - + // Creates an api request with the given parameters - pub async fn api_request(&self, mut params: BTreeMap<&str, &str>) -> Result { + pub async fn api_request( + &self, + mut params: BTreeMap<&str, &str>, + ) -> Result { params.insert("api_key", &self.config.dango_api_key); params.insert("sk", &self.config.session_key); - + // Creates and sets api call signature let api_sig = LastFM::request_sig(¶ms, &self.config.shared_secret); params.insert("api_sig", &api_sig); let mut string_params = String::from(""); - + // Creates method call string // Just iterate over values??? for key in params.keys() { let param_value = params.get(key).unwrap(); string_params.push_str(&format!("{key}={param_value}&")); } - + string_params.pop(); - + let url = "http://ws.audioscrobbler.com/2.0/"; - + let response = surf::post(url).body_string(string_params).await; return response; @@ -218,16 +235,16 @@ impl LastFM { sig_string.push_str(&format!("{key}{}", param_value.unwrap())); } sig_string.push_str(shared_secret); - + // Hashes signature using **INSECURE** MD5 (Required by last.fm api) let mut md5_hasher = Md5::new(); md5_hasher.update(sig_string); let hash_result = md5_hasher.finalize(); let hashed_sig = format!("{:#02x}", hash_result); - + return hashed_sig; } - + // Removes last.fm account from dango-music-player pub fn reset_account() { todo!(); @@ -237,13 +254,13 @@ impl LastFM { #[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct DiscordRPCConfig { pub enabled: bool, - pub dango_client_id: u64, + pub dango_client_id: u64, pub dango_icon: String, } pub struct DiscordRPC { config: DiscordRPCConfig, - pub client: discord_presence::client::Client + pub client: discord_presence::client::Client, } impl DiscordRPC { @@ -259,44 +276,57 @@ impl DiscordRPC { #[async_trait] impl MusicTracker for DiscordRPC { async fn track_now(&mut self, song: Song) -> Result<(), TrackerError> { + let unknown = String::from("Unknown"); + // Sets song title - let song_name = if let Some(song_name) = song.title { + let song_name = if let Some(song_name) = song.get_tag(&Tag::Title) { song_name } else { - String::from("Unknown") + &unknown }; - + + // Sets album + let album = if let Some(album) = song.get_tag(&Tag::Album) { + album + } else { + &unknown + }; + let _client_thread = self.client.start(); - + // Blocks thread execution until it has connected to local discord client let ready = self.client.block_until_event(Event::Ready); if ready.is_err() { return Err(TrackerError::ServiceUnavailable); } - - let start_time = std::time::SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64; + + let start_time = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; // Sets discord account activity to current playing song let send_activity = self.client.set_activity(|activity| { activity - .state(format!("Listening to: {}", song_name)) + .state(format!("{}", album)) + .details(format!("{}", song_name)) .assets(|assets| assets.large_image(&self.config.dango_icon)) .timestamps(|time| time.start(start_time)) }); - + match send_activity { Ok(_) => return Ok(()), Err(_) => return Err(TrackerError::ServiceUnavailable), } } - + async fn track_song(&mut self, song: Song) -> Result<(), TrackerError> { - return Ok(()) + return Ok(()); } - + async fn test_tracker(&mut self) -> Result<(), TrackerError> { - return Ok(()) + return Ok(()); } - + async fn get_times_tracked(&mut self, song: &Song) -> Result { return Ok(0); } @@ -316,9 +346,9 @@ pub struct ListenBrainz { #[async_trait] impl MusicTracker for ListenBrainz { async fn track_now(&mut self, song: Song) -> Result<(), TrackerError> { - let (artist, track) = match (song.artist, song.title) { + let (artist, track) = match (song.get_tag(&Tag::Artist), song.get_tag(&Tag::Title)) { (Some(artist), Some(track)) => (artist, track), - _ => return Err(TrackerError::InvalidSong) + _ => return Err(TrackerError::InvalidSong), }; // Creates a json to submit a single song as defined in the listenbrainz documentation let json_req = json!({ @@ -332,21 +362,28 @@ impl MusicTracker for ListenBrainz { } ] }); - - return match self.api_request(&json_req.to_string(), &String::from("/1/submit-listens")).await { + + return match self + .api_request(&json_req.to_string(), &String::from("/1/submit-listens")) + .await + { Ok(_) => Ok(()), - Err(err) => Err(TrackerError::from_surf_error(err)) - } - } - - async fn track_song(&mut self, song: Song) -> Result<(), TrackerError> { - let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("Your time is off.").as_secs() - 30; - - let (artist, track) = match (song.artist, song.title) { - (Some(artist), Some(track)) => (artist, track), - _ => return Err(TrackerError::InvalidSong) + Err(err) => Err(TrackerError::from_surf_error(err)), }; - + } + + async fn track_song(&mut self, song: Song) -> Result<(), TrackerError> { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Your time is off.") + .as_secs() + - 30; + + let (artist, track) = match (song.get_tag(&Tag::Artist), song.get_tag(&Tag::Title)) { + (Some(artist), Some(track)) => (artist, track), + _ => return Err(TrackerError::InvalidSong), + }; + let json_req = json!({ "listen_type": "single", "payload": [ @@ -359,11 +396,14 @@ impl MusicTracker for ListenBrainz { } ] }); - - return match self.api_request(&json_req.to_string(), &String::from("/1/submit-listens")).await { + + return match self + .api_request(&json_req.to_string(), &String::from("/1/submit-listens")) + .await + { Ok(_) => Ok(()), - Err(err) => Err(TrackerError::from_surf_error(err)) - } + Err(err) => Err(TrackerError::from_surf_error(err)), + }; } async fn test_tracker(&mut self) -> Result<(), TrackerError> { todo!() @@ -376,12 +416,19 @@ impl MusicTracker for ListenBrainz { impl ListenBrainz { pub fn new(config: &ListenBrainzConfig) -> Self { ListenBrainz { - config: config.clone() + config: config.clone(), } } // Makes an api request to configured url with given json - pub async fn api_request(&self, request: &String, endpoint: &String) -> Result { - let reponse = surf::post(format!("{}{}", &self.config.api_url, endpoint)).body_string(request.clone()).header("Authorization", format!("Token {}", self.config.auth_token)).await; - return reponse + pub async fn api_request( + &self, + request: &String, + endpoint: &String, + ) -> Result { + let reponse = surf::post(format!("{}{}", &self.config.api_url, endpoint)) + .body_string(request.clone()) + .header("Authorization", format!("Token {}", self.config.auth_token)) + .await; + return reponse; } }