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))) .service( web::resource("/playlists/{id}/tracks") .route(web::post().to(add_track)) .route(web::put().to(reorder_tracks)), ) .service( web::resource("/playlists/{id}/tracks/{track_id}") .route(web::delete().to(remove_track)), ); } /// 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()) } #[derive(Deserialize)] struct AddTrackRequest { track_id: i32, } /// POST /api/playlists/{id}/tracks — add a track to playlist. async fn add_track( 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(); queries::playlists::add_track(state.db.conn(), id, req.track_id).await?; Ok(HttpResponse::Ok().json(serde_json::json!({"ok": true}))) } #[derive(Deserialize)] struct ReorderTracksRequest { track_ids: Vec, } /// PUT /api/playlists/{id}/tracks — reorder tracks in playlist. async fn reorder_tracks( 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(); queries::playlists::reorder_tracks(state.db.conn(), id, req.track_ids).await?; Ok(HttpResponse::Ok().json(serde_json::json!({"ok": true}))) } /// DELETE /api/playlists/{id}/tracks/{track_id} — remove a track from playlist. async fn remove_track( state: web::Data, session: Session, path: web::Path<(i32, i32)>, ) -> Result { auth::require_auth(&session)?; let (id, track_id) = path.into_inner(); queries::playlists::remove_track(state.db.conn(), id, track_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)) }