feat(dsi): add search and accurate thumbnail helpers

Add native search forwarding, page handling, posting limits and cached previews using the saved thumbnail frame.
This commit is contained in:
rekushi committed 2026-10-29 04:23:46 +00:00
1 parent bf6bbef978
commit 26987fc497
1 file changed
+89 -6
+89 -6
View File
@@ -1,9 +1,12 @@
from __future__ import annotations from __future__ import annotations
import html import html
import hashlib
import io import io
import math import math
from collections import OrderedDict
from pathlib import Path from pathlib import Path
from threading import RLock
from urllib.parse import urlencode from urllib.parse import urlencode
from PIL import Image from PIL import Image
@@ -15,6 +18,8 @@ from reximemo_image_formats import encode_image
ROOT_HOST = "flipnote.hatena.com" ROOT_HOST = "flipnote.hatena.com"
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
_THUMBNAILS = OrderedDict()
_THUMBNAIL_LOCK = RLock()
def region_from_request(request): def region_from_request(request):
@@ -37,6 +42,23 @@ def current_account(request):
return Database.account_for_ip(client_ip(request)) return Database.account_for_ip(client_ip(request))
def posting_account(request):
from server_config import load_config
account = current_account(request)
if account is None and not load_config()["guest_posting"]:
return None, dialog(request, "Sign in from Account before posting on this server.")
return account, None
def read_ppm_upload(request):
from server_config import load_config
maximum = int(load_config()["max_upload_bytes"])
data = request.content.read(maximum + 1)
if len(data) > maximum:
raise ValueError("This Flipnote is larger than the server's upload limit.")
return data
def current_creator(request, *, fsid="", display_name=""): def current_creator(request, *, fsid="", display_name=""):
account = current_account(request) account = current_account(request)
return Database.get_or_create_creator( return Database.get_or_create_creator(
@@ -51,6 +73,15 @@ def keyboard_value(request):
return request_headers(request).get("x-email-addr", "").strip() return request_headers(request).get("x-email-addr", "").strip()
def search_keyboard_response(request, kind="flipnotes"):
query = keyboard_value(request)[:100].strip()
if not query:
return dialog(request, "Enter a title or creator name to search for.")
scope = "creators" if kind == "creators" else "flipnotes"
forward(request, dsi_url(request, "search.uls?") + urlencode({"q": query, "kind": scope}))
return b"OK"
def dialog(request, message, status=200): def dialog(request, message, status=200):
request.setResponseCode(int(status)) request.setResponseCode(int(status))
request.setHeader(b"X-DSi-Dialog-Type", b"1") request.setHeader(b"X-DSi-Dialog-Type", b"1")
@@ -62,9 +93,19 @@ def forward(request, target):
request.setHeader(b"X-DSi-Forwarder", str(target).encode("ascii", "ignore")) request.setHeader(b"X-DSi-Forwarder", str(target).encode("ascii", "ignore"))
def dsi_text_html(value, preserve_newlines=False):
"""Escape DSi text nodes without unsupported quote entities."""
text = "" if value is None else str(value)
text = text.replace("\x00", "")
text = text.replace("&", "&#38;").replace("<", "&#60;").replace(">", "&#62;")
if preserve_newlines:
text = text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "<br>")
return text
def html_page(request, title, body, extra_head=""): def html_page(request, title, body, extra_head=""):
request.setHeader(b"content-type", b"text/html; charset=utf-8") request.setHeader(b"content-type", b"text/html; charset=utf-8")
page = f"""<!doctype html><html><head><title>{html.escape(str(title))}</title> page = f"""<!doctype html><html><head><title>{dsi_text_html(title)}</title>
<meta name="uppertitle" content="{html.escape(str(title), quote=True)}"> <meta name="uppertitle" content="{html.escape(str(title), quote=True)}">
<link rel="stylesheet" type="text/css" href="http://{ROOT_HOST}/css/ds/common.css"> <link rel="stylesheet" type="text/css" href="http://{ROOT_HOST}/css/ds/common.css">
<link rel="stylesheet" type="text/css" href="http://{ROOT_HOST}/css/ds/basic.css"> <link rel="stylesheet" type="text/css" href="http://{ROOT_HOST}/css/ds/basic.css">
@@ -81,6 +122,26 @@ def query_int(request, name, default=1, minimum=1, maximum=1000000):
return min(maximum, max(minimum, n)) return min(maximum, max(minimum, n))
def page_number(request, total, per_page=50, name="page"):
return min(query_int(request, name), max(1, int(math.ceil(total / float(per_page)))))
def pagination_html(request, base, page, total, per_page=30, params=None, name="page"):
pages = max(1, int(math.ceil(total / float(per_page))))
if pages == 1:
return ""
params = dict(params or {})
links = []
if page > 1:
query = dict(params, **{name: page - 1})
links.append('<a href="%s">Previous</a>' % html.escape(dsi_url(request, base) + "?" + urlencode(query), quote=True))
links.append("Page %d of %d" % (page, pages))
if page < pages:
query = dict(params, **{name: page + 1})
links.append('<a href="%s">Next</a>' % html.escape(dsi_url(request, base) + "?" + urlencode(query), quote=True))
return '<div align="center">' + " | ".join(links) + "</div>"
def make_menu(layout=(4,), title=None, subtitle_top="", subtitle_bottom="", left="", right="", buttons=None, dropdowns=None, corner=None): def make_menu(layout=(4,), title=None, subtitle_top="", subtitle_bottom="", left="", right="", buttons=None, dropdowns=None, corner=None):
ugo = UGO() ugo = UGO()
ugo.Loaded = True ugo.Loaded = True
@@ -151,22 +212,44 @@ def pagination_buttons(request, base, page, total, per_page=50, params=None):
def ppm_first_frame_image(raw): def ppm_first_frame_image(raw):
return ppm_frame_image(raw, 0)
def ppm_frame_image(raw, frame_index):
ppm = PPM().Read(raw, ReadFrames=True, ReadSound=False) ppm = PPM().Read(raw, ReadFrames=True, ReadSound=False)
if not ppm: if not ppm:
raise ValueError("Invalid PPM") raise ValueError("Invalid PPM")
frame = ppm.GetFrame(0) frame_index = int(frame_index)
if frame_index < 0 or frame_index >= int(ppm.FrameCount):
frame_index = 0
frame = ppm.GetFrame(frame_index)
if frame is False or frame is None: if frame is False or frame is None:
raise ValueError("Missing first frame") raise ValueError("Missing frame")
data = frame.tobytes(order="F") data = frame.tobytes(order="F")
return Image.frombytes("RGBA", (len(frame), len(frame[0])), data) return Image.frombytes("RGBA", (len(frame), len(frame[0])), data)
def ppm_thumbnail_png(raw, size=(256, 192)): def ppm_thumbnail_png(raw, size=(256, 192), *, first_frame=False):
image = ppm_first_frame_image(raw).convert("RGB") raw = bytes(raw)
size = tuple(map(int, size))
key = (hashlib.sha256(raw).digest(), size, bool(first_frame))
with _THUMBNAIL_LOCK:
cached = _THUMBNAILS.get(key)
if cached is not None:
_THUMBNAILS.move_to_end(key)
return cached
frame_index = 0 if first_frame else int.from_bytes(raw[0x12:0x14], "little")
image = ppm_frame_image(raw, frame_index).convert("RGB")
image.thumbnail(size, Image.Resampling.NEAREST) image.thumbnail(size, Image.Resampling.NEAREST)
out = io.BytesIO() out = io.BytesIO()
image.save(out, "PNG", optimize=True) image.save(out, "PNG", optimize=True)
return out.getvalue() result = out.getvalue()
with _THUMBNAIL_LOCK:
_THUMBNAILS[key] = result
_THUMBNAILS.move_to_end(key)
while len(_THUMBNAILS) > 64:
_THUMBNAILS.popitem(last=False)
return result
def comment_npf(comment_id, width=128, height=64): def comment_npf(comment_id, width=128, height=64):