diff --git a/frontend/src/pages/dashboard.rs b/frontend/src/pages/dashboard.rs index cc8831b..bc31751 100644 --- a/frontend/src/pages/dashboard.rs +++ b/frontend/src/pages/dashboard.rs @@ -1,3 +1,4 @@ +use gloo_timers::callback::Interval; use yew::prelude::*; use crate::api; @@ -8,20 +9,139 @@ use crate::types::Status; pub fn dashboard() -> Html { let status = use_state(|| None::); let error = use_state(|| None::); + let message = use_state(|| None::); - { + // Fetch status function + let fetch_status = { let status = status.clone(); let error = error.clone(); - use_effect_with((), move |_| { + Callback::from(move |_: ()| { + let status = status.clone(); + let error = error.clone(); wasm_bindgen_futures::spawn_local(async move { match api::get_status().await { - Ok(s) => status.set(Some(s)), + Ok(s) => { + error.set(None); + status.set(Some(s)); + } Err(e) => error.set(Some(e.0)), } }); + }) + }; + + // Initial fetch + { + let fetch = fetch_status.clone(); + use_effect_with((), move |_| { + fetch.emit(()); }); } + // Auto-refresh every 5 seconds + { + let fetch = fetch_status.clone(); + use_effect_with((), move |_| { + let interval = Interval::new(1_000, move || { + fetch.emit(()); + }); + // Keep the interval alive for the component's lifetime + move || drop(interval) + }); + } + + // Action handlers + let on_sync = { + 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::sync_downloads().await { + Ok(s) => { + message.set(Some(format!("Synced: {} enqueued, {} skipped", s.enqueued, s.skipped))); + fetch.emit(()); + } + Err(e) => error.set(Some(e.0)), + } + }); + }) + }; + + let on_process = { + 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::process_downloads().await { + Ok(t) => { + message.set(Some(format!("Downloads started (task: {})", &t.task_id[..8]))); + fetch.emit(()); + } + Err(e) => error.set(Some(e.0)), + } + }); + }) + }; + + let on_index = { + 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_index().await { + Ok(t) => { message.set(Some(format!("Indexing started ({})", &t.task_id[..8]))); fetch.emit(()); } + Err(e) => error.set(Some(e.0)), + } + }); + }) + }; + + let on_tag = { + 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_tag().await { + Ok(t) => { message.set(Some(format!("Tagging started ({})", &t.task_id[..8]))); fetch.emit(()); } + Err(e) => error.set(Some(e.0)), + } + }); + }) + }; + + let on_organize = { + 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_organize().await { + Ok(t) => { message.set(Some(format!("Organizing started ({})", &t.task_id[..8]))); fetch.emit(()); } + Err(e) => error.set(Some(e.0)), + } + }); + }) + }; + if let Some(ref err) = *error { return html! {
{ format!("Error: {err}") }
}; } @@ -36,6 +156,13 @@ pub fn dashboard() -> Html {

{ "Dashboard" }

+ if let Some(ref msg) = *message { +
+

{ msg }

+
+ } + + // Stats grid
{ s.library.total_items }
@@ -55,34 +182,117 @@ pub fn dashboard() -> Html {
+ // Actions
-

{ "Download Queue" }

-

{ format!("{} pending, {} downloading", s.queue.pending, s.queue.downloading) }

+

{ "Actions" }

+
+ + + + + +
+ // Background Tasks if !s.tasks.is_empty() {

{ "Background Tasks" }

- - - - - + - { for s.tasks.iter().map(|t| html! { - - - - - + { for s.tasks.iter().map(|t| { + let progress_html = if let Some(ref p) = t.progress { + if p.total > 0 { + let pct = (p.current as f64 / p.total as f64 * 100.0) as u32; + html! { +
+
{ &p.message }{ format!(" ({}/{})", p.current, p.total) }
+
+
+
+
+ } + } else { + html! { { &p.message } } + } + } else { + html! {} + }; + html! { + + + + + + + } })}
{ "Type" }{ "Status" }{ "Result" }
{ "Type" }{ "Status" }{ "Progress" }{ "Result" }
{ &t.task_type }{ t.result.as_deref().unwrap_or("") }
{ &t.task_type }{ progress_html }{ t.result.as_deref().unwrap_or("") }
} + + // Download Queue Items + if !s.queue.items.is_empty() { +
+

{ format!("Download Queue ({} pending, {} downloading, {} failed)", + s.queue.pending, s.queue.downloading, s.queue.failed) }

+ + + + + + { for s.queue.items.iter().map(|item| html! { + + + + + + })} + +
{ "Query" }{ "Status" }{ "Error" }
{ &item.query }{ item.error_message.as_deref().unwrap_or("") }
+
+ } else if s.queue.pending > 0 || s.queue.downloading > 0 { +
+

{ "Download Queue" }

+

{ format!("{} pending, {} downloading", s.queue.pending, s.queue.downloading) }

+
+ } + + // Tagging Queue + if let Some(ref tagging) = s.tagging { + if tagging.needs_tagging > 0 { +
+

{ format!("Tagging Queue ({} tracks need metadata)", tagging.needs_tagging) }

+ if !tagging.items.is_empty() { + + + + + + { for tagging.items.iter().map(|t| html! { + + + + + + + })} + +
{ "Title" }{ "Artist" }{ "Album" }{ "MBID" }
{ t.title.as_deref().unwrap_or("Unknown") }{ t.artist.as_deref().unwrap_or("") }{ t.album.as_deref().unwrap_or("—") } + if t.album.is_some() { + { "needs album link" } + } else { + { "needs metadata" } + } +
+ } +
+ } + } } } diff --git a/frontend/src/pages/settings.rs b/frontend/src/pages/settings.rs index 19944a2..8f031f9 100644 --- a/frontend/src/pages/settings.rs +++ b/frontend/src/pages/settings.rs @@ -7,7 +7,6 @@ use crate::types::AppConfig; pub fn settings_page() -> Html { let config = use_state(|| None::); let error = use_state(|| None::); - let message = use_state(|| None::); { let config = config.clone(); @@ -22,53 +21,20 @@ pub fn settings_page() -> Html { }); } - let trigger = |action: &'static str| { - let message = message.clone(); - let error = error.clone(); - Callback::from(move |_: MouseEvent| { - let message = message.clone(); - let error = error.clone(); - wasm_bindgen_futures::spawn_local(async move { - let result = match action { - "index" => api::trigger_index().await, - "tag" => api::trigger_tag().await, - "organize" => api::trigger_organize().await, - _ => return, - }; - match result { - Ok(t) => message.set(Some(format!("{action} started (task: {})", t.task_id))), - Err(e) => error.set(Some(e.0)), - } - }); - }) - }; - html! {
- if let Some(ref msg) = *message { -
{ msg }
- } if let Some(ref err) = *error {
{ err }
} -
-

