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
@@ -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
|
||||||
os.replace(tmp, dest)
|
try:
|
||||||
|
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
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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:
|
||||||
out = io.BytesIO()
|
if any(not blob_path(value).is_file() for value in hashes):
|
||||||
img.save(out, "WEBP", quality=85)
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Изображение галереи не найдено, повторите импорт")
|
||||||
return put_bytes(out.getvalue(), ".webp")[0]
|
|
||||||
|
|
||||||
|
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):
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
@@ -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("&", "&")
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
@@ -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`; после сохранения доступ к картинкам подчиняется видимости игры,
|
||||||
|
а сборщик мусора сохраняет все изображения, на которые ссылается галерея.
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in new issue
Block a user