From 7e64c67b46055e88ff9b2b26ba5ed5e0a8b179fc Mon Sep 17 00:00:00 2001
From: G2-Games <ke0bhogsg@gmail.com>
Date: Fri, 19 Jan 2024 17:45:57 -0600
Subject: [PATCH] Added preliminary listenbrainz scrobbling, removed cue
 support. Closes #1

---
 Cargo.toml                               |  1 -
 src/music_controller/config.rs           | 19 +++--
 src/music_controller/music_controller.rs |  5 +-
 src/music_player/music_output.rs         |  5 +-
 src/music_player/music_player.rs         | 17 +++--
 src/music_storage/music_db.rs            | 60 ----------------
 src/music_tracker/music_tracker.rs       | 92 ++++++++++++++++++++++--
 7 files changed, 118 insertions(+), 81 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index b312946..3ee99fa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,7 +24,6 @@ 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"
diff --git a/src/music_controller/config.rs b/src/music_controller/config.rs
index c77a02a..034f12d 100644
--- a/src/music_controller/config.rs
+++ b/src/music_controller/config.rs
@@ -4,13 +4,14 @@ use std::fs;
 
 use serde::{Deserialize, Serialize};
 
-use crate::music_tracker::music_tracker::{LastFM, LastFMConfig, DiscordRPCConfig};
+use crate::music_tracker::music_tracker::{LastFMConfig, DiscordRPCConfig, ListenBrainzConfig};
 
 #[derive(Serialize, Deserialize, PartialEq, Eq)]
 pub struct Config {
     pub db_path: Box<PathBuf>,
     pub lastfm: Option<LastFMConfig>,
     pub discord: Option<DiscordRPCConfig>,
+    pub listenbrainz: Option<ListenBrainzConfig>,
 }
 
 impl Default for Config {
@@ -21,18 +22,24 @@ impl Default for 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
+    /// Creates and saves a new config with default values
     pub fn new(config_file: &PathBuf) -> std::io::Result<Config> {        
         let config = Config::default();
         config.save(config_file)?;
@@ -40,14 +47,14 @@ impl Config {
         Ok(config)
     }
 
-    // Loads config from given file path
+    /// 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
+    /// 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();
         
diff --git a/src/music_controller/music_controller.rs b/src/music_controller/music_controller.rs
index 29f4fb7..da4f676 100644
--- a/src/music_controller/music_controller.rs
+++ b/src/music_controller/music_controller.rs
@@ -1,12 +1,11 @@
 use std::path::PathBuf;
-use std::sync::{RwLock, Arc, Mutex};
+use std::sync::{RwLock, Arc};
 
 use rusqlite::Result;
 
 use crate::music_controller::config::Config;
 use crate::music_player::music_player::{MusicPlayer, PlayerStatus, DecoderMessage, DSPMessage};
-use crate::music_processor::music_processor::MusicProcessor;
-use crate::music_storage::music_db::{URI, Song};
+use crate::music_storage::music_db::Song;
 
 pub struct MusicController {
     pub config: Arc<RwLock<Config>>,
diff --git a/src/music_player/music_output.rs b/src/music_player/music_output.rs
index b774b0f..795e9e9 100644
--- a/src/music_player/music_output.rs
+++ b/src/music_player/music_output.rs
@@ -74,8 +74,7 @@ pub fn open_stream(spec: SignalSpec, duration: Duration) -> Result<Box<dyn Audio
             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?)
@@ -83,7 +82,7 @@ impl<T: OutputSample> AudioOutput<T> {
         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_len = ((50 * config.sample_rate.0 as usize) / 1000) * num_channels;
         let ring_buf= rb::SpscRb::new(ring_len);
         
         let ring_buf_producer = ring_buf.producer();
diff --git a/src/music_player/music_player.rs b/src/music_player/music_player.rs
index 2944788..5ab0ad9 100644
--- a/src/music_player/music_player.rs
+++ b/src/music_player/music_player.rs
@@ -21,7 +21,7 @@ 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, LastFM, DiscordRPCConfig, DiscordRPC, TrackerError};
+use crate::music_tracker::music_tracker::{MusicTracker, TrackerError, LastFM, DiscordRPC, ListenBrainz};
 
 // Struct that controls playback of music
 pub struct MusicPlayer {
@@ -146,13 +146,22 @@ impl MusicPlayer {
             // Sets local config for trackers to detect changes
             let local_config = global_config.clone();
             let mut trackers: Vec<Box<dyn MusicTracker>> = Vec::new();
-            // Updates local trackers to the music controller config
+            // Updates local trackers to the music controller config // TODO: refactor
             let update_trackers = |trackers: &mut Vec<Box<dyn MusicTracker>>|{
                 if let Some(lastfm_config) = global_config.lastfm.clone() {
-                    trackers.push(Box::new(LastFM::new(&lastfm_config)));
+                    if lastfm_config.enabled {
+                        trackers.push(Box::new(LastFM::new(&lastfm_config)));
+                    }
                 }
                 if let Some(discord_config) = global_config.discord.clone() {
-                    trackers.push(Box::new(DiscordRPC::new(&discord_config)));
+                    if discord_config.enabled {
+                        trackers.push(Box::new(DiscordRPC::new(&discord_config)));
+                    }
+                }
+                if let Some(listenbz_config) = global_config.listenbrainz.clone() {
+                    if listenbz_config.enabled {
+                        trackers.push(Box::new(ListenBrainz::new(&listenbz_config)));
+                    }
                 }
             };
             update_trackers(&mut trackers);
diff --git a/src/music_storage/music_db.rs b/src/music_storage/music_db.rs
index 7ed59fe..093d7c5 100644
--- a/src/music_storage/music_db.rs
+++ b/src/music_storage/music_db.rs
@@ -2,7 +2,6 @@ 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;
@@ -105,64 +104,6 @@ fn path_in_db(query_path: &Path, connection: &Connection) -> bool {
     }
 }
 
-/// 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: URI::Local(String::from("URI")),
-            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,
@@ -196,7 +137,6 @@ pub fn find_all_music(
             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(), &current_dir);
         }
     }
 
diff --git a/src/music_tracker/music_tracker.rs b/src/music_tracker/music_tracker.rs
index 7dd1d8a..213a5f1 100644
--- a/src/music_tracker/music_tracker.rs
+++ b/src/music_tracker/music_tracker.rs
@@ -1,10 +1,11 @@
 use std::time::{SystemTime, UNIX_EPOCH};
 use std::collections::BTreeMap;
+use serde_json::json;
 
 use async_trait::async_trait;
-use serde::{Serialize, Deserialize, Serializer};
+use serde::{Serialize, Deserialize};
 use md5::{Md5, Digest};
-use discord_presence::{ Event, DiscordError};
+use discord_presence::{Event};
 use surf::StatusCode;
 
 use crate::music_storage::music_db::Song;
@@ -38,7 +39,6 @@ pub enum TrackerError {
     Unknown,
 }
 
-
 impl TrackerError {
     pub fn from_surf_error(error: surf::Error) -> TrackerError {
         return match error.status() {
@@ -300,4 +300,88 @@ impl MusicTracker for DiscordRPC {
     async fn get_times_tracked(&mut self, song: &Song) -> Result<u32, TrackerError> {
         return Ok(0);
     }
-}
\ No newline at end of file
+}
+
+#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)]
+pub struct ListenBrainzConfig {
+    pub enabled: bool,
+    pub api_url: String,
+    pub auth_token: String,
+}
+
+pub struct ListenBrainz {
+    config: ListenBrainzConfig,
+}
+
+#[async_trait]
+impl MusicTracker for ListenBrainz {
+    async fn track_now(&mut self, song: Song) -> Result<(), TrackerError> {
+        let (artist, track) = match (song.artist, song.title) {
+            (Some(artist), Some(track)) => (artist, track),
+            _ => return Err(TrackerError::InvalidSong)
+        };
+        // Creates a json to submit a single song as defined in the listenbrainz documentation
+        let json_req = json!({
+            "listen_type": "playing_now",
+            "payload": [
+                {
+                    "track_metadata": {
+                        "artist_name": artist,
+                        "track_name": track,
+                    }
+                }
+            ]
+        });
+        
+        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)
+        };
+        
+        let json_req = json!({
+            "listen_type": "single",
+            "payload": [
+                {
+                    "listened_at": timestamp,
+                    "track_metadata": {
+                        "artist_name": artist,
+                        "track_name": track,
+                    }
+                }
+            ]
+        });
+        
+        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 test_tracker(&mut self) -> Result<(), TrackerError> {
+        todo!()
+    }
+    async fn get_times_tracked(&mut self, song: &Song) -> Result<u32, TrackerError> {
+        todo!()
+    }
+}
+
+impl ListenBrainz {
+    pub fn new(config: &ListenBrainzConfig) -> Self {
+        ListenBrainz {
+            config: config.clone()
+        }
+    }
+    // Makes an api request to configured url with given json
+    pub async fn api_request(&self, request: &String, endpoint: &String) -> Result<surf::Response, surf::Error> {
+        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
+    }
+}