{ "Actions" }

-
- - - -
-
- { match &*config { None => html! {

{ "Loading configuration..." }

}, Some(c) => html! { -
+

{ "Configuration" }

diff --git a/frontend/src/types.rs b/frontend/src/types.rs index 0f932be..4ae5cef 100644 --- a/frontend/src/types.rs +++ b/frontend/src/types.rs @@ -102,11 +102,19 @@ pub struct DownloadItem { // --- Tasks --- +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct TaskProgress { + pub current: u64, + pub total: u64, + pub message: String, +} + #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct TaskInfo { pub id: String, pub task_type: String, pub status: String, + pub progress: Option, pub result: Option, } @@ -117,10 +125,19 @@ pub struct TaskRef { // --- Status --- +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct TaggingStatus { + pub needs_tagging: usize, + #[serde(default)] + pub items: Vec, +} + #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct Status { pub library: LibrarySummary, pub queue: QueueStatus, + #[serde(default)] + pub tagging: Option, pub tasks: Vec, } @@ -137,6 +154,10 @@ pub struct LibrarySummary { pub struct QueueStatus { pub pending: usize, pub downloading: usize, + #[serde(default)] + pub failed: usize, + #[serde(default)] + pub items: Vec, } // --- API responses --- diff --git a/src/routes/downloads.rs b/src/routes/downloads.rs index c20376e..937baa3 100644 --- a/src/routes/downloads.rs +++ b/src/routes/downloads.rs @@ -87,6 +87,17 @@ async fn trigger_process( let tid = task_id.clone(); tokio::spawn(async move { + // Count total pending for progress reporting + let total = shanty_db::queries::downloads::list( + state.db.conn(), + Some(shanty_db::entities::download_queue::DownloadStatus::Pending), + ) + .await + .map(|v| v.len() as u64) + .unwrap_or(0); + + state.tasks.update_progress(&tid, 0, total, "Starting downloads..."); + let cookies = state.config.download.cookies_path.clone(); let format: shanty_dl::AudioFormat = state.config.download.format.parse().unwrap_or(shanty_dl::AudioFormat::Opus); let source: shanty_dl::SearchSource = state.config.download.search_source.parse().unwrap_or(shanty_dl::SearchSource::YouTubeMusic); diff --git a/src/routes/system.rs b/src/routes/system.rs index 672a811..957cf1f 100644 --- a/src/routes/system.rs +++ b/src/routes/system.rs @@ -21,19 +21,31 @@ async fn get_status( state: web::Data, ) -> Result { let summary = shanty_watch::library_summary(state.db.conn()).await?; - let pending = queries::downloads::list(state.db.conn(), Some(DownloadStatus::Pending)) - .await? - .len(); - let downloading = queries::downloads::list(state.db.conn(), Some(DownloadStatus::Downloading)) - .await? - .len(); + let pending_items = queries::downloads::list(state.db.conn(), Some(DownloadStatus::Pending)).await?; + let downloading_items = queries::downloads::list(state.db.conn(), Some(DownloadStatus::Downloading)).await?; + let failed_items = queries::downloads::list(state.db.conn(), Some(DownloadStatus::Failed)).await?; let tasks = state.tasks.list(); + // Combine active/recent download items for the dashboard + let mut queue_items = Vec::new(); + queue_items.extend(downloading_items.iter().cloned()); + queue_items.extend(pending_items.iter().cloned()); + queue_items.extend(failed_items.iter().take(5).cloned()); + + // Tracks needing metadata (tagging queue) + let needs_tagging = queries::tracks::get_needing_metadata(state.db.conn()).await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ "library": summary, "queue": { - "pending": pending, - "downloading": downloading, + "pending": pending_items.len(), + "downloading": downloading_items.len(), + "failed": failed_items.len(), + "items": queue_items, + }, + "tagging": { + "needs_tagging": needs_tagging.len(), + "items": needs_tagging.iter().take(20).collect::>(), }, "tasks": tasks, }))) @@ -47,6 +59,7 @@ async fn trigger_index( let tid = task_id.clone(); tokio::spawn(async move { + state.tasks.update_progress(&tid, 0, 0, "Scanning library..."); let scan_config = shanty_index::ScanConfig { root: state.config.library_path.clone(), dry_run: false, @@ -69,6 +82,7 @@ async fn trigger_tag( let tid = task_id.clone(); tokio::spawn(async move { + state.tasks.update_progress(&tid, 0, 0, "Preparing tagger..."); let mb = match shanty_tag::MusicBrainzClient::new() { Ok(c) => c, Err(e) => { @@ -81,6 +95,7 @@ async fn trigger_tag( write_tags: state.config.tagging.write_tags, confidence: state.config.tagging.confidence, }; + state.tasks.update_progress(&tid, 0, 0, "Tagging tracks..."); match shanty_tag::run_tagging(state.db.conn(), &mb, &tag_config, None).await { Ok(stats) => state.tasks.complete(&tid, format!("{stats}")), Err(e) => state.tasks.fail(&tid, e.to_string()), @@ -98,6 +113,7 @@ async fn trigger_organize( let tid = task_id.clone(); tokio::spawn(async move { + state.tasks.update_progress(&tid, 0, 0, "Organizing files..."); let org_config = shanty_org::OrgConfig { target_dir: state.config.library_path.clone(), format: state.config.organization_format.clone(), @@ -105,7 +121,18 @@ async fn trigger_organize( copy: false, }; match shanty_org::organize_from_db(state.db.conn(), &org_config).await { - Ok(stats) => state.tasks.complete(&tid, format!("{stats}")), + Ok(stats) => { + // Promote all Downloaded wanted items to Owned + let promoted = queries::wanted::promote_downloaded_to_owned(state.db.conn()) + .await + .unwrap_or(0); + let msg = if promoted > 0 { + format!("{stats} — {promoted} items marked as owned") + } else { + format!("{stats}") + }; + state.tasks.complete(&tid, msg); + } Err(e) => state.tasks.fail(&tid, e.to_string()), } }); diff --git a/src/tasks.rs b/src/tasks.rs index bc8a692..2cb7823 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -4,11 +4,19 @@ use std::sync::Mutex; use chrono::{NaiveDateTime, Utc}; use serde::Serialize; +#[derive(Debug, Clone, Serialize)] +pub struct TaskProgress { + pub current: u64, + pub total: u64, + pub message: String, +} + #[derive(Debug, Clone, Serialize)] pub struct TaskInfo { pub id: String, pub task_type: String, pub status: TaskStatus, + pub progress: Option, pub started_at: NaiveDateTime, pub completed_at: Option, pub result: Option, @@ -39,6 +47,7 @@ impl TaskManager { id: id.clone(), task_type: task_type.to_string(), status: TaskStatus::Running, + progress: None, started_at: Utc::now().naive_utc(), completed_at: None, result: None, @@ -47,10 +56,22 @@ impl TaskManager { id } + /// Update progress on a running task. + pub fn update_progress(&self, id: &str, current: u64, total: u64, message: &str) { + if let Some(task) = self.tasks.lock().unwrap().get_mut(id) { + task.progress = Some(TaskProgress { + current, + total, + message: message.to_string(), + }); + } + } + /// Mark a task as completed with a result string. pub fn complete(&self, id: &str, result: String) { if let Some(task) = self.tasks.lock().unwrap().get_mut(id) { task.status = TaskStatus::Completed; + task.progress = None; task.completed_at = Some(Utc::now().naive_utc()); task.result = Some(result); } @@ -60,6 +81,7 @@ impl TaskManager { pub fn fail(&self, id: &str, error: String) { if let Some(task) = self.tasks.lock().unwrap().get_mut(id) { task.status = TaskStatus::Failed; + task.progress = None; task.completed_at = Some(Utc::now().naive_utc()); task.result = Some(error); }