Render actual tweet video/multi-image media instead of a single thumbnail

Tweets can carry up to 4 photos/videos/gifs; fxtwitter's media.all
preserves their original order and, for videos, gives a real playable
.mp4 plus a poster thumbnail. TweetCard now renders these as a
1/2/3/4-item grid (Twitter's own layout shapes) with fixed cell
heights so a tall portrait image no longer dictates the whole card's
height in the column view, and video/gif items play back with native
controls instead of showing a static frame. Each item's url (and a
video's thumbnail) still resolves through the configured Nitter media
mode (self-host/proxy/direct) individually.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-23 00:11:20 +00:00
parent c2b34623d1
commit d4ab690061
6 changed files with 177 additions and 31 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Source, ContentItem } from '../../storage/db/types.js';
import type { Source, ContentItem, TweetMediaItem } from '../../storage/db/types.js';
import { cleanHtml, toSummary } from '../clean.js';
export interface FetchedItem {
@@ -10,7 +10,7 @@ export interface FetchedItem {
link: string;
publishedAt: string;
/** Set by the Nitter adapter only — carries the tweet's author info through to ContentItem.tweet. */
tweet?: { id: string; authorName: string; authorHandle: string; avatarUrl: string | null };
tweet?: { id: string; authorName: string; authorHandle: string; avatarUrl: string | null; media: TweetMediaItem[] };
raw: unknown;
}
+41 -11
View File
@@ -7,29 +7,54 @@
// two outlets' coverage of the same news event does — same reasoning as YouTube.
import Parser from 'rss-parser';
import type { Source } from '../../storage/db/types.js';
import type { Source, TweetMediaItem } from '../../storage/db/types.js';
import type { SourceAdapter, FetchedItem } from './base.js';
import { logger } from '../../storage/db/logs.js';
import { getSettings } from '../../storage/db/settings.js';
/** fxtwitter caps a tweet at 4 attached photos/videos/gifs, in tweet display order. */
const MAX_TWEET_MEDIA = 4;
const parser = new Parser<Record<string, unknown>>();
const USER_AGENT = 'Mozilla/5.0 (compatible; HomefeedBot/1.0; self-hosted RSS reader)';
const FETCH_TIMEOUT_MS = 10_000;
/**
* Shape confirmed against a real `curl https://api.fxtwitter.com/<handle>/status/<id>`
* response: `text`, `created_timestamp` (unix seconds), `author.name`/`avatar_url` all
* verified exactly as read below. `media.photos[].url` is still unconfirmed — that
* response had no attached photo — but is read with optional chaining regardless, so a
* shape mismatch there just falls back to the RSS description's own <img> (see
* fetch()'s photoUrl fallback) rather than breaking ingestion.
* Shape confirmed against two real `curl https://api.fxtwitter.com/<handle>/status/<id>`
* responses: `text`, `created_timestamp` (unix seconds), `author.name`/`avatar_url`, and
* (from a second, video-attached tweet) `media.all[]` — an ordered array covering both
* photos and videos, each with `type` ('photo' | 'video' | 'gif'), `url` (the direct
* playable/displayable URL — for video this is a real .mp4, not the .m3u8 playlist also
* present under `formats`), `thumbnail_url` (video/gif poster frame), and `width`/`height`.
* `media.all` is preferred over `media.photos`/`media.videos` since it's the only field
* that preserves the tweet's original media order.
*/
interface FxTweetMedia {
type?: string;
url?: string;
thumbnail_url?: string;
width?: number;
height?: number;
}
interface FxTweet {
text?: string;
created_timestamp?: number;
author?: { name?: string; screen_name?: string; avatar_url?: string };
media?: { photos?: { url?: string }[] };
media?: { all?: FxTweetMedia[]; photos?: FxTweetMedia[] };
}
/** Maps fxtwitter's media shape to our own, capped at the 4 items a tweet can carry. */
function toTweetMedia(enrichment: FxTweet | null): TweetMediaItem[] {
const items = enrichment?.media?.all ?? enrichment?.media?.photos ?? [];
return items.slice(0, MAX_TWEET_MEDIA).map((m): TweetMediaItem => ({
type: m.type === 'video' || m.type === 'gif' ? m.type : 'photo',
url: m.url ?? '',
thumbnailUrl: m.thumbnail_url ?? null,
width: m.width ?? null,
height: m.height ?? null
})).filter((m) => m.url);
}
/**
@@ -103,7 +128,12 @@ export const nitterAdapter: SourceAdapter = {
const authorName = enrichment?.author?.name ?? handle;
const avatarUrl = enrichment?.author?.avatar_url ?? null;
const text = enrichment?.text ?? ownHtml;
const photoUrl = enrichment?.media?.photos?.[0]?.url ?? rssImageUrl;
// Enrichment failed (or came back with no media) — fall back to the RSS
// description's own <img> as a single photo, same as before multi-media support.
const media = toTweetMedia(enrichment);
if (media.length === 0 && rssImageUrl) {
media.push({ type: 'photo', url: rssImageUrl, thumbnailUrl: null, width: null, height: null });
}
const publishedAt = enrichment?.created_timestamp
? new Date(enrichment.created_timestamp * 1000).toISOString()
: (item.isoDate ?? item.pubDate ?? new Date().toISOString());
@@ -112,11 +142,11 @@ export const nitterAdapter: SourceAdapter = {
title: item.title || text.slice(0, 100),
summary: text.slice(0, 500),
body: text,
images: photoUrl ? [{ url: photoUrl }] : [],
images: [],
videos: [],
link: item.link,
publishedAt,
tweet: { id: tweetId, authorName, authorHandle: handle, avatarUrl },
tweet: { id: tweetId, authorName, authorHandle: handle, avatarUrl, media },
raw: { rss: item, fxtwitter: enrichment }
});
}
+42 -10
View File
@@ -8,7 +8,7 @@ import { logger } from '../storage/db/logs.js';
import * as articles from '../storage/db/articles.js';
import * as tags from '../storage/db/tags.js';
import * as sources from '../storage/db/sources.js';
import type { GlobalSettings, MergedArticle, ContentItem } from '../storage/db/types.js';
import type { GlobalSettings, MergedArticle, ContentItem, TweetMediaItem } from '../storage/db/types.js';
const FOLLOW_UP_LOOKBACK_DAYS = 3;
@@ -99,6 +99,36 @@ async function resolveTweetMediaUrl(
return { url, storedMediaId: null };
}
/**
* Resolves every attached photo/video/gif's url — and, for video/gif, its poster
* thumbnail — through the admin's chosen Nitter media mode. Order and item count are
* preserved; TweetCard.svelte renders this array directly, there's no separate
* "hero image" concept for tweets the way there is for regular articles.
*/
async function resolveTweetMedia(
media: TweetMediaItem[],
mode: GlobalSettings['nitterMediaMode']
): Promise<{ media: TweetMediaItem[]; storedMediaIds: string[] }> {
const storedMediaIds: string[] = [];
const resolved: TweetMediaItem[] = [];
for (const item of media) {
const url = await resolveTweetMediaUrl(item.url, mode);
if (url.storedMediaId) storedMediaIds.push(url.storedMediaId);
let thumbnailUrl = item.thumbnailUrl;
if (thumbnailUrl) {
const resolvedThumb = await resolveTweetMediaUrl(thumbnailUrl, mode);
thumbnailUrl = resolvedThumb.url;
if (resolvedThumb.storedMediaId) storedMediaIds.push(resolvedThumb.storedMediaId);
}
resolved.push({ ...item, url: url.url, thumbnailUrl });
}
return { media: resolved, storedMediaIds };
}
/**
* Publishes a single item as-is, with no AI calls at all — used when the AI service
* isn't reachable (e.g. Ollama hasn't been set up yet, per the "assume it arrives
@@ -113,15 +143,9 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings)
const category = uniqueCategories([item]);
const storedMediaIds: string[] = [];
// Tweets never get a "hero image" — TweetCard.svelte renders tweet.media directly.
let heroImage: MergedArticle['heroImage'] = null;
if (item.tweet) {
const selected = selectBestImage([item]);
if (selected) {
const resolved = await resolveTweetMediaUrl(selected.url, settings.nitterMediaMode);
heroImage = { url: resolved.url, sourceItemId: selected.sourceItemId, selectionReason: selected.selectionReason };
if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId);
}
} else {
if (!item.tweet) {
const resolved = await resolveHeroImage([item], item.link);
heroImage = resolved.heroImage;
if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId);
@@ -139,7 +163,15 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings)
avatarUrl = resolved.url;
if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId);
}
tweet = { authorName: item.tweet.authorName, authorHandle: item.tweet.authorHandle, avatarUrl, sourceItemId: item.id };
const resolvedMedia = await resolveTweetMedia(item.tweet.media, settings.nitterMediaMode);
storedMediaIds.push(...resolvedMedia.storedMediaIds);
tweet = {
authorName: item.tweet.authorName,
authorHandle: item.tweet.authorHandle,
avatarUrl,
sourceItemId: item.id,
media: resolvedMedia.media
};
}
const article = await articles.insertArticle({
+13 -3
View File
@@ -14,6 +14,16 @@ export interface Source {
createdAt: string;
}
/** A single photo/video/gif attached to a tweet, in the tweet's own display order — fxtwitter caps this at 4. */
export interface TweetMediaItem {
type: 'photo' | 'video' | 'gif';
url: string;
/** Video/gif poster frame — null for photos. */
thumbnailUrl: string | null;
width: number | null;
height: number | null;
}
export interface ContentItem {
id: string;
sourceId: string;
@@ -32,7 +42,7 @@ export interface ContentItem {
eventId: string | null;
clusterId: string | null;
/** Nitter-sourced items only — null for everything else. */
tweet: { id: string; authorName: string; authorHandle: string; avatarUrl: string | null } | null;
tweet: { id: string; authorName: string; authorHandle: string; avatarUrl: string | null; media: TweetMediaItem[] } | null;
raw: unknown;
}
@@ -49,8 +59,8 @@ export interface MergedArticle {
body: string;
heroImage: { url: string; sourceItemId: string; selectionReason: string } | null;
video: { url: string; provider?: string; embedUrl?: string; sourceItemId: string } | null;
/** Nitter-sourced articles only — the embed card's author info (see TweetCard.svelte). Never set alongside video. */
tweet: { authorName: string; authorHandle: string; avatarUrl: string | null; sourceItemId: string } | null;
/** Nitter-sourced articles only — the embed card's author info and attached media (see TweetCard.svelte). Never set alongside video. */
tweet: { authorName: string; authorHandle: string; avatarUrl: string | null; sourceItemId: string; media: TweetMediaItem[] } | null;
category: string[];
geo: string | null;
eventId: string | null;
+69 -4
View File
@@ -6,6 +6,14 @@
let { article }: { article: MergedArticle } = $props();
const sourceLabel = $derived(article.sources[0]?.sourceName ?? 'Nitter');
const media = $derived(article.tweet?.media.slice(0, 4) ?? []);
// Native <video controls> needs its clicks not to fall through to the card's own
// <a> navigation (play/pause/scrub would otherwise just open the article instead).
// Plain images keep navigating as before — only the video element itself is exempted.
function handleMediaClick(e: MouseEvent) {
if ((e.target as HTMLElement).tagName === 'VIDEO') e.preventDefault();
}
</script>
<a class="tweet-card" href={`/article/${article.id}`}>
@@ -26,8 +34,28 @@
<span class="handle">@{article.tweet?.authorHandle}</span>
</div>
<div class="text">{article.body}</div>
{#if article.heroImage}
<img class="tweet-img" src={resolveMediaUrl(article.heroImage.url)} alt="" loading="lazy" />
{#if media.length > 0}
<div class="media-grid" data-count={media.length} onclick={handleMediaClick} role="presentation">
{#each media as item, i (item.url)}
<div class="media-cell" class:span-2={media.length === 3 && i === 0}>
{#if item.type === 'video' || item.type === 'gif'}
<video
class="media-el"
controls
preload="metadata"
playsinline
loop={item.type === 'gif'}
muted={item.type === 'gif'}
poster={item.thumbnailUrl ? resolveMediaUrl(item.thumbnailUrl) : undefined}
>
<source src={resolveMediaUrl(item.url)} />
</video>
{:else}
<img class="media-el" src={resolveMediaUrl(item.url)} alt="" loading="lazy" />
{/if}
</div>
{/each}
</div>
{/if}
<div class="time">{timeAgo(article.publishedAt)} &middot; {exactTime(article.publishedAt)}</div>
</a>
@@ -86,10 +114,47 @@
white-space: pre-line;
margin-bottom: 8px;
}
.tweet-img {
width: 100%;
/* Fixed cell heights (rather than max-height on an auto-height image) so a tall
portrait photo or video is cropped to a sensible box instead of dictating the
whole card's height — this is what keeps a single vertical image from taking
over the column. Twitter's own 1/2/3/4-item grid shapes, at a size that fits
this app's narrower single-column feed. */
.media-grid {
display: grid;
gap: 3px;
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 8px;
background: var(--surface-1);
}
.media-grid[data-count='1'] {
grid-template-columns: 1fr;
height: 380px;
}
.media-grid[data-count='2'] {
grid-template-columns: 1fr 1fr;
height: 240px;
}
.media-grid[data-count='3'] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
height: 300px;
}
.media-grid[data-count='4'] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
height: 300px;
}
.media-cell {
overflow: hidden;
}
.media-cell.span-2 {
grid-row: 1 / 3;
}
.media-el {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
background: var(--surface-1);
}
+10 -1
View File
@@ -8,13 +8,22 @@ export interface ArticleSource {
publishedAt: string;
}
/** A single photo/video/gif attached to a tweet, in the tweet's own display order — capped at 4. */
export interface TweetMediaItem {
type: 'photo' | 'video' | 'gif';
url: string;
thumbnailUrl: string | null;
width: number | null;
height: number | null;
}
export interface MergedArticle {
id: string;
title: string;
body: string;
heroImage: { url: string; sourceItemId: string; selectionReason: string } | null;
video: { url: string; provider?: string; embedUrl?: string; sourceItemId: string } | null;
tweet: { authorName: string; authorHandle: string; avatarUrl: string | null; sourceItemId: string } | null;
tweet: { authorName: string; authorHandle: string; avatarUrl: string | null; sourceItemId: string; media: TweetMediaItem[] } | null;
category: string[];
geo: string | null;
eventId: string | null;