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)
This commit is contained in:
@@ -134,6 +134,28 @@ export async function uploadRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(originalName)}"`);
|
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);
|
const stream = fs.createReadStream(filepath);
|
||||||
return reply.send(stream);
|
return reply.send(stream);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,20 +75,36 @@ export async function resolveEmbeds(
|
|||||||
let image: string | null = null;
|
let image: string | null = null;
|
||||||
let siteName: 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') {
|
if (classification.embedType === 'image') {
|
||||||
// Direct image URL — no fetch needed, use the URL as the image source
|
// Direct image URL — no fetch needed, use the URL as the image source
|
||||||
image = url;
|
image = url;
|
||||||
} else if (classification.needsMetadataFetch) {
|
} else if (classification.needsMetadataFetch) {
|
||||||
const metadata = await fetchUrlMetadata(url);
|
const metadata = await fetchUrlMetadata(url);
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
title = metadata.title;
|
// If the URL itself is a direct media resource (detected via Content-Type),
|
||||||
description = metadata.description;
|
// override the classification instead of trying to use og: metadata
|
||||||
image = metadata.image;
|
if (metadata.contentType) {
|
||||||
siteName = metadata.siteName;
|
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
|
// For generic embeds, skip if we couldn't extract a title and it's not a media URL
|
||||||
if (classification.embedType === 'generic' && !title) {
|
if (effectiveEmbedType === 'generic' && !title) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,7 +115,7 @@ export async function resolveEmbeds(
|
|||||||
messageId: isDm ? null : messageId,
|
messageId: isDm ? null : messageId,
|
||||||
dmMessageId: isDm ? messageId : null,
|
dmMessageId: isDm ? messageId : null,
|
||||||
url,
|
url,
|
||||||
embedType: classification.embedType,
|
embedType: effectiveEmbedType,
|
||||||
provider: classification.provider,
|
provider: classification.provider,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface UrlMetadata {
|
|||||||
image: string | null;
|
image: string | null;
|
||||||
siteName: string | null;
|
siteName: string | null;
|
||||||
url: string;
|
url: string;
|
||||||
|
/** Set when the URL itself is a direct media resource (image/video/audio) */
|
||||||
|
contentType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUrlMetadata(url: string): Promise<UrlMetadata | null> {
|
export async function fetchUrlMetadata(url: string): Promise<UrlMetadata | null> {
|
||||||
@@ -66,6 +68,13 @@ export async function fetchUrlMetadata(url: string): Promise<UrlMetadata | null>
|
|||||||
return null;
|
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
|
// Early exit if Content-Length > 512KB
|
||||||
const contentLength = parseInt(response.headers.get('content-length') ?? '0', 10);
|
const contentLength = parseInt(response.headers.get('content-length') ?? '0', 10);
|
||||||
if (contentLength > 512_000) {
|
if (contentLength > 512_000) {
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ export function AttachmentRenderer({ attachment }: AttachmentRendererProps) {
|
|||||||
|
|
||||||
if (mimetype.startsWith('video/')) {
|
if (mimetype.startsWith('video/')) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-1 max-w-[520px] rounded-lg overflow-hidden border border-white/[0.06]">
|
<div className="mt-1 max-w-[520px]">
|
||||||
<video
|
<video
|
||||||
controls
|
controls
|
||||||
preload="none"
|
preload="metadata"
|
||||||
poster={thumbUrl ?? undefined}
|
poster={thumbUrl ?? undefined}
|
||||||
className="max-w-full max-h-[400px] rounded-lg"
|
className="max-w-full max-h-[400px] rounded-lg"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export function VideoEmbed({ embed }: VideoEmbedProps) {
|
|||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
src={`${embed.embedUrl}?autoplay=1`}
|
src={`${embed.embedUrl}?autoplay=1`}
|
||||||
title={embed.title ?? 'Video'}
|
title={embed.title ?? 'Video'}
|
||||||
allow="autoplay; fullscreen; picture-in-picture"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
allowFullScreen
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user