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

@@ -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,
}),
});

View File

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

View File

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

View File

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

View File

@@ -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)]

View File

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

View File

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

View File

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