diff --git a/Cargo.toml b/Cargo.toml index d77c3a9..c62cef7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ shanty-tag = { path = "../shanty-tag" } shanty-org = { path = "../shanty-org" } shanty-watch = { path = "../shanty-watch" } shanty-dl = { path = "../shanty-dl" } +shanty-playlist = { path = "../shanty-playlist" } shanty-search = { path = "../shanty-search" } sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-native-tls"] } actix-web = "4" diff --git a/frontend/src/api.rs b/frontend/src/api.rs index 33dc94c..b19ee73 100644 --- a/frontend/src/api.rs +++ b/frontend/src/api.rs @@ -227,6 +227,15 @@ pub async fn trigger_monitor_check() -> Result { post_empty(&format!("{BASE}/monitor/check")).await } +// --- Scheduler --- +pub async fn skip_scheduled_pipeline() -> Result { + post_empty(&format!("{BASE}/scheduler/skip-pipeline")).await +} + +pub async fn skip_scheduled_monitor() -> Result { + post_empty(&format!("{BASE}/scheduler/skip-monitor")).await +} + // --- System --- pub async fn trigger_index() -> Result { post_empty(&format!("{BASE}/index")).await @@ -259,6 +268,42 @@ pub async fn save_config(config: &AppConfig) -> Result { resp.json().await.map_err(|e| ApiError(e.to_string())) } +// --- Playlists --- +pub async fn generate_playlist(req: &GenerateRequest) -> Result { + let body = serde_json::to_string(req).map_err(|e| ApiError(e.to_string()))?; + post_json(&format!("{BASE}/playlists/generate"), &body).await +} + +pub async fn save_playlist( + name: &str, + description: Option<&str>, + track_ids: &[i32], +) -> Result { + let body = serde_json::json!({ + "name": name, + "description": description, + "track_ids": track_ids, + }) + .to_string(); + post_json(&format!("{BASE}/playlists"), &body).await +} + +pub async fn list_playlists() -> Result, ApiError> { + get_json(&format!("{BASE}/playlists")).await +} + +pub async fn get_playlist(id: i32) -> Result { + get_json(&format!("{BASE}/playlists/{id}")).await +} + +pub async fn delete_playlist(id: i32) -> Result<(), ApiError> { + delete(&format!("{BASE}/playlists/{id}")).await +} + +pub fn export_m3u_url(id: i32) -> String { + format!("{BASE}/playlists/{id}/m3u") +} + // --- YouTube Auth --- pub async fn get_ytauth_status() -> Result { diff --git a/frontend/src/components/navbar.rs b/frontend/src/components/navbar.rs index 1616b3e..0712183 100644 --- a/frontend/src/components/navbar.rs +++ b/frontend/src/components/navbar.rs @@ -37,6 +37,7 @@ pub fn navbar(props: &Props) -> Html { { link(Route::Dashboard, "Dashboard") } { link(Route::Search, "Search") } { link(Route::Library, "Library") } + { link(Route::Playlists, "Playlists") } { link(Route::Downloads, "Downloads") } if props.role == "admin" { { link(Route::Settings, "Settings") } diff --git a/frontend/src/pages/dashboard.rs b/frontend/src/pages/dashboard.rs index 31d21d6..d7ce3e8 100644 --- a/frontend/src/pages/dashboard.rs +++ b/frontend/src/pages/dashboard.rs @@ -245,22 +245,60 @@ pub fn dashboard() -> Html { let mut rows = Vec::new(); if let Some(ref sched) = s.scheduled { if let Some(ref next) = sched.next_pipeline { + let on_skip = { + 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::skip_scheduled_pipeline().await { + Ok(_) => { + message.set(Some("Next pipeline run skipped".into())); + fetch.emit(()); + } + Err(e) => error.set(Some(e.0)), + } + }); + }) + }; rows.push(html! { { "Auto Pipeline" } { "Scheduled" } - { format!("Next run: {}", format_next_run(next)) } + }); } if let Some(ref next) = sched.next_monitor { + let on_skip = { + 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::skip_scheduled_monitor().await { + Ok(_) => { + message.set(Some("Next monitor check skipped".into())); + fetch.emit(()); + } + Err(e) => error.set(Some(e.0)), + } + }); + }) + }; rows.push(html! { { "Monitor Check" } { "Scheduled" } - { format!("Next run: {}", format_next_run(next)) } + }); } diff --git a/frontend/src/pages/mod.rs b/frontend/src/pages/mod.rs index bf77159..0f42c93 100644 --- a/frontend/src/pages/mod.rs +++ b/frontend/src/pages/mod.rs @@ -4,6 +4,7 @@ pub mod dashboard; pub mod downloads; pub mod library; pub mod login; +pub mod playlists; pub mod search; pub mod settings; pub mod setup; @@ -23,6 +24,8 @@ pub enum Route { Artist { id: String }, #[at("/albums/:mbid")] Album { mbid: String }, + #[at("/playlists")] + Playlists, #[at("/downloads")] Downloads, #[at("/settings")] @@ -39,6 +42,7 @@ pub fn switch(route: Route) -> Html { Route::Library => html! { }, Route::Artist { id } => html! { }, Route::Album { mbid } => html! { }, + Route::Playlists => html! { }, Route::Downloads => html! { }, Route::Settings => html! { }, Route::NotFound => html! {

