Files
audiobook-maker-pro-v4.2/routes/project_routes.py

492 lines
16 KiB
Python

# routes/project_routes.py - Project Management Routes (v4.2)
import re
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__)
# ============================================
# 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
# ============================================
@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 project metadata WITHOUT audio_data.
Audio is loaded lazily via /api/projects/<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 = ?', (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 id, block_order, block_type, content, tts_text,
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']),
'tts_text': clean_str(block['tts_text']),
'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': [{
'id': img['id'],
'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']),
'voice': clean_str(chapter['voice']),
'blocks': blocks_data
})
return jsonify({
'id': project['id'],
'name': clean_str(project['name']),
'created_at': clean_str(project['created_at']),
'updated_at': clean_str(project['updated_at']),
'chapters': chapters_data
})
@project_bp.route('/api/projects/<int:project_id>/audio/<int:block_id>', methods=['GET'])
@login_required
def get_block_audio(project_id, block_id):
"""
Return audio_data (base64) for a single block.
Used by the frontend to lazy-load audio after metadata is loaded.
"""
db = get_db()
cursor = db.cursor()
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'
})
@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'],
clean_str(chapter.get('title', 'Section')),
clean_str(chapter.get('voice', 'af_heart'))
))
chapter_id = cursor.lastrowid
for block in chapter.get('blocks', []):
transcription = clean_transcription(block.get('transcription', []))
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'],
clean_str(block.get('block_type', 'paragraph')),
clean_str(block.get('content', '')),
clean_str(block.get('tts_text', '')),
clean_str(block.get('audio_data', '')),
clean_str(block.get('audio_format', 'mp3')),
json.dumps(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,
clean_str(img.get('data', '')),
clean_str(img.get('format', 'png')),
clean_str(img.get('alt_text', '')),
clean_str(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 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
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."""
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 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."""
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})