"""API v3 文件下载服务。 下载用户提供的图片/视频/音频到本地存储。 """ import base64 import logging import os import re import uuid from datetime import datetime from urllib.parse import urlparse import httpx from app.config import settings logger = logging.getLogger("videogen") def _get_date_str() -> str: """获取当前日期字符串。""" return datetime.now().strftime("%Y%m%d") def _get_uploads_dir() -> str: """获取上传文件存储目录。""" upload_dir = os.path.join(os.path.dirname(settings.STORAGE_LOCAL_PATH), "uploads", "api") os.makedirs(upload_dir, exist_ok=True) return upload_dir async def download_file_from_url(url: str, sub_dir: str = "") -> str: """从 URL 下载文件到本地。 Args: url: 文件 URL sub_dir: 子目录(如 images/videos/audios) Returns: 相对路径: /uploads/api/{sub_dir}/{date}/{filename} """ upload_dir = _get_uploads_dir() date_str = _get_date_str() # 创建目标目录 dest_dir = os.path.join(upload_dir, sub_dir, date_str) os.makedirs(dest_dir, exist_ok=True) # 从 URL 提取扩展名 parsed = urlparse(url) path = parsed.path ext = os.path.splitext(path)[1].lower() if not ext or len(ext) > 10: ext = ".bin" # 默认扩展名 # 生成唯一文件名 filename = f"{uuid.uuid4().hex}{ext}" dest_path = os.path.join(dest_dir, filename) # 下载文件 async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client: async with client.stream("GET", url) as response: response.raise_for_status() with open(dest_path, "wb") as f: async for chunk in response.aiter_bytes(chunk_size=8192): f.write(chunk) # 返回相对路径 rel_path = f"/uploads/api/{sub_dir}/{date_str}/{filename}" logger.info("Downloaded file: %s -> %s", url[:80], rel_path) return rel_path def save_base64_file(data: str, sub_dir: str = "") -> str: """保存 Base64 编码的文件到本地。 Args: data: Base64 编码的数据(可包含 data:...;base64, 前缀) sub_dir: 子目录 Returns: 相对路径: /uploads/api/{sub_dir}/{date}/{filename} """ upload_dir = _get_uploads_dir() date_str = _get_date_str() # 创建目标目录 dest_dir = os.path.join(upload_dir, sub_dir, date_str) os.makedirs(dest_dir, exist_ok=True) # 解析 Base64 数据 if "," in data: header, b64_data = data.split(",", 1) # 从 header 提取 MIME 类型 mime_match = re.search(r"data:([^;]+)", header) mime_type = mime_match.group(1) if mime_match else "application/octet-stream" # 根据 MIME 类型确定扩展名 ext_map = { "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "image/gif": ".gif", "video/mp4": ".mp4", "video/webm": ".webm", "audio/mpeg": ".mp3", "audio/wav": ".wav", "audio/ogg": ".ogg", } ext = ext_map.get(mime_type, ".bin") else: b64_data = data ext = ".bin" # 解码并保存 try: file_data = base64.b64decode(b64_data) except Exception as exc: raise ValueError(f"Base64 解码失败: {exc}") filename = f"{uuid.uuid4().hex}{ext}" dest_path = os.path.join(dest_dir, filename) with open(dest_path, "wb") as f: f.write(file_data) rel_path = f"/uploads/api/{sub_dir}/{date_str}/{filename}" logger.info("Saved base64 file: %s (%d bytes)", rel_path, len(file_data)) return rel_path async def process_media_url(url: str, media_type: str) -> str: """处理媒体 URL:下载到本地或保存 Base64。 Args: url: URL 或 Base64 数据 media_type: image / video / audio Returns: 相对路径: /uploads/api/{type}/{date}/{filename} """ sub_dir = {"image": "images", "video": "videos", "audio": "audios"}.get(media_type, "files") # 判断是 Base64 还是 URL if url.startswith("data:"): return save_base64_file(url, sub_dir) else: return await download_file_from_url(url, sub_dir)