Audiobook Maker Pro v4.2 — production ready
This commit is contained in:
475
routes/generation_routes.py
Normal file
475
routes/generation_routes.py
Normal 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(''):
|
||||
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(''):
|
||||
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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user