// 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 . //! # mpdpopm //! //! Maintain ratings & playcounts for your mpd server. //! //! # Introduction //! //! This is a companion daemon for [mpd](https://www.musicpd.org/) that maintains play counts & //! ratings. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust //! (which I prefer to Go), it will allow you to maintain that information in your tags, as well as //! the sticker database, by invoking external commands to keep your tags up-to-date (something //! along the lines of [mpdcron](https://alip.github.io/mpdcron)). //! //! # Commands //! //! I'm currently sending all commands over one (configurable) channel. //! #![recursion_limit = "512"] // for the `select!' macro pub mod clients; pub mod config; pub mod filters_ast; pub mod messages; pub mod playcounts; pub mod storage; pub mod vars; #[rustfmt::skip] #[allow(clippy::extra_unused_lifetimes)] #[allow(clippy::needless_lifetimes)] #[allow(clippy::let_unit_value)] #[allow(clippy::just_underscores_and_digits)] pub mod filters { include!(concat!(env!("OUT_DIR"), "/src/filters.rs")); } use clients::{Client, IdleClient, IdleSubSystem}; use config::Config; use config::Connection; use filters_ast::FilterStickerNames; use messages::MessageProcessor; use playcounts::PlayState; use backtrace::Backtrace; use futures::{future::FutureExt, pin_mut, select}; use tokio::{ signal, signal::unix::{SignalKind, signal}, time::{Duration, sleep}, }; use tracing::{debug, error, info}; use std::path::PathBuf; //////////////////////////////////////////////////////////////////////////////////////////////////// #[derive(Debug)] #[non_exhaustive] pub enum Error { BadPath { pth: PathBuf, }, Client { source: crate::clients::Error, back: Backtrace, }, Playcounts { source: crate::playcounts::Error, back: Backtrace, }, } impl std::fmt::Display for Error { #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth), Error::Client { source, back: _ } => write!(f, "Client error: {}", source), Error::Playcounts { source, back: _ } => write!(f, "Playcount error: {}", source), } } } impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match &self { Error::Client { source, back: _ } => Some(source), _ => None, } } } pub type Result = std::result::Result; //////////////////////////////////////////////////////////////////////////////////////////////////// /// Core `mppopmd' logic pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> { info!("mpdpopm {} beginning.", vars::VERSION); let filter_stickers = FilterStickerNames::new(); let mut client = match cfg.conn { Connection::Local { ref path } => { Client::open(path).await.map_err(|err| Error::Client { source: err, back: Backtrace::new(), })? } Connection::TCP { ref host, port } => Client::connect(format!("{}:{}", host, port)) .await .map_err(|err| Error::Client { source: err, back: Backtrace::new(), })?, }; let mut state = PlayState::new(&mut client, cfg.played_thresh) .await .map_err(|err| Error::Client { source: err, back: Backtrace::new(), })?; let mut idle_client = match cfg.conn { Connection::Local { ref path } => { IdleClient::open(path).await.map_err(|err| Error::Client { source: err, back: Backtrace::new(), })? } Connection::TCP { ref host, port } => IdleClient::connect(format!("{}:{}", host, port)) .await .map_err(|err| Error::Client { source: err, back: Backtrace::new(), })?, }; idle_client .subscribe(&cfg.commands_chan) .await .map_err(|err| Error::Client { source: err, back: Backtrace::new(), })?; let mut hup = signal(SignalKind::hangup()).unwrap(); let mut kill = signal(SignalKind::terminate()).unwrap(); let ctrl_c = signal::ctrl_c().fuse(); let sighup = hup.recv().fuse(); let sigkill = kill.recv().fuse(); let tick = sleep(Duration::from_millis(cfg.poll_interval_ms)).fuse(); pin_mut!(ctrl_c, sighup, sigkill, tick); let mproc = MessageProcessor::new(); let mut done = false; while !done { debug!("selecting..."); let mut msg_check_needed = false; { // `idle_client' mutably borrowed here let mut idle = Box::pin(idle_client.idle().fuse()); loop { select! { _ = ctrl_c => { info!("got ctrl-C"); done = true; break; }, _ = sighup => { info!("got SIGHUP"); done = true; break; }, _ = sigkill => { info!("got SIGKILL"); done = true; break; }, _ = tick => { tick.set(sleep(Duration::from_millis(cfg.poll_interval_ms)).fuse()); state.update(&mut client) .await .map_err(|err| Error::Playcounts { source: err, back: Backtrace::new() })? }, // next = cmds.next() => match next { // Some(out) => { // debug!("output status is {:#?}", out.out); // match out.upd { // Some(uri) => { // debug!("{} needs to be updated", uri); // client.update(&uri).await.map_err(|err| Error::Client { // source: err, // back: Backtrace::new(), // })?; // }, // None => debug!("No database update needed"), // } // }, // None => { // debug!("No more commands to process."); // } // }, res = idle => match res { Ok(subsys) => { debug!("subsystem {} changed", subsys); if subsys == IdleSubSystem::Player { state.update(&mut client) .await .map_err(|err| Error::Playcounts { source: err, back: Backtrace::new() })? } else if subsys == IdleSubSystem::Message { msg_check_needed = true; } break; }, Err(err) => { debug!("error {err:#?} on idle"); done = true; break; } } } } } if msg_check_needed { // Check for any messages that have come in; if there's an error there's not a lot we // can do about it (suppose some client fat-fingers a command name, e.g.)-- just log it // & move on. if let Err(err) = mproc .check_messages( &mut client, &mut idle_client, state.last_status(), &cfg.commands_chan, &filter_stickers, ) .await { error!("Error while processing messages: {err:#?}"); } } } info!("mpdpopm exiting."); Ok(()) }