v4.3: file-based media storage + manual VACUUM maintenance

This commit is contained in:
Ashim Kumar
2026-06-12 13:24:00 +06:00
parent 965470853e
commit cc57204aff
10 changed files with 789 additions and 164 deletions

View File

@@ -1,12 +1,18 @@
# routes/project_routes.py - Project Management Routes (v4.2)
# routes/project_routes.py - Project Management Routes (v4.3)
import re
import os
import json
import base64
from flask import Blueprint, request, jsonify
from flask import Blueprint, request, jsonify, send_file
from db import get_db, vacuum_db
from db import get_db, vacuum_db, get_db_stats
from auth import login_required
from media_storage import (
save_audio, save_image, save_thumbnail,
read_file_base64, get_safe_abs_path,
delete_project_media, get_storage_usage_bytes
)
project_bp = Blueprint('project', __name__)
@@ -48,6 +54,7 @@ def list_projects():
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.thumbnail_path,
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
@@ -55,13 +62,20 @@ def list_projects():
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
WHERE c.project_id = p.id
AND ((mb.audio_data IS NOT NULL AND mb.audio_data != '')
OR (mb.audio_path IS NOT NULL AND mb.audio_path != ''))) as audio_count
FROM projects p
ORDER BY p.updated_at DESC
''')
projects = []
for row in cursor.fetchall():
# thumbnail: path থাকলে ফাইল থেকে, নইলে পুরোনো base64
thumb_data = row['thumbnail_data']
if row['thumbnail_path']:
thumb_data = read_file_base64(row['thumbnail_path'])
projects.append({
'id': row['id'],
'name': row['name'],
@@ -72,7 +86,7 @@ def list_projects():
'audio_count': row['audio_count'],
'is_published': bool(row['is_published']),
'published_at': row['published_at'],
'thumbnail_data': row['thumbnail_data'],
'thumbnail_data': thumb_data,
'thumbnail_format': row['thumbnail_format'] or 'png',
'description': row['description'] or '',
'author': row['author'] or '',
@@ -115,9 +129,8 @@ def create_project():
@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.
Get project metadata WITHOUT audio_data (lazy-loaded separately).
Images served as base64 from files (editor compatibility).
"""
db = get_db()
cursor = db.cursor()
@@ -137,8 +150,9 @@ def get_project(project_id):
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
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()
@@ -146,7 +160,8 @@ def get_project(project_id):
blocks_data = []
for block in blocks:
cursor.execute('''
SELECT * FROM block_images WHERE block_id = ? ORDER BY id
SELECT id, image_data, image_format, alt_text, position, image_path
FROM block_images WHERE block_id = ? ORDER BY id
''', (block['id'],))
images = cursor.fetchall()
@@ -158,23 +173,33 @@ def get_project(project_id):
except (json.JSONDecodeError, TypeError):
transcription = []
images_data = []
for img in images:
# path থাকলে ফাইল থেকে, নইলে পুরোনো base64
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({
'id': img['id'],
'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']),
'tts_text': clean_str(block['tts_text']),
'audio_data': '', # Empty here; loaded lazily by frontend
'audio_data': '',
'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]
'images': images_data
})
chapters_data.append({
@@ -197,15 +222,12 @@ def get_project(project_id):
@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.
"""
"""Stream audio for a single block (v4.3: from file, with base64 fallback)."""
db = get_db()
cursor = db.cursor()
cursor.execute('''
SELECT mb.audio_data, mb.audio_format
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 = ?
@@ -215,13 +237,21 @@ def get_block_audio(project_id, block_id):
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'})
# নতুন: ফাইল থেকে সরাসরি stream (Range request সাপোর্ট সহ)
if row['audio_path']:
abs_path = get_safe_abs_path(row['audio_path'])
if abs_path and os.path.exists(abs_path):
return send_file(abs_path, mimetype=f"audio/{row['audio_format'] or 'mp3'}",
conditional=True)
return jsonify({
'audio_data': clean_str(row['audio_data']),
'audio_format': clean_str(row['audio_format']) or 'mp3'
})
# পুরোনো: base64 JSON
if row['audio_data']:
return jsonify({
'audio_data': clean_str(row['audio_data']),
'audio_format': clean_str(row['audio_format']) or 'mp3'
})
return jsonify({'audio_data': '', 'audio_format': row['audio_format'] or 'mp3'})
@project_bp.route('/api/projects/<int:project_id>', methods=['PUT'])
@@ -256,7 +286,7 @@ def update_project(project_id):
@project_bp.route('/api/projects/<int:project_id>', methods=['DELETE'])
@login_required
def delete_project(project_id):
"""Delete a project and all its data."""
"""Delete a project, all DB data, AND its media folder (v4.3, no auto-vacuum)."""
db = get_db()
cursor = db.cursor()
@@ -282,7 +312,11 @@ def delete_project(project_id):
cursor.execute('DELETE FROM projects WHERE id = ?', (project_id,))
db.commit()
vacuum_db()
# v4.3: প্রজেক্টের সব মিডিয়া ফাইল মুছি
delete_project_media(project_id)
# NOTE: vacuum আর অটোমেটিক চলে না — ইউজার সেটিংস থেকে ম্যানুয়ালি করবে
return jsonify({'success': True})
@@ -290,7 +324,7 @@ def delete_project(project_id):
@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."""
"""Save all chapters and blocks. Audio/images stored as FILES (v4.3)."""
data = request.json
chapters = data.get('chapters', [])
@@ -301,6 +335,7 @@ def save_project_content(project_id):
if not cursor.fetchone():
return jsonify({'error': 'Project not found'}), 404
# পুরোনো DB রেকর্ড মুছি (ফাইলগুলো নতুন করে লেখা হবে)
cursor.execute('''
DELETE FROM block_images WHERE block_id IN (
SELECT mb.id FROM markdown_blocks mb
@@ -332,35 +367,58 @@ def save_project_content(project_id):
for block in chapter.get('blocks', []):
transcription = clean_transcription(block.get('transcription', []))
audio_format = clean_str(block.get('audio_format', 'mp3')) or 'mp3'
cursor.execute('''
INSERT INTO markdown_blocks
(chapter_id, block_order, block_type, content, tts_text, audio_data, audio_format, transcription)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
(chapter_id, block_order, block_type, content, tts_text,
audio_data, audio_path, audio_format, transcription)
VALUES (?, ?, ?, ?, ?, '', NULL, ?, ?)
''', (
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')),
audio_format,
json.dumps(transcription)
))
block_id = cursor.lastrowid
# অডিও ফাইলে সেভ করি
audio_b64 = block.get('audio_data', '')
if audio_b64:
rel_path = save_audio(project_id, block_id, audio_b64, audio_format)
if rel_path:
cursor.execute(
'UPDATE markdown_blocks SET audio_path = ? WHERE id = ?',
(rel_path, block_id)
)
# ইমেজগুলো ফাইলে সেভ করি
for img in block.get('images', []):
img_format = clean_str(img.get('format', 'png')) or 'png'
cursor.execute('''
INSERT INTO block_images (block_id, image_data, image_format, alt_text, position)
VALUES (?, ?, ?, ?, ?)
INSERT INTO block_images
(block_id, image_data, image_path, image_format, alt_text, position)
VALUES (?, '', NULL, ?, ?, ?)
''', (
block_id,
clean_str(img.get('data', '')),
clean_str(img.get('format', 'png')),
img_format,
clean_str(img.get('alt_text', '')),
clean_str(img.get('position', 'before'))
))
image_id = cursor.lastrowid
img_b64 = img.get('data', '')
if img_b64:
img_rel = save_image(project_id, image_id, img_b64, img_format)
if img_rel:
cursor.execute(
'UPDATE block_images SET image_path = ? WHERE id = ?',
(img_rel, image_id)
)
cursor.execute('''
UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?
@@ -392,7 +450,9 @@ def publish_project(project_id):
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 != ''
WHERE c.project_id = ?
AND ((mb.audio_data IS NOT NULL AND mb.audio_data != '')
OR (mb.audio_path IS NOT NULL AND mb.audio_path != ''))
''', (project_id,))
audio_count = cursor.fetchone()['cnt']
@@ -440,7 +500,7 @@ def unpublish_project(project_id):
@project_bp.route('/api/projects/<int:project_id>/thumbnail', methods=['POST'])
@login_required
def upload_thumbnail(project_id):
"""Upload a thumbnail image."""
"""Upload a thumbnail image (v4.3: stored as file)."""
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
@@ -460,19 +520,21 @@ def upload_thumbnail(project_id):
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
rel_path = save_thumbnail(project_id, img_bytes, fmt)
cursor.execute('''
UPDATE projects SET thumbnail_data = ?, thumbnail_format = ? WHERE id = ?
''', (b64, fmt, project_id))
UPDATE projects SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?
WHERE id = ?
''', (rel_path, fmt, project_id))
db.commit()
b64 = read_file_base64(rel_path)
return jsonify({
'success': True,
'thumbnail_data': b64,
@@ -483,9 +545,51 @@ def upload_thumbnail(project_id):
@project_bp.route('/api/projects/<int:project_id>/thumbnail', methods=['DELETE'])
@login_required
def delete_thumbnail(project_id):
"""Remove project thumbnail."""
"""Remove project thumbnail (DB + file)."""
db = get_db()
cursor = db.cursor()
cursor.execute('UPDATE projects SET thumbnail_data = NULL WHERE id = ?', (project_id,))
cursor.execute('SELECT thumbnail_path FROM projects WHERE id = ?', (project_id,))
row = cursor.fetchone()
if row and row['thumbnail_path']:
from media_storage import delete_file
delete_file(row['thumbnail_path'])
cursor.execute('UPDATE projects SET thumbnail_data = NULL, thumbnail_path = NULL WHERE id = ?',
(project_id,))
db.commit()
return jsonify({'success': True})
# ============================================
# v4.3: Database Maintenance (VACUUM + stats)
# ============================================
@project_bp.route('/api/maintenance/db-stats', methods=['GET'])
@login_required
def db_stats():
"""ডেটাবেস সাইজ, ফাঁকা স্পেস (%), এবং মিডিয়া স্টোরেজ সাইজ রিটার্ন করে।"""
stats = get_db_stats()
media_bytes = get_storage_usage_bytes()
stats['media_size_bytes'] = media_bytes
stats['media_size_mb'] = round(media_bytes / (1024 * 1024), 2)
return jsonify(stats)
@project_bp.route('/api/maintenance/vacuum', methods=['POST'])
@login_required
def run_vacuum():
"""ম্যানুয়ালি ডেটাবেস VACUUM চালায় (ফাঁকা স্পেস reclaim করে)।"""
before = get_db_stats()
try:
vacuum_db()
except Exception as e:
return jsonify({'error': f'VACUUM failed: {str(e)}'}), 500
after = get_db_stats()
reclaimed_mb = round(before['file_size_mb'] - after['file_size_mb'], 2)
return jsonify({
'success': True,
'message': f'VACUUM complete. Reclaimed {reclaimed_mb} MB.',
'before': before,
'after': after,
'reclaimed_mb': reclaimed_mb
})