Add upload library management and ops diagnostics
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -54,3 +54,4 @@ plans/transport-shots/
|
||||
# Search button visual review screenshots
|
||||
plans/search-button-shots/
|
||||
plans/search-button-final-shots/
|
||||
plans/admin-ops-shots/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -521,3 +521,17 @@ contrast in Glass Stage at 320/390/768/1280px and dark/light/contrast themes. It
|
||||
also captures Classic, Bento Hub, and Framework7 Swipe at 390/1280px to guard
|
||||
their existing card presentation. Screenshots are written to
|
||||
`plans/rec-card-shots/`.
|
||||
|
||||
## Admin operations screens
|
||||
|
||||
`npx playwright test -c playwright.admin-ops.config.js` checks the Performance,
|
||||
P2P Devices, and Uploads admin tabs at 390/768/1280px in dark/light/contrast,
|
||||
including AA body-text contrast, 44px controls, page overflow, and mobile
|
||||
bottom-navigation clearance. Viewport screenshots are saved under
|
||||
`plans/admin-ops-shots/` (ignored by Git).
|
||||
|
||||
For request-monitor overhead, run two local server instances against separate
|
||||
data directories, one with `OPS_MONITOR=0` and one with monitoring enabled, then
|
||||
run `OPS_BASE_URL=http://127.0.0.1:<off-port> OPS_ENABLED_URL=http://127.0.0.1:<on-port> node perf/admin-ops-bench.mjs`.
|
||||
The harness interleaves 500 `/api/version` requests per mode and prints p50/p95,
|
||||
mean latency, and the mean delta.
|
||||
|
||||
29
perf/admin-ops-bench.mjs
Normal file
29
perf/admin-ops-bench.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
// Compare local /api/version latency with request monitoring enabled and disabled.
|
||||
// Start two otherwise identical server instances and set OPS_BASE_URL/OPS_ENABLED_URL.
|
||||
const urls = { disabled: process.env.OPS_BASE_URL, enabled: process.env.OPS_ENABLED_URL };
|
||||
if (!urls.disabled || !urls.enabled) {
|
||||
console.error('Set OPS_BASE_URL and OPS_ENABLED_URL to local server URLs.');
|
||||
process.exit(2);
|
||||
}
|
||||
const samples = { disabled: [], enabled: [] };
|
||||
async function request(url) {
|
||||
const start = performance.now();
|
||||
const response = await fetch(`${url}/api/version`);
|
||||
await response.arrayBuffer();
|
||||
if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`);
|
||||
return performance.now() - start;
|
||||
}
|
||||
for (const key of Object.keys(urls)) for (let i = 0; i < 30; i++) await request(urls[key]);
|
||||
for (let i = 0; i < 500; i++) {
|
||||
for (const key of i % 2 ? ['enabled', 'disabled'] : ['disabled', 'enabled']) samples[key].push(await request(urls[key]));
|
||||
}
|
||||
const percentile = (values, p) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * p) - 1];
|
||||
const result = Object.fromEntries(Object.entries(samples).map(([key, values]) => [key, {
|
||||
requests: values.length,
|
||||
p50Ms: Number(percentile(values, .5).toFixed(3)),
|
||||
p95Ms: Number(percentile(values, .95).toFixed(3)),
|
||||
meanMs: Number((values.reduce((a, b) => a + b, 0) / values.length).toFixed(3)),
|
||||
}]));
|
||||
console.log(JSON.stringify({ route: '/api/version', pairs: samples.disabled.length, result,
|
||||
meanDeltaPercent: Number(((result.enabled.meanMs / result.disabled.meanMs - 1) * 100).toFixed(2)) }, null, 2));
|
||||
3
playwright.admin-ops.config.js
Normal file
3
playwright.admin-ops.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
const classic = require('./playwright.classic.config');
|
||||
module.exports = defineConfig({ ...classic, testMatch: /admin-ops\.spec\.js/ });
|
||||
53
server/db.js
53
server/db.js
@@ -234,7 +234,9 @@ export async function initDb() {
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
art TEXT, -- 'embedded' | 'file' | NULL
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
plays INTEGER NOT NULL DEFAULT 0
|
||||
plays INTEGER NOT NULL DEFAULT 0,
|
||||
gen INTEGER NOT NULL DEFAULT 1,
|
||||
art_gen INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- "This lyric line is wrong" reports from service mode. One open row per
|
||||
@@ -294,6 +296,9 @@ export async function initDb() {
|
||||
for (const col of ['owner TEXT', 'listed INTEGER NOT NULL DEFAULT 1']) {
|
||||
try { await db.execute(`ALTER TABLE uploads ADD COLUMN ${col}`); } catch { /* already there */ }
|
||||
}
|
||||
try { await db.execute('ALTER TABLE uploads ADD COLUMN gen INTEGER NOT NULL DEFAULT 1'); } catch { /* already migrated */ }
|
||||
try { await db.execute('ALTER TABLE uploads ADD COLUMN art_gen INTEGER NOT NULL DEFAULT 1'); } catch { /* already migrated */ }
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS upload_owner_quotas (owner TEXT PRIMARY KEY,max_bytes INTEGER NOT NULL CHECK(max_bytes>=0),updated_at INTEGER NOT NULL)`);
|
||||
// Integrated loudness (EBU R128 LUFS) measured by devices in the browser and
|
||||
// shared, so each song is measured once for everyone.
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS loudness (
|
||||
@@ -956,12 +961,13 @@ const uploadRow = (r) => ({
|
||||
duration: Number(r.duration) || 0, ext: r.ext, mime: r.mime, size: Number(r.size) || 0,
|
||||
art: r.art || null, createdAt: Number(r.created_at), plays: Number(r.plays) || 0,
|
||||
owner: r.owner || null, listed: r.listed === undefined || r.listed === null ? true : Number(r.listed) === 1,
|
||||
gen: Number(r.gen) || 1, artGen: Number(r.art_gen) || 1,
|
||||
});
|
||||
|
||||
export async function createUpload(u) {
|
||||
await db.execute({
|
||||
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, owner, listed, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())`,
|
||||
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, owner, listed, gen, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, unixepoch())`,
|
||||
args: [u.id, u.kind, u.title, u.artist || null, u.album || null, u.duration || 0, u.ext, u.mime, u.size || 0, u.art || null,
|
||||
u.owner || null, u.listed === false ? 0 : 1],
|
||||
});
|
||||
@@ -980,7 +986,7 @@ export async function sharedUploadUsage(owner) {
|
||||
}
|
||||
|
||||
export async function setUploadArt(id, art) {
|
||||
await db.execute({ sql: 'UPDATE uploads SET art = ? WHERE id = ?', args: [art, id] });
|
||||
await db.execute({ sql: 'UPDATE uploads SET art = ?, art_gen = art_gen + 1 WHERE id = ?', args: [art, id] });
|
||||
}
|
||||
|
||||
export async function getUpload(id) {
|
||||
@@ -988,6 +994,45 @@ export async function getUpload(id) {
|
||||
return r.rows[0] ? uploadRow(r.rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function updateUploadMeta(id, patch = {}) {
|
||||
const fields = ['title', 'artist', 'album', 'listed'].filter(k => patch[k] !== undefined);
|
||||
if (!fields.length) return !!(await getUpload(id));
|
||||
const values = fields.map(k => k === 'listed' ? (patch[k] ? 1 : 0) : String(patch[k] ?? '').slice(0, k === 'title' ? 300 : 200) || null);
|
||||
const result = await db.execute({ sql: `UPDATE uploads SET ${fields.map(k => `${k}=?`).join(',')} WHERE id=?`, args: [...values, id] });
|
||||
return !!result.rowsAffected;
|
||||
}
|
||||
|
||||
export async function replaceUploadFile(id, upload) {
|
||||
const result = await db.execute({ sql: `UPDATE uploads SET kind=?,title=?,artist=?,album=?,duration=?,ext=?,mime=?,size=?,art=?,gen=gen+1 WHERE id=?`,
|
||||
args: [upload.kind, upload.title, upload.artist || null, upload.album || null, upload.duration || 0, upload.ext, upload.mime, upload.size || 0, upload.art || null, id] });
|
||||
return !!result.rowsAffected;
|
||||
}
|
||||
|
||||
export async function listAdminUploads({ q = '', limit = 100, offset = 0 } = {}) {
|
||||
const args = [], where = q ? "WHERE lower(title || ' ' || ifnull(artist,'') || ' ' || ifnull(album,'')) LIKE ?" : '';
|
||||
if (q) args.push(`%${String(q).toLowerCase().replace(/[\\%_]/g, '\\$&')}%`);
|
||||
const rows = await db.execute({ sql: `SELECT * FROM uploads ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`, args: [...args, Math.max(1, Math.min(200, limit)), Math.max(0, offset)] });
|
||||
const count = await db.execute({ sql: `SELECT COUNT(*) AS n FROM uploads ${where}`, args });
|
||||
const owners = await db.execute("SELECT ifnull(owner,'(admin)') AS owner,COUNT(*) AS count,COALESCE(SUM(size),0) AS bytes FROM uploads GROUP BY owner ORDER BY bytes DESC");
|
||||
return { uploads: rows.rows.map(uploadRow), total: Number(count.rows[0]?.n) || 0, owners: owners.rows.map(r => ({ owner: r.owner, count: Number(r.count), bytes: Number(r.bytes) })) };
|
||||
}
|
||||
export async function listUploadFileNames() {
|
||||
const r = await db.execute('SELECT id,ext FROM uploads');
|
||||
return r.rows.flatMap(row => [`${row.id}.${row.ext}`, `${row.id}.art.jpg`]);
|
||||
}
|
||||
export async function listUploadQuotas() {
|
||||
const r = await db.execute('SELECT owner,max_bytes,updated_at FROM upload_owner_quotas ORDER BY owner');
|
||||
return r.rows.map(x => ({ owner: x.owner, maxBytes: Number(x.max_bytes), updatedAt: Number(x.updated_at) }));
|
||||
}
|
||||
export async function getUploadQuota(owner) {
|
||||
const r = await db.execute({ sql: 'SELECT max_bytes FROM upload_owner_quotas WHERE owner=?', args: [owner] });
|
||||
return r.rows[0] ? Number(r.rows[0].max_bytes) : null;
|
||||
}
|
||||
export async function setUploadQuota(owner, maxBytes) {
|
||||
if (maxBytes == null || Number(maxBytes) <= 0) return db.execute({ sql: 'DELETE FROM upload_owner_quotas WHERE owner=?', args: [owner] });
|
||||
return db.execute({ sql: 'INSERT INTO upload_owner_quotas(owner,max_bytes,updated_at) VALUES(?,?,unixepoch()) ON CONFLICT(owner) DO UPDATE SET max_bytes=excluded.max_bytes,updated_at=excluded.updated_at', args: [owner, Math.floor(Number(maxBytes))] });
|
||||
}
|
||||
|
||||
// Newest first; `q` matches title/artist/album (case-insensitive).
|
||||
// `listedOnly`: public search shows admin uploads, never device-shared ones.
|
||||
export async function listUploads({ q = '', limit = 100, listedOnly = false } = {}) {
|
||||
|
||||
@@ -3,8 +3,9 @@ import { audit } from './audit-log.js';
|
||||
export function registerP2pAdminRoutes(app, { db, adminAuth, p2pDb, hub, stats = async () => ({}) }) {
|
||||
app.get('/api/admin/p2p/devices', adminAuth, async c => {
|
||||
const offset = Math.max(0, Number(c.req.query('offset')) || 0);
|
||||
const devices = await p2pDb.listAdminDevices({ limit: 100, offset });
|
||||
return c.json({ ok: true, devices: devices.map(d => ({
|
||||
const page = await p2pDb.listAdminDevices({ limit: 101, offset });
|
||||
const devices = page.slice(0, 100);
|
||||
return c.json({ ok: true, hasMore: page.length > 100, devices: devices.map(d => ({
|
||||
id: d.device_id, fingerprint: d.fingerprint || '', fingerprintShort: (d.fingerprint || d.device_id).slice(0, 12),
|
||||
profile: d.profile || '', userAgent: d.user_agent || '', share: !!Number(d.share), banned: !!d.banned_at,
|
||||
lastSeenAt: Number(d.last_seen_at), online: hub.isOnline(d.device_id), holdings: Number(d.holdings), holdingBytes: Number(d.holding_bytes),
|
||||
|
||||
26
server/p2p-admin.test.js
Normal file
26
server/p2p-admin.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
import { registerP2pAdminRoutes } from './p2p-admin.js';
|
||||
|
||||
test('P2P admin actions require auth, log mutations, and control online devices', async () => {
|
||||
const events = [], calls = [];
|
||||
const db = { execute: async query => { events.push(query.args); return { rowsAffected: 1 }; } };
|
||||
const p2pDb = {
|
||||
listAdminDevices: async ({ limit }) => { expect(limit).toBe(101); return [{ device_id: 'dev_0123456789abcdef', fingerprint: 'fingerprint', profile: 'home', user_agent: 'iPhone', share: 1, banned_at: null, last_seen_at: 42, holdings: 2, holding_bytes: 80, bytes_up: 4, bytes_down: 3 }]; },
|
||||
setDeviceBanned: async (id, banned) => { calls.push(['ban', id, banned]); return true; },
|
||||
removeDeviceHolders: async id => { calls.push(['remove', id]); return 2; },
|
||||
};
|
||||
const hub = { isOnline: () => true, onlineCount: () => 1, send: (...args) => { calls.push(['send', ...args]); return true; }, disconnect: id => calls.push(['disconnect', id]) };
|
||||
const app = new Hono();
|
||||
registerP2pAdminRoutes(app, { db, p2pDb, hub, stats: async () => ({ content: 5 }), adminAuth: async (c, next) => { if (c.req.header('x-admin') !== 'yes') return c.json({ ok:false },401); await next(); } });
|
||||
expect((await app.request('/api/admin/p2p/devices')).status).toBe(401);
|
||||
const list = await app.request('/api/admin/p2p/devices', { headers: { 'x-admin': 'yes' } });
|
||||
expect(await list.json()).toMatchObject({ ok: true, network: { online: 1, content: 5 }, devices: [{ id: 'dev_0123456789abcdef', online: true, holdingBytes: 80 }] });
|
||||
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
|
||||
expect((await app.request('/api/admin/p2p/devices/dev_0123456789abcdef/ban', { method:'POST',headers,body:'{"banned":true}' })).status).toBe(200);
|
||||
expect((await app.request('/api/admin/p2p/devices/dev_0123456789abcdef/reannounce', { method:'POST',headers,body:'{}' })).status).toBe(200);
|
||||
expect((await app.request('/api/admin/p2p/devices/dev_0123456789abcdef/holdings', { method:'DELETE',headers })).status).toBe(200);
|
||||
expect(calls.some(x => x[0] === 'disconnect')).toBe(true);
|
||||
expect(calls.some(x => x[0] === 'send' && x[2].type === 'force-sync')).toBe(true);
|
||||
expect(events).toHaveLength(3);
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"start": "bun server.js",
|
||||
"dev": "bun --hot server.js",
|
||||
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./audit-log.test.js && bun test ./admin-tools.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js && bun test ./video-catalog.test.js && bun test ./search-cache.test.js && bun test --timeout 60000 ./deep-search.test.js"
|
||||
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./audit-log.test.js && bun test ./request-monitor.test.js && bun test ./admin-tools.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admin.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js && bun test ./video-catalog.test.js && bun test ./search-cache.test.js && bun test --timeout 60000 ./deep-search.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.14.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { statfs } from 'node:fs/promises';
|
||||
|
||||
@@ -43,7 +43,12 @@ export function createRequestMonitor({ snapshotFile = null, diskPaths = [], adap
|
||||
const value = await sample(); snapshots.push(value);
|
||||
while (snapshots.length && now() - snapshots[0].at > 24 * 3600_000) snapshots.shift();
|
||||
if (snapshotFile) {
|
||||
try { await mkdir(dirname(snapshotFile), { recursive: true }); await appendFile(snapshotFile, JSON.stringify(value) + '\n'); }
|
||||
try {
|
||||
await mkdir(dirname(snapshotFile), { recursive: true });
|
||||
const prior = (await readFile(snapshotFile, 'utf8').catch(() => '')).split('\n').filter(Boolean).map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(x => x?.at >= now() - 24 * 3600_000);
|
||||
const unique = new Map([...prior, value].map(item => [item.at, item]));
|
||||
await writeFile(snapshotFile, [...unique.values()].sort((a,b)=>a.at-b.at).map(item => JSON.stringify(item)).join('\n') + '\n');
|
||||
}
|
||||
catch (error) { console.warn('[monitor] snapshot write failed:', error.message); }
|
||||
}
|
||||
return value;
|
||||
@@ -54,7 +59,8 @@ export function createRequestMonitor({ snapshotFile = null, diskPaths = [], adap
|
||||
if (snapshotFile) {
|
||||
try {
|
||||
const persisted = (await readFile(snapshotFile, 'utf8')).split('\n').filter(Boolean).map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(x => x?.at >= cutoff);
|
||||
return [...persisted, ...out].slice(-1440);
|
||||
const unique = new Map([...persisted, ...out].map(item => [item.at, item]));
|
||||
return [...unique.values()].sort((a,b)=>a.at-b.at).slice(-1440);
|
||||
} catch { /* no file yet */ }
|
||||
}
|
||||
return out.slice(-1440);
|
||||
@@ -67,10 +73,11 @@ export function createRequestMonitor({ snapshotFile = null, diskPaths = [], adap
|
||||
try { await next(); status = c.res.status; }
|
||||
finally {
|
||||
inflight--;
|
||||
const key = routeKey(c.req.path);
|
||||
let key = routeKey(c.req.path);
|
||||
if (!routes.has(key) && routes.size >= 300) key = 'other';
|
||||
let row = routes.get(key);
|
||||
if (!row) { row = { latencies: [], requests: 0, errors: 0 }; routes.set(key, row); }
|
||||
row.requests++; if (status >= 500) row.errors++;
|
||||
row.requests++; if (status >= 400) row.errors++;
|
||||
row.latencies.push(Math.max(0, now() - started)); if (row.latencies.length > 1000) row.latencies.shift();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequestMonitor } from './request-monitor.js';
|
||||
import { Hono } from 'hono';
|
||||
import { registerRequestMonitorRoutes } from './request-monitor.js';
|
||||
|
||||
test('request monitor records route latency, status errors, and current process health', async () => {
|
||||
let t = 100;
|
||||
@@ -8,12 +10,25 @@ test('request monitor records route latency, status errors, and current process
|
||||
const middleware = monitor.middleware();
|
||||
const ctx = (path, status) => ({ req: { path }, res: { status } });
|
||||
await middleware(ctx('/api/media/abc/status', 200), async () => { t += 8; });
|
||||
await middleware(ctx('/api/media/abc/status', 404), async () => { t += 4; });
|
||||
await middleware(ctx('/api/media/xyz/status', 503), async () => { t += 12; });
|
||||
const sample = await monitor.sample();
|
||||
assert.equal(sample.routes['/api/media/:id/status'].requests, 2);
|
||||
assert.equal(sample.routes['/api/media/:id/status'].errors, 1);
|
||||
assert.equal(sample.routes['/api/media/:id/status'].requests, 3);
|
||||
assert.equal(sample.routes['/api/media/:id/status'].errors, 2);
|
||||
assert.equal(sample.routes['/api/media/:id/status'].p50, 8);
|
||||
assert.equal(sample.media.queued, 2);
|
||||
assert.ok(sample.rssBytes > 0);
|
||||
monitor.close();
|
||||
});
|
||||
|
||||
test('monitor snapshot history is retained and its endpoint is admin protected', async () => {
|
||||
let t = 10_000;
|
||||
const monitor = createRequestMonitor({ now: () => t, snapshotIntervalMs: 0 });
|
||||
await monitor.snapshot(); t += 60_000; await monitor.snapshot();
|
||||
const app = new Hono();
|
||||
registerRequestMonitorRoutes(app, { monitor, adminAuth: async (c,next) => c.req.header('x-admin') ? next() : c.json({ok:false},401) });
|
||||
assert.equal((await app.request('/api/admin/ops/metrics')).status, 401);
|
||||
const response = await app.request('/api/admin/ops/metrics', { headers: { 'x-admin': 'yes' } });
|
||||
const data = await response.json(); assert.equal(data.history.length, 2); assert.ok(data.current.rssBytes > 0);
|
||||
monitor.close();
|
||||
});
|
||||
|
||||
@@ -350,15 +350,20 @@ const app = new Hono();
|
||||
app.use('*', logger());
|
||||
const requestMonitor = createRequestMonitor({
|
||||
snapshotFile: pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'ops-metrics.jsonl'),
|
||||
snapshotIntervalMs: process.env.OPS_MONITOR === '0' ? 0 : 60_000,
|
||||
diskPaths: [{ name: 'app-data', path: dirname(process.env.DB_PATH || './data/ytplayer.db') }, { name: 'media', path: process.env.MEDIA_DIR || './data/media' }],
|
||||
adapters: {
|
||||
ytdlp: () => ytdlpPool ? ytdlpPool.stats() : null,
|
||||
media: () => media?.stats ? media.stats() : null,
|
||||
transcription: () => notesDb.transcriptionStats?.() || null,
|
||||
transcription: async () => {
|
||||
try { const rows = (await notesDb.db.execute("SELECT status,COUNT(*) AS n FROM lyric_transcriptions WHERE status IN ('queued','running') GROUP BY status")).rows;
|
||||
const counts = Object.fromEntries(rows.map(row => [row.status, Number(row.n)])); return { queued: counts.queued || 0, active: counts.running || 0 };
|
||||
} catch { return null; }
|
||||
},
|
||||
cache: () => ({ ...searchCacheCounters, memoryEntries: searchCache.size }),
|
||||
},
|
||||
});
|
||||
app.use('*', requestMonitor.middleware());
|
||||
if (process.env.OPS_MONITOR !== '0') app.use('*', requestMonitor.middleware());
|
||||
registerCatalogRoutes(app, { resolveListener: recommendationListener });
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { createWriteStream, existsSync, mkdirSync, unlinkSync, readdirSync, renameSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { audit } from './audit-log.js';
|
||||
|
||||
export const UPLOAD_ID_RE = /^upl_[a-f0-9]{12}$/;
|
||||
const MAX_BYTES = 4 * 1024 * 1024 * 1024; // 4 GB
|
||||
@@ -104,6 +105,7 @@ export function registerUploadRoutes(app, deps) {
|
||||
const artPath = (id) => locate(`${id}.art.jpg`);
|
||||
const newArtPath = (id) => join(writeDir(), `${id}.art.jpg`);
|
||||
const removeEverywhere = (name) => { for (const d of [uploadDir, backupDir].filter(Boolean)) { try { unlinkSync(join(d, name)); } catch { /* gone */ } } };
|
||||
const removeStaleCopies = (name, currentPath) => { for (const d of [uploadDir, backupDir].filter(Boolean)) { const p = join(d, name); if (p !== currentPath) try { unlinkSync(p); } catch { /* no alternate copy */ } } };
|
||||
|
||||
async function probeFile(path) {
|
||||
const out = await run(ffprobe, ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', path]);
|
||||
@@ -181,6 +183,7 @@ export function registerUploadRoutes(app, deps) {
|
||||
try {
|
||||
await Bun.write(target, file);
|
||||
const r = await ingest({ id, ext, target, size: file.size, filename: file.name, fields: body, artFile: body.art });
|
||||
await audit(db, 'admin', 'upload.create', id, { size: file.size, kind: r.upload.kind });
|
||||
return c.json({ ok: true, ...r });
|
||||
} catch (err) {
|
||||
cleanup(id, target);
|
||||
@@ -188,6 +191,85 @@ export function registerUploadRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/uploads/manage', requireAdminOrToken, async c => {
|
||||
const q = String(c.req.query('q') || '').trim().slice(0, 120), offset = Math.max(0, Number(c.req.query('offset')) || 0);
|
||||
const result = await db.listAdminUploads({ q, offset, limit: 100 });
|
||||
result.quotas = await db.listUploadQuotas?.() || [];
|
||||
const known = new Set(await db.listUploadFileNames());
|
||||
const orphans = [];
|
||||
for (const dir of dirs()) for (const name of readdirSync(dir).slice(0, 10000)) {
|
||||
if (!name.includes('.')) continue;
|
||||
const id = name.split('.')[0];
|
||||
if (UPLOAD_ID_RE.test(id) && !known.has(name) && !name.includes('.replace.')) orphans.push({ name, dir });
|
||||
}
|
||||
return c.json({ ok: true, ...result, offset, orphans: orphans.slice(0, 200) }, 200, { 'Cache-Control': 'no-store' });
|
||||
});
|
||||
|
||||
app.put('/api/admin/uploads/quotas/:owner', requireAdminOrToken, async c => {
|
||||
const owner = c.req.param('owner'); if (!/^[\w-]{8,128}$/.test(owner)) return c.json({ ok: false, error: 'invalid owner id' }, 400);
|
||||
const body = await c.req.json().catch(() => ({})), maxBytes = Number(body.maxBytes);
|
||||
if (!Number.isFinite(maxBytes) || maxBytes < 0 || maxBytes > 10 * 1024 ** 4) return c.json({ ok: false, error: 'quota must be zero (unlimited) or a valid byte count' }, 400);
|
||||
await db.setUploadQuota?.(owner, maxBytes || null);
|
||||
await audit(db, 'admin', 'upload.quota.set', owner, { maxBytes: maxBytes || null });
|
||||
return c.json({ ok: true, maxBytes: maxBytes || null });
|
||||
});
|
||||
|
||||
app.patch('/api/admin/uploads/:id', requireAdminOrToken, async c => {
|
||||
const id = c.req.param('id'); if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||
const body = await c.req.json().catch(() => ({})), before = await db.getUpload(id);
|
||||
if (!before) return c.json({ ok: false, error: 'not found' }, 404);
|
||||
const patch = {};
|
||||
for (const key of ['title','artist','album']) if (body[key] !== undefined) patch[key] = String(body[key]).trim();
|
||||
if (body.listed !== undefined) patch.listed = body.listed === true;
|
||||
await db.updateUploadMeta(id, patch);
|
||||
await audit(db, 'admin', 'upload.update', id, patch);
|
||||
return c.json({ ok: true, upload: await db.getUpload(id) });
|
||||
});
|
||||
|
||||
app.post('/api/admin/uploads/bulk', requireAdminOrToken, async c => {
|
||||
const body = await c.req.json().catch(() => ({})), ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).filter(id => UPLOAD_ID_RE.test(String(id))))].slice(0, 200);
|
||||
if (!ids.length || !['delete','list','unlist'].includes(body.action)) return c.json({ ok: false, error: 'choose uploads and delete, list, or unlist' }, 400);
|
||||
let changed = 0;
|
||||
for (const id of ids) {
|
||||
const u = await db.getUpload(id); if (!u) continue;
|
||||
if (body.action === 'delete') { await db.deleteUpload(id); removeEverywhere(`${u.id}.${u.ext}`); removeEverywhere(`${id}.art.jpg`); }
|
||||
else await db.updateUploadMeta(id, { listed: body.action === 'list' });
|
||||
await audit(db, 'admin', `upload.bulk.${body.action}`, id); changed++;
|
||||
}
|
||||
return c.json({ ok: true, changed });
|
||||
});
|
||||
|
||||
app.put('/api/admin/uploads/:id/replace', requireAdminOrToken, async c => {
|
||||
const id = c.req.param('id'), old = UPLOAD_ID_RE.test(id) ? await db.getUpload(id) : null;
|
||||
if (!old) return c.json({ ok: false, error: 'not found' }, 404);
|
||||
const filename = String(c.req.query('name') || 'replacement.' + old.ext), ext = extOf(filename);
|
||||
if (!VIDEO_EXT.has(ext) && !AUDIO_EXT.has(ext)) return c.json({ ok: false, error: extError(ext) }, 415);
|
||||
const declared = Number(c.req.header('content-length')); if (declared > MAX_BYTES) return c.json({ ok: false, error: 'file is larger than 4 GB' }, 413);
|
||||
const reader = c.req.raw.body?.getReader(); if (!reader) return c.json({ ok: false, error: 'empty body' }, 400);
|
||||
const dir = writeDir(), temp = join(dir, `${id}.replace.${randomBytes(4).toString('hex')}.${ext}`), target = join(dir, `${id}.${ext}`);
|
||||
let got = 0; const out = createWriteStream(temp);
|
||||
try {
|
||||
try { for (;;) { const { done, value } = await reader.read(); if (done) break; got += value.byteLength; if (got > MAX_BYTES) throw Object.assign(Error('file is larger than 4 GB'), { status: 413 }); if (!out.write(value)) await new Promise(r => out.once('drain', r)); } }
|
||||
finally { await new Promise(r => out.end(r)); }
|
||||
if (!got || (declared > 0 && got !== declared)) throw Object.assign(Error('empty or incomplete replacement'), { status: 400 });
|
||||
const probe = await probeFile(temp), info = describeProbe(probe, filename);
|
||||
if (!info.hasAudio) throw Object.assign(Error('replacement has no audio stream'), { status: 415 });
|
||||
const backup = `${target}.previous`;
|
||||
const existingTarget = existsSync(target);
|
||||
if (existingTarget) renameSync(target, backup);
|
||||
try {
|
||||
renameSync(temp, target);
|
||||
await db.replaceUploadFile(id, { kind: info.kind, title: String(c.req.query('title') ?? old.title).slice(0,300), artist: String(c.req.query('artist') ?? old.artist).slice(0,200), album: String(c.req.query('album') ?? old.album).slice(0,200), duration: info.duration, ext, mime: MIME[ext] || (info.kind === 'video' ? 'video/mp4' : 'audio/mpeg'), size: got, art: old.art });
|
||||
} catch (error) { try { unlinkSync(target); } catch {} if (existingTarget) renameSync(backup, target); throw error; }
|
||||
if (existingTarget) try { unlinkSync(backup); } catch {}
|
||||
removeStaleCopies(`${id}.${ext}`, target);
|
||||
if (old.ext !== ext) removeEverywhere(`${id}.${old.ext}`);
|
||||
await audit(db, 'admin', 'upload.replace', id, { oldExt: old.ext, ext, size: got });
|
||||
return c.json({ ok: true, upload: await db.getUpload(id) });
|
||||
} catch (error) { return c.json({ ok: false, error: error.status ? error.message : 'replacement failed validation' }, error.status || 422); }
|
||||
finally { try { unlinkSync(temp); } catch {} }
|
||||
});
|
||||
|
||||
// Streaming upload: the raw file is the request body, copied to disk chunk by
|
||||
// chunk so a multi-GB (hour-long) video never sits in memory.
|
||||
// PUT /api/admin/uploads/stream?name=talk.mp4&title=&artist=&album=
|
||||
@@ -219,6 +301,7 @@ export function registerUploadRoutes(app, deps) {
|
||||
if (declared > 0 && got !== declared) throw Object.assign(new Error(`upload cut short (${got} of ${declared} bytes)`), { status: 400 });
|
||||
const q = (k) => String(c.req.query(k) || '');
|
||||
const r = await ingest({ id, ext, target, size: got, filename: name, fields: { title: q('title'), artist: q('artist'), album: q('album') } });
|
||||
await audit(db, 'admin', 'upload.create', id, { size: got, kind: r.upload.kind });
|
||||
return c.json({ ok: true, ...r });
|
||||
} catch (err) {
|
||||
cleanup(id, target);
|
||||
@@ -252,6 +335,8 @@ export function registerUploadRoutes(app, deps) {
|
||||
if (declared > SHARED.fileBytes) return c.json({ ok: false, error: `shared files can be up to ${mb(SHARED.fileBytes)}` }, 413);
|
||||
if (sharedActive.has(fp)) return c.json({ ok: false, error: 'this device is already uploading — wait for it to finish' }, 429);
|
||||
const use = await db.sharedUploadUsage(fp);
|
||||
const ownerQuota = await db.getUploadQuota?.(fp);
|
||||
if (ownerQuota && use.bytes + declared > ownerQuota) return c.json({ ok: false, error: `owner storage quota of ${mb(ownerQuota)} reached` }, 413);
|
||||
if (use.today >= SHARED.perDay) return c.json({ ok: false, error: `limit of ${SHARED.perDay} shared uploads a day reached` }, 429);
|
||||
if (use.bytes + declared > SHARED.deviceBytes) return c.json({ ok: false, error: `this device's ${mb(SHARED.deviceBytes)} of shared uploads is used up` }, 413);
|
||||
if (use.totalShared + declared > SHARED.totalBytes) return c.json({ ok: false, error: 'the server has no room for more shared uploads' }, 507);
|
||||
@@ -268,8 +353,8 @@ export function registerUploadRoutes(app, deps) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
got += value.byteLength;
|
||||
if (got > SHARED.fileBytes || use.bytes + got > SHARED.deviceBytes) {
|
||||
throw Object.assign(new Error(`shared files can be up to ${mb(Math.min(SHARED.fileBytes, SHARED.deviceBytes - use.bytes))}`), { status: 413 });
|
||||
if (got > SHARED.fileBytes || use.bytes + got > SHARED.deviceBytes || (ownerQuota && use.bytes + got > ownerQuota)) {
|
||||
throw Object.assign(new Error(`shared storage quota reached (owner cap ${ownerQuota ? mb(ownerQuota) : mb(SHARED.deviceBytes)})`), { status: 413 });
|
||||
}
|
||||
if (!out.write(value)) await new Promise((r) => out.once('drain', r));
|
||||
}
|
||||
@@ -306,6 +391,8 @@ export function registerUploadRoutes(app, deps) {
|
||||
await Bun.write(tmp, buf);
|
||||
await run(ffmpeg, ['-v', 'error', '-y', '-i', tmp, '-frames:v', '1', '-vf', "scale='min(800,iw)':-2", newArtPath(id)]);
|
||||
await db.setUploadArt(id, 'file');
|
||||
removeStaleCopies(`${id}.art.jpg`, newArtPath(id));
|
||||
await audit(db, 'admin', 'upload.art.replace', id, { size: buf.byteLength });
|
||||
return c.json({ ok: true });
|
||||
} catch (err) {
|
||||
return c.json({ ok: false, error: 'unusable image: ' + err.message }, 400);
|
||||
@@ -320,6 +407,7 @@ export function registerUploadRoutes(app, deps) {
|
||||
const u = await db.getUpload(id);
|
||||
if (!u) return c.json({ ok: false, error: 'not found' }, 404);
|
||||
await db.deleteUpload(id);
|
||||
await audit(db, 'admin', 'upload.delete', id, { size: u.size });
|
||||
removeEverywhere(`${u.id}.${u.ext}`);
|
||||
removeEverywhere(`${id}.art.jpg`);
|
||||
return c.json({ ok: true });
|
||||
@@ -335,6 +423,8 @@ export function registerUploadRoutes(app, deps) {
|
||||
const id = c.req.param('id');
|
||||
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||
const u = await db.getUpload(id);
|
||||
const requestedGen = Number(c.req.query('g'));
|
||||
if (requestedGen && requestedGen !== Number(u?.gen || 1)) return c.json({ ok: false, error: 'upload generation changed' }, 409, { 'Cache-Control': 'no-store' });
|
||||
if (!u || !existsSync(filePath(u))) return c.json({ ok: false, error: 'not found' }, 404);
|
||||
db.touchUpload(id);
|
||||
return rangeFileResponse(c, filePath(u), u.mime, 'public, max-age=31536000, immutable');
|
||||
@@ -343,6 +433,8 @@ export function registerUploadRoutes(app, deps) {
|
||||
app.get('/api/uploads/:id/art', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||
const row = await db.getUpload(id), requestedGen = Number(c.req.query('g'));
|
||||
if (requestedGen && requestedGen !== Number(row?.artGen || 1)) return c.json({ ok: false, error: 'upload artwork generation changed' }, 409, { 'Cache-Control': 'no-store' });
|
||||
const p = artPath(id);
|
||||
if (!existsSync(p)) return c.json({ ok: false, error: 'no cover art' }, 404);
|
||||
return rangeFileResponse(c, p, 'image/jpeg', 'public, max-age=31536000, immutable');
|
||||
@@ -351,20 +443,20 @@ export function registerUploadRoutes(app, deps) {
|
||||
// /api/streams payload for an upload: one "Original" quality for video,
|
||||
// audio + cover art for audio (the app shows the art where the video goes).
|
||||
function streamsPayload(u) {
|
||||
const art = u.art ? `/api/uploads/${u.id}/art` : '';
|
||||
const url = `/api/uploads/${u.id}`;
|
||||
const art = u.art ? `/api/uploads/${u.id}/art?g=${u.artGen || 1}` : '';
|
||||
const url = `/api/uploads/${u.id}?g=${u.gen || 1}`;
|
||||
return {
|
||||
meta: { id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '', duration: u.duration, thumbnail: art },
|
||||
audioUrl: u.kind === 'audio' ? url : '',
|
||||
qualities: u.kind === 'video' ? [{ label: 'Original', height: 0, hasAudio: true, url }] : [],
|
||||
upload: true,
|
||||
upload: true, gen: u.gen || 1,
|
||||
kind: u.kind,
|
||||
art,
|
||||
};
|
||||
}
|
||||
const card = (u) => ({
|
||||
id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '',
|
||||
duration: u.duration, thumbnail: u.art ? `/api/uploads/${u.id}/art` : '', upload: true, kind: u.kind,
|
||||
id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '', gen: u.gen || 1,
|
||||
duration: u.duration, thumbnail: u.art ? `/api/uploads/${u.id}/art?g=${u.artGen || 1}` : '', upload: true, kind: u.kind,
|
||||
});
|
||||
|
||||
return { streamsPayload, card, filePath, artPath, isUploadId: (id) => UPLOAD_ID_RE.test(id) };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Admin uploads: real ffmpeg-made files through the real routes.
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync, copyFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Hono } from 'hono';
|
||||
@@ -32,7 +32,7 @@ beforeAll(async () => {
|
||||
'-shortest', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', fx('clip.mp4'));
|
||||
app = new Hono();
|
||||
uploads = registerUploadRoutes(app, {
|
||||
db, uploadDir: join(root, 'uploads'), parseLrc, sanitizeLyrics,
|
||||
db, uploadDir: join(root, 'uploads'), backupDir: join(root, 'backup-uploads'), parseLrc, sanitizeLyrics,
|
||||
requireAdminOrToken: async (c, next) => { if (c.req.header('x-admin') === 'yes') await next(); else return c.json({ ok: false, error: 'admin only' }, 401); },
|
||||
rangeFileResponse: (c, path, type) => {
|
||||
const body = readFileSync(path);
|
||||
@@ -108,11 +108,11 @@ describe('uploads', () => {
|
||||
expect(hit.uploads.map((u) => u.id)).toEqual([audioId]);
|
||||
const u = await db.getUpload(audioId);
|
||||
expect(uploads.streamsPayload(u)).toMatchObject({
|
||||
audioUrl: `/api/uploads/${audioId}`, kind: 'audio', upload: true, art: `/api/uploads/${audioId}/art`, qualities: [],
|
||||
audioUrl: `/api/uploads/${audioId}?g=1`, kind: 'audio', upload: true, art: `/api/uploads/${audioId}/art?g=1`, qualities: [],
|
||||
});
|
||||
expect(uploads.card(u)).toMatchObject({ id: audioId, title: 'Test Song', channel: 'Test Artist', kind: 'audio' });
|
||||
const v = uploads.streamsPayload(await db.getUpload(videoId));
|
||||
expect(v.qualities[0]).toMatchObject({ label: 'Original', hasAudio: true, url: `/api/uploads/${videoId}` });
|
||||
expect(v.qualities[0]).toMatchObject({ label: 'Original', hasAudio: true, url: `/api/uploads/${videoId}?g=1` });
|
||||
});
|
||||
|
||||
test('serving supports Range, and delete removes the files', async () => {
|
||||
@@ -162,7 +162,45 @@ describe('streaming upload (large / long files)', () => {
|
||||
const up = await (await put('/api/admin/uploads/stream?name=song.mp3', readFileSync(fx('song.mp3')))).json();
|
||||
const r = await put(`/api/admin/uploads/${up.upload.id}/art`, readFileSync(fx('cover.png')));
|
||||
expect((await r.json()).ok).toBe(true);
|
||||
expect((await db.getUpload(up.upload.id)).art).toBeTruthy();
|
||||
const updated = await db.getUpload(up.upload.id);
|
||||
expect(updated).toMatchObject({ art: 'file', gen: 1, artGen: 2 });
|
||||
expect(uploads.streamsPayload(updated).audioUrl).toBe(`/api/uploads/${up.upload.id}?g=1`);
|
||||
expect(uploads.streamsPayload(updated).art).toBe(`/api/uploads/${up.upload.id}/art?g=2`);
|
||||
expect((await app.request(`/api/uploads/${up.upload.id}/art?g=1`)).status).toBe(409);
|
||||
});
|
||||
|
||||
test('admin manager edits metadata, replaces bytes in place with a new generation, and pages orphan reports', async () => {
|
||||
const up = await (await put('/api/admin/uploads/stream?name=original.mp3', readFileSync(fx('song.mp3')))).json();
|
||||
const id = up.upload.id;
|
||||
expect(up.upload.gen).toBe(1);
|
||||
const edited = await app.request(`/api/admin/uploads/${id}`, { method: 'PATCH', headers: { 'x-admin': 'yes', 'content-type': 'application/json' }, body: JSON.stringify({ title: 'Changed title', artist: 'New artist', listed: false }) });
|
||||
expect((await edited.json()).upload).toMatchObject({ id, title: 'Changed title', artist: 'New artist', listed: false });
|
||||
const replaceBytes = readFileSync(fx('clip.mp4'));
|
||||
const replaced = await put(`/api/admin/uploads/${id}/replace?name=concert.mp4`, replaceBytes);
|
||||
const payload = await replaced.json();
|
||||
expect(replaced.status).toBe(200);
|
||||
expect(payload.upload).toMatchObject({ id, ext: 'mp4', kind: 'video', gen: 2, listed: false });
|
||||
expect((await app.request(`/api/uploads/${id}?g=1`)).status).toBe(409);
|
||||
expect((await app.request(`/api/uploads/${id}`, { headers: { Range: 'bytes=0-9' } })).status).toBe(206);
|
||||
expect(uploads.streamsPayload(payload.upload).qualities[0].url).toContain('?g=2');
|
||||
const listed = await app.request('/api/admin/uploads/bulk', { method: 'POST', headers: { 'x-admin': 'yes', 'content-type': 'application/json' }, body: JSON.stringify({ ids: [id], action: 'list' }) });
|
||||
expect(await listed.json()).toMatchObject({ ok: true, changed: 1 });
|
||||
const manage = await app.request('/api/admin/uploads/manage?q=Changed', { headers: { 'x-admin': 'yes' } });
|
||||
expect(await manage.json()).toMatchObject({ ok: true, total: 1, uploads: [expect.objectContaining({ id, listed: true, gen: 2 })] });
|
||||
const quota = await app.request('/api/admin/uploads/quotas/owner-device1', { method: 'PUT', headers: { 'x-admin': 'yes', 'content-type': 'application/json' }, body: JSON.stringify({ maxBytes: 5_000_000 }) });
|
||||
expect(await quota.json()).toMatchObject({ ok: true, maxBytes: 5_000_000 });
|
||||
expect(await db.getUploadQuota('owner-device1')).toBe(5_000_000);
|
||||
await db.setUploadQuota('owner-device1', null);
|
||||
});
|
||||
|
||||
test('same-extension replacement removes a stale fallback copy before publishing its new generation', async () => {
|
||||
const up = await (await put('/api/admin/uploads/stream?name=mirror.mp3', readFileSync(fx('song.mp3')))).json();
|
||||
const staleBackup = join(root, 'backup-uploads', `${up.upload.id}.mp3`);
|
||||
copyFileSync(join(root, 'uploads', `${up.upload.id}.mp3`), staleBackup);
|
||||
expect(existsSync(staleBackup)).toBe(true);
|
||||
const replaced = await put(`/api/admin/uploads/${up.upload.id}/replace?name=updated.mp3`, readFileSync(fx('song.mp3')));
|
||||
expect((await replaced.json()).upload.gen).toBe(2);
|
||||
expect(existsSync(staleBackup)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
65
tests/admin-ops.spec.js
Normal file
65
tests/admin-ops.spec.js
Normal file
@@ -0,0 +1,65 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
test('monitor, P2P diagnostics and upload manager render and send guarded actions', async ({ page }) => {
|
||||
const dir = path.resolve('plans/admin-ops-shots'); fs.mkdirSync(dir, { recursive: true });
|
||||
const calls = [];
|
||||
await page.route('**/api/**', async route => {
|
||||
const req = route.request(), url = new URL(req.url()); calls.push({ path: url.pathname, method: req.method(), body: req.postData() });
|
||||
let body = { ok: true };
|
||||
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
|
||||
if (url.pathname === '/api/admin/ops/metrics') body = { ok: true, current: { at: Date.now(), inflight: 2, rssBytes: 88_000_000, cpuPercent: 11.2, ytdlp: { queued: 1, workers: 2, busy: 1 }, media: { queued: 3, active: 'vid' }, cache: { hits: 9, misses: 2 }, routes: { '/api/version': { requests: 30, p50: 2, p95: 9, p99: 14, errors: 0, errorRate: 0 } }, volumes: [{ name: 'media', freeBytes: 5_000_000_000, totalBytes: 20_000_000_000 }] }, history: [{ at: Date.now() - 60_000, routes: { '/api/version': { p95: 10 } } }, { at: Date.now(), routes: { '/api/version': { p95: 9 } } }] };
|
||||
if (url.pathname === '/api/admin/p2p/devices') body = { ok: true, network: { online: 1, content: 14, intakeActive: 0, intakeOpen: 1 }, devices: [{ id: 'dev_0123456789abcdef', fingerprintShort: 'phone-4821', profile: 'family', userAgent: 'iPhone · iOS', banned: false, online: true, lastSeenAt: Date.now(), holdings: 8, holdingBytes: 900_000_000, bytesUp: 12_000, bytesDown: 0 }] };
|
||||
if (url.pathname === '/api/admin/uploads/manage') body = { ok: true, total: 1, offset: 0, owners: [{ owner: '(admin)', count: 1, bytes: 1_500_000 }], uploads: [{ id: 'upl_0123456789ab', title: 'Sunday service', artist: 'Choir', album: 'Live', ext: 'mp3', size: 1_500_000, gen: 3, listed: true }], orphans: [] };
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
});
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
await page.goto('/admin.html#editor');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
for (const theme of ['dark', 'light', 'contrast']) for (const width of [390, 768, 1280]) for (const tab of ['ops', 'devices', 'uploads']) {
|
||||
await page.evaluate(theme => { document.documentElement.dataset.editorTheme = theme; }, theme);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const nav = width <= 760 ? '#tabsBottom' : '#tabsTop';
|
||||
const button = page.locator(`${nav} [data-tab="${tab}"]`); await button.scrollIntoViewIfNeeded(); await button.click();
|
||||
await expect(page.locator(`#p-${tab}`)).toBeVisible();
|
||||
await page.waitForTimeout(30);
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth);
|
||||
expect(overflow, `${tab} page overflow at ${width}/${theme}`).toBe(false);
|
||||
const ui = await page.locator(`#p-${tab}`).evaluate(panel => {
|
||||
const rgb = value => (value.match(/[\d.]+/g) || []).slice(0, 3).map(Number);
|
||||
const luminance = value => {
|
||||
const [r, g, b] = rgb(value).map(n => n / 255).map(n => n <= .04045 ? n / 12.92 : ((n + .055) / 1.055) ** 2.4);
|
||||
return .2126 * r + .7152 * g + .0722 * b;
|
||||
};
|
||||
const ratio = (fg, bg) => { const a = luminance(fg), b = luminance(bg); return (Math.max(a, b) + .05) / (Math.min(a, b) + .05); };
|
||||
const bodyBg = getComputedStyle(document.body).backgroundColor;
|
||||
const checks = [...panel.querySelectorAll('.btn:not([disabled]), .field')].filter(el => el.getClientRects().length).map(el => ({ tag: el.tagName, h: el.getBoundingClientRect().height }));
|
||||
const text = [...panel.querySelectorAll('h1.page, h2, .lead, .muted, .metric span, .metric small, .tbl td, .tbl th')].filter(el => el.getClientRects().length).map(el => ratio(getComputedStyle(el).color, bodyBg));
|
||||
return { checks, text };
|
||||
});
|
||||
expect(ui.checks.every(el => el.h >= 43.5), `${tab} controls below 44px at ${width}/${theme}: ${JSON.stringify(ui.checks)}`).toBe(true);
|
||||
expect(ui.text.every(value => value >= 4.5), `${tab} heading/lead contrast below AA at ${width}/${theme}: ${ui.text}`).toBe(true);
|
||||
if (width <= 760) {
|
||||
const lastCard = page.locator(`#p-${tab} .card`).last();
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
const clearance = await lastCard.evaluate(el => ({ bottom: el.getBoundingClientRect().bottom, navTop: document.querySelector('#tabsBottom').getBoundingClientRect().top }));
|
||||
expect(clearance.bottom, `${tab} last card remains behind bottom nav at ${width}/${theme}`).toBeLessThanOrEqual(clearance.navTop);
|
||||
}
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({ path: path.join(dir, `${tab}-${theme}-${width}.png`) });
|
||||
}
|
||||
await page.locator('#tabsTop [data-tab="devices"]').click();
|
||||
await page.locator('[data-device-sync]').click();
|
||||
await page.locator('[data-device-holdings]').click();
|
||||
await page.locator('[data-device-ban]').click();
|
||||
await page.locator('#tabsTop [data-tab="uploads"]').click();
|
||||
await page.locator('[data-upload-save]').click();
|
||||
await page.locator('[data-upload-select]').check();
|
||||
await page.locator('#uploadBulkAction').selectOption('unlist'); await page.locator('#uploadBulkApply').click();
|
||||
expect(calls.some(c => c.path.endsWith('/reannounce'))).toBe(true);
|
||||
expect(calls.some(c => c.path.endsWith('/holdings') && c.method === 'DELETE')).toBe(true);
|
||||
expect(calls.some(c => c.path.endsWith('/ban') && c.method === 'POST')).toBe(true);
|
||||
expect(calls.some(c => c.path.endsWith('/upl_0123456789ab') && c.method === 'PATCH')).toBe(true);
|
||||
expect(calls.some(c => c.path === '/api/admin/uploads/bulk')).toBe(true);
|
||||
});
|
||||
Reference in New Issue
Block a user