Added the watch and scheduler systems

This commit is contained in:
Connor Johnstone
2026-03-20 16:28:15 -04:00
parent eaaff5f98f
commit 9d6c0e31c1
16 changed files with 948 additions and 164 deletions

View File

@@ -85,7 +85,6 @@ pub async fn get_me() -> Result<UserInfo, ApiError> {
get_json(&format!("{BASE}/auth/me")).await
}
// --- Lyrics ---
pub async fn get_lyrics(artist: &str, title: &str) -> Result<LyricsResult, ApiError> {
get_json(&format!("{BASE}/lyrics?artist={artist}&title={title}")).await
@@ -142,7 +141,6 @@ pub async fn get_album(mbid: &str) -> Result<MbAlbumDetail, ApiError> {
get_json(&format!("{BASE}/albums/{mbid}")).await
}
// --- Watchlist ---
pub async fn add_artist(name: &str, mbid: Option<&str>) -> Result<AddSummary, ApiError> {
let body = match mbid {
@@ -164,7 +162,6 @@ pub async fn add_album(
post_json(&format!("{BASE}/albums"), &body).await
}
// --- Downloads ---
pub async fn get_downloads(status: Option<&str>) -> Result<Vec<DownloadItem>, ApiError> {
let mut url = format!("{BASE}/downloads/queue");
@@ -210,6 +207,26 @@ pub async fn trigger_pipeline() -> Result<PipelineRef, ApiError> {
post_empty(&format!("{BASE}/pipeline")).await
}
// --- Monitor ---
pub async fn set_artist_monitored(id: i32, monitored: bool) -> Result<serde_json::Value, ApiError> {
if monitored {
post_empty(&format!("{BASE}/artists/{id}/monitor")).await
} else {
let resp = Request::delete(&format!("{BASE}/artists/{id}/monitor"))
.send()
.await
.map_err(|e| ApiError(e.to_string()))?;
if !resp.ok() {
return Err(ApiError(format!("HTTP {}", resp.status())));
}
resp.json().await.map_err(|e| ApiError(e.to_string()))
}
}
pub async fn trigger_monitor_check() -> Result<TaskRef, ApiError> {
post_empty(&format!("{BASE}/monitor/check")).await
}
// --- System ---
pub async fn trigger_index() -> Result<TaskRef, ApiError> {
post_empty(&format!("{BASE}/index")).await
@@ -223,7 +240,6 @@ pub async fn trigger_organize() -> Result<TaskRef, ApiError> {
post_empty(&format!("{BASE}/organize")).await
}
pub async fn get_config() -> Result<AppConfig, ApiError> {
get_json(&format!("{BASE}/config")).await
}

View File

@@ -145,6 +145,52 @@ pub fn artist_page(props: &Props) -> Html {
}
};
let monitor_btn = {
// Only show monitor toggle for artists that have a local DB ID (> 0)
let artist_id_num = d.artist.id;
let is_monitored = d.monitored;
if artist_id_num > 0 {
let message = message.clone();
let error = error.clone();
let fetch = fetch.clone();
let artist_id = id.clone();
let label = if is_monitored { "Unmonitor" } else { "Monitor" };
let btn_class = if is_monitored {
"btn btn-sm btn-secondary"
} else {
"btn btn-sm btn-primary"
};
html! {
<button class={btn_class}
onclick={Callback::from(move |_: MouseEvent| {
let new_state = !is_monitored;
let message = message.clone();
let error = error.clone();
let fetch = fetch.clone();
let artist_id = artist_id.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::set_artist_monitored(artist_id_num, new_state).await {
Ok(_) => {
let msg = if new_state {
"Monitoring enabled -- new releases will be auto-watched"
} else {
"Monitoring disabled"
};
message.set(Some(msg.to_string()));
fetch.emit(artist_id);
}
Err(e) => error.set(Some(e.0)),
}
});
})}>
{ label }
</button>
}
} else {
html! {}
}
};
html! {
<div>
if let Some(ref banner) = d.artist_banner {
@@ -158,7 +204,12 @@ pub fn artist_page(props: &Props) -> Html {
<img class="artist-photo" src={photo.clone()} loading="lazy" />
}
<div>
<h2>{ &d.artist.name }</h2>
<h2>
{ &d.artist.name }
if d.monitored {
<span class="badge badge-success" style="margin-left: 0.5rem; font-size: 0.7em; vertical-align: middle;">{ "Monitored" }</span>
}
</h2>
if let Some(ref info) = d.artist_info {
<div class="artist-meta">
if let Some(ref country) = info.country {
@@ -195,7 +246,10 @@ pub fn artist_page(props: &Props) -> Html {
}
</div>
</div>
{ watch_all_btn }
<div class="flex gap-1">
{ watch_all_btn }
{ monitor_btn }
</div>
</div>
if let Some(ref bio) = d.artist_bio {
<p class="artist-bio text-sm">{ bio }</p>

View File

@@ -5,6 +5,30 @@ use crate::api;
use crate::components::status_badge::StatusBadge;
use crate::types::Status;
/// Format a UTC datetime string as a relative time like "in 2h 15m".
fn format_next_run(datetime_str: &str) -> String {
let now_ms = js_sys::Date::new_0().get_time();
let parsed = js_sys::Date::parse(datetime_str);
// NaN means parse failed
if parsed.is_nan() {
return datetime_str.to_string();
}
let delta_ms = parsed - now_ms;
if delta_ms <= 0.0 {
return "soon".to_string();
}
let total_mins = (delta_ms / 60_000.0) as u64;
let hours = total_mins / 60;
let mins = total_mins % 60;
if hours > 0 {
format!("in {hours}h {mins}m")
} else {
format!("in {mins}m")
}
}
#[function_component(DashboardPage)]
pub fn dashboard() -> Html {
let status = use_state(|| None::<Status>);
@@ -157,6 +181,29 @@ pub fn dashboard() -> Html {
})
};
let on_monitor_check = {
let message = message.clone();
let error = error.clone();
let fetch = fetch_status.clone();
Callback::from(move |_: MouseEvent| {
let message = message.clone();
let error = error.clone();
let fetch = fetch.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::trigger_monitor_check().await {
Ok(t) => {
message.set(Some(format!(
"Monitor check started (task: {})",
&t.task_id[..8]
)));
fetch.emit(());
}
Err(e) => error.set(Some(e.0)),
}
});
})
};
let on_pipeline = {
let message = message.clone();
let error = error.clone();
@@ -193,6 +240,35 @@ pub fn dashboard() -> Html {
.iter()
.any(|t| t.status == "Pending" || t.status == "Running");
// Pre-compute scheduled task rows
let scheduled_rows = {
let mut rows = Vec::new();
if let Some(ref sched) = s.scheduled {
if let Some(ref next) = sched.next_pipeline {
rows.push(html! {
<tr>
<td>{ "Auto Pipeline" }</td>
<td><span class="badge badge-pending">{ "Scheduled" }</span></td>
<td></td>
<td class="text-sm text-muted">{ format!("Next run: {}", format_next_run(next)) }</td>
</tr>
});
}
if let Some(ref next) = sched.next_monitor {
rows.push(html! {
<tr>
<td>{ "Monitor Check" }</td>
<td><span class="badge badge-pending">{ "Scheduled" }</span></td>
<td></td>
<td class="text-sm text-muted">{ format!("Next run: {}", format_next_run(next)) }</td>
</tr>
});
}
}
rows
};
let has_scheduled = !scheduled_rows.is_empty();
html! {
<div>
<div class="page-header">
@@ -247,11 +323,12 @@ pub fn dashboard() -> Html {
<button class="btn btn-secondary" onclick={on_index}>{ "Re-index" }</button>
<button class="btn btn-secondary" onclick={on_tag}>{ "Auto-tag" }</button>
<button class="btn btn-secondary" onclick={on_organize}>{ "Organize" }</button>
<button class="btn btn-secondary" onclick={on_monitor_check}>{ "Check Monitored Artists" }</button>
</div>
</div>
// Background Tasks
if !s.tasks.is_empty() {
// Background Tasks (always show if there are tasks or scheduled items)
if !s.tasks.is_empty() || has_scheduled {
<div class="card">
<h3>{ "Background Tasks" }</h3>
<table class="tasks-table">
@@ -259,6 +336,7 @@ pub fn dashboard() -> Html {
<tr><th>{ "Type" }</th><th>{ "Status" }</th><th>{ "Progress" }</th><th>{ "Result" }</th></tr>
</thead>
<tbody>
{ for scheduled_rows.into_iter() }
{ for s.tasks.iter().map(|t| {
let progress_html = if let Some(ref p) = t.progress {
if p.total > 0 {

View File

@@ -45,6 +45,7 @@ pub fn library_page() -> Html {
<thead>
<tr>
<th>{ "Name" }</th>
<th>{ "Monitored" }</th>
<th>{ "Owned" }</th>
<th>{ "Watched" }</th>
<th>{ "Tracks" }</th>
@@ -58,6 +59,11 @@ pub fn library_page() -> Html {
{ &a.name }
</Link<Route>>
</td>
<td style="text-align: center;">
if a.monitored {
<span style="color: var(--success);" title="Monitored">{ "\u{2713}" }</span>
}
</td>
<td>
if a.total_items > 0 {
<span class="text-sm" style={

View File

@@ -99,7 +99,9 @@ pub fn settings_page() -> Html {
wasm_bindgen_futures::spawn_local(async move {
match api::ytauth_login_stop().await {
Ok(_) => {
message.set(Some("YouTube login complete! Cookies exported.".into()));
message.set(Some(
"YouTube login complete! Cookies exported.".into(),
));
if let Ok(s) = api::get_ytauth_status().await {
ytauth.set(Some(s));
}
@@ -123,7 +125,8 @@ pub fn settings_page() -> Html {
</>
}
} else if status.authenticated {
let age_text = status.cookie_age_hours
let age_text = status
.cookie_age_hours
.map(|h| format!("cookies {h:.0}h old"))
.unwrap_or_else(|| "authenticated".into());
let on_refresh = {
@@ -240,7 +243,10 @@ pub fn settings_page() -> Html {
};
let ytdlp_version_html = if let Some(ref status) = *ytauth {
let version = status.ytdlp_version.clone().unwrap_or_else(|| "not found".into());
let version = status
.ytdlp_version
.clone()
.unwrap_or_else(|| "not found".into());
if status.ytdlp_update_available {
let latest = status.ytdlp_latest.clone().unwrap_or_default();
html! {
@@ -266,7 +272,10 @@ pub fn settings_page() -> Html {
};
let lastfm_key_html = {
let key_set = ytauth.as_ref().map(|s| s.lastfm_api_key_set).unwrap_or(false);
let key_set = ytauth
.as_ref()
.map(|s| s.lastfm_api_key_set)
.unwrap_or(false);
if key_set {
html! {
<p class="text-sm" style="margin: 0.25rem 0 0 0;">
@@ -567,6 +576,60 @@ pub fn settings_page() -> Html {
</div>
</div>
// Scheduling
<div class="card">
<h3>{ "Scheduling" }</h3>
<p class="text-sm text-muted mb-1">{ "Automate pipeline runs and new release monitoring" }</p>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" checked={c.scheduling.pipeline_enabled}
onchange={let config = config.clone(); Callback::from(move |e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.scheduling.pipeline_enabled = input.checked();
config.set(Some(cfg));
})} />
{ " Automatically run full pipeline (sync, download, index, tag, organize)" }
</label>
</div>
<div class="form-group">
<label>{ "Pipeline Interval (hours)" }</label>
<input type="number" min="1" max="168" value={c.scheduling.pipeline_interval_hours.to_string()}
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
let mut cfg = (*config).clone().unwrap();
cfg.scheduling.pipeline_interval_hours = v;
config.set(Some(cfg));
}
})} />
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" checked={c.scheduling.monitor_enabled}
onchange={let config = config.clone(); Callback::from(move |e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.scheduling.monitor_enabled = input.checked();
config.set(Some(cfg));
})} />
{ " Automatically check monitored artists for new releases" }
</label>
</div>
<div class="form-group">
<label>{ "Monitor Interval (hours)" }</label>
<input type="number" min="1" max="168" value={c.scheduling.monitor_interval_hours.to_string()}
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
let mut cfg = (*config).clone().unwrap();
cfg.scheduling.monitor_interval_hours = v;
config.set(Some(cfg));
}
})} />
</div>
</div>
// Indexing
<div class="card">
<h3>{ "Indexing" }</h3>

View File

@@ -28,6 +28,8 @@ pub struct ArtistListItem {
pub id: i32,
pub name: String,
pub musicbrainz_id: Option<String>,
#[serde(default)]
pub monitored: bool,
pub total_watched: usize,
pub total_owned: usize,
pub total_items: usize,
@@ -62,6 +64,8 @@ pub struct FullArtistDetail {
#[serde(default)]
pub enriched: bool,
#[serde(default)]
pub monitored: bool,
#[serde(default)]
pub artist_info: Option<ArtistInfoFe>,
#[serde(default)]
pub artist_photo: Option<String>,
@@ -240,6 +244,14 @@ pub struct Status {
#[serde(default)]
pub tagging: Option<TaggingStatus>,
pub tasks: Vec<TaskInfo>,
#[serde(default)]
pub scheduled: Option<ScheduledTasks>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ScheduledTasks {
pub next_pipeline: Option<String>,
pub next_monitor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
@@ -297,6 +309,8 @@ pub struct AppConfig {
pub indexing: IndexingConfigFe,
#[serde(default)]
pub metadata: MetadataConfigFe,
#[serde(default)]
pub scheduling: SchedulingConfigFe,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
@@ -376,6 +390,25 @@ impl Default for MetadataConfigFe {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct SchedulingConfigFe {
#[serde(default)]
pub pipeline_enabled: bool,
#[serde(default = "default_pipeline_interval_hours")]
pub pipeline_interval_hours: u32,
#[serde(default)]
pub monitor_enabled: bool,
#[serde(default = "default_monitor_interval_hours")]
pub monitor_interval_hours: u32,
}
fn default_pipeline_interval_hours() -> u32 {
3
}
fn default_monitor_interval_hours() -> u32 {
12
}
fn default_metadata_source() -> String {
"musicbrainz".into()
}