{ "404 — Not Found" }

}, diff --git a/frontend/src/pages/playlists.rs b/frontend/src/pages/playlists.rs new file mode 100644 index 0000000..0d4310c --- /dev/null +++ b/frontend/src/pages/playlists.rs @@ -0,0 +1,466 @@ +use web_sys::HtmlInputElement; +use web_sys::HtmlSelectElement; +use yew::prelude::*; + +use crate::api; +use crate::types::*; + +#[function_component(PlaylistsPage)] +pub fn playlists_page() -> Html { + // All local artists (for seed picker) + let all_artists = use_state(Vec::::new); + { + let all_artists = all_artists.clone(); + use_effect_with((), move |_| { + wasm_bindgen_futures::spawn_local(async move { + if let Ok(artists) = api::list_artists(500, 0).await { + all_artists.set(artists); + } + }); + }); + } + + // Generate form state + let seed_input = use_state(String::new); + let seed_focused = use_state(|| false); + let seeds = use_state(Vec::::new); + let count = use_state(|| 50usize); + let popularity_bias = use_state(|| 5u8); + let ordering = use_state(|| "interleave".to_string()); // "score" | "interleave" | "random" + + // Results + let generated = use_state(|| None::); + let saved_playlists = use_state(|| None::>); + let viewing_playlist = use_state(|| None::); + let error = use_state(|| None::); + let message = use_state(|| None::); + let loading = use_state(|| false); + let save_name = use_state(String::new); + + // Load saved playlists on mount + let refresh_playlists = { + let saved_playlists = saved_playlists.clone(); + Callback::from(move |_: ()| { + let saved_playlists = saved_playlists.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Ok(list) = api::list_playlists().await { + saved_playlists.set(Some(list)); + } + }); + }) + }; + + { + let refresh = refresh_playlists.clone(); + use_effect_with((), move |_| { + refresh.emit(()); + }); + } + + // Generate handler + let on_generate = { + let seeds = seeds.clone(); + let count = count.clone(); + let popularity_bias = popularity_bias.clone(); + let ordering = ordering.clone(); + let generated = generated.clone(); + let error = error.clone(); + let loading = loading.clone(); + let message = message.clone(); + Callback::from(move |_: MouseEvent| { + let seeds = (*seeds).clone(); + let count = *count; + let popularity_bias = *popularity_bias; + let ordering_val = (*ordering).clone(); + let generated = generated.clone(); + let error = error.clone(); + let loading = loading.clone(); + let message = message.clone(); + loading.set(true); + error.set(None); + message.set(None); + wasm_bindgen_futures::spawn_local(async move { + let req = GenerateRequest { + strategy: "similar".to_string(), + seed_artists: seeds, + genres: vec![], + count, + popularity_bias, + ordering: ordering_val, + rules: None, + }; + + match api::generate_playlist(&req).await { + Ok(result) => { + let n = result.tracks.len(); + message.set(Some(format!("Generated {n} tracks"))); + generated.set(Some(result)); + } + Err(e) => error.set(Some(e.0)), + } + loading.set(false); + }); + }) + }; + + // Save handler + let on_save = { + let generated = generated.clone(); + let save_name = save_name.clone(); + let error = error.clone(); + let message = message.clone(); + let refresh = refresh_playlists.clone(); + Callback::from(move |_: MouseEvent| { + let generated = generated.clone(); + let name = (*save_name).clone(); + let error = error.clone(); + let message = message.clone(); + let refresh = refresh.clone(); + if name.is_empty() { + error.set(Some("Enter a playlist name".to_string())); + return; + } + wasm_bindgen_futures::spawn_local(async move { + if let Some(ref gen) = *generated { + let track_ids: Vec = gen.tracks.iter().map(|t| t.track_id).collect(); + match api::save_playlist(&name, None, &track_ids).await { + Ok(_) => { + message.set(Some(format!("Saved playlist: {name}"))); + refresh.emit(()); + } + Err(e) => error.set(Some(e.0)), + } + } + }); + }) + }; + + // Seed artist inputs + let strategy_inputs = { + { + let seed_input_c = seed_input.clone(); + let seeds_c = seeds.clone(); + // Filter artists by input text (case-insensitive subsequence match) + let query = (*seed_input_c).to_lowercase(); + let filtered: Vec<_> = { + (*all_artists).iter() + .filter(|a| { + if query.is_empty() { + return true; + } + let name_lower = a.name.to_lowercase(); + // Subsequence match like drift's fuzzy_match + let mut chars = query.chars(); + let mut current = chars.next(); + for c in name_lower.chars() { + if current == Some(c) { + current = chars.next(); + } + } + current.is_none() + }) + .filter(|a| !seeds_c.contains(&a.name)) + .take(15) + .cloned() + .collect() + }; + html! { +
+ +
+ + if *seed_focused && !filtered.is_empty() { +
+ { for filtered.iter().map(|a| { + let name = a.name.clone(); + let seeds = seeds.clone(); + let si = seed_input.clone(); + let track_count = a.total_items; + html! { +
+ { &a.name } + { format!("{} tracks", track_count) } +
+ } + })} +
+ } +
+
+ { for seeds_c.iter().enumerate().map(|(i, s)| { + let seeds = seeds_c.clone(); + html! { + { s }{ " \u{00d7}" } + } + })} +
+ + +
+ + +
+
+ } + } + }; + + // Track list display + let track_list = if let Some(ref gen) = *generated { + html! { +
+

