Files
furumi-ng/furumi-agent/src/web/admin.html
T

1341 lines
60 KiB
HTML
Raw Normal View History

2026-03-18 02:21:00 +00:00
<!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; }
2026-03-18 04:05:47 +00:00
body { font-family: 'Inter', sans-serif; background: var(--bg-base); color: var(--text); display: flex; flex-direction: column; }
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
header { background: var(--bg-panel); border-bottom: 1px solid var(--border); padding: 10px 24px; display: flex; align-items: center; gap: 20px; flex-shrink: 0; }
header h1 { font-size: 15px; font-weight: 600; }
.stats { display: flex; gap: 14px; margin-left: auto; font-size: 12px; color: var(--text-dim); }
.stats .stat { display: flex; gap: 3px; align-items: center; }
2026-03-18 02:21:00 +00:00
.stats .stat-value { color: var(--text); font-weight: 600; }
2026-03-18 04:05:47 +00:00
.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; } }
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
nav { display: flex; gap: 3px; }
nav button { background: none; border: none; color: var(--text-muted); padding: 5px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; font-family: inherit; }
2026-03-18 02:21:00 +00:00
nav button:hover { background: var(--bg-hover); color: var(--text); }
nav button.active { background: var(--bg-active); color: var(--accent); }
2026-03-18 04:05:47 +00:00
/* Filter bar */
.filter-bar { display: flex; gap: 4px; padding: 10px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0; align-items: center; flex-wrap: wrap; }
.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; }
.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; }
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
main { flex: 1; overflow-y: auto; }
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
/* 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; }
2026-03-19 02:36:27 +00:00
td { padding: 4px 10px; border-bottom: 1px solid var(--border); max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; height: 30px; max-height: 30px; }
2026-03-18 04:05:47 +00:00
tr:hover td { background: var(--bg-hover); }
tr.selected td { background: var(--bg-active); }
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
/* Group header */
.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); }
.group-header:hover td { background: var(--bg-card); }
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
/* 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%; }
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
/* Checkbox */
.cb { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; }
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
.status { padding: 2px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; text-transform: uppercase; }
2026-03-18 02:21:00 +00:00
.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); }
2026-03-19 00:55:49 +00:00
.status-merged { background: #0c2340; color: #60a5fa; }
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
.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; }
2026-03-18 02:21:00 +00:00
.btn-approve { background: #052e16; color: var(--success); }
.btn-approve:hover { background: #065f46; }
.btn-reject { background: #450a0a; color: var(--danger); }
.btn-reject:hover { background: #7f1d1d; }
2026-03-18 04:05:47 +00:00
.btn-retry { background: #1e1b4b; color: var(--accent); }
.btn-retry:hover { background: #312e81; }
2026-03-18 02:21:00 +00:00
.btn-edit { background: var(--bg-active); color: var(--text-dim); }
.btn-edit:hover { background: var(--bg-hover); color: var(--text); }
.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); }
2026-03-18 04:05:47 +00:00
.empty { text-align: center; padding: 48px; color: var(--text-muted); font-size: 13px; }
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
/* 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; }
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
/* 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; }
.raw-value { font-size: 10px; color: var(--text-muted); margin-top: 1px; }
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
/* Featured artist tags */
.feat-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; min-height: 24px; }
.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; }
.feat-tag .remove { cursor: pointer; color: var(--text-muted); font-size: 13px; line-height: 1; }
2026-03-18 02:21:00 +00:00
.feat-tag .remove:hover { color: var(--danger); }
2026-03-18 04:05:47 +00:00
.artist-search-wrap { position: relative; margin-top: 5px; }
.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; }
2026-03-18 02:21:00 +00:00
.artist-dropdown.open { display: block; }
2026-03-18 04:05:47 +00:00
.artist-option { padding: 5px 9px; cursor: pointer; font-size: 12px; }
2026-03-18 02:21:00 +00:00
.artist-option:hover { background: var(--bg-hover); }
2026-03-19 00:55:49 +00:00
/* 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; }
2026-03-19 02:09:04 +00:00
/* Search bar */
.search-bar { display: flex; gap: 6px; padding: 10px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0; align-items: center; flex-wrap: wrap; }
.search-bar input { background: var(--bg-card); border: 1px solid var(--border); border-radius: 5px; padding: 5px 9px; color: var(--text); font-family: inherit; font-size: 12px; min-width: 140px; }
.search-bar input:focus { border-color: var(--accent); outline: none; }
.search-bar input::placeholder { color: var(--text-muted); }
.search-bar .search-label { font-size: 11px; color: var(--text-muted); }
.search-bar .total-label { margin-left: auto; font-size: 11px; color: var(--text-muted); }
/* Pagination */
.pagination { display: flex; gap: 3px; padding: 10px 24px; justify-content: center; flex-shrink: 0; border-top: 1px solid var(--border); }
.pagination button { background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; padding: 3px 9px; color: var(--text-muted); font-size: 11px; font-family: inherit; cursor: pointer; }
.pagination button:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
.pagination button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.pagination button:disabled { opacity: 0.3; cursor: default; }
2026-03-18 02:21:00 +00:00
</style>
</head>
<body>
<header>
<h1>Furumi Agent</h1>
<nav>
2026-03-18 04:05:47 +00:00
<button class="active" onclick="showTab('queue',this)">Queue</button>
2026-03-19 02:09:04 +00:00
<button onclick="showTab('tracks',this)">Tracks</button>
<button onclick="showTab('albums',this)">Albums</button>
2026-03-18 04:05:47 +00:00
<button onclick="showTab('artists',this)">Artists</button>
2026-03-19 00:55:49 +00:00
<button onclick="showTab('merges',this)">Merges</button>
2026-03-18 02:21:00 +00:00
</nav>
2026-03-18 04:05:47 +00:00
<span class="agent-status idle" id="agentStatus">Idle</span>
2026-03-18 02:21:00 +00:00
<div class="stats" id="statsBar"></div>
</header>
2026-03-18 04:05:47 +00:00
<div class="filter-bar" id="filterBar"></div>
2026-03-18 02:21:00 +00:00
<main id="content"></main>
2026-03-18 04:05:47 +00:00
<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>
2026-03-18 02:21:00 +00:00
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this)closeModal()">
<div class="modal" id="modal"></div>
</div>
2026-03-19 00:55:49 +00:00
<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>
2026-03-18 02:21:00 +00:00
<script>
const _base = location.pathname.replace(/\/+$/, '');
const API = _base + '/api';
2026-03-18 02:21:00 +00:00
let currentTab = 'queue';
let currentFilter = null;
2026-03-18 04:05:47 +00:00
let queueItems = [];
let selected = new Set();
let searchTimer = null;
let editFeatured = [];
let statsCache = null;
2026-03-19 02:36:27 +00:00
let queuePageSize = 50;
let queueOffset = 0;
let queueTotal = 0;
2026-03-18 02:21:00 +00:00
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); }
2026-03-18 04:05:47 +00:00
catch(e) { console.error('API error:', r.status, text); return null; }
2026-03-18 02:21:00 +00:00
}
2026-03-18 04:05:47 +00:00
// --- Stats & polling ---
2026-03-18 02:21:00 +00:00
async function loadStats() {
const s = await api('/stats');
2026-03-18 04:05:47 +00:00
if (!s) return;
statsCache = s;
2026-03-18 02:21:00 +00:00
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>
2026-03-18 04:05:47 +00:00
`;
// Agent status
const el = document.getElementById('agentStatus');
2026-03-19 02:09:04 +00:00
if (s.pending_count > 0 || s.active_merges > 0) { el.textContent = 'Processing...'; el.className = 'agent-status busy'; }
2026-03-18 04:05:47 +00:00
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>
2026-03-19 00:55:49 +00:00
<button class="filter-btn ${f==='merged'?'active':''}" onclick="loadQueue('merged')">Merged<span class="count">${s.merged_count}</span></button>
2026-03-18 04:05:47 +00:00
<button class="filter-btn ${f==='approved'?'active':''}" onclick="loadQueue('approved')">Approved</button>
<button class="filter-btn ${f==='rejected'?'active':''}" onclick="loadQueue('rejected')">Rejected</button>
2026-03-19 02:36:27 +00:00
<select style="margin-left:auto;background:var(--bg-card);border:1px solid var(--border);border-radius:5px;padding:3px 6px;color:var(--text);font-size:11px;font-family:inherit" onchange="setQueuePageSize(parseInt(this.value))">
<option value="50" ${queuePageSize===50?'selected':''}>50</option>
<option value="100" ${queuePageSize===100?'selected':''}>100</option>
<option value="200" ${queuePageSize===200?'selected':''}>200</option>
</select>
2026-03-18 02:21:00 +00:00
`;
}
2026-03-18 04:05:47 +00:00
function showTab(tab, btn) {
2026-03-18 02:21:00 +00:00
currentTab = tab;
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
2026-03-18 04:05:47 +00:00
btn.classList.add('active');
clearSelection();
2026-03-19 02:09:04 +00:00
const pag = document.getElementById('lib-pagination');
if (pag) pag.style.display = 'none';
2026-03-18 04:05:47 +00:00
if (tab === 'queue') { loadQueue(); loadStats(); }
2026-03-19 02:36:27 +00:00
else if (tab === 'artists') { libPage.artists = 0; renderLibSearchBar([{ label: 'Artist', key: 'q', tab: 'artists', value: libSearch.artists.q, placeholder: 'search artist…' }], ''); loadLibArtists(); }
else if (tab === 'tracks') { libPage.tracks = 0; renderLibSearchBar([{ label: 'Title', key: 'q', tab: 'tracks', value: libSearch.tracks.q, placeholder: 'search title…' }, { label: 'Artist', key: 'artist', tab: 'tracks', value: libSearch.tracks.artist, placeholder: 'search artist…' }, { label: 'Album', key: 'album', tab: 'tracks', value: libSearch.tracks.album, placeholder: 'search album…' }], ''); loadLibTracks(); }
else if (tab === 'albums') { libPage.albums = 0; renderLibSearchBar([{ label: 'Album', key: 'q', tab: 'albums', value: libSearch.albums.q, placeholder: 'search album…' }, { label: 'Artist', key: 'artist', tab: 'albums', value: libSearch.albums.artist, placeholder: 'search artist…' }], ''); loadLibAlbums(); }
2026-03-19 00:55:49 +00:00
else if (tab === 'merges') { loadMerges(); document.getElementById('filterBar').innerHTML = ''; }
2026-03-18 02:21:00 +00:00
}
2026-03-18 04:05:47 +00:00
// --- Queue ---
async function loadQueue(status, keepSelection) {
2026-03-18 02:21:00 +00:00
currentFilter = status;
2026-03-19 02:36:27 +00:00
if (!keepSelection) { clearSelection(); queueOffset = 0; }
const qs = `?${status ? 'status='+status+'&' : ''}limit=${queuePageSize+1}&offset=${queueOffset}`;
const raw = await api(`/queue${qs}`) || [];
const hasMore = raw.length > queuePageSize;
queueItems = hasMore ? raw.slice(0, queuePageSize) : raw;
2026-03-18 04:05:47 +00:00
// 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();
2026-03-19 02:36:27 +00:00
renderQueue(hasMore);
2026-03-18 04:05:47 +00:00
if (statsCache) renderFilterBar(statsCache);
}
2026-03-19 02:36:27 +00:00
function setQueuePageSize(n) { queuePageSize = n; queueOffset = 0; loadQueue(currentFilter); }
function queueGo(offset) { queueOffset = Math.max(0, offset); loadQueue(currentFilter, true); }
function renderQueue(hasMore) {
2026-03-18 02:21:00 +00:00
const el = document.getElementById('content');
2026-03-18 04:05:47 +00:00
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);
2026-03-18 02:21:00 +00:00
}
2026-03-18 04:05:47 +00:00
let html = `<table><tr>
<th style="width:30px"><input type="checkbox" class="cb" onchange="toggleSelectAll(this.checked)"></th>
<th style="width:70px">Status</th>
<th>Artist</th>
<th>Title</th>
<th>Album</th>
<th style="width:40px">Yr</th>
<th style="width:30px">#</th>
<th style="width:40px">Conf</th>
<th style="width:130px">Actions</th>
</tr>`;
const renderRow = (it) => {
const sel = selected.has(it.id) ? ' selected' : '';
2026-03-18 02:21:00 +00:00
const conf = it.confidence != null ? it.confidence.toFixed(2) : '-';
2026-03-18 04:05:47 +00:00
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>
2026-03-18 02:21:00 +00:00
<td><span class="status status-${it.status}">${it.status}</span></td>
2026-03-18 04:05:47 +00:00
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_artist')" title="${esc(it.raw_artist||'')}">${esc(artist)}</td>
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_title')" title="${esc(it.raw_title||'')}">${esc(title)}</td>
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_album')" title="${esc(it.raw_album||'')}">${esc(album)}</td>
<td>${year}</td>
<td>${tnum}</td>
2026-03-18 02:21:00 +00:00
<td>${conf}</td>
<td class="actions">
2026-03-18 04:05:47 +00:00
${canApprove ? `<button class="btn btn-approve" onclick="approveItem('${it.id}')">Approve</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>` : ''}
2026-03-18 02:21:00 +00:00
<button class="btn btn-edit" onclick="editItem('${it.id}')">Edit</button>
</td>
</tr>`;
2026-03-18 04:05:47 +00:00
};
// Render grouped
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 || '';
const yearStr = year ? ` (${year})` : '';
const albumIds = items.map(i => i.id);
html += `<tr class="group-header">
<td><input type="checkbox" class="cb" onchange="toggleSelectGroup(${JSON.stringify(albumIds).replace(/"/g,'&quot;')},this.checked)"></td>
<td colspan="8">${esc(artist)}${esc(albumName)}${yearStr} &nbsp; (${items.length} tracks)</td>
</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);
2026-03-18 02:21:00 +00:00
}
html += '</table>';
2026-03-19 02:36:27 +00:00
let pagHtml = '<div class="pagination">';
if (queueOffset > 0) pagHtml += `<button onclick="queueGo(${queueOffset - queuePageSize})"> Prev</button>`;
if (hasMore) pagHtml += `<button onclick="queueGo(${queueOffset + queuePageSize})">Next </button>`;
pagHtml += '</div>';
el.innerHTML = html + pagHtml;
2026-03-18 02:21:00 +00:00
}
2026-03-18 04:05:47 +00:00
// --- Selection ---
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);
}
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
function toggleSelectAll(checked) {
selected.clear();
if (checked) queueItems.forEach(it => selected.add(it.id));
updateBatchBar();
renderQueue();
}
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
function toggleSelectGroup(ids, checked) {
ids.forEach(id => { if (checked) selected.add(id); else selected.delete(id); });
updateBatchBar();
renderQueue();
2026-03-18 02:21:00 +00:00
}
2026-03-18 04:05:47 +00:00
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');
}
2026-03-18 02:21:00 +00:00
}
2026-03-18 04:05:47 +00:00
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();
2026-03-18 02:21:00 +00:00
loadStats();
loadQueue(currentFilter);
}
2026-03-18 04:05:47 +00:00
// --- Single actions ---
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); }
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;
};
2026-03-18 02:21:00 +00:00
2026-03-18 04:05:47 +00:00
input.addEventListener('blur', save);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { td.textContent = current; }
});
}
// --- Full edit modal ---
2026-03-18 02:21:00 +00:00
async function editItem(id) {
const item = await api(`/queue/${id}`);
if (!item) return;
editFeatured = [];
if (item.norm_featured_artists) {
try { editFeatured = JSON.parse(item.norm_featured_artists); } catch(e) {}
}
2026-03-19 00:55:49 +00:00
// 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; };
2026-03-18 02:21:00 +00:00
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>
2026-03-19 00:55:49 +00:00
${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>
2026-03-18 02:21:00 +00:00
<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();
}
2026-03-18 04:05:47 +00:00
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);
}
// --- Featured artists ---
2026-03-18 02:21:00 +00:00
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('');
}
2026-03-18 04:05:47 +00:00
function removeFeat(idx) { editFeatured.splice(idx, 1); renderFeatTags(); }
2026-03-18 02:21:00 +00:00
function addFeat(name) {
name = name.trim();
if (!name || editFeatured.includes(name)) return;
editFeatured.push(name);
renderFeatTags();
const input = document.getElementById('feat-search');
2026-03-18 04:05:47 +00:00
if (input) input.value = '';
2026-03-18 02:21:00 +00:00
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) {
2026-03-18 04:05:47 +00:00
dd.innerHTML = `<div class="artist-option" onclick="addFeat('${esc(q)}')">Add "${esc(q)}" as new</div>`;
2026-03-18 02:21:00 +00:00
dd.classList.add('open');
return;
}
2026-03-18 04:05:47 +00:00
let html = results.map(a => `<div class="artist-option" onclick="addFeat('${esc(a.name)}')">${esc(a.name)}</div>`).join('');
2026-03-18 02:21:00 +00:00
const typed = document.getElementById('feat-search').value.trim();
if (typed && !results.find(a => a.name.toLowerCase() === typed.toLowerCase())) {
2026-03-18 04:05:47 +00:00
html += `<div class="artist-option" onclick="addFeat('${esc(typed)}')">Add "${esc(typed)}" as new</div>`;
2026-03-18 02:21:00 +00:00
}
dd.innerHTML = html;
dd.classList.add('open');
}, 250);
}
function onFeatKey(e) {
2026-03-18 04:05:47 +00:00
if (e.key === 'Enter') { e.preventDefault(); addFeat(e.target.value); }
else if (e.key === 'Escape') closeFeatDropdown();
2026-03-18 02:21:00 +00:00
}
2026-03-18 04:05:47 +00:00
function closeFeatDropdown() { const dd = document.getElementById('feat-dropdown'); if (dd) dd.classList.remove('open'); }
2026-03-18 02:21:00 +00:00
2026-03-19 02:09:04 +00:00
// --- Library tabs (Tracks / Albums / Artists) ---
2026-03-19 02:36:27 +00:00
let LIB_LIMIT = 50;
2026-03-19 02:09:04 +00:00
const libPage = { tracks: 0, albums: 0, artists: 0 };
const libSearch = {
tracks: { q: '', artist: '', album: '' },
albums: { q: '', artist: '' },
artists: { q: '' },
};
const libTotal = { tracks: 0, albums: 0, artists: 0 };
function fmtDuration(s) {
if (s == null) return '';
const m = Math.floor(s / 60), ss = Math.floor(s % 60);
return m + ':' + (ss < 10 ? '0' : '') + ss;
}
function renderPagination(container, total, page, onGo) {
const pages = Math.max(1, Math.ceil(total / LIB_LIMIT));
if (pages <= 1) { container.innerHTML = ''; return; }
const maxBtn = 7, half = Math.floor(maxBtn / 2);
let start = Math.max(0, page - half), end = Math.min(pages - 1, start + maxBtn - 1);
if (end - start < maxBtn - 1) start = Math.max(0, end - maxBtn + 1);
let html = `<button ${page===0?'disabled':''} onclick="${onGo}(${page-1})"></button>`;
if (start > 0) html += `<button onclick="${onGo}(0)">1</button>${start>1?'<button disabled>…</button>':''}`;
for (let i = start; i <= end; i++) html += `<button class="${i===page?'active':''}" onclick="${onGo}(${i})">${i+1}</button>`;
if (end < pages - 1) html += `${end<pages-2?'<button disabled>…</button>':''}<button onclick="${onGo}(${pages-1})">${pages}</button>`;
html += `<button ${page===pages-1?'disabled':''} onclick="${onGo}(${page+1})"></button>`;
container.innerHTML = html;
}
function libSearchInput(tab, field, val) {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
libSearch[tab][field] = val;
libPage[tab] = 0;
if (tab === 'tracks') loadLibTracks();
else if (tab === 'albums') loadLibAlbums();
else if (tab === 'artists') loadLibArtists();
}, 300);
}
function renderLibSearchBar(fields, totalLabel) {
// fields: [{id, label, key, tab, placeholder}]
const bar = document.getElementById('filterBar');
let html = fields.map(f =>
`<span class="search-label">${f.label}</span>
<input value="${esc(f.value)}" placeholder="${f.placeholder}"
oninput="libSearchInput('${f.tab}','${f.key}',this.value)"
style="min-width:150px">`
).join('');
2026-03-19 02:36:27 +00:00
html += `<span class="total-label"><span id="lib-total">${totalLabel}</span></span>`;
html += `<select id="lib-page-size" onchange="setLibPageSize(parseInt(this.value))" style="margin-left:8px;background:var(--bg-card);border:1px solid var(--border);border-radius:5px;padding:4px 6px;color:var(--text);font-size:11px;font-family:inherit">
<option value="50" ${LIB_LIMIT===50?'selected':''}>50</option>
<option value="100" ${LIB_LIMIT===100?'selected':''}>100</option>
<option value="200" ${LIB_LIMIT===200?'selected':''}>200</option>
</select>`;
2026-03-19 02:09:04 +00:00
bar.innerHTML = `<div class="search-bar" style="padding:0;border:none;width:100%">${html}</div>`;
}
2026-03-19 02:36:27 +00:00
function setLibPageSize(n) {
LIB_LIMIT = n;
libPage[currentTab] = 0;
if (currentTab === 'tracks') loadLibTracks();
else if (currentTab === 'albums') loadLibAlbums();
else if (currentTab === 'artists') loadLibArtists();
}
2026-03-19 02:09:04 +00:00
// ---- Tracks ----
async function loadLibTracks() {
const s = libSearch.tracks, p = libPage.tracks;
const params = new URLSearchParams({ q: s.q, artist: s.artist, album: s.album, limit: LIB_LIMIT, offset: p * LIB_LIMIT });
const data = await api('/library/tracks?' + params);
if (!data) return;
2026-03-19 02:36:27 +00:00
const tot = document.getElementById('lib-total');
if (tot) tot.textContent = `${data.total} tracks`;
2026-03-19 02:09:04 +00:00
2026-03-18 04:05:47 +00:00
const el = document.getElementById('content');
2026-03-19 02:09:04 +00:00
if (!data.items.length) {
el.innerHTML = '<div class="empty">No tracks found</div>';
} else {
2026-03-19 02:36:27 +00:00
let html = `<table><tr><th style="width:30px">#</th><th>Title</th><th>Artist</th><th>Album</th><th style="width:40px">Year</th><th style="width:45px">Dur</th><th>Genre</th><th style="width:50px">Edit</th></tr>`;
2026-03-19 02:09:04 +00:00
for (const t of data.items) {
html += `<tr>
<td style="color:var(--text-muted)">${t.track_number ?? ''}</td>
<td>${esc(t.title)}</td>
<td>${esc(t.artist_name)}</td>
2026-03-19 02:36:27 +00:00
<td><span style="display:inline-flex;align-items:center;gap:5px">${t.album_id ? `<img src="${API}/albums/${t.album_id}/cover" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'">` : ''}<span>${esc(t.album_name ?? '')}</span></span></td>
2026-03-19 02:09:04 +00:00
<td>${t.year ?? ''}</td>
<td style="color:var(--text-muted)">${fmtDuration(t.duration_secs)}</td>
<td style="color:var(--text-muted)">${esc(t.genre ?? '')}</td>
2026-03-19 02:36:27 +00:00
<td><button class="btn btn-edit" onclick="openTrackEdit(${t.id})">Edit</button></td>
2026-03-19 02:09:04 +00:00
</tr>`;
}
el.innerHTML = html + '</table>';
}
renderLibPagination(data.total, p, 'goLibTracks');
}
function goLibTracks(page) { libPage.tracks = page; loadLibTracks(); }
// ---- Albums ----
async function loadLibAlbums() {
const s = libSearch.albums, p = libPage.albums;
const params = new URLSearchParams({ q: s.q, artist: s.artist, limit: LIB_LIMIT, offset: p * LIB_LIMIT });
const data = await api('/library/albums?' + params);
if (!data) return;
2026-03-19 02:36:27 +00:00
const tot = document.getElementById('lib-total');
if (tot) tot.textContent = `${data.total} albums`;
2026-03-19 02:09:04 +00:00
const el = document.getElementById('content');
if (!data.items.length) {
el.innerHTML = '<div class="empty">No albums found</div>';
} else {
2026-03-19 02:36:27 +00:00
let html = `<table><tr><th>Album</th><th>Artist</th><th style="width:50px">Year</th><th style="width:60px">Tracks</th><th style="width:50px">Edit</th></tr>`;
2026-03-19 02:09:04 +00:00
for (const a of data.items) {
html += `<tr>
2026-03-19 02:36:27 +00:00
<td><span style="display:inline-flex;align-items:center;gap:5px"><img src="${API}/albums/${a.id}/cover" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'"><span>${esc(a.name)}</span></span></td>
2026-03-19 02:09:04 +00:00
<td>${esc(a.artist_name)}</td>
<td>${a.year ?? ''}</td>
<td style="color:var(--text-muted)">${a.track_count}</td>
2026-03-19 02:36:27 +00:00
<td><button class="btn btn-edit" onclick="openAlbumEdit(${a.id})">Edit</button></td>
2026-03-19 02:09:04 +00:00
</tr>`;
}
el.innerHTML = html + '</table>';
}
renderLibPagination(data.total, p, 'goLibAlbums');
}
function goLibAlbums(page) { libPage.albums = page; loadLibAlbums(); }
// ---- Artists ----
async function loadLibArtists() {
const s = libSearch.artists, p = libPage.artists;
const params = new URLSearchParams({ q: s.q, limit: LIB_LIMIT, offset: p * LIB_LIMIT });
const data = await api('/library/artists?' + params);
if (!data) return;
2026-03-19 02:36:27 +00:00
const tot = document.getElementById('lib-total');
if (tot) tot.textContent = `${data.total} artists`;
2026-03-19 02:09:04 +00:00
const el = document.getElementById('content');
if (!data.items.length) {
el.innerHTML = '<div class="empty">No artists found</div>';
} else {
let html = `<table><tr>
<th style="width:30px"></th><th style="width:50px">ID</th><th>Name</th>
<th style="width:60px">Albums</th><th style="width:60px">Tracks</th>
<th style="width:80px">Actions</th>
2026-03-18 04:05:47 +00:00
</tr>`;
2026-03-19 02:09:04 +00:00
for (const a of data.items) {
html += `<tr>
<td><input type="checkbox" class="cb" ${selectedArtists.has(a.id)?'checked':''} onchange="toggleSelectArtist(${a.id},this.checked)"></td>
<td style="color:var(--text-muted)">${a.id}</td>
<td class="editable" ondblclick="inlineEditArtist(this,${a.id})">${esc(a.name)}</td>
<td style="color:var(--text-muted)">${a.album_count}</td>
<td style="color:var(--text-muted)">${a.track_count}</td>
<td class="actions"><button class="btn btn-edit" onclick="editArtist(${a.id},'${esc(a.name)}')">Rename</button></td>
</tr>`;
}
el.innerHTML = html + '</table>';
2026-03-18 04:05:47 +00:00
}
2026-03-19 02:09:04 +00:00
renderLibPagination(data.total, p, 'goLibArtists');
}
function goLibArtists(page) { libPage.artists = page; loadLibArtists(); }
function renderLibPagination(total, page, goFn) {
// Render pagination bar below main content
let pag = document.getElementById('lib-pagination');
if (!pag) {
pag = document.createElement('div');
pag.id = 'lib-pagination';
pag.className = 'pagination';
document.querySelector('body').appendChild(pag);
}
renderPagination(pag, total, page, goFn);
// hide if not needed
pag.style.display = total <= LIB_LIMIT ? 'none' : 'flex';
2026-03-18 02:21:00 +00:00
}
2026-03-19 02:09:04 +00:00
// Keep alias for clearArtistSelection
function loadArtists() { loadLibArtists(); }
2026-03-18 04:05:47 +00:00
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 }),
});
2026-03-18 02:21:00 +00:00
};
2026-03-18 04:05:47 +00:00
input.addEventListener('blur', save);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { td.textContent = current; }
2026-03-18 02:21:00 +00:00
});
}
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();
}
2026-03-19 02:36:27 +00:00
// --- Track edit modal ---
async function openTrackEdit(id) {
const t = await api(`/tracks/${id}`);
if (!t) return;
editFeatured = [...(t.featured_artists || [])];
document.getElementById('modal').className = 'modal';
document.getElementById('modal').innerHTML = `
<h2>Edit Track</h2>
${t.album_id ? `<div style="float:right;margin-left:12px"><img src="${API}/albums/${t.album_id}/cover" width="80" height="80" style="border-radius:6px;object-fit:cover" onerror="this.style.display='none'"></div>` : ''}
<label>Title</label>
<input id="te-title" value="${esc(t.title)}">
<label>Artist</label>
<div class="artist-search-wrap" style="margin-top:3px">
<input id="te-artist-name" value="${esc(t.artist_name)}" autocomplete="off"
oninput="onTeArtistSearch(this.value)" onkeydown="onTeArtistKey(event)"
placeholder="Search artist…">
<div class="artist-dropdown" id="te-artist-dropdown"></div>
</div>
<input type="hidden" id="te-artist-id" value="${t.artist_id}">
<label>Album</label>
<div class="artist-search-wrap" style="margin-top:3px">
<input id="te-album-name" value="${esc(t.album_name || '')}" autocomplete="off"
oninput="onTeAlbumSearch(this.value)" onkeydown="onTeAlbumKey(event)"
placeholder="Search album…">
<div class="artist-dropdown" id="te-album-dropdown"></div>
</div>
<input type="hidden" id="te-album-id" value="${t.album_id || ''}">
<div class="detail-row">
<div class="field">
<label>Track #</label>
<input id="te-tracknum" type="number" value="${t.track_number ?? ''}">
</div>
<div class="field">
<label>Genre</label>
<input id="te-genre" value="${esc(t.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>
<div class="raw-value" style="margin-top:8px;clear:both">
Duration: ${fmtDuration(t.duration_secs)} · ${t.file_size ? (t.file_size/1048576).toFixed(1)+' MB' : ''} · ${esc(t.storage_path)}
</div>
<div class="modal-actions">
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveTrackEdit(${t.id})">Save</button>
</div>
`;
renderFeatTags();
openModal();
}
let teArtistTimer = null, teAlbumTimer = null;
function onTeArtistSearch(q) {
clearTimeout(teArtistTimer);
document.getElementById('te-artist-id').value = '';
if (q.length < 2) { document.getElementById('te-artist-dropdown').classList.remove('open'); return; }
teArtistTimer = setTimeout(async () => {
const results = await api(`/artists/search?q=${encodeURIComponent(q)}&limit=8`);
const dd = document.getElementById('te-artist-dropdown');
if (!results || !results.length) { dd.classList.remove('open'); return; }
dd.innerHTML = results.map(a => `<div class="artist-option" onclick="selectTeArtist(${a.id},'${esc(a.name)}')">${esc(a.name)}</div>`).join('');
dd.classList.add('open');
}, 200);
}
function selectTeArtist(id, name) {
document.getElementById('te-artist-id').value = id;
document.getElementById('te-artist-name').value = name;
document.getElementById('te-artist-dropdown').classList.remove('open');
// refresh album search with new artist
document.getElementById('te-album-name').value = '';
document.getElementById('te-album-id').value = '';
}
function onTeArtistKey(e) {
if (e.key === 'Escape') document.getElementById('te-artist-dropdown').classList.remove('open');
}
function onTeAlbumSearch(q) {
clearTimeout(teAlbumTimer);
document.getElementById('te-album-id').value = '';
const artistId = document.getElementById('te-artist-id').value;
if (q.length < 1) { document.getElementById('te-album-dropdown').classList.remove('open'); return; }
teAlbumTimer = setTimeout(async () => {
const params = new URLSearchParams({ q, ...(artistId ? { artist_id: artistId } : {}) });
const results = await api(`/albums/search?${params}`);
const dd = document.getElementById('te-album-dropdown');
if (!results || !results.length) { dd.classList.remove('open'); return; }
dd.innerHTML = results.map(a => `<div class="artist-option" onclick="selectTeAlbum(${a.id},'${esc(a.name)}')">${esc(a.name)}</div>`).join('');
dd.classList.add('open');
}, 200);
}
function selectTeAlbum(id, name) {
document.getElementById('te-album-id').value = id;
document.getElementById('te-album-name').value = name;
document.getElementById('te-album-dropdown').classList.remove('open');
}
function onTeAlbumKey(e) {
if (e.key === 'Escape') document.getElementById('te-album-dropdown').classList.remove('open');
}
async function saveTrackEdit(id) {
const artistId = parseInt(document.getElementById('te-artist-id').value);
const albumIdVal = document.getElementById('te-album-id').value;
if (!artistId) { alert('Please select an artist from the dropdown'); return; }
const body = {
title: document.getElementById('te-title').value,
artist_id: artistId,
album_id: albumIdVal ? parseInt(albumIdVal) : null,
track_number: parseInt(document.getElementById('te-tracknum').value) || null,
genre: document.getElementById('te-genre').value || null,
featured_artists: editFeatured,
};
await api(`/tracks/${id}`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
closeModal();
if (currentTab === 'tracks') loadLibTracks();
}
// --- Album edit modal ---
async function openAlbumEdit(id) {
const d = await api(`/albums/${id}/full`);
if (!d) return;
document.getElementById('modal').className = 'modal modal-wide';
document.getElementById('modal').innerHTML = `
<h2>Edit Album</h2>
<div style="display:flex;gap:16px;align-items:flex-start">
<img src="${API}/albums/${id}/cover" width="80" height="80" style="border-radius:6px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'">
<div style="flex:1">
<label>Album name</label>
<input id="ae-name" value="${esc(d.name)}">
<div class="detail-row">
<div class="field">
<label>Year</label>
<input id="ae-year" type="number" value="${d.year ?? ''}">
</div>
<div class="field">
<label>Artist</label>
<div class="artist-search-wrap" style="margin-top:3px">
<input id="ae-artist-name" value="${esc(d.artist_name)}" autocomplete="off"
oninput="onAeArtistSearch(this.value)" onkeydown="if(event.key==='Escape')document.getElementById('ae-artist-dropdown').classList.remove('open')" placeholder="Search artist…">
<div class="artist-dropdown" id="ae-artist-dropdown"></div>
</div>
<input type="hidden" id="ae-artist-id" value="${d.artist_id}">
</div>
</div>
</div>
</div>
<div class="section-label">Tracks <span style="font-weight:400;color:var(--text-dim)">(drag to reorder)</span></div>
<ul id="ae-track-list" style="list-style:none;margin:0;padding:0;max-height:350px;overflow-y:auto;border:1px solid var(--border);border-radius:5px">
${d.tracks.map((t, i) => `
<li data-id="${t.id}" data-idx="${i}" draggable="true"
style="display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--bg-card);cursor:grab;font-size:12px"
ondragstart="aeDragStart(event)" ondragover="aeDragOver(event)" ondrop="aeDrop(event)" ondragend="aeDragEnd()">
<span style="color:var(--text-muted);width:24px;text-align:right;flex-shrink:0">${t.track_number ?? ''}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.title)}</span>
<span style="color:var(--text-muted);font-size:11px;flex-shrink:0">${fmtDuration(t.duration_secs)}</span>
<button class="btn btn-edit" style="flex-shrink:0" onclick="openTrackEditFromAlbum(${t.id},${id})">Edit</button>
</li>
`).join('')}
</ul>
<div class="modal-actions">
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button>
<button class="btn btn-edit" onclick="saveAlbumReorder(${id})">Save Order</button>
<button class="btn btn-primary" onclick="saveAlbumEdit(${id})">Save Album</button>
</div>
`;
openModal();
}
// Drag and drop for album track list
let aeDragSrc = null;
function aeDragStart(e) {
aeDragSrc = e.currentTarget;
e.dataTransfer.effectAllowed = 'move';
e.currentTarget.style.opacity = '0.4';
}
function aeDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const li = e.currentTarget;
const list = li.parentNode;
const rect = li.getBoundingClientRect();
const after = e.clientY > rect.top + rect.height / 2;
if (after) list.insertBefore(aeDragSrc, li.nextSibling);
else list.insertBefore(aeDragSrc, li);
}
function aeDrop(e) { e.preventDefault(); }
function aeDragEnd() {
if (aeDragSrc) aeDragSrc.style.opacity = '';
aeDragSrc = null;
}
let aeArtistTimer = null;
function onAeArtistSearch(q) {
clearTimeout(aeArtistTimer);
document.getElementById('ae-artist-id').value = '';
if (q.length < 2) { document.getElementById('ae-artist-dropdown').classList.remove('open'); return; }
aeArtistTimer = setTimeout(async () => {
const results = await api(`/artists/search?q=${encodeURIComponent(q)}&limit=8`);
const dd = document.getElementById('ae-artist-dropdown');
if (!results || !results.length) { dd.classList.remove('open'); return; }
dd.innerHTML = results.map(a => `<div class="artist-option" onclick="selectAeArtist(${a.id},'${esc(a.name)}')">${esc(a.name)}</div>`).join('');
dd.classList.add('open');
}, 200);
}
function selectAeArtist(id, name) {
document.getElementById('ae-artist-id').value = id;
document.getElementById('ae-artist-name').value = name;
document.getElementById('ae-artist-dropdown').classList.remove('open');
}
async function saveAlbumEdit(id) {
const artistId = parseInt(document.getElementById('ae-artist-id').value);
if (!artistId) { alert('Please select an artist from the dropdown'); return; }
const body = {
name: document.getElementById('ae-name').value,
year: parseInt(document.getElementById('ae-year').value) || null,
artist_id: artistId,
};
await api(`/albums/${id}/edit`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
if (currentTab === 'albums') loadLibAlbums();
}
async function saveAlbumReorder(id) {
const items = document.querySelectorAll('#ae-track-list li');
const orders = Array.from(items).map((li, i) => [parseInt(li.dataset.id), i + 1]);
await api(`/albums/${id}/reorder`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ orders }) });
// update displayed numbers
items.forEach((li, i) => { li.querySelector('span').textContent = i + 1; });
}
async function openTrackEditFromAlbum(trackId, albumId) {
closeModal();
await openTrackEdit(trackId);
}
2026-03-18 04:05:47 +00:00
// --- Helpers ---
2026-03-18 02:21:00 +00:00
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;');
}
2026-03-19 00:55:49 +00:00
// --- 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();
}
2026-03-18 04:05:47 +00:00
// --- Init ---
2026-03-18 02:21:00 +00:00
loadStats();
loadQueue();
2026-03-18 04:05:47 +00:00
setInterval(loadStats, 5000);
// Auto-refresh queue when on queue tab
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);
2026-03-18 02:21:00 +00:00
</script>
</body>
</html>