v4.3: file-based media storage + manual VACUUM maintenance
This commit is contained in:
170
media_storage.py
Normal file
170
media_storage.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# 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
|
||||
|
||||
|
||||
# ============================================
|
||||
# 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
|
||||
Reference in New Issue
Block a user