init commit

This commit is contained in:
Reid 2024-08-22 00:34:43 -07:00
commit 6899d676aa
Signed by: reidlab
GPG key ID: DAF5EAF6665839FD
15 changed files with 1490 additions and 0 deletions

19
src/config.rs Normal file
View file

@ -0,0 +1,19 @@
use envconfig::Envconfig;
#[derive(Envconfig)]
pub struct Config {
#[envconfig(from = "PLAYER_BUS")]
pub player_bus: Option<String>,
#[envconfig(from = "USERNAME")]
pub username: String,
#[envconfig(from = "PASSWORD")]
pub password: String,
#[envconfig(from = "API_KEY")]
pub api_key: String,
#[envconfig(from = "API_SECRET")]
pub api_secret: String
}

15
src/lastfm.rs Normal file
View file

@ -0,0 +1,15 @@
use crate::config::Config;
use anyhow::{Context, Result};
use rustfm_scrobble_proxy::Scrobbler;
use tracing::info;
pub fn get_scrobbler(config: &Config) -> Result<Scrobbler> {
let mut scrobbler = Scrobbler::new(&config.api_key, &config.api_secret);
scrobbler
.authenticate_with_password(&config.username, &config.password)
.context("failed to authenticate")?;
info!(" successfully authenticated");
return Ok(scrobbler);
}

35
src/main.rs Normal file
View file

@ -0,0 +1,35 @@
#![feature(duration_constructors)]
mod config;
mod lastfm;
mod main_loop;
mod player;
mod track;
use crate::config::Config;
use crate::lastfm::get_scrobbler;
use anyhow::{Context, Result};
use envconfig::Envconfig;
use tracing::{debug, Level};
fn main() -> Result<()> {
dotenvy::dotenv()?;
tracing_subscriber::fmt()
.with_max_level(if cfg!(debug_assertions) { Level::DEBUG } else { Level::INFO })
.init();
debug!("{} by {}, {}",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_AUTHORS"),
env!("CARGO_PKG_DESCRIPTION")
);
let config = Config::init_from_env()
.context("failed to initiate configuration from .env")?;
let scrobbler = get_scrobbler(&config)
.context("failed to create scrobbler")?;
main_loop::start(&config, scrobbler)?;
Ok(())
}

118
src/main_loop.rs Normal file
View file

@ -0,0 +1,118 @@
use crate::config::Config;
use crate::player;
use crate::track::Track;
use anyhow::{Context, Result};
use mpris::PlaybackStatus;
use std::thread;
use std::time::{Duration, Instant};
use rustfm_scrobble_proxy::{Scrobble, Scrobbler};
use tracing::info;
const INTERVAL: Duration = Duration::from_millis(1000);
// see https://www.last.fm/api/scrobbling#scrobble-requests
// do not scrobble songs over 30 seconds, scrobble them if we are 4 minutes through,
// or halfway through (whichever occurs first)
const MIN_SONG_LENGTH: Duration = Duration::from_secs(30);
const MIN_SCROBBLE_POSITION: Duration = Duration::from_mins(4);
fn do_we_scrobble(position: Duration, length: Option<Duration>) -> bool {
match length {
Some(length) if length <= Duration::from_secs(30) => false,
Some(length) => position >= MIN_SCROBBLE_POSITION || position >= length / 2,
// we can't exactly appeal the spec here... but we don't also want only songs over 30 seconds to work
// because of this, it is a much safer option to use the min song length
None => position >= MIN_SONG_LENGTH,
}
}
pub fn start(config: &Config, scrobbler: Scrobbler) -> Result<()> {
info!(" looking for an active mpris player");
let mut player = player::wait_for_player(config)?;
info!(" found an active player: {}", player.identity());
let mut previously_logged_track = Track::default();
let mut timer = Instant::now();
let mut current_play_time = Duration::from_secs(0);
let mut scrobbled_current_song = false;
loop {
if !player::is_active(&player) {
info!(" player {} stopped, searching for an active mpris player", player.identity());
player = player::wait_for_player(config)
.context("failed to find player")?;
info!(" found an active player: {}", player.identity());
}
let playback_status = player
.get_playback_status()
.context("failed to retrieve playback status")?;
match playback_status {
PlaybackStatus::Playing => {}
_ => {
thread::sleep(INTERVAL);
continue;
}
}
let metadata = player
.get_metadata()
.context("failed to get metadata")?;
let current_track = Track::from_metadata(&metadata);
let length = metadata
.length()
.and_then(|length| if length.is_zero() { None } else { Some(length) });
if current_track == previously_logged_track {
if !scrobbled_current_song && do_we_scrobble(current_play_time, length) {
let scrobble = Scrobble::new(current_track.artist(), current_track.title(), current_track.album());
scrobbler.scrobble(&scrobble)
.context("failed to scrobble")?;
info!(" scrobble submitted sucessfully");
scrobbled_current_song = true
} else if length
.map(|length| current_play_time >= length)
.unwrap_or(false)
{
current_play_time = Duration::from_secs(0);
scrobbled_current_song = false;
}
current_play_time += timer.elapsed();
timer = Instant::now();
} else {
previously_logged_track.clone_from(&current_track);
timer = Instant::now();
current_play_time = Duration::from_secs(0);
scrobbled_current_song = false;
info!(
" now playing {} by {}{}",
current_track.title(),
current_track.artist(),
match current_track.album() {
Some(album) => format!(" on {}", album),
None => String::new(),
}
);
let scrobble = Scrobble::new(current_track.artist(), current_track.title(), current_track.album());
scrobbler.now_playing(&scrobble)
.context("failed to update now playing status")?;
info!(" now playing status updated successfully");
}
thread::sleep(INTERVAL);
}
}

