// 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::{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 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 = std::result::Result; /// 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.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\" 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() } }