Audiobook Maker Pro v4.2 — production ready
This commit is contained in:
417
routes/project_routes.py
Normal file
417
routes/project_routes.py
Normal file
@@ -0,0 +1,417 @@
|
||||
# routes/project_routes.py - Project Management Routes (v4.2)
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
@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 a project with all its chapters and blocks."""
|
||||
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()
|
||||
|
||||
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': json.loads(block['transcription']) if block['transcription'] else [],
|
||||
'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
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'id': project['id'],
|
||||
'name': project['name'],
|
||||
'created_at': project['created_at'],
|
||||
'updated_at': project['updated_at'],
|
||||
'chapters': chapters_data
|
||||
})
|
||||
|
||||
|
||||
@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'],
|
||||
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/<int:project_id>/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/<int:project_id>/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/<int:project_id>/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/<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})
|
||||
Reference in New Issue
Block a user