292 lines
9.0 KiB
Rust
292 lines
9.0 KiB
Rust
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<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())
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct AddTrackRequest {
|
|
track_id: i32,
|
|
}
|
|
|
|
/// POST /api/playlists/{id}/tracks — add a track to playlist.
|
|
async fn add_track(
|
|
state: web::Data<AppState>,
|
|
session: Session,
|
|
path: web::Path<i32>,
|
|
body: web::Json<AddTrackRequest>,
|
|
) -> Result<HttpResponse, ApiError> {
|
|
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<i32>,
|
|
}
|
|
|
|
/// PUT /api/playlists/{id}/tracks — reorder tracks in playlist.
|
|
async fn reorder_tracks(
|
|
state: web::Data<AppState>,
|
|
session: Session,
|
|
path: web::Path<i32>,
|
|
body: web::Json<ReorderTracksRequest>,
|
|
) -> Result<HttpResponse, ApiError> {
|
|
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<AppState>,
|
|
session: Session,
|
|
path: web::Path<(i32, i32)>,
|
|
) -> Result<HttpResponse, ApiError> {
|
|
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<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))
|
|
}
|