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

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

No files matched your search

+148 -2
View File
@@ -7,7 +7,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from PIL import Image
from PIL import Image, UnidentifiedImageError
from pydantic import SecretStr, ValidationError
from app import f95
@@ -30,7 +30,32 @@ def page(meta="", post="", reply=""):
def test_og_preview_and_existing_metadata():
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():
@@ -176,6 +201,127 @@ def test_missing_preview_is_nonfatal(monkeypatch):
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():
token = seal(1, "xf_user=private", "Browser")
assert "private" not in token