Added merge
All checks were successful
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m7s
Publish Web Player Image / build-and-push-image (push) Successful in 1m11s
Publish Server Image / build-and-push-image (push) Successful in 2m14s

This commit is contained in:
2026-03-19 00:55:49 +00:00
parent 4a272f373d
commit e1782a6e3b
13 changed files with 949 additions and 23 deletions

View File

@@ -78,6 +78,7 @@ td .inline-input { background: var(--bg-card); border: 1px solid var(--accent);
.status-approved { background: #052e16; color: var(--success); }
.status-rejected { background: #450a0a; color: var(--danger); }
.status-error { background: #450a0a; color: var(--danger); }
.status-merged { background: #0c2340; color: #60a5fa; }
.actions { display: flex; gap: 3px; }
.btn { border: none; padding: 3px 8px; border-radius: 3px; cursor: pointer; font-size: 11px; font-family: inherit; font-weight: 500; }
@@ -127,6 +128,25 @@ td .inline-input { background: var(--bg-card); border: 1px solid var(--accent);
.artist-dropdown.open { display: block; }
.artist-option { padding: 5px 9px; cursor: pointer; font-size: 12px; }
.artist-option:hover { background: var(--bg-hover); }
/* File info & LLM expand */
.info-grid { display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; margin-top: 4px; font-size: 11px; }
.info-grid .k { color: var(--text-muted); white-space: nowrap; }
.info-grid .v { color: var(--text-dim); word-break: break-all; }
details.llm-expand { margin-top: 10px; }
details.llm-expand summary { font-size: 11px; color: var(--text-muted); cursor: pointer; user-select: none; padding: 4px 0; }
details.llm-expand summary:hover { color: var(--text); }
details.llm-expand pre { background: var(--bg-card); border: 1px solid var(--border); border-radius: 5px; padding: 10px; font-size: 11px; color: var(--text-dim); overflow-x: auto; margin-top: 4px; white-space: pre-wrap; word-break: break-all; }
.modal.modal-wide { max-width: 900px; width: 90vw; }
.merge-table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 6px; }
.merge-table th { text-align: left; padding: 5px 8px; color: var(--text-muted); border-bottom: 1px solid var(--border); font-weight: 500; }
.merge-table td { padding: 4px 8px; border-bottom: 1px solid var(--border); }
.merge-table input { background: var(--bg-card); border: 1px solid var(--border); border-radius: 3px; padding: 2px 5px; color: var(--text); font-size: 11px; font-family: inherit; width: 100%; }
.merge-table input:focus { border-color: var(--accent); outline: none; }
.section-label { font-size: 11px; color: var(--text-muted); margin-top: 12px; margin-bottom: 4px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
.artist-select-bar { display: none; position: fixed; bottom: 24px; right: 24px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 10px; padding: 10px 16px; display: none; align-items: center; gap: 10px; box-shadow: 0 8px 32px rgba(0,0,0,0.6); z-index: 50; }
.artist-select-bar.visible { display: flex; }
.modal select { 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; }
</style>
</head>
<body>
@@ -136,6 +156,7 @@ td .inline-input { background: var(--bg-card); border: 1px solid var(--accent);
<nav>
<button class="active" onclick="showTab('queue',this)">Queue</button>
<button onclick="showTab('artists',this)">Artists</button>
<button onclick="showTab('merges',this)">Merges</button>
</nav>
<span class="agent-status idle" id="agentStatus">Idle</span>
<div class="stats" id="statsBar"></div>
@@ -158,6 +179,12 @@ td .inline-input { background: var(--bg-card); border: 1px solid var(--accent);
<div class="modal" id="modal"></div>
</div>
<div class="artist-select-bar" id="artistSelectBar">
<span id="artistSelectCount" style="font-size:13px;font-weight:600;color:var(--accent)">0 artists selected</span>
<button class="btn btn-primary" onclick="mergeSelectedArtists()">Merge Selected</button>
<button class="btn btn-cancel" onclick="clearArtistSelection()">Cancel</button>
</div>
<script>
const _base = location.pathname.replace(/\/+$/, '');
const API = _base + '/api';
@@ -206,6 +233,7 @@ function renderFilterBar(s) {
<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==='merged'?'active':''}" onclick="loadQueue('merged')">Merged<span class="count">${s.merged_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>
`;
@@ -218,6 +246,7 @@ function showTab(tab, btn) {
clearSelection();
if (tab === 'queue') { loadQueue(); loadStats(); }
else if (tab === 'artists') { loadArtists(); document.getElementById('filterBar').innerHTML = ''; }
else if (tab === 'merges') { loadMerges(); document.getElementById('filterBar').innerHTML = ''; }
}
// --- Queue ---
@@ -409,6 +438,24 @@ async function editItem(id) {
try { editFeatured = JSON.parse(item.norm_featured_artists); } catch(e) {}
}
// Build LLM JSON from normalized fields
let featuredParsed = [];
try { featuredParsed = item.norm_featured_artists ? JSON.parse(item.norm_featured_artists) : []; } catch(e) {}
const llmJson = {
artist: item.norm_artist,
title: item.norm_title,
album: item.norm_album,
year: item.norm_year,
track_number: item.norm_track_number,
genre: item.norm_genre,
featured_artists: featuredParsed,
confidence: item.confidence,
notes: item.llm_notes,
};
const fmtSize = (b) => b == null ? '-' : b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : (b/1024).toFixed(0)+' KB';
const fmtDur = (s) => { if (s == null) return '-'; const m = Math.floor(s/60); const ss = Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; };
document.getElementById('modal').innerHTML = `
<h2>Edit Metadata</h2>
<div class="detail-row">
@@ -453,8 +500,19 @@ async function editItem(id) {
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: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);margin-bottom:6px">${esc(item.error_message)}</div>` : ''}
<details class="llm-expand">
<summary>File info &amp; agent response</summary>
<div class="info-grid" style="margin-bottom:8px">
<span class="k">Status</span><span class="v"><span class="status status-${item.status}">${item.status}</span></span>
<span class="k">Confidence</span><span class="v">${item.confidence != null ? item.confidence.toFixed(3) : '-'}</span>
<span class="k">Duration</span><span class="v">${fmtDur(item.duration_secs)}</span>
<span class="k">Size</span><span class="v">${fmtSize(item.file_size)}</span>
<span class="k">Hash</span><span class="v">${esc(item.file_hash ? item.file_hash.slice(0,16)+'…' : '-')}</span>
<span class="k">Inbox path</span><span class="v">${esc(item.inbox_path || '-')}</span>
</div>
<pre>${esc(JSON.stringify(llmJson, null, 2))}</pre>
</details>
<div class="modal-actions">
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveEdit('${item.id}')">Save</button>
@@ -532,9 +590,10 @@ 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>';
let html = '<table><tr><th style="width:30px"></th><th>ID</th><th>Name</th><th>Actions</th></tr>';
for (const a of artists) {
html += `<tr>
<td><input type="checkbox" class="cb" ${selectedArtists.has(a.id)?'checked':''} onchange="toggleSelectArtist(${a.id},this.checked)"></td>
<td>${a.id}</td>
<td class="editable" ondblclick="inlineEditArtist(this,${a.id})">${esc(a.name)}</td>
<td class="actions">
@@ -592,6 +651,229 @@ function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// --- Artist selection for merge ---
let selectedArtists = new Set();
function toggleSelectArtist(id, checked) {
if (checked) selectedArtists.add(id); else selectedArtists.delete(id);
const bar = document.getElementById('artistSelectBar');
if (selectedArtists.size >= 2) {
bar.classList.add('visible');
document.getElementById('artistSelectCount').textContent = selectedArtists.size + ' artists selected';
} else {
bar.classList.remove('visible');
}
}
async function mergeSelectedArtists() {
if (selectedArtists.size < 2) return;
const ids = [...selectedArtists];
const result = await api('/merges', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_ids: ids }),
});
if (result && result.id) {
selectedArtists.clear();
document.getElementById('artistSelectBar').classList.remove('visible');
// Switch to merges tab
const mergesBtn = document.querySelector('nav button:nth-child(3)');
showTab('merges', mergesBtn);
}
}
function clearArtistSelection() {
selectedArtists.clear();
document.getElementById('artistSelectBar').classList.remove('visible');
if (currentTab === 'artists') loadArtists();
}
// --- Merges tab ---
let mergesData = [];
async function loadMerges() {
mergesData = await api('/merges') || [];
renderMerges();
}
function renderMerges() {
const el = document.getElementById('content');
if (!mergesData.length) { el.innerHTML = '<div class="empty">No merge jobs yet. Select artists and click Merge.</div>'; return; }
let html = `<table><tr>
<th style="width:80px">Status</th>
<th>Artists</th>
<th>Notes</th>
<th style="width:120px">Created</th>
<th style="width:80px">Actions</th>
</tr>`;
for (const m of mergesData) {
const ids = JSON.parse(m.source_artist_ids || '[]');
const notes = m.llm_notes ? esc(m.llm_notes.slice(0, 80)) + (m.llm_notes.length > 80 ? '…' : '') : '-';
const date = new Date(m.created_at).toLocaleDateString();
html += `<tr style="cursor:pointer" onclick="openMergeDetail('${m.id}')">
<td><span class="status status-${m.status}">${m.status}</span></td>
<td>${esc('IDs: ' + ids.join(', '))}</td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${notes}</td>
<td>${date}</td>
<td class="actions"><button class="btn btn-edit" onclick="event.stopPropagation();openMergeDetail('${m.id}')">View</button></td>
</tr>`;
}
html += '</table>';
el.innerHTML = html;
}
async function openMergeDetail(id) {
const detail = await api(`/merges/${id}`);
if (!detail) return;
renderMergeModal(detail);
openModal();
}
function renderMergeModal(detail) {
const { merge, artists, proposal } = detail;
const artistNames = artists.map(a => `${esc(a.name)} (ID: ${a.id})`).join(', ');
// Build winner dropdown options
const winnerOpts = artists.map(a =>
`<option value="${a.id}" ${proposal && proposal.winner_artist_id === a.id ? 'selected' : ''}>${esc(a.name)} (ID: ${a.id})</option>`
).join('');
// Build album mappings table rows
let albumRows = '';
if (proposal && proposal.album_mappings) {
for (const [i, m] of proposal.album_mappings.entries()) {
// Find source album artist name
let srcArtistName = '?', srcAlbumName = '?';
for (const a of artists) {
const alb = a.albums.find(al => al.id === m.source_album_id);
if (alb) { srcArtistName = a.name; srcAlbumName = alb.name; break; }
}
albumRows += `<tr>
<td>${esc(srcArtistName)}</td>
<td>${esc(srcAlbumName)} (ID:${m.source_album_id})</td>
<td><input data-i="${i}" data-field="canonical_name" value="${esc(m.canonical_name)}" oninput="updateAlbumMapping(this)"></td>
<td><input data-i="${i}" data-field="merge_into_album_id" type="number" placeholder="(keep)" value="${m.merge_into_album_id != null ? m.merge_into_album_id : ''}" oninput="updateAlbumMapping(this)" style="width:80px"></td>
</tr>`;
}
}
// Build tracks table
let trackRows = '';
for (const a of artists) {
for (const alb of a.albums) {
for (const t of alb.tracks) {
const num = t.track_number ? String(t.track_number).padStart(2,'0') + '. ' : '';
trackRows += `<tr>
<td>${esc(a.name)}</td>
<td>${esc(alb.name)}</td>
<td>${num}${esc(t.title)}</td>
</tr>`;
}
}
}
const canEdit = merge.status === 'review';
const canRetry = merge.status === 'error' || merge.status === 'pending';
document.getElementById('modal').className = 'modal modal-wide';
document.getElementById('modal').innerHTML = `
<h2>Artist Merge</h2>
<div class="section-label">Source artists</div>
<div class="raw-value" style="margin-bottom:8px">${artistNames}</div>
<div class="raw-value">Status: <span class="status status-${merge.status}">${merge.status}</span></div>
${merge.error_message ? `<div class="raw-value" style="color:var(--danger);margin-top:4px">${esc(merge.error_message)}</div>` : ''}
${proposal ? `
<div class="section-label">Proposal</div>
<div class="detail-row">
<div class="field">
<label>Canonical artist name</label>
<input id="mg-artist" value="${esc(proposal.canonical_artist_name)}" ${!canEdit?'disabled':''}>
</div>
<div class="field">
<label>Winner artist</label>
<select id="mg-winner" ${!canEdit?'disabled':''}>${winnerOpts}</select>
</div>
</div>
<div class="section-label">Album mappings</div>
<table class="merge-table">
<tr><th>Source artist</th><th>Source album</th><th>Canonical name</th><th>Merge into ID</th></tr>
${albumRows || '<tr><td colspan="4" style="color:var(--text-muted)">No album mappings</td></tr>'}
</table>
<div class="section-label">Tracks</div>
<div style="max-height:200px;overflow-y:auto;border:1px solid var(--border);border-radius:5px">
<table class="merge-table">
<tr><th>Artist</th><th>Album</th><th>Track</th></tr>
${trackRows || '<tr><td colspan="3" style="color:var(--text-muted)">No tracks</td></tr>'}
</table>
</div>
${merge.llm_notes ? `<div class="section-label">LLM Notes</div><div class="raw-value" style="margin-bottom:8px">${esc(merge.llm_notes)}</div>` : ''}
` : `<div class="raw-value" style="margin-top:8px">Waiting for LLM proposal...</div>`}
<div class="modal-actions">
<button class="btn btn-cancel" onclick="closeModal()">Close</button>
${canRetry ? `<button class="btn btn-retry" onclick="retryMerge('${merge.id}')">Retry</button>` : ''}
${canEdit ? `
<button class="btn btn-reject" onclick="rejectMerge('${merge.id}')">Reject</button>
<button class="btn btn-edit" onclick="saveMergeProposal('${merge.id}')">Save Changes</button>
<button class="btn btn-approve" onclick="approveMerge('${merge.id}')">Approve & Execute</button>
` : ''}
</div>
`;
// Store proposal for editing
window._editingProposal = proposal ? JSON.parse(JSON.stringify(proposal)) : null;
window._editingMergeId = merge.id;
}
function updateAlbumMapping(input) {
if (!window._editingProposal) return;
const i = parseInt(input.dataset.i);
const field = input.dataset.field;
if (field === 'merge_into_album_id') {
const v = input.value.trim();
window._editingProposal.album_mappings[i][field] = v ? parseInt(v) : null;
} else {
window._editingProposal.album_mappings[i][field] = input.value;
}
}
async function saveMergeProposal(id) {
if (!window._editingProposal) return;
const artistInput = document.getElementById('mg-artist');
const winnerSelect = document.getElementById('mg-winner');
if (artistInput) window._editingProposal.canonical_artist_name = artistInput.value;
if (winnerSelect) window._editingProposal.winner_artist_id = parseInt(winnerSelect.value);
await api(`/merges/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ proposal: window._editingProposal }),
});
await openMergeDetail(id);
}
async function approveMerge(id) {
if (!confirm('Execute this merge? This will move files and update the database. This cannot be undone.')) return;
await api(`/merges/${id}/approve`, { method: 'POST' });
closeModal();
loadMerges();
}
async function rejectMerge(id) {
await api(`/merges/${id}/reject`, { method: 'POST' });
closeModal();
loadMerges();
}
async function retryMerge(id) {
await api(`/merges/${id}/retry`, { method: 'POST' });
closeModal();
loadMerges();
}
// --- Init ---
loadStats();
loadQueue();

View File

@@ -86,20 +86,27 @@ pub async fn approve_queue_item(State(state): State<S>, Path(id): Path<Uuid>) ->
let album_dir = sanitize_filename(album);
let dest = state.config.storage_dir.join(&artist_dir).join(&album_dir).join(&filename);
let storage_path = if dest.exists() && !source.exists() {
use crate::ingest::mover::MoveOutcome;
let (storage_path, was_merged) = if dest.exists() && !source.exists() {
// File already moved (e.g. auto-approved earlier but DB not finalized)
dest.to_string_lossy().to_string()
(dest.to_string_lossy().to_string(), false)
} 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(),
Ok(MoveOutcome::Moved(p)) => (p.to_string_lossy().to_string(), false),
Ok(MoveOutcome::Merged(p)) => (p.to_string_lossy().to_string(), true),
Err(e) => return 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(),
Ok(track_id) => {
if was_merged {
let _ = db::update_pending_status(&state.pool, id, "merged", None).await;
}
(StatusCode::OK, Json(serde_json::json!({"track_id": track_id}))).into_response()
}
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -189,19 +196,26 @@ pub async fn batch_approve(State(state): State<S>, Json(body): Json<BatchIds>) -
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()
use crate::ingest::mover::MoveOutcome;
let (rel_path, was_merged) = if dest.exists() && !source.exists() {
(dest.to_string_lossy().to_string(), false)
} 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(),
Ok(MoveOutcome::Moved(p)) => (p.to_string_lossy().to_string(), false),
Ok(MoveOutcome::Merged(p)) => (p.to_string_lossy().to_string(), true),
Err(e) => { errors.push(format!("{}: {}", id, e)); continue; }
}
};
match db::approve_and_finalize(&state.pool, *id, &rel_path).await {
Ok(_) => ok += 1,
Ok(_) => {
if was_merged {
let _ = db::update_pending_status(&state.pool, *id, "merged", None).await;
}
ok += 1;
}
Err(e) => errors.push(format!("{}: {}", id, e)),
}
}
@@ -312,6 +326,110 @@ pub async fn update_album(
}
}
// --- Merges ---
#[derive(Deserialize)]
pub struct CreateMergeBody {
pub artist_ids: Vec<i64>,
}
pub async fn create_merge(State(state): State<S>, Json(body): Json<CreateMergeBody>) -> impl IntoResponse {
if body.artist_ids.len() < 2 {
return error_response(StatusCode::BAD_REQUEST, "need at least 2 artists to merge");
}
match db::insert_artist_merge(&state.pool, &body.artist_ids).await {
Ok(id) => (StatusCode::OK, Json(serde_json::json!({"id": id}))).into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn list_merges(State(state): State<S>) -> impl IntoResponse {
match db::list_artist_merges(&state.pool).await {
Ok(items) => (StatusCode::OK, Json(serde_json::to_value(items).unwrap())).into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn get_merge(State(state): State<S>, Path(id): Path<Uuid>) -> impl IntoResponse {
let merge = match db::get_artist_merge(&state.pool, id).await {
Ok(Some(m)) => m,
Ok(None) => return error_response(StatusCode::NOT_FOUND, "not found"),
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
};
let source_ids: Vec<i64> = serde_json::from_str(&merge.source_artist_ids).unwrap_or_default();
let artists = match db::get_artists_full_data(&state.pool, &source_ids).await {
Ok(a) => a,
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
};
let proposal: Option<serde_json::Value> = merge.proposal.as_deref()
.and_then(|p| serde_json::from_str(p).ok());
(StatusCode::OK, Json(serde_json::json!({
"merge": {
"id": merge.id,
"status": merge.status,
"source_artist_ids": source_ids,
"llm_notes": merge.llm_notes,
"error_message": merge.error_message,
"created_at": merge.created_at,
"updated_at": merge.updated_at,
},
"artists": artists,
"proposal": proposal,
}))).into_response()
}
#[derive(Deserialize)]
pub struct UpdateMergeBody {
pub proposal: serde_json::Value,
}
pub async fn update_merge(
State(state): State<S>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateMergeBody>,
) -> impl IntoResponse {
let notes = body.proposal.get("notes")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let proposal_json = match serde_json::to_string(&body.proposal) {
Ok(s) => s,
Err(e) => return error_response(StatusCode::BAD_REQUEST, &e.to_string()),
};
match db::update_merge_proposal(&state.pool, id, &proposal_json, &notes).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn approve_merge(State(state): State<S>, Path(id): Path<Uuid>) -> impl IntoResponse {
match crate::merge::execute_merge(&state, id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
let msg = e.to_string();
let _ = db::update_merge_status(&state.pool, id, "error", Some(&msg)).await;
error_response(StatusCode::INTERNAL_SERVER_ERROR, &msg)
}
}
}
pub async fn reject_merge(State(state): State<S>, Path(id): Path<Uuid>) -> impl IntoResponse {
match db::update_merge_status(&state.pool, id, "rejected", None).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn retry_merge(State(state): State<S>, Path(id): Path<Uuid>) -> impl IntoResponse {
match db::update_merge_status(&state.pool, id, "pending", None).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
// --- Helpers ---
fn error_response(status: StatusCode, message: &str) -> axum::response::Response {

View File

@@ -12,6 +12,7 @@ pub struct AppState {
pub pool: PgPool,
pub config: Arc<Args>,
pub system_prompt: Arc<String>,
pub merge_prompt: Arc<String>,
}
pub fn build_router(state: Arc<AppState>) -> Router {
@@ -31,7 +32,12 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.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));
.route("/albums/:id", put(api::update_album))
.route("/merges", get(api::list_merges).post(api::create_merge))
.route("/merges/:id", get(api::get_merge).put(api::update_merge))
.route("/merges/:id/approve", post(api::approve_merge))
.route("/merges/:id/reject", post(api::reject_merge))
.route("/merges/:id/retry", post(api::retry_merge));
Router::new()
.route("/", get(admin_html))