2026-03-18 02:21:00 +00:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
use axum::{
|
|
|
|
|
extract::{Path, Query, State},
|
|
|
|
|
http::StatusCode,
|
|
|
|
|
response::{IntoResponse, Json},
|
|
|
|
|
};
|
|
|
|
|
use serde::Deserialize;
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
use crate::db;
|
|
|
|
|
use super::AppState;
|
|
|
|
|
|
|
|
|
|
type S = Arc<AppState>;
|
|
|
|
|
|
|
|
|
|
// --- Stats ---
|
|
|
|
|
|
|
|
|
|
pub async fn stats(State(state): State<S>) -> impl IntoResponse {
|
|
|
|
|
match db::get_stats(&state.pool).await {
|
|
|
|
|
Ok(stats) => (StatusCode::OK, Json(serde_json::to_value(stats).unwrap())).into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Queue ---
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct QueueQuery {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub status: Option<String>,
|
|
|
|
|
#[serde(default = "default_limit")]
|
|
|
|
|
pub limit: i64,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub offset: i64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn default_limit() -> i64 {
|
|
|
|
|
50
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn list_queue(State(state): State<S>, Query(q): Query<QueueQuery>) -> impl IntoResponse {
|
|
|
|
|
match db::list_pending(&state.pool, q.status.as_deref(), q.limit, q.offset).await {
|
|
|
|
|
Ok(items) => (StatusCode::OK, Json(serde_json::to_value(items).unwrap())).into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get_queue_item(State(state): State<S>, Path(id): Path<Uuid>) -> impl IntoResponse {
|
|
|
|
|
match db::get_pending(&state.pool, id).await {
|
|
|
|
|
Ok(Some(item)) => (StatusCode::OK, Json(serde_json::to_value(item).unwrap())).into_response(),
|
|
|
|
|
Ok(None) => error_response(StatusCode::NOT_FOUND, "not found"),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn delete_queue_item(State(state): State<S>, Path(id): Path<Uuid>) -> impl IntoResponse {
|
|
|
|
|
match db::delete_pending(&state.pool, id).await {
|
|
|
|
|
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Ok(false) => error_response(StatusCode::NOT_FOUND, "not found"),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn approve_queue_item(State(state): State<S>, Path(id): Path<Uuid>) -> impl IntoResponse {
|
|
|
|
|
// Get pending track, move file, finalize in DB
|
|
|
|
|
let pt = match db::get_pending(&state.pool, id).await {
|
|
|
|
|
Ok(Some(pt)) => pt,
|
|
|
|
|
Ok(None) => return error_response(StatusCode::NOT_FOUND, "not found"),
|
|
|
|
|
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let artist = pt.norm_artist.as_deref().unwrap_or("Unknown Artist");
|
|
|
|
|
let album = pt.norm_album.as_deref().unwrap_or("Unknown Album");
|
|
|
|
|
let title = pt.norm_title.as_deref().unwrap_or("Unknown Title");
|
|
|
|
|
let source = std::path::Path::new(&pt.inbox_path);
|
|
|
|
|
let ext = source.extension().and_then(|e| e.to_str()).unwrap_or("flac");
|
|
|
|
|
let track_num = pt.norm_track_number.unwrap_or(0);
|
|
|
|
|
|
|
|
|
|
let filename = if track_num > 0 {
|
|
|
|
|
format!("{:02} - {}.{}", track_num, sanitize_filename(title), ext)
|
|
|
|
|
} else {
|
|
|
|
|
format!("{}.{}", sanitize_filename(title), ext)
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-18 04:05:47 +00:00
|
|
|
let artist_dir = sanitize_filename(artist);
|
|
|
|
|
let album_dir = sanitize_filename(album);
|
|
|
|
|
let dest = state.config.storage_dir.join(&artist_dir).join(&album_dir).join(&filename);
|
|
|
|
|
|
2026-03-19 00:55:49 +00:00
|
|
|
use crate::ingest::mover::MoveOutcome;
|
|
|
|
|
let (storage_path, was_merged) = if dest.exists() && !source.exists() {
|
2026-03-18 04:05:47 +00:00
|
|
|
// File already moved (e.g. auto-approved earlier but DB not finalized)
|
2026-03-19 00:55:49 +00:00
|
|
|
(dest.to_string_lossy().to_string(), false)
|
2026-03-18 04:05:47 +00:00
|
|
|
} else {
|
|
|
|
|
match crate::ingest::mover::move_to_storage(
|
|
|
|
|
&state.config.storage_dir, artist, album, &filename, source,
|
|
|
|
|
).await {
|
2026-03-19 00:55:49 +00:00
|
|
|
Ok(MoveOutcome::Moved(p)) => (p.to_string_lossy().to_string(), false),
|
|
|
|
|
Ok(MoveOutcome::Merged(p)) => (p.to_string_lossy().to_string(), true),
|
2026-03-18 04:05:47 +00:00
|
|
|
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
2026-03-18 02:21:00 +00:00
|
|
|
}
|
2026-03-18 04:05:47 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match db::approve_and_finalize(&state.pool, id, &storage_path).await {
|
2026-03-19 00:55:49 +00:00
|
|
|
Ok(track_id) => {
|
|
|
|
|
if was_merged {
|
|
|
|
|
let _ = db::update_pending_status(&state.pool, id, "merged", None).await;
|
|
|
|
|
}
|
|
|
|
|
(StatusCode::OK, Json(serde_json::json!({"track_id": track_id}))).into_response()
|
|
|
|
|
}
|
2026-03-18 02:21:00 +00:00
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn reject_queue_item(State(state): State<S>, Path(id): Path<Uuid>) -> impl IntoResponse {
|
|
|
|
|
match db::update_pending_status(&state.pool, id, "rejected", None).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct UpdateQueueItem {
|
|
|
|
|
pub norm_title: Option<String>,
|
|
|
|
|
pub norm_artist: Option<String>,
|
|
|
|
|
pub norm_album: Option<String>,
|
|
|
|
|
pub norm_year: Option<i32>,
|
|
|
|
|
pub norm_track_number: Option<i32>,
|
|
|
|
|
pub norm_genre: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub featured_artists: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn update_queue_item(
|
|
|
|
|
State(state): State<S>,
|
|
|
|
|
Path(id): Path<Uuid>,
|
|
|
|
|
Json(body): Json<UpdateQueueItem>,
|
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
|
let norm = db::NormalizedFields {
|
|
|
|
|
title: body.norm_title,
|
|
|
|
|
artist: body.norm_artist,
|
|
|
|
|
album: body.norm_album,
|
|
|
|
|
year: body.norm_year,
|
|
|
|
|
track_number: body.norm_track_number,
|
|
|
|
|
genre: body.norm_genre,
|
|
|
|
|
featured_artists: body.featured_artists,
|
2026-03-19 13:24:48 +00:00
|
|
|
release_type: None,
|
2026-03-18 02:21:00 +00:00
|
|
|
confidence: Some(1.0), // manual edit = full confidence
|
|
|
|
|
notes: Some("Manually edited".to_owned()),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match db::update_pending_normalized(&state.pool, id, "review", &norm, None).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 04:05:47 +00:00
|
|
|
// --- Retry ---
|
|
|
|
|
|
|
|
|
|
pub async fn retry_queue_item(State(state): State<S>, Path(id): Path<Uuid>) -> impl IntoResponse {
|
|
|
|
|
match db::update_pending_status(&state.pool, id, "pending", None).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Batch operations ---
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct BatchIds {
|
|
|
|
|
pub ids: Vec<Uuid>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn batch_approve(State(state): State<S>, Json(body): Json<BatchIds>) -> impl IntoResponse {
|
|
|
|
|
let mut ok = 0u32;
|
|
|
|
|
let mut errors = Vec::new();
|
|
|
|
|
for id in &body.ids {
|
|
|
|
|
let pt = match db::get_pending(&state.pool, *id).await {
|
|
|
|
|
Ok(Some(pt)) => pt,
|
|
|
|
|
Ok(None) => { errors.push(format!("{}: not found", id)); continue; }
|
|
|
|
|
Err(e) => { errors.push(format!("{}: {}", id, e)); continue; }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let artist = pt.norm_artist.as_deref().unwrap_or("Unknown Artist");
|
|
|
|
|
let album = pt.norm_album.as_deref().unwrap_or("Unknown Album");
|
|
|
|
|
let title = pt.norm_title.as_deref().unwrap_or("Unknown Title");
|
|
|
|
|
let source = std::path::Path::new(&pt.inbox_path);
|
|
|
|
|
let ext = source.extension().and_then(|e| e.to_str()).unwrap_or("flac");
|
|
|
|
|
let track_num = pt.norm_track_number.unwrap_or(0);
|
|
|
|
|
|
|
|
|
|
let filename = if track_num > 0 {
|
|
|
|
|
format!("{:02} - {}.{}", track_num, sanitize_filename(title), ext)
|
|
|
|
|
} else {
|
|
|
|
|
format!("{}.{}", sanitize_filename(title), ext)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let artist_dir = sanitize_filename(artist);
|
|
|
|
|
let album_dir = sanitize_filename(album);
|
|
|
|
|
let dest = state.config.storage_dir.join(&artist_dir).join(&album_dir).join(&filename);
|
|
|
|
|
|
2026-03-19 00:55:49 +00:00
|
|
|
use crate::ingest::mover::MoveOutcome;
|
|
|
|
|
let (rel_path, was_merged) = if dest.exists() && !source.exists() {
|
|
|
|
|
(dest.to_string_lossy().to_string(), false)
|
2026-03-18 04:05:47 +00:00
|
|
|
} else {
|
|
|
|
|
match crate::ingest::mover::move_to_storage(
|
|
|
|
|
&state.config.storage_dir, artist, album, &filename, source,
|
|
|
|
|
).await {
|
2026-03-19 00:55:49 +00:00
|
|
|
Ok(MoveOutcome::Moved(p)) => (p.to_string_lossy().to_string(), false),
|
|
|
|
|
Ok(MoveOutcome::Merged(p)) => (p.to_string_lossy().to_string(), true),
|
2026-03-18 04:05:47 +00:00
|
|
|
Err(e) => { errors.push(format!("{}: {}", id, e)); continue; }
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match db::approve_and_finalize(&state.pool, *id, &rel_path).await {
|
2026-03-19 00:55:49 +00:00
|
|
|
Ok(_) => {
|
|
|
|
|
if was_merged {
|
|
|
|
|
let _ = db::update_pending_status(&state.pool, *id, "merged", None).await;
|
|
|
|
|
}
|
|
|
|
|
ok += 1;
|
|
|
|
|
}
|
2026-03-18 04:05:47 +00:00
|
|
|
Err(e) => errors.push(format!("{}: {}", id, e)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
(StatusCode::OK, Json(serde_json::json!({"approved": ok, "errors": errors}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn batch_reject(State(state): State<S>, Json(body): Json<BatchIds>) -> impl IntoResponse {
|
|
|
|
|
let mut ok = 0u32;
|
|
|
|
|
for id in &body.ids {
|
|
|
|
|
if db::update_pending_status(&state.pool, *id, "rejected", None).await.is_ok() {
|
|
|
|
|
ok += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
(StatusCode::OK, Json(serde_json::json!({"rejected": ok}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn batch_retry(State(state): State<S>, Json(body): Json<BatchIds>) -> impl IntoResponse {
|
|
|
|
|
let mut ok = 0u32;
|
|
|
|
|
for id in &body.ids {
|
|
|
|
|
if db::update_pending_status(&state.pool, *id, "pending", None).await.is_ok() {
|
|
|
|
|
ok += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
(StatusCode::OK, Json(serde_json::json!({"retried": ok}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn batch_delete(State(state): State<S>, Json(body): Json<BatchIds>) -> impl IntoResponse {
|
|
|
|
|
let mut ok = 0u32;
|
|
|
|
|
for id in &body.ids {
|
|
|
|
|
if db::delete_pending(&state.pool, *id).await.unwrap_or(false) {
|
|
|
|
|
ok += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
(StatusCode::OK, Json(serde_json::json!({"deleted": ok}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 02:21:00 +00:00
|
|
|
// --- Artists ---
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct SearchArtistsQuery {
|
|
|
|
|
pub q: String,
|
|
|
|
|
#[serde(default = "default_search_limit")]
|
|
|
|
|
pub limit: i32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn default_search_limit() -> i32 {
|
|
|
|
|
10
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn search_artists(State(state): State<S>, Query(q): Query<SearchArtistsQuery>) -> impl IntoResponse {
|
|
|
|
|
if q.q.is_empty() {
|
|
|
|
|
return (StatusCode::OK, Json(serde_json::json!([]))).into_response();
|
|
|
|
|
}
|
|
|
|
|
match db::find_similar_artists(&state.pool, &q.q, q.limit).await {
|
|
|
|
|
Ok(artists) => (StatusCode::OK, Json(serde_json::to_value(artists).unwrap())).into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn list_artists(State(state): State<S>) -> impl IntoResponse {
|
|
|
|
|
match db::list_artists_all(&state.pool).await {
|
|
|
|
|
Ok(artists) => (StatusCode::OK, Json(serde_json::to_value(artists).unwrap())).into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct UpdateArtistBody {
|
|
|
|
|
pub name: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn update_artist(
|
|
|
|
|
State(state): State<S>,
|
|
|
|
|
Path(id): Path<i64>,
|
|
|
|
|
Json(body): Json<UpdateArtistBody>,
|
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
|
match db::update_artist_name(&state.pool, id, &body.name).await {
|
|
|
|
|
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Ok(false) => error_response(StatusCode::NOT_FOUND, "not found"),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Albums ---
|
|
|
|
|
|
|
|
|
|
pub async fn list_albums(State(state): State<S>, Path(artist_id): Path<i64>) -> impl IntoResponse {
|
|
|
|
|
match db::list_albums_by_artist(&state.pool, artist_id).await {
|
|
|
|
|
Ok(albums) => (StatusCode::OK, Json(serde_json::to_value(albums).unwrap())).into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct UpdateAlbumBody {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub year: Option<i32>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn update_album(
|
|
|
|
|
State(state): State<S>,
|
|
|
|
|
Path(id): Path<i64>,
|
|
|
|
|
Json(body): Json<UpdateAlbumBody>,
|
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
|
match db::update_album(&state.pool, id, &body.name, body.year).await {
|
|
|
|
|
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Ok(false) => error_response(StatusCode::NOT_FOUND, "not found"),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 00:55:49 +00:00
|
|
|
// --- Merges ---
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct CreateMergeBody {
|
|
|
|
|
pub artist_ids: Vec<i64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn create_merge(State(state): State<S>, Json(body): Json<CreateMergeBody>) -> impl IntoResponse {
|
|
|
|
|
if body.artist_ids.len() < 2 {
|
|
|
|
|
return error_response(StatusCode::BAD_REQUEST, "need at least 2 artists to merge");
|
|
|
|
|
}
|
|
|
|
|
match db::insert_artist_merge(&state.pool, &body.artist_ids).await {
|
|
|
|
|
Ok(id) => (StatusCode::OK, Json(serde_json::json!({"id": id}))).into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn list_merges(State(state): State<S>) -> impl IntoResponse {
|
|
|
|
|
match db::list_artist_merges(&state.pool).await {
|
|
|
|
|
Ok(items) => (StatusCode::OK, Json(serde_json::to_value(items).unwrap())).into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get_merge(State(state): State<S>, Path(id): Path<Uuid>) -> impl IntoResponse {
|
|
|
|
|
let merge = match db::get_artist_merge(&state.pool, id).await {
|
|
|
|
|
Ok(Some(m)) => m,
|
|
|
|
|
Ok(None) => return error_response(StatusCode::NOT_FOUND, "not found"),
|
|
|
|
|
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let source_ids: Vec<i64> = serde_json::from_str(&merge.source_artist_ids).unwrap_or_default();
|
|
|
|
|
let artists = match db::get_artists_full_data(&state.pool, &source_ids).await {
|
|
|
|
|
Ok(a) => a,
|
|
|
|
|
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let proposal: Option<serde_json::Value> = merge.proposal.as_deref()
|
|
|
|
|
.and_then(|p| serde_json::from_str(p).ok());
|
|
|
|
|
|
|
|
|
|
(StatusCode::OK, Json(serde_json::json!({
|
|
|
|
|
"merge": {
|
|
|
|
|
"id": merge.id,
|
|
|
|
|
"status": merge.status,
|
|
|
|
|
"source_artist_ids": source_ids,
|
|
|
|
|
"llm_notes": merge.llm_notes,
|
|
|
|
|
"error_message": merge.error_message,
|
|
|
|
|
"created_at": merge.created_at,
|
|
|
|
|
"updated_at": merge.updated_at,
|
|
|
|
|
},
|
|
|
|
|
"artists": artists,
|
|
|
|
|
"proposal": proposal,
|
|
|
|
|
}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct UpdateMergeBody {
|
|
|
|
|
pub proposal: serde_json::Value,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn update_merge(
|
|
|
|
|
State(state): State<S>,
|
|
|
|
|
Path(id): Path<Uuid>,
|
|
|
|
|
Json(body): Json<UpdateMergeBody>,
|
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
|
let notes = body.proposal.get("notes")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
.to_owned();
|
|
|
|
|
let proposal_json = match serde_json::to_string(&body.proposal) {
|
|
|
|
|
Ok(s) => s,
|
|
|
|
|
Err(e) => return error_response(StatusCode::BAD_REQUEST, &e.to_string()),
|
|
|
|
|
};
|
|
|
|
|
match db::update_merge_proposal(&state.pool, id, &proposal_json, ¬es).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn approve_merge(State(state): State<S>, Path(id): Path<Uuid>) -> impl IntoResponse {
|
|
|
|
|
match crate::merge::execute_merge(&state, id).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
let msg = e.to_string();
|
|
|
|
|
let _ = db::update_merge_status(&state.pool, id, "error", Some(&msg)).await;
|
|
|
|
|
error_response(StatusCode::INTERNAL_SERVER_ERROR, &msg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn reject_merge(State(state): State<S>, Path(id): Path<Uuid>) -> impl IntoResponse {
|
|
|
|
|
match db::update_merge_status(&state.pool, id, "rejected", None).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn retry_merge(State(state): State<S>, Path(id): Path<Uuid>) -> impl IntoResponse {
|
|
|
|
|
match db::update_merge_status(&state.pool, id, "pending", None).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 02:09:04 +00:00
|
|
|
// --- Library search ---
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct LibraryQuery {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub q: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub artist: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub album: String,
|
|
|
|
|
#[serde(default = "default_lib_limit")]
|
|
|
|
|
pub limit: i64,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub offset: i64,
|
|
|
|
|
}
|
|
|
|
|
fn default_lib_limit() -> i64 { 50 }
|
|
|
|
|
|
|
|
|
|
pub async fn library_tracks(State(state): State<S>, Query(q): Query<LibraryQuery>) -> impl IntoResponse {
|
|
|
|
|
let (tracks, total) = tokio::join!(
|
|
|
|
|
db::search_tracks(&state.pool, &q.q, &q.artist, &q.album, q.limit, q.offset),
|
|
|
|
|
db::count_tracks(&state.pool, &q.q, &q.artist, &q.album),
|
|
|
|
|
);
|
|
|
|
|
match (tracks, total) {
|
|
|
|
|
(Ok(rows), Ok(n)) => (StatusCode::OK, Json(serde_json::json!({"total": n, "items": rows}))).into_response(),
|
|
|
|
|
(Err(e), _) | (_, Err(e)) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn library_albums(State(state): State<S>, Query(q): Query<LibraryQuery>) -> impl IntoResponse {
|
|
|
|
|
let (albums, total) = tokio::join!(
|
|
|
|
|
db::search_albums(&state.pool, &q.q, &q.artist, q.limit, q.offset),
|
|
|
|
|
db::count_albums(&state.pool, &q.q, &q.artist),
|
|
|
|
|
);
|
|
|
|
|
match (albums, total) {
|
|
|
|
|
(Ok(rows), Ok(n)) => (StatusCode::OK, Json(serde_json::json!({"total": n, "items": rows}))).into_response(),
|
|
|
|
|
(Err(e), _) | (_, Err(e)) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn library_artists(State(state): State<S>, Query(q): Query<LibraryQuery>) -> impl IntoResponse {
|
|
|
|
|
let (artists, total) = tokio::join!(
|
|
|
|
|
db::search_artists_lib(&state.pool, &q.q, q.limit, q.offset),
|
|
|
|
|
db::count_artists_lib(&state.pool, &q.q),
|
|
|
|
|
);
|
|
|
|
|
match (artists, total) {
|
|
|
|
|
(Ok(rows), Ok(n)) => (StatusCode::OK, Json(serde_json::json!({"total": n, "items": rows}))).into_response(),
|
|
|
|
|
(Err(e), _) | (_, Err(e)) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 02:36:27 +00:00
|
|
|
// --- Track / Album detail & edit ---
|
|
|
|
|
|
|
|
|
|
pub async fn get_track(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
|
|
|
|
match db::get_track_full(&state.pool, id).await {
|
|
|
|
|
Ok(Some(t)) => (StatusCode::OK, Json(serde_json::to_value(t).unwrap())).into_response(),
|
|
|
|
|
Ok(None) => error_response(StatusCode::NOT_FOUND, "not found"),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn update_track(
|
|
|
|
|
State(state): State<S>,
|
|
|
|
|
Path(id): Path<i64>,
|
|
|
|
|
Json(body): Json<db::TrackUpdateFields>,
|
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
|
match db::update_track_metadata(&state.pool, id, &body).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get_album_full(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
|
|
|
|
match db::get_album_details(&state.pool, id).await {
|
|
|
|
|
Ok(Some(a)) => (StatusCode::OK, Json(serde_json::to_value(a).unwrap())).into_response(),
|
|
|
|
|
Ok(None) => error_response(StatusCode::NOT_FOUND, "not found"),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct AlbumUpdateBody {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub year: Option<i32>,
|
|
|
|
|
pub artist_id: i64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn update_album_full(
|
|
|
|
|
State(state): State<S>,
|
|
|
|
|
Path(id): Path<i64>,
|
|
|
|
|
Json(body): Json<AlbumUpdateBody>,
|
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
|
match db::update_album_full(&state.pool, id, &body.name, body.year, body.artist_id).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 15:28:25 +00:00
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct SetGenreBody { pub genre: String }
|
|
|
|
|
|
|
|
|
|
pub async fn set_album_tracks_genre(
|
|
|
|
|
State(state): State<S>,
|
|
|
|
|
Path(id): Path<i64>,
|
|
|
|
|
Json(body): Json<SetGenreBody>,
|
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
|
match db::set_album_tracks_genre(&state.pool, id, &body.genre).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 02:36:27 +00:00
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct ReorderBody {
|
|
|
|
|
pub orders: Vec<(i64, i32)>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn reorder_album_tracks(
|
|
|
|
|
State(state): State<S>,
|
|
|
|
|
Path(_id): Path<i64>,
|
|
|
|
|
Json(body): Json<ReorderBody>,
|
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
|
match db::reorder_tracks(&state.pool, &body.orders).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 15:28:25 +00:00
|
|
|
/// Cover by artist+album name — used for queue items that may not have an album_id yet.
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct CoverByNameQuery {
|
|
|
|
|
#[serde(default)] pub artist: String,
|
|
|
|
|
#[serde(default)] pub name: String,
|
|
|
|
|
}
|
|
|
|
|
pub async fn album_cover_by_name(State(state): State<S>, Query(q): Query<CoverByNameQuery>) -> impl IntoResponse {
|
|
|
|
|
let album_id = match db::find_album_id(&state.pool, &q.artist, &q.name).await {
|
|
|
|
|
Ok(Some(id)) => id,
|
|
|
|
|
_ => return StatusCode::NOT_FOUND.into_response(),
|
|
|
|
|
};
|
|
|
|
|
album_cover_by_id(&state, album_id).await
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 02:36:27 +00:00
|
|
|
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
2026-03-19 15:28:25 +00:00
|
|
|
album_cover_by_id(&state, id).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn album_cover_by_id(state: &super::AppState, id: i64) -> axum::response::Response {
|
|
|
|
|
// 1. Try album_images table
|
|
|
|
|
if let Ok(Some((file_path, mime_type))) = db::get_album_cover(&state.pool, id).await {
|
|
|
|
|
if let Ok(bytes) = tokio::fs::read(&file_path).await {
|
|
|
|
|
return ([(axum::http::header::CONTENT_TYPE, mime_type)], bytes).into_response();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Fallback: extract embedded cover from first track in album
|
|
|
|
|
if let Ok(Some(track_path)) = db::get_album_first_track_path(&state.pool, id).await {
|
|
|
|
|
let path = std::path::PathBuf::from(track_path);
|
|
|
|
|
if path.exists() {
|
|
|
|
|
let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&path)).await;
|
|
|
|
|
if let Ok(Some((bytes, mime))) = result {
|
|
|
|
|
return ([(axum::http::header::CONTENT_TYPE, mime)], bytes).into_response();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
StatusCode::NOT_FOUND.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn extract_embedded_cover(path: &std::path::Path) -> Option<(Vec<u8>, String)> {
|
|
|
|
|
use symphonia::core::{
|
|
|
|
|
formats::FormatOptions,
|
|
|
|
|
io::MediaSourceStream,
|
|
|
|
|
meta::MetadataOptions,
|
|
|
|
|
probe::Hint,
|
2026-03-19 02:36:27 +00:00
|
|
|
};
|
2026-03-19 15:28:25 +00:00
|
|
|
|
|
|
|
|
let file = std::fs::File::open(path).ok()?;
|
|
|
|
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
|
|
|
|
|
|
|
|
|
let mut hint = Hint::new();
|
|
|
|
|
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
|
|
|
|
hint.with_extension(ext);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut probed = symphonia::default::get_probe()
|
|
|
|
|
.format(
|
|
|
|
|
&hint,
|
|
|
|
|
mss,
|
|
|
|
|
&FormatOptions { enable_gapless: false, ..Default::default() },
|
|
|
|
|
&MetadataOptions::default(),
|
|
|
|
|
)
|
|
|
|
|
.ok()?;
|
|
|
|
|
|
|
|
|
|
if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) {
|
|
|
|
|
if let Some(v) = rev.visuals().first() {
|
|
|
|
|
return Some((v.data.to_vec(), v.media_type.clone()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(rev) = probed.format.metadata().current() {
|
|
|
|
|
if let Some(v) = rev.visuals().first() {
|
|
|
|
|
return Some((v.data.to_vec(), v.media_type.clone()));
|
|
|
|
|
}
|
2026-03-19 02:36:27 +00:00
|
|
|
}
|
2026-03-19 15:28:25 +00:00
|
|
|
None
|
2026-03-19 02:36:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct AlbumSearchQuery {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub q: String,
|
|
|
|
|
pub artist_id: Option<i64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn search_albums_for_artist(State(state): State<S>, Query(q): Query<AlbumSearchQuery>) -> impl IntoResponse {
|
|
|
|
|
match db::search_albums_for_artist(&state.pool, &q.q, q.artist_id).await {
|
|
|
|
|
Ok(items) => (StatusCode::OK, Json(serde_json::to_value(
|
|
|
|
|
items.iter().map(|(id, name)| serde_json::json!({"id": id, "name": name})).collect::<Vec<_>>()
|
|
|
|
|
).unwrap())).into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:24:48 +00:00
|
|
|
// --- Artist full admin form ---
|
|
|
|
|
|
|
|
|
|
pub async fn get_artist_full(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
|
|
|
|
let artist = match db::get_artist_by_id(&state.pool, id).await {
|
|
|
|
|
Ok(Some(a)) => a,
|
|
|
|
|
Ok(None) => return error_response(StatusCode::NOT_FOUND, "not found"),
|
|
|
|
|
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
};
|
|
|
|
|
let (albums, appearances) = tokio::join!(
|
|
|
|
|
db::get_artist_albums(&state.pool, id),
|
|
|
|
|
db::get_artist_appearances(&state.pool, id),
|
|
|
|
|
);
|
|
|
|
|
// For each album, load tracks
|
|
|
|
|
let albums = match albums {
|
|
|
|
|
Ok(a) => a,
|
|
|
|
|
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
};
|
|
|
|
|
let mut albums_with_tracks = Vec::new();
|
|
|
|
|
for album in albums {
|
|
|
|
|
let tracks = db::get_album_tracks_admin(&state.pool, album.id).await.unwrap_or_default();
|
|
|
|
|
albums_with_tracks.push(serde_json::json!({
|
|
|
|
|
"id": album.id, "name": album.name, "year": album.year,
|
|
|
|
|
"release_type": album.release_type, "hidden": album.hidden,
|
|
|
|
|
"track_count": album.track_count, "tracks": tracks,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
(StatusCode::OK, Json(serde_json::json!({
|
|
|
|
|
"artist": artist,
|
|
|
|
|
"albums": albums_with_tracks,
|
|
|
|
|
"appearances": appearances.unwrap_or_default(),
|
|
|
|
|
}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct SetHiddenBody { pub hidden: bool }
|
|
|
|
|
|
|
|
|
|
pub async fn set_track_hidden(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<SetHiddenBody>) -> impl IntoResponse {
|
|
|
|
|
match db::set_track_hidden(&state.pool, id, b.hidden).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn set_album_hidden(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<SetHiddenBody>) -> impl IntoResponse {
|
|
|
|
|
match db::set_album_hidden(&state.pool, id, b.hidden).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn set_artist_hidden(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<SetHiddenBody>) -> impl IntoResponse {
|
|
|
|
|
match db::set_artist_hidden(&state.pool, id, b.hidden).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct SetReleaseTypeBody { pub release_type: String }
|
|
|
|
|
|
|
|
|
|
pub async fn set_album_release_type(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<SetReleaseTypeBody>) -> impl IntoResponse {
|
|
|
|
|
let valid = ["album","single","ep","compilation","live"];
|
|
|
|
|
if !valid.contains(&b.release_type.as_str()) {
|
|
|
|
|
return error_response(StatusCode::BAD_REQUEST, "invalid release_type");
|
|
|
|
|
}
|
|
|
|
|
match db::set_album_release_type(&state.pool, id, &b.release_type).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct RenameArtistBody { pub name: String }
|
|
|
|
|
|
|
|
|
|
pub async fn rename_artist_api(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<RenameArtistBody>) -> impl IntoResponse {
|
|
|
|
|
match db::rename_artist_name(&state.pool, id, &b.name).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct AddAppearanceBody { pub track_id: i64 }
|
|
|
|
|
|
|
|
|
|
pub async fn add_appearance(State(state): State<S>, Path(artist_id): Path<i64>, Json(b): Json<AddAppearanceBody>) -> impl IntoResponse {
|
|
|
|
|
match db::add_track_appearance(&state.pool, b.track_id, artist_id).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn remove_appearance(State(state): State<S>, Path((artist_id, track_id)): Path<(i64, i64)>) -> impl IntoResponse {
|
|
|
|
|
match db::remove_track_appearance(&state.pool, track_id, artist_id).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct SearchTracksQuery { #[serde(default)] pub q: String }
|
|
|
|
|
|
|
|
|
|
pub async fn search_tracks_feat(State(state): State<S>, Query(q): Query<SearchTracksQuery>) -> impl IntoResponse {
|
|
|
|
|
match db::search_tracks_for_feat(&state.pool, &q.q).await {
|
|
|
|
|
Ok(rows) => (StatusCode::OK, Json(serde_json::to_value(
|
|
|
|
|
rows.iter().map(|(id, title, artist)| serde_json::json!({"id": id, "title": title, "artist_name": artist})).collect::<Vec<_>>()
|
|
|
|
|
).unwrap())).into_response(),
|
|
|
|
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 02:21:00 +00:00
|
|
|
// --- Helpers ---
|
|
|
|
|
|
|
|
|
|
fn error_response(status: StatusCode, message: &str) -> axum::response::Response {
|
|
|
|
|
(status, Json(serde_json::json!({"error": message}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn sanitize_filename(name: &str) -> String {
|
|
|
|
|
name.chars()
|
|
|
|
|
.map(|c| match c {
|
|
|
|
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
|
|
|
|
_ => c,
|
|
|
|
|
})
|
|
|
|
|
.collect::<String>()
|
|
|
|
|
.trim()
|
|
|
|
|
.to_owned()
|
|
|
|
|
}
|