use config::AppConfig;
use dioxus::core::Runtime;
use dioxus::logger::tracing::Instrument;
use dioxus::prelude::*;
use reader::Track;
use std::collections::HashMap;
use std::time::Duration;
const NOW_PLAYING_INTERVAL_SECS: u64 = 30;
const NOW_PLAYING_MAX_IDLE_SECS: u64 = 600;
#[derive(Clone, Copy)]
pub struct ScrobbleOptions {
pub include_librefm: bool,
pub include_musicbrainz_ids: bool,
}
impl ScrobbleOptions {
pub const REMOTE_NATIVE: Self = Self {
include_librefm: true,
include_musicbrainz_ids: false,
};
pub const REMOTE_WEB: Self = Self {
include_librefm: false,
include_musicbrainz_ids: false,
};
pub const LOCAL: Self = Self {
include_librefm: false,
include_musicbrainz_ids: true,
};
}
pub fn schedule(
track: Track,
item_id: Option<String>,
config: Signal<AppConfig>,
play_generation: Signal<usize>,
generation: usize,
is_playing: Signal<bool>,
active_source: Option<Signal<::server::source::ActiveSource>>,
options: ScrobbleOptions,
) {
let duration_secs = track.duration;
let threshold_secs = std::cmp::min(240, duration_secs / 2);
let started_at = scrobble::musicbrainz::now_unix();
let span = tracing::info_span!(
"scrobble.submit",
track = item_id.as_deref().unwrap_or(track.id.uid().as_str())
);
schedule_playing_now_heartbeat(
&track,
config,
play_generation,
generation,
is_playing,
options,
);
spawn_in_scope(
play_generation.origin_scope(),
async move {
if duration_secs < 30 {
tracing::info!(
"scrobble skipped: track too short ({duration_secs}s < 30s): {} - {}",
track.artist,
track.title
);
return;
}
if track.artist.trim().is_empty() || track.title.trim().is_empty() {
tracing::info!(
"scrobble skipped: missing artist or title metadata: {:?} - {:?}",
track.artist,
track.title
);
return;
}
if let (Some(source), Some(id)) = (active_source, item_id.as_deref()) {
let source = source.peek().clone();
if let Err(error) = source.scrobble_now_playing(id).await {
tracing::warn!("now-playing scrobble failed: {}", error);
}
}
let lastfm_api_key = config.read().lastfm_api_key.clone();
let lastfm_api_secret = config.read().lastfm_api_secret.clone();
let lastfm_session_key = config.read().lastfm_session_key.clone();
let has_lastfm = !lastfm_api_key.is_empty() && !lastfm_api_secret.is_empty();
if has_lastfm {
let playing_now = scrobble::lastfm::make_playing_now(
&track.artist,
&track.title,
Some(&track.album),
);
if let Err(error) = scrobble::lastfm::submit_now_playing(
&lastfm_api_key,
&lastfm_api_secret,
&lastfm_session_key,
&playing_now,
)
.await
{
tracing::warn!("Last.fm now playing failed: {}", error);
}
}
let librefm_session_key = config.read().librefm_session_key.clone();
let has_librefm = options.include_librefm && !librefm_session_key.is_empty();
if has_librefm {
let playing_now = scrobble::librefm::make_playing_now(
&track.artist,
&track.title,
Some(&track.album),
);
if let Err(error) = scrobble::librefm::submit_now_playing(
scrobble::librefm::API_KEY,
scrobble::librefm::API_SECRET,
&librefm_session_key,
&playing_now,
)
.await
{
tracing::warn!("Libre.fm now playing failed: {}", error);
}
}
let reached = wait_for_playtime(
Duration::from_secs(threshold_secs),
play_generation,
generation,
is_playing,
)
.await;
if !reached {
tracing::info!(
"scrobble skipped: track changed before {threshold_secs}s of playback: {} - {}",
track.artist,
track.title
);
return;
}
if let (Some(source), Some(id)) = (active_source, item_id.as_deref()) {
let source = source.peek().clone();
match source.scrobble(id).await {
Ok(_) => tracing::info!("scrobbled: {} - {}", track.artist, track.title),
Err(error) => tracing::warn!("scrobble failed: {}", error),
}
}
if has_lastfm {
let scrobble = scrobble::lastfm::make_scrobble(
&track.artist,
&track.title,
Some(&track.album),
);
match scrobble::lastfm::submit_scrobble(
&lastfm_api_key,
&lastfm_api_secret,
&lastfm_session_key,
&scrobble,
)
.await
{
Ok(_) => {
tracing::info!("Last.fm scrobbled: {} - {}", track.artist, track.title)
}
Err(error) => tracing::warn!("Last.fm scrobble failed: {}", error),
}
}
if has_librefm {
let scrobble = scrobble::librefm::make_scrobble(
&track.artist,
&track.title,
Some(&track.album),
);
match scrobble::librefm::submit_scrobble(
scrobble::librefm::API_KEY,
scrobble::librefm::API_SECRET,
&librefm_session_key,
&scrobble,
)
.await
{
Ok(_) => {
tracing::info!("Libre.fm scrobbled: {} - {}", track.artist, track.title)
}
Err(error) => tracing::warn!("Libre.fm scrobble failed: {}", error),
}
}
let token = config.read().musicbrainz_token.clone();
if !token.trim().is_empty() {
let info = listen_additional_info(&track, options.include_musicbrainz_ids);
let listen = scrobble::musicbrainz::make_listen(
&track.artist,
&track.title,
Some(&track.album),
Some(info),
started_at,
);
match scrobble::musicbrainz::submit_listens(&token, vec![listen], "single").await {
Ok(_) => {
tracing::info!("MusicBrainz scrobbled: {} - {}", track.artist, track.title)
}
Err(error) => tracing::warn!("MusicBrainz scrobble failed: {}", error),
}
}
}
.instrument(span),
);
}
fn schedule_playing_now_heartbeat(
track: &Track,
config: Signal<AppConfig>,
play_generation: Signal<usize>,
generation: usize,
is_playing: Signal<bool>,
options: ScrobbleOptions,
) {
if track.duration < 30 {
return;
}
let token = config.read().musicbrainz_token.clone();
if token.trim().is_empty() {
return;
}
let track = track.clone();
let include_ids = options.include_musicbrainz_ids;
let span = tracing::info_span!("scrobble.playing_now", track = track.id.uid().as_str());
spawn_in_scope(
play_generation.origin_scope(),
async move {
let mut announced = false;
let mut idle_secs: u64 = 0;
loop {
if *play_generation.read() != generation {
return;
}
if *is_playing.read() {
idle_secs = 0;
let now_info = listen_additional_info(&track, include_ids);
let playing_now = scrobble::musicbrainz::make_playing_now(
&track.artist,
&track.title,
Some(&track.album),
Some(now_info),
);
let sent = scrobble::musicbrainz::submit_listens(
&token,
vec![playing_now],
"playing_now",
)
.await
.is_ok();
if sent && !announced {
announced = true;
tracing::info!(
"ListenBrainz playing now: {} - {}",
track.artist,
track.title
);
}
} else {
idle_secs += NOW_PLAYING_INTERVAL_SECS;
if idle_secs >= NOW_PLAYING_MAX_IDLE_SECS {
return;
}
}
tokio::time::sleep(Duration::from_secs(NOW_PLAYING_INTERVAL_SECS)).await;
}
}
.instrument(span),
);
}
fn listen_additional_info(
track: &Track,
include_ids: bool,
) -> HashMap<&'static str, serde_json::Value> {
let mut map = HashMap::new();
map.insert("media_player", serde_json::Value::from("kopuz"));
map.insert("submission_client", serde_json::Value::from("kopuz"));
map.insert(
"submission_client_version",
serde_json::Value::from(env!("CARGO_PKG_VERSION")),
);
if track.duration > 0 {
map.insert(
"duration_ms",
serde_json::Value::from(track.duration * 1000),
);
}
if include_ids {
if let Some(mbid) = &track.musicbrainz_release_id {
map.insert("release_mbid", serde_json::Value::from(mbid.as_str()));
}
if let Some(mbid) = &track.musicbrainz_recording_id {
map.insert("recording_mbid", serde_json::Value::from(mbid.as_str()));
}
if let Some(mbid) = &track.musicbrainz_track_id {
map.insert("track_mbid", serde_json::Value::from(mbid.as_str()));
}
}
map
}
fn spawn_in_scope(scope: ScopeId, fut: impl std::future::Future<Output = ()> + 'static) {
Runtime::current().in_scope(scope, || {
spawn(fut);
});
}
async fn wait_for_playtime(
threshold: Duration,
play_generation: Signal<usize>,
generation: usize,
is_playing: Signal<bool>,
) -> bool {
let tick = Duration::from_secs(1);
let mut played = Duration::ZERO;
while played < threshold {
tokio::time::sleep(tick).await;
if *play_generation.read() != generation {
return false;
}
if *is_playing.read() {
played += tick;
}
}
true
}