Audiobook Maker Pro v4.2 — production ready

This commit is contained in:
Ashim Kumar
2026-05-22 18:28:47 +06:00
commit 0617a374dd
41 changed files with 15262 additions and 0 deletions

27
routes/__init__.py Normal file
View File

@@ -0,0 +1,27 @@
# routes/__init__.py - Blueprint Registration (v4.2)
from flask import Flask
def register_blueprints(app: Flask):
from routes.auth_routes import auth_bp
from routes.admin_routes import admin_bp
from routes.main_routes import main_bp
from routes.pdf_routes import pdf_bp
from routes.docx_routes import docx_bp
from routes.project_routes import project_bp
from routes.generation_routes import generation_bp
from routes.export_routes import export_bp
from routes.public_routes import public_bp # NEW
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(main_bp)
app.register_blueprint(pdf_bp)
app.register_blueprint(docx_bp)
app.register_blueprint(project_bp)
app.register_blueprint(generation_bp)
app.register_blueprint(export_bp)
app.register_blueprint(public_bp) # NEW
print("✅ All blueprints registered (v4.2)")

175
routes/admin_routes.py Normal file
View File

@@ -0,0 +1,175 @@
# routes/admin_routes.py - Admin Dashboard Routes
from flask import Blueprint, request, jsonify, session, send_from_directory
from db import get_db
from auth import admin_required
admin_bp = Blueprint('admin', __name__)
@admin_bp.route('/admin')
@admin_required
def admin_page():
"""Serve admin dashboard page."""
return send_from_directory('templates', 'admin.html')
@admin_bp.route('/api/admin/users', methods=['GET'])
@admin_required
def list_users():
"""List all users."""
db = get_db()
cursor = db.cursor()
cursor.execute('''
SELECT id, username, role, is_active, created_at, last_login
FROM users ORDER BY created_at DESC
''')
users = []
for row in cursor.fetchall():
users.append({
'id': row['id'],
'username': row['username'],
'role': row['role'],
'is_active': bool(row['is_active']),
'created_at': row['created_at'],
'last_login': row['last_login']
})
return jsonify({'users': users})
@admin_bp.route('/api/admin/users', methods=['POST'])
@admin_required
def create_user():
"""Create a new user."""
data = request.json
username = data.get('username', '').strip()
password = data.get('password', '')
role = data.get('role', 'user')
if not username or not password:
return jsonify({'error': 'Username and password are required'}), 400
if len(username) < 3:
return jsonify({'error': 'Username must be at least 3 characters'}), 400
if len(password) < 4:
return jsonify({'error': 'Password must be at least 4 characters'}), 400
if role not in ('user', 'admin'):
return jsonify({'error': 'Role must be "user" or "admin"'}), 400
db = get_db()
cursor = db.cursor()
try:
cursor.execute('''
INSERT INTO users (username, password, role, is_active)
VALUES (?, ?, ?, 1)
''', (username, password, role))
db.commit()
print(f"✅ New user created: {username} (role: {role})")
return jsonify({
'success': True,
'user_id': cursor.lastrowid,
'message': f'User "{username}" created successfully'
})
except Exception as e:
if 'UNIQUE constraint' in str(e):
return jsonify({'error': f'Username "{username}" already exists'}), 400
return jsonify({'error': str(e)}), 500
@admin_bp.route('/api/admin/users/<int:user_id>', methods=['PUT'])
@admin_required
def update_user(user_id):
"""Update a user."""
data = request.json
db = get_db()
cursor = db.cursor()
cursor.execute('SELECT id, username FROM users WHERE id = ?', (user_id,))
user = cursor.fetchone()
if not user:
return jsonify({'error': 'User not found'}), 404
# Build update query dynamically
updates = []
params = []
if 'username' in data:
username = data['username'].strip()
if len(username) < 3:
return jsonify({'error': 'Username must be at least 3 characters'}), 400
updates.append('username = ?')
params.append(username)
if 'password' in data and data['password']:
password = data['password']
if len(password) < 4:
return jsonify({'error': 'Password must be at least 4 characters'}), 400
updates.append('password = ?')
params.append(password)
if 'role' in data:
role = data['role']
if role not in ('user', 'admin'):
return jsonify({'error': 'Role must be "user" or "admin"'}), 400
# Prevent demoting self
if user_id == session.get('user_id') and role != 'admin':
return jsonify({'error': 'Cannot change your own role'}), 400
updates.append('role = ?')
params.append(role)
if 'is_active' in data:
# Prevent deactivating self
if user_id == session.get('user_id') and not data['is_active']:
return jsonify({'error': 'Cannot deactivate your own account'}), 400
updates.append('is_active = ?')
params.append(1 if data['is_active'] else 0)
if not updates:
return jsonify({'error': 'No fields to update'}), 400
params.append(user_id)
try:
cursor.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", params)
db.commit()
return jsonify({'success': True, 'message': 'User updated successfully'})
except Exception as e:
if 'UNIQUE constraint' in str(e):
return jsonify({'error': 'Username already exists'}), 400
return jsonify({'error': str(e)}), 500
@admin_bp.route('/api/admin/users/<int:user_id>', methods=['DELETE'])
@admin_required
def delete_user(user_id):
"""Delete a user."""
# Prevent deleting self
if user_id == session.get('user_id'):
return jsonify({'error': 'Cannot delete your own account'}), 400
db = get_db()
cursor = db.cursor()
cursor.execute('SELECT id, username FROM users WHERE id = ?', (user_id,))
user = cursor.fetchone()
if not user:
return jsonify({'error': 'User not found'}), 404
cursor.execute('DELETE FROM users WHERE id = ?', (user_id,))
db.commit()
print(f"🗑️ User deleted: {user['username']}")
return jsonify({'success': True, 'message': f'User "{user["username"]}" deleted'})

113
routes/auth_routes.py Normal file
View File

