feat: import F95 screenshot galleries with AVIF support
This commit is contained in:
1 parent
5b911d1e3b
commit
69f71cb836
21 files changed
+513
-33
No files matched your search
@@ -87,6 +87,7 @@ async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[d
|
||||
|
||||
@router.post("", response_model=GameOut, status_code=201)
|
||||
async def create_game(body: GameIn, user: Uploader, db: DB) -> Game:
|
||||
validate_gallery(body.gallery_hashes)
|
||||
if body.cover_hash and not blob_path(body.cover_hash).is_file():
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Обложка не найдена, загрузите превью снова")
|
||||
game = Game(
|
||||
@@ -95,6 +96,7 @@ async def create_game(body: GameIn, user: Uploader, db: DB) -> Game:
|
||||
tags=sorted({t.strip().lower() for t in body.tags if t.strip()}),
|
||||
created_by_id=user.id,
|
||||
cover_hash=body.cover_hash,
|
||||
gallery_hashes=list(dict.fromkeys(body.gallery_hashes)),
|
||||
)
|
||||
db.add(game)
|
||||
await db.commit()
|
||||
@@ -121,6 +123,9 @@ async def update_game(game_id: int, body: GameUpdateIn, user: CurrentUser, db: D
|
||||
game = await get_game(db, game_id)
|
||||
if not can_edit_game(user, game):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование")
|
||||
if body.gallery_hashes is not None:
|
||||
validate_gallery(body.gallery_hashes)
|
||||
game.gallery_hashes = list(dict.fromkeys(body.gallery_hashes))
|
||||
if body.cover_hash:
|
||||
if not blob_path(body.cover_hash).is_file():
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Обложка не найдена, загрузите превью снова")
|
||||
@@ -163,10 +168,19 @@ async def delete_game(game_id: int, _: Annotated[User, Depends(require("games.de
|
||||
|
||||
|
||||
def make_cover(data: bytes) -> str:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img.thumbnail((640, 640))
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGBA")
|
||||
out = io.BytesIO()
|
||||
img.save(out, "WEBP", quality=85)
|
||||
return put_bytes(out.getvalue(), ".webp")[0]
|
||||
return put_bytes(encode_cover(data), ".webp")[0]
|
||||
|
||||
|
||||
def validate_gallery(hashes: list[str]) -> None:
|
||||
if any(not blob_path(value).is_file() for value in hashes):
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Изображение галереи не найдено, повторите импорт")
|
||||
|
||||
|
||||
def encode_cover(data: bytes, max_size: tuple[int, int] = (640, 640)) -> bytes:
|
||||
with Image.open(io.BytesIO(data)) as image:
|
||||
image.thumbnail(max_size)
|
||||
if image.mode not in ("RGB", "RGBA"):
|
||||
image = image.convert("RGBA")
|
||||
out = io.BytesIO()
|
||||
image.save(out, "WEBP", quality=85)
|
||||
return out.getvalue()
|
||||
@@ -42,7 +42,7 @@ async def legacy_file(blob_id: str, request: Request, user: Player, db: DB):
|
||||
if not BLOB_ID_RE.fullmatch(blob_id):
|
||||
raise HTTPException(404)
|
||||
visible = visible_versions_filter(user)
|
||||
cover = await db.scalar(select(Game.id).where(Game.cover_hash == blob_id,
|
||||
cover = await db.scalar(select(Game.id).where(or_(Game.cover_hash == blob_id, Game.gallery_hashes.any(blob_id)),
|
||||
or_(Game.created_by_id == user.id, exists().where(GameVersion.game_id == Game.id, visible))).limit(1))
|
||||
if cover is None:
|
||||
# Keep old clients functional, with exactly the same version visibility rules.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user