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';
|
import { cleanHtml, toSummary } from '../clean.js';
|
||||||
|
|
||||||
export interface FetchedItem {
|
export interface FetchedItem {
|
||||||
@@ -10,7 +10,7 @@ export interface FetchedItem {
|
|||||||
link: string;
|
link: string;
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
/** Set by the Nitter adapter only — carries the tweet's author info through to ContentItem.tweet. */
|
/** 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;
|
raw: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,29 +7,54 @@
|
|||||||
// two outlets' coverage of the same news event does — same reasoning as YouTube.
|
// two outlets' coverage of the same news event does — same reasoning as YouTube.
|
||||||
|
|
||||||
import Parser from 'rss-parser';
|
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 type { SourceAdapter, FetchedItem } from './base.js';
|
||||||
import { logger } from '../../storage/db/logs.js';
|
import { logger } from '../../storage/db/logs.js';
|
||||||
import { getSettings } from '../../storage/db/settings.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 parser = new Parser<Record<string, unknown>>();
|
||||||
|
|
||||||
const USER_AGENT = 'Mozilla/5.0 (compatible; HomefeedBot/1.0; self-hosted RSS reader)';
|
const USER_AGENT = 'Mozilla/5.0 (compatible; HomefeedBot/1.0; self-hosted RSS reader)';
|
||||||
const FETCH_TIMEOUT_MS = 10_000;
|
const FETCH_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shape confirmed against a real `curl https://api.fxtwitter.com/<handle>/status/<id>`
|
* Shape confirmed against two real `curl https://api.fxtwitter.com/<handle>/status/<id>`
|
||||||
* response: `text`, `created_timestamp` (unix seconds), `author.name`/`avatar_url` all
|
* responses: `text`, `created_timestamp` (unix seconds), `author.name`/`avatar_url`, and
|
||||||
* verified exactly as read below. `media.photos[].url` is still unconfirmed — that
|
* (from a second, video-attached tweet) `media.all[]` — an ordered array covering both
|
||||||
* response had no attached photo — but is read with optional chaining regardless, so a
|
* photos and videos, each with `type` ('photo' | 'video' | 'gif'), `url` (the direct
|
||||||
* shape mismatch there just falls back to the RSS description's own <img> (see
|
* playable/displayable URL — for video this is a real .mp4, not the .m3u8 playlist also
|
||||||
* fetch()'s photoUrl fallback) rather than breaking ingestion.
|
* 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 {
|
interface FxTweet {
|
||||||
text?: string;
|
text?: string;
|
||||||
created_timestamp?: number;
|
created_timestamp?: number;
|
||||||
author?: { name?: string; screen_name?: string; avatar_url?: string };
|
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 authorName = enrichment?.author?.name ?? handle;
|
||||||
const avatarUrl = enrichment?.author?.avatar_url ?? null;
|
const avatarUrl = enrichment?.author?.avatar_url ?? null;
|
||||||
const text = enrichment?.text ?? ownHtml;
|
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
|
const publishedAt = enrichment?.created_timestamp
|
||||||
? new Date(enrichment.created_timestamp * 1000).toISOString()
|
? new Date(enrichment.created_timestamp * 1000).toISOString()
|
||||||
: (item.isoDate ?? item.pubDate ?? new Date().toISOString());
|
: (item.isoDate ?? item.pubDate ?? new Date().toISOString());
|
||||||
@@ -112,11 +142,11 @@ export const nitterAdapter: SourceAdapter = {
|
|||||||
title: item.title || text.slice(0, 100),
|
title: item.title || text.slice(0, 100),
|
||||||
summary: text.slice(0, 500),
|
summary: text.slice(0, 500),
|
||||||
body: text,
|
body: text,
|
||||||
images: photoUrl ? [{ url: photoUrl }] : [],
|
images: [],
|
||||||
videos: [],
|
videos: [],
|
||||||
link: item.link,
|
link: item.link,
|
||||||
publishedAt,
|
publishedAt,
|
||||||
tweet: { id: tweetId, authorName, authorHandle: handle, avatarUrl },
|
tweet: { id: tweetId, authorName, authorHandle: handle, avatarUrl, media },
|
||||||
raw: { rss: item, fxtwitter: enrichment }
|
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 articles from '../storage/db/articles.js';
|
||||||
import * as tags from '../storage/db/tags.js';
|
import * as tags from '../storage/db/tags.js';
|
||||||
import * as sources from '../storage/db/sources.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;
|
const FOLLOW_UP_LOOKBACK_DAYS = 3;
|
||||||
|
|
||||||
@@ -99,6 +99,36 @@ async function resolveTweetMediaUrl(
|
|||||||
return { url, storedMediaId: null };
|
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
|
* 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
|
* 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 category = uniqueCategories([item]);
|
||||||
const storedMediaIds: string[] = [];
|
const storedMediaIds: string[] = [];
|
||||||
|
|
||||||
|
// Tweets never get a "hero image" — TweetCard.svelte renders tweet.media directly.
|
||||||
let heroImage: MergedArticle['heroImage'] = null;
|
let heroImage: MergedArticle['heroImage'] = null;
|
||||||
if (item.tweet) {
|
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 {
|
|
||||||
const resolved = await resolveHeroImage([item], item.link);
|
const resolved = await resolveHeroImage([item], item.link);
|
||||||
heroImage = resolved.heroImage;
|
heroImage = resolved.heroImage;
|
||||||
if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId);
|
if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId);
|
||||||
@@ -139,7 +163,15 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings)
|
|||||||
avatarUrl = resolved.url;
|
avatarUrl = resolved.url;
|
||||||
if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId);
|
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({
|
const article = await articles.insertArticle({
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ export interface Source {
|
|||||||
createdAt: string;
|
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 {
|
export interface ContentItem {
|
||||||
id: string;
|
id: string;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
@@ -32,7 +42,7 @@ export interface ContentItem {
|
|||||||
eventId: string | null;
|
eventId: string | null;
|
||||||
clusterId: string | null;
|
clusterId: string | null;
|
||||||
/** Nitter-sourced items only — null for everything else. */
|
/** 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;
|
raw: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +59,8 @@ export interface MergedArticle {
|
|||||||
body: string;
|
body: string;
|
||||||
heroImage: { url: string; sourceItemId: string; selectionReason: string } | null;
|
heroImage: { url: string; sourceItemId: string; selectionReason: string } | null;
|
||||||
video: { url: string; provider?: string; embedUrl?: string; sourceItemId: 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. */
|
/** 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 } | null;
|
tweet: { authorName: string; authorHandle: string; avatarUrl: string | null; sourceItemId: string; media: TweetMediaItem[] } | null;
|
||||||
category: string[];
|
category: string[];
|
||||||
geo: string | null;
|
geo: string | null;
|
||||||
eventId: string | null;
|
eventId: string | null;
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
let { article }: { article: MergedArticle } = $props();
|
let { article }: { article: MergedArticle } = $props();
|
||||||
|
|
||||||
const sourceLabel = $derived(article.sources[0]?.sourceName ?? 'Nitter');
|
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>
|
</script>
|
||||||
|
|
||||||
<a class="tweet-card" href={`/article/${article.id}`}>
|
<a class="tweet-card" href={`/article/${article.id}`}>
|
||||||
@@ -26,8 +34,28 @@
|
|||||||
<span class="handle">@{article.tweet?.authorHandle}</span>
|
<span class="handle">@{article.tweet?.authorHandle}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text">{article.body}</div>
|
<div class="text">{article.body}</div>
|
||||||
{#if article.heroImage}
|
{#if media.length > 0}
|
||||||
<img class="tweet-img" src={resolveMediaUrl(article.heroImage.url)} alt="" loading="lazy" />
|
<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}
|
{/if}
|
||||||
<div class="time">{timeAgo(article.publishedAt)} · {exactTime(article.publishedAt)}</div>
|
<div class="time">{timeAgo(article.publishedAt)} · {exactTime(article.publishedAt)}</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -86,10 +114,47 @@
|
|||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.tweet-img {
|
/* Fixed cell heights (rather than max-height on an auto-height image) so a tall
|
||||||
width: 100%;
|
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);
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
margin-bottom: 8px;
|
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;
|
display: block;
|
||||||
background: var(--surface-1);
|
background: var(--surface-1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,22 @@ export interface ArticleSource {
|
|||||||
publishedAt: string;
|
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 {
|
export interface MergedArticle {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
heroImage: { url: string; sourceItemId: string; selectionReason: string } | null;
|
heroImage: { url: string; sourceItemId: string; selectionReason: string } | null;
|
||||||
video: { url: string; provider?: string; embedUrl?: string; sourceItemId: 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[];
|
category: string[];
|
||||||
geo: string | null;
|
geo: string | null;
|
||||||
eventId: string | null;
|
eventId: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user