aboutsummaryrefslogtreecommitdiffstats
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.rs342
1 files changed, 342 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..8fbee133
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs
@@ -0,0 +1,342 @@
+// 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::{last_played, play_count, skip_count};
+
+use anyhow::{Context, Error, Result, anyhow};
+use tracing::{debug, info};
+
+use std::time::SystemTime;
+
+/// 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, 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
+ ///
+ /// Returns whether a song finished between the last call and this one.
+ /// That can be used to add a new song to the queue.
+ pub async fn update(&mut self, client: &mut Client) -> Result<bool> {
+ let new_stat = client
+ .status()
+ .await
+ .context("Failed to get client status")?;
+
+ let previous_song_finished = 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;
+
+ // We are now playing something else, as such the previous one must have
+ // finished or was skipped.
+ true
+ } 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;
+
+ // We are still playing the same song, just skipped at the start again.
+ // This means that we don't need a new one.
+ false
+ } else {
+ // We are still playing the same song, so nothing changed
+ false
+ }
+ }
+ (PlayerStatus::Stopped, PlayerStatus::Play(_))
+ | (PlayerStatus::Stopped, PlayerStatus::Pause(_)) => {
+ self.have_incr_play_count = false;
+
+ // We didn't play anything before and now we play something. This means that we
+ // obviously have something to play and thus don't need to add another song.
+ false
+ }
+ (PlayerStatus::Pause(_), PlayerStatus::Stopped)
+ | (PlayerStatus::Play(_), PlayerStatus::Stopped) => {
+ self.have_incr_play_count = false;
+
+ // We played a song before and now we stopped, maybe because we ran out of songs to
+ // play. So we need to add another one.
+ true
+ }
+ (PlayerStatus::Stopped, PlayerStatus::Stopped) => {
+ // We did not play before and we are still not playing, as such nothing really
+ // changed.
+ false
+ }
+ };
+
+ 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 {:.2}% played.",
+ curr.file.display(),
+ curr.songid,
+ (curr.elapsed / curr.duration) * 100.0
+ );
+
+ let file = curr.file.to_str().ok_or_else(|| {
+ anyhow!("Failed to parse path as utf8: `{}`", curr.file.display())
+ })?;
+
+ 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)
+ .context("Failed to get system time")?
+ .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 {:.2}%.",
+ last.file.display(),
+ last.songid,
+ (last.elapsed / last.duration) * 100.0
+ );
+
+ let file = last.file.to_str().ok_or_else(|| {
+ anyhow!("Failed to parse path as utf8: `{}`", last.file.display())
+ })?;
+
+ let skip_count = skip_count::get(client, file).await?.unwrap_or_default();
+ skip_count::set(client, file, skip_count + 1).await?;
+ }
+ }
+ PlayerStatus::Pause(_) | PlayerStatus::Stopped => (),
+ };
+
+ self.last_server_stat = new_stat;
+ Ok(previous_song_finished)
+ }
+}
+
+#[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\" unwoundstack.com:playcount",
+ "sticker: unwoundstack.com:playcount=11\nOK\n",
+ ),
+ (
+ &format!(
+ "sticker set song \"E/Enya - Wild Child.mp3\" unwoundstack.com:lastplayed {}",
+ SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .unwrap()
+ .as_secs()
+ ),
+ "OK\n",
+ ),
+ (
+ "sticker set song \"E/Enya - Wild Child.mp3\" unwoundstack.com:playcount 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();
+ }
+}