@@ -0,0 +1,113 @@
# routes/auth_routes.py - Authentication Routes
from flask import Blueprint, request, jsonify, session, redirect, url_for, send_from_directory
from db import get_db
from auth import login_required, admin_required, get_current_user
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login')
def login_page():
"""Serve login page."""
if 'user_id' in session:
return redirect(url_for('main.index'))
return send_from_directory('templates', 'login.html')
@auth_bp.route('/api/auth/login', methods=['POST'])
def login():
"""Handle user login."""
data = request.json
username = data.get('username', '').strip()
password = data.get('password', '')
if not username or not password:
return jsonify({'error': 'Username and password are required'}), 400
db = get_db()
cursor = db.cursor()
cursor.execute('''
SELECT id, username, password, role, is_active
FROM users WHERE username = ?
''', (username,))
user = cursor.fetchone()
if not user:
return jsonify({'error': 'Invalid username or password'}), 401
if not user['is_active']:
return jsonify({'error': 'Account is disabled. Contact your administrator.'}), 403
if user['password'] != password:
return jsonify({'error': 'Invalid username or password'}), 401
# Set session
session['user_id'] = user['id']
session['username'] = user['username']
session['user_role'] = user['role']
# Update last login
cursor.execute('''
UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?
''', (user['id'],))
db.commit()
print(f"✅ User logged in: {username} (role: {user['role']})")
return jsonify({
'success': True,
'user': {
'id': user['id'],
'username': user['username'],
'role': user['role']
}
})
@auth_bp.route('/api/auth/logout', methods=['POST'])
def logout():
"""Handle user logout."""
username = session.get('username', 'Unknown')
session.clear()
print(f"👋 User logged out: {username}")
return jsonify({'success': True})
@auth_bp.route('/api/auth/me', methods=['GET'])
@login_required
def get_me():
"""Get current user info."""
user = get_current_user()
return jsonify({'user': user})
@auth_bp.route('/api/auth/change-password', methods=['POST'])
@login_required
def change_password():
"""Change current user's password."""
data = request.json
current_password = data.get('current_password', '')
new_password = data.get('new_password', '')
if not current_password or not new_password:
return jsonify({'error': 'Current password and new password are required'}), 400
if len(new_password) < 4:
return jsonify({'error': 'New password must be at least 4 characters'}), 400
db = get_db()
cursor = db.cursor()
cursor.execute('SELECT password FROM users WHERE id = ?', (session['user_id'],))
user = cursor.fetchone()
if not user or user['password'] != current_password:
return jsonify({'error': 'Current password is incorrect'}), 401
cursor.execute('UPDATE users SET password = ? WHERE id = ?', (new_password, session['user_id']))
db.commit()
return jsonify({'success': True, 'message': 'Password changed successfully'})

57
routes/docx_routes.py Normal file
View File

@@ -0,0 +1,57 @@
# routes/docx_routes.py - DOCX/DOC Upload and Processing Routes
import json
from flask import Blueprint, request, jsonify
from db import get_db
from docx_processor import process_docx_to_markdown
from ai_processor import process_document_smartly
from auth import login_required
docx_bp = Blueprint('docx', __name__)
@docx_bp.route('/api/upload-docx', methods=['POST'])
@login_required
def upload_docx():
"""Upload and process a DOCX or DOC file."""
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
doc_file = request.files['file']
if not doc_file or not doc_file.filename:
return jsonify({'error': 'Invalid file'}), 400
filename = doc_file.filename.lower()
if not (filename.endswith('.docx') or filename.endswith('.doc')):
return jsonify({'error': 'File must be a .docx or .doc file'}), 400
try:
print(f"📄 Processing Word document: {doc_file.filename}")
file_bytes = doc_file.read()
print(f" 📏 File size: {len(file_bytes)} bytes")
result = process_docx_to_markdown(file_bytes, doc_file.filename)
# --- AI Powered Smart Reconstruction & Section Tagging ---
smart_blocks = process_document_smartly(result.get('markdown_blocks', []), result.get('metadata', {}))
block_count = len(smart_blocks)
image_count = sum(1 for b in smart_blocks if b.get('type') == 'image')
text_count = block_count - image_count
print(f"✅ Word document processed & reconstructed: {block_count} blocks ({text_count} text, {image_count} images)")
return jsonify({
'success': True,
'filename': doc_file.filename,
'metadata': result.get('metadata', {}),
'blocks': smart_blocks
})
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500

185
routes/export_routes.py Normal file
View File

@@ -0,0 +1,185 @@
# routes/export_routes.py - Export Routes
import io
import os
import json
import base64
import zipfile
import re
from flask import Blueprint, request, jsonify, send_file
from db import get_db
from utils import sanitize_filename, strip_markdown
from auth import login_required
export_bp = Blueprint('export', __name__)
@export_bp.route('/api/export/<int:project_id>', methods=['GET'])
@login_required
def export_project(project_id):
"""Export project as ZIP file. Only includes chapters with audio."""
db = get_db()
cursor = db.cursor()
cursor.execute('SELECT * FROM projects WHERE id = ?', (project_id,))
project = cursor.fetchone()
if not project:
return jsonify({'error': 'Project not found'}), 404
project_name = sanitize_filename(project['name'])
cursor.execute('''
SELECT * FROM chapters WHERE project_id = ? ORDER BY chapter_number
''', (project_id,))
chapters = cursor.fetchall()
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
manifest = {
'title': project['name'],
'assets': [],
'images': []
}
for chapter in chapters:
section_id = chapter['chapter_number']
section_title = chapter['title']
cursor.execute('''
SELECT * FROM markdown_blocks WHERE chapter_id = ? ORDER BY block_order
''', (chapter['id'],))
blocks = cursor.fetchall()
chapter_has_audio = False
for block in blocks:
is_image_block = (
(block['content'] and block['content'].strip().startswith('![')) or
block['block_type'] == 'image'
)
if not is_image_block and block['audio_data']:
chapter_has_audio = True
break
if not chapter_has_audio:
continue
for block in blocks:
block_order = block['block_order']
prefix = f"{section_id}.{block_order}"
content = block['content']
is_image_block = (
(content and content.strip().startswith('![')) or
block['block_type'] == 'image'
)
cursor.execute('''
SELECT * FROM block_images WHERE block_id = ? ORDER BY id
''', (block['id'],))
images = cursor.fetchall()
image_idx = 0
for img in images:
if img['position'] == 'before':
image_filename = f"book/{prefix}_img{image_idx}.{img['image_format']}"
image_bytes = base64.b64decode(img['image_data'])
zf.writestr(image_filename, image_bytes)
manifest['images'].append({
'sortKey': prefix,
'file': image_filename
})
image_idx += 1
if is_image_block:
for img in images:
if img['position'] == 'after':
next_prefix = f"{section_id}.{block_order + 1}"
image_filename = f"book/{next_prefix}_img{image_idx}.{img['image_format']}"
image_bytes = base64.b64decode(img['image_data'])
zf.writestr(image_filename, image_bytes)
manifest['images'].append({
'sortKey': next_prefix,
'file': image_filename
})
image_idx += 1
continue
plain_text = strip_markdown(content)
if not plain_text.strip():
continue
if not block['audio_data']:
continue
text_filename = f"book/{prefix}_{project_name}.txt"
zf.writestr(text_filename, plain_text)
asset_entry = {
'prefix': f"{prefix}_",
'sortKey': prefix,
'sectionName': section_title,
'textFile': text_filename,
'audioFile': None,
'jsonFile': None
}
audio_filename = f"book/{prefix}_{project_name}.{block['audio_format'] or 'mp3'}"
audio_bytes = base64.b64decode(block['audio_data'])
zf.writestr(audio_filename, audio_bytes)
asset_entry['audioFile'] = audio_filename
if block['transcription']:
json_filename = f"book/{prefix}_{project_name}.json"
zf.writestr(json_filename, block['transcription'])
asset_entry['jsonFile'] = json_filename
manifest['assets'].append(asset_entry)
for img in images:
if img['position'] == 'after':
next_prefix = f"{section_id}.{block_order + 1}"
image_filename = f"book/{next_prefix}_img{image_idx}.{img['image_format']}"
image_bytes = base64.b64decode(img['image_data'])
zf.writestr(image_filename, image_bytes)
manifest['images'].append({
'sortKey': next_prefix,
'file': image_filename
})
image_idx += 1
# Write manifest.json to zip root
manifest_json_str = json.dumps(manifest, indent=2)
zf.writestr('manifest.json', manifest_json_str)
# --- DYNAMIC INJECTION FOR Reader.html & index.html ---
reader_templates_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'reader_templates'
)
index_path = os.path.join(reader_templates_dir, 'index.html')
if os.path.exists(index_path):
with open(index_path, 'r', encoding='utf-8') as f:
html_content = f.read()
# Inject manifest into index.html safely
html_content = html_content.replace('/*{{MANIFEST_DATA}}*/ null', manifest_json_str)
zf.writestr('index.html', html_content)
reader_path = os.path.join(reader_templates_dir, 'Reader.html')
if os.path.exists(reader_path):
with open(reader_path, 'r', encoding='utf-8') as f:
html_content = f.read()
# Inject manifest into Reader.html safely
html_content = html_content.replace('/*{{MANIFEST_DATA}}*/ null', manifest_json_str)
zf.writestr('Reader.html', html_content)
zip_buffer.seek(0)
return send_file(
zip_buffer,
mimetype='application/zip',
as_attachment=True,
download_name=f"{project_name}.zip"
)

475
routes/generation_routes.py Normal file
View File

@@ -0,0 +1,475 @@
# routes/generation_routes.py - Combined Endpoint with Correct Task Polling
import json
import time
import base64
import requests
from flask import Blueprint, request, jsonify
from db import get_db
from config import BEAM_COMBINED_URL, BEAM_API_TOKEN, get_beam_headers_json
from utils import convert_to_mp3, strip_markdown
from auth import login_required
generation_bp = Blueprint('generation', __name__)
# ============================================
# Beam Task Polling Config
# ============================================
BEAM_TASK_API = "https://api.beam.cloud/v2/task/{task_id}/"
POLL_INTERVAL = 3
POLL_MAX_WAIT = 300
def get_beam_auth_headers():
"""Beam API headers — Bearer AND Basic উভয়ই try করবে।"""
return {
'Authorization': f'Bearer {BEAM_API_TOKEN}',
'Content-Type': 'application/json',
}
def poll_beam_task(task_id):
"""Beam task poll করে result আনে।"""
print(f"⏳ Polling task: {task_id}")
task_url = BEAM_TASK_API.format(task_id=task_id)
print(f" URL: {task_url}")
start_time = time.time()
# প্রথম কয়েকটা attempt এ 404 আসতে পারে — task register হতে delay
initial_delay = True
while True:
elapsed = time.time() - start_time
if elapsed > POLL_MAX_WAIT:
print(f"❌ Polling timeout after {POLL_MAX_WAIT}s")
return None, f'Task timed out after {int(POLL_MAX_WAIT)} seconds'
# প্রথম ২ সেকেন্ড wait করি task register হতে
if initial_delay and elapsed < 2:
time.sleep(2)
initial_delay = False
continue
try:
# ★ Bearer token দিয়ে try
resp = requests.get(task_url, headers=get_beam_auth_headers(), timeout=30)
# Bearer fail হলে Basic try করি
if resp.status_code in (401, 403):
print(f" Bearer auth failed, trying Basic...")
basic_headers = {
'Authorization': f'Basic {BEAM_API_TOKEN}',
'Content-Type': 'application/json',
}
resp = requests.get(task_url, headers=basic_headers, timeout=30)
print(f" [{int(elapsed)}s] HTTP {resp.status_code} | Body: {len(resp.text)} chars")
if resp.status_code == 404:
# Task এখনও register হয়নি — wait
if elapsed < 30:
print(f" Task not found yet, waiting...")
time.sleep(POLL_INTERVAL)
continue
else:
# ৩০ সেকেন্ড পরেও 404 — সমস্যা
print(f"❌ Task not found after {int(elapsed)}s")
# ★ Debug: response body দেখি
print(f" 404 body: {resp.text[:300]}")
# ★ Alternative: Beam API base URL ভিন্ন হতে পারে
# কিছু Beam setup এ URL format ভিন্ন
alt_urls = [
f"https://api.beam.cloud/v2/task/{task_id}/status/",
f"https://api.beam.cloud/v2/task/{task_id}",
f"https://api.beam.cloud/v1/task/{task_id}/",
]
for alt_url in alt_urls:
try:
alt_resp = requests.get(alt_url, headers=get_beam_auth_headers(), timeout=10)
print(f" Alt URL {alt_url}: HTTP {alt_resp.status_code}")
if alt_resp.status_code == 200:
print(f" ✅ Found working URL!")
resp = alt_resp
break
except Exception:
pass
if resp.status_code == 404:
return None, f'Task {task_id} not found on Beam API after {int(elapsed)}s'
if resp.status_code == 200 and resp.text:
try:
data = resp.json()
except Exception as e:
print(f" JSON parse error: {e}")
print(f" Body: {resp.text[:300]}")
time.sleep(POLL_INTERVAL)
continue
status = data.get('status', '').upper()
print(f" [{int(elapsed)}s] Task status: {status}")
if status in ('COMPLETE', 'COMPLETED', 'SUCCESS'):
print(f"✅ Task complete!")
# ★ Result বের করা — Beam বিভিন্ন জায়গায় result রাখে
# 1. 'output' key
# 2. 'result' key
# 3. 'outputs' list (file-based)
# 4. response body তেই (endpoint mode)
actual_result = None
# Check 'output' (endpoint mode — function return value)
if data.get('output') and isinstance(data['output'], dict):
actual_result = data['output']
print(f" Result found in 'output' key")
# Check 'result'
elif data.get('result') and isinstance(data['result'], dict):
actual_result = data['result']
print(f" Result found in 'result' key")
# Check if top-level has audio_base64 (unlikely but possible)
elif data.get('audio_base64'):
actual_result = data
print(f" Result found in top-level data")
if actual_result and actual_result.get('audio_base64'):
return actual_result, None
elif actual_result and actual_result.get('success'):
return actual_result, None
# ★ Outputs (file-based) — need to download
outputs = data.get('outputs', [])
if outputs:
print(f" Task has {len(outputs)} output files")
# For our use case, result should be in 'output' not files
# But log it for debug
for out in outputs:
print(f" Output: {out.get('name', '?')}{out.get('url', '?')}")
# No usable result found
print(f" ⚠️ Task complete but no audio in response")
print(f" Response keys: {list(data.keys())}")
print(f" Full response (first 500): {json.dumps(data, default=str)[:500]}")
return None, 'Task completed but no audio data in result. Check Beam logs.'
elif status in ('FAILED', 'ERROR'):
error_msg = data.get('error', 'Task failed')
print(f"❌ Task failed: {error_msg}")
return None, f'Task failed: {error_msg}'
elif status in ('CANCELLED', 'CANCELED'):
return None, 'Task was cancelled'
elif status in ('TIMEOUT', 'EXPIRED'):
return None, f'Task {status.lower()} on Beam. Container may not have started in time.'
elif status in ('PENDING', 'RUNNING', 'RETRY'):
pass # Keep polling
else:
print(f" Unknown status: {status}")
elif resp.status_code != 404:
print(f" Unexpected HTTP {resp.status_code}: {resp.text[:200]}")
except requests.exceptions.RequestException as e:
print(f" Poll error: {e}")
time.sleep(POLL_INTERVAL)
# ============================================
# Beam Call + Smart Response Handler
# ============================================
def call_beam_and_get_result(text, voice='af_heart', speed=1.0):
"""Beam combined endpoint call + async polling।"""
if not BEAM_COMBINED_URL:
return None, 'BEAM_COMBINED_URL is not configured in .env'
print(f"📞 Calling: {BEAM_COMBINED_URL}")
print(f" text={len(text)} chars, voice={voice}")
response = requests.post(
BEAM_COMBINED_URL,
headers=get_beam_headers_json(),
json={
'text': text,
'voice': voice,
'speed': speed,
'skip_alignment': False,
},
timeout=300
)
print(f"📡 Status: {response.status_code}")
print(f"📡 Content-Length: {response.headers.get('Content-Length', 'N/A')}")
task_id = response.headers.get('X-Task-Id', '')
# ========================================
# 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'):
print(f"📋 Async mode — Task ID: {task_id}")
return poll_beam_task(task_id)
# ========================================
# CASE 2: Task ID + body
# ========================================
if task_id and response.text and response.text.strip():
print(f"📋 Task ID: {task_id} + body ({len(response.text)} chars)")
try:
result = response.json()
if result.get('success') and result.get('audio_base64'):
print(f"✅ Direct sync result")
return _extract(result), None
# Body isn't the final result — poll
return poll_beam_task(task_id)
except Exception:
return poll_beam_task(task_id)
# ========================================
# CASE 3: No task_id + empty body → Error
# ========================================
if not response.text or not response.text.strip():
return None, 'Empty response from Beam with no task ID'
# ========================================
# CASE 4: Synchronous response
# ========================================
if response.status_code != 200:
try:
err = response.json().get('error', response.text[:200])
except Exception:
err = response.text[:200]
return None, f'Beam Error ({response.status_code}): {err}'
try:
result = response.json()
except Exception as e:
return None, f'Invalid JSON: {response.text[:100]}'
if not result.get('success'):
return None, result.get('error', 'Unknown error')
return _extract(result), None
def _extract(result):
return {
'audio_base64': result.get('audio_base64', ''),
'audio_format': result.get('audio_format', 'wav'),
'sample_rate': result.get('sample_rate', 24000),
'transcription': result.get('timestamps', []),
}
# ============================================
# API Route: Single Block
# ============================================
@generation_bp.route('/api/generate', methods=['POST'])
@login_required
def generate_audio():
data = request.json
text = data.get('text', '')
voice = data.get('voice', 'af_heart')
block_id = data.get('block_id')
if not text:
return jsonify({'error': 'No text provided'}), 400
stripped = text.strip()
if stripped.startswith('![') and '](' in stripped and stripped.endswith(')'):
return jsonify({'error': 'Cannot generate audio for image content'}), 400
clean_text = strip_markdown(text)
if not clean_text.strip():
return jsonify({'error': 'No speakable text content'}), 400
try:
print(f"")
print(f"{'='*60}")
print(f"🔊 GENERATE REQUEST")
print(f" Voice: {voice}, Text: {len(clean_text)} chars")
print(f" Preview: {clean_text[:100]}...")
print(f"{'='*60}")
result, error = call_beam_and_get_result(clean_text, voice)
if error:
print(f"❌ Failed: {error}")
return jsonify({'error': error}), 500
audio_base64 = result.get('audio_base64', '')
source_format = result.get('audio_format', 'wav')
transcription = result.get('transcription', [])
if not audio_base64:
return jsonify({'error': 'No audio data received'}), 500
if source_format != 'mp3':
audio_base64 = convert_to_mp3(audio_base64, source_format)
if block_id:
db = get_db()
cursor = db.cursor()
cursor.execute('''
UPDATE markdown_blocks
SET audio_data = ?, audio_format = 'mp3', transcription = ?
WHERE id = ?
''', (audio_base64, json.dumps(transcription), block_id))
db.commit()
print(f"✅ DONE: audio={len(audio_base64)} bytes, words={len(transcription)}")
print(f"{'='*60}")
return jsonify({
'success': True,
'audio_data': audio_base64,
'audio_format': 'mp3',
'transcription': transcription
})
except requests.exceptions.ConnectionError as e:
print(f"❌ CONNECTION: {e}")
return jsonify({'error': 'Cannot connect to Beam Cloud.'}), 500
except requests.exceptions.Timeout:
return jsonify({'error': 'Request timed out. Try again in 1-2 minutes.'}), 500
except requests.exceptions.RequestException as e:
return jsonify({'error': f'API error: {str(e)}'}), 500
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500
# ============================================
# API Route: Chapter
# ============================================
@generation_bp.route('/api/generate-chapter', methods=['POST'])
@login_required
def generate_chapter_audio():
data = request.json
chapter_id = data.get('chapter_id')
voice = data.get('voice', 'af_heart')
if not chapter_id:
return jsonify({'error': 'Chapter ID required'}), 400
db = get_db()
cursor = db.cursor()
cursor.execute('''
SELECT id, content, tts_text, block_type FROM markdown_blocks
WHERE chapter_id = ? ORDER BY block_order
''', (chapter_id,))
blocks = cursor.fetchall()
if not blocks:
return jsonify({'error': 'No blocks found'}), 404
results = []
success_count = 0
error_count = 0
total = len(blocks)
print(f"\n{'='*60}")
print(f"📖 CHAPTER: {total} blocks, voice={voice}")
print(f"{'='*60}")
for idx, block in enumerate(blocks):
block_id = block['id']
block_type = block['block_type'] if 'block_type' in block.keys() else 'paragraph'
content = block['content'] or ''
text = block['tts_text'] if block['tts_text'] else content
if block_type == 'image':
results.append({'block_id': block_id, 'success': True, 'skipped': True})
continue
stripped = text.strip()
if stripped.startswith('![') and '](' in stripped and stripped.endswith(')'):
results.append({'block_id': block_id, 'success': True, 'skipped': True})
continue
clean_text = strip_markdown(text)
if not clean_text.strip():
results.append({'block_id': block_id, 'success': True, 'skipped': True})
continue
print(f"\n📖 Block {idx+1}/{total}: {len(clean_text)} chars")
try:
result, error = call_beam_and_get_result(clean_text, voice)
if error:
print(f"❌ Block {block_id}: {error}")
results.append({'block_id': block_id, 'success': False, 'error': error})
error_count += 1
continue
audio_base64 = result.get('audio_base64', '')
source_format = result.get('audio_format', 'wav')
transcription = result.get('transcription', [])
if not audio_base64:
results.append({'block_id': block_id, 'success': False, 'error': 'No audio'})
error_count += 1
continue
if source_format != 'mp3':
audio_base64 = convert_to_mp3(audio_base64, source_format)
cursor.execute('''
UPDATE markdown_blocks
SET audio_data = ?, audio_format = 'mp3', transcription = ?
WHERE id = ?
''', (audio_base64, json.dumps(transcription), block_id))
results.append({
'block_id': block_id,
'success': True,
'audio_data': audio_base64,
'transcription': transcription
})
success_count += 1
print(f"✅ Block {idx+1} done")
except Exception as e:
print(f"❌ Block {block_id}: {e}")
results.append({'block_id': block_id, 'success': False, 'error': str(e)})
error_count += 1
db.commit()
skipped = total - success_count - error_count
print(f"\n📖 COMPLETE: {success_count} ok, {error_count} fail, {skipped} skip")
print(f"{'='*60}\n")
return jsonify({
'success': True,
'results': results,
'summary': {
'total': total,
'generated': success_count,
'failed': error_count,
'skipped': skipped
}
})

61
routes/main_routes.py Normal file
View File

@@ -0,0 +1,61 @@
# routes/main_routes.py - Main Application Routes
import os
from flask import Blueprint, jsonify, send_from_directory, session
from config import DATABASE, VOICES
from auth import login_required, get_current_user
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
@login_required
def index():
"""Serve main application page."""
return send_from_directory('templates', 'index.html')
@main_bp.route('/static/<path:filename>')
def serve_static(filename):
"""Serve static files."""
return send_from_directory('static', filename)
@main_bp.route('/api/voices', methods=['GET'])
@login_required
def get_voices():
"""Get available TTS voices."""
return jsonify({'voices': VOICES})
@main_bp.route('/api/stats', methods=['GET'])
@login_required
def get_stats():
"""Get database statistics."""
from db import get_db
db = get_db()
cursor = db.cursor()
cursor.execute('SELECT COUNT(*) as count FROM projects')
project_count = cursor.fetchone()['count']
cursor.execute('SELECT COUNT(*) as count FROM chapters')
chapter_count = cursor.fetchone()['count']
cursor.execute('SELECT COUNT(*) as count FROM markdown_blocks')
block_count = cursor.fetchone()['count']
cursor.execute('SELECT COUNT(*) as count FROM pdf_documents')
pdf_count = cursor.fetchone()['count']
db_size = os.path.getsize(DATABASE) if os.path.exists(DATABASE) else 0
return jsonify({
'projects': project_count,
'chapters': chapter_count,
'blocks': block_count,
'pdf_documents': pdf_count,
'database_size_mb': round(db_size / (1024 * 1024), 2)
})

68
routes/pdf_routes.py Normal file
View File

@@ -0,0 +1,68 @@
# routes/pdf_routes.py - PDF Upload and Processing Routes
import json
from flask import Blueprint, request, jsonify
from db import get_db
from pdf_processor import process_pdf_to_markdown
from ai_processor import process_document_smartly
from auth import login_required
pdf_bp = Blueprint('pdf', __name__)
@pdf_bp.route('/api/upload-pdf', methods=['POST'])
@login_required
def upload_pdf():
"""Upload and process a PDF file."""
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
pdf_file = request.files['file']
if not pdf_file or not pdf_file.filename:
return jsonify({'error': 'Invalid file'}), 400
if not pdf_file.filename.lower().endswith('.pdf'):
return jsonify({'error': 'File must be a PDF'}), 400
try:
print(f"📄 Processing PDF: {pdf_file.filename}")
pdf_bytes = pdf_file.read()
result = process_pdf_to_markdown(pdf_bytes)
# --- AI Powered Smart Reconstruction & Section Tagging ---
smart_blocks = process_document_smartly(result['markdown_blocks'], result['metadata'])
# Save PDF document record
db = get_db()
cursor = db.cursor()
cursor.execute('''
INSERT INTO pdf_documents (filename, page_count, metadata)
VALUES (?, ?, ?)
''', (
pdf_file.filename,
result["page_count"],
json.dumps(result["metadata"])
))
db.commit()
doc_id = cursor.lastrowid
print(f"✅ PDF processed & reconstructed: {result['page_count']} pages, {len(smart_blocks)} blocks")
return jsonify({
'success': True,
'document_id': doc_id,
'filename': pdf_file.filename,
'page_count': result['page_count'],
'metadata': result['metadata'],
'blocks': smart_blocks
})
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500

417
routes/project_routes.py Normal file
View File

@@ -0,0 +1,417 @@
# routes/project_routes.py - Project Management Routes (v4.2)
import json
import base64
from flask import Blueprint, request, jsonify
from db import get_db, vacuum_db
from auth import login_required
project_bp = Blueprint('project', __name__)
@project_bp.route('/api/projects', methods=['GET'])
@login_required
def list_projects():
"""List all projects with publishing info."""
db = get_db()
cursor = db.cursor()
cursor.execute('''
SELECT p.id, p.name, p.created_at, p.updated_at,
p.is_published, p.published_at, p.thumbnail_data, p.thumbnail_format,
p.description, p.author, p.category, p.view_count,
(SELECT COUNT(*) FROM chapters WHERE project_id = p.id) as chapter_count,
(SELECT COUNT(*) FROM markdown_blocks mb
JOIN chapters c ON mb.chapter_id = c.id
WHERE c.project_id = p.id) as block_count,
(SELECT COUNT(*) FROM markdown_blocks mb
JOIN chapters c ON mb.chapter_id = c.id
WHERE c.project_id = p.id AND mb.audio_data IS NOT NULL AND mb.audio_data != '') as audio_count
FROM projects p
ORDER BY p.updated_at DESC
''')
projects = []
for row in cursor.fetchall():
projects.append({
'id': row['id'],
'name': row['name'],
'created_at': row['created_at'],
'updated_at': row['updated_at'],
'chapter_count': row['chapter_count'],
'block_count': row['block_count'],
'audio_count': row['audio_count'],
'is_published': bool(row['is_published']),
'published_at': row['published_at'],
'thumbnail_data': row['thumbnail_data'],
'thumbnail_format': row['thumbnail_format'] or 'png',
'description': row['description'] or '',
'author': row['author'] or '',
'category': row['category'] or '',
'view_count': row['view_count'] or 0
})
return jsonify({'projects': projects})
@project_bp.route('/api/projects', methods=['POST'])
@login_required
def create_project():
"""Create a new project."""
data = request.json
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Project name is required'}), 400
db = get_db()
cursor = db.cursor()
try:
cursor.execute('INSERT INTO projects (name) VALUES (?)', (name,))
db.commit()
return jsonify({
'success': True,
'project_id': cursor.lastrowid,
'name': name
})
except Exception as e:
if 'UNIQUE constraint' in str(e):
return jsonify({'error': 'Project with this name already exists'}), 400
return jsonify({'error': str(e)}), 500
@project_bp.route('/api/projects/<int:project_id>', methods=['GET'])
@login_required
def get_project(project_id):
"""Get a project with all its chapters and blocks."""
db = get_db()
cursor = db.cursor()
cursor.execute('SELECT * FROM projects WHERE id = ?', (project_id,))
project = cursor.fetchone()
if not project:
return jsonify({'error': 'Project not found'}), 404
cursor.execute('''
SELECT * FROM chapters WHERE project_id = ? ORDER BY chapter_number
''', (project_id,))
chapters = cursor.fetchall()
chapters_data = []
for chapter in chapters:
cursor.execute('''
SELECT * FROM markdown_blocks WHERE chapter_id = ? ORDER BY block_order
''', (chapter['id'],))
blocks = cursor.fetchall()
blocks_data = []
for block in blocks:
cursor.execute('''
SELECT * FROM block_images WHERE block_id = ? ORDER BY id
''', (block['id'],))
images = cursor.fetchall()
blocks_data.append({
'id': block['id'],
'block_order': block['block_order'],
'block_type': block['block_type'],
'content': block['content'],
'tts_text': block['tts_text'],
'audio_data': block['audio_data'],
'audio_format': block['audio_format'],
'transcription': json.loads(block['transcription']) if block['transcription'] else [],
'images': [{
'id': img['id'],
'data': img['image_data'],
'format': img['image_format'],
'alt_text': img['alt_text'],
'position': img['position']
} for img in images]
})
chapters_data.append({
'id': chapter['id'],
'chapter_number': chapter['chapter_number'],
'title': chapter['title'],
'voice': chapter['voice'],
'blocks': blocks_data
})
return jsonify({
'id': project['id'],
'name': project['name'],
'created_at': project['created_at'],
'updated_at': project['updated_at'],
'chapters': chapters_data
})
@project_bp.route('/api/projects/<int:project_id>', methods=['PUT'])
@login_required
def update_project(project_id):
"""Update project name."""
data = request.json
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Project name is required'}), 400
db = get_db()
cursor = db.cursor()
try:
cursor.execute('''
UPDATE projects SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
''', (name, project_id))
db.commit()
if cursor.rowcount == 0:
return jsonify({'error': 'Project not found'}), 404
return jsonify({'success': True})
except Exception as e:
if 'UNIQUE constraint' in str(e):
return jsonify({'error': 'A project with this name already exists'}), 400
return jsonify({'error': str(e)}), 500
@project_bp.route('/api/projects/<int:project_id>', methods=['DELETE'])
@login_required
def delete_project(project_id):
"""Delete a project and all its data."""
db = get_db()
cursor = db.cursor()
cursor.execute('SELECT id FROM projects WHERE id = ?', (project_id,))
if not cursor.fetchone():
return jsonify({'error': 'Project not found'}), 404
cursor.execute('''
DELETE FROM block_images WHERE block_id IN (
SELECT mb.id FROM markdown_blocks mb
JOIN chapters c ON mb.chapter_id = c.id
WHERE c.project_id = ?
)
''', (project_id,))
cursor.execute('''
DELETE FROM markdown_blocks WHERE chapter_id IN (
SELECT id FROM chapters WHERE project_id = ?
)
''', (project_id,))
cursor.execute('DELETE FROM chapters WHERE project_id = ?', (project_id,))
cursor.execute('DELETE FROM projects WHERE id = ?', (project_id,))
db.commit()
vacuum_db()
return jsonify({'success': True})
@project_bp.route('/api/projects/<int:project_id>/save', methods=['POST'])
@login_required
def save_project_content(project_id):
"""Save all chapters and blocks for a project."""
data = request.json
chapters = data.get('chapters', [])
db = get_db()
cursor = db.cursor()
cursor.execute('SELECT id FROM projects WHERE id = ?', (project_id,))
if not cursor.fetchone():
return jsonify({'error': 'Project not found'}), 404
cursor.execute('''
DELETE FROM block_images WHERE block_id IN (
SELECT mb.id FROM markdown_blocks mb
JOIN chapters c ON mb.chapter_id = c.id
WHERE c.project_id = ?
)
''', (project_id,))
cursor.execute('''
DELETE FROM markdown_blocks WHERE chapter_id IN (
SELECT id FROM chapters WHERE project_id = ?
)
''', (project_id,))
cursor.execute('DELETE FROM chapters WHERE project_id = ?', (project_id,))
for chapter in chapters:
cursor.execute('''
INSERT INTO chapters (project_id, chapter_number, title, voice)
VALUES (?, ?, ?, ?)
''', (
project_id,
chapter['chapter_number'],
chapter.get('title', 'Section'),
chapter.get('voice', 'af_heart')
))
chapter_id = cursor.lastrowid
for block in chapter.get('blocks', []):
cursor.execute('''
INSERT INTO markdown_blocks
(chapter_id, block_order, block_type, content, tts_text, audio_data, audio_format, transcription)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (
chapter_id,
block['block_order'],
block.get('block_type', 'paragraph'),
block['content'],
block.get('tts_text'),
block.get('audio_data'),
block.get('audio_format', 'mp3'),
json.dumps(block.get('transcription', []))
))
block_id = cursor.lastrowid
for img in block.get('images', []):
cursor.execute('''
INSERT INTO block_images (block_id, image_data, image_format, alt_text, position)
VALUES (?, ?, ?, ?, ?)
''', (
block_id,
img['data'],
img.get('format', 'png'),
img.get('alt_text', ''),
img.get('position', 'before')
))
cursor.execute('''
UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?
''', (project_id,))
db.commit()
return jsonify({'success': True, 'message': 'Project saved successfully'})
# ============================================
# v4.2: Publishing Endpoints
# ============================================
@project_bp.route('/api/projects/<int:project_id>/publish', methods=['POST'])
@login_required
def publish_project(project_id):
"""Publish a project to make it visible on public homepage."""
data = request.json or {}
db = get_db()
cursor = db.cursor()
cursor.execute('SELECT id, name FROM projects WHERE id = ?', (project_id,))
project = cursor.fetchone()
if not project:
return jsonify({'error': 'Project not found'}), 404
# Verify project has at least one chapter with audio
cursor.execute('''
SELECT COUNT(*) as cnt FROM markdown_blocks mb
JOIN chapters c ON mb.chapter_id = c.id
WHERE c.project_id = ? AND mb.audio_data IS NOT NULL AND mb.audio_data != ''
''', (project_id,))
audio_count = cursor.fetchone()['cnt']
if audio_count == 0:
return jsonify({'error': 'Cannot publish: no audio generated yet'}), 400
description = (data.get('description') or '').strip()
author = (data.get('author') or '').strip()
category = (data.get('category') or '').strip()
cursor.execute('''
UPDATE projects
SET is_published = 1,
published_at = CURRENT_TIMESTAMP,
description = ?,
author = ?,
category = ?
WHERE id = ?
''', (description, author, category, project_id))
db.commit()
return jsonify({
'success': True,
'message': f'"{project["name"]}" published successfully!'
})
@project_bp.route('/api/projects/<int:project_id>/unpublish', methods=['POST'])
@login_required
def unpublish_project(project_id):
"""Unpublish a project (but keep author/description/category for easy republish)."""
db = get_db()
cursor = db.cursor()
cursor.execute('SELECT id FROM projects WHERE id = ?', (project_id,))
if not cursor.fetchone():
return jsonify({'error': 'Project not found'}), 404
# Only flip is_published flag — keep author/description/category for republish
cursor.execute('UPDATE projects SET is_published = 0 WHERE id = ?', (project_id,))
db.commit()
return jsonify({'success': True, 'message': 'Project unpublished'})
@project_bp.route('/api/projects/<int:project_id>/thumbnail', methods=['POST'])
@login_required
def upload_thumbnail(project_id):
"""Upload a thumbnail image for the project."""
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
img_file = request.files['file']
if not img_file or not img_file.filename:
return jsonify({'error': 'Invalid file'}), 400
filename = img_file.filename.lower()
if not any(filename.endswith(ext) for ext in ('.png', '.jpg', '.jpeg', '.webp', '.gif')):
return jsonify({'error': 'File must be an image (PNG/JPG/WEBP/GIF)'}), 400
img_bytes = img_file.read()
if len(img_bytes) > 5 * 1024 * 1024:
return jsonify({'error': 'Image too large (max 5MB)'}), 400
fmt = filename.rsplit('.', 1)[-1]
if fmt == 'jpg':
fmt = 'jpeg'
b64 = base64.b64encode(img_bytes).decode('utf-8')
db = get_db()
cursor = db.cursor()
cursor.execute('SELECT id FROM projects WHERE id = ?', (project_id,))
if not cursor.fetchone():
return jsonify({'error': 'Project not found'}), 404
cursor.execute('''
UPDATE projects SET thumbnail_data = ?, thumbnail_format = ? WHERE id = ?
''', (b64, fmt, project_id))
db.commit()
return jsonify({
'success': True,
'thumbnail_data': b64,
'thumbnail_format': fmt
})
@project_bp.route('/api/projects/<int:project_id>/thumbnail', methods=['DELETE'])
@login_required
def delete_thumbnail(project_id):
"""Remove project thumbnail."""
db = get_db()
cursor = db.cursor()
cursor.execute('UPDATE projects SET thumbnail_data = NULL WHERE id = ?', (project_id,))
db.commit()
return jsonify({'success': True})

133
routes/public_routes.py Normal file
View File

@@ -0,0 +1,133 @@
# routes/public_routes.py - Public (No Auth) Routes for Published Audiobooks
import json
from flask import Blueprint, jsonify, send_from_directory, abort
from db import get_db
public_bp = Blueprint('public', __name__)
@public_bp.route('/home')
def public_home():
"""Public homepage - Bookcase view of published audiobooks."""
return send_from_directory('templates', 'public_home.html')
@public_bp.route('/read/<int:project_id>')
def public_reader(project_id):
"""Public reader page for a published audiobook."""
db = get_db()
cursor = db.cursor()
cursor.execute('SELECT id, is_published FROM projects WHERE id = ?', (project_id,))
project = cursor.fetchone()
if not project or not project['is_published']:
abort(404)
# Increment view count
cursor.execute('UPDATE projects SET view_count = view_count + 1 WHERE id = ?', (project_id,))
db.commit()
return send_from_directory('templates', 'public_reader.html')
@public_bp.route('/api/public/books', methods=['GET'])
def list_published_books():
"""List all published audiobooks (no auth required)."""
db = get_db()
cursor = db.cursor()
cursor.execute('''
SELECT p.id, p.name, p.description, p.author, p.category,
p.thumbnail_data, p.thumbnail_format, p.published_at,
p.view_count, p.created_at,
(SELECT COUNT(*) FROM chapters WHERE project_id = p.id) as chapter_count
FROM projects p
WHERE p.is_published = 1
ORDER BY p.published_at DESC
''')
books = []
for row in cursor.fetchall():
books.append({
'id': row['id'],
'name': row['name'],
'description': row['description'] or '',
'author': row['author'] or '',
'category': row['category'] or '',
'thumbnail_data': row['thumbnail_data'],
'thumbnail_format': row['thumbnail_format'] or 'png',
'published_at': row['published_at'],
'view_count': row['view_count'] or 0,
'chapter_count': row['chapter_count']
})
return jsonify({'books': books})
@public_bp.route('/api/public/books/<int:project_id>', methods=['GET'])
def get_published_book(project_id):
"""Get full published book content for the reader."""
db = get_db()
cursor = db.cursor()
cursor.execute('''
SELECT * FROM projects WHERE id = ? AND is_published = 1
''', (project_id,))
project = cursor.fetchone()
if not project:
return jsonify({'error': 'Book not found or not published'}), 404
cursor.execute('''
SELECT * FROM chapters WHERE project_id = ? ORDER BY chapter_number
''', (project_id,))
chapters = cursor.fetchall()
chapters_data = []
for chapter in chapters:
cursor.execute('''
SELECT * FROM markdown_blocks WHERE chapter_id = ? ORDER BY block_order
''', (chapter['id'],))
blocks = cursor.fetchall()
blocks_data = []
for block in blocks:
cursor.execute('''
SELECT * FROM block_images WHERE block_id = ? ORDER BY id
''', (block['id'],))
images = cursor.fetchall()
blocks_data.append({
'id': block['id'],
'block_order': block['block_order'],
'block_type': block['block_type'],
'content': block['content'],
'audio_data': block['audio_data'],
'audio_format': block['audio_format'],
'transcription': json.loads(block['transcription']) if block['transcription'] else [],
'images': [{
'data': img['image_data'],
'format': img['image_format'],
'alt_text': img['alt_text'],
'position': img['position']
} for img in images]
})
chapters_data.append({
'id': chapter['id'],
'chapter_number': chapter['chapter_number'],
'title': chapter['title'],
'blocks': blocks_data
})
return jsonify({
'id': project['id'],
'name': project['name'],
'description': project['description'] or '',
'author': project['author'] or '',
'thumbnail_data': project['thumbnail_data'],
'thumbnail_format': project['thumbnail_format'] or 'png',
'chapters': chapters_data
})