110 lines
3.2 KiB
Python
110 lines
3.2 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Tuple
|
|
from urllib.parse import urlparse
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, UploadFile, File, HTTPException
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel
|
|
|
|
# Хранилище картинок только для этого сервиса
|
|
MEDIA_ROOT = Path(os.getenv("NYANIMEDB_MEDIA_ROOT", "media")).resolve()
|
|
MEDIA_ROOT.mkdir(parents=True, exist_ok=True)
|
|
|
|
app = FastAPI()
|
|
|
|
|
|
def _guess_ext_from_url(url: str) -> str:
|
|
path = urlparse(url).path
|
|
_, ext = os.path.splitext(path)
|
|
if ext and len(ext) <= 5:
|
|
return ext
|
|
return ".jpg"
|
|
|
|
|
|
def _guess_ext_from_name(name: str) -> str:
|
|
_, ext = os.path.splitext(name)
|
|
if ext and len(ext) <= 5:
|
|
return ext
|
|
return ".jpg"
|
|
|
|
|
|
def _build_rel_path_from_hash(h: str, ext: str, subdir: str = "posters") -> Tuple[str, Path]:
|
|
"""
|
|
Строим путь вида subdir/ab/cd/<hash>.ext по sha1-хешу содержимого.
|
|
"""
|
|
level1 = h[:2]
|
|
level2 = h[2:4]
|
|
rel = f"{subdir}/{level1}/{level2}/{h}{ext}"
|
|
abs_path = MEDIA_ROOT / rel
|
|
return rel, abs_path
|
|
|
|
|
|
async def _save_bytes(data: bytes, *, filename_hint: str, subdir: str = "posters") -> str:
|
|
# sha1 по содержимому
|
|
h = hashlib.sha1(data).hexdigest()
|
|
# расширение либо из имени, либо .jpg
|
|
ext = _guess_ext_from_name(filename_hint)
|
|
|
|
rel, abs_path = _build_rel_path_from_hash(h, ext, subdir=subdir)
|
|
|
|
if abs_path.exists():
|
|
return rel
|
|
|
|
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp_path = abs_path.with_suffix(abs_path.suffix + ".tmp")
|
|
|
|
with open(tmp_path, "wb") as f:
|
|
f.write(data)
|
|
os.replace(tmp_path, abs_path)
|
|
|
|
return rel
|
|
|
|
|
|
class DownloadByUrlRequest(BaseModel):
|
|
url: str
|
|
subdir: str = "posters"
|
|
|
|
|
|
@app.post("/upload")
|
|
async def upload_image(
|
|
file: UploadFile = File(...),
|
|
subdir: str = "posters",
|
|
):
|
|
"""
|
|
Загрузка файла от клиента (ETL/админка/бекенд).
|
|
Возвращает относительный путь (subdir/ab/cd/hash.ext).
|
|
"""
|
|
data = await file.read()
|
|
rel = await _save_bytes(data, filename_hint=file.filename or "", subdir=subdir)
|
|
return {"path": rel}
|
|
|
|
|
|
@app.post("/download-by-url")
|
|
async def download_by_url(payload: DownloadByUrlRequest):
|
|
"""
|
|
Скачивает картинку по URL и сохраняет по тем же правилам, что downloader.py.
|
|
"""
|
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
r = await client.get(payload.url)
|
|
r.raise_for_status()
|
|
data = r.content
|
|
|
|
# filename_hint берём из URL
|
|
rel = await _save_bytes(data, filename_hint=payload.url, subdir=payload.subdir)
|
|
return {"path": rel}
|
|
|
|
|
|
@app.get("/{path:path}")
|
|
async def get_image(path: str):
|
|
"""
|
|
Отдаёт файл по относительному пути (например, posters/ab/cd/hash.jpg).
|
|
"""
|
|
abs_path = MEDIA_ROOT / path
|
|
if not abs_path.is_file():
|
|
raise HTTPException(status_code=404, detail="Image not found")
|
|
return FileResponse(abs_path)
|