v4.3: file-based media storage + manual VACUUM maintenance

This commit is contained in:
Ashim Kumar
2026-06-12 13:24:00 +06:00
parent 965470853e
commit cc57204aff
10 changed files with 789 additions and 164 deletions

View File

@@ -1,6 +1,6 @@
/**
* Audiobook Maker Pro v4.2 - Main Application
* UPDATED: Lazy audio loading to avoid large response truncation
* Audiobook Maker Pro v4.3 - Main Application
* UPDATED: Lazy audio loading + Storage & Maintenance (VACUUM)
*/
// ============================================
@@ -17,6 +17,7 @@ let voices = [];
let archiveModal = null;
let ttsEditModal = null;
let publishModal = null;
let dbMaintenanceModal = null;
let publishingProjectId = null;
let currentWorkflowStage = 'upload';
let allArchiveProjects = [];
@@ -26,7 +27,7 @@ let allArchiveProjects = [];
// ============================================
document.addEventListener('DOMContentLoaded', function() {
console.log('🎧 Audiobook Maker Pro v4.2 initializing...');
console.log('🎧 Audiobook Maker Pro v4.3 initializing...');
archiveModal = new bootstrap.Modal(document.getElementById('archiveModal'));
ttsEditModal = new bootstrap.Modal(document.getElementById('ttsEditModal'));
@@ -979,6 +980,32 @@ async function loadAudioBlocksInBackground(projectId, blockIds) {
async function fetchOne(blockId) {
try {
const resp = await fetch(`/api/projects/${projectId}/audio/${blockId}`);
// v4.3: এন্ডপয়েন্ট বাইনারি অডিও (audio/*) অথবা legacy base64 JSON দিতে পারে
const contentType = resp.headers.get('content-type') || '';
const blockData = editorBlocks.find(b => b.db_id === blockId);
if (contentType.startsWith('audio/')) {
// নতুন: ফাইল আছে — শুধু indicator আপডেট করি, base64 মেমরিতে রাখি না
// (reader নিজেই lazy fetch করবে)
if (blockData) {
blockData.has_audio = true;
const blockEl = document.getElementById(blockData.id);
if (blockEl) {
const indicator = blockEl.querySelector('.audio-indicator');
if (indicator) {
indicator.classList.remove('no-audio');
indicator.classList.add('has-audio');
indicator.title = 'Audio available';
}
}
}
loaded++;
return;
}
// Legacy base64 JSON
const data = await resp.json();
if (data.error || !data.audio_data) {
@@ -986,13 +1013,11 @@ async function loadAudioBlocksInBackground(projectId, blockIds) {
return;
}
// Update editorBlocks state by db_id
const blockData = editorBlocks.find(b => b.db_id === blockId);
if (blockData) {
blockData.audio_data = data.audio_data;
blockData.audio_format = data.audio_format;
blockData.has_audio = true;
// Update DOM indicator (green dot)
const blockEl = document.getElementById(blockData.id);
if (blockEl) {
const indicator = blockEl.querySelector('.audio-indicator');
@@ -1017,8 +1042,8 @@ async function loadAudioBlocksInBackground(projectId, blockIds) {
}
const msg = failed > 0
? `Loaded ${loaded}/${blockIds.length} audio blocks (${failed} failed)`
: `All ${loaded} audio blocks loaded ✓`;
? `Verified ${loaded}/${blockIds.length} audio blocks (${failed} failed)`
: `All ${loaded} audio blocks verified ✓`;
showNotification(msg, failed > 0 ? 'warning' : 'success');
if (typeof updatePanelUI === 'function') {
@@ -1028,7 +1053,7 @@ async function loadAudioBlocksInBackground(projectId, blockIds) {
async function deleteProject(projectId) {
if (!confirm('Are you sure you want to delete this project? This action cannot be undone.')) return;
if (!confirm('Are you sure you want to delete this project? This action cannot be undone.\n\nএই প্রজেক্টের অডিও ও ইমেজ ফাইলগুলোও মুছে যাবে।')) return;
showLoader('Deleting...');
@@ -1046,6 +1071,111 @@ async function deleteProject(projectId) {
}
}
// ============================================
// v4.3: Storage & Maintenance (VACUUM)
// ============================================
function openDbMaintenance() {
if (!dbMaintenanceModal) {
dbMaintenanceModal = new bootstrap.Modal(document.getElementById('dbMaintenanceModal'));
}
dbMaintenanceModal.show();
loadDbStats();
}
async function loadDbStats() {
const loadingEl = document.getElementById('dbStatsLoading');
const contentEl = document.getElementById('dbStatsContent');
if (loadingEl) loadingEl.style.display = 'block';
if (contentEl) contentEl.style.display = 'none';
try {
const resp = await fetch('/api/maintenance/db-stats');
const s = await resp.json();
if (s.error) {
showNotification(s.error, 'error');
return;
}
document.getElementById('dbmFileSize').textContent = `${s.file_size_mb} MB`;
document.getElementById('dbmMediaSize').textContent = `${s.media_size_mb} MB`;
document.getElementById('dbmFreeText').textContent =
`${s.free_mb} MB (${s.free_percent}%)`;
const bar = document.getElementById('dbmFreeBar');
const pct = Math.min(s.free_percent, 100);
bar.style.width = pct + '%';
bar.textContent = `${s.free_percent}%`;
// রঙ: কম হলে সবুজ, বেশি হলে হলুদ/লাল
bar.className = 'progress-bar';
if (s.free_percent >= 30) {
bar.classList.add('bg-danger');
} else if (s.free_percent >= 15) {
bar.classList.add('bg-warning');
} else {
bar.classList.add('bg-success');
}
const advice = document.getElementById('dbmAdvice');
if (s.free_percent >= 15) {
advice.className = 'alert alert-warning';
advice.style.display = 'block';
advice.innerHTML = `<i class="bi bi-exclamation-triangle me-1"></i>` +
`ডেটাবেসে <strong>${s.free_percent}%</strong> ফাঁকা স্পেস জমেছে। ` +
`<strong>Run VACUUM</strong> চালিয়ে এটি reclaim করতে পারেন।`;
} else {
advice.className = 'alert alert-success';
advice.style.display = 'block';
advice.innerHTML = `<i class="bi bi-check-circle me-1"></i>` +
`ফাঁকা স্পেস কম (<strong>${s.free_percent}%</strong>) — এখন VACUUM চালানোর দরকার নেই।`;
}
if (loadingEl) loadingEl.style.display = 'none';
if (contentEl) contentEl.style.display = 'block';
} catch (e) {
console.error(e);
showNotification('Failed to load storage info', 'error');
}
}
async function runDbVacuum() {
const vacuumBtn = document.getElementById('dbmVacuumBtn');
const refreshBtn = document.getElementById('dbmRefreshBtn');
if (!confirm('VACUUM এখন চালাবেন? এটি ডেটাবেস ছোট করবে কিন্তু কিছু সময় (ডেটাবেস বড় হলে কয়েক মিনিট) নিতে পারে।')) {
return;
}
if (vacuumBtn) {
vacuumBtn.disabled = true;
vacuumBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Running...';
}
if (refreshBtn) refreshBtn.disabled = true;
try {
const resp = await fetch('/api/maintenance/vacuum', { method: 'POST' });
const data = await resp.json();
if (data.error) {
showNotification(data.error, 'error');
} else {
showNotification(data.message || 'VACUUM complete', 'success');
loadDbStats();
}
} catch (e) {
showNotification('VACUUM failed', 'error');
} finally {
if (vacuumBtn) {
vacuumBtn.disabled = false;
vacuumBtn.innerHTML = '<i class="bi bi-stars me-1"></i>Run VACUUM';
}
if (refreshBtn) refreshBtn.disabled = false;
}
}
// ============================================
// TTS Text Editing
// ============================================

View File

@@ -1,12 +1,12 @@
/**
* Interactive Reader Module — Lazy Audio Loading (v4)
* Interactive Reader Module — File-based Audio (v4.3)
*
* Strategy:
* - Text + transcription are already loaded (from editorBlocks in memory).
* - Audio is fetched on-demand from /api/projects/<id>/audio/<block_id>
* when the user wants to play that block.
* - Smart preload: at 70% of current block, fetch next block's audio.
* - Memory cap: keep at most MAX_AUDIO_LOADED blob URLs alive.
* - Text + transcription loaded from editorBlocks in memory.
* - Audio fetched on-demand from /api/projects/<id>/audio/<block_id>.
* v4.3: endpoint may return binary audio (Content-Type: audio/*) OR
* legacy base64 JSON. Both handled.
* - Smart preload + memory cap (sliding window of blob URLs).
*/
// ============================================
@@ -66,7 +66,6 @@ function renderInteractiveReader() {
isFirstBlockOfChapter = false;
// has_audio comes from server; audio_data may not yet be loaded
if (!isImageBlock && blockData && (blockData.audio_data || blockData.has_audio)) {
hasAudio = true;
}
@@ -106,7 +105,6 @@ function renderInteractiveReader() {
const blockData = block._editorData;
const isImageBlock = block._isImage;
// has_audio is the SOURCE OF TRUTH for whether this block has audio on server
const hasBlockAudio = !isImageBlock && blockData && (blockData.audio_data || blockData.has_audio);
const blockId = blockData ? blockData.id : `reader_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
@@ -447,38 +445,45 @@ function setReaderButtonLoading(isLoading) {
}
// ============================================
// Audio Lazy Loading
// Audio Lazy Loading (v4.3 — binary OR base64)
// ============================================
/**
* Fetch audio for an instance. If already loaded into editorBlocks
* by background loader, use that. Otherwise fetch from API directly.
* Fetch audio for an instance.
* v4.3: endpoint may return binary audio (Content-Type: audio/*)
* → return { audio_url } ; OR legacy base64 JSON → return { audio_data }.
*/
async function fetchAudioForInstance(inst) {
// Path 1: audio_data already in editorBlocks (loaded in background)
// Path 1: audio_data already in editorBlocks (rare, generated this session)
if (inst.blockData && inst.blockData.audio_data) {
return {
audio_data: inst.blockData.audio_data,
audio_format: inst.blockData.audio_format || 'mp3'
};
}
// Path 2: fetch from API
if (!inst.blockData || !inst.blockData.db_id || !currentProject || !currentProject.id) {
throw new Error('Cannot fetch audio: missing block info');
}
const resp = await fetch(`/api/projects/${currentProject.id}/audio/${inst.blockData.db_id}`);
if (!resp.ok) throw new Error('No audio data');
const contentType = resp.headers.get('content-type') || '';
if (contentType.startsWith('audio/')) {
// v4.3: direct binary stream
const blob = await resp.blob();
return { audio_url: URL.createObjectURL(blob), audio_format: 'mp3' };
}
// Legacy base64 JSON
const data = await resp.json();
if (data.error || !data.audio_data) {
throw new Error(data.error || 'No audio data');
}
// Cache into editorBlocks for future use
inst.blockData.audio_data = data.audio_data;
inst.blockData.audio_format = data.audio_format;
return data;
}
@@ -488,8 +493,13 @@ function ensureReaderAudioLoaded(inst) {
inst.audioLoadingPromise = (async () => {
const audioInfo = await fetchAudioForInstance(inst);
const audioBlob = base64ToBlob(audioInfo.audio_data, `audio/${audioInfo.audio_format || 'mp3'}`);
const audioUrl = URL.createObjectURL(audioBlob);
let audioUrl;
if (audioInfo.audio_url) {
audioUrl = audioInfo.audio_url; // v4.3 binary blob URL
} else {
const audioBlob = base64ToBlob(audioInfo.audio_data, `audio/${audioInfo.audio_format || 'mp3'}`);
audioUrl = URL.createObjectURL(audioBlob);
}
const audio = new Audio(audioUrl);
return new Promise((resolve, reject) => {