51
src/player.rs Normal file
View file

@ -0,0 +1,51 @@
use crate::config::Config;
use anyhow::{anyhow, Result, Context};
use mpris::{Player, PlayerFinder, PlaybackStatus};
use std::thread;
use std::time::Duration;
const INTERVAL: Duration = Duration::from_millis(1000);
const MPRIS_BUS_PREFIX: &str = "org.mpris.MediaPlayer2.";
pub fn is_active(player: &Player) -> bool {
if !player.is_running() { return false; }
matches!(player.get_playback_status(), Ok(PlaybackStatus::Playing))
}
pub fn is_whitelisted(config: &Config, player: &Player) -> bool {
if Some(player.bus_name()) == config.player_bus.as_deref() {
return true
} else if config.player_bus.is_none() {
return player.bus_name().starts_with(MPRIS_BUS_PREFIX)
} else {
return false
}
}
pub fn wait_for_player(config: &Config) -> Result<Player> {
let finder = PlayerFinder::new()
.map_err(|err| anyhow!("{}", err))
.context("failed to connect to d-bus")?;
loop {
let players = match finder.iter_players() {
Ok(players) => players,
_ => {
thread::sleep(INTERVAL);
continue;
}
};
for player in players {
if let Ok(player) = player {
if is_active(&player) && is_whitelisted(config, &player) {
return Ok(player);
}
}
}
thread::sleep(INTERVAL);
}
}

77
src/track.rs Normal file
View file

@ -0,0 +1,77 @@
use mpris::Metadata;
#[derive(Debug, Default, PartialEq)]
pub struct Track {
artist: String,
title: String,
album: Option<String>,
}
impl Track {
pub fn artist(&self) -> &str {
&self.artist
}
pub fn title(&self) -> &str {
&self.title
}
pub fn album(&self) -> Option<&str> {
self.album.as_deref()
}
pub fn new(artist: &str, title: &str, album: Option<&str>) -> Self {
Self {
artist: artist.to_owned(),
title: title.to_owned(),
album: album.and_then(|album| {
if !album.is_empty() {
Some(album.to_owned())
} else {
None
}
}),
}
}
pub fn clear(&mut self) {
self.artist.clear();
self.title.clear();
self.album.take();
}
pub fn clone_from(&mut self, other: &Self) {
self.artist.clone_from(&other.artist);
self.title.clone_from(&other.title);
self.album.clone_from(&other.album);
}
pub fn from_metadata(metadata: &Metadata) -> Self {
// these unknown checks are required or else scrobbling will return a bad request
// last.fm can handle unknown artist kinda well!
let artist = metadata
.artists()
.as_ref()
.and_then(|artists| artists.first().copied())
.unwrap_or("Unknown Artist")
.to_owned();
// unknown song on the other hand.. well, it just scrobbles the song "Unknown Song"
// this is probably better than getting a bad request and the program crashing :3
let title = metadata.title().unwrap_or("Unknown Song").to_owned();
let album = metadata.album_name().and_then(|album| {
if !album.is_empty() {
Some(album.to_owned())
} else {
None
}
});
Self {
artist,
title,
album,
}
}
}