Files
furumi-ng/furumi-agent/src/web/admin.html
T
ab a730ab568c
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m6s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m7s
Publish Server Image / build-and-push-image (push) Successful in 2m13s
Improved admin UI
2026-03-19 15:28:25 +00:00

1723 lines
80 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Furumi Agent — Admin</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-base: #0a0c12;
--bg-panel: #111520;
--bg-card: #161d2e;
--bg-hover: #1e2740;
--bg-active: #252f4a;
--border: #1f2c45;
--accent: #7c6af7;
--accent-dim: #5a4fcf;
--text: #e2e8f0;
--text-muted: #64748b;
--text-dim: #94a3b8;
--success: #34d399;
--danger: #f87171;
--warning: #fbbf24;
}
html, body { height: 100%; overflow: hidden; }
body { font-family: 'Inter', sans-serif; background: var(--bg-base); color: var(--text); display: flex; flex-direction: column; }
header { background: var(--bg-panel); border-bottom: 1px solid var(--border); padding: 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; }
.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 { 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; }
nav button:hover { background: var(--bg-hover); color: var(--text); }
nav button.active { background: var(--bg-active); color: var(--accent); }
/* 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; }
main { flex: 1; overflow-y: auto; }
/* 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: 4px 10px; border-bottom: 1px solid var(--border); max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; height: 30px; max-height: 30px; }
tr:hover td { background: var(--bg-hover); }
tr.selected td { background: var(--bg-active); }
/* 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); }
/* 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-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); }
.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; }
.btn-approve { background: #052e16; color: var(--success); }
.btn-approve:hover { background: #065f46; }
.btn-reject { background: #450a0a; color: var(--danger); }
.btn-reject:hover { background: #7f1d1d; }
.btn-retry { background: #1e1b4b; color: var(--accent); }
.btn-retry:hover { background: #312e81; }
.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); }
.empty { text-align: center; padding: 48px; color: var(--text-muted); font-size: 13px; }
/* 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; }
.raw-value { font-size: 10px; color: var(--text-muted); margin-top: 1px; }
/* 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; }
.feat-tag .remove:hover { color: var(--danger); }
.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; }
.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; }
/* 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; }
/* Release type badges */
.release-badge { font-size: 9px; font-weight: 700; text-transform: uppercase; padding: 1px 5px; border-radius: 3px; letter-spacing: 0.04em; }
.rb-album { background: #1e2740; color: var(--text-dim); }
.rb-single { background: #1e3a2e; color: #6ee7b7; }
.rb-ep { background: #2e1e3a; color: #c4b5fd; }
.rb-compilation{ background: #3a2e1e; color: #fcd34d; }
.rb-live { background: #3a1e1e; color: #fca5a5; }
.hidden-badge { font-size: 9px; font-weight: 700; text-transform: uppercase; padding: 1px 5px; border-radius: 3px; background: #1a1a1a; color: #555; letter-spacing: 0.04em; }
/* Artist admin form */
.artist-section { margin-top: 14px; }
.artist-section-title { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; display: flex; align-items: center; gap: 8px; }
.album-block { border: 1px solid var(--border); border-radius: 6px; margin-bottom: 8px; overflow: hidden; }
.album-block-header { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--bg-card); cursor: pointer; }
.album-block-header img { width: 36px; height: 36px; border-radius: 3px; object-fit: cover; flex-shrink: 0; }
.album-block-header .ab-name { flex: 1; font-size: 12px; font-weight: 500; }
.album-block-header .ab-year { font-size: 10px; color: var(--text-muted); flex-shrink: 0; }
.album-block-body { display: none; padding: 0; }
.album-block-body.open { display: block; }
.album-track-row { display: flex; align-items: center; gap: 8px; padding: 4px 10px; border-top: 1px solid var(--border); font-size: 11px; background: var(--bg-base); }
.album-track-row.hidden-track { opacity: 0.45; }
.album-track-row .atr-num { color: var(--text-muted); width: 22px; text-align: right; flex-shrink: 0; }
.album-track-row .atr-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.album-track-row .atr-dur { color: var(--text-muted); font-size: 10px; flex-shrink: 0; }
.appearance-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; border-bottom: 1px solid var(--border); }
.appearance-row:last-child { border-bottom: none; }
.btn-hide { background: #1e293b; color: var(--text-muted); border: none; padding: 2px 7px; border-radius: 3px; cursor: pointer; font-size: 10px; font-family: inherit; }
.btn-hide:hover { background: #334155; }
.btn-show { background: #052e16; color: var(--success); border: none; padding: 2px 7px; border-radius: 3px; cursor: pointer; font-size: 10px; font-family: inherit; }
.btn-show:hover { background: #065f46; }
</style>
</head>
<body>
<header>
<h1>Furumi Agent</h1>
<nav>
<button class="active" onclick="showTab('queue',this)">Queue</button>
<button onclick="showTab('tracks',this)">Tracks</button>
<button onclick="showTab('albums',this)">Albums</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>
</header>
<div class="filter-bar" id="filterBar"></div>
<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" 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';
let currentTab = 'queue';
let currentFilter = null;
let queueItems = [];
let selected = new Set();
let searchTimer = null;
let editFeatured = [];
let statsCache = null;
let queuePageSize = 50;
let queueOffset = 0;
let queueTotal = 0;
async function api(path, opts) {
const r = await fetch(API + path, opts);
if (r.status === 204) return null;
const text = await r.text();
if (!text) return null;
try { return JSON.parse(text); }
catch(e) { console.error('API error:', r.status, text); return null; }
}
// --- Stats & polling ---
async function loadStats() {
const s = await api('/stats');
if (!s) return;
statsCache = s;
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>
`;
// Agent status
const el = document.getElementById('agentStatus');
if (s.pending_count > 0 || s.active_merges > 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==='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>
<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>
`;
}
function showTab(tab, btn, noHash) {
currentTab = tab;
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
clearSelection();
const pag = document.getElementById('lib-pagination');
if (pag) pag.style.display = 'none';
if (!noHash) location.hash = tab;
if (tab === 'queue') { loadQueue(); loadStats(); }
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(); }
else if (tab === 'merges') { loadMerges(); document.getElementById('filterBar').innerHTML = ''; }
}
// --- Queue ---
async function loadQueue(status, keepSelection) {
currentFilter = status;
if (!keepSelection) {
clearSelection();
queueOffset = 0;
location.hash = status ? 'queue/' + status : 'queue';
}
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;
// 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(hasMore);
if (statsCache) renderFilterBar(statsCache);
}
function setQueuePageSize(n) { queuePageSize = n; queueOffset = 0; loadQueue(currentFilter); }
function queueGo(offset) { queueOffset = Math.max(0, offset); loadQueue(currentFilter, true); }
function renderQueue(hasMore) {
const el = document.getElementById('content');
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 = `<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' : '';
const conf = it.confidence != null ? it.confidence.toFixed(2) : '-';
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 albumCoverUrl = (it.norm_album || it.raw_album) && (it.norm_artist || it.raw_artist)
? `${API}/albums/cover-by-name?artist=${encodeURIComponent(it.norm_artist||it.raw_artist||'')}&name=${encodeURIComponent(it.norm_album||it.raw_album||'')}`
: null;
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 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||'')}"><span style="display:inline-flex;align-items:center;gap:4px">${albumCoverUrl?`<img src="${albumCoverUrl}" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'">`:''}${esc(album)}</span></td>
<td>${year}</td>
<td>${tnum}</td>
<td>${conf}</td>
<td class="actions">
${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>` : ''}
<button class="btn btn-edit" onclick="editItem('${it.id}')">Edit</button>
</td>
</tr>`;
};
// 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);
}
html += '</table>';
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;
}
// --- 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);
}
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();
loadQueue(currentFilter);
}
// --- 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;
};
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 ---
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) {}
}
// 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">
<div class="field">
<label>Artist</label>
<input id="ed-artist" value="${esc(item.norm_artist || item.raw_artist || '')}">
<div class="raw-value">Raw: ${esc(item.raw_artist || '-')} | Path: ${esc(item.path_artist || '-')}</div>
</div>
</div>
<div class="detail-row">
<div class="field">
<label>Title</label>
<input id="ed-title" value="${esc(item.norm_title || item.raw_title || '')}">
<div class="raw-value">Raw: ${esc(item.raw_title || '-')} | Path: ${esc(item.path_title || '-')}</div>
</div>
</div>
<div class="detail-row">
<div class="field">
<label>Album</label>
<input id="ed-album" value="${esc(item.norm_album || item.raw_album || '')}">
<div class="raw-value">Raw: ${esc(item.raw_album || '-')} | Path: ${esc(item.path_album || '-')}</div>
</div>
<div class="field">
<label>Year</label>
<input id="ed-year" type="number" value="${item.norm_year || item.raw_year || ''}">
</div>
</div>
<div class="detail-row">
<div class="field">
<label>Track #</label>
<input id="ed-track" type="number" value="${item.norm_track_number || item.raw_track_number || ''}">
</div>
<div class="field">
<label>Genre</label>
<input id="ed-genre" value="${esc(item.norm_genre || item.raw_genre || '')}">
</div>
</div>
<label>Featured Artists</label>
<div class="feat-tags" id="feat-tags"></div>
<div class="artist-search-wrap">
<input id="feat-search" placeholder="Search artist to add..." autocomplete="off"
oninput="onFeatSearch(this.value)" onkeydown="onFeatKey(event)">
<div class="artist-dropdown" id="feat-dropdown"></div>
</div>
${item.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>
</div>
`;
renderFeatTags();
openModal();
}
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 ---
function renderFeatTags() {
const el = document.getElementById('feat-tags');
if (!el) return;
el.innerHTML = editFeatured.map((name, i) =>
`<span class="feat-tag">${esc(name)}<span class="remove" onclick="removeFeat(${i})">&times;</span></span>`
).join('');
}
function removeFeat(idx) { editFeatured.splice(idx, 1); renderFeatTags(); }
function addFeat(name) {
name = name.trim();
if (!name || editFeatured.includes(name)) return;
editFeatured.push(name);
renderFeatTags();
const input = document.getElementById('feat-search');
if (input) input.value = '';
closeFeatDropdown();
}
function onFeatSearch(q) {
clearTimeout(searchTimer);
if (q.length < 2) { closeFeatDropdown(); return; }
searchTimer = setTimeout(async () => {
const results = await api(`/artists/search?q=${encodeURIComponent(q)}&limit=8`);
const dd = document.getElementById('feat-dropdown');
if (!results || !results.length) {
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'); }
// --- Library tabs (Tracks / Albums / Artists) ---
let LIB_LIMIT = 50;
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('');
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>`;
bar.innerHTML = `<div class="search-bar" style="padding:0;border:none;width:100%">${html}</div>`;
}
function setLibPageSize(n) {
LIB_LIMIT = n;
libPage[currentTab] = 0;
if (currentTab === 'tracks') loadLibTracks();
else if (currentTab === 'albums') loadLibAlbums();
else if (currentTab === 'artists') loadLibArtists();
}
// ---- 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;
const tot = document.getElementById('lib-total');
if (tot) tot.textContent = `${data.total} tracks`;
const el = document.getElementById('content');
if (!data.items.length) {
el.innerHTML = '<div class="empty">No tracks found</div>';
} else {
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>`;
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>
<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>
<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>
<td><button class="btn btn-edit" onclick="openTrackEdit(${t.id})">Edit</button></td>
</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;
const tot = document.getElementById('lib-total');
if (tot) tot.textContent = `${data.total} albums`;
const el = document.getElementById('content');
if (!data.items.length) {
el.innerHTML = '<div class="empty">No albums found</div>';
} else {
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>`;
for (const a of data.items) {
html += `<tr>
<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> ${releaseBadge(a.release_type)} ${a.hidden?'<span class="hidden-badge">Hidden</span>':''}</td>
<td>${esc(a.artist_name)}</td>
<td>${a.year ?? ''}</td>
<td style="color:var(--text-muted)">${a.track_count}</td>
<td><button class="btn btn-edit" onclick="openAlbumEdit(${a.id})">Edit</button></td>
</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;
const tot = document.getElementById('lib-total');
if (tot) tot.textContent = `${data.total} artists`;
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:40px">ID</th><th>Name</th>
<th style="width:46px" title="Albums">LP</th>
<th style="width:42px" title="Singles">Sng</th>
<th style="width:34px" title="EPs">EP</th>
<th style="width:40px" title="Compilations">Cmp</th>
<th style="width:36px" title="Live">Live</th>
<th style="width:46px" title="Tracks">Trk</th>
<th style="width:60px">Actions</th>
</tr>`;
for (const a of data.items) {
const dim = 'style="color:var(--text-muted);text-align:center"';
html += `<tr ${a.hidden?'style="opacity:0.5"':''}>
<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>${a.hidden?'<span class="hidden-badge" style="margin-right:4px">H</span>':''}${esc(a.name)}</td>
<td ${dim}>${a.album_count||''}</td>
<td ${dim}>${a.single_count||''}</td>
<td ${dim}>${a.ep_count||''}</td>
<td ${dim}>${a.compilation_count||''}</td>
<td ${dim}>${a.live_count||''}</td>
<td ${dim}>${a.track_count||''}</td>
<td class="actions">
<button class="btn btn-edit" onclick="openArtistForm(${a.id})">Edit</button>
</td>
</tr>`;
}
el.innerHTML = html + '</table>';
}
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';
}
// Keep alias for clearArtistSelection
function loadArtists() { loadLibArtists(); }
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) {
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();
}
// --- Track edit modal ---
async function openTrackEdit(id, returnCb) {
const t = await api(`/tracks/${id}`);
if (!t) return;
editReturnCallback = returnCb || null;
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="cancelTrackEdit()">Cancel</button>
<button class="btn btn-primary" onclick="saveTrackEdit(${t.id})">Save</button>
</div>
`;
renderFeatTags();
openModal();
}
let teArtistTimer = null, teAlbumTimer = null;
let editReturnCallback = 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) });
const cb = editReturnCallback;
editReturnCallback = null;
if (cb) { cb(); } else { closeModal(); if (currentTab === 'tracks') loadLibTracks(); }
}
function cancelTrackEdit() {
const cb = editReturnCallback;
editReturnCallback = null;
if (cb) { cb(); } else { closeModal(); }
}
// --- Album edit modal ---
let albumEditReturnCallback = null;
async function openAlbumEdit(id, returnCb) {
albumEditReturnCallback = returnCb || null;
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="cancelAlbumEdit()">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');
}
function cancelAlbumEdit() {
const cb = albumEditReturnCallback;
albumEditReturnCallback = null;
if (cb) { cb(); } else { closeModal(); }
}
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) });
const cb = albumEditReturnCallback;
albumEditReturnCallback = null;
if (cb) { cb(); } else { closeModal(); 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) {
const parentCb = albumEditReturnCallback;
await openTrackEdit(trackId, () => openAlbumEdit(albumId, parentCb));
}
function openTrackEditForArtist(trackId, artistId) {
openTrackEdit(trackId, () => openArtistForm(artistId));
}
// --- Album inline meta edit (from artist form) ---
async function saveAlbumMeta(albumId, artistId) {
const name = document.getElementById(`alb-name-${albumId}`)?.value?.trim();
const yearRaw = document.getElementById(`alb-year-${albumId}`)?.value;
if (!name) return;
await api(`/albums/${albumId}/edit`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name, year: parseInt(yearRaw) || null, artist_id: artistId }),
});
// Update header display in place
const block = document.getElementById(`album-block-${albumId}`);
if (block) {
const nameSpan = block.querySelector('.ab-name');
if (nameSpan) nameSpan.textContent = name;
const yearSpan = block.querySelector('.ab-year');
if (yearSpan) yearSpan.textContent = yearRaw || '';
}
}
async function applyAlbumGenre(albumId) {
const genre = document.getElementById(`alb-genre-${albumId}`)?.value?.trim();
if (!genre) return;
await api(`/albums/${albumId}/genre`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ genre }),
});
document.getElementById(`alb-genre-${albumId}`).value = '';
}
// --- Helpers ---
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;');
}
// --- 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();
}
// --- Release badge helper ---
function releaseBadge(t) {
const labels = {album:'Album',single:'Single',ep:'EP',compilation:'Comp',live:'Live'};
return `<span class="release-badge rb-${t||'album'}">${labels[t]||t||'Album'}</span>`;
}
// --- Artist full admin form ---
async function openArtistForm(id) {
const d = await api(`/artists/${id}/full`);
if (!d) return;
const { artist, albums, appearances } = d;
// Separate albums by type
const mainAlbums = albums.filter(a => a.release_type === 'album' || a.release_type === 'compilation' || a.release_type === 'live');
const singles = albums.filter(a => a.release_type === 'single' || a.release_type === 'ep');
function renderAlbumBlock(alb) {
const hiddenCls = alb.hidden ? ' style="opacity:0.55"' : '';
const tracks = (alb.tracks || []).map(t => `
<div class="album-track-row ${t.hidden?'hidden-track':''}" data-tid="${t.id}">
<span class="atr-num">${t.track_number??''}</span>
<span class="atr-title">${esc(t.title)}</span>
<span class="atr-dur">${fmtDuration(t.duration_secs)}</span>
<button class="btn btn-edit" style="font-size:10px;padding:2px 6px" onclick="openTrackEditForArtist(${t.id},${id})">Edit</button>
<button data-hidden="${t.hidden}" onclick="toggleTrackHidden(${t.id},this)">${t.hidden?'Show':'Hide'}</button>
</div>`).join('');
const releaseTypes = ['album','single','ep','compilation','live'];
const typeOpts = releaseTypes.map(rt => `<option value="${rt}" ${alb.release_type===rt?'selected':''}>${rt.charAt(0).toUpperCase()+rt.slice(1)}</option>`).join('');
return `<div class="album-block" id="album-block-${alb.id}"${hiddenCls}>
<div class="album-block-header" onclick="toggleAlbumBlock(${alb.id})">
<img src="${API}/albums/${alb.id}/cover" onerror="this.style.display='none'">
<span class="ab-name">${esc(alb.name)}</span>
${alb.year ? `<span class="ab-year">${alb.year}</span>` : ''}
${releaseBadge(alb.release_type)}
${alb.hidden?'<span class="hidden-badge">Hidden</span>':''}
<select onclick="event.stopPropagation()" onchange="changeReleaseType(${alb.id},this.value)" style="background:var(--bg-base);border:1px solid var(--border);border-radius:3px;color:var(--text);font-size:10px;padding:2px 4px;font-family:inherit">${typeOpts}</select>
<button data-hidden="${alb.hidden}" onclick="event.stopPropagation();toggleAlbumHidden(${alb.id},this)">${alb.hidden?'Show':'Hide'}</button>
</div>
<div class="album-block-body" id="album-body-${alb.id}"></div>
</div>`;
}
const albumsHtml = mainAlbums.map(renderAlbumBlock).join('') || '<div style="color:var(--text-muted);font-size:12px;padding:4px 0">No albums</div>';
const singlesHtml = singles.map(renderAlbumBlock).join('') || '<div style="color:var(--text-muted);font-size:12px;padding:4px 0">No singles/EPs</div>';
const appHtml = appearances.map(ap => `
<div class="appearance-row">
<span style="flex:1">${esc(ap.primary_artist_name)} — <strong>${esc(ap.track_title)}</strong>${ap.album_name?` <span style="color:var(--text-muted)">(${esc(ap.album_name)})</span>`:''}</span>
<button class="btn btn-reject" style="font-size:10px;padding:2px 6px" onclick="removeAppearance(${id},${ap.track_id},this)">Remove</button>
</div>`).join('') || '<div style="color:var(--text-muted);font-size:12px;padding:4px 0">No appearances</div>';
document.getElementById('modal').className = 'modal modal-wide';
document.getElementById('modal').innerHTML = `
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
<h2 style="flex:1;margin:0">${esc(artist.name)}</h2>
${artist.hidden?'<span class="hidden-badge" style="font-size:11px">Hidden</span>':''}
<button data-hidden="${artist.hidden}" onclick="toggleArtistHidden(${id},this)">${artist.hidden?'Unhide Artist':'Hide Artist'}</button>
<button class="btn btn-edit" onclick="promptRenameArtist(${id})">Rename</button>
</div>
<div class="artist-section">
<div class="artist-section-title">Albums &amp; Compilations <span style="color:var(--text-dim);font-weight:400">(${mainAlbums.length})</span></div>
${albumsHtml}
</div>
<div class="artist-section">
<div class="artist-section-title">Singles &amp; EPs <span style="color:var(--text-dim);font-weight:400">(${singles.length})</span></div>
${singlesHtml}
</div>
<div class="artist-section">
<div class="artist-section-title">Appearances (feat.) <span style="color:var(--text-dim);font-weight:400">(${appearances.length})</span></div>
<div id="appearances-list">${appHtml}</div>
<div style="margin-top:8px;display:flex;gap:6px;align-items:center">
<div style="position:relative;flex:1">
<input id="feat-track-search" placeholder="Search track to add appearance…" autocomplete="off"
oninput="onFeatTrackSearch(${id},this.value)" style="width:100%;background:var(--bg-card);border:1px solid var(--border);border-radius:5px;padding:6px 9px;color:var(--text);font-family:inherit;font-size:12px">
<div class="artist-dropdown" id="feat-track-dropdown"></div>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-cancel" onclick="closeModal()">Close</button>
</div>
`;
// Fill album body content and restore open state
for (const alb of albums) {
const body = document.getElementById(`album-body-${alb.id}`);
if (!body) continue;
const tracks = (alb.tracks || []).map(t => `
<div class="album-track-row ${t.hidden?'hidden-track':''}" data-tid="${t.id}">
<span class="atr-num">${t.track_number??''}</span>
<span class="atr-title">${esc(t.title)}</span>
<span class="atr-dur">${fmtDuration(t.duration_secs)}</span>
<button class="btn btn-edit" style="font-size:10px;padding:2px 6px" onclick="openTrackEditForArtist(${t.id},${id})">Edit</button>
<button data-hidden="${t.hidden}" onclick="toggleTrackHidden(${t.id},this)">${t.hidden?'Show':'Hide'}</button>
</div>`).join('');
const albumMeta = `<div style="padding:8px 10px;border-bottom:1px solid var(--border);background:var(--bg-panel)">
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<input id="alb-name-${alb.id}" value="${esc(alb.name)}" placeholder="Album name"
style="flex:2;min-width:140px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
<input id="alb-year-${alb.id}" type="number" value="${alb.year??''}" placeholder="Year"
style="width:70px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
<button class="btn btn-primary" style="font-size:10px;padding:3px 10px" onclick="saveAlbumMeta(${alb.id},${id})">Save</button>
<span style="color:var(--border);user-select:none">|</span>
<input id="alb-genre-${alb.id}" placeholder="Apply genre to all tracks…"
style="flex:1;min-width:130px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
<button class="btn btn-edit" style="font-size:10px;padding:3px 10px" onclick="applyAlbumGenre(${alb.id})">Apply</button>
</div>
</div>`;
body.innerHTML = albumMeta + tracks;
if (openAlbumBlocks.has(alb.id)) body.classList.add('open');
}
openModal();
}
const openAlbumBlocks = new Set();
function toggleAlbumBlock(id) {
const body = document.getElementById(`album-body-${id}`);
if (!body) return;
body.classList.toggle('open');
if (body.classList.contains('open')) openAlbumBlocks.add(id);
else openAlbumBlocks.delete(id);
}
async function toggleTrackHidden(id, btn) {
const hidden = btn.dataset.hidden !== 'true'; // toggle
await api(`/tracks/${id}/hidden`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({hidden}) });
btn.dataset.hidden = String(hidden);
btn.textContent = hidden ? 'Show' : 'Hide';
const row = btn.closest('.album-track-row');
if (row) row.classList.toggle('hidden-track', hidden);
}
async function toggleAlbumHidden(id, btn) {
const hidden = btn.dataset.hidden !== 'true'; // toggle
await api(`/albums/${id}/hidden`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({hidden}) });
btn.dataset.hidden = String(hidden);
btn.textContent = hidden ? 'Show' : 'Hide';
const block = btn.closest('.album-block');
if (block) block.style.opacity = hidden ? '0.55' : '';
const header = block?.querySelector('.album-block-header');
let badge = header?.querySelector('.hidden-badge');
if (hidden && !badge) { badge = document.createElement('span'); badge.className='hidden-badge'; badge.textContent='Hidden'; header.insertBefore(badge, btn); }
else if (!hidden && badge) badge.remove();
}
async function toggleArtistHidden(id, btn) {
const hidden = btn.dataset.hidden !== 'true'; // toggle
await api(`/artists/${id}/hidden`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({hidden}) });
btn.dataset.hidden = String(hidden);
btn.textContent = hidden ? 'Unhide Artist' : 'Hide Artist';
}
async function changeReleaseType(id, type) {
await api(`/albums/${id}/release_type`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({release_type: type}) });
}
async function promptRenameArtist(id) {
const name = prompt('New artist name:');
if (!name) return;
await api(`/artists/${id}/rename`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name}) });
// refresh
closeModal();
openArtistForm(id);
}
let featTrackTimer = null;
function onFeatTrackSearch(artistId, q) {
clearTimeout(featTrackTimer);
const dd = document.getElementById('feat-track-dropdown');
if (q.length < 2) { dd.classList.remove('open'); return; }
featTrackTimer = setTimeout(async () => {
const results = await api(`/tracks/search?q=${encodeURIComponent(q)}`);
if (!results || !results.length) { dd.classList.remove('open'); return; }
dd.innerHTML = results.map(t =>
`<div class="artist-option" onclick="addAppearance(${artistId},${t.id},'${esc(t.artist_name+' — '+t.title)}')">${esc(t.artist_name)}${esc(t.title)}</div>`
).join('');
dd.classList.add('open');
}, 250);
}
async function addAppearance(artistId, trackId, label) {
await api(`/artists/${artistId}/appearances`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({track_id: trackId}) });
document.getElementById('feat-track-search').value = '';
document.getElementById('feat-track-dropdown').classList.remove('open');
// Add row to appearances list
const list = document.getElementById('appearances-list');
const row = document.createElement('div');
row.className = 'appearance-row';
row.innerHTML = `<span style="flex:1">${esc(label)}</span><button class="btn btn-reject" style="font-size:10px;padding:2px 6px" onclick="removeAppearance(${artistId},${trackId},this)">Remove</button>`;
list.appendChild(row);
}
async function removeAppearance(artistId, trackId, btn) {
await api(`/artists/${artistId}/appearances/${trackId}`, { method:'DELETE' });
btn.closest('.appearance-row').remove();
}
// --- Cover preview ---
(function() {
const box = document.createElement('div');
box.id = 'cover-preview';
box.style.cssText = [
'display:none', 'position:fixed', 'z-index:9999', 'pointer-events:none',
'border-radius:10px', 'overflow:hidden',
'box-shadow:0 12px 40px rgba(0,0,0,0.85)',
'border:1px solid rgba(255,255,255,0.08)',
'background:#0a0c12', 'transition:opacity 0.1s',
].join(';');
const img = document.createElement('img');
img.style.cssText = 'display:block;width:280px;height:280px;object-fit:contain';
box.appendChild(img);
document.body.appendChild(box);
let showTimer = null;
function isCoverImg(el) {
return el && el.tagName === 'IMG' && el.src && el.src.includes('/cover');
}
function place(e) {
const margin = 16, pw = 280, ph = 280;
const vw = window.innerWidth, vh = window.innerHeight;
let x = e.clientX + margin, y = e.clientY + margin;
if (x + pw > vw - 8) x = e.clientX - pw - margin;
if (y + ph > vh - 8) y = e.clientY - ph - margin;
box.style.left = x + 'px';
box.style.top = y + 'px';
}
document.addEventListener('mouseover', e => {
if (!isCoverImg(e.target)) return;
clearTimeout(showTimer);
showTimer = setTimeout(() => {
img.src = e.target.src;
box.style.display = 'block';
place(e);
}, 120);
});
document.addEventListener('mousemove', e => {
if (box.style.display === 'none') return;
if (!isCoverImg(e.target)) { clearTimeout(showTimer); box.style.display = 'none'; return; }
place(e);
});
document.addEventListener('mouseout', e => {
if (!isCoverImg(e.target)) return;
clearTimeout(showTimer);
box.style.display = 'none';
});
})();
// --- Init ---
(function restoreFromHash() {
const hash = location.hash.slice(1); // strip #
if (!hash) return;
const [tab, filter] = hash.split('/');
const validTabs = ['queue','tracks','albums','artists','merges'];
if (!validTabs.includes(tab)) return;
const btn = Array.from(document.querySelectorAll('nav button'))
.find(b => (b.getAttribute('onclick') || '').includes(`'${tab}'`));
if (!btn) return;
// Switch tab without overwriting the hash
showTab(tab, btn, true);
// For queue, also restore the filter
if (tab === 'queue' && filter) {
currentFilter = filter;
loadQueue(filter);
}
})();
loadStats();
if (currentTab === 'queue' && !location.hash.slice(1)) loadQueue();
setInterval(loadStats, 5000);
// Auto-refresh queue when on queue tab
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);
</script>
</body>
</html>