feat: import F95 screenshot galleries with AVIF support
CI / frontend (push) Failing after 19s
CI / backend (push) Failing after 41s

This commit is contained in:
mrleo1nid committed 2026-10-26 17:51:19 +03:00
1 parent 5b911d1e3b
commit 69f71cb836
21 files changed
+513 -33

No files matched your search

+66 -8
View File
@@ -1,4 +1,5 @@
import base64
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, status
@@ -7,12 +8,13 @@ from pydantic import BaseModel, SecretStr
from starlette.concurrency import run_in_threadpool
from app import f95
from app.blobs import read_bytes
from app.blobs import put_bytes
from app.deps import DB, CurrentUser, has_perm
from app.f95_session import open_session, seal
from app.routers.games import make_cover
from app.routers.games import encode_cover
router = APIRouter(prefix="/api/metadata", tags=["metadata"])
MAX_GALLERY_IMAGES = 24
class F95In(BaseModel):
@@ -27,6 +29,11 @@ class F95SessionIn(BaseModel):
user_agent: str = ""
class GalleryImage(BaseModel):
hash: str
preview: str
class MetaOut(BaseModel):
url: str
title: str = ""
@@ -35,10 +42,34 @@ class MetaOut(BaseModel):
cover_hash: str | None = None
cover_preview: str | None = None
cover_warning: str | None = None
gallery: list[GalleryImage] = []
gallery_warning: str | None = None
auth_required: bool = False
metadata_available: bool = True
def import_cover(url: str, thread_url: str, cookie: str, user_agent: str,
max_size: tuple[int, int] = (640, 640)) -> tuple[str, str]:
data = f95.fetch_image(url, thread_url, cookie, user_agent)
try:
encoded = encode_cover(data) if max_size == (640, 640) else encode_cover(data, max_size)
preview = encoded if max_size == (640, 640) else encode_cover(encoded)
except UnidentifiedImageError as e:
if data[4:8] == b"ftyp" and (b"avif" in data[8:32] or b"avis" in data[8:32]):
raise f95.F95Error("F95zone вернул AVIF, который сервер не смог прочитать. Обновите зависимости backend") from e
raise f95.F95Error("Ответ F95zone не удалось распознать как изображение") from e
except Image.DecompressionBombError as e:
raise f95.F95Error("У превью F95zone слишком большое разрешение") from e
except (OSError, ValueError) as e:
raise f95.F95Error("Не удалось преобразовать изображение F95zone в обложку") from e
try:
cover_hash = put_bytes(encoded, ".webp")[0]
except OSError as e:
raise f95.F95Error("Не удалось сохранить обложку на сервере. Проверьте доступ к хранилищу") from e
# Unsaved covers cannot be read through the protected /blobs route yet.
return cover_hash, "data:image/webp;base64," + base64.b64encode(preview).decode("ascii")
def load_metadata(url: str, cookie: str = "", user_agent: str = "") -> dict:
url = f95.validate_url(url)
try:
@@ -46,23 +77,50 @@ def load_metadata(url: str, cookie: str = "", user_agent: str = "") -> dict:
except f95.F95AuthError as e:
return {"url": url, "auth_required": True, "metadata_available": False, "cover_warning": str(e)}
logged_in = metadata.pop("logged_in", None)
gallery_urls = metadata.pop("gallery_urls", [])
metadata["cover_hash"] = None
if image := metadata.pop("image_url", None):
try:
metadata["cover_hash"] = make_cover(f95.fetch_image(image, metadata["url"], cookie, user_agent))
# Unsaved covers cannot be read through the protected /blobs route yet.
metadata["cover_preview"] = "data:image/webp;base64," + base64.b64encode(
read_bytes(metadata["cover_hash"])).decode("ascii")
metadata["cover_hash"], metadata["cover_preview"] = import_cover(image, metadata["url"], cookie, user_agent)
except f95.F95AuthError as e:
metadata["auth_required"] = True
metadata["cover_warning"] = f"{e}. Остальные данные загружены."
except f95.F95Error as e:
metadata["cover_warning"] = f"{e}. Остальные данные загружены."
except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError):
metadata["cover_warning"] = "Не удалось загрузить превью F95zone. Остальные данные загружены."
else:
metadata["auth_required"] = not cookie or logged_in in ("false", "0")
metadata["cover_warning"] = "В теме не найдено доступное превью. Остальные данные загружены."
metadata["gallery"] = []
def load_screenshot(image_url: str):
try:
return import_cover(image_url, metadata["url"], cookie, user_agent, (1920, 1920))
except f95.F95Error as error:
return error
if gallery_urls:
failed = 0
first_error = ""
seen = {metadata["cover_hash"]}
with ThreadPoolExecutor(max_workers=4) as executor:
for result in executor.map(load_screenshot, gallery_urls[:MAX_GALLERY_IMAGES]):
if isinstance(result, f95.F95Error):
failed += 1
first_error = first_error or str(result)
if isinstance(result, f95.F95AuthError):
metadata["auth_required"] = True
else:
image_hash, preview = result
if image_hash not in seen:
seen.add(image_hash)
metadata["gallery"].append({"hash": image_hash, "preview": preview})
warnings = []
if failed:
warnings.append(f"Не удалось загрузить изображений галереи: {failed}. {first_error}.")
if len(gallery_urls) > MAX_GALLERY_IMAGES:
warnings.append(f"За один импорт загружается до {MAX_GALLERY_IMAGES} изображений галереи.")
if warnings:
metadata["gallery_warning"] = " ".join(warnings)
return metadata