391 lines
13 KiB
Rust
391 lines
13 KiB
Rust
mod events;
|
|
mod query;
|
|
mod player_settings;
|
|
|
|
use std::collections::HashMap;
|
|
use std::fmt::Display;
|
|
use std::num::ParseIntError;
|
|
use std::str::FromStr;
|
|
use typed_builder::TypedBuilder;
|
|
use crate::cmus::query::CmusQueryResponse;
|
|
|
|
#[derive(Debug, PartialEq, Default)]
|
|
pub struct TrackMetadata {
|
|
tags: HashMap<String, String>,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Default)]
|
|
pub enum TrackStatus {
|
|
Playing,
|
|
Paused,
|
|
#[default]
|
|
Stopped,
|
|
}
|
|
|
|
#[derive(Debug, TypedBuilder, PartialEq, Default)]
|
|
pub struct Track {
|
|
pub status: TrackStatus,
|
|
pub path: String,
|
|
pub metadata: TrackMetadata,
|
|
pub duration: u32,
|
|
pub position: u32,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
pub enum CmusError {
|
|
CmusRunningError(String),
|
|
UnknownStatus,
|
|
NoStatus,
|
|
EmptyPath,
|
|
DurationError(String),
|
|
PositionError(String),
|
|
UnknownError(String),
|
|
UnknownAAAMode(String),
|
|
UnknownShuffleMode(String),
|
|
}
|
|
|
|
impl Display for CmusError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
CmusError::CmusRunningError(s) => write!(f, "Cmus running error: {}", s),
|
|
CmusError::UnknownStatus => write!(f, "Unknown status"),
|
|
CmusError::NoStatus => write!(f, "No status"),
|
|
CmusError::EmptyPath => write!(f, "Empty path"),
|
|
CmusError::DurationError(s) => write!(f, "Duration error: {}", s),
|
|
CmusError::PositionError(s) => write!(f, "Position error: {}", s),
|
|
CmusError::UnknownError(s) => write!(f, "Unknown error: {}", s),
|
|
CmusError::UnknownAAAMode(s) => write!(f, "Unknown AAA mode: {}", s),
|
|
CmusError::UnknownShuffleMode(s) => write!(f, "Unknown shuffle mode: {}", s),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FromStr for TrackStatus {
|
|
type Err = CmusError;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s {
|
|
"playing" => Ok(TrackStatus::Playing),
|
|
"paused" => Ok(TrackStatus::Paused),
|
|
"stopped" => Ok(TrackStatus::Stopped),
|
|
_ => Err(CmusError::UnknownStatus),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FromStr for Track {
|
|
type Err = CmusError;
|
|
|
|
/// Creates a `Track` from the output of `cmus-remote -Q`.
|
|
///
|
|
/// Pares the first 4 lines.
|
|
/// The first line is the status, the second is the path, the third is the duration, and the fourth is the position.
|
|
/// The rest of the lines are tags, and the player settings, so we'll send them to `TrackMetadata::parse`, to get the tags.
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
let mut lines = s.lines();
|
|
|
|
Ok(Track::builder()
|
|
.status(TrackStatus::from_str(
|
|
lines
|
|
.next()
|
|
.ok_or(CmusError::NoStatus)?
|
|
.split_once(' ')
|
|
.ok_or(CmusError::NoStatus)?
|
|
.1,
|
|
)?)
|
|
.path(
|
|
lines
|
|
.next()
|
|
.ok_or(CmusError::EmptyPath)?
|
|
.split_once(' ')
|
|
.ok_or(CmusError::EmptyPath)?
|
|
.1
|
|
.to_string(),
|
|
)
|
|
.duration(
|
|
lines
|
|
.next()
|
|
.ok_or(CmusError::DurationError("Missing duration".to_string()))?
|
|
.split_once(' ')
|
|
.ok_or(CmusError::DurationError("Empty duration".to_string()))?
|
|
.1
|
|
.parse()
|
|
.map_err(|e: ParseIntError| CmusError::DurationError(e.to_string()))?,
|
|
)
|
|
.position(
|
|
lines
|
|
.next()
|
|
.ok_or(CmusError::PositionError("Missing position".to_string()))?
|
|
.split_once(' ')
|
|
.ok_or(CmusError::PositionError("Empty position".to_string()))?
|
|
.1
|
|
.parse()
|
|
.map_err(|e: ParseIntError| CmusError::PositionError(e.to_string()))?,
|
|
)
|
|
.metadata(TrackMetadata::parse(lines))
|
|
.build())
|
|
}
|
|
}
|
|
|
|
impl TrackMetadata {
|
|
/// Parse the tags from the rest of `cmus-remote -Q` output.
|
|
/// This function will assume you processed the first 4 lines, and remove them from the iterator.
|
|
///
|
|
/// and also assume the all tags is contained in the iterator.
|
|
fn parse<'a>(mut lines: impl Iterator<Item = &'a str>) -> Self {
|
|
let mut tags = HashMap::new();
|
|
|
|
while let Some(line) = lines.next() {
|
|
match line.trim().split_once(' ') {
|
|
Some(("tag", rest)) => {
|
|
let Some((key, value)) = rest.split_once(' ') else {
|
|
continue; // Ignore lines that don't have a key and a value.
|
|
};
|
|
tags.insert(key.to_string(), value.to_string());
|
|
}
|
|
_ => break, // We've reached the end of the tags.
|
|
}
|
|
}
|
|
|
|
TrackMetadata { tags }
|
|
}
|
|
|
|
pub fn get(&self, key: &str) -> Option<&str> {
|
|
self.tags.get(key).map(|s| s.as_str())
|
|
}
|
|
}
|
|
|
|
impl Track {
|
|
/// Returns the name of the track.
|
|
///
|
|
/// This is the title, if it exists, otherwise it's the file name without the extension.
|
|
pub fn get_name(&self) -> &str {
|
|
self.metadata.get("title").unwrap_or_else(|| {
|
|
self.path
|
|
.split('/')
|
|
.last()
|
|
.unwrap_or("")
|
|
.split_once(".")
|
|
.unwrap_or(("", ""))
|
|
.0
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Make a status request to cmus.
|
|
/// And collect the output, and parse it into a `CmusQueryResponse`.
|
|
/// If the cmus is not running, or the socket is not available, this function will return an error.
|
|
#[inline]
|
|
pub fn ping_cmus(query_command: &mut std::process::Command) -> Result<CmusQueryResponse, CmusError> {
|
|
// Just run the command, and collect the output.
|
|
let output = query_command
|
|
.output()
|
|
.map_err(|e| CmusError::CmusRunningError(e.to_string()))?;
|
|
|
|
if !output.status.success() {
|
|
return Err(CmusError::CmusRunningError(
|
|
String::from_utf8(output.stderr).map_err(|e| CmusError::UnknownError(e.to_string()))?,
|
|
));
|
|
}
|
|
|
|
let output =
|
|
String::from_utf8(output.stdout).map_err(|e| CmusError::UnknownError(e.to_string()))?;
|
|
|
|
CmusQueryResponse::from_str(&output).map_err(|e| CmusError::UnknownError(e.to_string()))
|
|
}
|
|
|
|
/// Build the query command.
|
|
/// This function it should call only one time entire the program life time, So it makes sense to make it inline.
|
|
/// This function will return a `std::process::Command` that can be used to query cmus, you should store it in a variable :).
|
|
#[inline(always)]
|
|
pub fn build_query_command(
|
|
cmus_remote_bin: &str,
|
|
socket_addr: &Option<String>,
|
|
socket_pass: &Option<String>,
|
|
) -> std::process::Command {
|
|
let cmd_arr = cmus_remote_bin.split_whitespace().collect::<Vec<_>>();
|
|
let mut command = std::process::Command::new(cmd_arr[0]);
|
|
|
|
// If there are more than 1 slice, then add the rest of the slices as arguments.
|
|
if cmd_arr.len() > 1 {
|
|
command.args(&cmd_arr[1..]);
|
|
}
|
|
|
|
if let Some(socket_addr) = socket_addr {
|
|
command.arg("--server").arg(socket_addr); // Use the socket instead of the default socket.
|
|
}
|
|
|
|
if let Some(socket_pass) = socket_pass {
|
|
command.arg("--passwd").arg(socket_pass);
|
|
}
|
|
|
|
command.arg("-Q");
|
|
|
|
command
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::assert_matches::assert_matches;
|
|
|
|
const OUTPUT_WITH_ALL_TAGS: &str =
|
|
include_str!("../../tests/samples/cmus-remote-output-with-all-tags.txt");
|
|
|
|
const SOME_TAGS: &str = r#"tag artist Alex Goot
|
|
tag album Alex Goot & Friends, Vol. 3
|
|
tag title Photograph
|
|
tag date 2014
|
|
tag genre Pop
|
|
tag discnumber 1
|
|
tag tracknumber 8
|
|
tag albumartist Alex Goot
|
|
tag replaygain_track_gain -9.4 dB
|
|
tag composer Chad Kroeger
|
|
tag label mudhutdigital.com
|
|
tag publisher mudhutdigital.com
|
|
tag bpm 146
|
|
set aaa_mode artist
|
|
..."#;
|
|
|
|
#[test]
|
|
fn test_create_track_from_str() {
|
|
let track = Track::from_str(OUTPUT_WITH_ALL_TAGS);
|
|
|
|
assert_matches!(track, Ok(_));
|
|
|
|
let track = track.unwrap();
|
|
|
|
assert_eq!(track.status, TrackStatus::Playing);
|
|
assert_eq!(track.path, "/mnt/Data/Music/FLAC/Alex Goot/Alex Goot - Alex Goot & Friends, Vol. 3/08 - Photograph.mp3");
|
|
assert_eq!(track.duration, 284);
|
|
assert_eq!(track.position, 226);
|
|
assert_eq!(
|
|
track.metadata.tags.get("artist"),
|
|
Some(&"Alex Goot".to_string())
|
|
);
|
|
assert_eq!(
|
|
track.metadata.tags.get("album"),
|
|
Some(&"Alex Goot & Friends, Vol. 3".to_string())
|
|
);
|
|
assert_eq!(
|
|
track.metadata.tags.get("title"),
|
|
Some(&"Photograph".to_string())
|
|
);
|
|
assert_eq!(track.metadata.tags.get("date"), Some(&"2014".to_string()));
|
|
assert_eq!(track.metadata.tags.get("genre"), Some(&"Pop".to_string()));
|
|
assert_eq!(
|
|
track.metadata.tags.get("discnumber"),
|
|
Some(&"1".to_string())
|
|
);
|
|
assert_eq!(
|
|
track.metadata.tags.get("tracknumber"),
|
|
Some(&"8".to_string())
|
|
);
|
|
assert_eq!(
|
|
track.metadata.tags.get("albumartist"),
|
|
Some(&"Alex Goot".to_string())
|
|
);
|
|
assert_eq!(
|
|
track.metadata.tags.get("replaygain_track_gain"),
|
|
Some(&"-9.4 dB".to_string())
|
|
);
|
|
assert_eq!(
|
|
track.metadata.tags.get("composer"),
|
|
Some(&"Chad Kroeger".to_string())
|
|
);
|
|
assert_eq!(
|
|
track.metadata.tags.get("label"),
|
|
Some(&"mudhutdigital.com".to_string())
|
|
);
|
|
assert_eq!(
|
|
track.metadata.tags.get("publisher"),
|
|
Some(&"mudhutdigital.com".to_string())
|
|
);
|
|
assert_eq!(track.metadata.tags.get("bpm"), Some(&"146".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_metadata_from_the_string() {
|
|
let metadata = TrackMetadata::parse(SOME_TAGS.lines());
|
|
|
|
assert_eq!(metadata.tags.get("artist"), Some(&"Alex Goot".to_string()));
|
|
assert_eq!(
|
|
metadata.tags.get("album"),
|
|
Some(&"Alex Goot & Friends, Vol. 3".to_string())
|
|
);
|
|
assert_eq!(metadata.tags.get("title"), Some(&"Photograph".to_string()));
|
|
assert_eq!(metadata.tags.get("date"), Some(&"2014".to_string()));
|
|
assert_eq!(metadata.tags.get("genre"), Some(&"Pop".to_string()));
|
|
assert_eq!(metadata.tags.get("discnumber"), Some(&"1".to_string()));
|
|
assert_eq!(metadata.tags.get("tracknumber"), Some(&"8".to_string()));
|
|
assert_eq!(
|
|
metadata.tags.get("albumartist"),
|
|
Some(&"Alex Goot".to_string())
|
|
);
|
|
assert_eq!(
|
|
metadata.tags.get("replaygain_track_gain"),
|
|
Some(&"-9.4 dB".to_string())
|
|
);
|
|
assert_eq!(
|
|
metadata.tags.get("composer"),
|
|
Some(&"Chad Kroeger".to_string())
|
|
);
|
|
assert_eq!(
|
|
metadata.tags.get("label"),
|
|
Some(&"mudhutdigital.com".to_string())
|
|
);
|
|
assert_eq!(
|
|
metadata.tags.get("publisher"),
|
|
Some(&"mudhutdigital.com".to_string())
|
|
);
|
|
assert_eq!(metadata.tags.get("bpm"), Some(&"146".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_the_query_command_with_no_custom_socket_and_no_pass() {
|
|
let command = build_query_command("cmus-remote", &None, &None);
|
|
|
|
assert_eq!(command.get_program(), "cmus-remote");
|
|
assert_eq!(command.get_args().collect::<Vec<_>>(), &["-Q"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_the_query_command_with_custom_socket_and_no_pass() {
|
|
let command =
|
|
build_query_command("cmus-remote", &Some("/tmp/cmus-socket".to_string()), &None);
|
|
|
|
assert_eq!(command.get_program(), "cmus-remote");
|
|
assert_eq!(
|
|
command.get_args().collect::<Vec<_>>(),
|
|
&["--server", "/tmp/cmus-socket", "-Q"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_the_query_command_with_custom_socket_and_pass() {
|
|
let command = build_query_command(
|
|
"cmus-remote",
|
|
&Some("/tmp/cmus-socket".to_string()),
|
|
&Some("pass".to_string()),
|
|
);
|
|
|
|
assert_eq!(command.get_program(), "cmus-remote");
|
|
assert_eq!(
|
|
command.get_args().collect::<Vec<_>>(),
|
|
&["--server", "/tmp/cmus-socket", "--passwd", "pass", "-Q"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_the_query_command_with_custom_bin_path() {
|
|
let command = build_query_command("flatpak run io.github.cmus.cmus", &None, &None);
|
|
|
|
assert_eq!(command.get_program(), "flatpak");
|
|
assert_eq!(
|
|
command.get_args().collect::<Vec<_>>(),
|
|
&["run", "io.github.cmus.cmus", "-Q"]
|
|
);
|
|
}
|
|
}
|