{ format!("Generated Tracks ({})", gen.tracks.len()) }

+
+ + +
+ + + + + + + + + + + + { for gen.tracks.iter().enumerate().map(|(i, t)| html! { + + + + + + + + })} + +
{ "#" }{ "Title" }{ "Artist" }{ "Album" }{ "Score" }
{ i + 1 }{ t.title.as_deref().unwrap_or("Unknown") }{ t.artist.as_deref().unwrap_or("Unknown") }{ t.album.as_deref().unwrap_or("") }{ format!("{:.3}", t.score) }
+
+ } + } else { + html! {} + }; + + // Saved playlists display + let saved_list = if let Some(ref playlists) = *saved_playlists { + if playlists.is_empty() { + html! {

{ "No saved playlists." }

} + } else { + html! { + + + + + + + + + + + { for playlists.iter().map(|p| { + let id = p.id; + let refresh = refresh_playlists.clone(); + let viewing = viewing_playlist.clone(); + html! { + + + + + + + } + })} + +
{ "Name" }{ "Tracks" }{ "Created" }
+ { &p.name } + { p.track_count }{ &p.created_at } + { "M3U" } + +
+ } + } + } else { + html! {

{ "Loading..." }

} + }; + + // Viewing a saved playlist + let viewing_section = if let Some(ref detail) = *viewing_playlist { + html! { +
+

{ &detail.playlist.name }

