init commit
This commit is contained in:
commit
6899d676aa
15 changed files with 1490 additions and 0 deletions
19
src/config.rs
Normal file
19
src/config.rs
Normal 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
15
src/lastfm.rs
Normal 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
35
src/main.rs
Normal 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
118
src/main_loop.rs
Normal 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(¤t_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
51
src/player.rs
Normal 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
77
src/track.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue