Compare commits
1 Commits
9d6c0e31c1
...
ea6a6410f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea6a6410f3 |
@@ -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"
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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>
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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> },
|
||||
|
||||
466
frontend/src/pages/playlists.rs
Normal file
466
frontend/src/pages/playlists.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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<String, ApiError> {
|
||||
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<AppState>) {
|
||||
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");
|
||||
|
||||
@@ -40,10 +40,15 @@ pub fn spawn(state: web::Data<AppState>) {
|
||||
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");
|
||||
|
||||
@@ -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<AppState>,
|
||||
state: &web::Data<AppState>,
|
||||
release_group_mbid: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
// 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}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<String, ApiError> {
|
||||
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)]
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
232
src/routes/playlists.rs
Normal file
232
src/routes/playlists.rs
Normal file
@@ -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<AppState>,
|
||||
session: Session,
|
||||
body: web::Json<PlaylistRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<String>,
|
||||
track_ids: Vec<i32>,
|
||||
}
|
||||
|
||||
/// POST /api/playlists — save a generated playlist.
|
||||
async fn save_playlist(
|
||||
state: web::Data<AppState>,
|
||||
session: Session,
|
||||
body: web::Json<SavePlaylistRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<String>,
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<String>,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
/// PUT /api/playlists/{id} — update playlist name/description.
|
||||
async fn update_playlist(
|
||||
state: web::Data<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<i32>,
|
||||
body: web::Json<UpdatePlaylistRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<shanty_playlist::PlaylistTrack> = 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))
|
||||
}
|
||||
@@ -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<AppState>,
|
||||
session: Session,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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"})))
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ pub struct SchedulerInfo {
|
||||
pub next_pipeline: Option<chrono::NaiveDateTime>,
|
||||
/// When the next monitor check is scheduled (None if disabled).
|
||||
pub next_monitor: Option<chrono::NaiveDateTime>,
|
||||
/// 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 {
|
||||
|
||||
Reference in New Issue
Block a user