Added the watch and scheduler systems
This commit is contained in:
293
src/monitor.rs
Normal file
293
src/monitor.rs
Normal file
@@ -0,0 +1,293 @@
|
||||
//! Artist monitoring — periodically checks for new releases from monitored artists
|
||||
//! and automatically adds them to the watchlist.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use actix_web::web;
|
||||
use serde::Serialize;
|
||||
use shanty_data::MetadataFetcher;
|
||||
use shanty_db::queries;
|
||||
use shanty_search::SearchProvider;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Stats returned from a monitor check run.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MonitorStats {
|
||||
pub artists_checked: u32,
|
||||
pub new_releases_found: u32,
|
||||
pub tracks_added: u32,
|
||||
}
|
||||
|
||||
/// Check all monitored artists for new releases and add them to the watchlist.
|
||||
pub async fn check_monitored_artists(
|
||||
state: &web::Data<AppState>,
|
||||
) -> Result<MonitorStats, ApiError> {
|
||||
let monitored = queries::artists::list_monitored(state.db.conn()).await?;
|
||||
|
||||
let mut stats = MonitorStats {
|
||||
artists_checked: 0,
|
||||
new_releases_found: 0,
|
||||
tracks_added: 0,
|
||||
};
|
||||
|
||||
let allowed = state.config.read().await.allowed_secondary_types.clone();
|
||||
|
||||
for artist in &monitored {
|
||||
let mbid = match &artist.musicbrainz_id {
|
||||
Some(m) => m.clone(),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
artist_id = artist.id,
|
||||
name = %artist.name,
|
||||
"monitored artist has no MBID, skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
stats.artists_checked += 1;
|
||||
|
||||
// Fetch release groups from MusicBrainz
|
||||
let all_release_groups = match state.search.get_release_groups(&mbid).await {
|
||||
Ok(rgs) => rgs,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
artist = %artist.name,
|
||||
error = %e,
|
||||
"failed to fetch release groups for monitored artist"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Filter by allowed secondary types (same logic as enrich_artist)
|
||||
let release_groups: Vec<_> = all_release_groups
|
||||
.into_iter()
|
||||
.filter(|rg| {
|
||||
if rg.secondary_types.is_empty() {
|
||||
true
|
||||
} else {
|
||||
rg.secondary_types.iter().all(|st| allowed.contains(st))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Get all existing wanted items for this artist
|
||||
let artist_wanted = queries::wanted::list(state.db.conn(), None, None).await?;
|
||||
let wanted_mbids: HashSet<String> = artist_wanted
|
||||
.iter()
|
||||
.filter(|w| w.artist_id == Some(artist.id))
|
||||
.filter_map(|w| w.musicbrainz_id.clone())
|
||||
.collect();
|
||||
|
||||
// Check each release group's tracks to find new ones
|
||||
for rg in &release_groups {
|
||||
// Check if we already have any tracks from this release group cached
|
||||
let cache_key = format!("artist_rg_tracks:{}", rg.id);
|
||||
let cached_tracks: Option<Vec<String>> =
|
||||
if let Ok(Some(json)) = queries::cache::get(state.db.conn(), &cache_key).await {
|
||||
// Parse the cached data to get recording MBIDs
|
||||
if let Ok(cached) = serde_json::from_str::<serde_json::Value>(&json) {
|
||||
cached
|
||||
.get("tracks")
|
||||
.and_then(|t| t.as_array())
|
||||
.map(|tracks| {
|
||||
tracks
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
t.get("recording_mbid")
|
||||
.and_then(|m| m.as_str())
|
||||
.map(String::from)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let track_mbids = if let Some(mbids) = cached_tracks {
|
||||
mbids
|
||||
} else {
|
||||
// Not cached — resolve release and fetch tracks
|
||||
// Rate limit: sleep 1.1s between MB requests
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||
|
||||
let release_mbid = if let Some(ref rid) = rg.first_release_id {
|
||||
rid.clone()
|
||||
} else {
|
||||
// Need to resolve from release group
|
||||
match resolve_release_from_group(&rg.id).await {
|
||||
Ok(rid) => rid,
|
||||
Err(e) => {
|
||||
tracing::debug!(rg_id = %rg.id, error = %e, "skipping release group");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||
|
||||
match state.mb_client.get_release_tracks(&release_mbid).await {
|
||||
Ok(tracks) => tracks.into_iter().map(|t| t.recording_mbid).collect(),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
release = %release_mbid,
|
||||
error = %e,
|
||||
"failed to fetch tracks"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check if any of these tracks are NOT in the wanted items
|
||||
let new_mbids: Vec<&String> = track_mbids
|
||||
.iter()
|
||||
.filter(|mbid| !wanted_mbids.contains(*mbid))
|
||||
.collect();
|
||||
|
||||
if new_mbids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found a release group with new tracks — add the whole album via shanty_watch
|
||||
tracing::info!(
|
||||
artist = %artist.name,
|
||||
album = %rg.title,
|
||||
new_tracks = new_mbids.len(),
|
||||
"new release detected for monitored artist"
|
||||
);
|
||||
|
||||
stats.new_releases_found += 1;
|
||||
|
||||
match shanty_watch::add_album(
|
||||
state.db.conn(),
|
||||
Some(&artist.name),
|
||||
Some(&rg.title),
|
||||
rg.first_release_id.as_deref(),
|
||||
&state.mb_client,
|
||||
None, // system-initiated, no user_id
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(summary) => {
|
||||
stats.tracks_added += summary.tracks_added as u32;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
artist = %artist.name,
|
||||
album = %rg.title,
|
||||
error = %e,
|
||||
"failed to add album for monitored artist"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update last_checked_at
|
||||
if let Err(e) = queries::artists::update_last_checked(state.db.conn(), artist.id).await {
|
||||
tracing::warn!(
|
||||
artist_id = artist.id,
|
||||
error = %e,
|
||||
"failed to update last_checked_at"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// Given a release-group MBID, find the first release MBID.
|
||||
async fn resolve_release_from_group(release_group_mbid: &str) -> Result<String, ApiError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Shanty/0.1.0 (shanty-music-app)")
|
||||
.build()
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
let url = format!(
|
||||
"https://musicbrainz.org/ws/2/release?release-group={release_group_mbid}&fmt=json&limit=1"
|
||||
);
|
||||
|
||||
let resp: serde_json::Value = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
resp.get("releases")
|
||||
.and_then(|r| r.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|r| r.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no releases for group {release_group_mbid}")))
|
||||
}
|
||||
|
||||
/// Spawn the monitor scheduler background loop.
|
||||
///
|
||||
/// Sleeps for the configured interval, then checks monitored artists if enabled.
|
||||
/// Reads config each iteration so changes take effect without restart.
|
||||
pub fn spawn(state: web::Data<AppState>) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (enabled, hours) = {
|
||||
let cfg = state.config.read().await;
|
||||
(
|
||||
cfg.scheduling.monitor_enabled,
|
||||
cfg.scheduling.monitor_interval_hours.max(1),
|
||||
)
|
||||
};
|
||||
|
||||
let sleep_secs = u64::from(hours) * 3600;
|
||||
|
||||
// Update next-run time
|
||||
{
|
||||
let mut sched = state.scheduler.lock().await;
|
||||
sched.next_monitor = if enabled {
|
||||
Some(
|
||||
(chrono::Utc::now() + chrono::Duration::seconds(sleep_secs as i64))
|
||||
.naive_utc(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(sleep_secs)).await;
|
||||
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clear next-run while running
|
||||
{
|
||||
let mut sched = state.scheduler.lock().await;
|
||||
sched.next_monitor = None;
|
||||
}
|
||||
|
||||
tracing::info!("scheduled monitor check starting");
|
||||
match check_monitored_artists(&state).await {
|
||||
Ok(stats) => {
|
||||
tracing::info!(
|
||||
artists_checked = stats.artists_checked,
|
||||
new_releases = stats.new_releases_found,
|
||||
tracks_added = stats.tracks_added,
|
||||
"scheduled monitor check complete"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "scheduled monitor check failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user