add ffmpeg
This commit is contained in:
parent
d46d322210
commit
20954291c0
30
Dockerfile
30
Dockerfile
@ -1,26 +1,30 @@
|
||||
# Use Python 3.11 slim image as base
|
||||
FROM python:3.11-slim
|
||||
|
||||
# ---- System setup ----
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
FFMPEG_BIN=/usr/bin/ffmpeg \
|
||||
FFPROBE_BIN=/usr/bin/ffprobe
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install system dependencies
|
||||
# Install system dependencies (ffmpeg + curl for healthcheck, build tools for pip)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
ffmpeg \
|
||||
curl \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
COPY requirements.txt ./requirements.txt
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
RUN python -m pip install --upgrade pip \
|
||||
&& pip install -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
@ -28,9 +32,9 @@ COPY . .
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
# Health check (uses curl we installed above)
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
CMD curl -fsS http://localhost:8000/health || exit 1
|
||||
|
||||
# Run the application
|
||||
# Run the application (dev-friendly: --reload; change to gunicorn/uvicorn w/o reload for prod)
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
@ -2,7 +2,9 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
fastapi-app:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
@ -12,6 +14,8 @@ services:
|
||||
environment:
|
||||
- PYTHONPATH=/app
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- FFMPEG_BIN=/usr/bin/ffmpeg
|
||||
- FFPROBE_BIN=/usr/bin/ffprobe
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
@ -19,7 +23,7 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
84
scraper.py
84
scraper.py
@ -1,9 +1,15 @@
|
||||
# --- imports ---
|
||||
from sqlalchemy.orm import Session
|
||||
from database import InstagramAccount, InstagramPost
|
||||
from utils.boxapi_client import get_media, BoxAPIError
|
||||
from utils.parser_fast import parse_boxapi_items_fast
|
||||
from utils.seen_store import filter_unseen, mark_seen
|
||||
from utils.s3_uploader import upload_media_from_url
|
||||
# use optimized uploaders; keep the generic fallback as well
|
||||
from utils.s3_uploader import (
|
||||
upload_media_from_url,
|
||||
upload_image_from_url_optimized,
|
||||
upload_video_from_url_optimized,
|
||||
)
|
||||
from schemas import ScrapeRequest, ScrapedPost
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
@ -11,8 +17,6 @@ from datetime import datetime
|
||||
|
||||
def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
||||
try:
|
||||
# 1) one BoxAPI call (fast) — we keep this single round trip
|
||||
# Ask for more to offset duplicates
|
||||
raw_items, log_path = get_media(req.username, max(req.max_count, 12))
|
||||
except BoxAPIError as e:
|
||||
print(f"❌ BoxAPI error: {e}")
|
||||
@ -21,32 +25,27 @@ def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
||||
if not raw_items:
|
||||
return []
|
||||
|
||||
# 2) normalize quickly
|
||||
normalized = parse_boxapi_items_fast(raw_items)
|
||||
|
||||
# 3) apply cursor filter if provided (safe, optional)
|
||||
# optional cursor filter
|
||||
since_ts = getattr(req, "since_taken_at", None)
|
||||
if since_ts:
|
||||
try:
|
||||
since_ts = int(since_ts)
|
||||
normalized = [n for n in normalized if (n.get("taken_at") or 0) > since_ts]
|
||||
except Exception:
|
||||
# If since_taken_at is invalid, ignore it
|
||||
pass
|
||||
|
||||
# 4) filter already-seen
|
||||
# filter seen
|
||||
ids_in_order = [n["ig_post_id"] for n in normalized if n.get("ig_post_id")]
|
||||
unseen_ids = set(filter_unseen(req.username, ids_in_order))
|
||||
|
||||
unseen = [n for n in normalized if n["ig_post_id"] in unseen_ids]
|
||||
|
||||
# 5) take the next N unseen (newest first already)
|
||||
# pick next N
|
||||
batch = unseen[:req.max_count]
|
||||
|
||||
# 6) mark them seen (only what we return)
|
||||
mark_seen(req.username, [b["ig_post_id"] for b in batch])
|
||||
|
||||
# 7) Get or create InstagramAccount
|
||||
# ensure account
|
||||
account = db.query(InstagramAccount).filter_by(username=req.username).first()
|
||||
if not account:
|
||||
account = InstagramAccount(
|
||||
@ -63,53 +62,72 @@ def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
||||
new_post_count = 0
|
||||
|
||||
for item in batch:
|
||||
post_id = item["ig_post_id"]
|
||||
post_id = str(item["ig_post_id"]) # ENSURE clean, no slashes
|
||||
seller_id = str(req.seller_id)
|
||||
|
||||
# Check if already in database
|
||||
# skip if already saved
|
||||
if db.query(InstagramPost).filter_by(ig_post_id=post_id).first():
|
||||
continue
|
||||
|
||||
# Filter media URLs to avoid duplicates (especially for videos)
|
||||
# media URL list (normalized parser gives remote_urls)
|
||||
media_urls = list(item.get("remote_urls", []))
|
||||
|
||||
# If this is a video post, keep only true video URLs
|
||||
if item["media_type"] == "video":
|
||||
thumb = (item.get("thumbnail_url") or "").strip()
|
||||
def is_video(u: str) -> bool:
|
||||
base = u.split("?", 1)[0].lower()
|
||||
return base.endswith((".mp4", ".mov", ".m4v", ".webm"))
|
||||
# keep only real video URLs; drop image covers and anything equal to the thumbnail
|
||||
media_urls = [u for u in media_urls if is_video(u) and u != thumb]
|
||||
|
||||
# Upload remote URLs to S3 (optional - can be done async later)
|
||||
# Upload media to S3 (optimized path)
|
||||
uploaded_urls: List[str] = []
|
||||
for remote_url in media_urls:
|
||||
try:
|
||||
local_url = upload_media_from_url(remote_url, str(req.seller_id), f"{post_id}/")
|
||||
if local_url:
|
||||
uploaded_urls.append(local_url)
|
||||
if item["media_type"] == "video":
|
||||
# optimized 480p H.264 CRF23 (+faststart)
|
||||
url = upload_video_from_url_optimized(remote_url, seller_id, post_id)
|
||||
elif item["media_type"] in ("image", "carousel"):
|
||||
# carousel items may be images; optimize to WebP
|
||||
url = upload_image_from_url_optimized(remote_url, seller_id, post_id)
|
||||
if url is None:
|
||||
# some carousels include videos; try generic fallback
|
||||
url = upload_media_from_url(remote_url, seller_id, post_id)
|
||||
else:
|
||||
# unknown → generic detection/fallback
|
||||
url = upload_media_from_url(remote_url, seller_id, post_id)
|
||||
|
||||
if url:
|
||||
uploaded_urls.append(url)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error uploading media: {e}")
|
||||
continue
|
||||
|
||||
# Upload thumbnail (optional)
|
||||
final_thumbnail = item["thumbnail_url"]
|
||||
if item["thumbnail_url"]:
|
||||
# Upload/optimize thumbnail (if present)
|
||||
final_thumbnail = item.get("thumbnail_url") or None
|
||||
if final_thumbnail:
|
||||
try:
|
||||
thumb_uploaded = upload_media_from_url(item["thumbnail_url"], str(req.seller_id), f"{post_id}/thumbnail")
|
||||
if thumb_uploaded:
|
||||
final_thumbnail = thumb_uploaded
|
||||
thumb_url = upload_image_from_url_optimized(final_thumbnail, seller_id, post_id)
|
||||
if thumb_url:
|
||||
final_thumbnail = thumb_url
|
||||
else:
|
||||
# fallback to generic (no optimize)
|
||||
alt = upload_media_from_url(final_thumbnail, seller_id, post_id)
|
||||
if alt:
|
||||
final_thumbnail = alt
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to upload thumbnail: {e}")
|
||||
|
||||
# Save to DB
|
||||
# Save
|
||||
created_at = datetime.fromtimestamp(item["taken_at"]) if item.get("taken_at") else datetime.utcnow()
|
||||
post_entry = InstagramPost(
|
||||
ig_post_id=post_id,
|
||||
account_id=account.id,
|
||||
media_type=item["media_type"],
|
||||
caption=item["caption"],
|
||||
caption=item.get("caption"),
|
||||
thumbnail_url=final_thumbnail,
|
||||
local_media=uploaded_urls, # Use filtered S3 URLs
|
||||
created_at=datetime.fromtimestamp(item["taken_at"]),
|
||||
local_media=uploaded_urls,
|
||||
created_at=created_at,
|
||||
)
|
||||
db.add(post_entry)
|
||||
db.commit()
|
||||
@ -117,10 +135,10 @@ def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
||||
result.append(ScrapedPost(
|
||||
ig_post_id=post_id,
|
||||
media_type=item["media_type"],
|
||||
caption=item["caption"],
|
||||
caption=item.get("caption"),
|
||||
thumbnail_url=final_thumbnail,
|
||||
local_media=uploaded_urls, # Use filtered S3 URLs
|
||||
created_at=datetime.fromtimestamp(item["taken_at"])
|
||||
local_media=uploaded_urls,
|
||||
created_at=created_at,
|
||||
))
|
||||
|
||||
new_post_count += 1
|
||||
|
||||
193
utils/media_processing.py
Normal file
193
utils/media_processing.py
Normal file
@ -0,0 +1,193 @@
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Tuple, List
|
||||
from PIL import Image
|
||||
import requests
|
||||
|
||||
FFMPEG = os.getenv("FFMPEG_BIN", "ffmpeg")
|
||||
FFPROBE = os.getenv("FFPROBE_BIN", "ffprobe")
|
||||
|
||||
# ------------------------
|
||||
# Shared helpers
|
||||
# ------------------------
|
||||
|
||||
def _bool(name: str, default: bool) -> bool:
|
||||
v = os.getenv(name)
|
||||
if v is None:
|
||||
return default
|
||||
return v.strip().lower() in ("1", "true", "yes", "y")
|
||||
|
||||
def _int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.getenv(name, str(default)))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name, str(default)))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _parse_max_size(default: Tuple[int, int] = (1600, 1600)) -> Tuple[int, int]:
|
||||
raw = os.getenv("IMAGE_MAX_SIZE", f"{default[0]}x{default[1]}").strip().lower()
|
||||
m = re.match(r"^\s*(\d+)\s*[x,]\s*(\d+)\s*$", raw)
|
||||
if not m:
|
||||
return default
|
||||
return (int(m.group(1)), int(m.group(2)))
|
||||
|
||||
def download_bytes(url: str, timeout: int = 30) -> bytes:
|
||||
r = requests.get(url, stream=True, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
|
||||
# ------------------------
|
||||
# Image processing (WebP)
|
||||
# ------------------------
|
||||
|
||||
def process_image_to_webp_single(image_bytes: bytes) -> Tuple[str, bytes, str]:
|
||||
"""
|
||||
Resize image to:
|
||||
- scale percent from .env (default 0.85)
|
||||
- and cap to max size from .env (default 1600x1600)
|
||||
Save as WebP with quality from .env (default 85).
|
||||
|
||||
Returns (variant_name, data, content_type)
|
||||
variant_name: 'webp'
|
||||
"""
|
||||
scale_percent = _float("IMAGE_SCALE_PERCENT", 0.85)
|
||||
max_w, max_h = _parse_max_size((1600, 1600))
|
||||
quality = _int("IMAGE_QUALITY", 85)
|
||||
|
||||
img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
||||
orig_w, orig_h = img.size
|
||||
|
||||
# First apply percentage scaling
|
||||
target_w = max(1, int(orig_w * scale_percent))
|
||||
target_h = max(1, int(orig_h * scale_percent))
|
||||
|
||||
# Then cap to max size if still too large
|
||||
ratio_w = max_w / target_w if target_w > max_w else 1.0
|
||||
ratio_h = max_h / target_h if target_h > max_h else 1.0
|
||||
cap_ratio = min(ratio_w, ratio_h)
|
||||
if cap_ratio < 1.0:
|
||||
target_w = max(1, int(target_w * cap_ratio))
|
||||
target_h = max(1, int(target_h * cap_ratio))
|
||||
|
||||
# Resize using LANCZOS
|
||||
resized = img.resize((target_w, target_h), Image.LANCZOS)
|
||||
|
||||
out = io.BytesIO()
|
||||
# method=6 is a good quality/speed balance for WebP
|
||||
resized.save(out, format="WEBP", quality=quality, method=6)
|
||||
return ("webp", out.getvalue(), "image/webp")
|
||||
|
||||
# ------------------------
|
||||
# Video processing (H.264 @ 480p)
|
||||
# ------------------------
|
||||
|
||||
def _probe_streams(path: str) -> dict:
|
||||
"""Return ffprobe json for streams (video/audio codecs, dimensions)."""
|
||||
# Minimal probing; not strictly required but helps with SIMPLE mode decisions
|
||||
cmd = [
|
||||
FFPROBE, "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=codec_name,width,height",
|
||||
"-of", "json", path
|
||||
]
|
||||
try:
|
||||
out = subprocess.run(cmd, check=True, capture_output=True).stdout
|
||||
import json
|
||||
return json.loads(out.decode("utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def transcode_video_480_h264(video_bytes: bytes) -> Tuple[str, bytes, str]:
|
||||
"""
|
||||
Transcode to MP4 (H.264/AAC), height=VIDEO_HEIGHT (default 480), CRF=VIDEO_CRF (default 23).
|
||||
Adds +faststart unless disabled. Uses PRESET (default veryfast).
|
||||
If VIDEO_SIMPLE_MODE=true:
|
||||
- Use 'ultrafast' preset automatically
|
||||
- If input already H.264/AAC and <= target height, attempt stream-copy with -movflags +faststart
|
||||
(very quick) — otherwise fallback to a minimal transcode.
|
||||
Returns (variant_name, data, content_type) where variant_name='480p'
|
||||
"""
|
||||
height = _int("VIDEO_HEIGHT", 480)
|
||||
crf = _int("VIDEO_CRF", 23)
|
||||
preset = os.getenv("VIDEO_PRESET", "veryfast")
|
||||
faststart = _bool("VIDEO_FASTSTART", True)
|
||||
simple = _bool("VIDEO_SIMPLE_MODE", False)
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
in_path = os.path.join(td, "in.bin")
|
||||
out_path = os.path.join(td, f"out-{height}p.mp4")
|
||||
with open(in_path, "wb") as f:
|
||||
f.write(video_bytes)
|
||||
|
||||
vf = f"scale=-2:{height}"
|
||||
|
||||
# SIMPLE MODE quick path
|
||||
if simple:
|
||||
# Try to detect if we can safely stream copy
|
||||
meta = _probe_streams(in_path)
|
||||
v_ok = False
|
||||
try:
|
||||
st = meta.get("streams", [{}])[0]
|
||||
vcodec = st.get("codec_name")
|
||||
v_h = int(st.get("height", 10**9))
|
||||
v_ok = (vcodec == "h264" and v_h <= height)
|
||||
except Exception:
|
||||
v_ok = False
|
||||
|
||||
if v_ok:
|
||||
# Quick remux with faststart; try copy video/audio
|
||||
cmd = [FFMPEG, "-y", "-i", in_path, "-c", "copy"]
|
||||
if faststart:
|
||||
cmd += ["-movflags", "+faststart"]
|
||||
cmd += [out_path]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
with open(out_path, "rb") as f:
|
||||
return (f"{height}p", f.read(), "video/mp4")
|
||||
except Exception:
|
||||
# fall back to quick transcode below
|
||||
pass
|
||||
|
||||
# Quick transcode with ultrafast
|
||||
cmd = [
|
||||
FFMPEG, "-y",
|
||||
"-i", in_path,
|
||||
"-vf", vf,
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast", # force quickest in simple mode
|
||||
"-crf", str(crf),
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
]
|
||||
if faststart:
|
||||
cmd += ["-movflags", "+faststart"]
|
||||
cmd += [out_path]
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
with open(out_path, "rb") as f:
|
||||
return (f"{height}p", f.read(), "video/mp4")
|
||||
|
||||
# Normal path (preset configurable)
|
||||
cmd = [
|
||||
FFMPEG, "-y",
|
||||
"-i", in_path,
|
||||
"-vf", vf,
|
||||
"-c:v", "libx264",
|
||||
"-preset", preset,
|
||||
"-crf", str(crf),
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
]
|
||||
if faststart:
|
||||
cmd += ["-movflags", "+faststart"]
|
||||
cmd += [out_path]
|
||||
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
with open(out_path, "rb") as f:
|
||||
return (f"{height}p", f.read(), "video/mp4")
|
||||
@ -1,10 +1,14 @@
|
||||
import os
|
||||
import boto3
|
||||
import re
|
||||
import uuid
|
||||
import mimetypes
|
||||
from urllib.parse import urljoin
|
||||
from typing import Optional
|
||||
|
||||
import boto3
|
||||
import requests
|
||||
|
||||
# ---- ENV / S3 setup ---------------------------------------------------------
|
||||
|
||||
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
|
||||
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
|
||||
AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME")
|
||||
@ -12,6 +16,16 @@ S3_ENDPOINT_URL = os.getenv("S3_ENDPOINT_URL")
|
||||
S3_REGION = os.getenv("S3_REGION", "us-east-1")
|
||||
MEDIA_BASE_PATH = os.getenv("MEDIA_BASE_PATH", "instagram")
|
||||
|
||||
# Optimization toggles
|
||||
MEDIA_OPTIMIZE_IMAGES = os.getenv("MEDIA_OPTIMIZE_IMAGES", "true").strip().lower() in ("1", "true", "yes", "y")
|
||||
MEDIA_OPTIMIZE_VIDEOS = os.getenv("MEDIA_OPTIMIZE_VIDEOS", "true").strip().lower() in ("1", "true", "yes", "y")
|
||||
KEEP_ORIGINAL_IMAGES = os.getenv("KEEP_ORIGINAL_IMAGES", "false").strip().lower() in ("1", "true", "yes", "y")
|
||||
KEEP_ORIGINAL_VIDEOS = os.getenv("KEEP_ORIGINAL_VIDEOS", "false").strip().lower() in ("1", "true", "yes", "y")
|
||||
|
||||
# HTTP
|
||||
DOWNLOAD_TIMEOUT = int(os.getenv("DOWNLOAD_TIMEOUT", "30"))
|
||||
|
||||
# S3 client
|
||||
session = boto3.session.Session()
|
||||
s3 = session.client(
|
||||
service_name="s3",
|
||||
@ -21,30 +35,171 @@ s3 = session.client(
|
||||
region_name=S3_REGION,
|
||||
)
|
||||
|
||||
|
||||
def upload_media_from_url(media_url: str, seller_id: str, post_id: str) -> str | None:
|
||||
try:
|
||||
response = requests.get(media_url, stream=True, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
ext = mimetypes.guess_extension(content_type) or ".jpg"
|
||||
if ext in [".jpe", ".jfif", ".bin"]:
|
||||
ext = ".jpg"
|
||||
|
||||
filename = f"{uuid.uuid4()}{ext}"
|
||||
s3_path = f"{MEDIA_BASE_PATH}/{seller_id}/{post_id}/{filename}"
|
||||
|
||||
s3.upload_fileobj(
|
||||
response.raw,
|
||||
Bucket=AWS_STORAGE_BUCKET_NAME,
|
||||
Key=s3_path,
|
||||
ExtraArgs={"ACL": "public-read", "ContentType": content_type}
|
||||
# ---- Media processing imports ----------------------------------------------
|
||||
# These utilities implement your exact constraints:
|
||||
# - Images: scale 85%, cap 1600x1600, quality 85 → WebP
|
||||
# - Videos: H.264, 480p, CRF 23, +faststart (with SIMPLE mode support)
|
||||
from utils.media_processing import ( # type: ignore
|
||||
download_bytes,
|
||||
process_image_to_webp_single,
|
||||
transcode_video_480_h264,
|
||||
)
|
||||
|
||||
public_url = f"{S3_ENDPOINT_URL}/{AWS_STORAGE_BUCKET_NAME}/{s3_path}"
|
||||
return public_url
|
||||
|
||||
# ---- Helpers ----------------------------------------------------------------
|
||||
|
||||
def _safe_basename_from_url(url: str) -> str:
|
||||
name = url.split("?")[0].rstrip("/").split("/")[-1]
|
||||
name = name.rsplit(".", 1)[0] if "." in name else name
|
||||
name = re.sub(r"[^a-zA-Z0-9._-]+", "-", name).strip("-")
|
||||
return name or uuid.uuid4().hex[:8]
|
||||
|
||||
|
||||
def _guess_ext_from_content_type(content_type: str, fallback: str = ".bin") -> str:
|
||||
ext = mimetypes.guess_extension(content_type or "") or fallback
|
||||
# normalize oddballs
|
||||
if ext in [".jpe", ".jfif", ".bin"]:
|
||||
ext = ".jpg"
|
||||
return ext
|
||||
|
||||
|
||||
def _upload_bytes(data: bytes, content_type: str, seller_id: str, post_id: str, key: str) -> str:
|
||||
"""
|
||||
Upload a byte blob to S3 at:
|
||||
{MEDIA_BASE_PATH}/{seller_id}/{post_id}/{key}
|
||||
Returns a public URL.
|
||||
"""
|
||||
s3_path = f"{MEDIA_BASE_PATH}/{seller_id}/{post_id}/{key}"
|
||||
s3.put_object(
|
||||
Bucket=AWS_STORAGE_BUCKET_NAME,
|
||||
Key=s3_path,
|
||||
Body=data,
|
||||
ACL="public-read",
|
||||
ContentType=content_type or "application/octet-stream",
|
||||
)
|
||||
return f"{S3_ENDPOINT_URL}/{AWS_STORAGE_BUCKET_NAME}/{s3_path}"
|
||||
|
||||
|
||||
def _stream_to_s3_from_url(url: str, seller_id: str, post_id: str, key: str, content_type: Optional[str] = None) -> str:
|
||||
"""
|
||||
Stream a remote URL directly to S3 (no processing).
|
||||
"""
|
||||
with requests.get(url, stream=True, timeout=DOWNLOAD_TIMEOUT) as r:
|
||||
r.raise_for_status()
|
||||
ctype = content_type or r.headers.get("Content-Type") or "application/octet-stream"
|
||||
s3_path = f"{MEDIA_BASE_PATH}/{seller_id}/{post_id}/{key}"
|
||||
s3.upload_fileobj(
|
||||
r.raw,
|
||||
Bucket=AWS_STORAGE_BUCKET_NAME,
|
||||
Key=s3_path,
|
||||
ExtraArgs={"ACL": "public-read", "ContentType": ctype},
|
||||
)
|
||||
return f"{S3_ENDPOINT_URL}/{AWS_STORAGE_BUCKET_NAME}/{s3_path}"
|
||||
|
||||
|
||||
# ---- Public API -------------------------------------------------------------
|
||||
|
||||
def upload_image_from_url_optimized(image_url: str, seller_id: str, post_id: str) -> Optional[str]:
|
||||
"""
|
||||
Download -> (optional) optimize to WebP -> upload -> return public URL.
|
||||
If optimization fails, falls back to direct upload.
|
||||
"""
|
||||
base = _safe_basename_from_url(image_url)
|
||||
|
||||
try:
|
||||
if MEDIA_OPTIMIZE_IMAGES:
|
||||
# Download full bytes, run WebP pipeline (85% scale, max 1600x1600, quality 85)
|
||||
blob = download_bytes(image_url, timeout=DOWNLOAD_TIMEOUT)
|
||||
variant_name, data, ctype = process_image_to_webp_single(blob)
|
||||
url_out = _upload_bytes(data, ctype, seller_id, post_id, f"images/{base}-{variant_name}.webp")
|
||||
|
||||
if KEEP_ORIGINAL_IMAGES:
|
||||
# Keep original (best-effort content type)
|
||||
_upload_bytes(blob, "application/octet-stream", seller_id, post_id, f"images/{base}-original.bin")
|
||||
|
||||
return url_out
|
||||
|
||||
# No optimization: stream to S3 as-is
|
||||
# Try to pick a reasonable extension based on content type
|
||||
head = requests.head(image_url, timeout=DOWNLOAD_TIMEOUT, allow_redirects=True)
|
||||
content_type = head.headers.get("Content-Type", "")
|
||||
ext = _guess_ext_from_content_type(content_type, ".jpg")
|
||||
return _stream_to_s3_from_url(image_url, seller_id, post_id, f"images/{base}{ext}", content_type)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to upload media from {media_url}: {e}")
|
||||
print(f"❌ Image upload failed (optimized path). Falling back. url={image_url} err={e}")
|
||||
# Final fallback: best-effort stream
|
||||
try:
|
||||
return _stream_to_s3_from_url(image_url, seller_id, post_id, f"images/{base}.bin", "application/octet-stream")
|
||||
except Exception as e2:
|
||||
print(f"❌ Image upload fallback failed. url={image_url} err={e2}")
|
||||
return None
|
||||
|
||||
|
||||
def upload_video_from_url_optimized(video_url: str, seller_id: str, post_id: str) -> Optional[str]:
|
||||
"""
|
||||
Download -> (optional) transcode to MP4 (H.264, 480p, CRF 23, +faststart) -> upload -> return public URL.
|
||||
If optimization fails, falls back to direct upload.
|
||||
"""
|
||||
base = _safe_basename_from_url(video_url)
|
||||
|
||||
try:
|
||||
if MEDIA_OPTIMIZE_VIDEOS:
|
||||
blob = download_bytes(video_url, timeout=DOWNLOAD_TIMEOUT)
|
||||
variant_name, data, ctype = transcode_video_480_h264(blob)
|
||||
url_out = _upload_bytes(data, ctype, seller_id, post_id, f"videos/{base}-{variant_name}.mp4")
|
||||
|
||||
if KEEP_ORIGINAL_VIDEOS:
|
||||
_upload_bytes(blob, "application/octet-stream", seller_id, post_id, f"videos/{base}-original.bin")
|
||||
|
||||
return url_out
|
||||
|
||||
# No optimization: stream to S3 as-is
|
||||
head = requests.head(video_url, timeout=DOWNLOAD_TIMEOUT, allow_redirects=True)
|
||||
content_type = head.headers.get("Content-Type", "")
|
||||
ext = _guess_ext_from_content_type(content_type, ".mp4")
|
||||
return _stream_to_s3_from_url(video_url, seller_id, post_id, f"videos/{base}{ext}", content_type)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Video upload failed (optimized path). Falling back. url={video_url} err={e}")
|
||||
# Final fallback: best-effort stream
|
||||
try:
|
||||
return _stream_to_s3_from_url(video_url, seller_id, post_id, f"videos/{base}.bin", "application/octet-stream")
|
||||
except Exception as e2:
|
||||
print(f"❌ Video upload fallback failed. url={video_url} err={e2}")
|
||||
return None
|
||||
|
||||
|
||||
def upload_media_from_url(media_url: str, seller_id: str, post_id: str) -> Optional[str]:
|
||||
"""
|
||||
Backward-compatible alias if callers don't know type.
|
||||
Heuristic: decide by Content-Type, optimize when possible.
|
||||
"""
|
||||
try:
|
||||
head = requests.head(media_url, timeout=DOWNLOAD_TIMEOUT, allow_redirects=True)
|
||||
content_type = head.headers.get("Content-Type", "").lower()
|
||||
|
||||
if "image" in content_type:
|
||||
return upload_image_from_url_optimized(media_url, seller_id, post_id)
|
||||
if "video" in content_type:
|
||||
return upload_video_from_url_optimized(media_url, seller_id, post_id)
|
||||
|
||||
# Unknown → just stream raw
|
||||
base = _safe_basename_from_url(media_url)
|
||||
return _stream_to_s3_from_url(media_url, seller_id, post_id, f"misc/{base}", content_type or "application/octet-stream")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ HEAD failed for {media_url}: {e}")
|
||||
# If HEAD fails, still try image path → then video path → then raw
|
||||
url = upload_image_from_url_optimized(media_url, seller_id, post_id)
|
||||
if url:
|
||||
return url
|
||||
url = upload_video_from_url_optimized(media_url, seller_id, post_id)
|
||||
if url:
|
||||
return url
|
||||
try:
|
||||
base = _safe_basename_from_url(media_url)
|
||||
return _stream_to_s3_from_url(media_url, seller_id, post_id, f"misc/{base}.bin", "application/octet-stream")
|
||||
except Exception as e2:
|
||||
print(f"❌ Ultimate fallback failed for {media_url}: {e2}")
|
||||
return None
|
||||
Loading…
Reference in New Issue
Block a user