Added the watch and scheduler systems
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user