Added AI agent to manage metadata
This commit is contained in:
236
furumi-agent/src/web/api.rs
Normal file
236
furumi-agent/src/web/api.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
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)
|
||||
};
|
||||
|
||||
match crate::ingest::mover::move_to_storage(
|
||||
&state.config.storage_dir,
|
||||
artist,
|
||||
album,
|
||||
&filename,
|
||||
source,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(storage_path) => {
|
||||
let rel_path = storage_path.to_string_lossy().to_string();
|
||||
match db::approve_and_finalize(&state.pool, id, &rel_path).await {
|
||||
Ok(track_id) => (StatusCode::OK, Json(serde_json::json!({"track_id": track_id}))).into_response(),
|
||||
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
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,
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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()
|
||||
}
|
||||
Reference in New Issue
Block a user