222 lines
7.8 KiB
Python
222 lines
7.8 KiB
Python
# media_storage.py - File-based media storage manager (v4.3)
|
|
|
|
import os
|
|
import base64
|
|
import shutil
|
|
|
|
from config import MEDIA_STORAGE_DIR
|
|
|
|
|
|
def _ensure_dir(path):
|
|
os.makedirs(path, exist_ok=True)
|
|
|
|
|
|
def get_project_dir(project_id):
|
|
"""প্রজেক্টের base ডিরেক্টরি (absolute path) রিটার্ন করে।"""
|
|
return os.path.join(MEDIA_STORAGE_DIR, f'project_{int(project_id)}')
|
|
|
|
|
|
def get_audio_dir(project_id):
|
|
return os.path.join(get_project_dir(project_id), 'audio')
|
|
|
|
|
|
def get_images_dir(project_id):
|
|
return os.path.join(get_project_dir(project_id), 'images')
|
|
|
|
|
|
# ============================================
|
|
# Save operations (base64 string → file)
|
|
# ============================================
|
|
|
|
def save_audio(project_id, block_id, audio_base64, audio_format='mp3'):
|
|
"""
|
|
Audio ফাইল সেভ করে relative path রিটার্ন করে।
|
|
relative path ডেটাবেসে জমা হবে, যেমন: project_5/audio/block_123.mp3
|
|
"""
|
|
if not audio_base64:
|
|
return None
|
|
audio_dir = get_audio_dir(project_id)
|
|
_ensure_dir(audio_dir)
|
|
fmt = (audio_format or 'mp3').lower()
|
|
filename = f'block_{int(block_id)}.{fmt}'
|
|
abs_path = os.path.join(audio_dir, filename)
|
|
with open(abs_path, 'wb') as f:
|
|
f.write(base64.b64decode(audio_base64))
|
|
return f'project_{int(project_id)}/audio/{filename}'
|
|
|
|
|
|
def save_image(project_id, image_id, image_base64, image_format='png'):
|
|
"""Image ফাইল সেভ করে relative path রিটার্ন করে।"""
|
|
if not image_base64:
|
|
return None
|
|
images_dir = get_images_dir(project_id)
|
|
_ensure_dir(images_dir)
|
|
fmt = (image_format or 'png').lower()
|
|
filename = f'img_{int(image_id)}.{fmt}'
|
|
abs_path = os.path.join(images_dir, filename)
|
|
with open(abs_path, 'wb') as f:
|
|
f.write(base64.b64decode(image_base64))
|
|
return f'project_{int(project_id)}/images/{filename}'
|
|
|
|
|
|
def save_thumbnail(project_id, image_bytes, image_format='png'):
|
|
"""Thumbnail সেভ করে relative path রিটার্ন করে (raw bytes নেয়)।"""
|
|
if not image_bytes:
|
|
return None
|
|
proj_dir = get_project_dir(project_id)
|
|
_ensure_dir(proj_dir)
|
|
fmt = (image_format or 'png').lower()
|
|
filename = f'thumbnail.{fmt}'
|
|
abs_path = os.path.join(proj_dir, filename)
|
|
# পুরোনো thumbnail অন্য ফরম্যাটে থাকলে মুছে দিই
|
|
for old_ext in ('png', 'jpeg', 'jpg', 'webp', 'gif'):
|
|
old = os.path.join(proj_dir, f'thumbnail.{old_ext}')
|
|
if old_ext != fmt and os.path.exists(old):
|
|
try:
|
|
os.remove(old)
|
|
except OSError:
|
|
pass
|
|
with open(abs_path, 'wb') as f:
|
|
f.write(image_bytes)
|
|
return f'project_{int(project_id)}/{filename}'
|
|
|
|
|
|
# ============================================
|
|
# Read operations (file → bytes / base64)
|
|
# ============================================
|
|
|
|
def _safe_abs_path(relative_path):
|
|
"""
|
|
relative path কে absolute path-এ রূপান্তর করে এবং নিশ্চিত করে
|
|
যে এটা MEDIA_STORAGE_DIR-এর বাইরে যাচ্ছে না (path traversal রোধ)।
|
|
"""
|
|
if not relative_path:
|
|
return None
|
|
base = os.path.realpath(MEDIA_STORAGE_DIR)
|
|
abs_path = os.path.realpath(os.path.join(base, relative_path))
|
|
if not abs_path.startswith(base + os.sep) and abs_path != base:
|
|
return None # নিরাপত্তা লঙ্ঘন
|
|
return abs_path
|
|
|
|
|
|
def read_file_bytes(relative_path):
|
|
"""ফাইলের raw bytes রিটার্ন করে। না থাকলে None।"""
|
|
abs_path = _safe_abs_path(relative_path)
|
|
if not abs_path or not os.path.exists(abs_path):
|
|
return None
|
|
with open(abs_path, 'rb') as f:
|
|
return f.read()
|
|
|
|
|
|
def read_file_base64(relative_path):
|
|
"""ফাইল base64 স্ট্রিং হিসেবে রিটার্ন করে। না থাকলে খালি স্ট্রিং।"""
|
|
data = read_file_bytes(relative_path)
|
|
if data is None:
|
|
return ''
|
|
return base64.b64encode(data).decode('utf-8')
|
|
|
|
|
|
def get_safe_abs_path(relative_path):
|
|
"""Flask send_file-এর জন্য নিরাপদ absolute path।"""
|
|
return _safe_abs_path(relative_path)
|
|
|
|
|
|
# ============================================
|
|
# Storage stats (v4.3)
|
|
# ============================================
|
|
|
|
def get_storage_usage_bytes():
|
|
"""media-storage ফোল্ডারের মোট সাইজ (bytes) রিটার্ন করে।"""
|
|
total = 0
|
|
if not os.path.exists(MEDIA_STORAGE_DIR):
|
|
return 0
|
|
for root, _, files in os.walk(MEDIA_STORAGE_DIR):
|
|
for name in files:
|
|
fp = os.path.join(root, name)
|
|
try:
|
|
total += os.path.getsize(fp)
|
|
except OSError:
|
|
pass
|
|
return total
|
|
|
|
|
|
# ============================================
|
|
# Pending thumbnails (v4.4) — আপলোডের সময় জেনারেট, সেভের সময় commit
|
|
# ============================================
|
|
|
|
import uuid as _uuid
|
|
|
|
def _pending_thumbs_dir():
|
|
return os.path.join(MEDIA_STORAGE_DIR, '_pending_thumbs')
|
|
|
|
|
|
def save_pending_thumbnail(image_bytes, image_format='jpeg'):
|
|
"""
|
|
অস্থায়ী থাম্বনেইল সেভ করে একটা token রিটার্ন করে।
|
|
প্রজেক্ট এখনো তৈরি হয়নি বলে project_id ছাড়াই রাখা হয়।
|
|
"""
|
|
if not image_bytes:
|
|
return None
|
|
d = _pending_thumbs_dir()
|
|
_ensure_dir(d)
|
|
fmt = (image_format or 'jpeg').lower()
|
|
token = _uuid.uuid4().hex
|
|
filename = f'{token}.{fmt}'
|
|
with open(os.path.join(d, filename), 'wb') as f:
|
|
f.write(image_bytes)
|
|
return filename # token = filename
|
|
|
|
|
|
def read_pending_thumbnail(token):
|
|
"""Pending thumbnail-এর (bytes, format) ফেরত দেয়। না থাকলে (None, None)।"""
|
|
if not token or '/' in token or '\\' in token or '..' in token:
|
|
return None, None
|
|
path = os.path.join(_pending_thumbs_dir(), token)
|
|
if not os.path.exists(path):
|
|
return None, None
|
|
fmt = token.rsplit('.', 1)[-1] if '.' in token else 'jpeg'
|
|
with open(path, 'rb') as f:
|
|
return f.read(), fmt
|
|
|
|
|
|
def delete_pending_thumbnail(token):
|
|
"""Pending thumbnail মুছে দেয় (commit বা বাতিলের পর)।"""
|
|
if not token or '/' in token or '\\' in token or '..' in token:
|
|
return
|
|
path = os.path.join(_pending_thumbs_dir(), token)
|
|
if os.path.exists(path):
|
|
try:
|
|
os.remove(path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
# ============================================
|
|
# Delete operations
|
|
# ============================================
|
|
|
|
def delete_project_media(project_id):
|
|
"""প্রজেক্টের পুরো media ফোল্ডার মুছে দেয় (প্রজেক্ট ডিলিটের সময়)।"""
|
|
proj_dir = get_project_dir(project_id)
|
|
if os.path.exists(proj_dir):
|
|
try:
|
|
shutil.rmtree(proj_dir)
|
|
print(f"🗑️ Deleted media folder: {proj_dir}")
|
|
return True
|
|
except OSError as e:
|
|
print(f"⚠️ Failed to delete media folder {proj_dir}: {e}")
|
|
return False
|
|
return False
|
|
|
|
|
|
def delete_file(relative_path):
|
|
"""একটা নির্দিষ্ট ফাইল মুছে দেয়।"""
|
|
abs_path = _safe_abs_path(relative_path)
|
|
if abs_path and os.path.exists(abs_path):
|
|
try:
|
|
os.remove(abs_path)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
return False
|