476 lines
17 KiB
Python
476 lines
17 KiB
Python
# 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
|
|
}
|
|
})
|