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

@@ -16,6 +16,11 @@ STATIC_URL_PATH = '/static'
# Dev-এ env না থাকলে app.py-র পাশে রাখবে # Dev-এ env না থাকলে app.py-র পাশে রাখবে
DATABASE = os.getenv('DATABASE', os.path.join(BASE_DIR, 'audiobook_maker.db')) 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 --- # --- FLASK SECRET KEY ---
SECRET_KEY = os.getenv('SECRET_KEY', 'audiobook-maker-pro-' + str(uuid.uuid4())) SECRET_KEY = os.getenv('SECRET_KEY', 'audiobook-maker-pro-' + str(uuid.uuid4()))

46
db.py
View File

@@ -1,4 +1,4 @@
# db.py - Database Configuration and Operations (v4.2) # db.py - Database Configuration and Operations (v4.3)
import os import os
import sqlite3 import sqlite3
@@ -32,13 +32,19 @@ def get_db_connection():
def init_db(): def init_db():
"""Initialize database tables. Auto-creates parent directory.""" """Initialize database tables. Auto-creates parent directory + media storage."""
# ফোল্ডার না থাকলে নিজে থেকেই তৈরি করবে (Coolify volume mount-এর জন্য জরুরি) # ফোল্ডার না থাকলে নিজে থেকেই তৈরি করবে (Coolify volume mount-এর জন্য জরুরি)
db_dir = os.path.dirname(os.path.abspath(DATABASE)) db_dir = os.path.dirname(os.path.abspath(DATABASE))
if db_dir and not os.path.exists(db_dir): if db_dir and not os.path.exists(db_dir):
os.makedirs(db_dir, exist_ok=True) os.makedirs(db_dir, exist_ok=True)
print(f"📂 Created data directory: {db_dir}") 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: with get_db_connection() as conn:
cursor = conn.cursor() 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 = [ migrations = [
('projects', 'is_published', 'INTEGER DEFAULT 0'), ('projects', 'is_published', 'INTEGER DEFAULT 0'),
('projects', 'published_at', 'TIMESTAMP'), ('projects', 'published_at', 'TIMESTAMP'),
@@ -125,6 +131,10 @@ def init_db():
('projects', 'author', 'TEXT DEFAULT ""'), ('projects', 'author', 'TEXT DEFAULT ""'),
('projects', 'category', 'TEXT DEFAULT ""'), ('projects', 'category', 'TEXT DEFAULT ""'),
('projects', 'view_count', 'INTEGER DEFAULT 0'), ('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: for table, column, definition in migrations:
@@ -135,14 +145,42 @@ def init_db():
pass pass
conn.commit() conn.commit()
print(f"✅ Database initialized at {DATABASE} (v4.2)") print(f"✅ Database initialized at {DATABASE} (v4.3)")
def vacuum_db(): def vacuum_db():
"""ম্যানুয়াল VACUUM (এখন আর অটোমেটিক চলে না)।"""
with get_db_connection() as conn: with get_db_connection() as conn:
conn.execute('VACUUM') 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): def init_app(app):
app.teardown_appcontext(close_db) app.teardown_appcontext(close_db)
init_db() init_db()

170
media_storage.py Normal file
View File

@@ -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

96
migrate_to_files.py Normal file
View File

@@ -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()

View File

@@ -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 json
import time import time
@@ -40,8 +40,6 @@ def poll_beam_task(task_id):
print(f" URL: {task_url}") print(f" URL: {task_url}")
start_time = time.time() start_time = time.time()
# প্রথম কয়েকটা attempt এ 404 আসতে পারে — task register হতে delay
initial_delay = True initial_delay = True
while True: while True:
@@ -51,17 +49,14 @@ def poll_beam_task(task_id):
print(f"❌ Polling timeout after {POLL_MAX_WAIT}s") print(f"❌ Polling timeout after {POLL_MAX_WAIT}s")
return None, f'Task timed out after {int(POLL_MAX_WAIT)} seconds' return None, f'Task timed out after {int(POLL_MAX_WAIT)} seconds'
# প্রথম ২ সেকেন্ড wait করি task register হতে
if initial_delay and elapsed < 2: if initial_delay and elapsed < 2:
time.sleep(2) time.sleep(2)
initial_delay = False initial_delay = False
continue continue
try: try:
# ★ Bearer token দিয়ে try
resp = requests.get(task_url, headers=get_beam_auth_headers(), timeout=30) resp = requests.get(task_url, headers=get_beam_auth_headers(), timeout=30)
# Bearer fail হলে Basic try করি
if resp.status_code in (401, 403): if resp.status_code in (401, 403):
print(f" Bearer auth failed, trying Basic...") print(f" Bearer auth failed, trying Basic...")
basic_headers = { 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") print(f" [{int(elapsed)}s] HTTP {resp.status_code} | Body: {len(resp.text)} chars")
if resp.status_code == 404: if resp.status_code == 404:
# Task এখনও register হয়নি — wait
if elapsed < 30: if elapsed < 30:
print(f" Task not found yet, waiting...") print(f" Task not found yet, waiting...")
time.sleep(POLL_INTERVAL) time.sleep(POLL_INTERVAL)
continue continue
else: else:
# ৩০ সেকেন্ড পরেও 404 — সমস্যা
print(f"❌ Task not found after {int(elapsed)}s") print(f"❌ Task not found after {int(elapsed)}s")
# ★ Debug: response body দেখি
print(f" 404 body: {resp.text[:300]}") print(f" 404 body: {resp.text[:300]}")
# ★ Alternative: Beam API base URL ভিন্ন হতে পারে
# কিছু Beam setup এ URL format ভিন্ন
alt_urls = [ alt_urls = [
f"https://api.beam.cloud/v2/task/{task_id}/status/", f"https://api.beam.cloud/v2/task/{task_id}/status/",
f"https://api.beam.cloud/v2/task/{task_id}", 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'): if status in ('COMPLETE', 'COMPLETED', 'SUCCESS'):
print(f"✅ Task complete!") 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 actual_result = None
# Check 'output' (endpoint mode — function return value)
if data.get('output') and isinstance(data['output'], dict): if data.get('output') and isinstance(data['output'], dict):
actual_result = data['output'] actual_result = data['output']
print(f" Result found in 'output' key") print(f" Result found in 'output' key")
# Check 'result'
elif data.get('result') and isinstance(data['result'], dict): elif data.get('result') and isinstance(data['result'], dict):
actual_result = data['result'] actual_result = data['result']
print(f" Result found in 'result' key") print(f" Result found in 'result' key")
# Check if top-level has audio_base64 (unlikely but possible)
elif data.get('audio_base64'): elif data.get('audio_base64'):
actual_result = data actual_result = data
print(f" Result found in top-level 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'): elif actual_result and actual_result.get('success'):
return actual_result, None return actual_result, None
# ★ Outputs (file-based) — need to download
outputs = data.get('outputs', []) outputs = data.get('outputs', [])
if outputs: if outputs:
print(f" Task has {len(outputs)} output files") 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: for out in outputs:
print(f" Output: {out.get('name', '?')}{out.get('url', '?')}") print(f" Output: {out.get('name', '?')}{out.get('url', '?')}")
# No usable result found
print(f" ⚠️ Task complete but no audio in response") print(f" ⚠️ Task complete but no audio in response")
print(f" Response keys: {list(data.keys())}") print(f" Response keys: {list(data.keys())}")
print(f" Full response (first 500): {json.dumps(data, default=str)[:500]}") 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.' return None, f'Task {status.lower()} on Beam. Container may not have started in time.'
elif status in ('PENDING', 'RUNNING', 'RETRY'): elif status in ('PENDING', 'RUNNING', 'RETRY'):
pass # Keep polling pass
else: else:
print(f" Unknown status: {status}") 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', '') task_id = response.headers.get('X-Task-Id', '')
# ======================================== # CASE 1: Task ID + empty body → Async → Poll
# CASE 1: Task ID + empty/no body → Async → Poll
# ========================================
if task_id and (not response.text or not response.text.strip() or response.headers.get('Content-Length') == '0'): 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}") print(f"📋 Async mode — Task ID: {task_id}")
return poll_beam_task(task_id) return poll_beam_task(task_id)
# ========================================
# CASE 2: Task ID + body # CASE 2: Task ID + body
# ========================================
if task_id and response.text and response.text.strip(): if task_id and response.text and response.text.strip():
print(f"📋 Task ID: {task_id} + body ({len(response.text)} chars)") print(f"📋 Task ID: {task_id} + body ({len(response.text)} chars)")
try: 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'): if result.get('success') and result.get('audio_base64'):
print(f"✅ Direct sync result") print(f"✅ Direct sync result")
return _extract(result), None return _extract(result), None
# Body isn't the final result — poll
return poll_beam_task(task_id) return poll_beam_task(task_id)
except Exception: except Exception:
return poll_beam_task(task_id) return poll_beam_task(task_id)
# ========================================
# CASE 3: No task_id + empty body → Error # CASE 3: No task_id + empty body → Error
# ========================================
if not response.text or not response.text.strip(): if not response.text or not response.text.strip():
return None, 'Empty response from Beam with no task ID' return None, 'Empty response from Beam with no task ID'
# ========================================
# CASE 4: Synchronous response # CASE 4: Synchronous response
# ========================================
if response.status_code != 200: if response.status_code != 200:
try: try:
err = response.json().get('error', response.text[:200]) err = response.json().get('error', response.text[:200])
@@ -326,15 +291,26 @@ def generate_audio():
if source_format != 'mp3': if source_format != 'mp3':
audio_base64 = convert_to_mp3(audio_base64, source_format) audio_base64 = convert_to_mp3(audio_base64, source_format)
# block_id থাকলে সরাসরি ফাইলে সেভ করি (v4.3)
if block_id: if block_id:
from media_storage import save_audio
db = get_db() db = get_db()
cursor = db.cursor() cursor = db.cursor()
cursor.execute(''' cursor.execute('''
UPDATE markdown_blocks SELECT c.project_id FROM markdown_blocks mb
SET audio_data = ?, audio_format = 'mp3', transcription = ? JOIN chapters c ON mb.chapter_id = c.id
WHERE id = ? WHERE mb.id = ?
''', (audio_base64, json.dumps(transcription), block_id)) ''', (block_id,))
db.commit() 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"✅ DONE: audio={len(audio_base64)} bytes, words={len(transcription)}")
print(f"{'='*60}") print(f"{'='*60}")
@@ -377,7 +353,7 @@ def generate_chapter_audio():
cursor = db.cursor() cursor = db.cursor()
cursor.execute(''' 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 WHERE chapter_id = ? ORDER BY block_order
''', (chapter_id,)) ''', (chapter_id,))
blocks = cursor.fetchall() blocks = cursor.fetchall()
@@ -385,6 +361,11 @@ def generate_chapter_audio():
if not blocks: if not blocks:
return jsonify({'error': 'No blocks found'}), 404 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 = [] results = []
success_count = 0 success_count = 0
error_count = 0 error_count = 0
@@ -394,6 +375,8 @@ def generate_chapter_audio():
print(f"📖 CHAPTER: {total} blocks, voice={voice}") print(f"📖 CHAPTER: {total} blocks, voice={voice}")
print(f"{'='*60}") print(f"{'='*60}")
from media_storage import save_audio
for idx, block in enumerate(blocks): for idx, block in enumerate(blocks):
block_id = block['id'] block_id = block['id']
block_type = block['block_type'] if 'block_type' in block.keys() else 'paragraph' 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': if source_format != 'mp3':
audio_base64 = convert_to_mp3(audio_base64, source_format) 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(''' cursor.execute('''
UPDATE markdown_blocks UPDATE markdown_blocks
SET audio_data = ?, audio_format = 'mp3', transcription = ? SET audio_path = ?, audio_data = '', audio_format = 'mp3', transcription = ?
WHERE id = ? WHERE id = ?
''', (audio_base64, json.dumps(transcription), block_id)) ''', (rel_path, json.dumps(transcription), block_id))
results.append({ results.append({
'block_id': block_id, 'block_id': block_id,

View File

@@ -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 re
import os
import json import json
import base64 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 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__) project_bp = Blueprint('project', __name__)
@@ -48,6 +54,7 @@ def list_projects():
cursor.execute(''' cursor.execute('''
SELECT p.id, p.name, p.created_at, p.updated_at, SELECT p.id, p.name, p.created_at, p.updated_at,
p.is_published, p.published_at, p.thumbnail_data, p.thumbnail_format, p.is_published, p.published_at, p.thumbnail_data, p.thumbnail_format,
p.thumbnail_path,
p.description, p.author, p.category, p.view_count, p.description, p.author, p.category, p.view_count,
(SELECT COUNT(*) FROM chapters WHERE project_id = p.id) as chapter_count, (SELECT COUNT(*) FROM chapters WHERE project_id = p.id) as chapter_count,
(SELECT COUNT(*) FROM markdown_blocks mb (SELECT COUNT(*) FROM markdown_blocks mb
@@ -55,13 +62,20 @@ def list_projects():
WHERE c.project_id = p.id) as block_count, WHERE c.project_id = p.id) as block_count,
(SELECT COUNT(*) FROM markdown_blocks mb (SELECT COUNT(*) FROM markdown_blocks mb
JOIN chapters c ON mb.chapter_id = c.id 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 FROM projects p
ORDER BY p.updated_at DESC ORDER BY p.updated_at DESC
''') ''')
projects = [] projects = []
for row in cursor.fetchall(): 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({ projects.append({
'id': row['id'], 'id': row['id'],
'name': row['name'], 'name': row['name'],
@@ -72,7 +86,7 @@ def list_projects():
'audio_count': row['audio_count'], 'audio_count': row['audio_count'],
'is_published': bool(row['is_published']), 'is_published': bool(row['is_published']),
'published_at': row['published_at'], 'published_at': row['published_at'],
'thumbnail_data': row['thumbnail_data'], 'thumbnail_data': thumb_data,
'thumbnail_format': row['thumbnail_format'] or 'png', 'thumbnail_format': row['thumbnail_format'] or 'png',
'description': row['description'] or '', 'description': row['description'] or '',
'author': row['author'] or '', 'author': row['author'] or '',
@@ -115,9 +129,8 @@ def create_project():
@login_required @login_required
def get_project(project_id): def get_project(project_id):
""" """
Get project metadata WITHOUT audio_data. Get project metadata WITHOUT audio_data (lazy-loaded separately).
Audio is loaded lazily via /api/projects/<id>/audio/<block_id>. Images served as base64 from files (editor compatibility).
This keeps the response small (<1 MB) and avoids proxy truncation issues.
""" """
db = get_db() db = get_db()
cursor = db.cursor() cursor = db.cursor()
@@ -137,8 +150,9 @@ def get_project(project_id):
for chapter in chapters: for chapter in chapters:
cursor.execute(''' cursor.execute('''
SELECT id, block_order, block_type, content, tts_text, SELECT id, block_order, block_type, content, tts_text,
audio_format, transcription, audio_format, audio_path, transcription,
(audio_data IS NOT NULL AND audio_data != '') as has_audio ((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 FROM markdown_blocks WHERE chapter_id = ? ORDER BY block_order
''', (chapter['id'],)) ''', (chapter['id'],))
blocks = cursor.fetchall() blocks = cursor.fetchall()
@@ -146,7 +160,8 @@ def get_project(project_id):
blocks_data = [] blocks_data = []
for block in blocks: for block in blocks:
cursor.execute(''' 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'],)) ''', (block['id'],))
images = cursor.fetchall() images = cursor.fetchall()
@@ -158,23 +173,33 @@ def get_project(project_id):
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
transcription = [] 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({ blocks_data.append({
'id': block['id'], 'id': block['id'],
'block_order': block['block_order'], 'block_order': block['block_order'],
'block_type': clean_str(block['block_type']), 'block_type': clean_str(block['block_type']),
'content': clean_str(block['content']), 'content': clean_str(block['content']),
'tts_text': clean_str(block['tts_text']), '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', 'audio_format': clean_str(block['audio_format']) or 'mp3',
'has_audio': bool(block['has_audio']), 'has_audio': bool(block['has_audio']),
'transcription': transcription, 'transcription': transcription,
'images': [{ 'images': images_data
'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]
}) })
chapters_data.append({ chapters_data.append({
@@ -197,15 +222,12 @@ def get_project(project_id):
@project_bp.route('/api/projects/<int:project_id>/audio/<int:block_id>', methods=['GET']) @project_bp.route('/api/projects/<int:project_id>/audio/<int:block_id>', methods=['GET'])
@login_required @login_required
def get_block_audio(project_id, block_id): def get_block_audio(project_id, block_id):
""" """Stream audio for a single block (v4.3: from file, with base64 fallback)."""
Return audio_data (base64) for a single block.
Used by the frontend to lazy-load audio after metadata is loaded.
"""
db = get_db() db = get_db()
cursor = db.cursor() cursor = db.cursor()
cursor.execute(''' cursor.execute('''
SELECT mb.audio_data, mb.audio_format SELECT mb.audio_data, mb.audio_path, mb.audio_format
FROM markdown_blocks mb FROM markdown_blocks mb
JOIN chapters c ON mb.chapter_id = c.id JOIN chapters c ON mb.chapter_id = c.id
WHERE mb.id = ? AND c.project_id = ? WHERE mb.id = ? AND c.project_id = ?
@@ -215,13 +237,21 @@ def get_block_audio(project_id, block_id):
if not row: if not row:
return jsonify({'error': 'Block not found'}), 404 return jsonify({'error': 'Block not found'}), 404
if not row['audio_data']: # নতুন: ফাইল থেকে সরাসরি stream (Range request সাপোর্ট সহ)
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({ # পুরোনো: base64 JSON
'audio_data': clean_str(row['audio_data']), if row['audio_data']:
'audio_format': clean_str(row['audio_format']) or 'mp3' 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/<int:project_id>', methods=['PUT']) @project_bp.route('/api/projects/<int:project_id>', methods=['PUT'])
@@ -256,7 +286,7 @@ def update_project(project_id):
@project_bp.route('/api/projects/<int:project_id>', methods=['DELETE']) @project_bp.route('/api/projects/<int:project_id>', methods=['DELETE'])
@login_required @login_required
def delete_project(project_id): 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() db = get_db()
cursor = db.cursor() cursor = db.cursor()
@@ -282,7 +312,11 @@ def delete_project(project_id):
cursor.execute('DELETE FROM projects WHERE id = ?', (project_id,)) cursor.execute('DELETE FROM projects WHERE id = ?', (project_id,))
db.commit() db.commit()
vacuum_db()
# v4.3: প্রজেক্টের সব মিডিয়া ফাইল মুছি
delete_project_media(project_id)
# NOTE: vacuum আর অটোমেটিক চলে না — ইউজার সেটিংস থেকে ম্যানুয়ালি করবে
return jsonify({'success': True}) return jsonify({'success': True})
@@ -290,7 +324,7 @@ def delete_project(project_id):
@project_bp.route('/api/projects/<int:project_id>/save', methods=['POST']) @project_bp.route('/api/projects/<int:project_id>/save', methods=['POST'])
@login_required @login_required
def save_project_content(project_id): 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 data = request.json
chapters = data.get('chapters', []) chapters = data.get('chapters', [])
@@ -301,6 +335,7 @@ def save_project_content(project_id):
if not cursor.fetchone(): if not cursor.fetchone():
return jsonify({'error': 'Project not found'}), 404 return jsonify({'error': 'Project not found'}), 404
# পুরোনো DB রেকর্ড মুছি (ফাইলগুলো নতুন করে লেখা হবে)
cursor.execute(''' cursor.execute('''
DELETE FROM block_images WHERE block_id IN ( DELETE FROM block_images WHERE block_id IN (
SELECT mb.id FROM markdown_blocks mb SELECT mb.id FROM markdown_blocks mb
@@ -332,35 +367,58 @@ def save_project_content(project_id):
for block in chapter.get('blocks', []): for block in chapter.get('blocks', []):
transcription = clean_transcription(block.get('transcription', [])) transcription = clean_transcription(block.get('transcription', []))
audio_format = clean_str(block.get('audio_format', 'mp3')) or 'mp3'
cursor.execute(''' cursor.execute('''
INSERT INTO markdown_blocks INSERT INTO markdown_blocks
(chapter_id, block_order, block_type, content, tts_text, audio_data, audio_format, transcription) (chapter_id, block_order, block_type, content, tts_text,
VALUES (?, ?, ?, ?, ?, ?, ?, ?) audio_data, audio_path, audio_format, transcription)
VALUES (?, ?, ?, ?, ?, '', NULL, ?, ?)
''', ( ''', (
chapter_id, chapter_id,
block['block_order'], block['block_order'],
clean_str(block.get('block_type', 'paragraph')), clean_str(block.get('block_type', 'paragraph')),
clean_str(block.get('content', '')), clean_str(block.get('content', '')),
clean_str(block.get('tts_text', '')), clean_str(block.get('tts_text', '')),
clean_str(block.get('audio_data', '')), audio_format,
clean_str(block.get('audio_format', 'mp3')),
json.dumps(transcription) json.dumps(transcription)
)) ))
block_id = cursor.lastrowid 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', []): for img in block.get('images', []):
img_format = clean_str(img.get('format', 'png')) or 'png'
cursor.execute(''' cursor.execute('''
INSERT INTO block_images (block_id, image_data, image_format, alt_text, position) INSERT INTO block_images
VALUES (?, ?, ?, ?, ?) (block_id, image_data, image_path, image_format, alt_text, position)
VALUES (?, '', NULL, ?, ?, ?)
''', ( ''', (
block_id, block_id,
clean_str(img.get('data', '')), img_format,
clean_str(img.get('format', 'png')),
clean_str(img.get('alt_text', '')), clean_str(img.get('alt_text', '')),
clean_str(img.get('position', 'before')) 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(''' cursor.execute('''
UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ? UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?
@@ -392,7 +450,9 @@ def publish_project(project_id):
cursor.execute(''' cursor.execute('''
SELECT COUNT(*) as cnt FROM markdown_blocks mb SELECT COUNT(*) as cnt FROM markdown_blocks mb
JOIN chapters c ON mb.chapter_id = c.id 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,)) ''', (project_id,))
audio_count = cursor.fetchone()['cnt'] audio_count = cursor.fetchone()['cnt']
@@ -440,7 +500,7 @@ def unpublish_project(project_id):
@project_bp.route('/api/projects/<int:project_id>/thumbnail', methods=['POST']) @project_bp.route('/api/projects/<int:project_id>/thumbnail', methods=['POST'])
@login_required @login_required
def upload_thumbnail(project_id): def upload_thumbnail(project_id):
"""Upload a thumbnail image.""" """Upload a thumbnail image (v4.3: stored as file)."""
if 'file' not in request.files: if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400 return jsonify({'error': 'No file provided'}), 400
@@ -460,19 +520,21 @@ def upload_thumbnail(project_id):
if fmt == 'jpg': if fmt == 'jpg':
fmt = 'jpeg' fmt = 'jpeg'
b64 = base64.b64encode(img_bytes).decode('utf-8')
db = get_db() db = get_db()
cursor = db.cursor() cursor = db.cursor()
cursor.execute('SELECT id FROM projects WHERE id = ?', (project_id,)) cursor.execute('SELECT id FROM projects WHERE id = ?', (project_id,))
if not cursor.fetchone(): if not cursor.fetchone():
return jsonify({'error': 'Project not found'}), 404 return jsonify({'error': 'Project not found'}), 404
rel_path = save_thumbnail(project_id, img_bytes, fmt)
cursor.execute(''' cursor.execute('''
UPDATE projects SET thumbnail_data = ?, thumbnail_format = ? WHERE id = ? UPDATE projects SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?
''', (b64, fmt, project_id)) WHERE id = ?
''', (rel_path, fmt, project_id))
db.commit() db.commit()
b64 = read_file_base64(rel_path)
return jsonify({ return jsonify({
'success': True, 'success': True,
'thumbnail_data': b64, 'thumbnail_data': b64,
@@ -483,9 +545,51 @@ def upload_thumbnail(project_id):
@project_bp.route('/api/projects/<int:project_id>/thumbnail', methods=['DELETE']) @project_bp.route('/api/projects/<int:project_id>/thumbnail', methods=['DELETE'])
@login_required @login_required
def delete_thumbnail(project_id): def delete_thumbnail(project_id):
"""Remove project thumbnail.""" """Remove project thumbnail (DB + file)."""
db = get_db() db = get_db()
cursor = db.cursor() 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() db.commit()
return jsonify({'success': True}) 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
})

View File

@@ -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 re
import os
import json 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 db import get_db
from media_storage import get_safe_abs_path, read_file_base64
public_bp = Blueprint('public', __name__) public_bp = Blueprint('public', __name__)
@@ -53,7 +55,6 @@ def public_reader(project_id):
if not project or not project['is_published']: if not project or not project['is_published']:
abort(404) abort(404)
# Increment view count
cursor.execute('UPDATE projects SET view_count = view_count + 1 WHERE id = ?', (project_id,)) cursor.execute('UPDATE projects SET view_count = view_count + 1 WHERE id = ?', (project_id,))
db.commit() db.commit()
@@ -68,7 +69,7 @@ def list_published_books():
cursor.execute(''' cursor.execute('''
SELECT p.id, p.name, p.description, p.author, p.category, 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, p.view_count, p.created_at,
(SELECT COUNT(*) FROM chapters WHERE project_id = p.id) as chapter_count (SELECT COUNT(*) FROM chapters WHERE project_id = p.id) as chapter_count
FROM projects p FROM projects p
@@ -78,13 +79,17 @@ def list_published_books():
books = [] books = []
for row in cursor.fetchall(): for row in cursor.fetchall():
thumb_data = row['thumbnail_data']
if row['thumbnail_path']:
thumb_data = read_file_base64(row['thumbnail_path'])
books.append({ books.append({
'id': row['id'], 'id': row['id'],
'name': row['name'], 'name': row['name'],
'description': row['description'] or '', 'description': row['description'] or '',
'author': row['author'] or '', 'author': row['author'] or '',
'category': row['category'] or '', 'category': row['category'] or '',
'thumbnail_data': row['thumbnail_data'], 'thumbnail_data': thumb_data,
'thumbnail_format': row['thumbnail_format'] or 'png', 'thumbnail_format': row['thumbnail_format'] or 'png',
'published_at': row['published_at'], 'published_at': row['published_at'],
'view_count': row['view_count'] or 0, 'view_count': row['view_count'] or 0,
@@ -96,11 +101,7 @@ def list_published_books():
@public_bp.route('/api/public/books/<int:project_id>', methods=['GET']) @public_bp.route('/api/public/books/<int:project_id>', methods=['GET'])
def get_published_book(project_id): def get_published_book(project_id):
""" """Get book metadata WITHOUT audio_data (lazy-loaded separately)."""
Get book metadata WITHOUT audio_data.
Audio is loaded lazily via /api/public/books/<id>/audio/<block_id>.
This keeps the response small (<1 MB) and avoids proxy truncation issues.
"""
db = get_db() db = get_db()
cursor = db.cursor() cursor = db.cursor()
@@ -120,8 +121,9 @@ def get_published_book(project_id):
chapters_data = [] chapters_data = []
for chapter in chapters: for chapter in chapters:
cursor.execute(''' cursor.execute('''
SELECT id, block_order, block_type, content, audio_format, transcription, SELECT id, block_order, block_type, content, audio_format, audio_path, transcription,
(audio_data IS NOT NULL AND audio_data != '') as has_audio ((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 FROM markdown_blocks WHERE chapter_id = ? ORDER BY block_order
''', (chapter['id'],)) ''', (chapter['id'],))
blocks = cursor.fetchall() blocks = cursor.fetchall()
@@ -129,7 +131,8 @@ def get_published_book(project_id):
blocks_data = [] blocks_data = []
for block in blocks: for block in blocks:
cursor.execute(''' 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'],)) ''', (block['id'],))
images = cursor.fetchall() images = cursor.fetchall()
@@ -141,21 +144,30 @@ def get_published_book(project_id):
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
transcription = [] 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({ blocks_data.append({
'id': block['id'], 'id': block['id'],
'block_order': block['block_order'], 'block_order': block['block_order'],
'block_type': clean_str(block['block_type']), 'block_type': clean_str(block['block_type']),
'content': clean_str(block['content']), 'content': clean_str(block['content']),
'audio_data': '', # Empty here; loaded lazily by frontend 'audio_data': '',
'audio_format': clean_str(block['audio_format']) or 'mp3', 'audio_format': clean_str(block['audio_format']) or 'mp3',
'has_audio': bool(block['has_audio']), 'has_audio': bool(block['has_audio']),
'transcription': transcription, 'transcription': transcription,
'images': [{ 'images': images_data
'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]
}) })
chapters_data.append({ chapters_data.append({
@@ -165,12 +177,16 @@ def get_published_book(project_id):
'blocks': blocks_data 'blocks': blocks_data
}) })
thumb_data = project['thumbnail_data']
if project['thumbnail_path']:
thumb_data = read_file_base64(project['thumbnail_path'])
return jsonify({ return jsonify({
'id': project['id'], 'id': project['id'],
'name': clean_str(project['name']), 'name': clean_str(project['name']),
'description': clean_str(project['description']) if project['description'] else '', 'description': clean_str(project['description']) if project['description'] else '',
'author': clean_str(project['author']) if project['author'] 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', 'thumbnail_format': project['thumbnail_format'] or 'png',
'chapters': chapters_data 'chapters': chapters_data
}) })
@@ -178,21 +194,17 @@ def get_published_book(project_id):
@public_bp.route('/api/public/books/<int:project_id>/audio/<int:block_id>', methods=['GET']) @public_bp.route('/api/public/books/<int:project_id>/audio/<int:block_id>', methods=['GET'])
def get_public_block_audio(project_id, block_id): def get_public_block_audio(project_id, block_id):
""" """Stream audio file for a published book block (v4.3)."""
Return audio_data (base64) for a single block in a published book.
No auth required since the book is published publicly.
"""
db = get_db() db = get_db()
cursor = db.cursor() cursor = db.cursor()
# Verify project is published
cursor.execute('SELECT is_published FROM projects WHERE id = ?', (project_id,)) cursor.execute('SELECT is_published FROM projects WHERE id = ?', (project_id,))
project = cursor.fetchone() project = cursor.fetchone()
if not project or not project['is_published']: if not project or not project['is_published']:
return jsonify({'error': 'Book not found or not published'}), 404 return jsonify({'error': 'Book not found or not published'}), 404
cursor.execute(''' cursor.execute('''
SELECT mb.audio_data, mb.audio_format SELECT mb.audio_data, mb.audio_path, mb.audio_format
FROM markdown_blocks mb FROM markdown_blocks mb
JOIN chapters c ON mb.chapter_id = c.id JOIN chapters c ON mb.chapter_id = c.id
WHERE mb.id = ? AND c.project_id = ? WHERE mb.id = ? AND c.project_id = ?
@@ -202,10 +214,16 @@ def get_public_block_audio(project_id, block_id):
if not row: if not row:
return jsonify({'error': 'Block not found'}), 404 return jsonify({'error': 'Block not found'}), 404
if not row['audio_data']: if row['audio_path']:
return jsonify({'audio_data': '', 'audio_format': row['audio_format'] or 'mp3'}) 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({ if row['audio_data']:
'audio_data': clean_str(row['audio_data']), return jsonify({
'audio_format': clean_str(row['audio_format']) or 'mp3' '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'})

View File

@@ -1,6 +1,6 @@
/** /**
* Audiobook Maker Pro v4.2 - Main Application * Audiobook Maker Pro v4.3 - Main Application
* UPDATED: Lazy audio loading to avoid large response truncation * UPDATED: Lazy audio loading + Storage & Maintenance (VACUUM)
*/ */
// ============================================ // ============================================
@@ -17,6 +17,7 @@ let voices = [];
let archiveModal = null; let archiveModal = null;
let ttsEditModal = null; let ttsEditModal = null;
let publishModal = null; let publishModal = null;
let dbMaintenanceModal = null;
let publishingProjectId = null; let publishingProjectId = null;
let currentWorkflowStage = 'upload'; let currentWorkflowStage = 'upload';
let allArchiveProjects = []; let allArchiveProjects = [];
@@ -26,7 +27,7 @@ let allArchiveProjects = [];
// ============================================ // ============================================
document.addEventListener('DOMContentLoaded', function() { 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')); archiveModal = new bootstrap.Modal(document.getElementById('archiveModal'));
ttsEditModal = new bootstrap.Modal(document.getElementById('ttsEditModal')); ttsEditModal = new bootstrap.Modal(document.getElementById('ttsEditModal'));
@@ -979,6 +980,32 @@ async function loadAudioBlocksInBackground(projectId, blockIds) {
async function fetchOne(blockId) { async function fetchOne(blockId) {
try { try {
const resp = await fetch(`/api/projects/${projectId}/audio/${blockId}`); 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(); const data = await resp.json();
if (data.error || !data.audio_data) { if (data.error || !data.audio_data) {
@@ -986,13 +1013,11 @@ async function loadAudioBlocksInBackground(projectId, blockIds) {
return; return;
} }
// Update editorBlocks state by db_id
const blockData = editorBlocks.find(b => b.db_id === blockId);
if (blockData) { if (blockData) {
blockData.audio_data = data.audio_data; blockData.audio_data = data.audio_data;
blockData.audio_format = data.audio_format; blockData.audio_format = data.audio_format;
blockData.has_audio = true;
// Update DOM indicator (green dot)
const blockEl = document.getElementById(blockData.id); const blockEl = document.getElementById(blockData.id);
if (blockEl) { if (blockEl) {
const indicator = blockEl.querySelector('.audio-indicator'); const indicator = blockEl.querySelector('.audio-indicator');
@@ -1017,8 +1042,8 @@ async function loadAudioBlocksInBackground(projectId, blockIds) {
} }
const msg = failed > 0 const msg = failed > 0
? `Loaded ${loaded}/${blockIds.length} audio blocks (${failed} failed)` ? `Verified ${loaded}/${blockIds.length} audio blocks (${failed} failed)`
: `All ${loaded} audio blocks loaded ✓`; : `All ${loaded} audio blocks verified ✓`;
showNotification(msg, failed > 0 ? 'warning' : 'success'); showNotification(msg, failed > 0 ? 'warning' : 'success');
if (typeof updatePanelUI === 'function') { if (typeof updatePanelUI === 'function') {
@@ -1028,7 +1053,7 @@ async function loadAudioBlocksInBackground(projectId, blockIds) {
async function deleteProject(projectId) { 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...'); 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 // 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: * Strategy:
* - Text + transcription are already loaded (from editorBlocks in memory). * - Text + transcription loaded from editorBlocks in memory.
* - Audio is fetched on-demand from /api/projects/<id>/audio/<block_id> * - Audio fetched on-demand from /api/projects/<id>/audio/<block_id>.
* when the user wants to play that block. * v4.3: endpoint may return binary audio (Content-Type: audio/*) OR
* - Smart preload: at 70% of current block, fetch next block's audio. * legacy base64 JSON. Both handled.
* - Memory cap: keep at most MAX_AUDIO_LOADED blob URLs alive. * - Smart preload + memory cap (sliding window of blob URLs).
*/ */
// ============================================ // ============================================
@@ -66,7 +66,6 @@ function renderInteractiveReader() {
isFirstBlockOfChapter = false; isFirstBlockOfChapter = false;
// has_audio comes from server; audio_data may not yet be loaded
if (!isImageBlock && blockData && (blockData.audio_data || blockData.has_audio)) { if (!isImageBlock && blockData && (blockData.audio_data || blockData.has_audio)) {
hasAudio = true; hasAudio = true;
} }
@@ -106,7 +105,6 @@ function renderInteractiveReader() {
const blockData = block._editorData; const blockData = block._editorData;
const isImageBlock = block._isImage; 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 hasBlockAudio = !isImageBlock && blockData && (blockData.audio_data || blockData.has_audio);
const blockId = blockData ? blockData.id : `reader_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; 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 * Fetch audio for an instance.
* by background loader, use that. Otherwise fetch from API directly. * v4.3: endpoint may return binary audio (Content-Type: audio/*)
* → return { audio_url } ; OR legacy base64 JSON → return { audio_data }.
*/ */
async function fetchAudioForInstance(inst) { 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) { if (inst.blockData && inst.blockData.audio_data) {
return { return {
audio_data: inst.blockData.audio_data, audio_data: inst.blockData.audio_data,
audio_format: inst.blockData.audio_format || 'mp3' audio_format: inst.blockData.audio_format || 'mp3'
}; };
} }
// Path 2: fetch from API // Path 2: fetch from API
if (!inst.blockData || !inst.blockData.db_id || !currentProject || !currentProject.id) { if (!inst.blockData || !inst.blockData.db_id || !currentProject || !currentProject.id) {
throw new Error('Cannot fetch audio: missing block info'); throw new Error('Cannot fetch audio: missing block info');
} }
const resp = await fetch(`/api/projects/${currentProject.id}/audio/${inst.blockData.db_id}`); 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(); const data = await resp.json();
if (data.error || !data.audio_data) { if (data.error || !data.audio_data) {
throw new Error(data.error || 'No 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_data = data.audio_data;
inst.blockData.audio_format = data.audio_format; inst.blockData.audio_format = data.audio_format;
return data; return data;
} }
@@ -488,8 +493,13 @@ function ensureReaderAudioLoaded(inst) {
inst.audioLoadingPromise = (async () => { inst.audioLoadingPromise = (async () => {
const audioInfo = await fetchAudioForInstance(inst); const audioInfo = await fetchAudioForInstance(inst);
const audioBlob = base64ToBlob(audioInfo.audio_data, `audio/${audioInfo.audio_format || 'mp3'}`); let audioUrl;
const audioUrl = URL.createObjectURL(audioBlob); 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); const audio = new Audio(audioUrl);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audiobook Maker Pro v4.1</title> <title>Audiobook Maker Pro v4.3</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
@@ -163,7 +163,7 @@
<h1 class="app-title"> <h1 class="app-title">
<i class="bi bi-soundwave"></i> <i class="bi bi-soundwave"></i>
Audiobook Maker Pro Audiobook Maker Pro
<span class="version-badge">v4.2</span> <span class="version-badge">v4.3</span>
</h1> </h1>
</div> </div>
<div class="header-right"> <div class="header-right">
@@ -197,6 +197,11 @@
</a> </a>
</li> </li>
<li id="adminDivider" style="display:none;"><hr class="dropdown-divider"></li> <li id="adminDivider" style="display:none;"><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="#" onclick="openDbMaintenance()">
<i class="bi bi-hdd-stack me-2"></i>Storage & Maintenance
</a>
</li>
<li> <li>
<a class="dropdown-item" href="#" onclick="openChangePassword()"> <a class="dropdown-item" href="#" onclick="openChangePassword()">
<i class="bi bi-key me-2"></i>Change Password <i class="bi bi-key me-2"></i>Change Password
@@ -467,6 +472,69 @@
</div> </div>
</div> </div>
<!-- v4.3: Storage & Maintenance Modal -->
<div class="modal fade" id="dbMaintenanceModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-hdd-stack me-2"></i>Storage & Maintenance
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="dbStatsLoading" class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 text-muted">Loading storage info...</p>
</div>
<div id="dbStatsContent" style="display:none;">
<div class="row g-3 mb-3">
<div class="col-6">
<div class="p-3 rounded" style="background: var(--bg-tertiary);">
<div class="text-muted small mb-1"><i class="bi bi-database me-1"></i>Database Size</div>
<div class="fs-5 fw-bold" id="dbmFileSize">— MB</div>
</div>
</div>
<div class="col-6">
<div class="p-3 rounded" style="background: var(--bg-tertiary);">
<div class="text-muted small mb-1"><i class="bi bi-folder me-1"></i>Media Files Size</div>
<div class="fs-5 fw-bold" id="dbmMediaSize">— MB</div>
</div>
</div>
</div>
<div class="mb-2 d-flex justify-content-between align-items-center">
<span class="small fw-semibold">
<i class="bi bi-trash3 me-1"></i>Reclaimable free space
</span>
<span class="small text-muted" id="dbmFreeText">— MB (—%)</span>
</div>
<div class="progress mb-3" style="height: 14px;">
<div class="progress-bar" id="dbmFreeBar" role="progressbar" style="width: 0%;"></div>
</div>
<div class="alert" id="dbmAdvice" style="display:none;"></div>
<p class="text-muted small mb-0">
<i class="bi bi-info-circle me-1"></i>
VACUUM ডেটাবেসের ফাঁকা স্পেস reclaim করে এটি ছোট করে। প্রজেক্ট ডিলিট করার পর বা মাসে একবার চালানো ভালো। এটি কিছু সময় নিতে পারে।
</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary" id="dbmRefreshBtn" onclick="loadDbStats()">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
<button type="button" class="btn btn-primary" id="dbmVacuumBtn" onclick="runDbVacuum()">
<i class="bi bi-stars me-1"></i>Run VACUUM
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
@@ -475,4 +543,4 @@
<script src="/static/js/generation.js"></script> <script src="/static/js/generation.js"></script>
<script src="/static/js/interactive-reader.js"></script> <script src="/static/js/interactive-reader.js"></script>
</body> </body>
</html> </html>