297 lines
9.8 KiB
Python
297 lines
9.8 KiB
Python
import re
|
||
import os
|
||
import time
|
||
import asyncio
|
||
import traceback
|
||
import aiofiles
|
||
from pathlib import Path
|
||
from loguru import logger
|
||
|
||
from app.services.storage import storage_service
|
||
|
||
|
||
def sanitize_filename(filename: str) -> str:
|
||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||
if len(safe_name) > 100:
|
||
ext = Path(safe_name).suffix
|
||
safe_name = safe_name[:100 - len(ext)] + ext
|
||
return safe_name
|
||
|
||
|
||
def _extract_display_name(storage_name: str) -> str:
|
||
"""从存储文件名中提取显示名(去掉时间戳前缀)"""
|
||
if '_' in storage_name:
|
||
parts = storage_name.split('_', 1)
|
||
if parts[0].isdigit():
|
||
return parts[1]
|
||
return storage_name
|
||
|
||
|
||
async def _process_and_upload(temp_file_path: str, original_filename: str, content_type: str, user_id: str) -> str:
|
||
"""Strip multipart headers and upload to Supabase, return storage_path"""
|
||
try:
|
||
logger.info(f"Processing raw upload: {temp_file_path} for user {user_id}")
|
||
|
||
file_size = os.path.getsize(temp_file_path)
|
||
|
||
with open(temp_file_path, 'rb') as f:
|
||
head = f.read(4096)
|
||
|
||
first_line_end = head.find(b'\r\n')
|
||
if first_line_end == -1:
|
||
raise Exception("Could not find boundary in multipart body")
|
||
|
||
boundary = head[:first_line_end]
|
||
logger.info(f"Detected boundary: {boundary}")
|
||
|
||
header_end = head.find(b'\r\n\r\n')
|
||
if header_end == -1:
|
||
raise Exception("Could not find end of multipart headers")
|
||
|
||
start_offset = header_end + 4
|
||
logger.info(f"Video data starts at offset: {start_offset}")
|
||
|
||
f.seek(max(0, file_size - 200))
|
||
tail = f.read()
|
||
|
||
last_boundary_pos = tail.rfind(boundary)
|
||
if last_boundary_pos != -1:
|
||
end_offset = (max(0, file_size - 200) + last_boundary_pos) - 2
|
||
else:
|
||
logger.warning("Could not find closing boundary, assuming EOF")
|
||
end_offset = file_size
|
||
|
||
logger.info(f"Video data ends at offset: {end_offset}. Total video size: {end_offset - start_offset}")
|
||
|
||
video_path = temp_file_path + "_video.mp4"
|
||
with open(temp_file_path, 'rb') as src, open(video_path, 'wb') as dst:
|
||
src.seek(start_offset)
|
||
bytes_to_copy = end_offset - start_offset
|
||
copied = 0
|
||
while copied < bytes_to_copy:
|
||
chunk_size = min(1024 * 1024 * 10, bytes_to_copy - copied)
|
||
chunk = src.read(chunk_size)
|
||
if not chunk:
|
||
break
|
||
dst.write(chunk)
|
||
copied += len(chunk)
|
||
|
||
logger.info(f"Extracted video content to {video_path}")
|
||
|
||
timestamp = int(time.time())
|
||
safe_name = re.sub(r'[^a-zA-Z0-9._-]', '', original_filename)
|
||
storage_path = f"{user_id}/{timestamp}_{safe_name}"
|
||
|
||
with open(video_path, 'rb') as f:
|
||
file_content = f.read()
|
||
await storage_service.upload_file(
|
||
bucket=storage_service.BUCKET_MATERIALS,
|
||
path=storage_path,
|
||
file_data=file_content,
|
||
content_type=content_type
|
||
)
|
||
|
||
logger.info(f"Upload to Supabase complete: {storage_path}")
|
||
|
||
os.remove(temp_file_path)
|
||
os.remove(video_path)
|
||
|
||
return storage_path
|
||
|
||
except Exception as e:
|
||
logger.error(f"Background upload processing failed: {e}\n{traceback.format_exc()}")
|
||
raise
|
||
|
||
|
||
async def upload_material(request, user_id: str) -> dict:
|
||
"""接收流式上传并存储到 Supabase,返回素材信息"""
|
||
filename = "unknown_video.mp4"
|
||
content_type = "video/mp4"
|
||
|
||
timestamp = int(time.time())
|
||
temp_filename = f"upload_{timestamp}.raw"
|
||
temp_path = os.path.join("/tmp", temp_filename)
|
||
if os.name == 'nt':
|
||
temp_path = f"d:/tmp/{temp_filename}"
|
||
os.makedirs("d:/tmp", exist_ok=True)
|
||
|
||
try:
|
||
total_size = 0
|
||
last_log = 0
|
||
|
||
async with aiofiles.open(temp_path, 'wb') as f:
|
||
async for chunk in request.stream():
|
||
await f.write(chunk)
|
||
total_size += len(chunk)
|
||
|
||
if total_size - last_log > 20 * 1024 * 1024:
|
||
logger.info(f"Receiving stream... Processed {total_size / (1024*1024):.2f} MB")
|
||
last_log = total_size
|
||
|
||
logger.info(f"Stream reception complete. Total size: {total_size} bytes. Saved to {temp_path}")
|
||
|
||
if total_size == 0:
|
||
raise ValueError("Received empty body")
|
||
|
||
with open(temp_path, 'rb') as f:
|
||
head = f.read(4096).decode('utf-8', errors='ignore')
|
||
match = re.search(r'filename="([^"]+)"', head)
|
||
if match:
|
||
filename = match.group(1)
|
||
logger.info(f"Extracted filename from body: {filename}")
|
||
|
||
storage_path = await _process_and_upload(temp_path, filename, content_type, user_id)
|
||
|
||
signed_url = await storage_service.get_signed_url(
|
||
bucket=storage_service.BUCKET_MATERIALS,
|
||
path=storage_path
|
||
)
|
||
|
||
size_mb = total_size / (1024 * 1024)
|
||
display_name = _extract_display_name(storage_path.split('/')[-1])
|
||
|
||
return {
|
||
"id": storage_path,
|
||
"name": display_name,
|
||
"path": signed_url,
|
||
"size_mb": size_mb,
|
||
"type": "video"
|
||
}
|
||
|
||
except Exception as e:
|
||
error_msg = f"Streaming upload failed: {str(e)}"
|
||
detail_msg = f"Exception: {repr(e)}\nArgs: {e.args}\n{traceback.format_exc()}"
|
||
logger.error(error_msg + "\n" + detail_msg)
|
||
|
||
try:
|
||
with open("debug_upload.log", "a") as logf:
|
||
logf.write(f"\n--- Error at {time.ctime()} ---\n")
|
||
logf.write(detail_msg)
|
||
logf.write("\n-----------------------------\n")
|
||
except:
|
||
pass
|
||
|
||
if os.path.exists(temp_path):
|
||
try:
|
||
os.remove(temp_path)
|
||
except:
|
||
pass
|
||
raise
|
||
|
||
|
||
async def list_materials(user_id: str) -> list[dict]:
|
||
"""列出用户的所有素材"""
|
||
try:
|
||
files_obj = await storage_service.list_files(
|
||
bucket=storage_service.BUCKET_MATERIALS,
|
||
path=user_id
|
||
)
|
||
semaphore = asyncio.Semaphore(8)
|
||
|
||
async def build_item(f):
|
||
name = f.get('name')
|
||
if not name or name == '.emptyFolderPlaceholder':
|
||
return None
|
||
display_name = _extract_display_name(name)
|
||
full_path = f"{user_id}/{name}"
|
||
async with semaphore:
|
||
signed_url = await storage_service.get_signed_url(
|
||
bucket=storage_service.BUCKET_MATERIALS,
|
||
path=full_path
|
||
)
|
||
metadata = f.get('metadata', {})
|
||
size = metadata.get('size', 0)
|
||
created_at_str = f.get('created_at', '')
|
||
created_at = 0
|
||
if created_at_str:
|
||
from datetime import datetime
|
||
try:
|
||
dt = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
|
||
created_at = int(dt.timestamp())
|
||
except Exception:
|
||
pass
|
||
return {
|
||
"id": full_path,
|
||
"name": display_name,
|
||
"path": signed_url,
|
||
"size_mb": size / (1024 * 1024),
|
||
"type": "video",
|
||
"created_at": created_at
|
||
}
|
||
|
||
tasks = [build_item(f) for f in files_obj]
|
||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
|
||
materials = []
|
||
for item in results:
|
||
if not item:
|
||
continue
|
||
if isinstance(item, Exception):
|
||
logger.warning(f"Material signed url build failed: {item}")
|
||
continue
|
||
materials.append(item)
|
||
materials.sort(key=lambda x: x['id'], reverse=True)
|
||
return materials
|
||
except Exception as e:
|
||
logger.error(f"List materials failed: {e}")
|
||
return []
|
||
|
||
|
||
async def delete_material(material_id: str, user_id: str) -> None:
|
||
"""删除素材"""
|
||
if not material_id.startswith(f"{user_id}/"):
|
||
raise PermissionError("无权删除此素材")
|
||
await storage_service.delete_file(
|
||
bucket=storage_service.BUCKET_MATERIALS,
|
||
path=material_id
|
||
)
|
||
|
||
|
||
async def rename_material(material_id: str, new_name_raw: str, user_id: str) -> dict:
|
||
"""重命名素材,返回更新后的素材信息"""
|
||
if not material_id.startswith(f"{user_id}/"):
|
||
raise PermissionError("无权重命名此素材")
|
||
|
||
new_name_raw = new_name_raw.strip() if new_name_raw else ""
|
||
if not new_name_raw:
|
||
raise ValueError("新名称不能为空")
|
||
|
||
old_name = material_id.split("/", 1)[1]
|
||
old_ext = Path(old_name).suffix
|
||
base_name = Path(new_name_raw).stem if Path(new_name_raw).suffix else new_name_raw
|
||
safe_base = sanitize_filename(base_name).strip()
|
||
if not safe_base:
|
||
raise ValueError("新名称无效")
|
||
|
||
new_filename = f"{safe_base}{old_ext}"
|
||
|
||
prefix = None
|
||
if "_" in old_name:
|
||
maybe_prefix, _ = old_name.split("_", 1)
|
||
if maybe_prefix.isdigit():
|
||
prefix = maybe_prefix
|
||
if prefix:
|
||
new_filename = f"{prefix}_{new_filename}"
|
||
|
||
new_path = f"{user_id}/{new_filename}"
|
||
|
||
if new_path != material_id:
|
||
await storage_service.move_file(
|
||
bucket=storage_service.BUCKET_MATERIALS,
|
||
from_path=material_id,
|
||
to_path=new_path
|
||
)
|
||
|
||
signed_url = await storage_service.get_signed_url(
|
||
bucket=storage_service.BUCKET_MATERIALS,
|
||
path=new_path
|
||
)
|
||
|
||
display_name = _extract_display_name(new_filename)
|
||
|
||
return {
|
||
"id": new_path,
|
||
"name": display_name,
|
||
"path": signed_url,
|
||
}
|