// 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, skipped}; 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 pub async fn update(&mut self, client: &mut Client) -> Result<()> { let new_stat = client .status() .await .context("Failed to get client status")?; 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(|| { 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 {}.", last.file.display(), last.songid, last.elapsed / last.duration ); let file = last.file.to_str().ok_or_else(|| { anyhow!("Failed to parse path as utf8: `{}`", last.file.display()) })?; 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\" 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() } }