v4.3: file-based media storage + manual VACUUM maintenance
This commit is contained in:
@@ -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
46
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 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
170
media_storage.py
Normal 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
96
migrate_to_files.py
Normal 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()
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
|||||||
@@ -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'})
|
||||||
|
|||||||
148
static/js/app.js
148
static/js/app.js
@@ -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
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user