mirror of
https://github.com/Dangoware/dmp-core.git
synced 2025-04-19 17:42:55 -05:00
Restructured repository as a workspace
This commit is contained in:
commit
8fec560bcf
13 changed files with 1581 additions and 0 deletions
34
Cargo.toml
Normal file
34
Cargo.toml
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
[package]
|
||||||
|
name = "dango-core"
|
||||||
|
version = "0.1.1"
|
||||||
|
edition = "2021"
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
|
description = "A music backend that manages storage, querying, and playback of remote and local songs."
|
||||||
|
homepage = "https://dangoware.com/dango-music-player"
|
||||||
|
documentation = "https://docs.rs/dango-core"
|
||||||
|
readme = "README.md"
|
||||||
|
repository = "https://github.com/DangoWare/dango-music-player"
|
||||||
|
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"] }
|
||||||
|
time = "0.3.22"
|
||||||
|
toml = "0.7.5"
|
||||||
|
walkdir = "2.3.3"
|
||||||
|
cpal = "0.15.2"
|
||||||
|
heapless = "0.7.16"
|
||||||
|
rb = "0.4.1"
|
||||||
|
symphonia = { version = "0.5.3", features = ["all-codecs"] }
|
||||||
|
serde_json = "1.0.104"
|
||||||
|
cue = "2.0.0"
|
||||||
|
async-std = "1.12.0"
|
||||||
|
async-trait = "0.1.73"
|
||||||
|
md-5 = "0.10.5"
|
||||||
|
surf = "2.3.2"
|
||||||
|
futures = "0.3.28"
|
||||||
|
rubato = "0.12.0"
|
||||||
|
arrayvec = "0.7.4"
|
0
src/bus_control.rs
Normal file
0
src/bus_control.rs
Normal file
24
src/lib.rs
Normal file
24
src/lib.rs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
pub mod music_tracker {
|
||||||
|
pub mod music_tracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod music_storage {
|
||||||
|
pub mod music_db;
|
||||||
|
pub mod playlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod music_processor {
|
||||||
|
pub mod music_processor;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod music_player {
|
||||||
|
pub mod music_player;
|
||||||
|
pub mod music_output;
|
||||||
|
pub mod music_resampler;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod music_controller {
|
||||||
|
pub mod music_controller;
|
||||||
|
pub mod config;
|
||||||
|
pub mod init;
|
||||||
|
}
|
54
src/music_controller/config.rs
Normal file
54
src/music_controller/config.rs
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::fs::read_to_string;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::music_tracker::music_tracker::LastFM;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub db_path: Box<PathBuf>,
|
||||||
|
pub lastfm: Option<LastFM>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
// Creates and saves a new config with default values
|
||||||
|
pub fn new(config_file: &PathBuf) -> std::io::Result<Config> {
|
||||||
|
let path = PathBuf::from("./music_database.db3");
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
db_path: Box::new(path),
|
||||||
|
lastfm: None,
|
||||||
|
};
|
||||||
|
config.save(config_file)?;
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads config from given file path
|
||||||
|
pub fn from(config_file: &PathBuf) -> std::result::Result<Config, toml::de::Error> {
|
||||||
|
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(_) => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(temp_file, config_file)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
19
src/music_controller/init.rs
Normal file
19
src/music_controller/init.rs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
use std::path::Path;
|
||||||
|
use std::fs::File;
|
||||||
|
|
||||||
|
pub fn init() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_config() {
|
||||||
|
let config_path = "./config.toml";
|
||||||
|
|
||||||
|
if !Path::new(config_path).try_exists().unwrap() {
|
||||||
|
File::create("./config.toml").unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_db() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
68
src/music_controller/music_controller.rs
Normal file
68
src/music_controller/music_controller.rs
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use rusqlite::Result;
|
||||||
|
|
||||||
|
use crate::music_controller::config::Config;
|
||||||
|
use crate::music_player::music_player::{MusicPlayer, PlayerStatus, PlayerMessage, DSPMessage};
|
||||||
|
use crate::music_processor::music_processor::MusicProcessor;
|
||||||
|
use crate::music_storage::music_db::URI;
|
||||||
|
|
||||||
|
pub struct MusicController {
|
||||||
|
pub config: Config,
|
||||||
|
music_player: MusicPlayer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MusicController {
|
||||||
|
/// Creates new MusicController with config at given path
|
||||||
|
pub fn new(config_path: &PathBuf) -> Result<MusicController, std::io::Error>{
|
||||||
|
let config = Config::new(config_path)?;
|
||||||
|
let music_player = MusicPlayer::new();
|
||||||
|
|
||||||
|
let controller = MusicController {
|
||||||
|
config,
|
||||||
|
music_player,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates new music controller from a config at given path
|
||||||
|
pub fn from(config_path: &PathBuf) -> std::result::Result<MusicController, toml::de::Error> {
|
||||||
|
let config = Config::from(config_path)?;
|
||||||
|
let music_player = MusicPlayer::new();
|
||||||
|
|
||||||
|
let controller = MusicController {
|
||||||
|
config,
|
||||||
|
music_player,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens and plays song at given URI
|
||||||
|
pub fn open_song(&mut self, uri: &URI) {
|
||||||
|
self.music_player.open_song(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends given message to music player
|
||||||
|
pub fn song_control(&mut self, message: PlayerMessage) {
|
||||||
|
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 audio playback volume
|
||||||
|
pub fn get_vol(&mut self) -> f32 {
|
||||||
|
return self.music_player.music_processor.audio_volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(PlayerMessage::DSP(DSPMessage::UpdateProcessor(Box::new(self.music_player.music_processor.clone()))));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
167
src/music_player/music_output.rs
Normal file
167
src/music_player/music_output.rs
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
use std::{result, thread};
|
||||||
|
|
||||||
|
use symphonia::core::audio::{AudioBufferRef, SignalSpec, RawSample, SampleBuffer};
|
||||||
|
use symphonia::core::conv::{ConvertibleSample, IntoSample, FromSample};
|
||||||
|
use symphonia::core::units::Duration;
|
||||||
|
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use cpal::{self, SizedSample};
|
||||||
|
|
||||||
|
use rb::*;
|
||||||
|
|
||||||
|
use crate::music_player::music_resampler::Resampler;
|
||||||
|
|
||||||
|
pub trait AudioStream {
|
||||||
|
fn write(&mut self, decoded: AudioBufferRef<'_>) -> Result<()>;
|
||||||
|
fn flush(&mut self);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AudioOutputError {
|
||||||
|
OpenStreamError,
|
||||||
|
PlayStreamError,
|
||||||
|
StreamClosedError,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = result::Result<T, AudioOutputError>;
|
||||||
|
|
||||||
|
pub trait OutputSample: SizedSample + FromSample<f32> + IntoSample<f32> +cpal::Sample + ConvertibleSample + RawSample + std::marker::Send + 'static {}
|
||||||
|
|
||||||
|
pub struct AudioOutput<T>
|
||||||
|
where T: OutputSample,
|
||||||
|
{
|
||||||
|
ring_buf_producer: rb::Producer<T>,
|
||||||
|
sample_buf: SampleBuffer<T>,
|
||||||
|
stream: cpal::Stream,
|
||||||
|
resampler: Option<Resampler<T>>,
|
||||||
|
}
|
||||||
|
impl OutputSample for i8 {}
|
||||||
|
impl OutputSample for i16 {}
|
||||||
|
impl OutputSample for i32 {}
|
||||||
|
//impl OutputSample for i64 {}
|
||||||
|
impl OutputSample for u8 {}
|
||||||
|
impl OutputSample for u16 {}
|
||||||
|
impl OutputSample for u32 {}
|
||||||
|
//impl OutputSample for u64 {}
|
||||||
|
impl OutputSample for f32 {}
|
||||||
|
impl OutputSample for f64 {}
|
||||||
|
//create a new trait with functions, then impl that somehow
|
||||||
|
|
||||||
|
pub fn open_stream(spec: SignalSpec, duration: Duration) -> Result<Box<dyn AudioStream>> {
|
||||||
|
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::<i8>::create_stream(spec, &device, &config.into(), duration),
|
||||||
|
cpal::SampleFormat::I16 => AudioOutput::<i16>::create_stream(spec, &device, &config.into(), duration),
|
||||||
|
cpal::SampleFormat::I32 => AudioOutput::<i32>::create_stream(spec, &device, &config.into(), duration),
|
||||||
|
//cpal::SampleFormat::I64 => AudioOutput::<i64>::create_stream(spec, &device, &config.into(), duration),
|
||||||
|
cpal::SampleFormat::U8 => AudioOutput::<u8>::create_stream(spec, &device, &config.into(), duration),
|
||||||
|
cpal::SampleFormat::U16 => AudioOutput::<u16>::create_stream(spec, &device, &config.into(), duration),
|
||||||
|
cpal::SampleFormat::U32 => AudioOutput::<u32>::create_stream(spec, &device, &config.into(), duration),
|
||||||
|
//cpal::SampleFormat::U64 => AudioOutput::<u64>::create_stream(spec, &device, &config.into(), duration),
|
||||||
|
cpal::SampleFormat::F32 => AudioOutput::<f32>::create_stream(spec, &device, &config.into(), duration),
|
||||||
|
cpal::SampleFormat::F64 => AudioOutput::<f64>::create_stream(spec, &device, &config.into(), duration),
|
||||||
|
_ => todo!(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl<T: OutputSample> AudioOutput<T> {
|
||||||
|
// Creates the stream (TODO: Merge w/open_stream?)
|
||||||
|
fn create_stream(spec: SignalSpec, device: &cpal::Device, config: &cpal::StreamConfig, duration: Duration) -> Result<Box<dyn AudioStream>> {
|
||||||
|
let num_channels = config.channels as usize;
|
||||||
|
|
||||||
|
// Ring buffer is created with 200ms audio capacity
|
||||||
|
let ring_len = ((200 * config.sample_rate.0 as usize) / 1000) * num_channels;
|
||||||
|
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,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
//TODO: Handle error here properly
|
||||||
|
move |err| println!("Yeah we erroring out here"),
|
||||||
|
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::<T>::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))
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Box::new(AudioOutput { ring_buf_producer, sample_buf, stream, resampler}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: OutputSample> AudioStream for AudioOutput<T> {
|
||||||
|
// Writes given samples to ring buffer
|
||||||
|
fn write(&mut self, decoded: AudioBufferRef<'_>) -> Result<()> {
|
||||||
|
if decoded.frames() == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut samples: &[T] = if let Some(resampler) = &mut self.resampler {
|
||||||
|
// Resamples if required
|
||||||
|
match resampler.resample(decoded) {
|
||||||
|
Some(resampled) => resampled,
|
||||||
|
None => return Ok(()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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();
|
||||||
|
|
||||||
|
while let Some(written) = self.ring_buf_producer.write_blocking(stale_samples) {
|
||||||
|
stale_samples = &stale_samples[written..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = self.stream.pause();
|
||||||
|
}
|
||||||
|
}
|
364
src/music_player/music_player.rs
Normal file
364
src/music_player/music_player.rs
Normal file
|
@ -0,0 +1,364 @@
|
||||||
|
use std::sync::mpsc::{self, Sender, Receiver};
|
||||||
|
use std::thread;
|
||||||
|
use std::io::SeekFrom;
|
||||||
|
|
||||||
|
use async_std::io::ReadExt;
|
||||||
|
use async_std::task;
|
||||||
|
|
||||||
|
use symphonia::core::codecs::{CODEC_TYPE_NULL, DecoderOptions, Decoder};
|
||||||
|
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
|
||||||
|
use symphonia::core::io::{MediaSourceStream, MediaSource};
|
||||||
|
use symphonia::core::meta::MetadataOptions;
|
||||||
|
use symphonia::core::probe::Hint;
|
||||||
|
use symphonia::core::errors::Error;
|
||||||
|
use symphonia::core::units::Time;
|
||||||
|
|
||||||
|
use futures::AsyncBufRead;
|
||||||
|
|
||||||
|
use crate::music_player::music_output::AudioStream;
|
||||||
|
use crate::music_processor::music_processor::MusicProcessor;
|
||||||
|
use crate::music_storage::music_db::URI;
|
||||||
|
|
||||||
|
// Struct that controls playback of music
|
||||||
|
pub struct MusicPlayer {
|
||||||
|
pub music_processor: MusicProcessor,
|
||||||
|
player_status: PlayerStatus,
|
||||||
|
message_sender: Option<Sender<PlayerMessage>>,
|
||||||
|
status_receiver: Option<Receiver<PlayerStatus>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum PlayerStatus {
|
||||||
|
Playing,
|
||||||
|
Paused,
|
||||||
|
Stopped,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum PlayerMessage {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Stop,
|
||||||
|
SeekTo(u64),
|
||||||
|
DSP(DSPMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum DSPMessage {
|
||||||
|
UpdateProcessor(Box<MusicProcessor>)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MusicPlayer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
MusicPlayer {
|
||||||
|
music_processor: MusicProcessor::new(),
|
||||||
|
player_status: PlayerStatus::Stopped,
|
||||||
|
message_sender: None,
|
||||||
|
status_receiver: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens and plays song with given path in separate thread
|
||||||
|
pub fn open_song(&mut self, uri: &URI) {
|
||||||
|
// Creates mpsc channels to communicate with thread
|
||||||
|
let (message_sender, message_receiver) = mpsc::channel();
|
||||||
|
let (status_sender, status_receiver) = mpsc::channel();
|
||||||
|
self.message_sender = Some(message_sender);
|
||||||
|
self.status_receiver = Some(status_receiver);
|
||||||
|
|
||||||
|
let owned_uri = uri.clone();
|
||||||
|
|
||||||
|
// Creates thread that audio is decoded in
|
||||||
|
thread::spawn(move || {
|
||||||
|
let (mut reader, mut decoder) = MusicPlayer::get_reader_and_dec(&owned_uri);
|
||||||
|
|
||||||
|
let mut seek_time: Option<u64> = None;
|
||||||
|
|
||||||
|
let mut audio_output: Option<Box<dyn AudioStream>> = None;
|
||||||
|
|
||||||
|
let mut music_processor = MusicProcessor::new();
|
||||||
|
|
||||||
|
'main_decode: loop {
|
||||||
|
// Handles message received from the MusicPlayer if there is one // TODO: Refactor
|
||||||
|
let received_message = message_receiver.try_recv();
|
||||||
|
match received_message {
|
||||||
|
Ok(PlayerMessage::Pause) => {
|
||||||
|
status_sender.send(PlayerStatus::Paused).unwrap();
|
||||||
|
// Loops on a blocking message receiver to wait for a play/stop message
|
||||||
|
'inner_pause: loop {
|
||||||
|
let message = message_receiver.try_recv();
|
||||||
|
match message {
|
||||||
|
Ok(PlayerMessage::Play) => {
|
||||||
|
status_sender.send(PlayerStatus::Playing).unwrap();
|
||||||
|
break 'inner_pause
|
||||||
|
},
|
||||||
|
Ok(PlayerMessage::Stop) => {
|
||||||
|
status_sender.send(PlayerStatus::Stopped).unwrap();
|
||||||
|
break 'main_decode
|
||||||
|
},
|
||||||
|
_ => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Exits main decode loop and subsequently ends thread (?)
|
||||||
|
Ok(PlayerMessage::Stop) => {
|
||||||
|
status_sender.send(PlayerStatus::Stopped).unwrap();
|
||||||
|
break 'main_decode
|
||||||
|
},
|
||||||
|
Ok(PlayerMessage::SeekTo(time)) => seek_time = Some(time),
|
||||||
|
Ok(PlayerMessage::DSP(dsp_message)) => {
|
||||||
|
match dsp_message {
|
||||||
|
DSPMessage::UpdateProcessor(new_processor) => music_processor = *new_processor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
match seek_time {
|
||||||
|
Some(time) => {
|
||||||
|
let seek_to = SeekTo::Time { time: Time::from(time), track_id: Some(0) };
|
||||||
|
reader.seek(SeekMode::Accurate, seek_to).unwrap();
|
||||||
|
seek_time = None;
|
||||||
|
}
|
||||||
|
None => {} //Nothing to do!
|
||||||
|
}
|
||||||
|
|
||||||
|
let packet = match reader.next_packet() {
|
||||||
|
Ok(packet) => packet,
|
||||||
|
Err(Error::ResetRequired) => panic!(), //TODO,
|
||||||
|
Err(err) => {
|
||||||
|
//Unrecoverable?
|
||||||
|
panic!("{}", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_reader_and_dec(uri: &URI) -> (Box<dyn FormatReader>, Box<dyn Decoder>) {
|
||||||
|
// Opens remote/local source and creates MediaSource for symphonia
|
||||||
|
let config = RemoteOptions { media_buffer_len: 10000, forward_buffer_len: 10000};
|
||||||
|
let src: Box<dyn MediaSource> = match uri {
|
||||||
|
URI::Local(path) => Box::new(std::fs::File::open(path).expect("Failed to open file")),
|
||||||
|
URI::Remote(_, location) => Box::new(RemoteSource::new(location.as_ref(), &config).unwrap()),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 dec_opts: DecoderOptions = Default::default();
|
||||||
|
|
||||||
|
let mut decoder = symphonia::default::get_codecs().make(&track.codec_params, &dec_opts)
|
||||||
|
.expect("unsupported codec");
|
||||||
|
|
||||||
|
return (reader, decoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates status by checking on messages from spawned thread
|
||||||
|
fn update_status(&mut self) {
|
||||||
|
let status = self.status_receiver.as_mut().unwrap().try_recv();
|
||||||
|
if status.is_ok() {
|
||||||
|
self.player_status = status.unwrap();
|
||||||
|
match status.unwrap() {
|
||||||
|
// Removes receiver and sender since spawned thread no longer exists
|
||||||
|
PlayerStatus::Stopped => {
|
||||||
|
self.status_receiver = None;
|
||||||
|
self.message_sender = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sends message to spawned thread
|
||||||
|
pub fn send_message(&mut self, message: PlayerMessage) {
|
||||||
|
self.update_status();
|
||||||
|
// Checks that message sender exists before sending a message off
|
||||||
|
if self.message_sender.is_some() {
|
||||||
|
self.message_sender.as_mut().unwrap().send(message).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_status(&mut self) -> PlayerStatus {
|
||||||
|
self.update_status();
|
||||||
|
return self.player_status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Make the buffer length do anything
|
||||||
|
/// Options for remote sources
|
||||||
|
///
|
||||||
|
/// media_buffer_len is how many bytes are to be buffered in totala
|
||||||
|
///
|
||||||
|
/// forward_buffer is how many bytes can ahead of the seek position without the remote source being read from
|
||||||
|
pub struct RemoteOptions {
|
||||||
|
media_buffer_len: u64,
|
||||||
|
forward_buffer_len: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RemoteOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
RemoteOptions {
|
||||||
|
media_buffer_len: 100000,
|
||||||
|
forward_buffer_len: 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A remote source of media
|
||||||
|
struct RemoteSource {
|
||||||
|
reader: Box<dyn AsyncBufRead + Send + Sync + Unpin>,
|
||||||
|
media_buffer: Vec<u8>,
|
||||||
|
forward_buffer_len: u64,
|
||||||
|
offset: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteSource {
|
||||||
|
/// Creates a new RemoteSource with given uri and configuration
|
||||||
|
pub fn new(uri: &str, config: &RemoteOptions) -> Result<Self, surf::Error> {
|
||||||
|
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(),
|
||||||
|
forward_buffer_len: config.forward_buffer_len,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: refactor this + buffer into the buffer passed into the function, not a newly allocated one
|
||||||
|
impl std::io::Read for RemoteSource {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
// Reads bytes into the media buffer if the offset is within the specified distance from the end of the buffer
|
||||||
|
if self.media_buffer.len() as u64 - self.offset < self.forward_buffer_len {
|
||||||
|
let mut buffer = [0; 1024];
|
||||||
|
let read_bytes = task::block_on(async {
|
||||||
|
match self.reader.read_exact(&mut buffer).await {
|
||||||
|
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
|
||||||
|
let mut bytes_read = 0;
|
||||||
|
for location in 0..1024 {
|
||||||
|
if (location + self.offset as usize) < self.media_buffer.len() {
|
||||||
|
buf[location] = self.media_buffer[location + self.offset as usize];
|
||||||
|
bytes_read += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.offset += bytes_read;
|
||||||
|
return Ok(bytes_read as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::io::Seek for RemoteSource {
|
||||||
|
// Seeks to a given position
|
||||||
|
// Seeking past the internal buffer's length results in the seeking to the end of content
|
||||||
|
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
|
||||||
|
match pos {
|
||||||
|
// Offset is set to given position
|
||||||
|
SeekFrom::Start(pos) => {
|
||||||
|
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 {
|
||||||
|
self.offset = self.media_buffer.len() as u64;
|
||||||
|
} else {
|
||||||
|
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{
|
||||||
|
self.offset = self.media_buffer.len() as u64;
|
||||||
|
} else {
|
||||||
|
self.offset += pos as u64
|
||||||
|
}
|
||||||
|
return Ok(self.offset);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaSource for RemoteSource {
|
||||||
|
fn is_seekable(&self) -> bool {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_len(&self) -> Option<u64> {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
147
src/music_player/music_resampler.rs
Normal file
147
src/music_player/music_resampler.rs
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
// Symphonia
|
||||||
|
// Copyright (c) 2019-2022 The Project Symphonia Developers.
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
use symphonia::core::audio::{AudioBuffer, AudioBufferRef, Signal, SignalSpec};
|
||||||
|
use symphonia::core::conv::{FromSample, IntoSample};
|
||||||
|
use symphonia::core::sample::Sample;
|
||||||
|
|
||||||
|
pub struct Resampler<T> {
|
||||||
|
resampler: rubato::FftFixedIn<f32>,
|
||||||
|
input: Vec<Vec<f32>>,
|
||||||
|
output: Vec<Vec<f32>>,
|
||||||
|
interleaved: Vec<T>,
|
||||||
|
duration: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Resampler<T>
|
||||||
|
where
|
||||||
|
T: Sample + FromSample<f32> + IntoSample<f32>,
|
||||||
|
{
|
||||||
|
fn resample_inner(&mut self) -> &[T] {
|
||||||
|
{
|
||||||
|
//let mut input = heapless::Vec::<f32, 32>::new();
|
||||||
|
let mut input: arrayvec::ArrayVec<&[f32], 32> = Default::default();
|
||||||
|
|
||||||
|
for channel in self.input.iter() {
|
||||||
|
input.push(&channel[..self.duration]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resample.
|
||||||
|
rubato::Resampler::process_into_buffer(
|
||||||
|
&mut self.resampler,
|
||||||
|
&input,
|
||||||
|
&mut self.output,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove consumed samples from the input buffer.
|
||||||
|
for channel in self.input.iter_mut() {
|
||||||
|
channel.drain(0..self.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interleave the planar samples from Rubato.
|
||||||
|
let num_channels = self.output.len();
|
||||||
|
|
||||||
|
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() {
|
||||||
|
*s = self.output[ch][i].into_sample();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&self.interleaved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Resampler<T>
|
||||||
|
where
|
||||||
|
T: Sample + FromSample<f32> + IntoSample<f32>,
|
||||||
|
{
|
||||||
|
pub fn new(spec: SignalSpec, to_sample_rate: usize, duration: u64) -> Self {
|
||||||
|
let duration = duration as usize;
|
||||||
|
let num_channels = spec.channels.count();
|
||||||
|
|
||||||
|
let resampler = rubato::FftFixedIn::<f32>::new(
|
||||||
|
spec.rate as usize,
|
||||||
|
to_sample_rate,
|
||||||
|
duration,
|
||||||
|
2,
|
||||||
|
num_channels,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = rubato::Resampler::output_buffer_allocate(&resampler);
|
||||||
|
|
||||||
|
let input = vec![Vec::with_capacity(duration); num_channels];
|
||||||
|
|
||||||
|
Self { resampler, input, output, duration, interleaved: Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resamples a planar/non-interleaved input.
|
||||||
|
///
|
||||||
|
/// Returns the resampled samples in an interleaved format.
|
||||||
|
pub fn resample(&mut self, input: AudioBufferRef<'_>) -> Option<&[T]> {
|
||||||
|
// Copy and convert samples into input buffer.
|
||||||
|
convert_samples_any(&input, &mut self.input);
|
||||||
|
|
||||||
|
// Check if more samples are required.
|
||||||
|
if self.input[0].len() < self.duration {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(self.resample_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resample any remaining samples in the resample buffer.
|
||||||
|
pub fn flush(&mut self) -> Option<&[T]> {
|
||||||
|
let len = self.input[0].len();
|
||||||
|
|
||||||
|
if len == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let partial_len = len % self.duration;
|
||||||
|
|
||||||
|
if partial_len != 0 {
|
||||||
|
// Fill each input channel buffer with silence to the next multiple of the resampler
|
||||||
|
// duration.
|
||||||
|
for channel in self.input.iter_mut() {
|
||||||
|
channel.resize(len + (self.duration - partial_len), f32::MID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(self.resample_inner())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_samples_any(input: &AudioBufferRef<'_>, output: &mut [Vec<f32>]) {
|
||||||
|
match input {
|
||||||
|
AudioBufferRef::U8(input) => convert_samples(input, output),
|
||||||
|
AudioBufferRef::U16(input) => convert_samples(input, output),
|
||||||
|
AudioBufferRef::U24(input) => convert_samples(input, output),
|
||||||
|
AudioBufferRef::U32(input) => convert_samples(input, output),
|
||||||
|
AudioBufferRef::S8(input) => convert_samples(input, output),
|
||||||
|
AudioBufferRef::S16(input) => convert_samples(input, output),
|
||||||
|
AudioBufferRef::S24(input) => convert_samples(input, output),
|
||||||
|
AudioBufferRef::S32(input) => convert_samples(input, output),
|
||||||
|
AudioBufferRef::F32(input) => convert_samples(input, output),
|
||||||
|
AudioBufferRef::F64(input) => convert_samples(input, output),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_samples<S>(input: &AudioBuffer<S>, output: &mut [Vec<f32>])
|
||||||
|
where
|
||||||
|
S: Sample + IntoSample<f32>,
|
||||||
|
{
|
||||||
|
for (c, dst) in output.iter_mut().enumerate() {
|
||||||
|
let src = input.chan(c);
|
||||||
|
dst.extend(src.iter().map(|&s| s.into_sample()));
|
||||||
|
}
|
||||||
|
}
|
35
src/music_processor/music_processor.rs
Normal file
35
src/music_processor/music_processor.rs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
use symphonia::core::audio::{AudioBuffer, AudioBufferRef, Signal, AsAudioBufferRef, SignalSpec};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MusicProcessor {
|
||||||
|
pub audio_buffer: AudioBuffer<f32>,
|
||||||
|
pub audio_volume: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
456
src/music_storage/music_db.rs
Normal file
456
src/music_storage/music_db.rs
Normal file
|
@ -0,0 +1,456 @@
|
||||||
|
use file_format::{FileFormat, Kind};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use lofty::{Accessor, AudioFile, Probe, TaggedFileExt, ItemKey, ItemValue, TagType};
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use cue::{cd_text::PTI, cd::CD};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
use time::Date;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
use crate::music_controller::config::Config;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Song {
|
||||||
|
pub path: Box<Path>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
tracknum: Option<usize>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
date: Option<Date>,
|
||||||
|
genre: Option<String>,
|
||||||
|
plays: Option<usize>,
|
||||||
|
favorited: Option<bool>,
|
||||||
|
format: Option<FileFormat>, // TODO: Make this a proper FileFormat eventually
|
||||||
|
duration: Option<Duration>,
|
||||||
|
pub custom_tags: Option<Vec<Tag>>,
|
||||||
|
}
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum URI{
|
||||||
|
Local(String),
|
||||||
|
Remote(Service, String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum Service {
|
||||||
|
InternetRadio,
|
||||||
|
Spotify,
|
||||||
|
Youtube,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Playlist {
|
||||||
|
title: String,
|
||||||
|
cover_art: Box<Path>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_db() -> Result<(), rusqlite::Error> {
|
||||||
|
let path = "./music_database.db3";
|
||||||
|
let db_connection = Connection::open(path)?;
|
||||||
|
|
||||||
|
db_connection.pragma_update(None, "synchronous", "0")?;
|
||||||
|
db_connection.pragma_update(None, "journal_mode", "WAL")?;
|
||||||
|
|
||||||
|
// 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::<usize, bool>(0).unwrap(),
|
||||||
|
None => false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a cuesheet given a path and a directory it is located in,
|
||||||
|
/// returning a Vec of Song objects
|
||||||
|
fn parse_cuesheet(
|
||||||
|
cuesheet_path: &Path,
|
||||||
|
current_dir: &PathBuf
|
||||||
|
) -> Result<Vec<Song>, Box<dyn std::error::Error>>{
|
||||||
|
let cuesheet = CD::parse_file(cuesheet_path.to_path_buf())?;
|
||||||
|
|
||||||
|
let album = cuesheet.get_cdtext().read(PTI::Title);
|
||||||
|
|
||||||
|
let mut song_list:Vec<Song> = vec![];
|
||||||
|
|
||||||
|
for (index, track) in cuesheet.tracks().iter().enumerate() {
|
||||||
|
let track_string_path = format!("{}/{}", current_dir.to_string_lossy(), track.get_filename());
|
||||||
|
let track_path = Path::new(&track_string_path);
|
||||||
|
|
||||||
|
if !track_path.exists() {continue};
|
||||||
|
|
||||||
|
// Get the format as a string
|
||||||
|
let short_format = match FileFormat::from_file(track_path) {
|
||||||
|
Ok(fmt) => Some(fmt),
|
||||||
|
Err(_) => None
|
||||||
|
};
|
||||||
|
|
||||||
|
let duration = Duration::from_secs(track.get_length().unwrap_or(-1) as u64);
|
||||||
|
|
||||||
|
let custom_index_start = Tag::Custom{
|
||||||
|
tag: String::from("dango_cue_index_start"),
|
||||||
|
tag_value: track.get_index(0).unwrap_or(-1).to_string()
|
||||||
|
};
|
||||||
|
let custom_index_end = Tag::Custom{
|
||||||
|
tag: String::from("dango_cue_index_end"),
|
||||||
|
tag_value: track.get_index(0).unwrap_or(-1).to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let custom_tags: Vec<Tag> = vec![custom_index_start, custom_index_end];
|
||||||
|
|
||||||
|
let tags = track.get_cdtext();
|
||||||
|
let cue_song = Song {
|
||||||
|
path: track_path.into(),
|
||||||
|
title: tags.read(PTI::Title),
|
||||||
|
album: album.clone(),
|
||||||
|
tracknum: Some(index + 1),
|
||||||
|
artist: tags.read(PTI::Performer),
|
||||||
|
date: None,
|
||||||
|
genre: tags.read(PTI::Genre),
|
||||||
|
plays: Some(0),
|
||||||
|
favorited: Some(false),
|
||||||
|
format: short_format,
|
||||||
|
duration: Some(duration),
|
||||||
|
custom_tags: Some(custom_tags)
|
||||||
|
};
|
||||||
|
|
||||||
|
song_list.push(cue_song);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(song_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_all_music(
|
||||||
|
config: &Config,
|
||||||
|
target_path: &str,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let format = FileFormat::from_file(target_file.path())?;
|
||||||
|
let extension = target_file
|
||||||
|
.path()
|
||||||
|
.extension()
|
||||||
|
.expect("Could not find file extension");
|
||||||
|
|
||||||
|
// 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
|
||||||
|
parse_cuesheet(target_file.path(), ¤t_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// create the indexes after all the data is inserted
|
||||||
|
db_connection.execute(
|
||||||
|
"CREATE INDEX path_index ON music_collection (song_path)", ()
|
||||||
|
)?;
|
||||||
|
|
||||||
|
db_connection.execute(
|
||||||
|
"CREATE INDEX custom_tags_index ON custom_tags (song_path)", ()
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
||||||
|
Err(_) => match Probe::open(target_file)
|
||||||
|
.expect("ERROR: Bad path provided!")
|
||||||
|
.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?
|
||||||
|
};
|
||||||
|
|
||||||
|
let custom_value = match item.value() {
|
||||||
|
ItemValue::Text(value) => value,
|
||||||
|
ItemValue::Locator(value) => value,
|
||||||
|
ItemValue::Binary(_) => ""
|
||||||
|
};
|
||||||
|
|
||||||
|
if loops > 0 {
|
||||||
|
custom_insert.push_str(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
custom_insert.push_str(&format!(" (?1, '{}', '{}')", custom_key.replace("\'", "''"), custom_value.replace("\'", "''")));
|
||||||
|
|
||||||
|
loops += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the format as a string
|
||||||
|
let short_format: Option<String> = 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)]
|
||||||
|
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<Vec<MusicObject>> {
|
||||||
|
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 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MusicObject> = vec![];
|
||||||
|
|
||||||
|
while let Some(row) = rows.next().unwrap() {
|
||||||
|
let custom_tags: Vec<Tag> = match row.get::<usize, String>(11) {
|
||||||
|
Ok(result) => serde_json::from_str(&result).unwrap_or(vec![]),
|
||||||
|
Err(_) => vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_format: FileFormat = FileFormat::from(row.get::<usize, String>(9).unwrap().as_bytes());
|
||||||
|
|
||||||
|
let new_song = Song {
|
||||||
|
// TODO: Implement proper errors here
|
||||||
|
path: Path::new(&row.get::<usize, String>(0).unwrap_or("".to_owned())).into(),
|
||||||
|
title: row.get::<usize, String>(1).ok(),
|
||||||
|
album: row.get::<usize, String>(2).ok(),
|
||||||
|
tracknum: row.get::<usize, usize>(3).ok(),
|
||||||
|
artist: row.get::<usize, String>(4).ok(),
|
||||||
|
date: Date::from_calendar_date(row.get::<usize, i32>(5).unwrap_or(0), time::Month::January, 1).ok(), // TODO: Fix this to get the actual date
|
||||||
|
genre: row.get::<usize, String>(6).ok(),
|
||||||
|
plays: row.get::<usize, usize>(7).ok(),
|
||||||
|
favorited: row.get::<usize, bool>(8).ok(),
|
||||||
|
format: Some(file_format),
|
||||||
|
duration: Some(Duration::from_secs(row.get::<usize, u64>(10).unwrap_or(0))),
|
||||||
|
custom_tags: Some(custom_tags),
|
||||||
|
};
|
||||||
|
|
||||||
|
final_result.push(MusicObject::Song(new_song));
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(final_result)
|
||||||
|
}
|
27
src/music_storage/playlist.rs
Normal file
27
src/music_storage/playlist.rs
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
use std::path::Path;
|
||||||
|
use crate::music_controller::config::Config;
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
186
src/music_tracker/music_tracker.rs
Normal file
186
src/music_tracker/music_tracker.rs
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use md5::{Md5, Digest};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait MusicTracker {
|
||||||
|
/// Adds one listen to a song halfway through playback
|
||||||
|
async fn track_song(&self, song: &String) -> Result<(), surf::Error>;
|
||||||
|
|
||||||
|
/// Adds a 'listening' status to the music tracker service of choice
|
||||||
|
async fn track_now(&self, song: &String) -> Result<(), surf::Error>;
|
||||||
|
|
||||||
|
/// Reads config files, and attempts authentication with service
|
||||||
|
async fn test_tracker(&self) -> Result<(), surf::Error>;
|
||||||
|
|
||||||
|
/// Returns plays for a given song according to tracker service
|
||||||
|
async fn get_times_tracked(&self, song: &String) -> Result<u32, surf::Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct LastFM {
|
||||||
|
dango_api_key: String,
|
||||||
|
auth_token: Option<String>,
|
||||||
|
shared_secret: Option<String>,
|
||||||
|
session_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MusicTracker for LastFM {
|
||||||
|
async fn track_song(&self, song: &String) -> Result<(), surf::Error> {
|
||||||
|
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 string_timestamp = timestamp.to_string();
|
||||||
|
params.insert("method", "track.scrobble");
|
||||||
|
params.insert("artist", "Kikuo");
|
||||||
|
params.insert("track", "A Happy Death - Again");
|
||||||
|
params.insert("timestamp", &string_timestamp);
|
||||||
|
|
||||||
|
self.api_request(params).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn track_now(&self, song: &String) -> Result<(), surf::Error> {
|
||||||
|
let mut params: BTreeMap<&str, &str> = BTreeMap::new();
|
||||||
|
params.insert("method", "track.updateNowPlaying");
|
||||||
|
params.insert("artist", "Kikuo");
|
||||||
|
params.insert("track", "A Happy Death - Again");
|
||||||
|
self.api_request(params).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_tracker(&self) -> Result<(), surf::Error> {
|
||||||
|
let mut params: BTreeMap<&str, &str> = BTreeMap::new();
|
||||||
|
params.insert("method", "chart.getTopArtists");
|
||||||
|
self.api_request(params).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_times_tracked(&self, song: &String) -> Result<u32, surf::Error> {
|
||||||
|
todo!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
struct AuthToken {
|
||||||
|
token: String
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
struct SessionResponse {
|
||||||
|
name: String,
|
||||||
|
key: String,
|
||||||
|
subscriber: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
struct Session {
|
||||||
|
session: SessionResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LastFM {
|
||||||
|
// Returns a url to be accessed by the user
|
||||||
|
pub async fn get_auth_url(&mut self) -> Result<String, surf::Error> {
|
||||||
|
let method = String::from("auth.gettoken");
|
||||||
|
let api_key = self.dango_api_key.clone();
|
||||||
|
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?;
|
||||||
|
self.auth_token = Some(auth_token.token.clone());
|
||||||
|
|
||||||
|
let auth_url = format!("http://www.last.fm/api/auth/?api_key={api_key}&token={}", auth_token.token);
|
||||||
|
|
||||||
|
return Ok(auth_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_session(&mut self) {
|
||||||
|
let method = String::from("auth.getSession");
|
||||||
|
let api_key = self.dango_api_key.clone();
|
||||||
|
let auth_token = self.auth_token.clone().unwrap();
|
||||||
|
let shared_secret = self.shared_secret.clone().unwrap();
|
||||||
|
|
||||||
|
// Creates api_sig as defined in last.fm documentation
|
||||||
|
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.unwrap();
|
||||||
|
|
||||||
|
// Sets session key from received response
|
||||||
|
let session_response: Session = serde_json::from_str(&response).unwrap();
|
||||||
|
self.session_key = Some(session_response.session.key.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a new LastFM struct
|
||||||
|
pub fn new() -> LastFM {
|
||||||
|
let last_fm = LastFM {
|
||||||
|
// Grab this from config in future
|
||||||
|
dango_api_key: String::from("29a071e3113ab8ed36f069a2d3e20593"),
|
||||||
|
auth_token: None,
|
||||||
|
// Also grab from config in future
|
||||||
|
shared_secret: Some(String::from("5400c554430de5c5002d5e4bcc295b3d")),
|
||||||
|
session_key: None,
|
||||||
|
};
|
||||||
|
return last_fm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates an api request with the given parameters
|
||||||
|
pub async fn api_request(&self, mut params: BTreeMap<&str, &str>) -> Result<surf::Response, surf::Error> {
|
||||||
|
params.insert("api_key", &self.dango_api_key);
|
||||||
|
params.insert("sk", &self.session_key.as_ref().unwrap());
|
||||||
|
|
||||||
|
// Creates and sets api call signature
|
||||||
|
let api_sig = LastFM::request_sig(¶ms, &self.shared_secret.as_ref().unwrap());
|
||||||
|
params.insert("api_sig", &api_sig);
|
||||||
|
let mut string_params = String::from("");
|
||||||
|
|
||||||
|
// Creates method call string
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns an api signature as defined in the last.fm api documentation
|
||||||
|
fn request_sig(params: &BTreeMap<&str, &str>, shared_secret: &str) -> String {
|
||||||
|
let mut sig_string = String::new();
|
||||||
|
// Appends keys and values of parameters to the unhashed sig
|
||||||
|
for key in params.keys() {
|
||||||
|
let param_value = params.get(*key);
|
||||||
|
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!();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue