about summary refs log tree commit diff stats
path: root/pkgs/by-name/mp/mpdpopm/src/playcounts.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/playcounts.rs367
1 files changed, 367 insertions, 0 deletions
diff --git a/pkgs/by-name/mp/mpdpopm/src/playcounts.rs b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs
new file mode 100644
index 00000000..4e308d4a
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs
@@ -0,0 +1,367 @@
+// 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/>.
+
+//! playcounts -- managing play counts & lastplayed times
+//!
+//! # Introduction
+//!
+//! Play counts & last played timestamps are maintained so long as [PlayState::update] is called
+//! regularly (every few seconds, say). For purposes of library maintenance, however, they can be
+//! set explicitly:
+//!
+//! - `setpc PLAYCOUNT( TRACK)?`
+//! - `setlp LASTPLAYED( TRACK)?`
+//!
+
+use crate::clients::{Client, PlayerStatus};
+use crate::storage::{self, last_played, play_count, skipped};
+
+use backtrace::Backtrace;
+use tracing::{debug, info};
+
+use std::path::PathBuf;
+use std::time::SystemTime;
+
+#[derive(Debug)]
+pub enum Error {
+    PlayerStopped,
+    BadPath {
+        pth: PathBuf,
+    },
+    SystemTime {
+        source: std::time::SystemTimeError,
+        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::PlayerStopped => write!(f, "The MPD player is stopped"),
+            Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth),
+            Error::SystemTime { source, back: _ } => {
+                write!(f, "Couldn't get system time: {}", source)
+            }
+            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::SystemTime { source, back: _ } => Some(source),
+            Error::Client { source, back: _ } => Some(source),
+            _ => None,
+        }
+    }
+}
+
+impl From<storage::Error> for Error {
+    fn from(value: storage::Error) -> Self {
+        match value {
+            storage::Error::PlayerStopped => Self::PlayerStopped,
+            storage::Error::BadPath { pth } => Self::BadPath { pth },
+            storage::Error::SystemTime { source, back } => Self::SystemTime { source, back },
+            storage::Error::Client { source, back } => Self::Client { source, back },
+            _ => unreachable!(),
+        }
+    }
+}
+
+type Result<T> = std::result::Result<T, Error>;
+
+/// Current server state in terms of the play status (stopped/paused/playing, current track, elapsed
+/// time in current track, &c)
+#[derive(Debug)]
+pub struct PlayState {
+    /// Last known server status
+    last_server_stat: PlayerStatus,
+
+    /// true if we have already incremented the last known track's playcount
+    have_incr_play_count: bool,
+
+    /// Percentage threshold, expressed as a number between zero & one, for considering a song to
+    /// have been played
+    played_thresh: f64,
+    last_song_was_skipped: bool,
+}
+
+impl PlayState {
+    /// Create a new PlayState instance; async because it will reach out to the mpd server
+    /// to get current status.
+    pub async fn new(
+        client: &mut Client,
+        played_thresh: f64,
+    ) -> std::result::Result<PlayState, crate::clients::Error> {
+        Ok(PlayState {
+            last_server_stat: client.status().await?,
+            have_incr_play_count: false,
+            last_song_was_skipped: false,
+            played_thresh,
+        })
+    }
+
+    /// Retrieve a copy of the last known player status
+    pub fn last_status(&self) -> PlayerStatus {
+        self.last_server_stat.clone()
+    }
+
+    /// Poll the server-- update our status; maybe increment the current track's play count; the
+    /// caller must arrange to have this method invoked periodically to keep our state fresh
+    pub async fn update(&mut self, client: &mut Client) -> Result<()> {
+        let new_stat = client.status().await.map_err(|err| Error::Client {
+            source: err,
+            back: Backtrace::new(),
+        })?;
+
+        match (&self.last_server_stat, &new_stat) {
+            (PlayerStatus::Play(last), PlayerStatus::Play(curr))
+            | (PlayerStatus::Pause(last), PlayerStatus::Play(curr))
+            | (PlayerStatus::Play(last), PlayerStatus::Pause(curr))
+            | (PlayerStatus::Pause(last), PlayerStatus::Pause(curr)) => {
+                // Last we knew, we were playing, and we're playing now.
+                if last.songid != curr.songid {
+                    debug!("New songid-- resetting PC incremented flag.");
+
+                    if !self.have_incr_play_count {
+                        // We didn't mark the previous song as played.
+                        // As such, the user must have skipped it :(
+                        self.last_song_was_skipped = true;
+                    }
+
+                    self.have_incr_play_count = false;
+                } else if last.elapsed > curr.elapsed
+                    && self.have_incr_play_count
+                    && curr.elapsed / curr.duration <= 0.1
+                {
+                    debug!("Re-play-- resetting PC incremented flag.");
+                    self.have_incr_play_count = false;
+                }
+            }
+            (PlayerStatus::Stopped, PlayerStatus::Play(_))
+            | (PlayerStatus::Stopped, PlayerStatus::Pause(_))
+            | (PlayerStatus::Pause(_), PlayerStatus::Stopped)
+            | (PlayerStatus::Play(_), PlayerStatus::Stopped) => {
+                self.have_incr_play_count = false;
+            }
+            (PlayerStatus::Stopped, PlayerStatus::Stopped) => (),
+        }
+
+        match &new_stat {
+            PlayerStatus::Play(curr) => {
+                let pct = curr.played_pct();
+                debug!("Updating status: {:.3}% complete.", 100.0 * pct);
+                if !self.have_incr_play_count && pct >= self.played_thresh {
+                    info!(
+                        "Increment play count for '{}' (songid: {}) at {} played.",
+                        curr.file.display(),
+                        curr.songid,
+                        curr.elapsed / curr.duration
+                    );
+
+                    let file = curr.file.to_str().ok_or_else(|| Error::BadPath {
+                        pth: curr.file.clone(),
+                    })?;
+
+                    let curr_pc = play_count::get(client, file).await?.unwrap_or_default();
+
+                    debug!("Current PC is {}.", curr_pc);
+
+                    last_played::set(
+                        client,
+                        file,
+                        SystemTime::now()
+                            .duration_since(SystemTime::UNIX_EPOCH)
+                            .map_err(|err| Error::SystemTime {
+                                source: err,
+                                back: Backtrace::new(),
+                            })?
+                            .as_secs(),
+                    )
+                    .await?;
+                    self.have_incr_play_count = true;
+
+                    play_count::set(client, file, curr_pc + 1).await?;
+                } else if self.last_song_was_skipped {
+                    self.last_song_was_skipped = false;
+                    let last = self
+                        .last_server_stat
+                        .current_song()
+                        .expect("To exist, as it was skipped");
+
+                    info!(
+                        "Marking '{}' (songid: {}) as skipped at {}.",
+                        last.file.display(),
+                        last.songid,
+                        last.elapsed / last.duration
+                    );
+
+                    let file = last.file.to_str().ok_or_else(|| Error::BadPath {
+                        pth: last.file.clone(),
+                    })?;
+
+                    let skip_count = skipped::get(client, file).await?.unwrap_or_default();
+                    skipped::set(client, file, skip_count + 1).await?;
+                }
+            }
+            PlayerStatus::Pause(_) | PlayerStatus::Stopped => (),
+        };
+
+        self.last_server_stat = new_stat;
+        Ok(()) // No need to update the DB
+    }
+}
+
+#[cfg(test)]
+mod player_state_tests {
+    use super::*;
+    use crate::clients::test_mock::Mock;
+
+    /// "Smoke" tests for player state
+    #[tokio::test]
+    async fn player_state_smoke() {
+        let mock = Box::new(Mock::new(&[
+            (
+                "status",
+                "repeat: 0
+random: 1
+single: 0
+consume: 1
+playlist: 2
+playlistlength: 66
+mixrampdb: 0.000000
+state: stop
+xfade: 5
+song: 51
+songid: 52
+nextsong: 11
+nextsongid: 12
+OK
+",
+            ),
+            (
+                "status",
+                "volume: 100
+repeat: 0
+random: 1
+single: 0
+consume: 1
+playlist: 2
+playlistlength: 66
+mixrampdb: 0.000000
+state: play
+xfade: 5
+song: 51
+songid: 52
+time: 5:228
+elapsed: 5.337
+bitrate: 192
+duration: 227.637
+audio: 44100:24:2
+nextsong: 11
+nextsongid: 12
+OK
+",
+            ),
+            (
+                "playlistid 52",
+                "file: E/Enya - Wild Child.mp3
+Last-Modified: 2008-11-09T00:06:30Z
+Artist: Enya
+Title: Wild Child
+Album: A Day Without Rain (Japanese Retail)
+Date: 2000
+Genre: Celtic
+Time: 228
+duration: 227.637
+Pos: 51
+Id: 52
+OK
+",
+            ),
+            (
+                "status",
+                "volume: 100
+repeat: 0
+random: 1
+single: 0
+consume: 1
+playlist: 2
+playlistlength: 66
+mixrampdb: 0.000000
+state: play
+xfade: 5
+song: 51
+songid: 52
+time: 5:228
+elapsed: 200
+bitrate: 192
+duration: 227.637
+audio: 44100:24:2
+nextsong: 11
+nextsongid: 12
+OK
+",
+            ),
+            (
+                "playlistid 52",
+                "file: E/Enya - Wild Child.mp3
+Last-Modified: 2008-11-09T00:06:30Z
+Artist: Enya
+Title: Wild Child
+Album: A Day Without Rain (Japanese Retail)
+Date: 2000
+Genre: Celtic
+Time: 228
+duration: 227.637
+Pos: 51
+Id: 52
+OK
+",
+            ),
+            (
+                "sticker get song \"E/Enya - Wild Child.mp3\" pc",
+                "sticker: pc=11\nOK\n",
+            ),
+            (
+                &format!(
+                    "sticker set song \"E/Enya - Wild Child.mp3\" lp {}",
+                    SystemTime::now()
+                        .duration_since(SystemTime::UNIX_EPOCH)
+                        .unwrap()
+                        .as_secs()
+                ),
+                "OK\n",
+            ),
+            ("sticker set song \"E/Enya - Wild Child.mp3\" pc 12", "OK\n"),
+        ]));
+
+        let mut cli = Client::new(mock).unwrap();
+        let mut ps = PlayState::new(&mut cli, 0.6).await.unwrap();
+        let check = match ps.last_status() {
+            PlayerStatus::Play(_) | PlayerStatus::Pause(_) => false,
+            PlayerStatus::Stopped => true,
+        };
+        assert!(check);
+
+        ps.update(&mut cli).await.unwrap();
+        ps.update(&mut cli).await.unwrap()
+    }
+}