diff --git a/config.py b/config.py index 62a3bf4..5d8211d 100644 --- a/config.py +++ b/config.py @@ -16,6 +16,11 @@ STATIC_URL_PATH = '/static' # Dev-এ env না থাকলে app.py-র পাশে রাখবে DATABASE = os.getenv('DATABASE', os.path.join(BASE_DIR, 'audiobook_maker.db')) +# --- MEDIA STORAGE (v4.3) --- +# ডেটাবেসের পাশেই media-storage ফোল্ডার রাখি যাতে একই persistent volume-এ থাকে +_DB_DIR = os.path.dirname(os.path.abspath(DATABASE)) +MEDIA_STORAGE_DIR = os.getenv('MEDIA_STORAGE_DIR', os.path.join(_DB_DIR, 'media-storage')) + # --- FLASK SECRET KEY --- SECRET_KEY = os.getenv('SECRET_KEY', 'audiobook-maker-pro-' + str(uuid.uuid4())) diff --git a/db.py b/db.py index 232b24e..43ef179 100644 --- a/db.py +++ b/db.py @@ -1,4 +1,4 @@ -# db.py - Database Configuration and Operations (v4.2) +# db.py - Database Configuration and Operations (v4.3) import os import sqlite3 @@ -32,13 +32,19 @@ def get_db_connection(): def init_db(): - """Initialize database tables. Auto-creates parent directory.""" + """Initialize database tables. Auto-creates parent directory + media storage.""" # ফোল্ডার না থাকলে নিজে থেকেই তৈরি করবে (Coolify volume mount-এর জন্য জরুরি) db_dir = os.path.dirname(os.path.abspath(DATABASE)) if db_dir and not os.path.exists(db_dir): os.makedirs(db_dir, exist_ok=True) print(f"📂 Created data directory: {db_dir}") + # v4.3: media-storage ফোল্ডার তৈরি + from config import MEDIA_STORAGE_DIR + if not os.path.exists(MEDIA_STORAGE_DIR): + os.makedirs(MEDIA_STORAGE_DIR, exist_ok=True) + print(f"📂 Created media storage directory: {MEDIA_STORAGE_DIR}") + with get_db_connection() as conn: cursor = conn.cursor() @@ -115,7 +121,7 @@ def init_db(): ) ''') - # v4.2 publishing migrations + # v4.2 publishing migrations + v4.3 file-based media path columns migrations = [ ('projects', 'is_published', 'INTEGER DEFAULT 0'), ('projects', 'published_at', 'TIMESTAMP'), @@ -125,6 +131,10 @@ def init_db(): ('projects', 'author', 'TEXT DEFAULT ""'), ('projects', 'category', 'TEXT DEFAULT ""'), ('projects', 'view_count', 'INTEGER DEFAULT 0'), + # --- v4.3: file-based media path columns --- + ('projects', 'thumbnail_path', 'TEXT'), + ('markdown_blocks', 'audio_path', 'TEXT'), + ('block_images', 'image_path', 'TEXT'), ] for table, column, definition in migrations: @@ -135,14 +145,42 @@ def init_db(): pass conn.commit() - print(f"✅ Database initialized at {DATABASE} (v4.2)") + print(f"✅ Database initialized at {DATABASE} (v4.3)") def vacuum_db(): + """ম্যানুয়াল VACUUM (এখন আর অটোমেটিক চলে না)।""" with get_db_connection() as conn: conn.execute('VACUUM') +def get_db_stats(): + """ + ডেটাবেসের সাইজ এবং কত% ফাঁকা (reusable free) স্পেস আছে তা রিটার্ন করে। + SQLite-এর freelist_count আর page_count দিয়ে হিসাব করা হয়। + """ + file_size = os.path.getsize(DATABASE) if os.path.exists(DATABASE) else 0 + with get_db_connection() as conn: + cur = conn.cursor() + page_count = cur.execute('PRAGMA page_count').fetchone()[0] + freelist_count = cur.execute('PRAGMA freelist_count').fetchone()[0] + page_size = cur.execute('PRAGMA page_size').fetchone()[0] + + free_bytes = freelist_count * page_size + free_percent = round((freelist_count / page_count) * 100, 1) if page_count > 0 else 0.0 + + return { + 'file_size_bytes': file_size, + 'file_size_mb': round(file_size / (1024 * 1024), 2), + 'page_count': page_count, + 'freelist_count': freelist_count, + 'page_size': page_size, + 'free_bytes': free_bytes, + 'free_mb': round(free_bytes / (1024 * 1024), 2), + 'free_percent': free_percent, + } + + def init_app(app): app.teardown_appcontext(close_db) init_db() diff --git a/media_storage.py b/media_storage.py new file mode 100644 index 0000000..0ba50fe --- /dev/null +++ b/media_storage.py @@ -0,0 +1,170 @@ +# media_storage.py - File-based media storage manager (v4.3) + +import os +import base64 +import shutil + +from config import MEDIA_STORAGE_DIR + + +def _ensure_dir(path): + os.makedirs(path, exist_ok=True) + + +def get_project_dir(project_id): + """প্রজেক্টের base ডিরেক্টরি (absolute path) রিটার্ন করে।""" + return os.path.join(MEDIA_STORAGE_DIR, f'project_{int(project_id)}') + + +def get_audio_dir(project_id): + return os.path.join(get_project_dir(project_id), 'audio') + + +def get_images_dir(project_id): + return os.path.join(get_project_dir(project_id), 'images') + + +# ============================================ +# Save operations (base64 string → file) +# ============================================ + +def save_audio(project_id, block_id, audio_base64, audio_format='mp3'): + """ + Audio ফাইল সেভ করে relative path রিটার্ন করে। + relative path ডেটাবেসে জমা হবে, যেমন: project_5/audio/block_123.mp3 + """ + if not audio_base64: + return None + audio_dir = get_audio_dir(project_id) + _ensure_dir(audio_dir) + fmt = (audio_format or 'mp3').lower() + filename = f'block_{int(block_id)}.{fmt}' + abs_path = os.path.join(audio_dir, filename) + with open(abs_path, 'wb') as f: + f.write(base64.b64decode(audio_base64)) + return f'project_{int(project_id)}/audio/{filename}' + + +def save_image(project_id, image_id, image_base64, image_format='png'): + """Image ফাইল সেভ করে relative path রিটার্ন করে।""" + if not image_base64: + return None + images_dir = get_images_dir(project_id) + _ensure_dir(images_dir) + fmt = (image_format or 'png').lower() + filename = f'img_{int(image_id)}.{fmt}' + abs_path = os.path.join(images_dir, filename) + with open(abs_path, 'wb') as f: + f.write(base64.b64decode(image_base64)) + return f'project_{int(project_id)}/images/{filename}' + + +def save_thumbnail(project_id, image_bytes, image_format='png'): + """Thumbnail সেভ করে relative path রিটার্ন করে (raw bytes নেয়)।""" + if not image_bytes: + return None + proj_dir = get_project_dir(project_id) + _ensure_dir(proj_dir) + fmt = (image_format or 'png').lower() + filename = f'thumbnail.{fmt}' + abs_path = os.path.join(proj_dir, filename) + # পুরোনো thumbnail অন্য ফরম্যাটে থাকলে মুছে দিই + for old_ext in ('png', 'jpeg', 'jpg', 'webp', 'gif'): + old = os.path.join(proj_dir, f'thumbnail.{old_ext}') + if old_ext != fmt and os.path.exists(old): + try: + os.remove(old) + except OSError: + pass + with open(abs_path, 'wb') as f: + f.write(image_bytes) + return f'project_{int(project_id)}/{filename}' + + +# ============================================ +# Read operations (file → bytes / base64) +# ============================================ + +def _safe_abs_path(relative_path): + """ + relative path কে absolute path-এ রূপান্তর করে এবং নিশ্চিত করে + যে এটা MEDIA_STORAGE_DIR-এর বাইরে যাচ্ছে না (path traversal রোধ)। + """ + if not relative_path: + return None + base = os.path.realpath(MEDIA_STORAGE_DIR) + abs_path = os.path.realpath(os.path.join(base, relative_path)) + if not abs_path.startswith(base + os.sep) and abs_path != base: + return None # নিরাপত্তা লঙ্ঘন + return abs_path + + +def read_file_bytes(relative_path): + """ফাইলের raw bytes রিটার্ন করে। না থাকলে None।""" + abs_path = _safe_abs_path(relative_path) + if not abs_path or not os.path.exists(abs_path): + return None + with open(abs_path, 'rb') as f: + return f.read() + + +def read_file_base64(relative_path): + """ফাইল base64 স্ট্রিং হিসেবে রিটার্ন করে। না থাকলে খালি স্ট্রিং।""" + data = read_file_bytes(relative_path) + if data is None: + return '' + return base64.b64encode(data).decode('utf-8') + + +def get_safe_abs_path(relative_path): + """Flask send_file-এর জন্য নিরাপদ absolute path।""" + return _safe_abs_path(relative_path) + + +# ============================================ +# Storage stats (v4.3) +# ============================================ + +def get_storage_usage_bytes(): + """media-storage ফোল্ডারের মোট সাইজ (bytes) রিটার্ন করে।""" + total = 0 + if not os.path.exists(MEDIA_STORAGE_DIR): + return 0 + for root, _, files in os.walk(MEDIA_STORAGE_DIR): + for name in files: + fp = os.path.join(root, name) + try: + total += os.path.getsize(fp) + except OSError: + pass + return total + + +# ============================================ +# Delete operations +# ============================================ + +def delete_project_media(project_id): + """প্রজেক্টের পুরো media ফোল্ডার মুছে দেয় (প্রজেক্ট ডিলিটের সময়)।""" + proj_dir = get_project_dir(project_id) + if os.path.exists(proj_dir): + try: + shutil.rmtree(proj_dir) + print(f"🗑️ Deleted media folder: {proj_dir}") + return True + except OSError as e: + print(f"⚠️ Failed to delete media folder {proj_dir}: {e}") + return False + return False + + +def delete_file(relative_path): + """একটা নির্দিষ্ট ফাইল মুছে দেয়।""" + abs_path = _safe_abs_path(relative_path) + if abs_path and os.path.exists(abs_path): + try: + os.remove(abs_path) + return True + except OSError: + return False + return False diff --git a/migrate_to_files.py b/migrate_to_files.py new file mode 100644 index 0000000..6aa45e5 --- /dev/null +++ b/migrate_to_files.py @@ -0,0 +1,96 @@ +# migrate_to_files.py - One-time: base64 in DB → files on disk (v4.2 → v4.3) + +import sqlite3 +import base64 + +from config import DATABASE +from db import init_db +from media_storage import save_audio, save_image, save_thumbnail + + +def migrate(): + # নিশ্চিত করি নতুন কলামগুলো (audio_path, image_path, thumbnail_path) তৈরি আছে + print("🔧 Ensuring schema is up to date...") + init_db() + + conn = sqlite3.connect(DATABASE) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + # --- Audio --- + cur.execute(''' + SELECT mb.id as block_id, mb.audio_data, mb.audio_format, c.project_id + FROM markdown_blocks mb JOIN chapters c ON mb.chapter_id = c.id + WHERE mb.audio_data IS NOT NULL AND mb.audio_data != '' + AND (mb.audio_path IS NULL OR mb.audio_path = '') + ''') + rows = cur.fetchall() + print(f"🔊 Migrating {len(rows)} audio blocks...") + done = 0 + for r in rows: + try: + rel = save_audio(r['project_id'], r['block_id'], r['audio_data'], r['audio_format'] or 'mp3') + if rel: + cur.execute("UPDATE markdown_blocks SET audio_path=?, audio_data='' WHERE id=?", + (rel, r['block_id'])) + done += 1 + except Exception as e: + print(f" ⚠️ Audio block {r['block_id']} failed: {e}") + conn.commit() + print(f" ✅ {done} audio files written") + + # --- Images --- + cur.execute(''' + SELECT bi.id as image_id, bi.image_data, bi.image_format, c.project_id + FROM block_images bi + JOIN markdown_blocks mb ON bi.block_id = mb.id + JOIN chapters c ON mb.chapter_id = c.id + WHERE bi.image_data IS NOT NULL AND bi.image_data != '' + AND (bi.image_path IS NULL OR bi.image_path = '') + ''') + rows = cur.fetchall() + print(f"🖼️ Migrating {len(rows)} images...") + done = 0 + for r in rows: + try: + rel = save_image(r['project_id'], r['image_id'], r['image_data'], r['image_format'] or 'png') + if rel: + cur.execute("UPDATE block_images SET image_path=?, image_data='' WHERE id=?", + (rel, r['image_id'])) + done += 1 + except Exception as e: + print(f" ⚠️ Image {r['image_id']} failed: {e}") + conn.commit() + print(f" ✅ {done} image files written") + + # --- Thumbnails --- + cur.execute(''' + SELECT id, thumbnail_data, thumbnail_format FROM projects + WHERE thumbnail_data IS NOT NULL AND thumbnail_data != '' + AND (thumbnail_path IS NULL OR thumbnail_path = '') + ''') + rows = cur.fetchall() + print(f"📕 Migrating {len(rows)} thumbnails...") + done = 0 + for r in rows: + try: + raw = base64.b64decode(r['thumbnail_data']) + rel = save_thumbnail(r['id'], raw, r['thumbnail_format'] or 'png') + if rel: + cur.execute("UPDATE projects SET thumbnail_path=?, thumbnail_data=NULL WHERE id=?", + (rel, r['id'])) + done += 1 + except Exception as e: + print(f" ⚠️ Thumbnail {r['id']} failed: {e}") + conn.commit() + print(f" ✅ {done} thumbnails written") + + print("🧹 Running VACUUM (this may take a while on a large DB)...") + conn.execute('VACUUM') + conn.commit() + conn.close() + print("✅ Migration complete! Database should now be much smaller.") + + +if __name__ == '__main__': + migrate() diff --git a/routes/generation_routes.py b/routes/generation_routes.py index 60b936e..891e776 100644 --- a/routes/generation_routes.py +++ b/routes/generation_routes.py @@ -1,4 +1,4 @@ -# routes/generation_routes.py - Combined Endpoint with Correct Task Polling +# routes/generation_routes.py - Combined Endpoint with Correct Task Polling (v4.3) import json import time @@ -40,8 +40,6 @@ def poll_beam_task(task_id): print(f" URL: {task_url}") start_time = time.time() - - # প্রথম কয়েকটা attempt এ 404 আসতে পারে — task register হতে delay initial_delay = True while True: @@ -51,17 +49,14 @@ def poll_beam_task(task_id): print(f"❌ Polling timeout after {POLL_MAX_WAIT}s") return None, f'Task timed out after {int(POLL_MAX_WAIT)} seconds' - # প্রথম ২ সেকেন্ড wait করি task register হতে if initial_delay and elapsed < 2: time.sleep(2) initial_delay = False continue try: - # ★ Bearer token দিয়ে try resp = requests.get(task_url, headers=get_beam_auth_headers(), timeout=30) - # Bearer fail হলে Basic try করি if resp.status_code in (401, 403): print(f" Bearer auth failed, trying Basic...") basic_headers = { @@ -73,20 +68,14 @@ def poll_beam_task(task_id): print(f" [{int(elapsed)}s] HTTP {resp.status_code} | Body: {len(resp.text)} chars") if resp.status_code == 404: - # Task এখনও register হয়নি — wait if elapsed < 30: print(f" Task not found yet, waiting...") time.sleep(POLL_INTERVAL) continue else: - # ৩০ সেকেন্ড পরেও 404 — সমস্যা print(f"❌ Task not found after {int(elapsed)}s") - - # ★ Debug: response body দেখি print(f" 404 body: {resp.text[:300]}") - # ★ Alternative: Beam API base URL ভিন্ন হতে পারে - # কিছু Beam setup এ URL format ভিন্ন alt_urls = [ f"https://api.beam.cloud/v2/task/{task_id}/status/", f"https://api.beam.cloud/v2/task/{task_id}", @@ -121,25 +110,14 @@ def poll_beam_task(task_id): if status in ('COMPLETE', 'COMPLETED', 'SUCCESS'): print(f"✅ Task complete!") - # ★ Result বের করা — Beam বিভিন্ন জায়গায় result রাখে - # 1. 'output' key - # 2. 'result' key - # 3. 'outputs' list (file-based) - # 4. response body তেই (endpoint mode) - actual_result = None - # Check 'output' (endpoint mode — function return value) if data.get('output') and isinstance(data['output'], dict): actual_result = data['output'] print(f" Result found in 'output' key") - - # Check 'result' elif data.get('result') and isinstance(data['result'], dict): actual_result = data['result'] print(f" Result found in 'result' key") - - # Check if top-level has audio_base64 (unlikely but possible) elif data.get('audio_base64'): actual_result = data print(f" Result found in top-level data") @@ -149,16 +127,12 @@ def poll_beam_task(task_id): elif actual_result and actual_result.get('success'): return actual_result, None - # ★ Outputs (file-based) — need to download outputs = data.get('outputs', []) if outputs: print(f" Task has {len(outputs)} output files") - # For our use case, result should be in 'output' not files - # But log it for debug for out in outputs: print(f" Output: {out.get('name', '?')} → {out.get('url', '?')}") - # No usable result found print(f" ⚠️ Task complete but no audio in response") print(f" Response keys: {list(data.keys())}") print(f" Full response (first 500): {json.dumps(data, default=str)[:500]}") @@ -177,7 +151,7 @@ def poll_beam_task(task_id): return None, f'Task {status.lower()} on Beam. Container may not have started in time.' elif status in ('PENDING', 'RUNNING', 'RETRY'): - pass # Keep polling + pass else: print(f" Unknown status: {status}") @@ -221,16 +195,12 @@ def call_beam_and_get_result(text, voice='af_heart', speed=1.0): task_id = response.headers.get('X-Task-Id', '') - # ======================================== - # CASE 1: Task ID + empty/no body → Async → Poll - # ======================================== + # CASE 1: Task ID + empty body → Async → Poll if task_id and (not response.text or not response.text.strip() or response.headers.get('Content-Length') == '0'): print(f"📋 Async mode — Task ID: {task_id}") return poll_beam_task(task_id) - # ======================================== # CASE 2: Task ID + body - # ======================================== if task_id and response.text and response.text.strip(): print(f"📋 Task ID: {task_id} + body ({len(response.text)} chars)") try: @@ -238,20 +208,15 @@ def call_beam_and_get_result(text, voice='af_heart', speed=1.0): if result.get('success') and result.get('audio_base64'): print(f"✅ Direct sync result") return _extract(result), None - # Body isn't the final result — poll return poll_beam_task(task_id) except Exception: return poll_beam_task(task_id) - # ======================================== # CASE 3: No task_id + empty body → Error - # ======================================== if not response.text or not response.text.strip(): return None, 'Empty response from Beam with no task ID' - # ======================================== # CASE 4: Synchronous response - # ======================================== if response.status_code != 200: try: err = response.json().get('error', response.text[:200]) @@ -326,15 +291,26 @@ def generate_audio(): if source_format != 'mp3': audio_base64 = convert_to_mp3(audio_base64, source_format) + # block_id থাকলে সরাসরি ফাইলে সেভ করি (v4.3) if block_id: + from media_storage import save_audio db = get_db() cursor = db.cursor() cursor.execute(''' - UPDATE markdown_blocks - SET audio_data = ?, audio_format = 'mp3', transcription = ? - WHERE id = ? - ''', (audio_base64, json.dumps(transcription), block_id)) - db.commit() + SELECT c.project_id FROM markdown_blocks mb + JOIN chapters c ON mb.chapter_id = c.id + WHERE mb.id = ? + ''', (block_id,)) + row = cursor.fetchone() + if row: + project_id = row['project_id'] + rel_path = save_audio(project_id, block_id, audio_base64, 'mp3') + cursor.execute(''' + UPDATE markdown_blocks + SET audio_path = ?, audio_data = '', audio_format = 'mp3', transcription = ? + WHERE id = ? + ''', (rel_path, json.dumps(transcription), block_id)) + db.commit() print(f"✅ DONE: audio={len(audio_base64)} bytes, words={len(transcription)}") print(f"{'='*60}") @@ -377,7 +353,7 @@ def generate_chapter_audio(): cursor = db.cursor() cursor.execute(''' - SELECT id, content, tts_text, block_type FROM markdown_blocks + SELECT id, content, tts_text, block_type, chapter_id FROM markdown_blocks WHERE chapter_id = ? ORDER BY block_order ''', (chapter_id,)) blocks = cursor.fetchall() @@ -385,6 +361,11 @@ def generate_chapter_audio(): if not blocks: return jsonify({'error': 'No blocks found'}), 404 + # project_id বের করি (ফাইল সেভের জন্য) + cursor.execute('SELECT project_id FROM chapters WHERE id = ?', (chapter_id,)) + ch_row = cursor.fetchone() + project_id = ch_row['project_id'] if ch_row else None + results = [] success_count = 0 error_count = 0 @@ -394,6 +375,8 @@ def generate_chapter_audio(): print(f"📖 CHAPTER: {total} blocks, voice={voice}") print(f"{'='*60}") + from media_storage import save_audio + for idx, block in enumerate(blocks): block_id = block['id'] block_type = block['block_type'] if 'block_type' in block.keys() else 'paragraph' @@ -437,11 +420,14 @@ def generate_chapter_audio(): if source_format != 'mp3': audio_base64 = convert_to_mp3(audio_base64, source_format) + # v4.3: ফাইলে সেভ + rel_path = save_audio(project_id, block_id, audio_base64, 'mp3') if project_id else None + cursor.execute(''' UPDATE markdown_blocks - SET audio_data = ?, audio_format = 'mp3', transcription = ? + SET audio_path = ?, audio_data = '', audio_format = 'mp3', transcription = ? WHERE id = ? - ''', (audio_base64, json.dumps(transcription), block_id)) + ''', (rel_path, json.dumps(transcription), block_id)) results.append({ 'block_id': block_id, diff --git a/routes/project_routes.py b/routes/project_routes.py index d32f4ab..eca6567 100644 --- a/routes/project_routes.py +++ b/routes/project_routes.py @@ -1,12 +1,18 @@ -# routes/project_routes.py - Project Management Routes (v4.2) +# routes/project_routes.py - Project Management Routes (v4.3) import re +import os import json import base64 -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, send_file -from db import get_db, vacuum_db +from db import get_db, vacuum_db, get_db_stats from auth import login_required +from media_storage import ( + save_audio, save_image, save_thumbnail, + read_file_base64, get_safe_abs_path, + delete_project_media, get_storage_usage_bytes +) project_bp = Blueprint('project', __name__) @@ -48,6 +54,7 @@ def list_projects(): cursor.execute(''' SELECT p.id, p.name, p.created_at, p.updated_at, p.is_published, p.published_at, p.thumbnail_data, p.thumbnail_format, + p.thumbnail_path, p.description, p.author, p.category, p.view_count, (SELECT COUNT(*) FROM chapters WHERE project_id = p.id) as chapter_count, (SELECT COUNT(*) FROM markdown_blocks mb @@ -55,13 +62,20 @@ def list_projects(): WHERE c.project_id = p.id) as block_count, (SELECT COUNT(*) FROM markdown_blocks mb JOIN chapters c ON mb.chapter_id = c.id - WHERE c.project_id = p.id AND mb.audio_data IS NOT NULL AND mb.audio_data != '') as audio_count + WHERE c.project_id = p.id + AND ((mb.audio_data IS NOT NULL AND mb.audio_data != '') + OR (mb.audio_path IS NOT NULL AND mb.audio_path != ''))) as audio_count FROM projects p ORDER BY p.updated_at DESC ''') projects = [] for row in cursor.fetchall(): + # thumbnail: path থাকলে ফাইল থেকে, নইলে পুরোনো base64 + thumb_data = row['thumbnail_data'] + if row['thumbnail_path']: + thumb_data = read_file_base64(row['thumbnail_path']) + projects.append({ 'id': row['id'], 'name': row['name'], @@ -72,7 +86,7 @@ def list_projects(): 'audio_count': row['audio_count'], 'is_published': bool(row['is_published']), 'published_at': row['published_at'], - 'thumbnail_data': row['thumbnail_data'], + 'thumbnail_data': thumb_data, 'thumbnail_format': row['thumbnail_format'] or 'png', 'description': row['description'] or '', 'author': row['author'] or '', @@ -115,9 +129,8 @@ def create_project(): @login_required def get_project(project_id): """ - Get project metadata WITHOUT audio_data. - Audio is loaded lazily via /api/projects//audio/. - This keeps the response small (<1 MB) and avoids proxy truncation issues. + Get project metadata WITHOUT audio_data (lazy-loaded separately). + Images served as base64 from files (editor compatibility). """ db = get_db() cursor = db.cursor() @@ -137,8 +150,9 @@ def get_project(project_id): for chapter in chapters: cursor.execute(''' SELECT id, block_order, block_type, content, tts_text, - audio_format, transcription, - (audio_data IS NOT NULL AND audio_data != '') as has_audio + audio_format, audio_path, transcription, + ((audio_data IS NOT NULL AND audio_data != '') + OR (audio_path IS NOT NULL AND audio_path != '')) as has_audio FROM markdown_blocks WHERE chapter_id = ? ORDER BY block_order ''', (chapter['id'],)) blocks = cursor.fetchall() @@ -146,7 +160,8 @@ def get_project(project_id): blocks_data = [] for block in blocks: cursor.execute(''' - SELECT * FROM block_images WHERE block_id = ? ORDER BY id + SELECT id, image_data, image_format, alt_text, position, image_path + FROM block_images WHERE block_id = ? ORDER BY id ''', (block['id'],)) images = cursor.fetchall() @@ -158,23 +173,33 @@ def get_project(project_id): except (json.JSONDecodeError, TypeError): transcription = [] + images_data = [] + for img in images: + # path থাকলে ফাইল থেকে, নইলে পুরোনো base64 + img_data = '' + if img['image_path']: + img_data = read_file_base64(img['image_path']) + elif img['image_data']: + img_data = clean_str(img['image_data']) + images_data.append({ + 'id': img['id'], + 'data': img_data, + 'format': clean_str(img['image_format']) or 'png', + 'alt_text': clean_str(img['alt_text']), + 'position': clean_str(img['position']) or 'before' + }) + blocks_data.append({ 'id': block['id'], 'block_order': block['block_order'], 'block_type': clean_str(block['block_type']), 'content': clean_str(block['content']), 'tts_text': clean_str(block['tts_text']), - 'audio_data': '', # Empty here; loaded lazily by frontend + 'audio_data': '', 'audio_format': clean_str(block['audio_format']) or 'mp3', 'has_audio': bool(block['has_audio']), 'transcription': transcription, - 'images': [{ - 'id': img['id'], - 'data': clean_str(img['image_data']), - 'format': clean_str(img['image_format']) or 'png', - 'alt_text': clean_str(img['alt_text']), - 'position': clean_str(img['position']) or 'before' - } for img in images] + 'images': images_data }) chapters_data.append({ @@ -197,15 +222,12 @@ def get_project(project_id): @project_bp.route('/api/projects//audio/', methods=['GET']) @login_required def get_block_audio(project_id, block_id): - """ - Return audio_data (base64) for a single block. - Used by the frontend to lazy-load audio after metadata is loaded. - """ + """Stream audio for a single block (v4.3: from file, with base64 fallback).""" db = get_db() cursor = db.cursor() cursor.execute(''' - SELECT mb.audio_data, mb.audio_format + SELECT mb.audio_data, mb.audio_path, mb.audio_format FROM markdown_blocks mb JOIN chapters c ON mb.chapter_id = c.id WHERE mb.id = ? AND c.project_id = ? @@ -215,13 +237,21 @@ def get_block_audio(project_id, block_id): if not row: return jsonify({'error': 'Block not found'}), 404 - if not row['audio_data']: - return jsonify({'audio_data': '', 'audio_format': row['audio_format'] or 'mp3'}) + # নতুন: ফাইল থেকে সরাসরি stream (Range request সাপোর্ট সহ) + if row['audio_path']: + abs_path = get_safe_abs_path(row['audio_path']) + if abs_path and os.path.exists(abs_path): + return send_file(abs_path, mimetype=f"audio/{row['audio_format'] or 'mp3'}", + conditional=True) - return jsonify({ - 'audio_data': clean_str(row['audio_data']), - 'audio_format': clean_str(row['audio_format']) or 'mp3' - }) + # পুরোনো: base64 JSON + if row['audio_data']: + return jsonify({ + 'audio_data': clean_str(row['audio_data']), + 'audio_format': clean_str(row['audio_format']) or 'mp3' + }) + + return jsonify({'audio_data': '', 'audio_format': row['audio_format'] or 'mp3'}) @project_bp.route('/api/projects/', methods=['PUT']) @@ -256,7 +286,7 @@ def update_project(project_id): @project_bp.route('/api/projects/', methods=['DELETE']) @login_required def delete_project(project_id): - """Delete a project and all its data.""" + """Delete a project, all DB data, AND its media folder (v4.3, no auto-vacuum).""" db = get_db() cursor = db.cursor() @@ -282,7 +312,11 @@ def delete_project(project_id): cursor.execute('DELETE FROM projects WHERE id = ?', (project_id,)) db.commit() - vacuum_db() + + # v4.3: প্রজেক্টের সব মিডিয়া ফাইল মুছি + delete_project_media(project_id) + + # NOTE: vacuum আর অটোমেটিক চলে না — ইউজার সেটিংস থেকে ম্যানুয়ালি করবে return jsonify({'success': True}) @@ -290,7 +324,7 @@ def delete_project(project_id): @project_bp.route('/api/projects//save', methods=['POST']) @login_required def save_project_content(project_id): - """Save all chapters and blocks for a project.""" + """Save all chapters and blocks. Audio/images stored as FILES (v4.3).""" data = request.json chapters = data.get('chapters', []) @@ -301,6 +335,7 @@ def save_project_content(project_id): if not cursor.fetchone(): return jsonify({'error': 'Project not found'}), 404 + # পুরোনো DB রেকর্ড মুছি (ফাইলগুলো নতুন করে লেখা হবে) cursor.execute(''' DELETE FROM block_images WHERE block_id IN ( SELECT mb.id FROM markdown_blocks mb @@ -332,35 +367,58 @@ def save_project_content(project_id): for block in chapter.get('blocks', []): transcription = clean_transcription(block.get('transcription', [])) + audio_format = clean_str(block.get('audio_format', 'mp3')) or 'mp3' cursor.execute(''' INSERT INTO markdown_blocks - (chapter_id, block_order, block_type, content, tts_text, audio_data, audio_format, transcription) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (chapter_id, block_order, block_type, content, tts_text, + audio_data, audio_path, audio_format, transcription) + VALUES (?, ?, ?, ?, ?, '', NULL, ?, ?) ''', ( chapter_id, block['block_order'], clean_str(block.get('block_type', 'paragraph')), clean_str(block.get('content', '')), clean_str(block.get('tts_text', '')), - clean_str(block.get('audio_data', '')), - clean_str(block.get('audio_format', 'mp3')), + audio_format, json.dumps(transcription) )) block_id = cursor.lastrowid + # অডিও ফাইলে সেভ করি + audio_b64 = block.get('audio_data', '') + if audio_b64: + rel_path = save_audio(project_id, block_id, audio_b64, audio_format) + if rel_path: + cursor.execute( + 'UPDATE markdown_blocks SET audio_path = ? WHERE id = ?', + (rel_path, block_id) + ) + + # ইমেজগুলো ফাইলে সেভ করি for img in block.get('images', []): + img_format = clean_str(img.get('format', 'png')) or 'png' cursor.execute(''' - INSERT INTO block_images (block_id, image_data, image_format, alt_text, position) - VALUES (?, ?, ?, ?, ?) + INSERT INTO block_images + (block_id, image_data, image_path, image_format, alt_text, position) + VALUES (?, '', NULL, ?, ?, ?) ''', ( block_id, - clean_str(img.get('data', '')), - clean_str(img.get('format', 'png')), + img_format, clean_str(img.get('alt_text', '')), clean_str(img.get('position', 'before')) )) + image_id = cursor.lastrowid + + img_b64 = img.get('data', '') + if img_b64: + img_rel = save_image(project_id, image_id, img_b64, img_format) + if img_rel: + cursor.execute( + 'UPDATE block_images SET image_path = ? WHERE id = ?', + (img_rel, image_id) + ) cursor.execute(''' UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ? @@ -392,7 +450,9 @@ def publish_project(project_id): cursor.execute(''' SELECT COUNT(*) as cnt FROM markdown_blocks mb JOIN chapters c ON mb.chapter_id = c.id - WHERE c.project_id = ? AND mb.audio_data IS NOT NULL AND mb.audio_data != '' + WHERE c.project_id = ? + AND ((mb.audio_data IS NOT NULL AND mb.audio_data != '') + OR (mb.audio_path IS NOT NULL AND mb.audio_path != '')) ''', (project_id,)) audio_count = cursor.fetchone()['cnt'] @@ -440,7 +500,7 @@ def unpublish_project(project_id): @project_bp.route('/api/projects//thumbnail', methods=['POST']) @login_required def upload_thumbnail(project_id): - """Upload a thumbnail image.""" + """Upload a thumbnail image (v4.3: stored as file).""" if 'file' not in request.files: return jsonify({'error': 'No file provided'}), 400 @@ -460,19 +520,21 @@ def upload_thumbnail(project_id): if fmt == 'jpg': fmt = 'jpeg' - b64 = base64.b64encode(img_bytes).decode('utf-8') - db = get_db() cursor = db.cursor() cursor.execute('SELECT id FROM projects WHERE id = ?', (project_id,)) if not cursor.fetchone(): return jsonify({'error': 'Project not found'}), 404 + rel_path = save_thumbnail(project_id, img_bytes, fmt) + cursor.execute(''' - UPDATE projects SET thumbnail_data = ?, thumbnail_format = ? WHERE id = ? - ''', (b64, fmt, project_id)) + UPDATE projects SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ? + WHERE id = ? + ''', (rel_path, fmt, project_id)) db.commit() + b64 = read_file_base64(rel_path) return jsonify({ 'success': True, 'thumbnail_data': b64, @@ -483,9 +545,51 @@ def upload_thumbnail(project_id): @project_bp.route('/api/projects//thumbnail', methods=['DELETE']) @login_required def delete_thumbnail(project_id): - """Remove project thumbnail.""" + """Remove project thumbnail (DB + file).""" db = get_db() cursor = db.cursor() - cursor.execute('UPDATE projects SET thumbnail_data = NULL WHERE id = ?', (project_id,)) + cursor.execute('SELECT thumbnail_path FROM projects WHERE id = ?', (project_id,)) + row = cursor.fetchone() + if row and row['thumbnail_path']: + from media_storage import delete_file + delete_file(row['thumbnail_path']) + cursor.execute('UPDATE projects SET thumbnail_data = NULL, thumbnail_path = NULL WHERE id = ?', + (project_id,)) db.commit() return jsonify({'success': True}) + + +# ============================================ +# v4.3: Database Maintenance (VACUUM + stats) +# ============================================ + +@project_bp.route('/api/maintenance/db-stats', methods=['GET']) +@login_required +def db_stats(): + """ডেটাবেস সাইজ, ফাঁকা স্পেস (%), এবং মিডিয়া স্টোরেজ সাইজ রিটার্ন করে।""" + stats = get_db_stats() + media_bytes = get_storage_usage_bytes() + stats['media_size_bytes'] = media_bytes + stats['media_size_mb'] = round(media_bytes / (1024 * 1024), 2) + return jsonify(stats) + + +@project_bp.route('/api/maintenance/vacuum', methods=['POST']) +@login_required +def run_vacuum(): + """ম্যানুয়ালি ডেটাবেস VACUUM চালায় (ফাঁকা স্পেস reclaim করে)।""" + before = get_db_stats() + try: + vacuum_db() + except Exception as e: + return jsonify({'error': f'VACUUM failed: {str(e)}'}), 500 + after = get_db_stats() + + reclaimed_mb = round(before['file_size_mb'] - after['file_size_mb'], 2) + return jsonify({ + 'success': True, + 'message': f'VACUUM complete. Reclaimed {reclaimed_mb} MB.', + 'before': before, + 'after': after, + 'reclaimed_mb': reclaimed_mb + }) diff --git a/routes/public_routes.py b/routes/public_routes.py index 481973c..2d96826 100644 --- a/routes/public_routes.py +++ b/routes/public_routes.py @@ -1,10 +1,12 @@ -# routes/public_routes.py - Public (No Auth) Routes for Published Audiobooks +# routes/public_routes.py - Public (No Auth) Routes for Published Audiobooks (v4.3) import re +import os import json -from flask import Blueprint, jsonify, send_from_directory, abort +from flask import Blueprint, jsonify, send_from_directory, send_file, abort from db import get_db +from media_storage import get_safe_abs_path, read_file_base64 public_bp = Blueprint('public', __name__) @@ -53,7 +55,6 @@ def public_reader(project_id): if not project or not project['is_published']: abort(404) - # Increment view count cursor.execute('UPDATE projects SET view_count = view_count + 1 WHERE id = ?', (project_id,)) db.commit() @@ -68,7 +69,7 @@ def list_published_books(): cursor.execute(''' SELECT p.id, p.name, p.description, p.author, p.category, - p.thumbnail_data, p.thumbnail_format, p.published_at, + p.thumbnail_data, p.thumbnail_format, p.thumbnail_path, p.published_at, p.view_count, p.created_at, (SELECT COUNT(*) FROM chapters WHERE project_id = p.id) as chapter_count FROM projects p @@ -78,13 +79,17 @@ def list_published_books(): books = [] for row in cursor.fetchall(): + thumb_data = row['thumbnail_data'] + if row['thumbnail_path']: + thumb_data = read_file_base64(row['thumbnail_path']) + books.append({ 'id': row['id'], 'name': row['name'], 'description': row['description'] or '', 'author': row['author'] or '', 'category': row['category'] or '', - 'thumbnail_data': row['thumbnail_data'], + 'thumbnail_data': thumb_data, 'thumbnail_format': row['thumbnail_format'] or 'png', 'published_at': row['published_at'], 'view_count': row['view_count'] or 0, @@ -96,11 +101,7 @@ def list_published_books(): @public_bp.route('/api/public/books/', methods=['GET']) def get_published_book(project_id): - """ - Get book metadata WITHOUT audio_data. - Audio is loaded lazily via /api/public/books//audio/. - This keeps the response small (<1 MB) and avoids proxy truncation issues. - """ + """Get book metadata WITHOUT audio_data (lazy-loaded separately).""" db = get_db() cursor = db.cursor() @@ -120,8 +121,9 @@ def get_published_book(project_id): chapters_data = [] for chapter in chapters: cursor.execute(''' - SELECT id, block_order, block_type, content, audio_format, transcription, - (audio_data IS NOT NULL AND audio_data != '') as has_audio + SELECT id, block_order, block_type, content, audio_format, audio_path, transcription, + ((audio_data IS NOT NULL AND audio_data != '') + OR (audio_path IS NOT NULL AND audio_path != '')) as has_audio FROM markdown_blocks WHERE chapter_id = ? ORDER BY block_order ''', (chapter['id'],)) blocks = cursor.fetchall() @@ -129,7 +131,8 @@ def get_published_book(project_id): blocks_data = [] for block in blocks: cursor.execute(''' - SELECT * FROM block_images WHERE block_id = ? ORDER BY id + SELECT image_data, image_format, alt_text, position, image_path + FROM block_images WHERE block_id = ? ORDER BY id ''', (block['id'],)) images = cursor.fetchall() @@ -141,21 +144,30 @@ def get_published_book(project_id): except (json.JSONDecodeError, TypeError): transcription = [] + images_data = [] + for img in images: + img_data = '' + if img['image_path']: + img_data = read_file_base64(img['image_path']) + elif img['image_data']: + img_data = clean_str(img['image_data']) + images_data.append({ + 'data': img_data, + 'format': clean_str(img['image_format']) or 'png', + 'alt_text': clean_str(img['alt_text']), + 'position': clean_str(img['position']) or 'before' + }) + blocks_data.append({ 'id': block['id'], 'block_order': block['block_order'], 'block_type': clean_str(block['block_type']), 'content': clean_str(block['content']), - 'audio_data': '', # Empty here; loaded lazily by frontend + 'audio_data': '', 'audio_format': clean_str(block['audio_format']) or 'mp3', 'has_audio': bool(block['has_audio']), 'transcription': transcription, - 'images': [{ - 'data': clean_str(img['image_data']), - 'format': clean_str(img['image_format']) or 'png', - 'alt_text': clean_str(img['alt_text']), - 'position': clean_str(img['position']) or 'before' - } for img in images] + 'images': images_data }) chapters_data.append({ @@ -165,12 +177,16 @@ def get_published_book(project_id): 'blocks': blocks_data }) + thumb_data = project['thumbnail_data'] + if project['thumbnail_path']: + thumb_data = read_file_base64(project['thumbnail_path']) + return jsonify({ 'id': project['id'], 'name': clean_str(project['name']), 'description': clean_str(project['description']) if project['description'] else '', 'author': clean_str(project['author']) if project['author'] else '', - 'thumbnail_data': project['thumbnail_data'], + 'thumbnail_data': thumb_data, 'thumbnail_format': project['thumbnail_format'] or 'png', 'chapters': chapters_data }) @@ -178,21 +194,17 @@ def get_published_book(project_id): @public_bp.route('/api/public/books//audio/', methods=['GET']) def get_public_block_audio(project_id, block_id): - """ - Return audio_data (base64) for a single block in a published book. - No auth required since the book is published publicly. - """ + """Stream audio file for a published book block (v4.3).""" db = get_db() cursor = db.cursor() - # Verify project is published cursor.execute('SELECT is_published FROM projects WHERE id = ?', (project_id,)) project = cursor.fetchone() if not project or not project['is_published']: return jsonify({'error': 'Book not found or not published'}), 404 cursor.execute(''' - SELECT mb.audio_data, mb.audio_format + SELECT mb.audio_data, mb.audio_path, mb.audio_format FROM markdown_blocks mb JOIN chapters c ON mb.chapter_id = c.id WHERE mb.id = ? AND c.project_id = ? @@ -202,10 +214,16 @@ def get_public_block_audio(project_id, block_id): if not row: return jsonify({'error': 'Block not found'}), 404 - if not row['audio_data']: - return jsonify({'audio_data': '', 'audio_format': row['audio_format'] or 'mp3'}) + if row['audio_path']: + abs_path = get_safe_abs_path(row['audio_path']) + if abs_path and os.path.exists(abs_path): + return send_file(abs_path, mimetype=f"audio/{row['audio_format'] or 'mp3'}", + conditional=True) - return jsonify({ - 'audio_data': clean_str(row['audio_data']), - 'audio_format': clean_str(row['audio_format']) or 'mp3' - }) + if row['audio_data']: + return jsonify({ + 'audio_data': clean_str(row['audio_data']), + 'audio_format': clean_str(row['audio_format']) or 'mp3' + }) + + return jsonify({'audio_data': '', 'audio_format': row['audio_format'] or 'mp3'}) diff --git a/static/js/app.js b/static/js/app.js index 6cbce75..5b13a55 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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 = `` + + `ডেটাবেসে ${s.free_percent}% ফাঁকা স্পেস জমেছে। ` + + `Run VACUUM চালিয়ে এটি reclaim করতে পারেন।`; + } else { + advice.className = 'alert alert-success'; + advice.style.display = 'block'; + advice.innerHTML = `` + + `ফাঁকা স্পেস কম (${s.free_percent}%) — এখন 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 = '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 = 'Run VACUUM'; + } + if (refreshBtn) refreshBtn.disabled = false; + } +} + // ============================================ // TTS Text Editing // ============================================ diff --git a/static/js/interactive-reader.js b/static/js/interactive-reader.js index 5416706..0849469 100644 --- a/static/js/interactive-reader.js +++ b/static/js/interactive-reader.js @@ -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//audio/ - * 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//audio/. + * 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) => { diff --git a/templates/index.html b/templates/index.html index df0fd55..6a0c36d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,7 +3,7 @@ - Audiobook Maker Pro v4.1 + Audiobook Maker Pro v4.3 @@ -163,7 +163,7 @@

Audiobook Maker Pro - v4.2 + v4.3

+ + + @@ -475,4 +543,4 @@ - \ No newline at end of file +