Fixed agent UI
This commit is contained in:
@@ -326,6 +326,18 @@ pub async fn approve_and_finalize(
|
|||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Check if track already exists (e.g. previously approved but pending not cleaned up)
|
||||||
|
let existing: Option<(i64,)> = sqlx::query_as("SELECT id FROM tracks WHERE file_hash = $1")
|
||||||
|
.bind(&pt.file_hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some((track_id,)) = existing {
|
||||||
|
// Already finalized — just mark pending as approved
|
||||||
|
update_pending_status(pool, pending_id, "approved", None).await?;
|
||||||
|
return Ok(track_id);
|
||||||
|
}
|
||||||
|
|
||||||
let artist_name = pt.norm_artist.as_deref().unwrap_or("Unknown Artist");
|
let artist_name = pt.norm_artist.as_deref().unwrap_or("Unknown Artist");
|
||||||
let artist_id = upsert_artist(pool, artist_name).await?;
|
let artist_id = upsert_artist(pool, artist_name).await?;
|
||||||
|
|
||||||
@@ -425,6 +437,16 @@ pub async fn find_album_id(pool: &PgPool, artist_name: &str, album_name: &str) -
|
|||||||
Ok(row.map(|r| r.0))
|
Ok(row.map(|r| r.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch pending tracks that need (re-)processing by the LLM pipeline.
|
||||||
|
pub async fn list_pending_for_processing(pool: &PgPool, limit: i64) -> Result<Vec<PendingTrack>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, PendingTrack>(
|
||||||
|
"SELECT * FROM pending_tracks WHERE status = 'pending' ORDER BY created_at ASC LIMIT $1"
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
// --- DTOs for insert helpers ---
|
// --- DTOs for insert helpers ---
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ pub async fn run(state: Arc<AppState>) {
|
|||||||
Ok(count) => tracing::info!(count, "processed new files"),
|
Ok(count) => tracing::info!(count, "processed new files"),
|
||||||
Err(e) => tracing::error!(?e, "inbox scan failed"),
|
Err(e) => tracing::error!(?e, "inbox scan failed"),
|
||||||
}
|
}
|
||||||
|
// Re-process pending tracks (e.g. retried from admin UI)
|
||||||
|
match reprocess_pending(&state).await {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(count) => tracing::info!(count, "re-processed pending tracks"),
|
||||||
|
Err(e) => tracing::error!(?e, "pending re-processing failed"),
|
||||||
|
}
|
||||||
tokio::time::sleep(interval).await;
|
tokio::time::sleep(interval).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +67,137 @@ async fn scan_inbox(state: &Arc<AppState>) -> anyhow::Result<usize> {
|
|||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-process pending tracks from DB (e.g. tracks retried via admin UI).
|
||||||
|
/// These already have raw metadata and path hints stored — just need RAG + LLM.
|
||||||
|
async fn reprocess_pending(state: &Arc<AppState>) -> anyhow::Result<usize> {
|
||||||
|
let pending = db::list_pending_for_processing(&state.pool, 10).await?;
|
||||||
|
if pending.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
for pt in &pending {
|
||||||
|
tracing::info!(id = %pt.id, title = pt.raw_title.as_deref().unwrap_or("?"), "Re-processing pending track");
|
||||||
|
|
||||||
|
db::update_pending_status(&state.pool, pt.id, "processing", None).await?;
|
||||||
|
|
||||||
|
// Build raw metadata and hints from stored DB fields
|
||||||
|
let raw_meta = metadata::RawMetadata {
|
||||||
|
title: pt.raw_title.clone(),
|
||||||
|
artist: pt.raw_artist.clone(),
|
||||||
|
album: pt.raw_album.clone(),
|
||||||
|
track_number: pt.raw_track_number.map(|n| n as u32),
|
||||||
|
year: pt.raw_year.map(|n| n as u32),
|
||||||
|
genre: pt.raw_genre.clone(),
|
||||||
|
duration_secs: pt.duration_secs,
|
||||||
|
};
|
||||||
|
|
||||||
|
let hints = db::PathHints {
|
||||||
|
title: pt.path_title.clone(),
|
||||||
|
artist: pt.path_artist.clone(),
|
||||||
|
album: pt.path_album.clone(),
|
||||||
|
year: pt.path_year,
|
||||||
|
track_number: pt.path_track_number,
|
||||||
|
};
|
||||||
|
|
||||||
|
// RAG lookup
|
||||||
|
let artist_query = raw_meta.artist.as_deref()
|
||||||
|
.or(hints.artist.as_deref())
|
||||||
|
.unwrap_or("");
|
||||||
|
let album_query = raw_meta.album.as_deref()
|
||||||
|
.or(hints.album.as_deref())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let similar_artists = if !artist_query.is_empty() {
|
||||||
|
db::find_similar_artists(&state.pool, artist_query, 5).await.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let similar_albums = if !album_query.is_empty() {
|
||||||
|
db::find_similar_albums(&state.pool, album_query, 5).await.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// LLM normalization
|
||||||
|
match normalize::normalize(state, &raw_meta, &hints, &similar_artists, &similar_albums).await {
|
||||||
|
Ok(normalized) => {
|
||||||
|
let confidence = normalized.confidence.unwrap_or(0.0);
|
||||||
|
let status = if confidence >= state.config.confidence_threshold {
|
||||||
|
"approved"
|
||||||
|
} else {
|
||||||
|
"review"
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
id = %pt.id,
|
||||||
|
norm_artist = normalized.artist.as_deref().unwrap_or("-"),
|
||||||
|
norm_title = normalized.title.as_deref().unwrap_or("-"),
|
||||||
|
confidence,
|
||||||
|
status,
|
||||||
|
"Re-processing complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
db::update_pending_normalized(&state.pool, pt.id, status, &normalized, None).await?;
|
||||||
|
|
||||||
|
if status == "approved" {
|
||||||
|
let artist = normalized.artist.as_deref().unwrap_or("Unknown Artist");
|
||||||
|
let album = normalized.album.as_deref().unwrap_or("Unknown Album");
|
||||||
|
let title = normalized.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 = normalized.track_number.unwrap_or(0);
|
||||||
|
|
||||||
|
let dest_filename = if track_num > 0 {
|
||||||
|
format!("{:02} - {}.{}", track_num, sanitize_filename(title), ext)
|
||||||
|
} else {
|
||||||
|
format!("{}.{}", sanitize_filename(title), ext)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if already moved
|
||||||
|
let dest = state.config.storage_dir
|
||||||
|
.join(sanitize_filename(artist))
|
||||||
|
.join(sanitize_filename(album))
|
||||||
|
.join(&dest_filename);
|
||||||
|
|
||||||
|
let storage_path = if dest.exists() && !source.exists() {
|
||||||
|
dest.to_string_lossy().to_string()
|
||||||
|
} else if source.exists() {
|
||||||
|
match mover::move_to_storage(
|
||||||
|
&state.config.storage_dir, artist, album, &dest_filename, source,
|
||||||
|
).await {
|
||||||
|
Ok(p) => p.to_string_lossy().to_string(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(id = %pt.id, ?e, "Failed to move file");
|
||||||
|
db::update_pending_status(&state.pool, pt.id, "error", Some(&e.to_string())).await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::error!(id = %pt.id, "Source file missing: {:?}", source);
|
||||||
|
db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match db::approve_and_finalize(&state.pool, pt.id, &storage_path).await {
|
||||||
|
Ok(track_id) => tracing::info!(id = %pt.id, track_id, "Track finalized"),
|
||||||
|
Err(e) => tracing::error!(id = %pt.id, ?e, "Failed to finalize"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(id = %pt.id, ?e, "LLM normalization failed");
|
||||||
|
db::update_pending_status(&state.pool, pt.id, "error", Some(&e.to_string())).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
/// Recursively remove empty directories inside the inbox.
|
/// Recursively remove empty directories inside the inbox.
|
||||||
/// Does not remove the inbox root itself.
|
/// Does not remove the inbox root itself.
|
||||||
async fn cleanup_empty_dirs(dir: &std::path::Path) -> bool {
|
async fn cleanup_empty_dirs(dir: &std::path::Path) -> bool {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
<title>Furumi Agent — Admin</title>
|
<title>Furumi Agent — Admin</title>
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
@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; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -27,101 +26,52 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html, body { height: 100%; overflow: hidden; }
|
html, body { height: 100%; overflow: hidden; }
|
||||||
|
body { font-family: 'Inter', sans-serif; background: var(--bg-base); color: var(--text); display: flex; flex-direction: column; }
|
||||||
|
|
||||||
body {
|
header { background: var(--bg-panel); border-bottom: 1px solid var(--border); padding: 10px 24px; display: flex; align-items: center; gap: 20px; flex-shrink: 0; }
|
||||||
font-family: 'Inter', sans-serif;
|
header h1 { font-size: 15px; font-weight: 600; }
|
||||||
background: var(--bg-base);
|
.stats { display: flex; gap: 14px; margin-left: auto; font-size: 12px; color: var(--text-dim); }
|
||||||
color: var(--text);
|
.stats .stat { display: flex; gap: 3px; align-items: center; }
|
||||||
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; }
|
.stats .stat-value { color: var(--text); font-weight: 600; }
|
||||||
|
.agent-status { font-size: 11px; padding: 3px 8px; border-radius: 4px; }
|
||||||
|
.agent-status.idle { background: #052e16; color: var(--success); }
|
||||||
|
.agent-status.busy { background: #1e1b4b; color: var(--accent); animation: pulse 1.5s infinite; }
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } }
|
||||||
|
|
||||||
nav {
|
nav { display: flex; gap: 3px; }
|
||||||
display: flex;
|
nav button { background: none; border: none; color: var(--text-muted); padding: 5px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; font-family: inherit; }
|
||||||
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:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
nav button.active { background: var(--bg-active); color: var(--accent); }
|
nav button.active { background: var(--bg-active); color: var(--accent); }
|
||||||
|
|
||||||
main {
|
/* Filter bar */
|
||||||
flex: 1;
|
.filter-bar { display: flex; gap: 4px; padding: 10px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0; align-items: center; flex-wrap: wrap; }
|
||||||
overflow-y: auto;
|
.filter-btn { font-size: 11px; padding: 4px 10px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 5px; color: var(--text-muted); cursor: pointer; font-family: inherit; }
|
||||||
padding: 16px 24px;
|
.filter-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||||
}
|
.filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.filter-btn .count { font-weight: 600; margin-left: 3px; }
|
||||||
|
|
||||||
table {
|
main { flex: 1; overflow-y: auto; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
th { text-align: left; padding: 6px 10px; color: var(--text-muted); font-weight: 500; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg-base); z-index: 2; font-size: 11px; }
|
||||||
|
td { padding: 5px 10px; border-bottom: 1px solid var(--border); max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
tr:hover td { background: var(--bg-hover); }
|
tr:hover td { background: var(--bg-hover); }
|
||||||
|
tr.selected td { background: var(--bg-active); }
|
||||||
|
|
||||||
.status {
|
/* Group header */
|
||||||
padding: 2px 8px;
|
.group-header td { background: var(--bg-card); font-weight: 600; color: var(--text-dim); font-size: 11px; letter-spacing: 0.03em; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||||
border-radius: 4px;
|
.group-header:hover td { background: var(--bg-card); }
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* Inline edit */
|
||||||
|
td.editable { cursor: text; }
|
||||||
|
td.editable:hover { outline: 1px dashed var(--border); outline-offset: -2px; }
|
||||||
|
td .inline-input { background: var(--bg-card); border: 1px solid var(--accent); border-radius: 3px; padding: 2px 5px; color: var(--text); font-size: 12px; font-family: inherit; width: 100%; }
|
||||||
|
|
||||||
|
/* Checkbox */
|
||||||
|
.cb { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; }
|
||||||
|
|
||||||
|
.status { padding: 2px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; text-transform: uppercase; }
|
||||||
.status-pending { background: #1e293b; color: var(--text-muted); }
|
.status-pending { background: #1e293b; color: var(--text-muted); }
|
||||||
.status-processing { background: #1e1b4b; color: var(--accent); }
|
.status-processing { background: #1e1b4b; color: var(--accent); }
|
||||||
.status-review { background: #422006; color: var(--warning); }
|
.status-review { background: #422006; color: var(--warning); }
|
||||||
@@ -129,180 +79,54 @@ tr:hover td { background: var(--bg-hover); }
|
|||||||
.status-rejected { background: #450a0a; color: var(--danger); }
|
.status-rejected { background: #450a0a; color: var(--danger); }
|
||||||
.status-error { background: #450a0a; color: var(--danger); }
|
.status-error { background: #450a0a; color: var(--danger); }
|
||||||
|
|
||||||
.actions {
|
.actions { display: flex; gap: 3px; }
|
||||||
display: flex;
|
.btn { border: none; padding: 3px 8px; border-radius: 3px; cursor: pointer; font-size: 11px; font-family: inherit; font-weight: 500; }
|
||||||
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 { background: #052e16; color: var(--success); }
|
||||||
.btn-approve:hover { background: #065f46; }
|
.btn-approve:hover { background: #065f46; }
|
||||||
.btn-reject { background: #450a0a; color: var(--danger); }
|
.btn-reject { background: #450a0a; color: var(--danger); }
|
||||||
.btn-reject:hover { background: #7f1d1d; }
|
.btn-reject:hover { background: #7f1d1d; }
|
||||||
|
.btn-retry { background: #1e1b4b; color: var(--accent); }
|
||||||
|
.btn-retry:hover { background: #312e81; }
|
||||||
.btn-edit { background: var(--bg-active); color: var(--text-dim); }
|
.btn-edit { background: var(--bg-active); color: var(--text-dim); }
|
||||||
.btn-edit:hover { background: var(--bg-hover); color: var(--text); }
|
.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 { background: var(--accent); color: white; }
|
||||||
.btn-primary:hover { background: var(--accent-dim); }
|
.btn-primary:hover { background: var(--accent-dim); }
|
||||||
.btn-cancel { background: var(--bg-card); color: var(--text-dim); }
|
.btn-cancel { background: var(--bg-card); color: var(--text-dim); }
|
||||||
.btn-cancel:hover { background: var(--bg-hover); }
|
.btn-cancel:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
/* Detail fields in modal */
|
.empty { text-align: center; padding: 48px; color: var(--text-muted); font-size: 13px; }
|
||||||
.detail-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* Floating batch toolbar */
|
||||||
|
.batch-bar { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--bg-panel); border: 1px solid var(--border); border-radius: 10px; padding: 10px 20px; display: flex; gap: 10px; align-items: center; box-shadow: 0 8px 32px rgba(0,0,0,0.6); z-index: 50; transition: opacity 0.2s, transform 0.2s; }
|
||||||
|
.batch-bar.hidden { opacity: 0; pointer-events: none; transform: translateX(-50%) translateY(20px); }
|
||||||
|
.batch-bar .batch-count { font-size: 13px; font-weight: 600; margin-right: 8px; color: var(--accent); }
|
||||||
|
.batch-bar .btn { padding: 6px 14px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* 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; max-height: 90vh; overflow-y: auto; }
|
||||||
|
.modal h2 { font-size: 15px; margin-bottom: 14px; }
|
||||||
|
.modal label { display: block; font-size: 11px; color: var(--text-muted); margin-bottom: 3px; margin-top: 10px; }
|
||||||
|
.modal input, .modal textarea { width: 100%; background: var(--bg-card); border: 1px solid var(--border); border-radius: 5px; padding: 7px 9px; color: var(--text); font-family: inherit; font-size: 12px; }
|
||||||
|
.modal textarea { resize: vertical; min-height: 50px; }
|
||||||
|
.modal-actions { margin-top: 16px; display: flex; gap: 6px; justify-content: flex-end; }
|
||||||
|
.modal-actions .btn { padding: 7px 14px; }
|
||||||
|
|
||||||
|
.detail-row { display: flex; gap: 10px; margin-top: 6px; }
|
||||||
.detail-row .field { flex: 1; }
|
.detail-row .field { flex: 1; }
|
||||||
|
.raw-value { font-size: 10px; color: var(--text-muted); margin-top: 1px; }
|
||||||
|
|
||||||
.raw-value {
|
/* Featured artist tags */
|
||||||
font-size: 11px;
|
.feat-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; min-height: 24px; }
|
||||||
color: var(--text-muted);
|
.feat-tag { display: flex; align-items: center; gap: 3px; background: var(--bg-active); border: 1px solid var(--border); border-radius: 3px; padding: 1px 7px; font-size: 11px; }
|
||||||
margin-top: 2px;
|
.feat-tag .remove { cursor: pointer; color: var(--text-muted); font-size: 13px; line-height: 1; }
|
||||||
}
|
|
||||||
|
|
||||||
/* 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); }
|
.feat-tag .remove:hover { color: var(--danger); }
|
||||||
|
.artist-search-wrap { position: relative; margin-top: 5px; }
|
||||||
/* Artist search dropdown */
|
.artist-dropdown { position: absolute; top: 100%; left: 0; right: 0; background: var(--bg-card); border: 1px solid var(--border); border-radius: 0 0 5px 5px; max-height: 140px; overflow-y: auto; z-index: 10; display: none; }
|
||||||
.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-dropdown.open { display: block; }
|
||||||
|
.artist-option { padding: 5px 9px; cursor: pointer; font-size: 12px; }
|
||||||
.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:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
.artist-option .sim {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -310,26 +134,40 @@ tr:hover td { background: var(--bg-hover); }
|
|||||||
<header>
|
<header>
|
||||||
<h1>Furumi Agent</h1>
|
<h1>Furumi Agent</h1>
|
||||||
<nav>
|
<nav>
|
||||||
<button class="active" onclick="showTab('queue')">Queue</button>
|
<button class="active" onclick="showTab('queue',this)">Queue</button>
|
||||||
<button onclick="showTab('artists')">Artists</button>
|
<button onclick="showTab('artists',this)">Artists</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
<span class="agent-status idle" id="agentStatus">Idle</span>
|
||||||
<div class="stats" id="statsBar"></div>
|
<div class="stats" id="statsBar"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="filter-bar" id="filterBar"></div>
|
||||||
|
|
||||||
<main id="content"></main>
|
<main id="content"></main>
|
||||||
|
|
||||||
|
<div class="batch-bar hidden" id="batchBar">
|
||||||
|
<span class="batch-count" id="batchCount">0 selected</span>
|
||||||
|
<button class="btn btn-approve" onclick="batchAction('approve')">Approve</button>
|
||||||
|
<button class="btn btn-reject" onclick="batchAction('reject')">Reject</button>
|
||||||
|
<button class="btn btn-retry" onclick="batchAction('retry')">Retry</button>
|
||||||
|
<button class="btn btn-edit" onclick="batchAction('delete')">Delete</button>
|
||||||
|
<button class="btn btn-cancel" onclick="clearSelection()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this)closeModal()">
|
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this)closeModal()">
|
||||||
<div class="modal" id="modal"></div>
|
<div class="modal" id="modal"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Derive API base from current page path so it works behind any reverse-proxy prefix
|
|
||||||
// e.g. page at "/admin" or "/admin/" → API = "/admin/api"
|
|
||||||
// e.g. page at "/" → API = "/api"
|
|
||||||
const _base = location.pathname.replace(/\/+$/, '');
|
const _base = location.pathname.replace(/\/+$/, '');
|
||||||
const API = _base + '/api';
|
const API = _base + '/api';
|
||||||
let currentTab = 'queue';
|
let currentTab = 'queue';
|
||||||
let currentFilter = null;
|
let currentFilter = null;
|
||||||
|
let queueItems = [];
|
||||||
|
let selected = new Set();
|
||||||
|
let searchTimer = null;
|
||||||
|
let editFeatured = [];
|
||||||
|
let statsCache = null;
|
||||||
|
|
||||||
async function api(path, opts) {
|
async function api(path, opts) {
|
||||||
const r = await fetch(API + path, opts);
|
const r = await fetch(API + path, opts);
|
||||||
@@ -337,117 +175,235 @@ async function api(path, opts) {
|
|||||||
const text = await r.text();
|
const text = await r.text();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
try { return JSON.parse(text); }
|
try { return JSON.parse(text); }
|
||||||
catch(e) { console.error('API parse error:', r.status, text); return null; }
|
catch(e) { console.error('API error:', r.status, text); return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Stats & polling ---
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
const s = await api('/stats');
|
const s = await api('/stats');
|
||||||
|
if (!s) return;
|
||||||
|
statsCache = s;
|
||||||
document.getElementById('statsBar').innerHTML = `
|
document.getElementById('statsBar').innerHTML = `
|
||||||
<div class="stat">Tracks: <span class="stat-value">${s.total_tracks}</span></div>
|
<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">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">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>
|
// Agent status
|
||||||
<div class="stat">Errors: <span class="stat-value">${s.error_count}</span></div>
|
const el = document.getElementById('agentStatus');
|
||||||
|
if (s.pending_count > 0) { el.textContent = 'Processing...'; el.className = 'agent-status busy'; }
|
||||||
|
else { el.textContent = 'Idle'; el.className = 'agent-status idle'; }
|
||||||
|
|
||||||
|
// Update filter counts if on queue tab
|
||||||
|
if (currentTab === 'queue') renderFilterBar(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFilterBar(s) {
|
||||||
|
const bar = document.getElementById('filterBar');
|
||||||
|
if (currentTab !== 'queue') { bar.innerHTML = ''; return; }
|
||||||
|
const f = currentFilter;
|
||||||
|
bar.innerHTML = `
|
||||||
|
<button class="filter-btn ${!f?'active':''}" onclick="loadQueue()">All</button>
|
||||||
|
<button class="filter-btn ${f==='review'?'active':''}" onclick="loadQueue('review')">Review<span class="count">${s.review_count}</span></button>
|
||||||
|
<button class="filter-btn ${f==='pending'?'active':''}" onclick="loadQueue('pending')">Pending<span class="count">${s.pending_count}</span></button>
|
||||||
|
<button class="filter-btn ${f==='error'?'active':''}" onclick="loadQueue('error')">Errors<span class="count">${s.error_count}</span></button>
|
||||||
|
<button class="filter-btn ${f==='approved'?'active':''}" onclick="loadQueue('approved')">Approved</button>
|
||||||
|
<button class="filter-btn ${f==='rejected'?'active':''}" onclick="loadQueue('rejected')">Rejected</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTab(tab) {
|
function showTab(tab, btn) {
|
||||||
currentTab = tab;
|
currentTab = tab;
|
||||||
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
||||||
event.target.classList.add('active');
|
btn.classList.add('active');
|
||||||
if (tab === 'queue') loadQueue();
|
clearSelection();
|
||||||
else if (tab === 'artists') loadArtists();
|
if (tab === 'queue') { loadQueue(); loadStats(); }
|
||||||
|
else if (tab === 'artists') { loadArtists(); document.getElementById('filterBar').innerHTML = ''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadQueue(status) {
|
// --- Queue ---
|
||||||
|
async function loadQueue(status, keepSelection) {
|
||||||
currentFilter = status;
|
currentFilter = status;
|
||||||
const qs = status ? `?status=${status}` : '';
|
if (!keepSelection) clearSelection();
|
||||||
const items = await api(`/queue${qs}`);
|
const qs = status ? `?status=${status}&limit=200` : '?limit=200';
|
||||||
const el = document.getElementById('content');
|
queueItems = await api(`/queue${qs}`) || [];
|
||||||
|
// Prune selection: remove ids no longer in the list
|
||||||
|
const currentIds = new Set(queueItems.map(i => i.id));
|
||||||
|
for (const id of [...selected]) { if (!currentIds.has(id)) selected.delete(id); }
|
||||||
|
updateBatchBar();
|
||||||
|
renderQueue();
|
||||||
|
if (statsCache) renderFilterBar(statsCache);
|
||||||
|
}
|
||||||
|
|
||||||
if (!items.length) {
|
function renderQueue() {
|
||||||
el.innerHTML = '<div class="empty">No items in queue</div>';
|
const el = document.getElementById('content');
|
||||||
return;
|
if (!queueItems.length) { el.innerHTML = '<div class="empty">No items in queue</div>'; return; }
|
||||||
|
|
||||||
|
// Group by album
|
||||||
|
const groups = {};
|
||||||
|
const noAlbum = [];
|
||||||
|
for (const it of queueItems) {
|
||||||
|
const key = it.norm_album || it.raw_album || it.path_album;
|
||||||
|
if (key) { (groups[key] = groups[key] || []).push(it); }
|
||||||
|
else noAlbum.push(it);
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = `
|
let html = `<table><tr>
|
||||||
<div style="margin-bottom:12px;display:flex;gap:4px">
|
<th style="width:30px"><input type="checkbox" class="cb" onchange="toggleSelectAll(this.checked)"></th>
|
||||||
<button class="btn ${!status?'btn-primary':'btn-edit'}" onclick="loadQueue()">All</button>
|
<th style="width:70px">Status</th>
|
||||||
<button class="btn ${status==='review'?'btn-primary':'btn-edit'}" onclick="loadQueue('review')">Review</button>
|
<th>Artist</th>
|
||||||
<button class="btn ${status==='pending'?'btn-primary':'btn-edit'}" onclick="loadQueue('pending')">Pending</button>
|
<th>Title</th>
|
||||||
<button class="btn ${status==='approved'?'btn-primary':'btn-edit'}" onclick="loadQueue('approved')">Approved</button>
|
<th>Album</th>
|
||||||
<button class="btn ${status==='error'?'btn-primary':'btn-edit'}" onclick="loadQueue('error')">Errors</button>
|
<th style="width:40px">Yr</th>
|
||||||
</div>
|
<th style="width:30px">#</th>
|
||||||
<table>
|
<th style="width:40px">Conf</th>
|
||||||
<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>
|
<th style="width:130px">Actions</th>
|
||||||
`;
|
</tr>`;
|
||||||
|
|
||||||
for (const it of items) {
|
const renderRow = (it) => {
|
||||||
|
const sel = selected.has(it.id) ? ' selected' : '';
|
||||||
const conf = it.confidence != null ? it.confidence.toFixed(2) : '-';
|
const conf = it.confidence != null ? it.confidence.toFixed(2) : '-';
|
||||||
html += `<tr>
|
const artist = it.norm_artist || it.raw_artist || '-';
|
||||||
|
const title = it.norm_title || it.raw_title || '-';
|
||||||
|
const album = it.norm_album || it.raw_album || '-';
|
||||||
|
const year = it.norm_year || it.raw_year || '';
|
||||||
|
const tnum = it.norm_track_number || it.raw_track_number || '';
|
||||||
|
const canApprove = it.status === 'review';
|
||||||
|
const canRetry = it.status === 'error';
|
||||||
|
return `<tr class="${sel}" data-id="${it.id}">
|
||||||
|
<td><input type="checkbox" class="cb" ${selected.has(it.id)?'checked':''} onchange="toggleSelect('${it.id}',this.checked)"></td>
|
||||||
<td><span class="status status-${it.status}">${it.status}</span></td>
|
<td><span class="status status-${it.status}">${it.status}</span></td>
|
||||||
<td title="${esc(it.raw_artist)}">${esc(it.raw_artist || '-')}</td>
|
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_artist')" title="${esc(it.raw_artist||'')}">${esc(artist)}</td>
|
||||||
<td title="${esc(it.raw_title)}">${esc(it.raw_title || '-')}</td>
|
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_title')" title="${esc(it.raw_title||'')}">${esc(title)}</td>
|
||||||
<td title="${esc(it.norm_artist)}">${esc(it.norm_artist || '-')}</td>
|
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_album')" title="${esc(it.raw_album||'')}">${esc(album)}</td>
|
||||||
<td title="${esc(it.norm_title)}">${esc(it.norm_title || '-')}</td>
|
<td>${year}</td>
|
||||||
<td title="${esc(it.norm_album)}">${esc(it.norm_album || '-')}</td>
|
<td>${tnum}</td>
|
||||||
<td>${conf}</td>
|
<td>${conf}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
${it.status === 'review' ? `<button class="btn btn-approve" onclick="approveItem('${it.id}')">Approve</button>` : ''}
|
${canApprove ? `<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>` : ''}
|
${canApprove ? `<button class="btn btn-reject" onclick="rejectItem('${it.id}')">Reject</button>` : ''}
|
||||||
|
${canRetry ? `<button class="btn btn-retry" onclick="retryItem('${it.id}')">Retry</button>` : ''}
|
||||||
<button class="btn btn-edit" onclick="editItem('${it.id}')">Edit</button>
|
<button class="btn btn-edit" onclick="editItem('${it.id}')">Edit</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
};
|
||||||
|
|
||||||
html += '</table>';
|
// Render grouped
|
||||||
el.innerHTML = html;
|
for (const [albumName, items] of Object.entries(groups)) {
|
||||||
}
|
const artist = items[0].norm_artist || items[0].raw_artist || '?';
|
||||||
|
const year = items[0].norm_year || items[0].raw_year || '';
|
||||||
async function loadArtists() {
|
const yearStr = year ? ` (${year})` : '';
|
||||||
const artists = await api('/artists');
|
const albumIds = items.map(i => i.id);
|
||||||
const el = document.getElementById('content');
|
html += `<tr class="group-header">
|
||||||
|
<td><input type="checkbox" class="cb" onchange="toggleSelectGroup(${JSON.stringify(albumIds).replace(/"/g,'"')},this.checked)"></td>
|
||||||
if (!artists.length) {
|
<td colspan="8">${esc(artist)} — ${esc(albumName)}${yearStr} (${items.length} tracks)</td>
|
||||||
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>`;
|
</tr>`;
|
||||||
|
for (const it of items) html += renderRow(it);
|
||||||
}
|
}
|
||||||
|
// Ungrouped
|
||||||
|
if (noAlbum.length) {
|
||||||
|
if (Object.keys(groups).length) {
|
||||||
|
html += `<tr class="group-header"><td></td><td colspan="8">Ungrouped</td></tr>`;
|
||||||
|
}
|
||||||
|
for (const it of noAlbum) html += renderRow(it);
|
||||||
|
}
|
||||||
|
|
||||||
html += '</table>';
|
html += '</table>';
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function approveItem(id) {
|
// --- Selection ---
|
||||||
await api(`/queue/${id}/approve`, { method: 'POST' });
|
function toggleSelect(id, checked) {
|
||||||
|
if (checked) selected.add(id); else selected.delete(id);
|
||||||
|
updateBatchBar();
|
||||||
|
// Update row style
|
||||||
|
const row = document.querySelector(`tr[data-id="${id}"]`);
|
||||||
|
if (row) row.classList.toggle('selected', checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll(checked) {
|
||||||
|
selected.clear();
|
||||||
|
if (checked) queueItems.forEach(it => selected.add(it.id));
|
||||||
|
updateBatchBar();
|
||||||
|
renderQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectGroup(ids, checked) {
|
||||||
|
ids.forEach(id => { if (checked) selected.add(id); else selected.delete(id); });
|
||||||
|
updateBatchBar();
|
||||||
|
renderQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() { selected.clear(); updateBatchBar(); }
|
||||||
|
|
||||||
|
function updateBatchBar() {
|
||||||
|
const bar = document.getElementById('batchBar');
|
||||||
|
if (selected.size > 0) {
|
||||||
|
bar.classList.remove('hidden');
|
||||||
|
document.getElementById('batchCount').textContent = selected.size + ' selected';
|
||||||
|
} else {
|
||||||
|
bar.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchAction(action) {
|
||||||
|
const ids = [...selected];
|
||||||
|
if (!ids.length) return;
|
||||||
|
if (action === 'delete' && !confirm(`Delete ${ids.length} item(s)?`)) return;
|
||||||
|
await api(`/queue/batch/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
clearSelection();
|
||||||
loadStats();
|
loadStats();
|
||||||
loadQueue(currentFilter);
|
loadQueue(currentFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rejectItem(id) {
|
// --- Single actions ---
|
||||||
await api(`/queue/${id}/reject`, { method: 'POST' });
|
async function approveItem(id) { await api(`/queue/${id}/approve`, { method: 'POST' }); loadStats(); loadQueue(currentFilter); }
|
||||||
loadStats();
|
async function rejectItem(id) { await api(`/queue/${id}/reject`, { method: 'POST' }); loadStats(); loadQueue(currentFilter); }
|
||||||
loadQueue(currentFilter);
|
async function retryItem(id) { await api(`/queue/${id}/retry`, { method: 'POST' }); loadStats(); loadQueue(currentFilter); }
|
||||||
|
|
||||||
|
// --- Inline editing ---
|
||||||
|
function inlineEdit(td, id, field) {
|
||||||
|
if (td.querySelector('.inline-input')) return;
|
||||||
|
const current = td.textContent.trim();
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'inline-input';
|
||||||
|
input.value = current === '-' ? '' : current;
|
||||||
|
td.textContent = '';
|
||||||
|
td.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const val = input.value.trim();
|
||||||
|
td.textContent = val || '-';
|
||||||
|
// Send update
|
||||||
|
const body = {};
|
||||||
|
body[field] = val || null;
|
||||||
|
await api(`/queue/${id}/update`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
// Update local cache
|
||||||
|
const item = queueItems.find(i => i.id === id);
|
||||||
|
if (item) item[field] = val || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener('blur', save);
|
||||||
|
input.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||||||
|
if (e.key === 'Escape') { td.textContent = current; }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let editFeatured = [];
|
// --- Full edit modal ---
|
||||||
let searchTimer = null;
|
|
||||||
|
|
||||||
async function editItem(id) {
|
async function editItem(id) {
|
||||||
const item = await api(`/queue/${id}`);
|
const item = await api(`/queue/${id}`);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
// Parse featured artists from JSON string
|
|
||||||
editFeatured = [];
|
editFeatured = [];
|
||||||
if (item.norm_featured_artists) {
|
if (item.norm_featured_artists) {
|
||||||
try { editFeatured = JSON.parse(item.norm_featured_artists); } catch(e) {}
|
try { editFeatured = JSON.parse(item.norm_featured_artists); } catch(e) {}
|
||||||
@@ -497,7 +453,7 @@ async function editItem(id) {
|
|||||||
oninput="onFeatSearch(this.value)" onkeydown="onFeatKey(event)">
|
oninput="onFeatSearch(this.value)" onkeydown="onFeatKey(event)">
|
||||||
<div class="artist-dropdown" id="feat-dropdown"></div>
|
<div class="artist-dropdown" id="feat-dropdown"></div>
|
||||||
</div>
|
</div>
|
||||||
${item.llm_notes ? `<label>Agent Notes</label><div class="raw-value" style="margin-bottom:8px">${esc(item.llm_notes)}</div>` : ''}
|
${item.llm_notes ? `<label>Agent Notes</label><div class="raw-value" style="margin-bottom:6px">${esc(item.llm_notes)}</div>` : ''}
|
||||||
${item.error_message ? `<label>Error</label><div class="raw-value" style="color:var(--danger)">${esc(item.error_message)}</div>` : ''}
|
${item.error_message ? `<label>Error</label><div class="raw-value" style="color:var(--danger)">${esc(item.error_message)}</div>` : ''}
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button>
|
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button>
|
||||||
@@ -508,76 +464,6 @@ async function editItem(id) {
|
|||||||
openModal();
|
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})">×</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) {
|
async function saveEdit(id) {
|
||||||
const body = {
|
const body = {
|
||||||
norm_artist: document.getElementById('ed-artist').value || null,
|
norm_artist: document.getElementById('ed-artist').value || null,
|
||||||
@@ -597,6 +483,96 @@ async function saveEdit(id) {
|
|||||||
loadQueue(currentFilter);
|
loadQueue(currentFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Featured artists ---
|
||||||
|
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})">×</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) {
|
||||||
|
dd.innerHTML = `<div class="artist-option" onclick="addFeat('${esc(q)}')">Add "${esc(q)}" as new</div>`;
|
||||||
|
dd.classList.add('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = results.map(a => `<div class="artist-option" onclick="addFeat('${esc(a.name)}')">${esc(a.name)}</div>`).join('');
|
||||||
|
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(); addFeat(e.target.value); }
|
||||||
|
else if (e.key === 'Escape') closeFeatDropdown();
|
||||||
|
}
|
||||||
|
function closeFeatDropdown() { const dd = document.getElementById('feat-dropdown'); if (dd) dd.classList.remove('open'); }
|
||||||
|
|
||||||
|
// --- Artists tab ---
|
||||||
|
async function loadArtists() {
|
||||||
|
const artists = await api('/artists');
|
||||||
|
const el = document.getElementById('content');
|
||||||
|
if (!artists || !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 class="editable" ondblclick="inlineEditArtist(this,${a.id})">${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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineEditArtist(td, id) {
|
||||||
|
if (td.querySelector('.inline-input')) return;
|
||||||
|
const current = td.textContent.trim();
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'inline-input';
|
||||||
|
input.value = current;
|
||||||
|
td.textContent = '';
|
||||||
|
td.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
const save = async () => {
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (!val || val === current) { td.textContent = current; return; }
|
||||||
|
td.textContent = val;
|
||||||
|
await api(`/artists/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: val }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
input.addEventListener('blur', save);
|
||||||
|
input.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||||||
|
if (e.key === 'Escape') { td.textContent = current; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function editArtist(id, currentName) {
|
async function editArtist(id, currentName) {
|
||||||
const name = prompt('New artist name:', currentName);
|
const name = prompt('New artist name:', currentName);
|
||||||
if (!name || name === currentName) return;
|
if (!name || name === currentName) return;
|
||||||
@@ -608,18 +584,20 @@ async function editArtist(id, currentName) {
|
|||||||
loadArtists();
|
loadArtists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
function openModal() { document.getElementById('modalOverlay').classList.add('visible'); }
|
function openModal() { document.getElementById('modalOverlay').classList.add('visible'); }
|
||||||
function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }
|
function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
if (s == null) return '';
|
if (s == null) return '';
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init
|
// --- Init ---
|
||||||
loadStats();
|
loadStats();
|
||||||
loadQueue();
|
loadQueue();
|
||||||
setInterval(loadStats, 10000);
|
setInterval(loadStats, 5000);
|
||||||
|
// Auto-refresh queue when on queue tab
|
||||||
|
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -82,22 +82,24 @@ pub async fn approve_queue_item(State(state): State<S>, Path(id): Path<Uuid>) ->
|
|||||||
format!("{}.{}", sanitize_filename(title), ext)
|
format!("{}.{}", sanitize_filename(title), ext)
|
||||||
};
|
};
|
||||||
|
|
||||||
match crate::ingest::mover::move_to_storage(
|
let artist_dir = sanitize_filename(artist);
|
||||||
&state.config.storage_dir,
|
let album_dir = sanitize_filename(album);
|
||||||
artist,
|
let dest = state.config.storage_dir.join(&artist_dir).join(&album_dir).join(&filename);
|
||||||
album,
|
|
||||||
&filename,
|
let storage_path = if dest.exists() && !source.exists() {
|
||||||
source,
|
// File already moved (e.g. auto-approved earlier but DB not finalized)
|
||||||
)
|
dest.to_string_lossy().to_string()
|
||||||
.await
|
} else {
|
||||||
{
|
match crate::ingest::mover::move_to_storage(
|
||||||
Ok(storage_path) => {
|
&state.config.storage_dir, artist, album, &filename, source,
|
||||||
let rel_path = storage_path.to_string_lossy().to_string();
|
).await {
|
||||||
match db::approve_and_finalize(&state.pool, id, &rel_path).await {
|
Ok(p) => p.to_string_lossy().to_string(),
|
||||||
Ok(track_id) => (StatusCode::OK, Json(serde_json::json!({"track_id": track_id}))).into_response(),
|
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match db::approve_and_finalize(&state.pool, id, &storage_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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +146,98 @@ pub async fn update_queue_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
|
|
||||||
|
let rel_path = if dest.exists() && !source.exists() {
|
||||||
|
dest.to_string_lossy().to_string()
|
||||||
|
} else {
|
||||||
|
match crate::ingest::mover::move_to_storage(
|
||||||
|
&state.config.storage_dir, artist, album, &filename, source,
|
||||||
|
).await {
|
||||||
|
Ok(p) => p.to_string_lossy().to_string(),
|
||||||
|
Err(e) => { errors.push(format!("{}: {}", id, e)); continue; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match db::approve_and_finalize(&state.pool, *id, &rel_path).await {
|
||||||
|
Ok(_) => ok += 1,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
// --- Artists ---
|
// --- Artists ---
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/queue/:id", get(api::get_queue_item).delete(api::delete_queue_item))
|
.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/approve", post(api::approve_queue_item))
|
||||||
.route("/queue/:id/reject", post(api::reject_queue_item))
|
.route("/queue/:id/reject", post(api::reject_queue_item))
|
||||||
|
.route("/queue/:id/retry", post(api::retry_queue_item))
|
||||||
.route("/queue/:id/update", put(api::update_queue_item))
|
.route("/queue/:id/update", put(api::update_queue_item))
|
||||||
|
.route("/queue/batch/approve", post(api::batch_approve))
|
||||||
|
.route("/queue/batch/reject", post(api::batch_reject))
|
||||||
|
.route("/queue/batch/retry", post(api::batch_retry))
|
||||||
|
.route("/queue/batch/delete", post(api::batch_delete))
|
||||||
.route("/artists/search", get(api::search_artists))
|
.route("/artists/search", get(api::search_artists))
|
||||||
.route("/artists", get(api::list_artists))
|
.route("/artists", get(api::list_artists))
|
||||||
.route("/artists/:id", put(api::update_artist))
|
.route("/artists/:id", put(api::update_artist))
|
||||||
|
|||||||
Reference in New Issue
Block a user