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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)} · {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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user