# routes/public_routes.py - Public (No Auth) Routes for Published Audiobooks (v4.3) import re import os import json from flask import Blueprint, jsonify, send_from_directory, send_file, abort from db import get_db from media_storage import get_safe_abs_path, read_file_base64 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/') 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) 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.thumbnail_path, 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(): thumb_data = row['thumbnail_data'] if row['thumbnail_path']: thumb_data = read_file_base64(row['thumbnail_path']) books.append({ 'id': row['id'], 'name': row['name'], 'description': row['description'] or '', 'author': row['author'] or '', 'category': row['category'] or '', 'thumbnail_data': thumb_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/', methods=['GET']) def get_published_book(project_id): """Get book metadata WITHOUT audio_data (lazy-loaded separately).""" 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, audio_path, transcription, ((audio_data IS NOT NULL AND audio_data != '') OR (audio_path IS NOT NULL AND audio_path != '')) 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 image_data, image_format, alt_text, position, image_path 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 = [] images_data = [] for img in images: img_data = '' if img['image_path']: img_data = read_file_base64(img['image_path']) elif img['image_data']: img_data = clean_str(img['image_data']) images_data.append({ 'data': img_data, 'format': clean_str(img['image_format']) or 'png', 'alt_text': clean_str(img['alt_text']), 'position': clean_str(img['position']) or 'before' }) 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': '', 'audio_format': clean_str(block['audio_format']) or 'mp3', 'has_audio': bool(block['has_audio']), 'transcription': transcription, 'images': images_data }) chapters_data.append({ 'id': chapter['id'], 'chapter_number': chapter['chapter_number'], 'title': clean_str(chapter['title']), 'blocks': blocks_data }) thumb_data = project['thumbnail_data'] if project['thumbnail_path']: thumb_data = read_file_base64(project['thumbnail_path']) 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': thumb_data, 'thumbnail_format': project['thumbnail_format'] or 'png', 'chapters': chapters_data }) @public_bp.route('/api/public/books//audio/', methods=['GET']) def get_public_block_audio(project_id, block_id): """Return audio as base64 JSON for a published book block (v4.3).""" db = get_db() cursor = db.cursor() 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_path, 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 audio_format = clean_str(row['audio_format']) or 'mp3' 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}) if row['audio_data']: return jsonify({ 'audio_data': clean_str(row['audio_data']), 'audio_format': audio_format }) return jsonify({'audio_data': '', 'audio_format': audio_format})