+ if let Some(ref desc) = detail.playlist.description { +

{ desc }

+ } + + + + + + + + + + + { for detail.tracks.iter().enumerate().map(|(i, t)| html! { + + + + + + + })} + +
{ "#" }{ "Title" }{ "Artist" }{ "Album" }
{ i + 1 }{ t.title.as_deref().unwrap_or("Unknown") }{ t.artist.as_deref().unwrap_or("Unknown") }{ t.album.as_deref().unwrap_or("") }
+
+ } + } else { + html! {} + }; + + html! { +
+ + + if let Some(ref msg) = *message { +
{ msg }
+ } + if let Some(ref err) = *error { +
{ err }
+ } + +
+

{ "Generate Playlist" }

+

{ "Build a playlist from similar artists in your library using Last.fm data." }

+ + { strategy_inputs } + +
+ + +
+ + +
+ + { track_list } + +
+

{ "Saved Playlists" }

+ { saved_list } +
+ + { viewing_section } +
+ } +} diff --git a/frontend/src/types.rs b/frontend/src/types.rs index 70fae86..f093139 100644 --- a/frontend/src/types.rs +++ b/frontend/src/types.rs @@ -289,6 +289,88 @@ pub struct SyncStats { pub skipped: u64, } +// --- Playlists --- + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GenerateRequest { + pub strategy: String, + #[serde(default)] + pub seed_artists: Vec, + #[serde(default)] + pub genres: Vec, + #[serde(default = "default_playlist_count")] + pub count: usize, + #[serde(default = "default_popularity_bias")] + pub popularity_bias: u8, + #[serde(default = "default_ordering")] + pub ordering: String, + #[serde(default)] + pub rules: Option, +} + +fn default_playlist_count() -> usize { + 50 +} + +fn default_popularity_bias() -> u8 { + 5 +} + +fn default_ordering() -> String { + "interleave".into() +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct SmartRulesInput { + #[serde(default)] + pub genres: Vec, + pub added_within_days: Option, + pub year_range: Option<(i32, i32)>, + #[serde(default)] + pub artists: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct GeneratedPlaylist { + pub tracks: Vec, + pub strategy: String, + #[serde(default)] + pub resolved_seeds: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct GeneratedTrack { + pub track_id: i32, + pub file_path: String, + pub title: Option, + pub artist: Option, + pub album: Option, + pub score: f64, + pub duration: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct PlaylistSummary { + pub id: i32, + pub name: String, + pub description: Option, + pub track_count: u64, + pub created_at: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct PlaylistDetail { + pub playlist: SavedPlaylist, + pub tracks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct SavedPlaylist { + pub id: i32, + pub name: String, + pub description: Option, +} + // --- Config --- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/frontend/style.css b/frontend/style.css index 86a936f..b0f9b32 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -169,6 +169,35 @@ a:hover { color: var(--accent-hover); } max-width: 800px; margin-top: 0.5rem; } +.autocomplete-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + z-index: 10; + max-height: 250px; + overflow-y: auto; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); +} +.autocomplete-item { + padding: 0.5rem 0.75rem; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; +} +.autocomplete-item:hover { + background: var(--bg-hover); +} +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.5rem; +} .lyrics { font-family: inherit; font-size: 0.85rem; diff --git a/src/main.rs b/src/main.rs index 4b945da..fbedc0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,7 +55,7 @@ async fn main() -> anyhow::Result<()> { let db = Database::new(&config.database_url).await?; let mb_client = MusicBrainzFetcher::new()?; - let search = MusicBrainzSearch::new()?; + let search = MusicBrainzSearch::with_limiter(mb_client.limiter())?; let wiki_fetcher = WikipediaFetcher::new()?; let bind = format!("{}:{}", config.web.bind, config.web.port); @@ -74,6 +74,8 @@ async fn main() -> anyhow::Result<()> { scheduler: tokio::sync::Mutex::new(shanty_web::state::SchedulerInfo { next_pipeline: None, next_monitor: None, + skip_pipeline: false, + skip_monitor: false, }), }); diff --git a/src/monitor.rs b/src/monitor.rs index b738cf6..3bfe59e 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -114,15 +114,12 @@ pub async fn check_monitored_artists( 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; - + // Not cached — resolve release and fetch tracks (rate limited by shared MB client) 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 { + // Resolve from release group (goes through shared rate limiter) + match state.mb_client.resolve_release_from_group(&rg.id).await { Ok(rid) => rid, Err(e) => { tracing::debug!(rg_id = %rg.id, error = %e, "skipping release group"); @@ -131,8 +128,6 @@ pub async fn check_monitored_artists( } }; - 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) => { @@ -203,35 +198,6 @@ pub async fn check_monitored_artists( Ok(stats) } -/// Given a release-group MBID, find the first release MBID. -async fn resolve_release_from_group(release_group_mbid: &str) -> Result { - 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. @@ -268,10 +234,15 @@ pub fn spawn(state: web::Data) { continue; } - // Clear next-run while running + // Check if this run was skipped { let mut sched = state.scheduler.lock().await; sched.next_monitor = None; + if sched.skip_monitor { + sched.skip_monitor = false; + tracing::info!("scheduled monitor check skipped (user cancelled)"); + continue; + } } tracing::info!("scheduled monitor check starting"); diff --git a/src/pipeline_scheduler.rs b/src/pipeline_scheduler.rs index 52a5310..fb5b6b0 100644 --- a/src/pipeline_scheduler.rs +++ b/src/pipeline_scheduler.rs @@ -40,10 +40,15 @@ pub fn spawn(state: web::Data) { continue; } - // Clear next-run while running + // Check if this run was skipped { let mut sched = state.scheduler.lock().await; sched.next_pipeline = None; + if sched.skip_pipeline { + sched.skip_pipeline = false; + tracing::info!("scheduled pipeline skipped (user cancelled)"); + continue; + } } tracing::info!("scheduled pipeline starting"); diff --git a/src/routes/albums.rs b/src/routes/albums.rs index 0007d41..c1a6ffb 100644 --- a/src/routes/albums.rs +++ b/src/routes/albums.rs @@ -116,51 +116,18 @@ async fn get_album( }))) } -/// Given a release-group MBID, find the first release MBID via the MB API. +/// Given a release-group MBID, find the first release MBID via the shared MB client. async fn resolve_release_from_group( - _state: &web::Data, + state: &web::Data, release_group_mbid: &str, ) -> Result { - // Use the MB client's get_json (it's private, so we go through search) - // The approach: search for releases by this release group - // MB API: /ws/2/release?release-group={mbid}&fmt=json&limit=1 - // Since we can't call get_json directly, use the artist_releases approach - // to find a release that matches this group. - // - // Actually, the simplest: the MetadataFetcher trait has get_artist_releases - // which returns releases, but we need releases for a release GROUP. - // Let's add a direct HTTP call here via reqwest. - - let url = format!( - "https://musicbrainz.org/ws/2/release?release-group={release_group_mbid}&fmt=json&limit=1" - ); - - // Respect rate limiting by going through a small delay - tokio::time::sleep(std::time::Duration::from_millis(1100)).await; - - let client = reqwest::Client::builder() - .user_agent("Shanty/0.1.0 (shanty-music-app)") - .build() - .map_err(|e| ApiError::Internal(e.to_string()))?; - - let resp: serde_json::Value = client - .get(&url) - .send() + state + .mb_client + .resolve_release_from_group(release_group_mbid) .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(|| { + .map_err(|e| { ApiError::NotFound(format!( - "no releases found for release group {release_group_mbid}" + "no releases found for release group {release_group_mbid}: {e}" )) }) } diff --git a/src/routes/artists.rs b/src/routes/artists.rs index 2c0609b..dce7efa 100644 --- a/src/routes/artists.rs +++ b/src/routes/artists.rs @@ -193,8 +193,12 @@ async fn get_cached_album_tracks( let release_mbid = if let Some(rid) = first_release_id { rid.to_string() } else { - // Browse releases for this release group - resolve_release_from_group(rg_id).await? + // Browse releases for this release group (through shared rate limiter) + state + .mb_client + .resolve_release_from_group(rg_id) + .await + .map_err(|e| ApiError::Internal(format!("MB error for group {rg_id}: {e}")))? }; let mb_tracks = state @@ -228,37 +232,6 @@ async fn get_cached_album_tracks( Ok(cached) } -/// Given a release-group MBID, find the first release MBID. -async fn resolve_release_from_group(release_group_mbid: &str) -> Result { - tokio::time::sleep(std::time::Duration::from_millis(1100)).await; - - 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}"))) -} - #[derive(Deserialize)] pub struct ArtistFullParams { #[serde(default)] diff --git a/src/routes/mod.rs b/src/routes/mod.rs index f8f473c..12fed66 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -3,6 +3,7 @@ pub mod artists; pub mod auth; pub mod downloads; pub mod lyrics; +pub mod playlists; pub mod search; pub mod system; pub mod tracks; @@ -21,6 +22,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .configure(downloads::configure) .configure(lyrics::configure) .configure(system::configure) - .configure(ytauth::configure), + .configure(ytauth::configure) + .configure(playlists::configure), ); } diff --git a/src/routes/playlists.rs b/src/routes/playlists.rs new file mode 100644 index 0000000..f4107f5 --- /dev/null +++ b/src/routes/playlists.rs @@ -0,0 +1,232 @@ +use actix_session::Session; +use actix_web::{HttpResponse, web}; +use serde::{Deserialize, Serialize}; + +use shanty_db::queries; +use shanty_playlist::{self, PlaylistRequest}; + +use crate::auth; +use crate::error::ApiError; +use crate::state::AppState; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service(web::resource("/playlists/generate").route(web::post().to(generate_playlist))) + .service( + web::resource("/playlists") + .route(web::get().to(list_playlists)) + .route(web::post().to(save_playlist)), + ) + .service( + web::resource("/playlists/{id}") + .route(web::get().to(get_playlist)) + .route(web::put().to(update_playlist)) + .route(web::delete().to(delete_playlist)), + ) + .service(web::resource("/playlists/{id}/m3u").route(web::get().to(export_m3u))); +} + +/// POST /api/playlists/generate — generate a playlist without saving. +async fn generate_playlist( + state: web::Data, + session: Session, + body: web::Json, +) -> Result { + auth::require_auth(&session)?; + let req = body.into_inner(); + let conn = state.db.conn(); + + let config = state.config.read().await; + let lastfm_key = config.metadata.lastfm_api_key.clone(); + drop(config); + + let result = match req.strategy.as_str() { + "similar" => { + let api_key = lastfm_key.unwrap_or_default(); + if api_key.is_empty() { + return Err(ApiError::BadRequest( + "SHANTY_LASTFM_API_KEY is required for similar-artist playlists".into(), + )); + } + let fetcher = shanty_data::LastFmSimilarFetcher::new(api_key) + .map_err(|e| ApiError::Internal(e.to_string()))?; + shanty_playlist::similar_artists( + conn, + &fetcher, + req.seed_artists, + req.count, + req.popularity_bias, + &req.ordering, + ) + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + } + "genre" => shanty_playlist::genre_based(conn, req.genres, req.count, &req.ordering) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?, + "random" => shanty_playlist::random(conn, req.count, req.ordering != "random") + .await + .map_err(|e| ApiError::Internal(e.to_string()))?, + "smart" => { + let rules = req.rules.unwrap_or_default(); + shanty_playlist::smart(conn, rules, req.count) + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + } + other => { + return Err(ApiError::BadRequest(format!("unknown strategy: {other}"))); + } + }; + + Ok(HttpResponse::Ok().json(result)) +} + +#[derive(Deserialize)] +struct SavePlaylistRequest { + name: String, + description: Option, + track_ids: Vec, +} + +/// POST /api/playlists — save a generated playlist. +async fn save_playlist( + state: web::Data, + session: Session, + body: web::Json, +) -> Result { + let (user_id, _, _) = auth::require_auth(&session)?; + let req = body.into_inner(); + let playlist = queries::playlists::create( + state.db.conn(), + &req.name, + req.description.as_deref(), + Some(user_id), + &req.track_ids, + ) + .await?; + + Ok(HttpResponse::Created().json(playlist)) +} + +/// GET /api/playlists — list saved playlists. +async fn list_playlists( + state: web::Data, + session: Session, +) -> Result { + let (user_id, _, _) = auth::require_auth(&session)?; + let playlists = queries::playlists::list(state.db.conn(), Some(user_id)).await?; + + #[derive(Serialize)] + struct PlaylistSummary { + id: i32, + name: String, + description: Option, + track_count: u64, + created_at: String, + } + + let mut summaries = Vec::new(); + for p in playlists { + let count = queries::playlists::get_track_count(state.db.conn(), p.id).await?; + summaries.push(PlaylistSummary { + id: p.id, + name: p.name, + description: p.description, + track_count: count, + created_at: p.created_at.format("%Y-%m-%d %H:%M").to_string(), + }); + } + + Ok(HttpResponse::Ok().json(summaries)) +} + +/// GET /api/playlists/{id} — get playlist with tracks. +async fn get_playlist( + state: web::Data, + session: Session, + path: web::Path, +) -> Result { + auth::require_auth(&session)?; + let id = path.into_inner(); + let playlist = queries::playlists::get_by_id(state.db.conn(), id).await?; + let tracks = queries::playlists::get_tracks(state.db.conn(), id).await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "playlist": playlist, + "tracks": tracks, + }))) +} + +#[derive(Deserialize)] +struct UpdatePlaylistRequest { + name: Option, + description: Option, +} + +/// PUT /api/playlists/{id} — update playlist name/description. +async fn update_playlist( + state: web::Data, + session: Session, + path: web::Path, + body: web::Json, +) -> Result { + auth::require_auth(&session)?; + let id = path.into_inner(); + let req = body.into_inner(); + let playlist = queries::playlists::update( + state.db.conn(), + id, + req.name.as_deref(), + req.description.as_deref(), + ) + .await?; + Ok(HttpResponse::Ok().json(playlist)) +} + +/// DELETE /api/playlists/{id} — delete a playlist. +async fn delete_playlist( + state: web::Data, + session: Session, + path: web::Path, +) -> Result { + auth::require_auth(&session)?; + let id = path.into_inner(); + queries::playlists::delete(state.db.conn(), id).await?; + Ok(HttpResponse::NoContent().finish()) +} + +/// GET /api/playlists/{id}/m3u — export as M3U file. +async fn export_m3u( + state: web::Data, + session: Session, + path: web::Path, +) -> Result { + auth::require_auth(&session)?; + let id = path.into_inner(); + let playlist = queries::playlists::get_by_id(state.db.conn(), id).await?; + let tracks = queries::playlists::get_tracks(state.db.conn(), id).await?; + + // Convert DB tracks to PlaylistTracks for M3U generation + let playlist_tracks: Vec = tracks + .into_iter() + .map(|t| shanty_playlist::PlaylistTrack { + track_id: t.id, + file_path: t.file_path, + title: t.title, + artist: t.artist, + album: t.album, + score: 0.0, + duration: t.duration, + }) + .collect(); + + let m3u = shanty_playlist::to_m3u(&playlist_tracks); + let filename = format!("{}.m3u", playlist.name.replace(' ', "_")); + + Ok(HttpResponse::Ok() + .insert_header(("Content-Type", "audio/x-mpegurl")) + .insert_header(( + "Content-Disposition", + format!("attachment; filename=\"{filename}\""), + )) + .body(m3u)) +} diff --git a/src/routes/system.rs b/src/routes/system.rs index bc3486c..0b01c3d 100644 --- a/src/routes/system.rs +++ b/src/routes/system.rs @@ -22,6 +22,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(web::resource("/watchlist/{id}").route(web::delete().to(remove_watchlist))) .service(web::resource("/monitor/check").route(web::post().to(trigger_monitor_check))) .service(web::resource("/monitor/status").route(web::get().to(get_monitor_status))) + .service(web::resource("/scheduler/skip-pipeline").route(web::post().to(skip_pipeline))) + .service(web::resource("/scheduler/skip-monitor").route(web::post().to(skip_monitor))) .service( web::resource("/config") .route(web::get().to(get_config)) @@ -303,3 +305,25 @@ async fn save_config( tracing::info!("config updated via API"); Ok(HttpResponse::Ok().json(&new_config)) } + +async fn skip_pipeline( + state: web::Data, + session: Session, +) -> Result { + auth::require_admin(&session)?; + let mut sched = state.scheduler.lock().await; + sched.skip_pipeline = true; + sched.next_pipeline = None; + Ok(HttpResponse::Ok().json(serde_json::json!({"status": "skipped"}))) +} + +async fn skip_monitor( + state: web::Data, + session: Session, +) -> Result { + auth::require_admin(&session)?; + let mut sched = state.scheduler.lock().await; + sched.skip_monitor = true; + sched.next_monitor = None; + Ok(HttpResponse::Ok().json(serde_json::json!({"status": "skipped"}))) +} diff --git a/src/state.rs b/src/state.rs index 023aeed..608b430 100644 --- a/src/state.rs +++ b/src/state.rs @@ -20,6 +20,10 @@ pub struct SchedulerInfo { pub next_pipeline: Option, /// When the next monitor check is scheduled (None if disabled). pub next_monitor: Option, + /// Skip the next pipeline run (one-shot, resets after skip). + pub skip_pipeline: bool, + /// Skip the next monitor run (one-shot, resets after skip). + pub skip_monitor: bool, } pub struct AppState {