// Copyright (C) 2020-2025 Michael herstine // // 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 . //! 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 { 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 { 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(_)) | (PlayerStatus::Pause(_), PlayerStatus::Stopped) | (PlayerStatus::Play(_), PlayerStatus::Stopped) => { self.have_incr_play_count = false; // We played and stopped or stopped and play now so we did probably not change the // song? false } (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() } }