Files
FabledCurator/backend/app/services/post_feed_service.py
T
bvandeusen c95b760294 feat(posts): in-context anchored feed with bidirectional infinite scroll
Provenance "View post" deep-links to /posts?post_id=X, which now opens the
feed centered on that post with infinite load in BOTH directions.

Backend: PostFeedService.scroll gains a direction (older|newer); new
around(post_id) returns a window of newer + the post + older with a cursor
for each end. /api/posts accepts ?around= and ?direction=. + API tests.

Frontend: posts store gains loadAround/loadOlder/loadNewer (older appends,
newer prepends) with per-end cursors; PostsView's anchored mode scrolls to
the post, observes top + bottom sentinels, and preserves scroll position on
upward prepend so the page doesn't jump. Normal feed mode unchanged.

Closes the remaining half of the post-navigation work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:12:18 -04:00

286 lines
11 KiB
Python

"""FC-3e: cursor-paginated read service for the Posts stream.
Mirrors GalleryService.scroll's cursor encoding so the frontend pattern
is identical: base64 of "<iso8601_sort_key>|<post_id>". Sort key is
COALESCE(Post.post_date, Post.downloaded_at) so posts without a
publish date sort by when we captured them.
Pure read-surface; no writes. The service composes the post dict
(including thumbnails from ImageRecord.primary_post_id and non-media
attachments from PostAttachment) so the API layer can jsonify directly.
"""
from __future__ import annotations
import base64
from datetime import datetime
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Artist, ImageRecord, Post, PostAttachment, Source
from ..utils.text import html_to_plain, truncate_at_word
from .gallery_service import thumbnail_url
CURSOR_SEPARATOR = "|"
DESCRIPTION_LIMIT = 280
THUMBNAIL_LIMIT = 6
def encode_cursor(sort_key: datetime, post_id: int) -> str:
raw = f"{sort_key.isoformat()}{CURSOR_SEPARATOR}{post_id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
ts_part, id_part = raw.split(CURSOR_SEPARATOR, 1)
return datetime.fromisoformat(ts_part), int(id_part)
except Exception as exc:
raise ValueError(f"invalid cursor: {cursor!r}") from exc
def _sort_key():
"""Postgres COALESCE expression used in ORDER BY and WHERE clauses."""
return func.coalesce(Post.post_date, Post.downloaded_at)
class PostFeedService:
def __init__(self, session: AsyncSession):
self.session = session
async def scroll(
self,
*,
cursor: str | None = None,
artist_id: int | None = None,
platform: str | None = None,
limit: int = 24,
direction: str = "older",
) -> dict:
"""Paginate the feed from `cursor`. direction='older' walks back in
time (default, infinite-scroll down); direction='newer' walks forward
(scroll up in an anchored view). Items are always returned in feed
(descending) order; `next_cursor` points to the far edge in the
requested direction (null when exhausted)."""
if limit < 1 or limit > 100:
raise ValueError("limit must be between 1 and 100")
if direction not in ("older", "newer"):
raise ValueError("direction must be 'older' or 'newer'")
sort_key = _sort_key()
stmt = (
select(Post, Artist, Source)
.join(Source, Post.source_id == Source.id)
.join(Artist, Source.artist_id == Artist.id)
)
if artist_id is not None:
stmt = stmt.where(Source.artist_id == artist_id)
if platform is not None:
stmt = stmt.where(Source.platform == platform)
if cursor:
cur_ts, cur_id = decode_cursor(cursor)
if direction == "older":
stmt = stmt.where(or_(
sort_key < cur_ts,
and_(sort_key == cur_ts, Post.id < cur_id),
))
else:
stmt = stmt.where(or_(
sort_key > cur_ts,
and_(sort_key == cur_ts, Post.id > cur_id),
))
if direction == "older":
stmt = stmt.order_by(sort_key.desc(), Post.id.desc())
else:
stmt = stmt.order_by(sort_key.asc(), Post.id.asc())
stmt = stmt.limit(limit + 1)
rows = (await self.session.execute(stmt)).all()
has_more = len(rows) > limit
rows = rows[:limit]
if direction == "newer":
# Fetched ascending (closest-newer first); flip to feed order.
rows = list(reversed(rows))
next_cursor: str | None = None
if has_more and rows:
# Far edge in the travel direction: oldest row going older,
# newest row going newer (rows is descending for display).
edge_post = rows[-1][0] if direction == "older" else rows[0][0]
edge_key = edge_post.post_date or edge_post.downloaded_at
next_cursor = encode_cursor(edge_key, edge_post.id)
post_ids = [p.id for p, _, _ in rows]
thumbs_map = await self._thumbnails_for(post_ids)
atts_map = await self._attachments_for(post_ids)
items = [
self._to_dict(post, artist, source, thumbs_map, atts_map)
for post, artist, source in rows
]
return {"items": items, "next_cursor": next_cursor}
async def around(
self,
*,
post_id: int,
artist_id: int | None = None,
platform: str | None = None,
limit: int = 12,
) -> dict | None:
"""A window centered on `post_id`: up to `limit` newer posts + the
post + up to `limit` older posts, in feed (descending) order, with a
cursor for each end. Returns None if the post doesn't exist."""
anchor = (await self.session.execute(
select(Post, Artist, Source)
.join(Source, Post.source_id == Source.id)
.join(Artist, Source.artist_id == Artist.id)
.where(Post.id == post_id)
)).one_or_none()
if anchor is None:
return None
anchor_post, anchor_artist, anchor_source = anchor
anchor_key = anchor_post.post_date or anchor_post.downloaded_at
anchor_cursor = encode_cursor(anchor_key, anchor_post.id)
older = await self.scroll(
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
limit=limit, direction="older",
)
newer = await self.scroll(
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
limit=limit, direction="newer",
)
thumbs_map = await self._thumbnails_for([anchor_post.id])
atts_map = await self._attachments_for([anchor_post.id])
anchor_item = self._to_dict(
anchor_post, anchor_artist, anchor_source, thumbs_map, atts_map,
)
return {
"items": newer["items"] + [anchor_item] + older["items"],
"cursor_older": older["next_cursor"],
"cursor_newer": newer["next_cursor"],
"anchor_id": anchor_post.id,
}
async def get_post(self, post_id: int) -> dict | None:
row = (await self.session.execute(
select(Post, Artist, Source)
.join(Source, Post.source_id == Source.id)
.join(Artist, Source.artist_id == Artist.id)
.where(Post.id == post_id)
)).one_or_none()
if row is None:
return None
post, artist, source = row
# Detail endpoint returns the FULL image list for PostModal's
# masonry grid — feed query still caps at THUMBNAIL_LIMIT via
# the default arg.
thumbs_map = await self._thumbnails_for([post.id], limit=None)
atts_map = await self._attachments_for([post.id])
item = self._to_dict(post, artist, source, thumbs_map, atts_map)
item["description_full"] = html_to_plain(post.description)
return item
# --- composition helpers ---------------------------------------------
async def _thumbnails_for(
self, post_ids: list[int], *, limit: int | None = THUMBNAIL_LIMIT,
) -> dict[int, dict]:
"""post_id -> {"thumbs": [...up to limit], "more": int}.
Selects up to `limit` images per post via window function so we
can detect overflow in a single query. Pass `limit=None` to
return ALL thumbnails per post (used by `get_post` for PostModal's
masonry grid; the feed pass keeps the default cap so payloads
stay small).
"""
if not post_ids:
return {}
# Rank images within each post; cap at `limit` rows per post when
# limit is set, return all when limit is None.
ranked = (
select(
ImageRecord.id,
ImageRecord.primary_post_id,
ImageRecord.sha256,
ImageRecord.mime,
func.row_number().over(
partition_by=ImageRecord.primary_post_id,
order_by=ImageRecord.id.asc(),
).label("rn"),
func.count(ImageRecord.id).over(
partition_by=ImageRecord.primary_post_id,
).label("total"),
)
.where(ImageRecord.primary_post_id.in_(post_ids))
.subquery()
)
stmt = select(
ranked.c.id, ranked.c.primary_post_id,
ranked.c.sha256, ranked.c.mime, ranked.c.total,
)
if limit is not None:
stmt = stmt.where(ranked.c.rn <= limit)
rows = (await self.session.execute(stmt)).all()
out: dict[int, dict] = {pid: {"thumbs": [], "more": 0} for pid in post_ids}
for img_id, pid, sha, mime, total in rows:
entry = out.setdefault(pid, {"thumbs": [], "more": 0})
entry["thumbs"].append({
"image_id": img_id,
"thumbnail_url": thumbnail_url(sha, mime),
"mime": mime,
})
# `total` is constant per partition; overflow = total - THUMBNAIL_LIMIT.
entry["more"] = max(0, total - THUMBNAIL_LIMIT)
return out
async def _attachments_for(self, post_ids: list[int]) -> dict[int, list[dict]]:
if not post_ids:
return {}
rows = (await self.session.execute(
select(PostAttachment)
.where(PostAttachment.post_id.in_(post_ids))
.order_by(PostAttachment.id.asc())
)).scalars().all()
out: dict[int, list[dict]] = {pid: [] for pid in post_ids}
for att in rows:
out.setdefault(att.post_id, []).append({
"id": att.id,
"original_filename": att.original_filename,
"ext": att.ext,
"mime": att.mime,
"size_bytes": att.size_bytes,
"download_url": f"/api/attachments/{att.id}/download",
})
return out
def _to_dict(
self, post: Post, artist: Artist, source: Source,
thumbs_map: dict, atts_map: dict,
) -> dict:
plain_full = html_to_plain(post.description) if post.description else None
if plain_full is None:
description_plain, truncated = None, False
else:
description_plain, truncated = truncate_at_word(plain_full, DESCRIPTION_LIMIT)
thumbs_entry = thumbs_map.get(post.id, {"thumbs": [], "more": 0})
return {
"id": post.id,
"external_post_id": post.external_post_id,
"post_url": post.post_url,
"post_title": post.post_title,
"post_date": post.post_date.isoformat() if post.post_date else None,
"downloaded_at": post.downloaded_at.isoformat(),
"description_plain": description_plain,
"description_truncated": truncated,
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
"source": {"id": source.id, "platform": source.platform},
"thumbnails": thumbs_entry["thumbs"],
"thumbnails_more": thumbs_entry["more"],
"attachments": atts_map.get(post.id, []),
}