Added the playlist generator

This commit is contained in:
Connor Johnstone
2026-03-20 18:09:47 -04:00
parent 9d6c0e31c1
commit ea6a6410f3
17 changed files with 962 additions and 116 deletions

View File

@@ -227,6 +227,15 @@ pub async fn trigger_monitor_check() -> Result<TaskRef, ApiError> {
post_empty(&format!("{BASE}/monitor/check")).await
}
// --- Scheduler ---
pub async fn skip_scheduled_pipeline() -> Result<serde_json::Value, ApiError> {
post_empty(&format!("{BASE}/scheduler/skip-pipeline")).await
}
pub async fn skip_scheduled_monitor() -> Result<serde_json::Value, ApiError> {
post_empty(&format!("{BASE}/scheduler/skip-monitor")).await
}
// --- System ---
pub async fn trigger_index() -> Result<TaskRef, ApiError> {
post_empty(&format!("{BASE}/index")).await
@@ -259,6 +268,42 @@ pub async fn save_config(config: &AppConfig) -> Result<AppConfig, ApiError> {
resp.json().await.map_err(|e| ApiError(e.to_string()))
}
// --- Playlists ---
pub async fn generate_playlist(req: &GenerateRequest) -> Result<GeneratedPlaylist, ApiError> {
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<serde_json::Value, ApiError> {
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<Vec<PlaylistSummary>, ApiError> {
get_json(&format!("{BASE}/playlists")).await
}
pub async fn get_playlist(id: i32) -> Result<PlaylistDetail, ApiError> {
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<YtAuthStatus, ApiError> {

View File

@@ -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") }

View File

@@ -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! {
<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>
<td><button class="btn btn-sm btn-danger" onclick={on_skip}>{ "Skip" }</button></td>
</tr>
});
}
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! {
<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>
<td><button class="btn btn-sm btn-danger" onclick={on_skip}>{ "Skip" }</button></td>
</tr>
});
}

View File

@@ -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! { <library::LibraryPage /> },
Route::Artist { id } => html! { <artist::ArtistPage {id} /> },
Route::Album { mbid } => html! { <album::AlbumPage {mbid} /> },
Route::Playlists => html! { <playlists::PlaylistsPage /> },
Route::Downloads => html! { <downloads::DownloadsPage /> },
Route::Settings => html! { <settings::SettingsPage /> },
Route::NotFound => html! { <h2>{ "404 — Not Found" }</h2> },

View File

@@ -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::<ArtistListItem>::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::<String>::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::<GeneratedPlaylist>);
let saved_playlists = use_state(|| None::<Vec<PlaylistSummary>>);
let viewing_playlist = use_state(|| None::<PlaylistDetail>);
let error = use_state(|| None::<String>);
let message = use_state(|| None::<String>);
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<i32> = 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! {
<div class="form-group">
<label>{ "Seed Artists" }</label>
<div style="position:relative;">
<input
type="text"
placeholder="Search your library..."
value={(*seed_input_c).clone()}
oninput={{
let si = seed_input.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
si.set(input.value());
})
}}
onfocus={{
let f = seed_focused.clone();
Callback::from(move |_: FocusEvent| f.set(true))
}}
onblur={{
let f = seed_focused.clone();
// Delay to allow click on dropdown item
Callback::from(move |_: FocusEvent| {
let f = f.clone();
gloo_timers::callback::Timeout::new(200, move || f.set(false)).forget();
})
}}
/>
if *seed_focused && !filtered.is_empty() {
<div class="autocomplete-dropdown">
{ 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! {
<div class="autocomplete-item" onclick={Callback::from(move |_: MouseEvent| {
let mut v = (*seeds).clone();
v.push(name.clone());
seeds.set(v);
si.set(String::new());
})}>
<span>{ &a.name }</span>
<span class="text-muted text-sm">{ format!("{} tracks", track_count) }</span>
</div>
}
})}
</div>
}
</div>
<div class="tag-list">
{ for seeds_c.iter().enumerate().map(|(i, s)| {
let seeds = seeds_c.clone();
html! {
<span class="tag" onclick={Callback::from(move |_: MouseEvent| {
let mut v = (*seeds).clone();
v.remove(i);
seeds.set(v);
})}>{ s }{ " \u{00d7}" }</span>
}
})}
</div>
<label>{ format!("Popularity Bias: {}", *popularity_bias) }</label>
<input
type="range"
min="0" max="10"
value={popularity_bias.to_string()}
oninput={{
let pb = popularity_bias.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
pb.set(v);
}
})
}}
/>
<div class="form-group">
<label>{ "Track Order" }</label>
<select onchange={{
let ord = ordering.clone();
Callback::from(move |e: Event| {
let select: HtmlSelectElement = e.target_unchecked_into();
ord.set(select.value());
})
}}>
<option value="interleave" selected={*ordering == "interleave"}>{ "Interleave (spread artists)" }</option>
<option value="score" selected={*ordering == "score"}>{ "By Score (best first)" }</option>
<option value="random" selected={*ordering == "random"}>{ "Random" }</option>
</select>
</div>
</div>
}
}
};
// Track list display
let track_list = if let Some(ref gen) = *generated {
html! {
<div class="card">
<h3>{ format!("Generated Tracks ({})", gen.tracks.len()) }</h3>
<div style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
<input
type="text"
placeholder="Playlist name..."
value={(*save_name).clone()}
oninput={{
let sn = save_name.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
sn.set(input.value());
})
}}
/>
<button class="btn btn-primary" onclick={on_save.clone()}>{ "Save Playlist" }</button>
</div>
<table>
<thead>
<tr>
<th>{ "#" }</th>
<th>{ "Title" }</th>
<th>{ "Artist" }</th>
<th>{ "Album" }</th>
<th>{ "Score" }</th>
</tr>
</thead>
<tbody>
{ for gen.tracks.iter().enumerate().map(|(i, t)| html! {
<tr>
<td>{ i + 1 }</td>
<td>{ t.title.as_deref().unwrap_or("Unknown") }</td>
<td>{ t.artist.as_deref().unwrap_or("Unknown") }</td>
<td class="text-muted">{ t.album.as_deref().unwrap_or("") }</td>
<td class="text-sm text-muted">{ format!("{:.3}", t.score) }</td>
</tr>
})}
</tbody>
</table>
</div>
}
} else {
html! {}
};
// Saved playlists display
let saved_list = if let Some(ref playlists) = *saved_playlists {
if playlists.is_empty() {
html! { <p class="text-muted">{ "No saved playlists." }</p> }
} else {
html! {
<table>
<thead>
<tr>
<th>{ "Name" }</th>
<th>{ "Tracks" }</th>
<th>{ "Created" }</th>
<th></th>
</tr>
</thead>
<tbody>
{ for playlists.iter().map(|p| {
let id = p.id;
let refresh = refresh_playlists.clone();
let viewing = viewing_playlist.clone();
html! {
<tr>
<td>
<a href="#" onclick={{
let viewing = viewing.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
let viewing = viewing.clone();
wasm_bindgen_futures::spawn_local(async move {
if let Ok(detail) = api::get_playlist(id).await {
viewing.set(Some(detail));
}
});
})
}}>{ &p.name }</a>
</td>
<td>{ p.track_count }</td>
<td class="text-sm text-muted">{ &p.created_at }</td>
<td>
<a href={api::export_m3u_url(id)}
class="btn btn-sm btn-secondary"
download="true">{ "M3U" }</a>
<button class="btn btn-sm btn-danger" onclick={{
let refresh = refresh.clone();
let viewing = viewing.clone();
Callback::from(move |_: MouseEvent| {
let refresh = refresh.clone();
let viewing = viewing.clone();
wasm_bindgen_futures::spawn_local(async move {
let _ = api::delete_playlist(id).await;
viewing.set(None);
refresh.emit(());
});
})
}}>{ "Delete" }</button>
</td>
</tr>
}
})}
</tbody>
</table>
}
}
} else {
html! { <p class="loading">{ "Loading..." }</p> }
};
// Viewing a saved playlist
let viewing_section = if let Some(ref detail) = *viewing_playlist {
html! {
<div class="card">
<h3>{ &detail.playlist.name }</h3>
if let Some(ref desc) = detail.playlist.description {
<p class="text-muted">{ desc }</p>
}
<table>
<thead>
<tr>
<th>{ "#" }</th>
<th>{ "Title" }</th>
<th>{ "Artist" }</th>
<th>{ "Album" }</th>
</tr>
</thead>
<tbody>
{ for detail.tracks.iter().enumerate().map(|(i, t)| html! {
<tr>
<td>{ i + 1 }</td>
<td>{ t.title.as_deref().unwrap_or("Unknown") }</td>
<td>{ t.artist.as_deref().unwrap_or("Unknown") }</td>
<td class="text-muted">{ t.album.as_deref().unwrap_or("") }</td>
</tr>
})}
</tbody>
</table>
</div>
}
} else {
html! {}
};
html! {
<div>
<div class="page-header">
<h2>{ "Playlists" }</h2>
</div>
if let Some(ref msg) = *message {
<div class="card" style="border-color: var(--success);">{ msg }</div>
}
if let Some(ref err) = *error {
<div class="card error">{ err }</div>
}
<div class="card">
<h3>{ "Generate Playlist" }</h3>
<p class="text-muted text-sm">{ "Build a playlist from similar artists in your library using Last.fm data." }</p>
{ strategy_inputs }
<div class="form-group">
<label>{ format!("Count: {}", *count) }</label>
<input type="range" min="10" max="200" step="10"
value={count.to_string()}
oninput={{
let count = count.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
count.set(v);
}
})
}}
/>
</div>
<button
class="btn btn-primary"
onclick={on_generate}
disabled={*loading}
>
{ if *loading { "Generating..." } else { "Generate" } }
</button>
</div>
{ track_list }
<div class="card">
<h3>{ "Saved Playlists" }</h3>
{ saved_list }
</div>
{ viewing_section }
</div>
}
}

View File

@@ -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<String>,
#[serde(default)]
pub genres: Vec<String>,
#[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<SmartRulesInput>,
}
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<String>,
pub added_within_days: Option<u32>,
pub year_range: Option<(i32, i32)>,
#[serde(default)]
pub artists: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct GeneratedPlaylist {
pub tracks: Vec<GeneratedTrack>,
pub strategy: String,
#[serde(default)]
pub resolved_seeds: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct GeneratedTrack {
pub track_id: i32,
pub file_path: String,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub score: f64,
pub duration: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct PlaylistSummary {
pub id: i32,
pub name: String,
pub description: Option<String>,
pub track_count: u64,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct PlaylistDetail {
pub playlist: SavedPlaylist,
pub tracks: Vec<Track>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct SavedPlaylist {
pub id: i32,
pub name: String,
pub description: Option<String>,
}
// --- Config ---
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View File

@@ -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;