Added AI agent to manage metadata

This commit is contained in:
2026-03-18 02:21:00 +00:00
parent 8a49a5013b
commit d5068aaa33
17 changed files with 3384 additions and 1 deletions

View File

@@ -0,0 +1,621 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Furumi Agent — Admin</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-base: #0a0c12;
--bg-panel: #111520;
--bg-card: #161d2e;
--bg-hover: #1e2740;
--bg-active: #252f4a;
--border: #1f2c45;
--accent: #7c6af7;
--accent-dim: #5a4fcf;
--text: #e2e8f0;
--text-muted: #64748b;
--text-dim: #94a3b8;
--success: #34d399;
--danger: #f87171;
--warning: #fbbf24;
}
html, body { height: 100%; overflow: hidden; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg-base);
color: var(--text);
display: flex;
flex-direction: column;
}
header {
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex;
align-items: center;
gap: 24px;
}
header h1 {
font-size: 16px;
font-weight: 600;
}
.stats {
display: flex;
gap: 16px;
margin-left: auto;
font-size: 13px;
color: var(--text-dim);
}
.stats .stat { display: flex; gap: 4px; align-items: center; }
.stats .stat-value { color: var(--text); font-weight: 600; }
nav {
display: flex;
gap: 4px;
}
nav button {
background: none;
border: none;
color: var(--text-muted);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-family: inherit;
}
nav button:hover { background: var(--bg-hover); color: var(--text); }
nav button.active { background: var(--bg-active); color: var(--accent); }
main {
flex: 1;
overflow-y: auto;
padding: 16px 24px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
text-align: left;
padding: 8px 12px;
color: var(--text-muted);
font-weight: 500;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--bg-base);
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
tr:hover td { background: var(--bg-hover); }
.status {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.status-pending { background: #1e293b; color: var(--text-muted); }
.status-processing { background: #1e1b4b; color: var(--accent); }
.status-review { background: #422006; color: var(--warning); }
.status-approved { background: #052e16; color: var(--success); }
.status-rejected { background: #450a0a; color: var(--danger); }
.status-error { background: #450a0a; color: var(--danger); }
.actions {
display: flex;
gap: 4px;
}
.btn {
border: none;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-family: inherit;
font-weight: 500;
}
.btn-approve { background: #052e16; color: var(--success); }
.btn-approve:hover { background: #065f46; }
.btn-reject { background: #450a0a; color: var(--danger); }
.btn-reject:hover { background: #7f1d1d; }
.btn-edit { background: var(--bg-active); color: var(--text-dim); }
.btn-edit:hover { background: var(--bg-hover); color: var(--text); }
.empty {
text-align: center;
padding: 48px;
color: var(--text-muted);
font-size: 14px;
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-overlay.visible { display: flex; }
.modal {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
min-width: 400px;
max-width: 600px;
}
.modal h2 { font-size: 16px; margin-bottom: 16px; }
.modal label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
margin-top: 12px;
}
.modal input, .modal textarea {
width: 100%;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
color: var(--text);
font-family: inherit;
font-size: 13px;
}
.modal textarea { resize: vertical; min-height: 60px; }
.modal-actions {
margin-top: 20px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
.modal-actions .btn {
padding: 8px 16px;
}
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-dim); }
.btn-cancel { background: var(--bg-card); color: var(--text-dim); }
.btn-cancel:hover { background: var(--bg-hover); }
/* Detail fields in modal */
.detail-row {
display: flex;
gap: 12px;
margin-top: 8px;
}
.detail-row .field { flex: 1; }
.raw-value {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* Featured artists tags */
.feat-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
min-height: 28px;
}
.feat-tag {
display: flex;
align-items: center;
gap: 4px;
background: var(--bg-active);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
}
.feat-tag .remove {
cursor: pointer;
color: var(--text-muted);
font-size: 14px;
line-height: 1;
}
.feat-tag .remove:hover { color: var(--danger); }
/* Artist search dropdown */
.artist-search-wrap {
position: relative;
margin-top: 6px;
}
.artist-search-wrap input {
width: 100%;
}
.artist-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0 0 6px 6px;
max-height: 160px;
overflow-y: auto;
z-index: 10;
display: none;
}
.artist-dropdown.open { display: block; }
.artist-option {
padding: 6px 10px;
cursor: pointer;
font-size: 13px;
display: flex;
justify-content: space-between;
}
.artist-option:hover { background: var(--bg-hover); }
.artist-option .sim {
color: var(--text-muted);
font-size: 11px;
}
</style>
</head>
<body>
<header>
<h1>Furumi Agent</h1>
<nav>
<button class="active" onclick="showTab('queue')">Queue</button>
<button onclick="showTab('artists')">Artists</button>
</nav>
<div class="stats" id="statsBar"></div>
</header>
<main id="content"></main>
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this)closeModal()">
<div class="modal" id="modal"></div>
</div>
<script>
const API = '/api';
let currentTab = 'queue';
let currentFilter = null;
async function api(path, opts) {
const r = await fetch(API + path, opts);
if (r.status === 204) return null;
const text = await r.text();
if (!text) return null;
try { return JSON.parse(text); }
catch(e) { console.error('API parse error:', r.status, text); return null; }
}
async function loadStats() {
const s = await api('/stats');
document.getElementById('statsBar').innerHTML = `
<div class="stat">Tracks: <span class="stat-value">${s.total_tracks}</span></div>
<div class="stat">Artists: <span class="stat-value">${s.total_artists}</span></div>
<div class="stat">Albums: <span class="stat-value">${s.total_albums}</span></div>
<div class="stat">Pending: <span class="stat-value">${s.pending_count}</span></div>
<div class="stat">Review: <span class="stat-value">${s.review_count}</span></div>
<div class="stat">Errors: <span class="stat-value">${s.error_count}</span></div>
`;
}
function showTab(tab) {
currentTab = tab;
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
if (tab === 'queue') loadQueue();
else if (tab === 'artists') loadArtists();
}
async function loadQueue(status) {
currentFilter = status;
const qs = status ? `?status=${status}` : '';
const items = await api(`/queue${qs}`);
const el = document.getElementById('content');
if (!items.length) {
el.innerHTML = '<div class="empty">No items in queue</div>';
return;
}
let html = `
<div style="margin-bottom:12px;display:flex;gap:4px">
<button class="btn ${!status?'btn-primary':'btn-edit'}" onclick="loadQueue()">All</button>
<button class="btn ${status==='review'?'btn-primary':'btn-edit'}" onclick="loadQueue('review')">Review</button>
<button class="btn ${status==='pending'?'btn-primary':'btn-edit'}" onclick="loadQueue('pending')">Pending</button>
<button class="btn ${status==='approved'?'btn-primary':'btn-edit'}" onclick="loadQueue('approved')">Approved</button>
<button class="btn ${status==='error'?'btn-primary':'btn-edit'}" onclick="loadQueue('error')">Errors</button>
</div>
<table>
<tr><th>Status</th><th>Raw Artist</th><th>Raw Title</th><th>Norm Artist</th><th>Norm Title</th><th>Norm Album</th><th>Conf</th><th>Actions</th></tr>
`;
for (const it of items) {
const conf = it.confidence != null ? it.confidence.toFixed(2) : '-';
html += `<tr>
<td><span class="status status-${it.status}">${it.status}</span></td>
<td title="${esc(it.raw_artist)}">${esc(it.raw_artist || '-')}</td>
<td title="${esc(it.raw_title)}">${esc(it.raw_title || '-')}</td>
<td title="${esc(it.norm_artist)}">${esc(it.norm_artist || '-')}</td>
<td title="${esc(it.norm_title)}">${esc(it.norm_title || '-')}</td>
<td title="${esc(it.norm_album)}">${esc(it.norm_album || '-')}</td>
<td>${conf}</td>
<td class="actions">
${it.status === 'review' ? `<button class="btn btn-approve" onclick="approveItem('${it.id}')">Approve</button>` : ''}
${it.status === 'review' ? `<button class="btn btn-reject" onclick="rejectItem('${it.id}')">Reject</button>` : ''}
<button class="btn btn-edit" onclick="editItem('${it.id}')">Edit</button>
</td>
</tr>`;
}
html += '</table>';
el.innerHTML = html;
}
async function loadArtists() {
const artists = await api('/artists');
const el = document.getElementById('content');
if (!artists.length) {
el.innerHTML = '<div class="empty">No artists yet</div>';
return;
}
let html = '<table><tr><th>ID</th><th>Name</th><th>Actions</th></tr>';
for (const a of artists) {
html += `<tr>
<td>${a.id}</td>
<td>${esc(a.name)}</td>
<td class="actions">
<button class="btn btn-edit" onclick="editArtist(${a.id}, '${esc(a.name)}')">Rename</button>
</td>
</tr>`;
}
html += '</table>';
el.innerHTML = html;
}
async function approveItem(id) {
await api(`/queue/${id}/approve`, { method: 'POST' });
loadStats();
loadQueue(currentFilter);
}
async function rejectItem(id) {
await api(`/queue/${id}/reject`, { method: 'POST' });
loadStats();
loadQueue(currentFilter);
}
let editFeatured = [];
let searchTimer = null;
async function editItem(id) {
const item = await api(`/queue/${id}`);
if (!item) return;
// Parse featured artists from JSON string
editFeatured = [];
if (item.norm_featured_artists) {
try { editFeatured = JSON.parse(item.norm_featured_artists); } catch(e) {}
}
document.getElementById('modal').innerHTML = `
<h2>Edit Metadata</h2>
<div class="detail-row">
<div class="field">
<label>Artist</label>
<input id="ed-artist" value="${esc(item.norm_artist || item.raw_artist || '')}">
<div class="raw-value">Raw: ${esc(item.raw_artist || '-')} | Path: ${esc(item.path_artist || '-')}</div>
</div>
</div>
<div class="detail-row">
<div class="field">
<label>Title</label>
<input id="ed-title" value="${esc(item.norm_title || item.raw_title || '')}">
<div class="raw-value">Raw: ${esc(item.raw_title || '-')} | Path: ${esc(item.path_title || '-')}</div>
</div>
</div>
<div class="detail-row">
<div class="field">
<label>Album</label>
<input id="ed-album" value="${esc(item.norm_album || item.raw_album || '')}">
<div class="raw-value">Raw: ${esc(item.raw_album || '-')} | Path: ${esc(item.path_album || '-')}</div>
</div>
<div class="field">
<label>Year</label>
<input id="ed-year" type="number" value="${item.norm_year || item.raw_year || ''}">
</div>
</div>
<div class="detail-row">
<div class="field">
<label>Track #</label>
<input id="ed-track" type="number" value="${item.norm_track_number || item.raw_track_number || ''}">
</div>
<div class="field">
<label>Genre</label>
<input id="ed-genre" value="${esc(item.norm_genre || item.raw_genre || '')}">
</div>
</div>
<label>Featured Artists</label>
<div class="feat-tags" id="feat-tags"></div>
<div class="artist-search-wrap">
<input id="feat-search" placeholder="Search artist to add..." autocomplete="off"
oninput="onFeatSearch(this.value)" onkeydown="onFeatKey(event)">
<div class="artist-dropdown" id="feat-dropdown"></div>
</div>
${item.llm_notes ? `<label>Agent Notes</label><div class="raw-value" style="margin-bottom:8px">${esc(item.llm_notes)}</div>` : ''}
${item.error_message ? `<label>Error</label><div class="raw-value" style="color:var(--danger)">${esc(item.error_message)}</div>` : ''}
<div class="modal-actions">
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveEdit('${item.id}')">Save</button>
</div>
`;
renderFeatTags();
openModal();
}
function renderFeatTags() {
const el = document.getElementById('feat-tags');
if (!el) return;
el.innerHTML = editFeatured.map((name, i) =>
`<span class="feat-tag">${esc(name)}<span class="remove" onclick="removeFeat(${i})">&times;</span></span>`
).join('');
}
function removeFeat(idx) {
editFeatured.splice(idx, 1);
renderFeatTags();
}
function addFeat(name) {
name = name.trim();
if (!name || editFeatured.includes(name)) return;
editFeatured.push(name);
renderFeatTags();
const input = document.getElementById('feat-search');
if (input) { input.value = ''; }
closeFeatDropdown();
}
function onFeatSearch(q) {
clearTimeout(searchTimer);
if (q.length < 2) { closeFeatDropdown(); return; }
searchTimer = setTimeout(async () => {
const results = await api(`/artists/search?q=${encodeURIComponent(q)}&limit=8`);
const dd = document.getElementById('feat-dropdown');
if (!results || !results.length) {
// Show option to add as new
dd.innerHTML = `<div class="artist-option" onclick="addFeat('${esc(q)}')">
Add "${esc(q)}" as new
</div>`;
dd.classList.add('open');
return;
}
let html = '';
for (const a of results) {
html += `<div class="artist-option" onclick="addFeat('${esc(a.name)}')">
${esc(a.name)}
</div>`;
}
// Always offer to add typed value as-is
const typed = document.getElementById('feat-search').value.trim();
if (typed && !results.find(a => a.name.toLowerCase() === typed.toLowerCase())) {
html += `<div class="artist-option" onclick="addFeat('${esc(typed)}')">
Add "${esc(typed)}" as new
</div>`;
}
dd.innerHTML = html;
dd.classList.add('open');
}, 250);
}
function onFeatKey(e) {
if (e.key === 'Enter') {
e.preventDefault();
const val = e.target.value.trim();
if (val) addFeat(val);
} else if (e.key === 'Escape') {
closeFeatDropdown();
}
}
function closeFeatDropdown() {
const dd = document.getElementById('feat-dropdown');
if (dd) dd.classList.remove('open');
}
async function saveEdit(id) {
const body = {
norm_artist: document.getElementById('ed-artist').value || null,
norm_title: document.getElementById('ed-title').value || null,
norm_album: document.getElementById('ed-album').value || null,
norm_year: parseInt(document.getElementById('ed-year').value) || null,
norm_track_number: parseInt(document.getElementById('ed-track').value) || null,
norm_genre: document.getElementById('ed-genre').value || null,
featured_artists: editFeatured,
};
await api(`/queue/${id}/update`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
closeModal();
loadQueue(currentFilter);
}
async function editArtist(id, currentName) {
const name = prompt('New artist name:', currentName);
if (!name || name === currentName) return;
await api(`/artists/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
loadArtists();
}
function openModal() { document.getElementById('modalOverlay').classList.add('visible'); }
function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// Init
loadStats();
loadQueue();
setInterval(loadStats, 10000);
</script>
</body>
</html>

236
furumi-agent/src/web/api.rs Normal file
View 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()
}

View File

@@ -0,0 +1,39 @@
pub mod api;
use std::sync::Arc;
use axum::{Router, routing::{get, post, put}};
use sqlx::PgPool;
use crate::config::Args;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub config: Arc<Args>,
pub system_prompt: Arc<String>,
}
pub fn build_router(state: Arc<AppState>) -> Router {
let api = Router::new()
.route("/stats", get(api::stats))
.route("/queue", get(api::list_queue))
.route("/queue/:id", get(api::get_queue_item).delete(api::delete_queue_item))
.route("/queue/:id/approve", post(api::approve_queue_item))
.route("/queue/:id/reject", post(api::reject_queue_item))
.route("/queue/:id/update", put(api::update_queue_item))
.route("/artists/search", get(api::search_artists))
.route("/artists", get(api::list_artists))
.route("/artists/:id", put(api::update_artist))
.route("/artists/:id/albums", get(api::list_albums))
.route("/albums/:id", put(api::update_album));
Router::new()
.route("/", get(admin_html))
.nest("/api", api)
.with_state(state)
}
async fn admin_html() -> axum::response::Html<&'static str> {
axum::response::Html(include_str!("admin.html"))
}