From c0133397e3de5250c998e1b98e8a395db40653aa Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:19:04 +0100 Subject: [PATCH] fix: resolve 4 media embed bugs from testing 1. Video aspect ratio: remove container border/overflow-hidden, use preload="metadata" so browser knows dimensions before play 2. YouTube Error 153: remove sandbox attr (too restrictive), add full allow permissions (encrypted-media, accelerometer, gyroscope, etc.) 3. Google Images not displaying: detect image Content-Type from HTTP response in metadataFetcher, override classifier to create image embed for URLs that serve image/* content 4. Audio seeking broken: add HTTP Range request support in uploads route (Accept-Ranges, Content-Range, 206 Partial Content) --- packages/server/src/routes/uploads.ts | 22 ++++++++++++++ packages/server/src/utils/embedResolver.ts | 30 ++++++++++++++----- packages/server/src/utils/metadataFetcher.ts | 9 ++++++ .../components/chat/AttachmentRenderer.tsx | 4 +-- .../src/components/chat/embeds/VideoEmbed.tsx | 4 +-- 5 files changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/server/src/routes/uploads.ts b/packages/server/src/routes/uploads.ts index 45ecdd89..73a10bb0 100644 --- a/packages/server/src/routes/uploads.ts +++ b/packages/server/src/routes/uploads.ts @@ -134,6 +134,28 @@ export async function uploadRoutes(app: FastifyInstance): Promise { reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(originalName)}"`); } + // Support Range requests for audio/video seeking + const stat = fs.statSync(filepath); + const fileSize = stat.size; + const rangeHeader = request.headers.range; + + if (rangeHeader) { + const parts = rangeHeader.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0] ?? '0', 10); + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; + const chunkSize = end - start + 1; + + reply.header('Content-Range', `bytes ${start}-${end}/${fileSize}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunkSize); + reply.code(206); + + const stream = fs.createReadStream(filepath, { start, end }); + return reply.send(stream); + } + + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', fileSize); const stream = fs.createReadStream(filepath); return reply.send(stream); }); diff --git a/packages/server/src/utils/embedResolver.ts b/packages/server/src/utils/embedResolver.ts index 6afdf3b8..f1d07746 100644 --- a/packages/server/src/utils/embedResolver.ts +++ b/packages/server/src/utils/embedResolver.ts @@ -75,20 +75,36 @@ export async function resolveEmbeds( let image: string | null = null; let siteName: string | null = null; + // Track the effective embed type (may be overridden by Content-Type detection) + let effectiveEmbedType = classification.embedType; + if (classification.embedType === 'image') { // Direct image URL — no fetch needed, use the URL as the image source image = url; } else if (classification.needsMetadataFetch) { const metadata = await fetchUrlMetadata(url); if (metadata) { - title = metadata.title; - description = metadata.description; - image = metadata.image; - siteName = metadata.siteName; + // If the URL itself is a direct media resource (detected via Content-Type), + // override the classification instead of trying to use og: metadata + if (metadata.contentType) { + if (metadata.contentType.startsWith('image/')) { + effectiveEmbedType = 'image'; + image = url; + } else if (metadata.contentType.startsWith('video/')) { + effectiveEmbedType = 'video'; + } else if (metadata.contentType.startsWith('audio/')) { + effectiveEmbedType = 'audio'; + } + } else { + title = metadata.title; + description = metadata.description; + image = metadata.image; + siteName = metadata.siteName; + } } - // For generic embeds, skip if we couldn't extract a title - if (classification.embedType === 'generic' && !title) { + // For generic embeds, skip if we couldn't extract a title and it's not a media URL + if (effectiveEmbedType === 'generic' && !title) { continue; } } @@ -99,7 +115,7 @@ export async function resolveEmbeds( messageId: isDm ? null : messageId, dmMessageId: isDm ? messageId : null, url, - embedType: classification.embedType, + embedType: effectiveEmbedType, provider: classification.provider, title, description, diff --git a/packages/server/src/utils/metadataFetcher.ts b/packages/server/src/utils/metadataFetcher.ts index 73591abb..9eb1d5ed 100644 --- a/packages/server/src/utils/metadataFetcher.ts +++ b/packages/server/src/utils/metadataFetcher.ts @@ -22,6 +22,8 @@ export interface UrlMetadata { image: string | null; siteName: string | null; url: string; + /** Set when the URL itself is a direct media resource (image/video/audio) */ + contentType?: string; } export async function fetchUrlMetadata(url: string): Promise { @@ -66,6 +68,13 @@ export async function fetchUrlMetadata(url: string): Promise return null; } + // If the response is a direct media file (image/video/audio), return early + // with the content type — don't try to parse it as HTML + const responseContentType = response.headers.get('content-type') ?? ''; + if (responseContentType.startsWith('image/') || responseContentType.startsWith('video/') || responseContentType.startsWith('audio/')) { + return { title: null, description: null, image: null, siteName: null, url, contentType: responseContentType }; + } + // Early exit if Content-Length > 512KB const contentLength = parseInt(response.headers.get('content-length') ?? '0', 10); if (contentLength > 512_000) { diff --git a/packages/web/src/components/chat/AttachmentRenderer.tsx b/packages/web/src/components/chat/AttachmentRenderer.tsx index b1cb7de6..9494b6e7 100644 --- a/packages/web/src/components/chat/AttachmentRenderer.tsx +++ b/packages/web/src/components/chat/AttachmentRenderer.tsx @@ -44,10 +44,10 @@ export function AttachmentRenderer({ attachment }: AttachmentRendererProps) { if (mimetype.startsWith('video/')) { return ( -
+