# routes/project_routes.py - Project Management Routes (v4.2) import json import base64 from flask import Blueprint, request, jsonify, Response, stream_with_context 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/', methods=['GET']) @login_required def get_project(project_id): """ Get a project with all its chapters and blocks. Streamed response: large projects (with many audio blocks) can produce 10-50 MB of JSON. We stream it in chunks so that the reverse proxy (Traefik in Coolify) doesn't buffer the entire payload and truncate it. """ 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() # Safely parse transcription (might be NULL, empty, or malformed) transcription = [] if block['transcription']: try: transcription = json.loads(block['transcription']) except (json.JSONDecodeError, TypeError): transcription = [] 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': transcription, '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 }) response_data = { 'id': project['id'], 'name': project['name'], 'created_at': project['created_at'], 'updated_at': project['updated_at'], 'chapters': chapters_data } # Stream the JSON in chunks. ensure_ascii=False keeps Unicode (e.g. Bangla) # compact and avoids the JSON ballooning to 2-3x its size. def generate(): json_str = json.dumps(response_data, ensure_ascii=False) chunk_size = 64 * 1024 # 64 KB per chunk for i in range(0, len(json_str), chunk_size): yield json_str[i:i + chunk_size] return Response( stream_with_context(generate()), mimetype='application/json; charset=utf-8', headers={ 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' # Tell Nginx/Traefik: don't buffer this response } ) @project_bp.route('/api/projects/', 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/', 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//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//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//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//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//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})