5925ebe1f1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
7.9 KiB
Python
210 lines
7.9 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,
|
|
) -> dict:
|
|
if limit < 1 or limit > 100:
|
|
raise ValueError("limit must be between 1 and 100")
|
|
|
|
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)
|
|
stmt = stmt.where(
|
|
or_(
|
|
sort_key < cur_ts,
|
|
and_(sort_key == cur_ts, Post.id < cur_id),
|
|
)
|
|
)
|
|
|
|
stmt = stmt.order_by(sort_key.desc(), Post.id.desc()).limit(limit + 1)
|
|
rows = (await self.session.execute(stmt)).all()
|
|
|
|
next_cursor: str | None = None
|
|
if len(rows) > limit:
|
|
last_post, _, _ = rows[limit - 1]
|
|
last_key = last_post.post_date or last_post.downloaded_at
|
|
next_cursor = encode_cursor(last_key, last_post.id)
|
|
rows = rows[:limit]
|
|
|
|
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 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
|
|
thumbs_map = await self._thumbnails_for([post.id])
|
|
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]) -> dict[int, dict]:
|
|
"""post_id -> {"thumbs": [...up to 6], "more": int}.
|
|
|
|
Selects THUMBNAIL_LIMIT+1 images per post via window function so we
|
|
can detect overflow in a single query.
|
|
"""
|
|
if not post_ids:
|
|
return {}
|
|
# Rank images within each post and fetch only the top THUMBNAIL_LIMIT+1.
|
|
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()
|
|
)
|
|
rows = (await self.session.execute(
|
|
select(
|
|
ranked.c.id, ranked.c.primary_post_id,
|
|
ranked.c.sha256, ranked.c.mime, ranked.c.total,
|
|
).where(ranked.c.rn <= THUMBNAIL_LIMIT)
|
|
)).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, []),
|
|
}
|