212 lines
7.2 KiB
Python
212 lines
7.2 KiB
Python
# routes/public_routes.py - Public (No Auth) Routes for Published Audiobooks
|
|
|
|
import re
|
|
import json
|
|
from flask import Blueprint, jsonify, send_from_directory, abort
|
|
|
|
from db import get_db
|
|
|
|
public_bp = Blueprint('public', __name__)
|
|
|
|
|
|
# ============================================
|
|
# Helpers
|
|
# ============================================
|
|
|
|
_CONTROL_CHAR_RE = re.compile(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]')
|
|
|
|
|
|
def clean_str(s):
|
|
if s is None:
|
|
return ''
|
|
if not isinstance(s, str):
|
|
s = str(s)
|
|
return _CONTROL_CHAR_RE.sub('', s)
|
|
|
|
|
|
def clean_transcription(transcription):
|
|
if isinstance(transcription, list):
|
|
for t in transcription:
|
|
if isinstance(t, dict) and 'word' in t:
|
|
t['word'] = clean_str(t.get('word', ''))
|
|
return transcription
|
|
|
|
|
|
# ============================================
|
|
# Routes
|
|
# ============================================
|
|
|
|
@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 book metadata WITHOUT audio_data.
|
|
Audio is loaded lazily via /api/public/books/<id>/audio/<block_id>.
|
|
This keeps the response small (<1 MB) and avoids proxy truncation issues.
|
|
"""
|
|
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 id, block_order, block_type, content, audio_format, transcription,
|
|
(audio_data IS NOT NULL AND audio_data != '') as has_audio
|
|
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()
|
|
|
|
transcription = []
|
|
if block['transcription']:
|
|
try:
|
|
transcription = json.loads(block['transcription'])
|
|
transcription = clean_transcription(transcription)
|
|
except (json.JSONDecodeError, TypeError):
|
|
transcription = []
|
|
|
|
blocks_data.append({
|
|
'id': block['id'],
|
|
'block_order': block['block_order'],
|
|
'block_type': clean_str(block['block_type']),
|
|
'content': clean_str(block['content']),
|
|
'audio_data': '', # Empty here; loaded lazily by frontend
|
|
'audio_format': clean_str(block['audio_format']) or 'mp3',
|
|
'has_audio': bool(block['has_audio']),
|
|
'transcription': transcription,
|
|
'images': [{
|
|
'data': clean_str(img['image_data']),
|
|
'format': clean_str(img['image_format']) or 'png',
|
|
'alt_text': clean_str(img['alt_text']),
|
|
'position': clean_str(img['position']) or 'before'
|
|
} for img in images]
|
|
})
|
|
|
|
chapters_data.append({
|
|
'id': chapter['id'],
|
|
'chapter_number': chapter['chapter_number'],
|
|
'title': clean_str(chapter['title']),
|
|
'blocks': blocks_data
|
|
})
|
|
|
|
return jsonify({
|
|
'id': project['id'],
|
|
'name': clean_str(project['name']),
|
|
'description': clean_str(project['description']) if project['description'] else '',
|
|
'author': clean_str(project['author']) if project['author'] else '',
|
|
'thumbnail_data': project['thumbnail_data'],
|
|
'thumbnail_format': project['thumbnail_format'] or 'png',
|
|
'chapters': chapters_data
|
|
})
|
|
|
|
|
|
@public_bp.route('/api/public/books/<int:project_id>/audio/<int:block_id>', methods=['GET'])
|
|
def get_public_block_audio(project_id, block_id):
|
|
"""
|
|
Return audio_data (base64) for a single block in a published book.
|
|
No auth required since the book is published publicly.
|
|
"""
|
|
db = get_db()
|
|
cursor = db.cursor()
|
|
|
|
# Verify project is published
|
|
cursor.execute('SELECT is_published FROM projects WHERE id = ?', (project_id,))
|
|
project = cursor.fetchone()
|
|
if not project or not project['is_published']:
|
|
return jsonify({'error': 'Book not found or not published'}), 404
|
|
|
|
cursor.execute('''
|
|
SELECT mb.audio_data, mb.audio_format
|
|
FROM markdown_blocks mb
|
|
JOIN chapters c ON mb.chapter_id = c.id
|
|
WHERE mb.id = ? AND c.project_id = ?
|
|
''', (block_id, project_id))
|
|
row = cursor.fetchone()
|
|
|
|
if not row:
|
|
return jsonify({'error': 'Block not found'}), 404
|
|
|
|
if not row['audio_data']:
|
|
return jsonify({'audio_data': '', 'audio_format': row['audio_format'] or 'mp3'})
|
|
|
|
return jsonify({
|
|
'audio_data': clean_str(row['audio_data']),
|
|
'audio_format': clean_str(row['audio_format']) or 'mp3'
|
|
})
|