Add upload library management and ops diagnostics
This commit is contained in:
@@ -372,6 +372,12 @@
|
||||
.analytics-form .query { flex: 1 1 250px; }
|
||||
.analytics-form .field { width: 100%; min-width: 0; }
|
||||
.analytics-form button { min-height: 44px; }
|
||||
#p-ops .btn, #p-devices .btn, #p-uploads .btn, #p-uploads .field, #p-devices .field { min-height: 44px; }
|
||||
#p-uploads .tbl td { vertical-align: top; }
|
||||
#p-uploads .tbl td .field { min-width: 130px; margin-bottom: 5px; }
|
||||
#p-uploads .tbl label.btn { display: inline-flex; align-items: center; cursor: pointer; }
|
||||
#p-uploads input[type="checkbox"], #p-devices input[type="checkbox"] { width: 20px; height: 20px; accent-color: var(--a1); }
|
||||
#p-uploads input[type="file"]::file-selector-button { min-height: 36px; margin: 2px 8px 2px 0; padding: 0 10px; border: 1px solid var(--line-2); border-radius: 7px; background: var(--bg-3); color: var(--text); font: 650 12px var(--ui); }
|
||||
.bulk-selection { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin: 12px 0; }
|
||||
.bulk-selection label { display: inline-flex; align-items: center; gap: 9px; min-height: 44px; color: var(--text); }
|
||||
.bulk-selection input[type="checkbox"] { width: 20px; height: 20px; accent-color: var(--a1); }
|
||||
@@ -381,10 +387,17 @@
|
||||
#bulkJobs .btn.sm { min-height: 44px; height: auto; }
|
||||
.analytics-table { overflow-x: auto; margin-top: 12px; }
|
||||
.analytics-table table { width: 100%; }
|
||||
#opsCards { grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); }
|
||||
#opsRoutes { min-width: 680px; }
|
||||
#opsRoutes th:first-child, #opsRoutes td:first-child { position: sticky; left: 0; z-index: 1; background: var(--bg-1); }
|
||||
#p-ops .scroll { overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; }
|
||||
#opsRoutes th:not(:first-child), #opsRoutes td:not(:first-child) { white-space: nowrap; }
|
||||
.route-scroll-hint { display: none; }
|
||||
.collection-job { padding: 14px 0; border-bottom: 1px solid var(--line-2); overflow-wrap: anywhere; }
|
||||
.collection-job p { margin: 4px 0; color: var(--text-2); }
|
||||
#metadataDetail { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 400px; overflow: auto; font-size: 13px; }
|
||||
@media (max-width: 480px) { .analytics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .metric { padding: 12px; } .metric b { font-size: 22px; } }
|
||||
@media (max-width: 760px) { .route-scroll-hint { display: block; margin: 2px 0 8px; font-size: 12px; } }
|
||||
@media (max-width: 480px) { .analytics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .metric { padding: 12px; } .metric b { font-size: 22px; } #opsCards { grid-template-columns: repeat(2,minmax(0,1fr)); } }
|
||||
.deep-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(min(100%,240px),1fr)); gap: 12px; }
|
||||
.deep-grid label { display: grid; gap: 5px; min-width: 0; color: var(--text); font-size: 13px; }
|
||||
.deep-search .muted { color: var(--text-2); }
|
||||
@@ -408,7 +421,7 @@
|
||||
overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; scrollbar-width: thin;
|
||||
background: var(--bg-1); border-top: 1px solid var(--line-2);
|
||||
}
|
||||
.nav .tab { flex: 0 0 68px; min-width: 68px; min-height: 44px; flex-direction: column; justify-content: center; gap: 3px; height: auto; padding: 4px 3px; font-size: 10.5px; border-radius: 12px; }
|
||||
.nav .tab { flex: 0 0 68px; min-width: 68px; min-height: 44px; flex-direction: column; justify-content: center; gap: 3px; height: auto; padding: 4px 3px; font-size: 10px; line-height: 1.05; text-align: center; white-space: normal; border-radius: 12px; }
|
||||
.nav .tab .badge { position: absolute; top: 2px; left: 55%; }
|
||||
.steps { grid-template-columns: 1fr; }
|
||||
.ln-main { grid-template-columns: 82px minmax(0, 1fr) 44px; gap: 6px; padding: 7px; }
|
||||
@@ -744,7 +757,7 @@
|
||||
<div class="row"><div><h1 class="page">Performance</h1><p class="lead">Route latency, service queues, process health and storage headroom.</p></div><span class="grow"></span><button class="btn" id="opsRefresh" type="button">Refresh</button></div>
|
||||
<p id="opsStatus" class="msg" role="status" aria-live="polite"></p>
|
||||
<div class="analytics-grid" id="opsCards"></div>
|
||||
<div class="card"><h2>Route health</h2><div class="scroll"><table class="tbl" id="opsRoutes"></table></div></div>
|
||||
<div class="card"><h2>Route health</h2><p class="muted route-scroll-hint">Swipe the table horizontally to compare every route metric.</p><div class="scroll"><table class="tbl" id="opsRoutes"></table></div></div>
|
||||
<div class="card"><h2>Storage volumes</h2><div id="opsVolumes"></div></div>
|
||||
<div class="card"><h2>24-hour p95 history</h2><div id="opsHistory" class="analytics-grid"></div></div>
|
||||
<div id="opsAlerts" aria-live="polite"></div>
|
||||
@@ -752,7 +765,16 @@
|
||||
<section id="p-devices" class="panel">
|
||||
<div class="row"><div><h1 class="page">P2P devices</h1><p class="lead">Presence, stored holdings and device-level sharing controls.</p></div><span class="grow"></span><button class="btn" id="devicesRefresh" type="button">Refresh</button></div>
|
||||
<div class="analytics-grid" id="deviceHealth"></div><p id="devicesStatus" class="msg" role="status" aria-live="polite"></p>
|
||||
<div class="card"><div class="scroll"><table class="tbl" id="deviceTable"></table></div></div>
|
||||
<div class="card"><h2>Network health</h2><svg id="deviceNetworkGraph" role="img" aria-label="P2P network health summary" viewBox="0 0 100 40" preserveAspectRatio="none" style="width:100%;height:72px;stroke:var(--a1);fill:none;stroke-width:2"></svg><p class="muted">Online devices · verified holdings · active intake · open intake, left to right.</p></div>
|
||||
<div class="card"><div class="scroll"><table class="tbl" id="deviceTable"></table></div><div class="row"><button class="btn" id="devicesPrev" type="button">Previous</button><span id="devicesPage" class="muted"></span><button class="btn" id="devicesNext" type="button">Next</button></div></div>
|
||||
</section>
|
||||
<section id="p-uploads" class="panel">
|
||||
<h1 class="page">Upload library</h1><p class="lead">Edit metadata and visibility, replace files without changing IDs, and review storage by owner.</p>
|
||||
<form class="card analytics-form" id="uploadCreateForm"><label class="query">Audio or video file<input class="field" type="file" id="uploadCreateFile" accept="audio/*,video/*" required /></label><label>Title<input class="field" id="uploadCreateTitle" maxlength="300" placeholder="Use embedded title" /></label><label>Artist<input class="field" id="uploadCreateArtist" maxlength="200" /></label><label>Album<input class="field" id="uploadCreateAlbum" maxlength="200" /></label><button class="btn pri" type="submit">Add upload</button></form>
|
||||
<div class="card"><h2>Storage by owner</h2><div id="uploadOwners" class="analytics-grid"></div></div>
|
||||
<div class="row"><input class="field" id="uploadManageFilter" type="search" placeholder="Filter uploads" /><button class="btn" id="uploadManageRefresh" type="button">Refresh</button><span class="grow"></span><select class="field" id="uploadBulkAction"><option value="list">List selected</option><option value="unlist">Unlist selected</option><option value="delete">Delete selected</option></select><button class="btn dng" id="uploadBulkApply" type="button">Apply</button></div>
|
||||
<p id="uploadManageStatus" class="msg" role="status" aria-live="polite"></p><div class="card"><div class="scroll"><table class="tbl" id="uploadManageTable"></table></div><div class="row"><button class="btn" id="uploadManagePrev" type="button">Previous</button><span id="uploadManagePage" class="muted"></span><button class="btn" id="uploadManageNext" type="button">Next</button></div></div>
|
||||
<div class="card"><h2>Orphan files</h2><p class="muted">Files in upload storage without a database row. Review before removing them from disk.</p><div id="uploadOrphans"></div></div>
|
||||
</section>
|
||||
|
||||
<section id="p-deep-search" class="panel deep-search">
|
||||
@@ -854,6 +876,7 @@
|
||||
library: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>',
|
||||
peers: '<circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.3 10.9l7.4-3.8M8.3 13.1l7.4 3.8"/>',
|
||||
devices: '<rect x="6" y="3" width="12" height="18" rx="2"/><path d="M10 17h4"/>',
|
||||
uploads: '<path d="M12 16V4m0 0L7 9m5-5 5 5M5 14v5h14v-5"/>',
|
||||
history: '<path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
|
||||
backups: '<path d="M4 5h16v15H4zM8 5V3h8v2M8 11h8M8 15h5"/>',
|
||||
analytics: '<path d="M4 20h16M7 16V9M12 16V4M17 16v-5"/>',
|
||||
@@ -862,7 +885,7 @@
|
||||
audit: '<path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/>',
|
||||
access: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
|
||||
};
|
||||
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['lyric-search', 'Lyrics search'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['devices', 'Devices'], ['history', 'History'], ['backups', 'Backups'], ['analytics', 'Stats'], ['ops', 'Performance'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
|
||||
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['lyric-search', 'Lyrics search'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['uploads', 'Uploads'], ['peers', 'P2P'], ['devices', 'Devices'], ['history', 'History'], ['backups', 'Backups'], ['analytics', 'Stats'], ['ops', 'Performance'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
|
||||
let tab = 'editor';
|
||||
function buildTabs() {
|
||||
const html = TABS.map(([k, l]) => `<button class="tab" role="tab" data-tab="${k}" aria-selected="false"><svg viewBox="0 0 24 24">${ICONS[k]}</svg><span>${l}</span>${k === 'reports' ? '<span class="badge hidden" data-badge>0</span>' : ''}</button>`).join('');
|
||||
@@ -887,8 +910,9 @@
|
||||
if (name === 'library') { loadUploads(); loadBulkLibrary(); loadBulkJobs(); }
|
||||
if (name === 'access') loadTokens();
|
||||
if (name === 'analytics') loadAnalytics();
|
||||
if (name === 'ops') loadOpsMetrics();
|
||||
if (name === 'ops') loadOpsMetrics(); else clearTimeout(opsTimer);
|
||||
if (name === 'devices') loadDevices();
|
||||
if (name === 'uploads') loadManagedUploads();
|
||||
if (name === 'deep-search') loadDeepSearch();
|
||||
if (name === 'audit') loadAudit();
|
||||
window.scrollTo({ top: 0 });
|
||||
@@ -956,6 +980,7 @@
|
||||
await Promise.all([loadCollections(), loadMetadata()]);
|
||||
} catch (error) { $('analyticsStatus').textContent = error.message || 'Could not load server analytics.'; }
|
||||
}
|
||||
let opsTimer = null;
|
||||
async function loadOpsMetrics() {
|
||||
const status = $('opsStatus'); if (!status) return;
|
||||
status.textContent = 'Sampling service health…';
|
||||
@@ -966,6 +991,7 @@
|
||||
['yt-dlp pool', current.ytdlp ? `${current.ytdlp.busy}/${current.ytdlp.workers} busy · ${current.ytdlp.queued} queued` : 'Unavailable'],
|
||||
['Media queue', current.media ? `${current.media.queued} queued · ${current.media.active ? '1 active' : 'idle'}` : 'Unavailable'],
|
||||
['Search cache', current.cache ? `${current.cache.hits} hits · ${current.cache.misses} misses` : 'Unavailable'],
|
||||
['Lyrics queue', current.transcription ? `${current.transcription.queued} queued · ${current.transcription.active} active` : 'Unavailable'],
|
||||
];
|
||||
$('opsCards').innerHTML = cards.map(([name,value]) => `<div class="metric"><span>${esc(name)}</span><b>${esc(value)}</b></div>`).join('');
|
||||
const rows = Object.entries(current.routes || {}).sort((a,b)=>b[1].requests-a[1].requests);
|
||||
@@ -976,19 +1002,30 @@
|
||||
$('opsHistory').innerHTML = `<div class="metric" style="grid-column:1/-1"><span>Peak route p95 · last ${points.length} snapshots</span><b>${maxPoint} ms</b><svg role="img" aria-label="Peak p95 latency sparkline" viewBox="0 0 100 40" preserveAspectRatio="none" style="width:100%;height:64px;stroke:var(--a1);fill:none;stroke-width:2"><polyline points="${poly}" vector-effect="non-scaling-stroke"/></svg></div>`;
|
||||
const alerts = [];
|
||||
for (const [path,r] of rows) if (r.p95 > 5000 || r.errorRate > .1) alerts.push(`<div class="card" role="status"><b>Attention:</b> ${esc(path)} p95 ${r.p95} ms · errors ${(r.errorRate*100).toFixed(1)}%</div>`);
|
||||
if (current.ytdlp && current.ytdlp.queued > Math.max(4,current.ytdlp.workers*3)) alerts.push('<div class="card" role="status"><b>Attention:</b> yt-dlp queue is saturated.</div>');
|
||||
if (current.media?.queued > 50) alerts.push('<div class="card" role="status"><b>Attention:</b> media cache queue is backed up.</div>');
|
||||
if ((current.volumes||[]).some(v=>!v.unavailable&&v.freeBytes<1024**3)) alerts.push('<div class="card" role="status"><b>Attention:</b> a monitored volume has less than 1 GB free.</div>');
|
||||
$('opsAlerts').innerHTML = alerts.join(''); status.textContent = `Updated ${new Date(current.at).toLocaleTimeString()}`;
|
||||
clearTimeout(opsTimer); if (tab === 'ops') opsTimer = setTimeout(loadOpsMetrics, 10000);
|
||||
} catch (error) { status.textContent = error.message || 'Monitoring unavailable.'; }
|
||||
}
|
||||
$('opsRefresh')?.addEventListener('click', loadOpsMetrics);
|
||||
let devicesOffset = 0;
|
||||
async function loadDevices() {
|
||||
$('devicesStatus').textContent = 'Loading registered devices…';
|
||||
try {
|
||||
const j = await api('/api/admin/p2p/devices');
|
||||
const j = await api(`/api/admin/p2p/devices?offset=${devicesOffset}`);
|
||||
$('deviceHealth').innerHTML = [['Online now',j.network.online],['Verified content',j.network.content],['Intake active',j.network.intakeActive],['Open intake',j.network.intakeOpen]].map(([a,b])=>`<div class="metric"><span>${esc(a)}</span><b>${esc(b)}</b></div>`).join('');
|
||||
const bars=[j.network.online,j.network.content,j.network.intakeActive,j.network.intakeOpen].map(Number), max=Math.max(1,...bars);
|
||||
$('deviceNetworkGraph').innerHTML=`<title>Online devices, verified items, active intake and open intake</title><path d="M4 36H98" stroke="var(--line-2)"/><polyline points="${bars.map((v,i)=>`${8+i*28},${34-v/max*28}`).join(' ')}"/><circle cx="8" cy="${34-bars[0]/max*28}" r="2"/><circle cx="36" cy="${34-bars[1]/max*28}" r="2"/><circle cx="64" cy="${34-bars[2]/max*28}" r="2"/><circle cx="92" cy="${34-bars[3]/max*28}" r="2"/>`;
|
||||
$('deviceTable').innerHTML = '<tr><th>Device</th><th>Presence</th><th>Holdings</th><th>Traffic</th><th>Controls</th></tr>' + (j.devices.map(d=>`<tr><td><b>${esc(d.fingerprintShort)}</b><br><small>${esc(d.profile)} · ${esc(d.userAgent)}</small></td><td>${d.banned?'Banned':d.online?'Online':'Last seen '+new Date(d.lastSeenAt).toLocaleString()}</td><td>${d.holdings} · ${bytes(d.holdingBytes)}</td><td>Sent ${bytes(d.bytesUp)} · received ${bytes(d.bytesDown)}</td><td><div class="row"><button class="btn sm" data-device-sync="${esc(d.id)}" ${!d.online?'disabled':''}>Re-announce</button><button class="btn sm" data-device-holdings="${esc(d.id)}">Revoke holdings</button><button class="btn sm dng" data-device-ban="${esc(d.id)}" data-banned="${d.banned}">${d.banned?'Unban':'Ban'}</button></div></td></tr>`).join('') || '<tr><td colspan="5" class="muted">No devices registered.</td></tr>');
|
||||
$('devicesPage').textContent = `${j.devices.length ? devicesOffset + 1 : 0}–${devicesOffset + j.devices.length}${j.hasMore ? '+' : ''} devices`;
|
||||
$('devicesPrev').disabled = devicesOffset <= 0; $('devicesNext').disabled = !j.hasMore;
|
||||
$('devicesStatus').textContent = `${j.devices.length} devices shown`;
|
||||
} catch (error) { $('devicesStatus').textContent = error.message || 'Could not load devices.'; }
|
||||
}
|
||||
$('devicesPrev')?.addEventListener('click',()=>{devicesOffset=Math.max(0,devicesOffset-100);loadDevices();});
|
||||
$('devicesNext')?.addEventListener('click',()=>{devicesOffset+=100;loadDevices();});
|
||||
$('devicesRefresh')?.addEventListener('click', loadDevices);
|
||||
$('deviceTable')?.addEventListener('click', async e => {
|
||||
const b=e.target.closest('button'); if(!b)return;
|
||||
@@ -999,6 +1036,59 @@
|
||||
await loadDevices();
|
||||
} catch(error) { $('devicesStatus').textContent=error.message || 'Device action failed.'; }
|
||||
});
|
||||
let uploadManageOffset = 0;
|
||||
async function loadManagedUploads() {
|
||||
$('uploadManageStatus').textContent = 'Loading uploads…';
|
||||
try {
|
||||
const j = await api(`/api/admin/uploads/manage?q=${encodeURIComponent($('uploadManageFilter').value)}&offset=${uploadManageOffset}`);
|
||||
if (!j.ok) throw Error(j.error || 'Could not load uploads.');
|
||||
const quotaByOwner=new Map((j.quotas||[]).map(q=>[q.owner,q.maxBytes]));
|
||||
$('uploadOwners').innerHTML = j.owners.map(o=>`<div class="metric"><span>${esc(o.owner)}</span><b>${o.count} files</b><small>${bytes(o.bytes)} · quota ${quotaByOwner.has(o.owner)?bytes(quotaByOwner.get(o.owner)):'unlimited'}</small>${o.owner!=='(admin)'?`<div class="row"><input class="field" type="number" min="0" step="64" data-quota-owner="${esc(o.owner)}" placeholder="MB; 0 unlimited" value="${quotaByOwner.has(o.owner)?Math.round(quotaByOwner.get(o.owner)/1048576):''}"><button class="btn sm" data-quota-save="${esc(o.owner)}">Set quota</button></div>`:''}</div>`).join('') || '<p class="muted">No uploads.</p>';
|
||||
$('uploadManageTable').innerHTML = '<tr><th><input type="checkbox" id="uploadSelectPage" aria-label="Select page"></th><th>File</th><th>Metadata</th><th>Visibility</th><th>Replace / art</th></tr>' + (j.uploads.map(u=>`<tr data-upload-row="${esc(u.id)}"><td><input type="checkbox" data-upload-select="${esc(u.id)}" aria-label="Select ${esc(u.title)}"></td><td><b>${esc(u.title)}</b><br><small>${esc(u.id)} · gen ${u.gen} · ${bytes(u.size)}</small></td><td><input class="field" data-title value="${esc(u.title)}" aria-label="Title"><input class="field" data-artist value="${esc(u.artist)}" aria-label="Artist"><input class="field" data-album value="${esc(u.album)}" aria-label="Album"><button class="btn sm" data-upload-save="${esc(u.id)}">Save details</button></td><td><label><input type="checkbox" data-listed ${u.listed?'checked':''}> Listed in search</label></td><td><label class="btn sm">Replace audio/video<input type="file" data-replace="${esc(u.id)}" accept="audio/*,video/*" hidden></label><label class="btn sm">Replace art<input type="file" data-art="${esc(u.id)}" accept="image/*" hidden></label><button class="btn sm dng" data-upload-delete="${esc(u.id)}">Delete</button></td></tr>`).join('') || '<tr><td colspan="5" class="muted">No uploads found.</td></tr>');
|
||||
$('uploadSelectPage').addEventListener('change', e=>document.querySelectorAll('[data-upload-select]').forEach(c=>c.checked=e.target.checked));
|
||||
$('uploadOrphans').innerHTML = j.orphans.map(o=>`<p><code>${esc(o.name)}</code> · untracked file</p>`).join('') || '<p class="muted">No orphan files detected.</p>';
|
||||
$('uploadManagePage').textContent = `${j.total ? uploadManageOffset + 1 : 0}–${uploadManageOffset + j.uploads.length} of ${j.total}`;
|
||||
$('uploadManagePrev').disabled = uploadManageOffset <= 0; $('uploadManageNext').disabled = uploadManageOffset + j.uploads.length >= j.total;
|
||||
$('uploadManageStatus').textContent = `${j.uploads.length} uploads loaded. Transcode queue is not enabled in this build.`;
|
||||
} catch(error) { $('uploadManageStatus').textContent = error.message || 'Could not load uploads.'; }
|
||||
}
|
||||
async function uploadRaw(path, method, file) {
|
||||
const res = await fetch(path,{method,body:file,credentials:'same-origin'}); const j=await res.json().catch(()=>({ok:false,error:`HTTP ${res.status}`})); if(!res.ok||!j.ok) throw Error(j.error||`HTTP ${res.status}`); return j;
|
||||
}
|
||||
$('uploadManageRefresh')?.addEventListener('click',()=>{uploadManageOffset=0;loadManagedUploads();});
|
||||
$('uploadManageFilter')?.addEventListener('input',()=>{clearTimeout(window.__uploadSearchTimer);window.__uploadSearchTimer=setTimeout(()=>{uploadManageOffset=0;loadManagedUploads();},250);});
|
||||
$('uploadManagePrev')?.addEventListener('click',()=>{uploadManageOffset=Math.max(0,uploadManageOffset-100);loadManagedUploads();});
|
||||
$('uploadManageNext')?.addEventListener('click',()=>{uploadManageOffset+=100;loadManagedUploads();});
|
||||
$('uploadManageTable')?.addEventListener('click',async e=>{
|
||||
const b=e.target.closest('button'); if(!b)return;
|
||||
try {
|
||||
const id=b.dataset.uploadSave||b.dataset.uploadDelete; if(!id)return;
|
||||
if(b.dataset.uploadDelete && !confirm('Delete this uploaded file and its cover art? This cannot be undone.'))return;
|
||||
const row=b.closest('[data-upload-row]');
|
||||
if(b.dataset.uploadSave){const r=await api(`/api/admin/uploads/${id}`,{method:'PATCH',body:{title:row.querySelector('[data-title]').value,artist:row.querySelector('[data-artist]').value,album:row.querySelector('[data-album]').value,listed:row.querySelector('[data-listed]').checked}});if(!r.ok)throw Error(r.error||'Save failed.');}
|
||||
else await api(`/api/admin/uploads/${id}`,{method:'DELETE'});
|
||||
await loadManagedUploads();
|
||||
} catch(error){$('uploadManageStatus').textContent=error.message||'Upload action failed.';}
|
||||
});
|
||||
$('uploadManageTable')?.addEventListener('change',async e=>{
|
||||
const input=e.target.closest('input[type=file]'); if(!input?.files?.[0])return;
|
||||
const id=input.dataset.replace||input.dataset.art, file=input.files[0];
|
||||
try {
|
||||
const url=input.dataset.replace?`/api/admin/uploads/${id}/replace?name=${encodeURIComponent(file.name)}`:`/api/admin/uploads/${id}/art`;
|
||||
const result=await uploadRaw(url,'PUT',file); $('uploadManageStatus').textContent=input.dataset.replace?`Replaced file; generation is now ${result.upload.gen}.`:'Cover art updated.'; await loadManagedUploads();
|
||||
} catch(error){$('uploadManageStatus').textContent=error.message||'Upload failed.';}
|
||||
});
|
||||
$('uploadOwners')?.addEventListener('click',async e=>{const b=e.target.closest('[data-quota-save]');if(!b)return;const input=b.parentElement.querySelector('[data-quota-owner]');try{const mb=Number(input.value)||0;const r=await api(`/api/admin/uploads/quotas/${encodeURIComponent(b.dataset.quotaSave)}`,{method:'PUT',body:{maxBytes:mb*1048576}});if(!r.ok)throw Error(r.error||'Quota update failed.');await loadManagedUploads();}catch(error){$('uploadManageStatus').textContent=error.message||'Quota update failed.';}});
|
||||
$('uploadBulkApply')?.addEventListener('click',async()=>{
|
||||
const ids=[...document.querySelectorAll('[data-upload-select]:checked')].map(x=>x.dataset.uploadSelect), action=$('uploadBulkAction').value;
|
||||
if(!ids.length)return $('uploadManageStatus').textContent='Select at least one upload.';
|
||||
if(action==='delete'&&!confirm(`Delete ${ids.length} selected uploads and their files?`))return;
|
||||
try{const j=await api('/api/admin/uploads/bulk',{method:'POST',body:{ids,action}});if(!j.ok)throw Error(j.error||'Bulk action failed.');$('uploadManageStatus').textContent=`Updated ${j.changed} uploads.`;await loadManagedUploads();}catch(error){$('uploadManageStatus').textContent=error.message||'Bulk action failed.';}
|
||||
});
|
||||
$('uploadCreateForm')?.addEventListener('submit',async e=>{
|
||||
e.preventDefault();const file=$('uploadCreateFile').files[0];if(!file)return;
|
||||
try{const url=`/api/admin/uploads/stream?name=${encodeURIComponent(file.name)}&title=${encodeURIComponent($('uploadCreateTitle').value)}&artist=${encodeURIComponent($('uploadCreateArtist').value)}&album=${encodeURIComponent($('uploadCreateAlbum').value)}`;await uploadRaw(url,'PUT',file);e.target.reset();await loadManagedUploads();}catch(error){$('uploadManageStatus').textContent=error.message||'Upload failed.';}
|
||||
});
|
||||
async function loadCollections() {
|
||||
try {
|
||||
const j = await api('/api/admin/collections'); if (!j.ok) return;
|
||||
|
||||
Reference in New Issue
Block a user