Audiobook Maker Pro v4.2 — production ready
This commit is contained in:
27
routes/__init__.py
Normal file
27
routes/__init__.py
Normal 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
175
routes/admin_routes.py
Normal 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
113
routes/auth_routes.py
Normal 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
57
routes/docx_routes.py
Normal 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
185
routes/export_routes.py
Normal 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
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
|
||||
}
|
||||
})
|
||||
61
routes/main_routes.py
Normal file
61
routes/main_routes.py
Normal 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
68
routes/pdf_routes.py
Normal 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
417
routes/project_routes.py
Normal 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
133
routes/public_routes.py
Normal 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
|
||||
})
|
||||
Reference in New Issue
Block a user