fix: show unsaved F95 covers and clarify import results
CI / frontend (push) Failing after 28s
CI / backend (push) Failing after 42s

This commit is contained in:
mrleo1nid committed 2026-10-26 16:10:26 +03:00
1 parent ad622389bd
commit 785233f763
7 files changed
+149 -20

No files matched your search

+6
View File
@@ -1,3 +1,4 @@
import base64
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, status
@@ -6,6 +7,7 @@ from pydantic import BaseModel, SecretStr
from starlette.concurrency import run_in_threadpool
from app import f95
from app.blobs import read_bytes
from app.deps import DB, CurrentUser, has_perm
from app.f95_session import open_session, seal
from app.routers.games import make_cover
@@ -31,6 +33,7 @@ class MetaOut(BaseModel):
description: str = ""
tags: list[str] = []
cover_hash: str | None = None
cover_preview: str | None = None
cover_warning: str | None = None
auth_required: bool = False
metadata_available: bool = True
@@ -47,6 +50,9 @@ def load_metadata(url: str, cookie: str = "", user_agent: str = "") -> dict:
if image := metadata.pop("image_url", None):
try:
metadata["cover_hash"] = make_cover(f95.fetch_image(image, metadata["url"], cookie, user_agent))
# Unsaved covers cannot be read through the protected /blobs route yet.
metadata["cover_preview"] = "data:image/webp;base64," + base64.b64encode(
read_bytes(metadata["cover_hash"])).decode("ascii")
except f95.F95AuthError as e:
metadata["auth_required"] = True
metadata["cover_warning"] = f"{e}. Остальные данные загружены."
+4
View File
@@ -1,4 +1,5 @@
import asyncio
import base64
import io
import urllib.error
from email.message import Message
@@ -123,6 +124,9 @@ def test_import_stores_local_webp_and_keeps_metadata_on_image_failure(tmp_path,
assert cover.format == "WEBP"
assert cover.size == (640, 427)
assert "image_url" not in result
prefix, encoded = result["cover_preview"].split(",", 1)
assert prefix == "data:image/webp;base64"
assert base64.b64decode(encoded) == blob_path(result["cover_hash"]).read_bytes()
assert metadata.MetaOut(**result).cover_hash == result["cover_hash"]
monkeypatch.setattr(f95, "fetch_image", lambda *_: b"not an image")
failed = metadata.load_metadata(THREAD)
+4 -6
View File
@@ -4,7 +4,7 @@ import { api, formatSize, uploadRaw, type GameDetail, type Version } from "../ap
import { useAuth } from "../auth";
import { getRecent, pushRecent } from "../recent";
import { Cover, ErrorNote, Spinner } from "../ui/Bits";
import { F95Fetch } from "../ui/F95Fetch";
import { F95CoverPreview, F95Fetch } from "../ui/F95Fetch";
import { Icon } from "../ui/Icon";
import { LanguageMenu } from "../ui/LanguageMenu";
import { aggregateLanguages, versionLanguages } from "./languages";
@@ -364,6 +364,7 @@ function Report({ report }: { report: Record<string, any> | null }) {
function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: boolean) => void }) {
const [coverHash, setCoverHash] = useState<string | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [title, setTitle] = useState(game.title);
const [description, setDescription] = useState(game.description);
const [tags, setTags] = useState(game.tags.join(", "));
@@ -386,12 +387,9 @@ function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: bool
setDescription(m.description);
setTags(m.tags.join(", "));
setCoverHash(m.cover_hash);
setCoverPreview(m.cover_preview);
}} />
{coverHash && <div className="field">
<span>Новая обложка из F95zone</span>
<img src={`/blobs/${coverHash}`} alt="Превью новой обложки" style={{ maxWidth: 320, maxHeight: 200, objectFit: "contain" }} />
<button type="button" className="btn btn-ghost" onClick={() => setCoverHash(null)}>Оставить текущую обложку</button>
</div>}
{coverHash && <F95CoverPreview source={coverPreview} editing onRemove={() => { setCoverHash(null); setCoverPreview(null); }} />}
<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><input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="через запятую: романтика, драма" /></label>
+6 -7
View File
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { formatSize } from "../api";
import { UploadProgress, useUploads } from "../uploads";
import { ErrorNote, PageHeader } from "../ui/Bits";
import { F95Fetch } from "../ui/F95Fetch";
import { F95CoverPreview, F95Fetch } from "../ui/F95Fetch";
import { Icon } from "../ui/Icon";
const ARCHIVE = /\.(zip|7z|rar|tar|gz|tgz|bz2|xz)$/i;
@@ -14,6 +14,7 @@ export function NewGame() {
const [description, setDescription] = useState("");
const [tags, setTags] = useState("");
const [coverHash, setCoverHash] = useState<string | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [over, setOver] = useState(false);
const { start } = useUploads();
@@ -44,6 +45,7 @@ export function NewGame() {
setDescription("");
setTags("");
setCoverHash(null);
setCoverPreview(null);
};
return (
@@ -76,13 +78,10 @@ export function NewGame() {
setDescription(m.description);
setTags(m.tags.join(", "));
setCoverHash(m.cover_hash);
setCoverPreview(m.cover_preview);
}} />
{coverHash && <div className="field">
<span>Обложка из F95zone</span>
<img src={`/blobs/${coverHash}`} alt="Превью обложки" style={{ maxWidth: 320, maxHeight: 200, objectFit: "contain" }} />
<button type="button" className="btn btn-ghost" onClick={() => setCoverHash(null)}>Убрать превью</button>
</div>}
<div className="form-row">
{coverHash && <F95CoverPreview source={coverPreview} onRemove={() => { setCoverHash(null); setCoverPreview(null); }} />}
<div className="form-row game-metadata-row">
<label className="field">
<span>Название</span>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="По имени архива" />
+14
View File
@@ -720,6 +720,20 @@ input[type="file"] { padding: 0.4rem; }
.f95-modal .modal-actions .btn { flex: 1 1 180px; min-width: 0; white-space: normal; }
.f95-modal details .field { margin-top: var(--space-3); }
.f95-modal ol { padding-left: 1.25rem; margin-block: var(--space-2); }
.f95-import { gap: 0.65rem; }
.f95-link { display: flex; gap: 0.5rem; }
.f95-link input { min-width: 0; }
.f95-settings { font-size: 0.85rem; color: var(--muted); }
.f95-settings .actions { margin-top: 0.5rem; }
.f95-cover { display: flex; gap: var(--space-3); align-items: center; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-2); }
.f95-cover-image { width: 160px; height: 100px; flex-shrink: 0; display: grid; place-items: center; overflow: hidden; border-radius: var(--radius-s); background: var(--bg); }
.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; }
.game-metadata-row { align-items: flex-start; }
@media (max-width: 520px) {
.f95-cover { align-items: flex-start; flex-direction: column; }
.game-metadata-row > .field { flex-basis: 100%; min-width: 0; }
}
.toasts {
position: fixed;
+49 -7
View File
@@ -8,6 +8,7 @@ export interface F95Meta {
description: string;
tags: string[];
cover_hash: string | null;
cover_preview: string | null;
cover_warning: string | null;
auth_required: boolean;
metadata_available: boolean;
@@ -25,19 +26,25 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
const [needsAuth, setNeedsAuth] = useState(false);
const [cookies, setCookies] = useState<CookieValues>(EMPTY_COOKIES);
const [agent, setAgent] = useState("");
const [result, setResult] = useState<F95Meta | null>(null);
const [notice, setNotice] = useState("");
const decline = () => {
setNeedsAuth(false);
setCookies(EMPTY_COOKIES);
setAgent("");
if (result?.auth_required) setNotice("Авторизация пропущена. Загруженные данные остались в форме.");
};
const load = async () => {
setBusy(true);
setError("");
setWarning("");
setNotice("");
setResult(null);
try {
const metadata = await api.post<F95Meta>("/api/metadata/f95", { url });
setResult(metadata);
if (metadata.metadata_available) onLoaded(metadata);
setWarning(metadata.cover_warning ?? "");
setNeedsAuth(metadata.auth_required);
@@ -69,7 +76,7 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
try {
await api.del("/api/metadata/f95/session");
decline();
setWarning("Ваши сохранённые cookie F95zone удалены.");
setNotice("Ваши сохранённые cookie F95zone удалены.");
} catch (e) {
setError((e as Error).message);
} finally {
@@ -78,9 +85,9 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
};
return (
<div className="field">
<div className="field f95-import">
<span>Ссылка на тему F95zone</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
<div className="f95-link">
<input value={url} disabled={busy} onChange={(e) => setUrl(e.target.value)} style={{ flex: 1 }}
placeholder="https://f95zone.to/threads/…"
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (url.trim() && !busy) void load(); } }} />
@@ -88,13 +95,26 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
{busy ? "Загрузка…" : "Подтянуть"}
</button>
</div>
<span className="hint">Загрузит название, описание, теги и превью для обложки. Результат можно поправить перед сохранением.</span>
{warning && <span className="hint" role="status">{warning}</span>}
{error && <span className="hint" style={{ color: "var(--danger, #e5484d)" }}>{error}</span>}
<span className="hint">Заполнит поля по теме игры. Изменения сохранятся после отправки формы.</span>
{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">
<div><b>Данные из F95zone загружены</b>
<p className="small">{result.cover_hash && result.cover_preview
? "Название, описание и теги заполнены, обложка подготовлена. Проверьте результат перед сохранением."
: "Название, описание и теги заполнены. Обложку получить не удалось."}</p>
{result.cover_warning && <p className="small">{result.cover_warning}</p>}
</div>
</div>}
{!busy && !result?.metadata_available && warning && <div className="note note-info" role="status">{warning}</div>}
{notice && <div className="note note-info" role="status">{notice}</div>}
{error && <div className="note note-error" role="alert">{error}</div>}
<details className="f95-settings">
<summary>Настройки F95zone</summary>
<div className="actions">
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => setNeedsAuth(true)}>Cookie F95zone</button>
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => setNeedsAuth(true)}>Изменить cookie</button>
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => void forget()}>Забыть cookie F95zone</button>
</div>
</details>
{needsAuth && createPortal(<F95SessionDialog busy={busy} cookies={cookies} agent={agent}
setCookies={setCookies} setAgent={setAgent} error={error} warning={warning}
onDecline={decline} onSave={() => void saveSession()} />, document.body)}
@@ -102,6 +122,28 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
);
}
export function F95CoverPreview({ source, onRemove, editing = false }: {
source: string | null; onRemove: () => void; editing?: boolean;
}) {
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [source]);
return <div className="f95-cover">
<div className="f95-cover-image">
{source && !failed
? <img src={source} alt="Обложка игры из F95zone" onError={() => setFailed(true)} />
: <span className="hint">Превью недоступно</span>}
</div>
<div className="f95-cover-info">
<b>Обложка из F95zone</b>
<p className="hint">{failed || !source ? "Не удалось показать изображение. Повторите импорт из F95zone."
: editing ? "Заменит текущую обложку после сохранения." : "Будет сохранена вместе с игрой."}</p>
<button type="button" className="btn btn-ghost btn-sm" onClick={onRemove}>
{editing ? "Оставить текущую обложку" : "Убрать обложку"}
</button>
</div>
</div>;
}
function F95SessionDialog({ busy, cookies, agent, setCookies, setAgent, error, warning, onDecline, onSave }: {
busy: boolean; cookies: CookieValues; agent: string; error: string; warning: string;
setCookies: (value: CookieValues) => void; setAgent: (value: string) => void;
+66
View File
@@ -108,6 +108,7 @@ test("F95 personal cookies: decline, expired session, retry, delete and no paren
assert.equal(await page.evaluate(() => window.submits), 0);
assert.equal(await page.evaluate(() => localStorage.length), 0);
await page.getByText("Настройки F95zone", {exact: true}).click();
await page.getByRole("button", {name: "Забыть cookie F95zone"}).click();
await page.getByText("Ваши сохранённые cookie F95zone удалены.").waitFor();
assert.equal(deleted, 1);
@@ -125,3 +126,68 @@ test("F95 personal cookies: decline, expired session, retry, delete and no paren
await new Promise(resolve => server.close(resolve));
}
});
test("new game shows an unsaved F95 cover and aligns metadata fields", {
skip: !process.env.PGAMES_PLAYWRIGHT_PATH,
}, async () => {
const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href);
const compiled = await build({stdin: {contents: `
import {createElement as h} from 'react';
import {createRoot} from 'react-dom/client';
import {MemoryRouter} from 'react-router-dom';
import {TranslationRunProvider} from './translator/runner';
import {UploadProvider} from './uploads';
import {NewGame} from './pages/NewGame';
createRoot(document.querySelector('main')).render(h(MemoryRouter, {initialEntries: ['/games/new']},
h(TranslationRunProvider, null, h(UploadProvider, null, h(NewGame)))));`,
resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx"},
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent"});
const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8");
let blobRequests = 0;
const server = createServer((req, res) => {
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.startsWith("/blobs/")) { blobRequests++; res.writeHead(404); res.end(); return; }
if (req.url === "/api/metadata/f95") {
res.setHeader("Content-Type", "application/json");
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",
cover_preview: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jCTkAAAAASUVORK5CYII=",
cover_warning: null, auth_required: false, metadata_available: true})); return;
}
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>');
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
let browser;
try {
browser = await chromium.launch({headless: true, executablePath: process.env.PGAMES_CHROMIUM_EXECUTABLE});
const page = await browser.newPage({colorScheme: "dark", viewport: {width: 947, height: 870}});
page.setDefaultTimeout(10000);
await page.goto(`http://127.0.0.1:${server.address().port}`);
await page.locator('input[type="file"]').setInputFiles({name: "MIST-1.0.3-pc.zip", mimeType: "application/zip", buffer: Buffer.from("archive")});
await page.getByPlaceholder("https://f95zone.to/threads/…").fill("https://f95zone.to/threads/example.123/");
await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
await page.getByText("Данные из F95zone загружены", {exact: true}).waitFor();
await page.getByRole("img", {name: "Обложка игры из F95zone"}).evaluate(image => image.decode());
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: /^Теги/}).inputValue(), "adventure, survival");
const title = await page.getByRole("textbox", {name: "Название", exact: true}).boundingBox();
const tags = await page.getByRole("textbox", {name: /^Теги/}).boundingBox();
assert.ok(Math.abs(title.y - tags.y) < 1, "name and tags inputs align despite the tags hint");
for (const width of [947, 375]) {
await page.setViewportSize({width, height: 870});
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
if (process.env.PGAMES_F95_SCREENSHOT_DIR) {
await page.screenshot({path: `${process.env.PGAMES_F95_SCREENSHOT_DIR}/f95-result-${width}.png`, fullPage: true});
}
}
await page.getByRole("button", {name: "Убрать обложку", exact: true}).click();
assert.equal(await page.getByRole("img", {name: "Обложка игры из F95zone"}).count(), 0);
assert.equal(await page.getByRole("textbox", {name: "Название", exact: true}).inputValue(), "Mist");
} finally {
await browser?.close();
await new Promise(resolve => server.close(resolve));
}
});