about summary refs log tree commit diff stats
path: root/pkgs/by-name/mp/mpdpopm/src/ratings.rs
diff options
context:
space:
mode:
Diffstat (limited to 'pkgs/by-name/mp/mpdpopm/src/ratings.rs')
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/ratings.rs195
1 files changed, 195 insertions, 0 deletions
diff --git a/pkgs/by-name/mp/mpdpopm/src/ratings.rs b/pkgs/by-name/mp/mpdpopm/src/ratings.rs
new file mode 100644
index 00000000..739d3827
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/ratings.rs
@@ -0,0 +1,195 @@
+// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com>
+//
+// This file is part of mpdpopm.
+//
+// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU
+// General Public License as published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
+// Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along with mpdpopm.  If not,
+// see <http://www.gnu.org/licenses/>.
+
+//! Logic for rating MPD tracks.
+//!
+//! # Introduction
+//!
+//! This module contains types implementing a basic rating functionality for
+//! [MPD](http://www.musicpd.org).
+//!
+//! # Discussion
+//!
+//! Rating messages to the relevant channel take the form `RATING( TRACK)?` (the two components can
+//! be separated by any whitespace). The rating can be given by an integer between 0 & 255
+//! (inclusive) represented in base ten, or as one-to-five asterisks (i.e. `\*{1,5}`). In the latter
+//! case, the rating will be mapped to 1-255 as per Winamp's
+//! [convention](http://forums.winamp.com/showpost.php?p=2903240&postcount=94):
+//!
+//!   - 224-255: 5 stars when READ with windows explorer, writes 255
+//!   - 160-223: 4 stars when READ with windows explorer, writes 196
+//!   - 096-159: 3 stars when READ with windows explorer, writes 128
+//!   - 032-095: 2 stars when READ with windows explorer, writes 64
+//!   - 001-031: 1 stars when READ with windows explorer, writes 1
+//!
+//! NB a rating of zero means "not rated".
+//!
+//! Everything after the first whitepace, if present, is taken to be the track to be rated (i.e.
+//! the track may contain whitespace). If omitted, the rating is taken to apply to the current
+//! track.
+
+use backtrace::Backtrace;
+
+use std::path::PathBuf;
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+//                                             Error                                              //
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+/// An enumeration of ratings errors
+#[derive(Debug)]
+pub enum Error {
+    Rating {
+        source: std::num::ParseIntError,
+        text: String,
+    },
+    PlayerStopped,
+    NotImplemented {
+        feature: String,
+    },
+    BadPath {
+        pth: PathBuf,
+        back: Backtrace,
+    },
+    Client {
+        source: crate::clients::Error,
+        back: Backtrace,
+    },
+}
+
+impl std::fmt::Display for Error {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        match self {
+            Error::Rating { source, text } => write!(
+                f,
+                "Unable to interpret ``{}'' as a rating: {}",
+                text, source
+            ),
+            Error::PlayerStopped => write!(f, "Player stopped"),
+            Error::NotImplemented { feature } => write!(f, "{} not implemented", feature),
+            Error::BadPath { pth, back: _ } => write!(f, "Bad path: {:?}", pth),
+            Error::Client { source, back: _ } => write!(f, "Client error: {}", source),
+        }
+    }
+}
+
+impl std::error::Error for Error {
+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+        match &self {
+            Error::Rating { text: _, source } => Some(source),
+            Error::Client { source, back: _ } => Some(source),
+            _ => None,
+        }
+    }
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+//                                     RatingRequest message                                      //
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+/// The track to which a rating shall be applied.
+#[derive(Debug, PartialEq)]
+pub enum RatedTrack {
+    Current,
+    File(std::path::PathBuf),
+    Relative(i8),
+}
+
+/// A request from a client to rate a track.
+#[derive(Debug)]
+pub struct RatingRequest {
+    pub rating: u8,
+    pub track: RatedTrack,
+}
+
+/// Produce a RatingRequest instance from a line of MPD output.
+impl std::convert::TryFrom<&str> for RatingRequest {
+    type Error = Error;
+
+    /// Attempt to produce a RatingRequest instance from a line of MPD response to a
+    /// "readmessages" command. After the channel line, each subsequent line will be of the form
+    /// "message: $MESSAGE"-- this method assumes that the "message: " prefix has been stripped off
+    /// (i.e. we're dealing with a single line of text containing only our custom message format).
+    ///
+    /// For ratings, we expect a message of the form: "RATING (TRACK)?".
+    fn try_from(text: &str) -> std::result::Result<Self, Self::Error> {
+        // We expect a message of the form: "RATING (TRACK)?"; let us split `text' into those two
+        // components for separate processing:
+        let text = text.trim();
+        let (rating, track) = match text.find(char::is_whitespace) {
+            Some(idx) => (&text[..idx], &text[idx + 1..]),
+            None => (text, ""),
+        };
+
+        // Rating first-- the desired rating can be specified in a few ways...
+        let rating = if rating.is_empty() {
+            // an empty string is interpreted as zero:
+            0u8
+        } else {
+            // "*{1,5}" is interpreted as one-five stars, mapped to [0,255] as per Winamp:
+            match rating {
+                "*" => 1,
+                "**" => 64,
+                "***" => 128,
+                "****" => 196,
+                "*****" => 255,
+                // failing that, we try just interperting `rating' as an unsigned integer:
+                _ => rating.parse::<u8>().map_err(|err| Error::Rating {
+                    source: err,
+                    text: String::from(rating),
+                })?,
+            }
+        };
+
+        // Next-- track. This, too, can be given in a few ways:
+        let track = if track.is_empty() {
+            // nothing at all just means "current track"
+            RatedTrack::Current
+        } else {
+            // otherwise...
+            match text.parse::<i8>() {
+                // if we can interpret `track' as an i8, we take it as an offset...
+                Ok(i) => RatedTrack::Relative(i),
+                // else, we assume it's a path. If it's not, we'll figure that out downstream.
+                Err(_) => RatedTrack::File(std::path::PathBuf::from(&track)),
+            }
+        };
+
+        Ok(RatingRequest { rating, track })
+    }
+}
+
+#[cfg(test)]
+mod rating_request_tests {
+    use super::*;
+    use std::convert::TryFrom;
+
+    /// RatingRequest smoke tests
+    #[test]
+    fn rating_request_smoke() {
+        let req = RatingRequest::try_from("*** foo bar splat.mp3").unwrap();
+        assert_eq!(req.rating, 128);
+        assert_eq!(
+            req.track,
+            RatedTrack::File(PathBuf::from("foo bar splat.mp3"))
+        );
+        let req = RatingRequest::try_from("255").unwrap();
+        assert_eq!(req.rating, 255);
+        assert_eq!(req.track, RatedTrack::Current);
+        let _req = RatingRequest::try_from("******").unwrap_err();
+    }
+}