mirror of
https://github.com/Dangoware/dango-music-player.git
synced 2025-04-19 10:02:53 -05:00
Added preliminary listenbrainz scrobbling, removed cue support. Closes #1
This commit is contained in:
parent
14edbe7c46
commit
7e64c67b46
7 changed files with 118 additions and 81 deletions
|
@ -24,7 +24,6 @@ heapless = "0.7.16"
|
||||||
rb = "0.4.1"
|
rb = "0.4.1"
|
||||||
symphonia = { version = "0.5.3", features = ["all-codecs"] }
|
symphonia = { version = "0.5.3", features = ["all-codecs"] }
|
||||||
serde_json = "1.0.104"
|
serde_json = "1.0.104"
|
||||||
cue = "2.0.0"
|
|
||||||
async-std = "1.12.0"
|
async-std = "1.12.0"
|
||||||
async-trait = "0.1.73"
|
async-trait = "0.1.73"
|
||||||
md-5 = "0.10.5"
|
md-5 = "0.10.5"
|
||||||
|
|
|
@ -4,13 +4,14 @@ use std::fs;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub db_path: Box<PathBuf>,
|
pub db_path: Box<PathBuf>,
|
||||||
pub lastfm: Option<LastFMConfig>,
|
pub lastfm: Option<LastFMConfig>,
|
||||||
pub discord: Option<DiscordRPCConfig>,
|
pub discord: Option<DiscordRPCConfig>,
|
||||||
|
pub listenbrainz: Option<ListenBrainzConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
|
@ -21,18 +22,24 @@ impl Default for Config {
|
||||||
db_path: Box::new(path),
|
db_path: Box::new(path),
|
||||||
|
|
||||||
lastfm: None,
|
lastfm: None,
|
||||||
|
|
||||||
discord: Some(DiscordRPCConfig {
|
discord: Some(DiscordRPCConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
dango_client_id: 1144475145864499240,
|
dango_client_id: 1144475145864499240,
|
||||||
dango_icon: String::from("flat"),
|
dango_icon: String::from("flat"),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
listenbrainz: Some(ListenBrainzConfig {
|
||||||
|
enabled: false,
|
||||||
|
api_url: String::from("https://api.listenbrainz.org"),
|
||||||
|
auth_token: String::from(""),
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
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> {
|
pub fn new(config_file: &PathBuf) -> std::io::Result<Config> {
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
config.save(config_file)?;
|
config.save(config_file)?;
|
||||||
|
@ -40,14 +47,14 @@ impl Config {
|
||||||
Ok(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> {
|
pub fn from(config_file: &PathBuf) -> std::result::Result<Config, toml::de::Error> {
|
||||||
return toml::from_str(&read_to_string(config_file)
|
return toml::from_str(&read_to_string(config_file)
|
||||||
.expect("Failed to initalize music config: File not found!"));
|
.expect("Failed to initalize music config: File not found!"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saves config to given path
|
/// Saves config to given path
|
||||||
// Saves -> temp file, if successful, removes old config, and renames temp 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<()> {
|
pub fn save(&self, config_file: &PathBuf) -> std::io::Result<()> {
|
||||||
let toml = toml::to_string_pretty(self).unwrap();
|
let toml = toml::to_string_pretty(self).unwrap();
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{RwLock, Arc, Mutex};
|
use std::sync::{RwLock, Arc};
|
||||||
|
|
||||||
use rusqlite::Result;
|
use rusqlite::Result;
|
||||||
|
|
||||||
use crate::music_controller::config::Config;
|
use crate::music_controller::config::Config;
|
||||||
use crate::music_player::music_player::{MusicPlayer, PlayerStatus, DecoderMessage, DSPMessage};
|
use crate::music_player::music_player::{MusicPlayer, PlayerStatus, DecoderMessage, DSPMessage};
|
||||||
use crate::music_processor::music_processor::MusicProcessor;
|
use crate::music_storage::music_db::Song;
|
||||||
use crate::music_storage::music_db::{URI, Song};
|
|
||||||
|
|
||||||
pub struct MusicController {
|
pub struct MusicController {
|
||||||
pub config: Arc<RwLock<Config>>,
|
pub config: Arc<RwLock<Config>>,
|
||||||
|
|
|
@ -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),
|
cpal::SampleFormat::F64 => AudioOutput::<f64>::create_stream(spec, &device, &config.into(), duration),
|
||||||
_ => todo!(),
|
_ => todo!(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl<T: OutputSample> AudioOutput<T> {
|
impl<T: OutputSample> AudioOutput<T> {
|
||||||
// Creates the stream (TODO: Merge w/open_stream?)
|
// Creates the stream (TODO: Merge w/open_stream?)
|
||||||
|
@ -83,7 +82,7 @@ impl<T: OutputSample> AudioOutput<T> {
|
||||||
let num_channels = config.channels as usize;
|
let num_channels = config.channels as usize;
|
||||||
|
|
||||||
// Ring buffer is created with 200ms audio capacity
|
// 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= rb::SpscRb::new(ring_len);
|
||||||
|
|
||||||
let ring_buf_producer = ring_buf.producer();
|
let ring_buf_producer = ring_buf.producer();
|
||||||
|
|
|
@ -21,7 +21,7 @@ use crate::music_controller::config::Config;
|
||||||
use crate::music_player::music_output::AudioStream;
|
use crate::music_player::music_output::AudioStream;
|
||||||
use crate::music_processor::music_processor::MusicProcessor;
|
use crate::music_processor::music_processor::MusicProcessor;
|
||||||
use crate::music_storage::music_db::{URI, Song};
|
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
|
// Struct that controls playback of music
|
||||||
pub struct MusicPlayer {
|
pub struct MusicPlayer {
|
||||||
|
@ -146,13 +146,22 @@ impl MusicPlayer {
|
||||||
// Sets local config for trackers to detect changes
|
// Sets local config for trackers to detect changes
|
||||||
let local_config = global_config.clone();
|
let local_config = global_config.clone();
|
||||||
let mut trackers: Vec<Box<dyn MusicTracker>> = Vec::new();
|
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>>|{
|
let update_trackers = |trackers: &mut Vec<Box<dyn MusicTracker>>|{
|
||||||
if let Some(lastfm_config) = global_config.lastfm.clone() {
|
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() {
|
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);
|
update_trackers(&mut trackers);
|
||||||
|
|
|
@ -2,7 +2,6 @@ use file_format::{FileFormat, Kind};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use lofty::{Accessor, AudioFile, Probe, TaggedFileExt, ItemKey, ItemValue, TagType};
|
use lofty::{Accessor, AudioFile, Probe, TaggedFileExt, ItemKey, ItemValue, TagType};
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use cue::{cd_text::PTI, cd::CD};
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
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(
|
pub fn find_all_music(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
|
@ -196,7 +137,6 @@ pub fn find_all_music(
|
||||||
add_file_to_db(target_file.path(), &db_connection)
|
add_file_to_db(target_file.path(), &db_connection)
|
||||||
} else if extension.to_ascii_lowercase() == "cue" {
|
} else if extension.to_ascii_lowercase() == "cue" {
|
||||||
// TODO: implement cuesheet support
|
// TODO: implement cuesheet support
|
||||||
parse_cuesheet(target_file.path(), ¤t_dir);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Serialize, Deserialize, Serializer};
|
use serde::{Serialize, Deserialize};
|
||||||
use md5::{Md5, Digest};
|
use md5::{Md5, Digest};
|
||||||
use discord_presence::{ Event, DiscordError};
|
use discord_presence::{Event};
|
||||||
use surf::StatusCode;
|
use surf::StatusCode;
|
||||||
|
|
||||||
use crate::music_storage::music_db::Song;
|
use crate::music_storage::music_db::Song;
|
||||||
|
@ -38,7 +39,6 @@ pub enum TrackerError {
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl TrackerError {
|
impl TrackerError {
|
||||||
pub fn from_surf_error(error: surf::Error) -> TrackerError {
|
pub fn from_surf_error(error: surf::Error) -> TrackerError {
|
||||||
return match error.status() {
|
return match error.status() {
|
||||||
|
@ -300,4 +300,88 @@ impl MusicTracker for DiscordRPC {
|
||||||
async fn get_times_tracked(&mut self, song: &Song) -> Result<u32, TrackerError> {
|
async fn get_times_tracked(&mut self, song: &Song) -> Result<u32, TrackerError> {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue