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

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
}
})