Files
akai-utils/web/ui/app.js
T
david d7a17eb054 fix: show extract API errors in UI instead of false 'Extraction complete'
doExtract() only checked data.message, so JSON responses with
error key (e.g. 'no ISO files found') would show green success.
Now checks data.error first and displays it in red.
2026-06-22 01:10:40 -07:00

364 lines
12 KiB
JavaScript

// AKAI Utils — Web UI
const API = '/api';
let eventSource = null;
// ─── Search ────────────────────────────────────────────────────────
document.getElementById('search-btn').addEventListener('click', doSearch);
document.getElementById('search-query').addEventListener('keydown', e => {
if (e.key === 'Enter') doSearch();
});
document.getElementById('search-dl-all').addEventListener('click', downloadTop10);
async function doSearch() {
const q = document.getElementById('search-query').value;
const filter = document.getElementById('search-filter').value;
const status = document.getElementById('search-status');
const results = document.getElementById('search-results');
status.textContent = 'Searching...';
results.innerHTML = '';
try {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (filter) params.set('filter', filter);
params.set('limit', '50');
const resp = await fetch(`${API}/search?${params}`);
const data = await resp.json();
status.textContent = `Found ${data.length} items`;
document.getElementById('result-count').textContent = `(${data.length})`;
renderResults(data);
} catch (e) {
status.textContent = 'Search failed: ' + e.message;
}
}
function renderResults(items) {
const container = document.getElementById('search-results');
container.innerHTML = items.map(r => `
<div class="result-card">
<div class="title">${esc(r.title)}</div>
<div class="meta">
<span>${r.identifier}</span>
<span>${r.downloads.toLocaleString()} downloads</span>
</div>
<div class="actions">
<button class="btn-dl" data-action="download" data-identifier="${esc(r.identifier)}">Download ISOs</button>
<button class="btn-list" data-action="list" data-identifier="${esc(r.identifier)}">List Files</button>
</div>
<div id="list-${sanitize(r.identifier)}" class="file-list"></div>
</div>
`).join('');
}
async function downloadTop10() {
const q = document.getElementById('search-query').value;
const filter = document.getElementById('search-filter').value;
const params = new URLSearchParams();
if (q) params.set('q', q);
if (filter) params.set('filter', filter);
params.set('limit', '10');
try {
const resp = await fetch(`${API}/search?${params}`);
const data = await resp.json();
for (const item of data) {
await downloadItem(item.identifier);
}
} catch (e) {
alert('Bulk download failed: ' + e.message);
}
}
// ─── List ──────────────────────────────────────────────────────────
async function listItem(identifier) {
const el = document.getElementById('list-' + sanitize(identifier));
el.innerHTML = 'Loading...';
try {
const resp = await fetch(`${API}/list?identifier=${encodeURIComponent(identifier)}`);
const files = await resp.json();
if (!Array.isArray(files) || files.length === 0) {
el.innerHTML = '<span class="no-files">No ISO files</span>';
return;
}
el.innerHTML = files.map(f =>
`<div class="file-row">${esc(f.name)} <span class="file-size">${f.size || '?'}</span></div>`
).join('');
} catch (e) {
el.innerHTML = 'Failed: ' + e.message;
}
}
// ─── Download ──────────────────────────────────────────────────────
async function downloadItem(identifier) {
try {
const resp = await fetch(`${API}/download?identifier=${encodeURIComponent(identifier)}&dir=./output/isos`);
const data = await resp.json();
if (data.error) {
alert(data.error);
return;
}
startProgressStream();
} catch (e) {
alert('Download failed: ' + e.message);
}
}
function startProgressStream() {
if (eventSource) eventSource.close();
eventSource = new EventSource(`${API}/progress`);
eventSource.onmessage = function(e) {
const jobs = JSON.parse(e.data);
renderDownloadQueue(jobs);
const allDone = Object.values(jobs).every(j => j.done || j.error);
if (allDone && Object.keys(jobs).length > 0) {
eventSource.close();
eventSource = null;
}
};
eventSource.onerror = function() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
};
}
function renderDownloadQueue(jobs) {
const container = document.getElementById('download-queue');
const count = document.getElementById('dl-count');
const entries = Object.values(jobs);
count.textContent = `(${entries.length})`;
container.innerHTML = entries.map(j => {
const pct = j.total_size > 0 ? Math.round(j.downloaded / j.total_size * 100) : 0;
let cls = '';
if (j.error) cls = 'error';
else if (j.done) cls = 'done';
let info = '';
if (j.error) info = j.error;
else if (j.done) info = formatBytes(j.total_size) + ' — complete';
else info = formatBytes(j.downloaded) + ' / ' + formatBytes(j.total_size) +
(j.speed > 0 ? ' — ' + formatBytes(j.speed) + '/s' : '');
return `
<div class="dl-item">
<div class="dl-name">${esc(j.file_name)}</div>
<div class="dl-bar"><div class="dl-bar-fill ${cls}" style="width:${pct}%"></div></div>
<div class="dl-info"><span>${info}</span><span>${j.done && !j.error ? '✓' : pct + '%'}</span></div>
</div>`;
}).join('');
}
// ─── Extract ───────────────────────────────────────────────────────
document.getElementById('extract-btn').addEventListener('click', doExtract);
async function doExtract() {
const isoDir = document.getElementById('extract-iso-dir').value;
const wavDir = document.getElementById('extract-wav-dir').value;
const status = document.getElementById('extract-status');
status.textContent = 'Extracting... (check server console for output)';
status.style.color = 'var(--yellow)';
try {
const resp = await fetch(`${API}/extract?dir=${encodeURIComponent(isoDir)}&out=${encodeURIComponent(wavDir)}`);
const data = await resp.json();
if (data.error) {
status.textContent = data.error;
status.style.color = 'var(--accent)';
return;
}
status.textContent = data.message || 'Extraction complete';
status.style.color = 'var(--green)';
} catch (e) {
status.textContent = 'Extraction failed: ' + e.message;
status.style.color = 'var(--accent)';
}
}
// ─── WAV Browser ──────────────────────────────────────────────────
let audioCtx = null;
let currentSource = null;
let currentAudioBuffer = null;
let startTime = 0;
let pauseOffset = 0;
let isPlaying = false;
let animationFrame = null;
document.getElementById('wav-browse-btn').addEventListener('click', browseWavs);
async function browseWavs() {
const dir = document.getElementById('wav-dir').value;
const list = document.getElementById('wav-list');
list.innerHTML = 'Loading...';
console.log('browseWavs: dir =', dir);
try {
const url = `${API}/list-wavs?dir=${encodeURIComponent(dir)}`;
console.log('browseWavs: fetching', url);
const resp = await fetch(url);
console.log('browseWavs: response status', resp.status, 'ok', resp.ok);
if (!resp.ok) {
list.innerHTML = '<span class="no-files">HTTP ' + resp.status + '</span>';
return;
}
const data = await resp.json();
console.log('browseWavs: parsed data', data, 'type', typeof data, 'isArray', Array.isArray(data));
if (!data || data.error) {
list.innerHTML = '<span class="no-files">Error: ' + (data && data.error ? data.error : 'server error') + '</span>';
return;
}
if (!Array.isArray(data) || data.length === 0) {
list.innerHTML = '<span class="no-files">No WAV files found</span>';
return;
}
console.log('browseWavs: found', data.length, 'wavs');
list.innerHTML = data.map(f => `
<div class="wav-item" data-file="${esc(f)}">
<span class="wav-name" title="${esc(f)}">${esc(f)}</span>
<button class="wav-play-btn" data-action="play" data-wav="${esc(f)}">Play</button>
</div>
`).join('');
} catch (e) {
console.error('browseWavs: error', e);
list.innerHTML = 'Failed: ' + e.message;
}
}
async function playWav(filename) {
console.log('playWav:', filename);
if (!audioCtx) {
audioCtx = new AudioContext();
console.log('playWav: created AudioContext');
}
if (currentSource) {
currentSource.stop();
currentSource = null;
}
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
const nowPlaying = document.getElementById('wav-now-playing');
nowPlaying.textContent = 'Loading: ' + filename;
try {
const resp = await fetch(`/wavs/${encodeURIComponent(filename)}`);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const arrayBuffer = await resp.arrayBuffer();
currentAudioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
currentSource = audioCtx.createBufferSource();
currentSource.buffer = currentAudioBuffer;
currentSource.connect(audioCtx.destination);
currentSource.onended = () => {
if (isPlaying) {
isPlaying = false;
pauseOffset = 0;
nowPlaying.textContent = '';
document.getElementById('wav-progress-fill').style.width = '0%';
document.getElementById('wav-time').textContent = formatTime(0);
clearActiveItem();
}
};
startTime = audioCtx.currentTime - pauseOffset;
currentSource.start(0, pauseOffset);
isPlaying = true;
pauseOffset = 0;
nowPlaying.textContent = '▶ ' + filename;
setActiveItem(filename);
updateProgress();
} catch (e) {
nowPlaying.textContent = 'Error: ' + e.message;
}
}
function updateProgress() {
if (!isPlaying || !currentAudioBuffer) return;
const elapsed = audioCtx.currentTime - startTime;
const duration = currentAudioBuffer.duration;
const pct = Math.min((elapsed / duration) * 100, 100);
document.getElementById('wav-progress-fill').style.width = pct + '%';
document.getElementById('wav-time').textContent = formatTime(elapsed);
if (elapsed < duration) {
animationFrame = requestAnimationFrame(updateProgress);
}
}
function formatTime(secs) {
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return m + ':' + s.toString().padStart(2, '0');
}
function setActiveItem(filename) {
document.querySelectorAll('.wav-item').forEach(el => {
el.classList.toggle('playing', el.dataset.file === filename);
});
}
function clearActiveItem() {
document.querySelectorAll('.wav-item').forEach(el => {
el.classList.remove('playing');
});
}
// ─── Helpers ──────────────────────────────────────────────────────
function esc(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitize(s) {
return s.replace(/[^a-zA-Z0-9_-]/g, '_');
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// Initial load
doSearch();
// Event delegation for dynamically generated buttons
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'download') {
downloadItem(btn.dataset.identifier);
} else if (action === 'list') {
listItem(btn.dataset.identifier);
} else if (action === 'play') {
playWav(btn.dataset.wav);
}
});