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
+511 -31

No files matched your search

+7
View File
@@ -33,7 +33,14 @@ def _finalize(tmp: Path, digest: str, ext: str) -> tuple[str, int]:
else: else:
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
tmp.chmod(0o644) # mkstemp creates 0600; nginx serves blobs as another user tmp.chmod(0o644) # mkstemp creates 0600; nginx serves blobs as another user
try:
os.replace(tmp, dest) os.replace(tmp, dest)
except OSError:
# Another import may have published the same content since exists().
# Windows can refuse replacement while that file is being accessed.
if not dest.is_file() or dest.stat().st_size != size:
raise
tmp.unlink()
return blob_id, size return blob_id, size
+16 -2
View File
@@ -82,7 +82,7 @@ def fetch_image(url: str, thread_url: str, cookie: str = "", user_agent: str = "
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code in (401, 403): if e.code in (401, 403):
raise F95AuthError("F95zone не принял cookie или ограничил доступ к превью") from e raise F95AuthError("F95zone не принял cookie или ограничил доступ к превью") from e
raise F95Error("Не удалось скачать превью F95zone") from e raise F95Error(f"F95zone не отдал изображение (HTTP {e.code})") from e
except OSError as e: except OSError as e:
raise F95Error("Не удалось скачать превью F95zone") from e raise F95Error("Не удалось скачать превью F95zone") from e
@@ -133,6 +133,8 @@ class _Parser(HTMLParser):
self.og_image = None self.og_image = None
self.post_image = None self.post_image = None
self.cover_image = None self.cover_image = None
self.gallery_urls: list[str] = []
self._gallery_paths: set[str] = set()
self.tags: list[str] = [] self.tags: list[str] = []
self.post: list[str] = [] self.post: list[str] = []
self.template = "" self.template = ""
@@ -157,6 +159,14 @@ class _Parser(HTMLParser):
self._in_tag, self._tag_buf = True, "" self._in_tag, self._tag_buf = True, ""
if self._done: if self._done:
return return
# Screenshot lightboxes are often inside collapsed spoilers. Their href is
# the full-sized attachment; img src/data-src below it is only a thumbnail.
if self._depth and tag == "a" and "js-lbImage" in cls:
if candidate := image_url(a.get("href") or ""):
identity = urlparse(candidate)._replace(query="", fragment="").geturl()
if identity not in self._gallery_paths:
self._gallery_paths.add(identity)
self.gallery_urls.append(candidate)
if self._depth == 0: if self._depth == 0:
if tag == "div" and "bbWrapper" in cls and not self.post: if tag == "div" and "bbWrapper" in cls and not self.post:
self._depth = 1 self._depth = 1
@@ -215,8 +225,12 @@ def parse(html: str) -> dict:
# "VN - Ren'Py - Hooked! [v0.6.0] [BlenderKnight]" -> "Hooked!" # "VN - Ren'Py - Hooked! [v0.6.0] [BlenderKnight]" -> "Hooked!"
title = re.sub(r"\s*\[[^\]]*\]", "", title) title = re.sub(r"\s*\[[^\]]*\]", "", title)
title = re.sub(r"^(?:[A-Za-z0-9'’ ]{1,20} - )+", "", title).strip() title = re.sub(r"^(?:[A-Za-z0-9'’ ]{1,20} - )+", "", title).strip()
cover = p.cover_image or p.post_image or p.og_image
cover_path = urlparse(cover)._replace(query="", fragment="").geturl() if cover else None
gallery = [url for url in p.gallery_urls
if urlparse(url)._replace(query="", fragment="").geturl() != cover_path]
return {"title": title, "description": _description("".join(p.post)), return {"title": title, "description": _description("".join(p.post)),
"tags": sorted(set(p.tags)), "image_url": p.cover_image or p.post_image or p.og_image, "tags": sorted(set(p.tags)), "image_url": cover, "gallery_urls": gallery,
"logged_in": p.logged_in} "logged_in": p.logged_in}
+2
View File
@@ -64,6 +64,8 @@ async def collect(execute=False):
for manifest in (await db.scalars(select(VersionHistory.manifest))).all(): for manifest in (await db.scalars(select(VersionHistory.manifest))).all():
live.update(hashes(manifest)) live.update(hashes(manifest))
live.update(h for h in (await db.scalars(select(Game.cover_hash))).all() if h) live.update(h for h in (await db.scalars(select(Game.cover_hash))).all() if h)
for gallery in (await db.scalars(select(Game.gallery_hashes))).all():
live.update(gallery or [])
for key in live: for key in live:
if not BLOB_ID_RE.fullmatch(key) or not blob_path(key).is_file(): if not BLOB_ID_RE.fullmatch(key) or not blob_path(key).is_file():
raise RuntimeError(f"Missing/invalid referenced blob {key}; GC aborted") raise RuntimeError(f"Missing/invalid referenced blob {key}; GC aborted")
+1
View File
@@ -58,6 +58,7 @@ class Game(Base):
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
engine: Mapped[str] = mapped_column(String(32), default="renpy") engine: Mapped[str] = mapped_column(String(32), default="renpy")
cover_hash: Mapped[str | None] = mapped_column(String(80)) cover_hash: Mapped[str | None] = mapped_column(String(80))
gallery_hashes: Mapped[list[str]] = mapped_column(ARRAY(String(80)), default=list, server_default="{}")
# the version "Play" opens; translations are published by merging into it # the version "Play" opens; translations are published by merging into it
main_version_id: Mapped[int | None] = mapped_column( main_version_id: Mapped[int | None] = mapped_column(
ForeignKey("game_versions.id", ondelete="SET NULL", use_alter=True)) ForeignKey("game_versions.id", ondelete="SET NULL", use_alter=True))
+20 -6
View File
@@ -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) @router.post("", response_model=GameOut, status_code=201)
async def create_game(body: GameIn, user: Uploader, db: DB) -> Game: 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(): if body.cover_hash and not blob_path(body.cover_hash).is_file():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Обложка не найдена, загрузите превью снова") raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Обложка не найдена, загрузите превью снова")
game = Game( 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()}), tags=sorted({t.strip().lower() for t in body.tags if t.strip()}),
created_by_id=user.id, created_by_id=user.id,
cover_hash=body.cover_hash, cover_hash=body.cover_hash,
gallery_hashes=list(dict.fromkeys(body.gallery_hashes)),
) )
db.add(game) db.add(game)
await db.commit() 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) game = await get_game(db, game_id)
if not can_edit_game(user, game): if not can_edit_game(user, game):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование") 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 body.cover_hash:
if not blob_path(body.cover_hash).is_file(): if not blob_path(body.cover_hash).is_file():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Обложка не найдена, загрузите превью снова") 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: def make_cover(data: bytes) -> str:
img = Image.open(io.BytesIO(data)) return put_bytes(encode_cover(data), ".webp")[0]
img.thumbnail((640, 640))
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGBA") 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() out = io.BytesIO()
img.save(out, "WEBP", quality=85) image.save(out, "WEBP", quality=85)
return put_bytes(out.getvalue(), ".webp")[0] return out.getvalue()
+1 -1
View File
@@ -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): if not BLOB_ID_RE.fullmatch(blob_id):
raise HTTPException(404) raise HTTPException(404)
visible = visible_versions_filter(user) 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)) or_(Game.created_by_id == user.id, exists().where(GameVersion.game_id == Game.id, visible))).limit(1))
if cover is None: if cover is None:
# Keep old clients functional, with exactly the same version visibility rules. # Keep old clients functional, with exactly the same version visibility rules.
+66 -8
View File
@@ -1,4 +1,5 @@
import base64 import base64
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import quote from urllib.parse import quote
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
@@ -7,12 +8,13 @@ from pydantic import BaseModel, SecretStr
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from app import f95 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.deps import DB, CurrentUser, has_perm
from app.f95_session import open_session, seal 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"]) router = APIRouter(prefix="/api/metadata", tags=["metadata"])
MAX_GALLERY_IMAGES = 24
class F95In(BaseModel): class F95In(BaseModel):
@@ -27,6 +29,11 @@ class F95SessionIn(BaseModel):
user_agent: str = "" user_agent: str = ""
class GalleryImage(BaseModel):
hash: str
preview: str
class MetaOut(BaseModel): class MetaOut(BaseModel):
url: str url: str
title: str = "" title: str = ""
@@ -35,10 +42,34 @@ class MetaOut(BaseModel):
cover_hash: str | None = None cover_hash: str | None = None
cover_preview: str | None = None cover_preview: str | None = None
cover_warning: str | None = None cover_warning: str | None = None
gallery: list[GalleryImage] = []
gallery_warning: str | None = None
auth_required: bool = False auth_required: bool = False
metadata_available: bool = True 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: def load_metadata(url: str, cookie: str = "", user_agent: str = "") -> dict:
url = f95.validate_url(url) url = f95.validate_url(url)
try: try:
@@ -46,23 +77,50 @@ def load_metadata(url: str, cookie: str = "", user_agent: str = "") -> dict:
except f95.F95AuthError as e: except f95.F95AuthError as e:
return {"url": url, "auth_required": True, "metadata_available": False, "cover_warning": str(e)} return {"url": url, "auth_required": True, "metadata_available": False, "cover_warning": str(e)}
logged_in = metadata.pop("logged_in", None) logged_in = metadata.pop("logged_in", None)
gallery_urls = metadata.pop("gallery_urls", [])
metadata["cover_hash"] = None metadata["cover_hash"] = None
if image := metadata.pop("image_url", None): if image := metadata.pop("image_url", None):
try: try:
metadata["cover_hash"] = make_cover(f95.fetch_image(image, metadata["url"], cookie, user_agent)) metadata["cover_hash"], metadata["cover_preview"] = import_cover(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")
except f95.F95AuthError as e: except f95.F95AuthError as e:
metadata["auth_required"] = True metadata["auth_required"] = True
metadata["cover_warning"] = f"{e}. Остальные данные загружены." metadata["cover_warning"] = f"{e}. Остальные данные загружены."
except f95.F95Error as e: except f95.F95Error as e:
metadata["cover_warning"] = f"{e}. Остальные данные загружены." metadata["cover_warning"] = f"{e}. Остальные данные загружены."
except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError):
metadata["cover_warning"] = "Не удалось загрузить превью F95zone. Остальные данные загружены."
else: else:
metadata["auth_required"] = not cookie or logged_in in ("false", "0") metadata["auth_required"] = not cookie or logged_in in ("false", "0")
metadata["cover_warning"] = "В теме не найдено доступное превью. Остальные данные загружены." 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 return metadata
+6 -1
View File
@@ -1,8 +1,10 @@
from datetime import datetime from datetime import datetime
from typing import Any from typing import Annotated, Any
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
GalleryHash = Annotated[str, Field(pattern=r"^[0-9a-f]{64}\.webp$")]
class ORM(BaseModel): class ORM(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -97,6 +99,7 @@ class GameOut(ORM):
tags: list[str] tags: list[str]
engine: str engine: str
cover_hash: str | None cover_hash: str | None
gallery_hashes: list[str] = []
created_at: datetime created_at: datetime
main_version_id: int | None = None main_version_id: int | None = None
# version counts visible to the requesting user (filled by the list endpoint) # version counts visible to the requesting user (filled by the list endpoint)
@@ -114,6 +117,7 @@ class GameIn(BaseModel):
description: str = "" description: str = ""
tags: list[str] = [] tags: list[str] = []
cover_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}\.webp$") cover_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}\.webp$")
gallery_hashes: list[GalleryHash] = Field(default_factory=list, max_length=48)
class GameUpdateIn(BaseModel): class GameUpdateIn(BaseModel):
@@ -121,6 +125,7 @@ class GameUpdateIn(BaseModel):
description: str | None = None description: str | None = None
tags: list[str] | None = None tags: list[str] | None = None
cover_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}\.webp$") cover_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}\.webp$")
gallery_hashes: list[GalleryHash] | None = Field(default=None, max_length=48)
class VersionIn(BaseModel): class VersionIn(BaseModel):
@@ -0,0 +1,16 @@
"""Locally stored game screenshots."""
import sqlalchemy as sa
from alembic import op
revision = "0006"
down_revision = "0005"
branch_labels = depends_on = None
def upgrade():
op.add_column("games", sa.Column("gallery_hashes", sa.ARRAY(sa.String(80)),
nullable=False, server_default="{}"))
def downgrade():
op.drop_column("games", "gallery_hashes")
+1 -1
View File
@@ -7,7 +7,7 @@ pydantic-settings==2.9.1
argon2-cffi==23.1.0 argon2-cffi==23.1.0
pyjwt==2.10.1 pyjwt==2.10.1
cryptography==50.0.2 cryptography==50.0.2
pillow==11.2.1 pillow==11.3.0
UnityPy==1.25.4 UnityPy==1.25.4
TypeTreeGeneratorAPI==0.0.10 TypeTreeGeneratorAPI==0.0.10
dnfile==0.18.0 dnfile==0.18.0
+148 -2
View File
@@ -7,7 +7,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest import pytest
from PIL import Image from PIL import Image, UnidentifiedImageError
from pydantic import SecretStr, ValidationError from pydantic import SecretStr, ValidationError
from app import f95 from app import f95
@@ -30,7 +30,32 @@ def page(meta="", post="", reply=""):
def test_og_preview_and_existing_metadata(): def test_og_preview_and_existing_metadata():
result = f95.parse(page(f'<meta property="og:image" content="{IMAGE}">')) result = f95.parse(page(f'<meta property="og:image" content="{IMAGE}">'))
assert result == {"title": "Example", "description": "A story", "tags": [], "image_url": IMAGE, "logged_in": None} assert result == {"title": "Example", "description": "A story", "tags": [], "image_url": IMAGE,
"logged_in": None, "gallery_urls": []}
def test_gallery_uses_full_lightbox_urls_in_spoilers_without_noscript_or_reply_duplicates():
full = "https://attachments.f95zone.to/2021/07/1312176_bg_mmoralescene06_01.jpg?h=test&e=123&u=42"
escaped = full.replace("&", "&amp;")
html = page(post='<img class="bbImage" src="/cover.png" alt="F95 Cover">'
'<div class="bbCodeSpoiler">'
f'<a href="{escaped}" class="js-lbImage" data-fancybox="lb-post-2799977">'
'<img class="bbImage" src="/thumb/screen.jpg" width="100" height="57">'
'<noscript><img class="bbImage" src="/thumb/screen.jpg"></noscript></a>'
f'<a class="js-lbImage" href="{escaped}">duplicate</a>'
'<a class="js-lbImage" href="https://evil.test/a.jpg">external</a></div>',
reply='<a class="js-lbImage" href="/reply.jpg">reply</a>')
result = f95.parse(html)
assert result["gallery_urls"] == [full]
assert result["image_url"] == "https://f95zone.to/cover.png"
def test_gallery_deduplicates_signed_urls_and_excludes_cover():
html = page(post='<a class="js-lbImage" href="/cover.png?h=one">'
'<img class="bbImage" src="/cover.png?h=two" alt="Cover"></a>'
'<a class="js-lbImage" href="/screen.jpg?h=one">screen</a>'
'<a class="js-lbImage" href="/screen.jpg?h=two">duplicate</a>')
assert f95.parse(html)["gallery_urls"] == ["https://f95zone.to/screen.jpg?h=one"]
def test_mist_banner_beats_metadata_thumbnail_and_earlier_inline_image(): def test_mist_banner_beats_metadata_thumbnail_and_earlier_inline_image():
@@ -176,6 +201,127 @@ def test_missing_preview_is_nonfatal(monkeypatch):
assert result["auth_required"] is True assert result["auth_required"] is True
def test_avif_banner_with_png_filename_converts_to_webp(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
get_settings.cache_clear()
source = io.BytesIO()
Image.new("RGB", (1200, 500), "orange").save(source, "AVIF")
monkeypatch.setattr(f95, "load", lambda *_: {**f95.parse(page(post=(
'<img class="bbImage" src="https://attachments.f95zone.to/2022/03/1736427_F95Banner_Sideversion.png" '
'alt="F95 Cover.png">'))), "url": THREAD})
monkeypatch.setattr(f95, "fetch_image", lambda *_: source.getvalue())
try:
result = metadata.load_metadata(THREAD, "xf_user=test")
assert not result.get("cover_warning")
assert not result.get("auth_required")
with Image.open(blob_path(result["cover_hash"])) as cover:
assert cover.format == "WEBP"
assert cover.size == (640, 267)
finally:
get_settings.cache_clear()
def test_missing_avif_codec_reports_the_real_failure(monkeypatch):
monkeypatch.setattr(f95, "fetch_image", lambda *_: b"\x00\x00\x00\x1cftypavif\x00\x00\x00\x00avif")
def unsupported(_):
raise UnidentifiedImageError("unsupported")
monkeypatch.setattr(metadata, "encode_cover", unsupported)
with pytest.raises(f95.F95Error, match="AVIF.*зависимости backend"):
metadata.import_cover(IMAGE, THREAD, "", "")
def test_cover_storage_failure_has_a_distinct_error(monkeypatch):
monkeypatch.setattr(f95, "fetch_image", lambda *_: b"image")
monkeypatch.setattr(metadata, "encode_cover", lambda *_: b"webp")
def denied(*_):
raise PermissionError("private path")
monkeypatch.setattr(metadata, "put_bytes", denied)
with pytest.raises(f95.F95Error, match="доступ к хранилищу") as error:
metadata.import_cover(IMAGE, THREAD, "", "")
assert "private path" not in str(error.value)
def test_gallery_import_stores_large_image_and_private_preview_with_partial_failures(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
get_settings.cache_clear()
image = io.BytesIO()
Image.new("RGB", (1600, 900), "blue").save(image, "AVIF")
full_url = "https://attachments.f95zone.to/original.jpg?h=valid"
monkeypatch.setattr(f95, "load", lambda *_: {"title": "Example", "description": "Story", "tags": [],
"url": THREAD, "image_url": None, "logged_in": "true",
"gallery_urls": [full_url, full_url + "&duplicate=1", "https://attachments.f95zone.to/failed.jpg"]})
calls = []
def fetch(url, thread, cookie, agent):
calls.append((url, thread, cookie, agent))
if "failed" in url:
raise f95.F95Error("Ошибка скачивания")
return image.getvalue()
monkeypatch.setattr(f95, "fetch_image", fetch)
try:
result = metadata.load_metadata(THREAD, "xf_user=personal", "Browser")
assert len(result["gallery"]) == 1 # identical images are stored only once
entry = result["gallery"][0]
with Image.open(blob_path(entry["hash"])) as stored:
assert stored.format == "WEBP"
assert stored.size == (1600, 900)
with Image.open(io.BytesIO(base64.b64decode(entry["preview"].split(",", 1)[1]))) as preview:
assert preview.size == (640, 360)
assert result["gallery_warning"].startswith("Не удалось загрузить изображений галереи: 1.")
assert all(call[1:] == (THREAD, "xf_user=personal", "Browser") for call in calls)
assert "gallery_urls" not in result
assert metadata.MetaOut(**result).gallery[0].hash == entry["hash"]
finally:
get_settings.cache_clear()
def test_gallery_auth_failure_requests_replacement_cookie_and_keeps_successes(monkeypatch):
monkeypatch.setattr(f95, "load", lambda *_: {"url": THREAD, "image_url": None, "logged_in": "true",
"gallery_urls": ["https://f95zone.to/a.jpg", "https://f95zone.to/b.jpg"]})
def load_image(url, *_):
if url.endswith("b.jpg"):
raise f95.F95AuthError("Нужна авторизация")
return "a" * 64 + ".webp", "data:image/webp;base64,test"
monkeypatch.setattr(metadata, "import_cover", load_image)
result = metadata.load_metadata(THREAD, "xf_user=expired")
assert result["auth_required"] is True
assert len(result["gallery"]) == 1
def test_gallery_is_saved_preserved_and_removed_with_game(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
get_settings.cache_clear()
image_hash = "a" * 64 + ".webp"
db = SimpleNamespace(add=lambda _: None, commit=AsyncMock())
user = SimpleNamespace(id=1)
game = SimpleNamespace(gallery_hashes=[image_hash])
monkeypatch.setattr(games, "get_game", AsyncMock(return_value=game))
monkeypatch.setattr(games, "can_edit_game", lambda *_: True)
try:
with pytest.raises(games.HTTPException):
asyncio.run(games.create_game(GameIn(title="Example", gallery_hashes=[image_hash]), user, db))
path = blob_path(image_hash)
path.parent.mkdir(parents=True)
path.write_bytes(b"image")
created = asyncio.run(games.create_game(GameIn(title="Example", gallery_hashes=[image_hash, image_hash]), user, db))
assert created.gallery_hashes == [image_hash]
asyncio.run(games.update_game(1, GameUpdateIn(title="Renamed"), user, db))
assert game.gallery_hashes == [image_hash]
asyncio.run(games.update_game(1, GameUpdateIn(gallery_hashes=[]), user, db))
assert game.gallery_hashes == []
with pytest.raises(ValidationError):
GameIn(title="Example", gallery_hashes=["../secret"])
finally:
get_settings.cache_clear()
def test_personal_sessions_encrypted_and_bound_to_user(): def test_personal_sessions_encrypted_and_bound_to_user():
token = seal(1, "xf_user=private", "Browser") token = seal(1, "xf_user=private", "Browser")
assert "private" not in token assert "private" not in token
+75
View File
@@ -0,0 +1,75 @@
import asyncio
import importlib
import io
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import patch
from alembic.config import Config
from alembic.migration import MigrationContext
from alembic.operations import Operations
from alembic.script import ScriptDirectory
from app import gc
from app.blobs import blob_path, put_bytes
from app.config import get_settings
from app.models import Game
def test_gallery_migration_has_one_head_and_postgres_default_for_existing_games():
output = io.StringIO()
context = MigrationContext.configure(dialect_name="postgresql", opts={"as_sql": True, "output_buffer": output})
operations = Operations(context)
migration = importlib.import_module("migrations.versions.0006_game_gallery")
with patch("alembic.op.add_column", operations.add_column):
migration.upgrade()
sql = output.getvalue()
assert "gallery_hashes VARCHAR(80)[] DEFAULT '{}' NOT NULL" in sql
assert ScriptDirectory.from_config(Config("alembic.ini")).get_heads() == ["0006"]
def test_gc_retains_gallery_images_without_cover_or_version_references(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
get_settings.cache_clear()
live = "a" * 64 + ".webp"
orphan = "b" * 64 + ".webp"
for value in (live, orphan):
path = blob_path(value)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"image")
async def scalars(statement):
expr = statement.column_descriptions[0]["expr"]
return SimpleNamespace(all=lambda: [[live]] if expr is Game.gallery_hashes else [])
@asynccontextmanager
async def session():
yield SimpleNamespace(scalars=scalars)
@asynccontextmanager
async def access(**_):
yield
monkeypatch.setattr(gc, "SessionLocal", session)
monkeypatch.setattr(gc, "storage_access", access)
try:
report = asyncio.run(gc.collect())
assert report["retainedBlobs"] == 1
assert report["files"] == 1
assert blob_path(live).exists()
finally:
get_settings.cache_clear()
def test_identical_parallel_gallery_images_share_a_blob(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
get_settings.cache_clear()
try:
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(lambda _: put_bytes(b"same image", ".webp"), range(32)))
assert len({value[0] for value in results}) == 1
assert blob_path(results[0][0]).read_bytes() == b"same image"
assert not list((tmp_path / "blobs/tmp").iterdir())
finally:
get_settings.cache_clear()
+25
View File
@@ -54,3 +54,28 @@ backend. Шифрование использует `SECRET_KEY`; при смен
20 МиБ, переходы за пределы F95zone блокируются до отправки cookie. 20 МиБ, переходы за пределы F95zone блокируются до отправки cookie.
Если изображение скачать не удалось, текстовые данные импортируются с сообщением Если изображение скачать не удалось, текстовые данные импортируются с сообщением
об ошибке превью; текущая обложка игры сохраняется. об ошибке превью; текущая обложка игры сохраняется.
F95zone может отдать AVIF даже по ссылке с расширением `.png`. Для таких изображений
нужна Pillow 11.3.0 с поддержкой AVIF (версия закреплена в requirements.txt).
После обновления зависимости пересоберите образ backend: `docker compose up -d --build backend`.
Повторите импорт из темы, чтобы получить обложку; уже сохранённые обложки менять не требуется.
## Галерея игры
Импорт также собирает `a.js-lbImage` из первого сообщения, включая блоки со
скриншотами в спойлерах. Используется полный `href` со всеми параметрами доступа,
а не маленький `img src` из каталога `thumb`. Повторы и обложка исключаются;
изображения из ответов в теме не импортируются.
Картинки скачиваются с личными cookie, преобразуются в WebP до 1920×1920 и
сохраняются локально. Для просмотра до сохранения формы API возвращает отдельные
небольшие превью. Подписанные ссылки F95zone и cookie в карточке игры не хранятся.
За один импорт загружается до 24 изображений; ошибки отдельных файлов не отменяют
остальные. Форма показывает количество загруженных кадров и предупреждение о пропусках.
Галерею можно просмотреть и удалить лишние кадры перед сохранением. В редакторе
игры импорт дополняет имеющуюся галерею без повторов.
В карточке можно сохранить до 48 кадров; во время импорта сохранение формы недоступно.
Для хранения галереи нужна
миграция `0006`; после сохранения доступ к картинкам подчиняется видимости игры,
а сборщик мусора сохраняет все изображения, на которые ссылается галерея.
+1
View File
@@ -47,6 +47,7 @@ export interface Game {
tags: string[]; tags: string[];
engine: string; engine: string;
cover_hash: string | null; cover_hash: string | null;
gallery_hashes: string[];
main_version_id: number | null; main_version_id: number | null;
created_at: string; created_at: string;
ready_versions?: number; ready_versions?: number;
+9 -2
View File
@@ -10,6 +10,7 @@ import { LanguageMenu } from "../ui/LanguageMenu";
import { aggregateLanguages, versionLanguages } from "./languages"; import { aggregateLanguages, versionLanguages } from "./languages";
import { useUi } from "../ui/Ui"; import { useUi } from "../ui/Ui";
import { useUploads } from "../uploads"; import { useUploads } from "../uploads";
import { GameGallery, mergeGallery, type GalleryImage } from "../ui/GameGallery";
const STATUS: Record<Version["status"], string> = { const STATUS: Record<Version["status"], string> = {
uploading: "Загружается", uploading: "Загружается",
@@ -224,6 +225,7 @@ export function GamePage() {
</div> </div>
</div> </div>
{!editing && <GameGallery images={(game.gallery_hashes ?? []).map(hash => ({ hash }))} />}
<div className="section-title" style={{ marginTop: "var(--space-5)" }}> <div className="section-title" style={{ marginTop: "var(--space-5)" }}>
<h2>Версии</h2> <h2>Версии</h2>
<span className="count">{game.versions.length}</span> <span className="count">{game.versions.length}</span>
@@ -365,6 +367,8 @@ function Report({ report }: { report: Record<string, any> | null }) {
function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: boolean) => void }) { function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: boolean) => void }) {
const [coverHash, setCoverHash] = useState<string | null>(null); const [coverHash, setCoverHash] = useState<string | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null); const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [gallery, setGallery] = useState<GalleryImage[]>((game.gallery_hashes ?? []).map(hash => ({ hash })));
const [importing, setImporting] = useState(false);
const [title, setTitle] = useState(game.title); const [title, setTitle] = useState(game.title);
const [description, setDescription] = useState(game.description); const [description, setDescription] = useState(game.description);
const [tags, setTags] = useState(game.tags.join(", ")); const [tags, setTags] = useState(game.tags.join(", "));
@@ -374,6 +378,7 @@ function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: bool
await api.patch(`/api/games/${game.id}`, { await api.patch(`/api/games/${game.id}`, {
title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean), title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
...(coverHash ? { cover_hash: coverHash } : {}), ...(coverHash ? { cover_hash: coverHash } : {}),
gallery_hashes: gallery.map(image => image.hash),
}); });
onDone(true); onDone(true);
} catch (e) { } catch (e) {
@@ -382,20 +387,22 @@ function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: bool
}; };
return ( return (
<form className="editor" onSubmit={(e) => { e.preventDefault(); void save(); }}> <form className="editor" onSubmit={(e) => { e.preventDefault(); void save(); }}>
<F95Fetch onLoaded={(m) => { <F95Fetch onBusyChange={setImporting} onLoaded={(m) => {
setTitle(m.title); setTitle(m.title);
setDescription(m.description); setDescription(m.description);
setTags(m.tags.join(", ")); setTags(m.tags.join(", "));
setCoverHash(m.cover_hash); setCoverHash(m.cover_hash);
setCoverPreview(m.cover_preview); setCoverPreview(m.cover_preview);
setGallery(items => mergeGallery(items, m.gallery ?? []));
}} /> }} />
{coverHash && <F95CoverPreview source={coverPreview} editing onRemove={() => { setCoverHash(null); setCoverPreview(null); }} />} {coverHash && <F95CoverPreview source={coverPreview} editing onRemove={() => { setCoverHash(null); setCoverPreview(null); }} />}
<GameGallery images={gallery} onRemove={hash => setGallery(items => items.filter(image => image.hash !== hash))} />
<label className="field"><span>Название</span><input value={title} onChange={(e) => setTitle(e.target.value)} /></label> <label className="field"><span>Название</span><input value={title} onChange={(e) => setTitle(e.target.value)} /></label>
<label className="field"><span>Описание</span><textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} /></label> <label className="field"><span>Описание</span><textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} /></label>
<label className="field"><span>Теги</span><input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="через запятую: романтика, драма" /></label> <label className="field"><span>Теги</span><input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="через запятую: романтика, драма" /></label>
{error && <ErrorNote>{error}</ErrorNote>} {error && <ErrorNote>{error}</ErrorNote>}
<div className="actions"> <div className="actions">
<button className="btn btn-primary" disabled={!title.trim()}>Сохранить</button> <button className="btn btn-primary" disabled={!title.trim() || importing || gallery.length > 48}>Сохранить</button>
<button type="button" className="btn" onClick={() => onDone(false)}>Отмена</button> <button type="button" className="btn" onClick={() => onDone(false)}>Отмена</button>
</div> </div>
</form> </form>
+9 -3
View File
@@ -5,6 +5,7 @@ import { UploadProgress, useUploads } from "../uploads";
import { ErrorNote, PageHeader } from "../ui/Bits"; import { ErrorNote, PageHeader } from "../ui/Bits";
import { F95CoverPreview, F95Fetch } from "../ui/F95Fetch"; import { F95CoverPreview, F95Fetch } from "../ui/F95Fetch";
import { Icon } from "../ui/Icon"; import { Icon } from "../ui/Icon";
import { GameGallery, type GalleryImage } from "../ui/GameGallery";
const ARCHIVE = /\.(zip|7z|rar|tar|gz|tgz|bz2|xz)$/i; const ARCHIVE = /\.(zip|7z|rar|tar|gz|tgz|bz2|xz)$/i;
@@ -15,6 +16,8 @@ export function NewGame() {
const [tags, setTags] = useState(""); const [tags, setTags] = useState("");
const [coverHash, setCoverHash] = useState<string | null>(null); const [coverHash, setCoverHash] = useState<string | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null); const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [gallery, setGallery] = useState<GalleryImage[]>([]);
const [importing, setImporting] = useState(false);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [over, setOver] = useState(false); const [over, setOver] = useState(false);
const { start } = useUploads(); const { start } = useUploads();
@@ -39,13 +42,14 @@ export function NewGame() {
e.preventDefault(); e.preventDefault();
if (!file) return; if (!file) return;
setError(""); setError("");
start({ file, title, description, coverHash, tags: tags.split(",").map((t) => t.trim()).filter(Boolean) }); start({ file, title, description, coverHash, galleryHashes: gallery.map(image => image.hash), tags: tags.split(",").map((t) => t.trim()).filter(Boolean) });
setFile(null); setFile(null);
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setTags(""); setTags("");
setCoverHash(null); setCoverHash(null);
setCoverPreview(null); setCoverPreview(null);
setGallery([]);
}; };
return ( return (
@@ -73,14 +77,16 @@ export function NewGame() {
)} )}
</label> </label>
<F95Fetch onLoaded={(m) => { <F95Fetch onBusyChange={setImporting} onLoaded={(m) => {
setTitle(m.title); setTitle(m.title);
setDescription(m.description); setDescription(m.description);
setTags(m.tags.join(", ")); setTags(m.tags.join(", "));
setCoverHash(m.cover_hash); setCoverHash(m.cover_hash);
setCoverPreview(m.cover_preview); setCoverPreview(m.cover_preview);
setGallery(m.gallery ?? []);
}} /> }} />
{coverHash && <F95CoverPreview source={coverPreview} onRemove={() => { setCoverHash(null); setCoverPreview(null); }} />} {coverHash && <F95CoverPreview source={coverPreview} onRemove={() => { setCoverHash(null); setCoverPreview(null); }} />}
<GameGallery images={gallery} onRemove={hash => setGallery(items => items.filter(image => image.hash !== hash))} />
<div className="form-row game-metadata-row"> <div className="form-row game-metadata-row">
<label className="field"> <label className="field">
<span>Название</span> <span>Название</span>
@@ -99,7 +105,7 @@ export function NewGame() {
{error && <ErrorNote>{error}</ErrorNote>} {error && <ErrorNote>{error}</ErrorNote>}
<div className="actions"> <div className="actions">
<button className="btn btn-primary btn-lg" disabled={!file}> <button className="btn btn-primary btn-lg" disabled={!file || importing}>
<Icon name="upload" size={20} />Загрузить <Icon name="upload" size={20} />Загрузить
</button> </button>
<button type="button" className="btn btn-ghost" onClick={() => navigate(-1)}>Отмена</button> <button type="button" className="btn btn-ghost" onClick={() => navigate(-1)}>Отмена</button>
+9
View File
@@ -730,6 +730,15 @@ input[type="file"] { padding: 0.4rem; }
.f95-cover-image img { width: 100%; height: 100%; object-fit: contain; } .f95-cover-image img { width: 100%; height: 100%; object-fit: contain; }
.f95-cover-info { min-width: 0; display: flex; flex-direction: column; gap: 0.4rem; align-items: flex-start; } .f95-cover-info { min-width: 0; display: flex; flex-direction: column; gap: 0.4rem; align-items: flex-start; }
.game-metadata-row { align-items: flex-start; } .game-metadata-row { align-items: flex-start; }
.game-gallery { min-width: 0; margin-block: var(--space-3); }
.game-gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr)); gap: var(--space-3); }
.game-gallery-item { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; }
.game-gallery-thumb { width: 100%; aspect-ratio: 16 / 10; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-s); overflow: hidden; background: var(--surface-2); cursor: zoom-in; }
.game-gallery-thumb img { width: 100%; height: 100%; object-fit: cover; }
.game-gallery-viewer { width: min(1100px, 94vw); max-height: 94dvh; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); }
.game-gallery-viewer::backdrop { background: rgba(0, 0, 0, 0.85); }
.game-gallery-viewer > img { display: block; width: 100%; max-height: 75dvh; object-fit: contain; }
.game-gallery-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; margin-bottom: var(--space-3); }
@media (max-width: 520px) { @media (max-width: 520px) {
.f95-cover { align-items: flex-start; flex-direction: column; } .f95-cover { align-items: flex-start; flex-direction: column; }
.game-metadata-row > .field { flex-basis: 100%; min-width: 0; } .game-metadata-row > .field { flex-basis: 100%; min-width: 0; }
+9 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { api } from "../api"; import { api } from "../api";
import type { GalleryImage } from "./GameGallery";
export interface F95Meta { export interface F95Meta {
url: string; url: string;
@@ -10,6 +11,8 @@ export interface F95Meta {
cover_hash: string | null; cover_hash: string | null;
cover_preview: string | null; cover_preview: string | null;
cover_warning: string | null; cover_warning: string | null;
gallery: GalleryImage[];
gallery_warning: string | null;
auth_required: boolean; auth_required: boolean;
metadata_available: boolean; metadata_available: boolean;
} }
@@ -18,7 +21,7 @@ interface CookieValues { xf_user: string; xf_session: string; cf_clearance: stri
const EMPTY_COOKIES: CookieValues = { xf_user: "", xf_session: "", cf_clearance: "" }; const EMPTY_COOKIES: CookieValues = { xf_user: "", xf_session: "", cf_clearance: "" };
/** Imports editable metadata and a locally cached cover from an F95zone thread. */ /** Imports editable metadata and a locally cached cover from an F95zone thread. */
export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) { export function F95Fetch({ onLoaded, onBusyChange }: { onLoaded: (m: F95Meta) => void; onBusyChange?: (busy: boolean) => void }) {
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -28,6 +31,7 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
const [agent, setAgent] = useState(""); const [agent, setAgent] = useState("");
const [result, setResult] = useState<F95Meta | null>(null); const [result, setResult] = useState<F95Meta | null>(null);
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
useEffect(() => { onBusyChange?.(busy); }, [busy, onBusyChange]);
const decline = () => { const decline = () => {
setNeedsAuth(false); setNeedsAuth(false);
@@ -96,13 +100,15 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
</button> </button>
</div> </div>
<span className="hint">Заполнит поля по теме игры. Изменения сохранятся после отправки формы.</span> <span className="hint">Заполнит поля по теме игры. Изменения сохранятся после отправки формы.</span>
{busy && <div className="note note-info" role="status">Загружаем данные и обложку из F95zone…</div>} {busy && <div className="note note-info" role="status">Загружаем данные, обложку и галерею из F95zone…</div>}
{!busy && result?.metadata_available && <div className={`note ${result.cover_warning ? "note-info" : "note-ok"}`} role="status"> {!busy && result?.metadata_available && <div className={`note ${result.cover_warning || result.gallery_warning ? "note-info" : "note-ok"}`} role="status">
<div><b>Данные из F95zone загружены</b> <div><b>Данные из F95zone загружены</b>
<p className="small">{result.cover_hash && result.cover_preview <p className="small">{result.cover_hash && result.cover_preview
? "Название, описание и теги заполнены, обложка подготовлена. Проверьте результат перед сохранением." ? "Название, описание и теги заполнены, обложка подготовлена. Проверьте результат перед сохранением."
: "Название, описание и теги заполнены. Обложку получить не удалось."}</p> : "Название, описание и теги заполнены. Обложку получить не удалось."}</p>
{result.cover_warning && <p className="small">{result.cover_warning}</p>} {result.cover_warning && <p className="small">{result.cover_warning}</p>}
{!!result.gallery?.length && <p className="small">Изображений для галереи: {result.gallery.length}.</p>}
{result.gallery_warning && <p className="small">{result.gallery_warning}</p>}
</div> </div>
</div>} </div>}
{!busy && !result?.metadata_available && warning && <div className="note note-info" role="status">{warning}</div>} {!busy && !result?.metadata_available && warning && <div className="note note-info" role="status">{warning}</div>}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
export interface GalleryImage { hash: string; preview?: string }
export function mergeGallery(current: GalleryImage[], incoming: GalleryImage[]): GalleryImage[] {
const images = new Map(current.map(image => [image.hash, image]));
for (const image of incoming) images.set(image.hash, image);
return [...images.values()];
}
export function GameGallery({ images, onRemove }: { images: GalleryImage[]; onRemove?: (hash: string) => void }) {
const [active, setActive] = useState<string | null>(null);
const index = images.findIndex(image => image.hash === active);
if (!images.length) return null;
return <section className="game-gallery" aria-label="Галерея игры">
<div className="section-title"><h2>Галерея</h2><span className="count">{images.length}</span></div>
{onRemove && <p className="hint">Изображения сохранятся вместе с игрой. Ненужные кадры можно убрать.</p>}
{onRemove && images.length > 48 && <p className="note note-error" role="alert">В галерее можно сохранить до 48 изображений. Уберите лишние кадры.</p>}
<div className="game-gallery-grid">
{images.map((image, i) => <div className="game-gallery-item" key={image.hash}>
<button type="button" className="game-gallery-thumb" onClick={() => setActive(image.hash)} aria-label={`Открыть изображение ${i + 1}`}>
<img src={image.preview ?? `/blobs/${image.hash}`} alt={`Кадр ${i + 1}`} loading="lazy" />
</button>
{onRemove && <button type="button" className="btn btn-ghost btn-sm" onClick={() => onRemove(image.hash)} aria-label={`Убрать изображение ${i + 1}`}>Убрать</button>}
</div>)}
</div>
{index >= 0 && createPortal(<GalleryViewer images={images} index={index}
onSelect={i => setActive(images[i].hash)} onClose={() => setActive(null)} />, document.body)}
</section>;
}
function GalleryViewer({ images, index, onSelect, onClose }: {
images: GalleryImage[]; index: number; onSelect: (index: number) => void; onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialog.current?.showModal();
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = overflow; };
}, []);
const move = (step: number) => onSelect((index + step + images.length) % images.length);
const image = images[index];
return <dialog ref={dialog} className="game-gallery-viewer" aria-label="Просмотр галереи"
onCancel={onClose} onClick={e => { if (e.target === e.currentTarget) onClose(); }}
onKeyDown={e => {
if (e.key === "ArrowLeft") { e.preventDefault(); move(-1); }
if (e.key === "ArrowRight") { e.preventDefault(); move(1); }
}}>
<div className="game-gallery-toolbar">
<button type="button" className="btn" onClick={() => move(-1)} disabled={images.length < 2}>Назад</button>
<span aria-live="polite">{index + 1} / {images.length}</span>
<button type="button" className="btn" onClick={() => move(1)} disabled={images.length < 2}>Далее</button>
<button type="button" className="btn" onClick={onClose}>Закрыть</button>
</div>
<img src={image.preview ?? `/blobs/${image.hash}`} alt={`Кадр ${index + 1}`} />
</dialog>;
}
+2
View File
@@ -25,6 +25,7 @@ interface UploadInput {
description?: string; description?: string;
tags?: string[]; tags?: string[];
coverHash?: string | null; coverHash?: string | null;
galleryHashes?: string[];
} }
interface UploadContextValue { interface UploadContextValue {
@@ -57,6 +58,7 @@ export function UploadProvider({ children }: { children: ReactNode }) {
title: input.title?.trim() || file.name.replace(/\.(zip|7z|rar|tar.*)$/i, ""), title: input.title?.trim() || file.name.replace(/\.(zip|7z|rar|tar.*)$/i, ""),
description: input.description ?? "", tags: input.tags ?? [], description: input.description ?? "", tags: input.tags ?? [],
cover_hash: input.coverHash ?? null, cover_hash: input.coverHash ?? null,
gallery_hashes: input.galleryHashes ?? [],
}); });
gameId = game.id; gameId = game.id;
} }
+29 -2
View File
@@ -144,16 +144,28 @@ test("new game shows an unsaved F95 cover and aligns metadata fields", {
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent"}); bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent"});
const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8");
let blobRequests = 0; let blobRequests = 0;
const server = createServer((req, res) => { let createdGame;
const server = createServer(async (req, res) => {
if (req.url === "/styles.css") { res.setHeader("Content-Type", "text/css"); res.end(css); return; } if (req.url === "/styles.css") { res.setHeader("Content-Type", "text/css"); res.end(css); return; }
if (req.url === "/bundle.js") { res.setHeader("Content-Type", "application/javascript"); res.end(compiled.outputFiles[0].text); return; } if (req.url === "/bundle.js") { res.setHeader("Content-Type", "application/javascript"); res.end(compiled.outputFiles[0].text); return; }
if (req.url.startsWith("/blobs/")) { blobRequests++; res.writeHead(404); res.end(); return; } if (req.url.startsWith("/blobs/")) { blobRequests++; res.writeHead(404); res.end(); return; }
if (req.url === "/api/games" && req.method === "POST") {
let body = "";
for await (const chunk of req) body += chunk;
createdGame = JSON.parse(body);
res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({id: 1})); return;
}
if (req.url.startsWith("/api/games/1/upload")) {
res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({id: 1, status: "ready"})); return;
}
if (req.url === "/api/metadata/f95") { if (req.url === "/api/metadata/f95") {
res.setHeader("Content-Type", "application/json"); res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({url: "https://f95zone.to/threads/example.123/", title: "Mist", description: "A story", res.end(JSON.stringify({url: "https://f95zone.to/threads/example.123/", title: "Mist", description: "A story",
tags: ["adventure", "survival"], cover_hash: "a".repeat(64) + ".webp", tags: ["adventure", "survival"], cover_hash: "a".repeat(64) + ".webp",
cover_preview: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jCTkAAAAASUVORK5CYII=", cover_preview: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jCTkAAAAASUVORK5CYII=",
cover_warning: null, auth_required: false, metadata_available: true})); return; cover_warning: null, auth_required: false, metadata_available: true,
gallery: ["b", "c"].map(char => ({hash: char.repeat(64) + ".webp",
preview: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jCTkAAAAASUVORK5CYII="}))})); return;
} }
res.setHeader("Content-Type", "text/html"); res.setHeader("Content-Type", "text/html");
res.end('<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/styles.css"><main></main><script src="/bundle.js"></script>'); res.end('<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/styles.css"><main></main><script src="/bundle.js"></script>');
@@ -170,6 +182,15 @@ test("new game shows an unsaved F95 cover and aligns metadata fields", {
await page.getByRole("button", {name: "Подтянуть", exact: true}).click(); await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
await page.getByText("Данные из F95zone загружены", {exact: true}).waitFor(); await page.getByText("Данные из F95zone загружены", {exact: true}).waitFor();
await page.getByRole("img", {name: "Обложка игры из F95zone"}).evaluate(image => image.decode()); await page.getByRole("img", {name: "Обложка игры из F95zone"}).evaluate(image => image.decode());
const gallery = page.getByRole("region", {name: "Галерея игры"});
await gallery.getByRole("button", {name: "Открыть изображение 1", exact: true}).click();
const viewer = page.getByRole("dialog", {name: "Просмотр галереи"});
await viewer.waitFor();
await viewer.getByRole("img", {name: "Кадр 1", exact: true}).evaluate(image => image.decode());
await viewer.press("ArrowRight");
await viewer.getByRole("img", {name: "Кадр 2", exact: true}).waitFor();
await viewer.press("Escape");
await viewer.waitFor({state: "detached"});
assert.equal(blobRequests, 0, "unsaved covers must not use a route that requires a saved game"); assert.equal(blobRequests, 0, "unsaved covers must not use a route that requires a saved game");
assert.equal(await page.getByRole("textbox", {name: "Название", exact: true}).inputValue(), "Mist"); assert.equal(await page.getByRole("textbox", {name: "Название", exact: true}).inputValue(), "Mist");
assert.equal(await page.getByRole("textbox", {name: /^Теги/}).inputValue(), "adventure, survival"); assert.equal(await page.getByRole("textbox", {name: /^Теги/}).inputValue(), "adventure, survival");
@@ -186,6 +207,12 @@ test("new game shows an unsaved F95 cover and aligns metadata fields", {
await page.getByRole("button", {name: "Убрать обложку", exact: true}).click(); await page.getByRole("button", {name: "Убрать обложку", exact: true}).click();
assert.equal(await page.getByRole("img", {name: "Обложка игры из F95zone"}).count(), 0); assert.equal(await page.getByRole("img", {name: "Обложка игры из F95zone"}).count(), 0);
assert.equal(await page.getByRole("textbox", {name: "Название", exact: true}).inputValue(), "Mist"); assert.equal(await page.getByRole("textbox", {name: "Название", exact: true}).inputValue(), "Mist");
await gallery.getByRole("button", {name: "Убрать изображение 1", exact: true}).click();
assert.equal(await gallery.getByRole("img").count(), 1);
await page.getByRole("button", {name: "Загрузить", exact: true}).click();
await page.getByText("Готово", {exact: true}).waitFor();
assert.deepEqual(createdGame.gallery_hashes, ["c".repeat(64) + ".webp"]);
assert.equal(createdGame.title, "Mist");
} finally { } finally {
await browser?.close(); await browser?.close();
await new Promise(resolve => server.close(resolve)); await new Promise(resolve => server.close(resolve));