v4.3 ui: 3D wooden bookshelf redesign, fix player cover aspect ratio, and smooth subtitle scroll

This commit is contained in:
Ashim Kumar
2026-07-03 18:43:07 +06:00
parent cf93085e22
commit 14d18fbad4
14 changed files with 1174 additions and 193 deletions

View File

@@ -11,8 +11,10 @@ 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
delete_project_media, get_storage_usage_bytes,
read_pending_thumbnail, delete_pending_thumbnail # v4.4
)
from thumbnail_generator import generate_text_cover # v4.4 backfill
project_bp = Blueprint('project', __name__)
@@ -259,7 +261,7 @@ def get_block_audio(project_id, block_id):
@project_bp.route('/api/projects/<int:project_id>', methods=['PUT'])
@login_required
def update_project(project_id):
"""Update project name."""
"""Update project name and metadata (author/description/category)."""
data = request.json
name = data.get('name', '').strip()
@@ -269,10 +271,28 @@ def update_project(project_id):
db = get_db()
cursor = db.cursor()
# মেটাডেটা ফিল্ড — পাঠানো হলেই আপডেট হবে
updates = ['name = ?']
params = [name]
if 'author' in data:
updates.append('author = ?')
params.append((data.get('author') or '').strip())
if 'description' in data:
updates.append('description = ?')
params.append((data.get('description') or '').strip())
if 'category' in data:
updates.append('category = ?')
params.append((data.get('category') or '').strip())
updates.append('updated_at = CURRENT_TIMESTAMP')
params.append(project_id)
try:
cursor.execute('''
UPDATE projects SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
''', (name, project_id))
cursor.execute(
f"UPDATE projects SET {', '.join(updates)} WHERE id = ?",
params
)
db.commit()
if cursor.rowcount == 0:
@@ -422,6 +442,32 @@ def save_project_content(project_id):
(img_rel, image_id)
)
# --- v4.4: pending auto-thumbnail commit করা (শুধু যদি প্রজেক্টে থাম্বনেইল না থাকে) ---
pending_token = clean_str(data.get('pending_thumbnail', ''))
if pending_token:
cursor.execute(
'SELECT thumbnail_path, thumbnail_data FROM projects WHERE id = ?',
(project_id,)
)
prow = cursor.fetchone()
already_has_thumb = prow and (prow['thumbnail_path'] or prow['thumbnail_data'])
if not already_has_thumb:
thumb_bytes, thumb_fmt = read_pending_thumbnail(pending_token)
if thumb_bytes:
rel_path = save_thumbnail(project_id, thumb_bytes, thumb_fmt)
if rel_path:
cursor.execute('''
UPDATE projects
SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?,
thumbnail_auto = 1
WHERE id = ?
''', (rel_path, thumb_fmt, project_id))
print(f" 🖼️ Auto-thumbnail applied to project {project_id}")
# commit বা বাতিল — যেভাবেই হোক pending ফাইল মুছে ফেলি
delete_pending_thumbnail(pending_token)
cursor.execute('''
UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?
''', (project_id,))
@@ -531,7 +577,8 @@ def upload_thumbnail(project_id):
rel_path = save_thumbnail(project_id, img_bytes, fmt)
cursor.execute('''
UPDATE projects SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?
UPDATE projects SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?,
thumbnail_auto = 0
WHERE id = ?
''', (rel_path, fmt, project_id))
db.commit()
@@ -565,12 +612,230 @@ def delete_thumbnail(project_id):
# v4.3: Database Maintenance (VACUUM + stats)
# ============================================
@project_bp.route('/api/projects/<int:project_id>/generate-thumbnail', methods=['POST'])
@login_required
def generate_single_thumbnail(project_id):
"""
একটি নির্দিষ্ট প্রজেক্টের থাম্বনেইল auto-generate/regenerate করে (v4.4)।
সোর্স: (১) প্রথম embedded image block, (২) fallback হিসেবে text-cover।
"""
import base64
from thumbnail_generator import _optimize_image_bytes
db = get_db()
cursor = db.cursor()
cursor.execute('SELECT id, name, author FROM projects WHERE id = ?', (project_id,))
proj = cursor.fetchone()
if not proj:
return jsonify({'error': 'Project not found'}), 404
thumb_bytes = None
thumb_fmt = None
source = None
# সোর্স ১: প্রথম embedded image
cursor.execute('''
SELECT bi.image_data, bi.image_path, bi.image_format
FROM block_images bi
JOIN markdown_blocks mb ON bi.block_id = mb.id
JOIN chapters c ON mb.chapter_id = c.id
WHERE c.project_id = ?
AND ((bi.image_path IS NOT NULL AND bi.image_path != '')
OR (bi.image_data IS NOT NULL AND bi.image_data != ''))
ORDER BY c.chapter_number, mb.block_order, bi.id
LIMIT 1
''', (project_id,))
img_row = cursor.fetchone()
if img_row:
raw = None
if img_row['image_path']:
b64 = read_file_base64(img_row['image_path'])
if b64:
try:
raw = base64.b64decode(b64)
except Exception:
raw = None
elif img_row['image_data']:
try:
raw = base64.b64decode(clean_str(img_row['image_data']))
except Exception:
raw = None
if raw and len(raw) > 4000:
thumb_bytes, thumb_fmt = _optimize_image_bytes(
raw, img_row['image_format'] or 'png'
)
if thumb_bytes:
source = 'image'
# সোর্স ২: text-cover fallback
if not thumb_bytes:
thumb_bytes, thumb_fmt = generate_text_cover(proj['name'], proj['author'] or '')
if thumb_bytes:
source = 'text'
if not thumb_bytes:
return jsonify({'error': 'Could not generate thumbnail (Pillow may be missing)'}), 500
rel_path = save_thumbnail(project_id, thumb_bytes, thumb_fmt)
if not rel_path:
return jsonify({'error': 'Failed to save thumbnail'}), 500
cursor.execute('''
UPDATE projects
SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?,
thumbnail_auto = 1
WHERE id = ?
''', (rel_path, thumb_fmt, project_id))
db.commit()
b64 = read_file_base64(rel_path)
return jsonify({
'success': True,
'source': source,
'thumbnail_data': b64,
'thumbnail_format': thumb_fmt,
'message': f'Thumbnail generated from {"first image" if source == "image" else "text cover"}'
})
@project_bp.route('/api/maintenance/backfill-thumbnails', methods=['POST'])
@login_required
def backfill_thumbnails():
"""
থাম্বনেইল জেনারেট করে (v4.4)।
সোর্স: (১) প্রথম embedded image block, (২) fallback হিসেবে text-cover।
force=False → শুধু থাম্বনেইল-বিহীন প্রজেক্ট।
force=True → auto-generated থাম্বনেইলও নতুন করে বানায় (user-uploaded রক্ষা পায়)।
"""
data = request.json or {}
force = bool(data.get('force', False))
db = get_db()
cursor = db.cursor()
if force:
# user-uploaded (thumbnail_auto=0 AND থাম্বনেইল আছে) ছাড়া সব
cursor.execute('''
SELECT id, name, author FROM projects
WHERE (thumbnail_path IS NULL OR thumbnail_path = '')
OR thumbnail_auto = 1
''')
else:
cursor.execute('''
SELECT id, name, author FROM projects
WHERE (thumbnail_path IS NULL OR thumbnail_path = '')
AND (thumbnail_data IS NULL OR thumbnail_data = '')
''')
projects = cursor.fetchall()
generated = 0
from_image = 0
from_text = 0
failed = 0
for proj in projects:
pid = proj['id']
thumb_bytes = None
thumb_fmt = None
# সোর্স ১: প্রথম embedded image (path বা base64)
cursor.execute('''
SELECT bi.image_data, bi.image_path, bi.image_format
FROM block_images bi
JOIN markdown_blocks mb ON bi.block_id = mb.id
JOIN chapters c ON mb.chapter_id = c.id
WHERE c.project_id = ?
AND ((bi.image_path IS NOT NULL AND bi.image_path != '')
OR (bi.image_data IS NOT NULL AND bi.image_data != ''))
ORDER BY c.chapter_number, mb.block_order, bi.id
LIMIT 1
''', (pid,))
img_row = cursor.fetchone()
if img_row:
import base64
raw = None
if img_row['image_path']:
raw = read_file_base64(img_row['image_path'])
if raw:
try:
raw = base64.b64decode(raw)
except Exception:
raw = None
elif img_row['image_data']:
try:
raw = base64.b64decode(clean_str(img_row['image_data']))
except Exception:
raw = None
if raw and len(raw) > 4000:
from thumbnail_generator import _optimize_image_bytes
thumb_bytes, thumb_fmt = _optimize_image_bytes(
raw, img_row['image_format'] or 'png'
)
if thumb_bytes:
from_image += 1
# সোর্স ২: text-cover fallback
if not thumb_bytes:
thumb_bytes, thumb_fmt = generate_text_cover(
proj['name'], proj['author'] or ''
)
if thumb_bytes:
from_text += 1
if not thumb_bytes:
failed += 1
continue
rel_path = save_thumbnail(pid, thumb_bytes, thumb_fmt)
if rel_path:
cursor.execute('''
UPDATE projects
SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?,
thumbnail_auto = 1
WHERE id = ?
''', (rel_path, thumb_fmt, pid))
generated += 1
else:
failed += 1
db.commit()
return jsonify({
'success': True,
'total_without_thumbnail': len(projects),
'generated': generated,
'from_image': from_image,
'from_text': from_text,
'failed': failed,
'force': force,
'message': f'{generated} thumbnails generated '
f'({from_image} from images, {from_text} text covers).'
})
@project_bp.route('/api/maintenance/db-stats', methods=['GET'])
@login_required
def db_stats():
"""ডেটাবেস সাইজ, ফাঁকা স্পেস (%), এবং মিডিয়া স্টোরেজ সাইজ রিটার্ন করে।"""
stats = get_db_stats()
media_bytes = get_storage_usage_bytes()
try:
stats = get_db_stats()
except Exception as e:
print(f"⚠️ get_db_stats failed: {e}")
return jsonify({'error': f'Database stats failed: {str(e)}'}), 500
# মিডিয়া স্ক্যান আলাদা try/except — ফোল্ডার বিশাল/দুর্গম হলেও stats রিটার্ন হবে
try:
media_bytes = get_storage_usage_bytes()
except Exception as e:
print(f"⚠️ get_storage_usage_bytes failed: {e}")
media_bytes = 0
stats['media_size_bytes'] = media_bytes
stats['media_size_mb'] = round(media_bytes / (1024 * 1024), 2)
return jsonify(stats)