v4.3 fix: serve audio as base64 JSON (gunicorn Range fix), path-aware export, faster project load
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# routes/export_routes.py - Export Routes
|
# routes/export_routes.py - Export Routes (v4.3: file-based media)
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
@@ -11,9 +11,39 @@ from flask import Blueprint, request, jsonify, send_file
|
|||||||
from db import get_db
|
from db import get_db
|
||||||
from utils import sanitize_filename, strip_markdown
|
from utils import sanitize_filename, strip_markdown
|
||||||
from auth import login_required
|
from auth import login_required
|
||||||
|
from media_storage import read_file_bytes
|
||||||
|
|
||||||
export_bp = Blueprint('export', __name__)
|
export_bp = Blueprint('export', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_audio_bytes(block):
|
||||||
|
"""audio_path থাকলে ফাইল থেকে, নইলে DB base64 থেকে bytes রিটার্ন করে।"""
|
||||||
|
if block['audio_path']:
|
||||||
|
data = read_file_bytes(block['audio_path'])
|
||||||
|
if data:
|
||||||
|
return data
|
||||||
|
if block['audio_data']:
|
||||||
|
try:
|
||||||
|
return base64.b64decode(block['audio_data'])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_image_bytes(img):
|
||||||
|
"""image_path থাকলে ফাইল থেকে, নইলে DB base64 থেকে bytes রিটার্ন করে।"""
|
||||||
|
if img['image_path']:
|
||||||
|
data = read_file_bytes(img['image_path'])
|
||||||
|
if data:
|
||||||
|
return data
|
||||||
|
if img['image_data']:
|
||||||
|
try:
|
||||||
|
return base64.b64decode(img['image_data'])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@export_bp.route('/api/export/<int:project_id>', methods=['GET'])
|
@export_bp.route('/api/export/<int:project_id>', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def export_project(project_id):
|
def export_project(project_id):
|
||||||
@@ -52,13 +82,15 @@ def export_project(project_id):
|
|||||||
''', (chapter['id'],))
|
''', (chapter['id'],))
|
||||||
blocks = cursor.fetchall()
|
blocks = cursor.fetchall()
|
||||||
|
|
||||||
|
# চ্যাপ্টারে কোনো অডিও আছে কিনা (file বা base64)
|
||||||
chapter_has_audio = False
|
chapter_has_audio = False
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
is_image_block = (
|
is_image_block = (
|
||||||
(block['content'] and block['content'].strip().startswith('![')) or
|
(block['content'] and block['content'].strip().startswith('![')) or
|
||||||
block['block_type'] == 'image'
|
block['block_type'] == 'image'
|
||||||
)
|
)
|
||||||
if not is_image_block and block['audio_data']:
|
has_audio = bool(block['audio_path']) or bool(block['audio_data'])
|
||||||
|
if not is_image_block and has_audio:
|
||||||
chapter_has_audio = True
|
chapter_has_audio = True
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -83,34 +115,37 @@ def export_project(project_id):
|
|||||||
image_idx = 0
|
image_idx = 0
|
||||||
for img in images:
|
for img in images:
|
||||||
if img['position'] == 'before':
|
if img['position'] == 'before':
|
||||||
image_filename = f"book/{prefix}_img{image_idx}.{img['image_format']}"
|
img_bytes = _resolve_image_bytes(img)
|
||||||
image_bytes = base64.b64decode(img['image_data'])
|
if img_bytes:
|
||||||
zf.writestr(image_filename, image_bytes)
|
image_filename = f"book/{prefix}_img{image_idx}.{img['image_format']}"
|
||||||
manifest['images'].append({
|
zf.writestr(image_filename, img_bytes)
|
||||||
'sortKey': prefix,
|
manifest['images'].append({
|
||||||
'file': image_filename
|
'sortKey': prefix,
|
||||||
})
|
'file': image_filename
|
||||||
image_idx += 1
|
})
|
||||||
|
image_idx += 1
|
||||||
|
|
||||||
if is_image_block:
|
if is_image_block:
|
||||||
for img in images:
|
for img in images:
|
||||||
if img['position'] == 'after':
|
if img['position'] == 'after':
|
||||||
next_prefix = f"{section_id}.{block_order + 1}"
|
img_bytes = _resolve_image_bytes(img)
|
||||||
image_filename = f"book/{next_prefix}_img{image_idx}.{img['image_format']}"
|
if img_bytes:
|
||||||
image_bytes = base64.b64decode(img['image_data'])
|
next_prefix = f"{section_id}.{block_order + 1}"
|
||||||
zf.writestr(image_filename, image_bytes)
|
image_filename = f"book/{next_prefix}_img{image_idx}.{img['image_format']}"
|
||||||
manifest['images'].append({
|
zf.writestr(image_filename, img_bytes)
|
||||||
'sortKey': next_prefix,
|
manifest['images'].append({
|
||||||
'file': image_filename
|
'sortKey': next_prefix,
|
||||||
})
|
'file': image_filename
|
||||||
image_idx += 1
|
})
|
||||||
|
image_idx += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
plain_text = strip_markdown(content)
|
plain_text = strip_markdown(content)
|
||||||
if not plain_text.strip():
|
if not plain_text.strip():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not block['audio_data']:
|
audio_bytes = _resolve_audio_bytes(block)
|
||||||
|
if not audio_bytes:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
text_filename = f"book/{prefix}_{project_name}.txt"
|
text_filename = f"book/{prefix}_{project_name}.txt"
|
||||||
@@ -126,7 +161,6 @@ def export_project(project_id):
|
|||||||
}
|
}
|
||||||
|
|
||||||
audio_filename = f"book/{prefix}_{project_name}.{block['audio_format'] or 'mp3'}"
|
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)
|
zf.writestr(audio_filename, audio_bytes)
|
||||||
asset_entry['audioFile'] = audio_filename
|
asset_entry['audioFile'] = audio_filename
|
||||||
|
|
||||||
@@ -139,15 +173,16 @@ def export_project(project_id):
|
|||||||
|
|
||||||
for img in images:
|
for img in images:
|
||||||
if img['position'] == 'after':
|
if img['position'] == 'after':
|
||||||
next_prefix = f"{section_id}.{block_order + 1}"
|
img_bytes = _resolve_image_bytes(img)
|
||||||
image_filename = f"book/{next_prefix}_img{image_idx}.{img['image_format']}"
|
if img_bytes:
|
||||||
image_bytes = base64.b64decode(img['image_data'])
|
next_prefix = f"{section_id}.{block_order + 1}"
|
||||||
zf.writestr(image_filename, image_bytes)
|
image_filename = f"book/{next_prefix}_img{image_idx}.{img['image_format']}"
|
||||||
manifest['images'].append({
|
zf.writestr(image_filename, img_bytes)
|
||||||
'sortKey': next_prefix,
|
manifest['images'].append({
|
||||||
'file': image_filename
|
'sortKey': next_prefix,
|
||||||
})
|
'file': image_filename
|
||||||
image_idx += 1
|
})
|
||||||
|
image_idx += 1
|
||||||
|
|
||||||
# Write manifest.json to zip root
|
# Write manifest.json to zip root
|
||||||
manifest_json_str = json.dumps(manifest, indent=2)
|
manifest_json_str = json.dumps(manifest, indent=2)
|
||||||
@@ -163,7 +198,6 @@ def export_project(project_id):
|
|||||||
if os.path.exists(index_path):
|
if os.path.exists(index_path):
|
||||||
with open(index_path, 'r', encoding='utf-8') as f:
|
with open(index_path, 'r', encoding='utf-8') as f:
|
||||||
html_content = f.read()
|
html_content = f.read()
|
||||||
# Inject manifest into index.html safely
|
|
||||||
html_content = html_content.replace('/*{{MANIFEST_DATA}}*/ null', manifest_json_str)
|
html_content = html_content.replace('/*{{MANIFEST_DATA}}*/ null', manifest_json_str)
|
||||||
zf.writestr('index.html', html_content)
|
zf.writestr('index.html', html_content)
|
||||||
|
|
||||||
@@ -171,7 +205,6 @@ def export_project(project_id):
|
|||||||
if os.path.exists(reader_path):
|
if os.path.exists(reader_path):
|
||||||
with open(reader_path, 'r', encoding='utf-8') as f:
|
with open(reader_path, 'r', encoding='utf-8') as f:
|
||||||
html_content = f.read()
|
html_content = f.read()
|
||||||
# Inject manifest into Reader.html safely
|
|
||||||
html_content = html_content.replace('/*{{MANIFEST_DATA}}*/ null', manifest_json_str)
|
html_content = html_content.replace('/*{{MANIFEST_DATA}}*/ null', manifest_json_str)
|
||||||
zf.writestr('Reader.html', html_content)
|
zf.writestr('Reader.html', html_content)
|
||||||
|
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ def get_project(project_id):
|
|||||||
@project_bp.route('/api/projects/<int:project_id>/audio/<int:block_id>', methods=['GET'])
|
@project_bp.route('/api/projects/<int:project_id>/audio/<int:block_id>', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def get_block_audio(project_id, block_id):
|
def get_block_audio(project_id, block_id):
|
||||||
"""Stream audio for a single block (v4.3: from file, with base64 fallback)."""
|
"""Return audio as base64 JSON (v4.3: read from file, no send_file/Range)."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
cursor = db.cursor()
|
cursor = db.cursor()
|
||||||
|
|
||||||
@@ -237,21 +237,23 @@ def get_block_audio(project_id, block_id):
|
|||||||
if not row:
|
if not row:
|
||||||
return jsonify({'error': 'Block not found'}), 404
|
return jsonify({'error': 'Block not found'}), 404
|
||||||
|
|
||||||
# নতুন: ফাইল থেকে সরাসরি stream (Range request সাপোর্ট সহ)
|
audio_format = clean_str(row['audio_format']) or 'mp3'
|
||||||
if row['audio_path']:
|
|
||||||
abs_path = get_safe_abs_path(row['audio_path'])
|
|
||||||
if abs_path and os.path.exists(abs_path):
|
|
||||||
return send_file(abs_path, mimetype=f"audio/{row['audio_format'] or 'mp3'}",
|
|
||||||
conditional=True)
|
|
||||||
|
|
||||||
# পুরোনো: base64 JSON
|
# নতুন: ফাইল থেকে পড়ে base64 হিসেবে পাঠাই (gunicorn Range সমস্যা এড়াতে)
|
||||||
|
if row['audio_path']:
|
||||||
|
from media_storage import read_file_base64
|
||||||
|
b64 = read_file_base64(row['audio_path'])
|
||||||
|
if b64:
|
||||||
|
return jsonify({'audio_data': b64, 'audio_format': audio_format})
|
||||||
|
|
||||||
|
# পুরোনো: DB-তে থাকা base64 (মাইগ্রেট না হওয়া)
|
||||||
if row['audio_data']:
|
if row['audio_data']:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'audio_data': clean_str(row['audio_data']),
|
'audio_data': clean_str(row['audio_data']),
|
||||||
'audio_format': clean_str(row['audio_format']) or 'mp3'
|
'audio_format': audio_format
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({'audio_data': '', 'audio_format': row['audio_format'] or 'mp3'})
|
return jsonify({'audio_data': '', 'audio_format': audio_format})
|
||||||
|
|
||||||
|
|
||||||
@project_bp.route('/api/projects/<int:project_id>', methods=['PUT'])
|
@project_bp.route('/api/projects/<int:project_id>', methods=['PUT'])
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ def get_published_book(project_id):
|
|||||||
|
|
||||||
@public_bp.route('/api/public/books/<int:project_id>/audio/<int:block_id>', methods=['GET'])
|
@public_bp.route('/api/public/books/<int:project_id>/audio/<int:block_id>', methods=['GET'])
|
||||||
def get_public_block_audio(project_id, block_id):
|
def get_public_block_audio(project_id, block_id):
|
||||||
"""Stream audio file for a published book block (v4.3)."""
|
"""Return audio as base64 JSON for a published book block (v4.3)."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
cursor = db.cursor()
|
cursor = db.cursor()
|
||||||
|
|
||||||
@@ -214,16 +214,18 @@ def get_public_block_audio(project_id, block_id):
|
|||||||
if not row:
|
if not row:
|
||||||
return jsonify({'error': 'Block not found'}), 404
|
return jsonify({'error': 'Block not found'}), 404
|
||||||
|
|
||||||
|
audio_format = clean_str(row['audio_format']) or 'mp3'
|
||||||
|
|
||||||
if row['audio_path']:
|
if row['audio_path']:
|
||||||
abs_path = get_safe_abs_path(row['audio_path'])
|
from media_storage import read_file_base64
|
||||||
if abs_path and os.path.exists(abs_path):
|
b64 = read_file_base64(row['audio_path'])
|
||||||
return send_file(abs_path, mimetype=f"audio/{row['audio_format'] or 'mp3'}",
|
if b64:
|
||||||
conditional=True)
|
return jsonify({'audio_data': b64, 'audio_format': audio_format})
|
||||||
|
|
||||||
if row['audio_data']:
|
if row['audio_data']:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'audio_data': clean_str(row['audio_data']),
|
'audio_data': clean_str(row['audio_data']),
|
||||||
'audio_format': clean_str(row['audio_format']) or 'mp3'
|
'audio_format': audio_format
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({'audio_data': '', 'audio_format': row['audio_format'] or 'mp3'})
|
return jsonify({'audio_data': '', 'audio_format': audio_format})
|
||||||
|
|||||||
Reference in New Issue
Block a user